webpost.ing

Materials that wear and flow: chalk, graphite, watercolor, smudge and paper

By releases ·

webpaint.ing has brushes that behave like things: a stick of chalk that wears away as it draws, graphite in eight grades, watercolor that stays wet for a few seconds and spreads, a smudge that drags paint along, and papers with a grain of their own. This article is for programmers and graphics people, and it is about how those are built, with the arithmetic written out. A companion article, How webpaint.ing draws, covers the pipeline they all sit on: stamps, layers, blending and the other compute shaders. Everything here was read from the code as it stands today; numbers are the code's numbers, and what is not built is marked as such.

All five share one constraint, and it explains most of what follows. A drawing is a list of operations, and Undo, saving and shared drawings all replay that list, so replaying must give exactly the pixels that drawing gave. A chalk stick wears as it draws, a smudge reads the pixels it is about to change, and wet paint depends on what was laid a moment earlier, so each of these depends on state. The answer in each case is the same: the state goes into the record of the stroke, the rule is a pure function of that state and the stroke's points, and the arithmetic that carries state forward is done in whole numbers, so that no card, batch size or machine can round it differently.

The post format has no inline math, so quantities in sentences are in words and formulas stand on their own lines. Figures are named by kind in their captions: screenshots of the running app, charts computed by a script from the code's constants and formulas, and one diagram of boxes and arrows drawn for this article.

1. A stick as a field of column heights

A user asked whether the erodable brushes are voxels, a mesh, or something else. The stick is none of the three. It is a height field of columns: a square grid of columns across the stick, and for each column one whole number, how much of its length has been worn away from the tip. There is no voxel for the inside, because the inside is implied: every column is solid from its worn end up to the stick's full length. There is no mesh in the simulation, and there are no points either, because the dust a stick sheds is counted, not tracked.

The grid is 8, 16, 24 or 32 columns across (Coarse, Normal, Fine, Very fine). The stick is round, so a column belongs to it only if its center lies inside the circle. With columns numbered from 0 to s - 1 across, the center of column (i, j) in half-columns from the middle is

\small p = \big(2i - (s - 1),\ 2j - (s - 1)\big)
\small \text{stick} \iff p_x^2 + p_y^2 \le s^2

which leaves 52, 208, 448 and 812 columns that are stick at the four resolutions, out of 64, 256, 576 and 1024. A cell is one sixteenth of the stick's width at every resolution, the stick is 48 cells long, and a column's worn amount r counts 1/1024 of a cell, so an unused column is F = 49152 parts and r always fits in 16 bits. Chalk starts as all zeros, a flat end. A new Graphite stick is a sharpened cone: r is zero in the middle and grows in proportion to the distance from the middle, up to a rim between 6144 and 12288 parts back depending on the grade.

Left: a round stick seen from its tip as a 16 by 16 grid of squares, with the 208 squares inside the circle shaded. Right: the side view of ten columns hanging down by different lengths, the worn ones shorter, against a tilted red line that is the paper and touches the two longest.

Diagram: the stick as columns, and why one number per column is enough. Material is only lost from the end, so a column is solid from the top down to its worn end.

Why this shape is enough: material is lost only from the tip, and only where the tip meets the paper. Under that rule a column can never lose material from the middle or the side, so one number describes it exactly, with no overhangs, no holes and nothing to search. A stick is 256 numbers at Normal, small enough to be written into every stroke, and a step of the simulation is one pass over the columns.

The pose is a rotation

The simulation never rotates the stick. It turns the paper into the stick's own frame once per stroke, and after that everything is integer arithmetic on columns. Three angles go in, whole degrees: the tilt θ from upright (0 to 80), the lean λ, the direction on the paper the top of the stick leans toward (the interface calls it Direction), and the twist ω of the stick about its own length (Turn). The stick's axes in paper coordinates are

\small e_z = \begin{pmatrix} \sin\theta\cos\lambda \\ \sin\theta\sin\lambda \\ \cos\theta \end{pmatrix}
\small e_x^{0} = \begin{pmatrix} \cos\theta\cos\lambda \\ \cos\theta\sin\lambda \\ -\sin\theta \end{pmatrix}
\small e_y^{0} = \begin{pmatrix} -\sin\lambda \\ \cos\lambda \\ 0 \end{pmatrix}
\small \begin{aligned} e_x &= e_x^{0}\cos\omega + e_y^{0}\sin\omega \\ e_y &= -e_x^{0}\sin\omega + e_y^{0}\cos\omega \end{aligned}

