INKTHE SHORT VERSION

The whole engine,
in about five minutes.

Two halves: a pen that deposits pigment onto paper, and a pipeline that decides where the pen goes. This is the load-bearing part of how the pen works and how the head works — the ideas you cannot drop and still have it function, and nothing else. Hover a dotted word for its definition; drag anything with a slider.

01THE SHAPE OF IT

Eight stages, each handing the next something narrower than it received. A skeleton becomes a posed mesh; a mesh becomes a ; that becomes one channel of darkness; darkness becomes strokes. Then the pen: strokes become quads, quads become pigment , density becomes pixels.

Nothing is a filter over a rendered image — there is no image until the last stage. And no stage knows what came before it: by the time the pen is involved there is no head left in the data, which is why the light can change without touching a triangle and the pen can be swapped without either half noticing.

FIG 01EIGHT STAGES
THE HEAD · TYPESCRIPT, CPUPOSEskin the rigmeshRASTER28k trianglesg-bufferLIGHTshade the normalstoneHATCHwalk it in rowsstrokes — points, pressure, a seed, and nothing elseTHE PEN · GLSL, GPUPATHsmooth, resamplepointsMARKquad per segmentquadsDEPOSITSDF + graindensityRENDERtone + paperfive greys on paper
Click any stage to read it in full on the page it belongs to. The head half is plain TypeScript on the CPU; the GPU only appears once there are strokes to rasterise.
FIG 02WHAT COMES OUT
THE PIPELINE AT ITS DEFAULTS
The drawn area is 302 × 447px and it is made of about thirty pen strokes in total — a fact worth carrying into section 04.

02THE ONE IDEA · FIVE GREYS, APPLIED LAST

