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 buffer of surface facts; that becomes one channel of darkness; darkness becomes strokes. Then the pen: strokes become quads, quads become pigment density, 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.
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 posterised soft edge is unstable: noise nudges coverage 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.
03A HEAD BECOMES A FIELD OF DARKNESS
The head is posed by linear blend skinning, rasterised on the CPU into a small G-buffer — depth, normals, 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.
Lambert 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 terminator a pixel wide between them. Rows crossing that either draw at full weight or not at all.
Wrap lighting 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.
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.
04A FIELD BECOMES MARKS
The hatcher walks ruled rows 20px apart, sampling darkness under the nib every 2.4px. One rule: while the tone is above the threshold the pen is down, and the whole run 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 pressure, driving both the nib's width and its flow. 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.
05MARKS BECOME INK
The pen resamples each path at a quarter of the nib width, makes one quad per segment, and draws the whole stroke in a single instanced call. For every pixel inside a quad the fragment shader asks how much pigment lands here, and answers with a signed distance 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.
Grain 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 MAX a pixel covered by six segments is as dark as the darkest of them. Strokes go into a scratch buffer 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 float buffer as unbounded density, not in RGB. Opacity comes later from Beer–Lambert absorption, a = 1 − exp(−density × absorption) — a saturation ceiling for free rather than a clamp. At the fineliner's 4.5, one pass is already at 99% and a second is invisible.
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.
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 autocorrelation 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.
| 01 | Quantise opacity, not colour, at the very end | the five greys land exactly, and the speckled edge comes free |
| 02 | Sample grain in canvas space | the texture belongs to the sheet, so crossing strokes agree about the paper |
| 03 | MAX within a stroke, add between strokes | overlap inside a mark is free; overlap between marks still builds |
| 04 | Accumulate unbounded density, resolve once | colour, paper and tone stay decisions made after the drawing exists |
| 05 | Wrap the light before hatching it | a hatcher can only draw the gradient it is handed |
| 06 | One run, one stroke | every break is somewhere the head got light, not somewhere a cell ended |
| 07 | Carry the tone as pressure along the mark | weight is the drawing; there are only thirty strokes to work with |