Together e_x, e_y and e_z are the columns of a rotation matrix R that takes a point of the stick to its place in the paper's frame. The simulation needs two things from R. The third row, (n_x, n_y, n_z), says how high above the paper a point of the stick is, and the first two rows say where on the paper it lands. Each of the nine numbers is rounded to a whole number scaled so that 1024 is 1.0:

\small \text{pose} = \big(\operatorname{round}(1024\,R_{ij})\big)_{i,j}
\small \text{height of a point } q = n\cdot q

This is the only place in the whole simulation with sines and cosines. The card is handed the nine integers and never calls a trigonometric function. A mouse has no tilt, so it gets 45 degrees, no twist, leaning up and to the right. A pen reports tilt on two axes; these become tilt and lean on the sheet, corrected for how far the view is turned, so a stroke replays the same however the sheet is turned later.

One step: contact, wear and deposit

A step is one stamp of the brush's stamper: a point on the stroke, kept in sixteenths of a pixel, with a pressure p from 0 to 255. Stamps are spaced at 0.12 of the brush width by default. For each step, every column of the stick does the following, in whole numbers. Let (p_x, p_y) be the column's center as above and r_c its worn amount. A column's end is at the height (integer division rounds toward zero, as in the shader)

\small h_c = 1024\cdot \operatorname{trunc}\!\left(\tfrac{16\,(n_x p_x + n_y p_y)}{s}\right) + 2\, n_z\, r_c

and a column that is used up (r_c at least F) or outside the stick counts as a very large height. The lowest end of all, L, is where the stick would rest on the paper. The stick is pressed in by the pressure times the material's press per step P, so a column is in contact by how far its end is below the pressed plane:

\text{into}_c = L + p\,P - h_c

A column above the plane is not touching and wears nothing. A column in contact turns part of itself into pigment, in proportion to how deep it is in contact and how far the pen traveled since the last step, and never more than it has left. The distance d is a whole-number square root in sixteenths of a pixel, counted at most 512 (32 pixels), and the first step of a stroke counts as 16 (one pixel). With W the material's wear divisor, and integer division rounding down:

\small w_c = \min\!\left(\left\lfloor \tfrac{\text{into}_c \cdot \min(d,\ 512)}{W} \right\rfloor,\ F - r_c\right)
\small \text{(and } w_c = 0 \text{ where } \text{into}_c \le 0\text{)}

The stick loses what it turns into pigment, so r_c grows by w_c at the default Wear of 100 percent. A worked example: a column at the lowest point of a flat end, at full pressure of 255 with Chalk's press per step of 12288 and divisor of 12000000, makes 255 × 12288 × 16 / 12000000, about 4.18 parts of pigment for each pixel the pen travels, before rounding down. Its 49152 parts would last about 11,800 pixels of travel. The chart below shows that rate against pressure.

Where the pigment lands is the part that makes a worn stick behave like one. The pigment of a column is laid along the path from the last step to this one, one equal share at each whole pixel of it, at most 64 shares, with the remainder of the division handed out one part at a time to the first shares. It is shifted by where the column's worn face is: the column's place across the stick, and how far up the stick the face now is, turned by the first two rows of the pose and scaled to the brush size. The lowest part of the stick stays at the pen, so the mark does not slide away from the pen as the stick wears.

The paper then decides how much of each share is caught. Every pixel of the sheet has a fixed height from 0 to 255, its tooth, worked out from where the pixel is: a hash of the pixel mixed with the hash of its 2 by 2 block, so the grain is a little coarser than a pixel. With all arithmetic modulo 2 to the power 32 on unsigned integers, shifts toward the low bits, and x and y the pixel's coordinates:

\small \begin{aligned} h &= x\cdot 374761393 + y\cdot 668265263 \\ h &\leftarrow (h \oplus (h \gg 13))\cdot 1274126177 \\ h &\leftarrow h \oplus (h \gg 16) \\ H(x, y) &= (h \gg 8)\ \&\ 255 \end{aligned}
\small \text{tooth} = \big(3\, H(\lfloor \tfrac{x}{2} \rfloor, \lfloor \tfrac{y}{2} \rfloor) + H(x, y)\big) \gg 2