Take a pen drawing with real ink in it and count the distinct grey values. There will be far fewer than you expect. This engine uses five, at mix(white, #111111, a) for a in quarter steps: 17, 77, 136, 196, 255.

Almost everything the eye reads as ink rather than as vector falls out of that snap. A soft edge is unstable: noise nudges back and forth across a step, and every crossing flips a pixel a whole level darker. The speckled boundary is a property of the last line of the render pass, not of the shape of the mark — which is why chasing it with better nib geometry never works.

Two details carry it. The snap happens at the very end, and it quantises opacity rather than colour: snap alpha and then mix(paper, ink, a) and those five greys land exactly, where snapping the RGB result makes the steps a function of the ink and paper colours, drifting the moment either changes.

FIG 03WHERE THE SPECKLE COMES FROM
FIVE TONE LEVELS
CONTINUOUS, SAME MARKS
Hatched rows at 2×, quantised to five levels and left continuous. The geometry is identical between the two panels. Everything that reads as ink is in the left one's edges.

03A HEAD BECOMES A FIELD OF DARKNESS

The head is posed by , rasterised on the CPU into a small G-buffer — depth, , and an ink allowance that fades out the neck stump — then shaded down to one number per pixel: darkness, 0 for bare paper and 1 for solid ink. Nothing downstream sees anything else. That shading function is forty lines and decides almost everything.

alone fails, and the reason generalises. Half the sphere of surface directions maps onto a gradient and the other half onto a single value — solid black — so the hatcher receives a bright region, a black region, and a a pixel wide between them. Rows crossing that either draw at full weight or not at all.

is the fix and it is frankly a cheat: let light past the terminator by a fixed amount, then rescale so full brightness still means full brightness. The cliff becomes a ramp running from the lit profile edge to the back of the skull, and that ramp is the face — the cheekbone, the brow, the plane change at the jaw are all the hatch following it.

FIG 04THE SHADING FUNCTION, TERM BY TERM
DARKNESS FIELD
WHAT THE PEN DRAWS FROM IT
float lambert = dot(normal, lightDir);
float lit = clamp((lambert + wrap) / (1.0 + wrap), 0.0, 1.0);
float d = 1.0 - lit;
d += rim * pow(1.0 - facing, 3.0);
d = PIVOT + (d - PIVOT) * contrast;
d *= fade;

The last multiply is not lighting at all. Each vertex carries an ink allowance that falls from 1 just under the jaw to 0 by the bottom of the neck stump, and the rasteriser interpolates it into the G-buffer alongside the normals. Multiplying the darkness by it means the tone reaches the hatcher already faded, so runs shorten and then stop — the drawing ends where the head does rather than at the edge of the paper.

The contrast line stays greyed throughout: at the default of 1.0 it is the identity. A fifth term, contrast, pivots low at 0.35 so raising it spends most of its range on the shadow side — 04.3 on the head page.

Real output at every step: the darkness field on the left, the drawing the hatcher makes from it on the right. Step 1 is textbook-correct and unusable.

04A FIELD BECOMES MARKS

The hatcher walks ruled rows 20px apart, sampling darkness under the every 2.4px. One rule: while the tone is above the threshold the pen is down, and the whole goes down as a single stroke, not a chain of dashes. So every break in the drawing is somewhere the head got light enough to lift the nib — the eye socket, the front of the cheek, the gap under the jaw. Chopping rows into jittered cells instead, which is the usual recipe, breaks wherever a cell ends, and that reads as noise laid over the form rather than as the form.

That is about thirty strokes for a whole head. There is barely any drawing at all in the sense of quantity, so all of it has to be weight: tone is sampled at every point along a run and handed to the pen as , driving both the nib's width and its . A single stroke swells from about 3px to 14px and back. The nostril, the lip line and the eye socket are far too small to be marks of their own at a 20px pitch — they exist only as the nib changing weight as it passes over them.

FIG 05TONE → PRESSURE → WIDTH
no mark drawn0px5px10px15px0.09p 0.140.25p 0.290.50p 0.530.75p 0.761.00p 1.00
INK WEIGHT is 14px, so the pen's nominal nib is 7.57px — the peak divided by 1 + 0.85. The swing runs from 2.9px at the threshold to 14.0px on solid tone, a ratio of 4.8 to one.
WEIGHT FROM TONE · PRESSURE → WIDTH 0.85
THE SAME RUNS AT ONE WEIGHT · PRESSURE → WIDTH 0
The real mapping, with the nib drawn at true scale at five points along it. Below, the same face at 1:1 with weight modulation on and off — same runs, same tone, same row pitch, and only the nib's response to pressure differs.

05MARKS BECOME INK

The pen each path at a quarter of the nib width, makes one quad per segment, and draws the whole stroke in a single call. For every pixel inside a quad the asks how much pigment lands here, and answers with a to the segment's centre line. Width wobble, drift, edge erosion and fibre wicking are one line each on top of it.

Three rules do the rest of the work.

is sampled at gl_FragCoord.xy — the pixel's position on the canvas, not a coordinate relative to the nib. One word in the shader, and it decides whether the result looks like paper or like television static, because a second stroke over the same patch has to hit the same fibres.

Segments blend with gl.MAX, not FUNC_ADD. Resampling that finely means consecutive segments overlap almost entirely, and added together every joint would bead into a lump. Under a pixel covered by six segments is as dark as the darkest of them. Strokes go into a this way and only the finished stroke is added into the page, so overlap within a mark is free while overlap between marks still accumulates.

Pigment lands in a as unbounded density, not in RGB. Opacity comes later from absorption, a = 1 − exp(−density × absorption) — a for free rather than a clamp. At the fineliner's 4.5, one pass is already at 99% and a second is invisible.

FIG 06THE DEPOSIT SHADER, TERM BY TERM
float sd = length(vec2(dx, dy)) - w;
w *= 1.0 + uWobbleAmt * 2.0 * (vnoise1(arc / uWobbleLen) - 0.5);
float dy = vLocal.y - drift;
sd += (mix(n1, n2, uEdgeSharp) - 0.5) * 2.0 * uEdgeAmt;
sd -= (tooth - 0.5) * uToothEdge;
a = floor(a * (uLevels - 1.0) + 0.5) / (uLevels - 1.0);

Applied at the very end, in the render pass. Collapsing coverage to five steps turns the soft antialiased boundary into a crunchy speckled edge. Almost everything that reads as ink rather than as vector comes from this one line.

Real renders from the engine, one line of shader at a time. Step 1 is a clean signed-distance capsule; step 6 is the calibrated fineliner.

06AND THEN MEASURE IT

Tuning either half by eye stalls quickly: past the first few changes you cannot tell whether one helped. So both are measured. tools/measure.mjs slices perpendicular to strokes, and the number that earns its keep is the of edge roughness — it pins grain size at 2.5px, which no amount of squinting will set, because the eye reads noise as a texture and not as a length.

tools/tone-match.mjs scores two drawings by tonal structure: ink coverage resampled onto a grid laid over each image's own ink bounds, so framing and scale drop out. The grid must be coarser than the row pitch or it stops scoring tone and starts scoring the phase of the hatch.

07WHAT IS LOAD-BEARING

Seven things. Everything else on the two long pages is detail hanging off one of them.

01Quantise opacity, not colour, at the very endthe five greys land exactly, and the speckled edge comes free
02Sample grain in canvas spacethe texture belongs to the sheet, so crossing strokes agree about the paper
03MAX within a stroke, add between strokesoverlap inside a mark is free; overlap between marks still builds
04Accumulate unbounded density, resolve oncecolour, paper and tone stay decisions made after the drawing exists
05Wrap the light before hatching ita hatcher can only draw the gradient it is handed
06One run, one strokeevery break is somewhere the head got light, not somewhere a cell ended
07Carry the tone as pressure along the markweight is the drawing; there are only thirty strokes to work with