How webpaint.ing is built: the graphics card and the tools
By releases ·
webpaint.ing is the drawing app that sits beside this site. Earlier notes said what the graphics card does for it and how shared drawings stay in step. This one goes a level down: which work runs as a compute shader, which runs as an ordinary drawing shader, which is still plain code, and how a tool is put together. Where something is only planned, it says so.
Three kinds of work
A graphics card can run two kinds of program. A drawing shader paints shapes and pictures into a picture. A compute shader is a small program that runs over a grid of numbers with nothing drawn at all, thousands of copies side by side. The third kind of work is ordinary code on the processor. The app uses all three, and most of it is the first.
- Compute shaders, built: two. The flood behind Fill and Select by colour, and the enlargement of an export. There are no others.
- Drawing shaders, built: nearly everything else. Brush stamps, the joined pieces of a stroke, laying a stroke onto its layer, showing each layer, laying down a fill, moving, scaling, rotating and warping a selection, the selection's moving dashes, onion skin, and the 3D reference model.
- Ordinary code, built: the shape of a selection, where each brush stamp goes, and saving.
The flood, step by step
Fill needs to know which pixels are like the one you pressed and joined to it. The card answers this in two stages.
- Stage one: which pixels are close enough. One compute copy runs for every pixel of the layer. Colours are compared as whole numbers from 0 to 255, never as fractions, so the answer cannot differ from one card to another. Each pixel is marked as close enough or not, and the pressed pixel is marked as the start.
- Stage two: which of those are joined. Sixteen sweeps grow the marked area, eight along rows and eight along columns, taking turns. In a sweep each row (or column) is handled by one copy of the program, which walks the line and treats each unbroken run of close pixels as one unit. If any pixel of a run is already in the area, or touches it from the line above or below, the whole run joins at once. That is why a sweep carries the area all the way across a shape in one go, and why a shape with a dozen turns still finishes.
- A shader cannot read the picture it is writing, so each sweep reads one picture and writes the other, and leaves a small flag if it changed anything.
- The result stays on the card. The fill is drawn from it at once, so a big fill appears without a wait.
- Checked afterwards. A frame later the app reads the flags. If the last two sweeps changed nothing, the area is complete. If they did (a maze, a spiral), ordinary code finishes the job from the stage-one picture, which is also the definition of the right answer. Either way the same pixels are filled.
The fill then stores its pixels as a list of runs (row, start, length). That read-back is the part not yet on the card.
Enlarging an export
Exporting at 2, 4 or 8 times makes each pixel a sharp square. A compute shader does it in one pass: each pixel of the large picture takes the colour of the small pixel it lies in. Because a card's mistakes are reported late, the app first runs a tiny trial of four colours and checks the result. A card that fails is not used, an ordinary canvas does the job instead, and the Export window says which way was taken.
Planned, not built
- A selection kept as a picture on the card, with add, subtract, grow and shrink as compute passes. Today its shape is worked out in ordinary code, which is the slow part for a very large, ragged selection.
- Fills that store the press instead of the pixels, replayed by running the flood again on the card, so nothing is read back.
- Layers as tiles that exist only where there is paint.
- Compute for moves and large brushes, only if drawing shaders prove too slow. Today they are not the slow part.
A drawing is a list of ops
Every mark is an op: a small record of what was done. A stroke op holds its points as whole numbers (positions in sixteenths of a pixel, pressure from 0 to 255) and its brush settings. A fill op holds the runs it filled. Nothing changes the drawing except applying an op, and opening a saved drawing replays its ops through the same door. So the drawing is its ops in order, and Undo, saving and shared drawings all come from that one fact.
Undo does not keep whole pictures. For each op drawn, the app keeps the small rectangle the op changed, as it was before, cut from a second copy of the layer that always runs one op behind. Undoing the last op puts one patch back and draws nothing. When no patch can answer (an older op, or a change too large to keep a patch of), the app goes back to a whole copy of the layer that it keeps every so often and redraws the ops after it.
Strokes run at two speeds
While the pen is down, a stroke is drawn as stamps, one soft disc after another. That is quick and can be added to as the pen moves. Stamps in a row leave a faintly scalloped edge, though, and on a large brush you can see it.
So when the stroke lands, it is drawn again as pieces. Each pair of neighbouring stamps is joined by the shape a disc sweeps as it slides from one to the next, growing or shrinking to the next size. The sheet is cut into square tiles, and each tile is one drawing job given the pieces that touch it. For every pixel it takes the strongest cover of any piece, not the sum, which is what makes the joins invisible. The soft edge is looked up from a small table worked out once per stroke from what a row of stamps adds up to.
The stamps stay the single source. Pieces are made from the stamp list alone, and stamps are placed from the op's integers, so replaying a stroke gives the same stamps and the same pieces. Only strokes where this is safe get pieces: the round brush with soft edges, even strength, overlapping stamps. Others stay as stamps.
Adding a tool
A tool is a few small pieces, each in one place.
- One row in the tool list: its name, its icon, the settings it sets (a mode, such as fill or erase) and the words shown when the pointer rests on it. The wide tools panel and the narrow rail read the same list, so they cannot disagree.
- Its options in the top strip: Fill has one, Tolerance; brushes have Size, Smooth and more. Each setting has one setter, so a slider, a typed number and an arrow key cannot disagree.
- An op, if it marks the drawing: the tool builds one and hands it over to be applied. Everything else follows.
- An Info text: how to use the tool, and only the shortcuts that really exist.
How it is tested
The engine has a suite of tests that run with no screen, so nothing in them may depend on pixels: replays, spacing of stamps, how pieces are cut, the fill against its definition. The page has its own suite. For what only eyes can judge, a headless browser opens a new drawing, draws, and saves a picture to look at, and another run exports images through the real Export dialog.