The tooth is not random per stroke. It is a fixed function of the pixel, which is why a stroke replays onto the same grain. A share of pigment is caught in proportion to tooth plus pressure plus the material's catch bias B, limited to 0 to 255 and taken as out of 256. What is not caught is dust:

\small \text{caught} = \text{share}\cdot \operatorname{clamp}\big(\text{tooth} + p + B,\ 0,\ 255\big)
\small \text{dust} \mathrel{+}= 256\cdot\text{share} - \text{caught}

Light pressure means only the high points of the paper catch, a broken grainy mark, and heavy pressure reaches the valleys as well. Pigment that would land off the sheet is all dust. The caught pigment is added into one whole number for every pixel of the sheet with an atomic add, so the order in which columns arrive cannot change the sum. Finally the amount A at a pixel becomes the stroke's coverage, 0 to 255, with the material's cover gain G, limited so the arithmetic cannot overflow, and the stroke lands on the layer through that coverage in the brush color, like any see-through stroke, so chalk over chalk builds up:

\small \text{coverage} = \min\!\left(255,\ \left\lfloor \tfrac{\min(A,\ A_{\max})\cdot G}{2048} \right\rfloor\right)
\small \begin{aligned} A_{\max} &= 1048576 \quad (G \le 2000) \\ A_{\max} &= \lfloor 2\,000\,000\,000 / G \rfloor \quad (\text{otherwise}) \end{aligned}
Three charts, one above the other. Top: pigment per step from one column against pressure, three straight lines for travel of 32 pixels, 8 pixels and 1 pixel, with 32 pixels reaching about 130 at full pressure. Middle: coverage against pigment for gains 512, 700 and 1900, rising straight to 255 and then flat. Bottom: the share of pigment the paper catches against pressure, an S-shaped curve from about 0.1 to 0.9.

Charts, computed from the rules above. Top: pigment per step against pressure for a column at the lowest point of a flat Chalk end. Middle: coverage against pigment, saturating at 1020 for Chalk, 747 for HB and 275 for Extra soft. Bottom: the share of pigment the paper catches, averaged over the paper's tooth.

What this does without being told to: a fresh stick held at 45 degrees rests on one edge, so only a thin sliver of columns is in contact and the line is thin. Those columns wear first, the contact spreads, and in time the tip becomes a flat face against the paper with every column in contact: a broad line.

Left: the side panel's picture of a chalk stick worn to a slanted flat face, standing on the paper. Right: three thin grainy chalk lines drawn at light, medium and heavy pressure with a new stick, and below them a broad dark band drawn after a long scribble.

Screenshot of the running app: the side panel's picture of the stick after a long scribble, and the marks. The top three lines are a new stick at pressures 0.2, 0.6 and 1.0. The broad band at the bottom is the worn stick at pressure 0.9.

Materials, and the three settings

A material is five numbers: press per step P, wear divisor W, catch bias B, cover gain G and the height of a sharpened point. Chalk is P 12288, W 12000000, B -128, G 512 and a flat end. Graphite has one material for each grade, hardest first, and Chalk and each grade are the same simulation with different numbers:

A white square with eight horizontal graphite lines, in pairs, each pair a light and then a heavy pressure, for the grades 4H, HB, 4B and Extra soft from the top. The lines get darker and broader down the picture.

Screenshot of the running app: Graphite grades 4H, HB, 4B and Extra soft from the top, each at pressure 0.3 and then 1.0, size 20.

Three settings travel with each stroke. Softness (Chalk only, 0 to 100, where 50 is Chalk as it always was) moves four of the material's numbers, piecewise linearly (a different slope below and above 50) and in whole numbers: P from 8192 to 16384, W from 18000000 down to 7000000, B from -96 to -160 and G from 256 to 1112. Resolution changes the grid, and the cover gain is scaled by the number of columns that are stick (208 over this resolution's), so that the same wear lays the same total pigment at every resolution. Wear is 0 to 300 percent, 100 by default, and it scales what the stick loses, not what is laid: the pigment is still worked out with the plain divisor, and the loss from the stick with the divisor scaled by 100 over Wear.

What is conserved

Every bit of pigment is either laid on the paper or dust. In whole numbers, the material turned into pigment, times 256, equals laid plus dust, where dust includes whatever fell off the edge of the sheet, and a test counts it. At Wear 100 the stick loses exactly what it turns into pigment, so material is conserved. At Wear 0 the stick loses nothing and still lays pigment, so it never changes shape and keeps its point or edge, and material is not conserved. At Wear 300 the stick pays about three times what it lays; because the two amounts are rounded down separately, a small loss can happen where no pigment is laid at all, a property of the arithmetic and not a goal.

On the card

On WebGPU the simulation runs in two compute shaders. A stroke is handed over in batches of steps: live drawing hands steps over as they come, a replay hands over all of an op's at once, and a batch of more than 2048 steps is cut. The buffers are the stick (room for 1024 worn amounts, the finest stick, then 12 numbers: whether a step has been taken, where it was, running totals for the tests, a scratch number for the lowest height, the material's three numbers and the wear), the steps (a ring of 65536 steps of three whole numbers) and the pigment, one unsigned whole number per sheet pixel.

The first pass, WEAR, is one group of 256 invocations; invocation i takes columns i, i + 256 and so on, so a finer stick is longer work and not another shader. It takes the steps of a batch one after another. The lowest column is found with an atomic minimum into one slot of the stick buffer, which is reset, offered the heights of every invocation's columns, and read back after a barrier, so there are three barriers a step. The usual way is a group-shared array, but this engine's WebGPU converter refuses one, and buffers cannot be read back from the browser yet, so the stick is also written out each batch as a small picture, one pixel per column with the low byte in red and the high byte in green, which can be read, and only the checks do. The second pass, COVER, runs in groups of 8 by 8 over the rectangle the stroke has reached so far, its path grown by three brush widths and 8 pixels, and turns pigment into coverage, one byte per pixel. An ordinary drawing pass then lays the coverage in the brush color.

One consequence to be honest about: WEAR is a single group, so the steps of a stroke are serial and only the columns run side by side. That is cheap at up to 1024 columns, and it is what makes each step see the wear of the step before it. A live stroke shows nothing beyond its last step, because the stick wears only where it has been.

Replay and the plain-code twin

A wearing stroke depends on the stick's shape when it began, so each stroke's record carries the stick it began with, as two bytes a column in base 64: 512 bytes, 684 characters, at Normal, and 128 to 2048 bytes across the resolutions. No stick means a new one, flat for Chalk and a sharpened cone for Graphite. The wear is a pure function of that stick and the stroke's points, with no randomness, no clock and no read from the card. A pen can change tilt, direction or twist during one line, and the hold is part of the record, so the line is cut into pieces when it moves by 5 degrees of tilt, 10 of direction (counted only once the tilt is 10 degrees or more) or 15 of twist, and at most once every 120 milliseconds; each piece begins with the stick the piece before it left. The price is up to about eight records a second, each carrying the whole stick.

The same arithmetic exists a second time in plain code, written to be read and following the shader line for line. It serves the tests, the stick at the end of a stroke (the app follows the stick with it, so it never reads the card), and drawing where there is no compute. Opening the site with a self-check switch runs the shader against the twin on the real card: one stroke of 90 steps handed over in uneven batches, comparing every column of the stick and every pixel of coverage, for a worn stick, a stick with columns used up, two Graphite grades, every resolution, Wear 0 and 300, and Softness 0 and 100. They must agree exactly. A separate test checks that the same steps cut into different batches give the same stick and pigment.

What it does not simulate

A finer or sparse voxel body would add chipping, breaking and overhangs, at the cost of state: 16 by 16 by 48 is 12288 cells where there are now 256 numbers, and on the card several columns could contend for one voxel, so removals would need a fixed order, where today's atomic adds may arrive in any order because addition commutes and removal does not. A mesh or distance field for the picture of the stick could be added alone, since it feeds nothing back. Neither is built.

2. Smudge and Blend

The Smudge tool drags the paint under the brush along the stroke, and its Blend mode softens in place. It reads the pixels it is about to change, so what it leaves depends on the layer as it stood before the stroke. Three decisions follow. The arithmetic is whole numbers on bytes: positions in sixteenths of a pixel, strengths 0 to 256, pixels the layer's own premultiplied bytes. A stroke is a list of fixed steps taken one at a time, in order, and no step depends on how many arrive together. And what lands on the layer is always made again from the op, so nothing from the live picture is trusted.

One step is one stamp, with a center c and size in sixteenths of a pixel, and a strength t from the tool's strength times the pen's pressure, 0 to 256. The brush's weight at a pixel at distance e from the center,

Links