Drawing ink on paper,
one stage at a time.
A pen is a tool that deposits pigment onto a height field1. That single sentence is the whole architecture: ballpoint, fineliner and felt tip are not three engines, they are three parameterisations of one. What separates them is the rule by which pigment transfers, not the way a stroke is drawn.
This page walks through that engine in the order the data flows, and every figure is a live render from the same code the playground runs. Where something is interactive, poke at it — most of these ideas are far easier to see move than to read about.
00WHAT MAKES A MARK LOOK DRAWN
The instinct when building a pen renderer is to reach for geometry: a better nib profile, a wobblier outline, a softer cap. It is mostly the wrong place to spend the effort, and the quickest way to see why is to take a hatched pen drawing — a scan, a printed plate, anything with real ink in it — and count the distinct grey values.
There will be far fewer than you expect — a handful, where an antialiased2 vector render of the same shapes would give you a continuous ramp. This engine uses five, at mix(white, #111111, a) for a in ¼ steps: 17, 77, 136, 196 and 255. A soft coverage4 value snapped onto four strengths of ink plus bare paper.
That snapping is also where the chewed, speckled edge comes from. A posterised3 soft boundary is unstable: noise nudges coverage back and forth across a quantisation step, and every crossing flips a pixel a whole level darker or lighter. The texture is a property of the last line of the render pass, not of the shape of the mark — which is why chasing it with brush geometry alone never works.
01THE PIPELINE
Four stages, kept strictly separate. Each one hands the next a different kind of thing: points become quads, quads become density5, density becomes pixels. Keeping them apart is what makes it possible to change the paper without touching the brush, or re-render the whole page at export resolution without redrawing a stroke.
Two rules do most of the work, and both are easy to get wrong in ways that are hard to diagnose later.
Grain6 is anchored to the canvas, not to the brush. All noise is sampled in canvas space, so a second stroke over the same patch of paper hits the same fibres. Grain that rides along with the nib7 crawls, and reads as fake instantly.
Pigment accumulates in a float buffer8, not in RGB. Strokes deposit density; opacity, paper colour and tone are applied once, at the end. This is what gives a saturation ceiling9 for free rather than as a special case.
02STAGE ONE · PATH
A pointer gives you a jittery, unevenly spaced list of positions. The gaps between samples encode speed, not distance — move fast and the browser hands you a few widely spread points, move slowly and you get a dense cluster. Neither is what a rasteriser wants.
So the path is smoothed with a few passes of a [1 2 1] kernel, then resampled10 at a fixed spacing of about a quarter of the nib width. After that, segment density is a property of the pen rather than of how fast the hand moved, and the deposit stage can assume every segment is roughly the same size.
Speed and pressure are carried along per point, because the pen model needs them later: pressure feeds width and flow11, speed feeds thinning and the skipping that a starved nib does on a fast stroke.
03STAGE TWO · MARK12
Each pair of adjacent points becomes one instance13 — a single quad, stretched to cover that segment’s capsule14 and padded outward far enough that noise, drift and width variation cannot reach the edge and get clipped. The GPU is handed four vertices and a list of per-segment attributes, and draws the entire stroke in one call.
The vertex shader15 builds a local frame from the segment direction, then places each corner at a + dir·along + normal·across. Nothing about the mark’s shape is decided here — this stage only guarantees that every pixel the mark could possibly touch gets a fragment to think with.
04STAGE THREE · DEPOSIT16
This is where a mark stops being geometry. For every pixel inside a quad, the fragment shader17 asks a single question — how much pigment lands here? — and answers it by building up a signed distance18 and then reading coverage off it.
Step through the terms below. Each one adds a single line to the shader, and each is responsible for one thing you would notice if it were missing.
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.
04.1THE RULE THAT MATTERS MOST
The erosion term samples noise at gl_FragCoord.xy — the pixel’s position on the canvas — and not at a coordinate relative to the nib. It is a one-word difference in the shader and it decides whether the result looks like paper or like television static.
Both sides below run the same code with the same noise. The only difference is where it is sampled from.
04.2BLENDING WITHIN A STROKE
Resampling at a quarter of the nib width means consecutive segments overlap almost entirely. If their coverage were added together, every joint would bead into a dark lump and the stroke would look like a string of pearls.
The fix is a different blend equation23: gl.MAX rather than FUNC_ADD. Overlap then costs nothing: a pixel covered by six segments is simply as dark as the darkest of them. Strokes are drawn into a scratch buffer24 this way, and only the finished stroke is added into the page — so overlap within a mark is free while overlap between marks still builds up, which is the physically right answer to both.
04.3WHY THE BUFFER IS FLOAT
Deposits land in an R16F render target holding pigment density, which is unbounded, rather than an 8-bit colour buffer, which is not. Eight passes of a wet pen can push density well past 1.0 and still carry meaning; clamped to a byte, that information is gone and the saturation curve has to be faked per stroke.
Keeping density separate from appearance also means the ink colour, the paper, the absorption and the tone steps are all decisions made after the drawing exists. Change any of them and the page re-renders without a single stroke being rasterised again.
05STAGE FOUR · RENDER
One full-screen pass turns density into pixels. First, opacity, using Beer–Lambert25 absorption — a = 1 − exp(−density × absorption). The shape of that curve is the reason a second pass over the same line barely darkens it while the first pass does almost all the work. It is a saturation ceiling that falls out of the maths instead of being clamped in.
Then the tone step, and it matters that it quantises opacity rather than colour. Snap alpha to five levels and then run mix(paper, ink, a), and the five greys of section 00 land on the page exactly. Snap the RGB result instead and the steps become a function of the ink and paper colours, drifting the moment either changes.
06CALIBRATING BY MEASUREMENT
Tuning a pen by eye stalls quickly. Early on every change is obviously better or obviously worse; past that point you cannot tell whether a change helped, and you start moving sliders in circles. The way out is to stop admiring images and start measuring them — tools/measure.mjs takes perpendicular slices through strokes and reports a handful of numbers, and those numbers are what you tune against.
Most of them are the obvious ones: how thick a stroke is, how much that thickness varies, how rough the edge is. The interesting measure is the autocorrelation26 of that edge roughness — take the wobble along one side of a stroke and ask how much it resembles itself when shifted by one pixel, two, three. Where the answer reaches zero is the size of the features in the noise.
For a real fineliner mark it falls away by about three pixels, which pins the grain’s correlation length27 at roughly 2.5px. That is worth dwelling on: GRAIN SIZE is a parameter no amount of squinting will set correctly, because the eye reads the noise as a texture and not as a length. A twenty-line script reads it straight off.
| MEASURE | WHAT IT PINS DOWN | THIS ENGINE |
|---|---|---|
| tone levels | the render pass’s quantiser | 5 · 17 77 136 196 255 |
| stroke thickness | nib width | median 12px |
| thickness spread | width jitter and pressure response | sd 3.36px |
| edge roughness | how hard the noise displaces the edge | 0.75–1.10px sd |
| grain autocorr, lag 1–4 | grain size — the scale of the noise | 0.54 0.26 0.11 −0.04 |
The TEST SHEET button in the playground draws the same calibration layout every time — speed ramp, pressure ramps, taper, S-curve, hard reversal, dwell dots, overlap patches, hatching at three spacings. Same sheet, same seeds, so a parameter change is judged against the last render rather than against memory.
07TECHNIQUES USED
Nothing here needs a library. The whole engine is WebGL2 plus a few hundred lines of GLSL, and it leans on five features in particular.
| Instanced rendering | drawArraysInstanced | One stroke, however long, is a single draw call: four vertices and N per-segment attribute sets. |
| MAX blend equation | blendEquation(gl.MAX) | Makes overlap free within a stroke, so resampling density does not affect darkness. |
| Float render targets | EXT_color_buffer_float | Pigment density is unbounded and needs to survive past 1.0 for the saturation curve to mean anything. |
| Scissor rectangles28 | gl.scissor | Each stroke only clears and composites its own bounding box, so redrawing thousands of strokes stays cheap. |
| Signed distance fields | in the fragment shader | A capsule SDF gives correct round caps and a continuous edge to perturb — the noise has something meaningful to displace. |
08WHAT IS NOT BUILT YET
Only the fineliner has been calibrated. Ballpoint and felt tip are starting points with the right machinery exposed but no measurement behind them.
Colour is the honest gap. Layering translucent pigment is not source-over; doing it properly needs Kubelka–Munk29 absorption and scattering coefficients, which is what keeps layered colours from going muddy grey. It buys nothing while the target is black on white, so it waits until the first pen that needs two inks to sit on top of each other.
09GLOSSARY
Every marked word on this page, numbered in the order it first appears. The superscript beside a word links down here; the entries link across to each other.
- 01Height field
- A surface stored as one height per point rather than as geometry. The paper here is a height field: bright means a fibre standing proud, dark means a pit between fibres. Every pen in this engine is a rule for how pigment transfers onto it.
- 02Antialiasing
- Softening a shape's boundary by giving edge pixels partial coverage instead of a hard on or off. Without it edges look jagged. With it, and then posterised, you get speckle — noise nudging coverage across a tone step.SEE ALSO Posterisation3 · Coverage4
- 03Posterisation
- Snapping a continuous range onto a small number of fixed steps. Doing it to opacity — at the very end, after everything else — is what puts a pen drawing's handful of greys on the page, and the speckled edge along with them.SEE ALSO Antialiasing2 · Coverage4
- 04Coverage
- How much of a single pixel the mark covers, from 0 to 1. Read off the signed distance by a smooth ramp across the boundary; multiplied by flow, it becomes the density that gets deposited.
- 05Density
- How much pigment is sitting on a patch of paper, added up across every stroke that crossed it. It is not an opacity and not a colour, and it is unbounded — eight passes really can be eight times the first. Only the render stage turns it into something visible.
- 06Grain
- The paper's texture as it shows up in a mark: the speckle along an edge and the mottling inside it. In this engine grain is a noise function sampled at the pixel's canvas position, which is what keeps it attached to the sheet instead of to the pen.SEE ALSO Tooth22 · Value noise21
- 07Nib
- The tip of the pen — the part touching the paper. Its width, and how much that width answers to pressure, is most of what separates a fineliner from a felt tip.SEE ALSO Flow11
- 08Float render target
- An off-screen buffer storing real numbers rather than 0–255 bytes. Density has to be able to pass 1.0 and keep meaning; clamped to a byte that information is gone and the saturation curve has to be faked per stroke instead of emerging on its own.SEE ALSO Density5 · Beer–Lambert law25
- 09Saturation ceiling
- The point past which more pigment stops making a mark darker. Real ink has one; naive alpha blending does not, which is why repeatedly stamping a translucent brush eventually goes pure black instead of settling.SEE ALSO Beer–Lambert law25 · Density5
- 10Resampling
- Replacing a path's original points with new ones spaced evenly along it. Pointer samples arrive at whatever rate the device reports, so their spacing encodes speed; resampling makes segment size a property of the pen instead.SEE ALSO Arc length19 · Instance13
- 11Flow
- How readily ink leaves the nib. Separate from width: pressing a ballpoint harder barely widens the line but pushes noticeably more ink out, so flow scales density rather than geometry.
- 12Mark
- The second stage of the pipeline: turning a path into geometry the GPU can rasterise. It decides where a stroke could possibly put ink, not how much — that is the next stage's job.
- 13Instance
- One copy of a shape drawn from a shared template. Here every stroke segment is an instance of the same four-vertex quad, with per-segment data telling the GPU where to put it — so an entire stroke costs one draw call no matter how long it is.SEE ALSO Vertex shader15 · Mark12
- 14Capsule
- A line segment thickened by a fixed radius: a rectangle with a half-circle on each end. Its distance function is a two-line expression and it gives correctly rounded pen caps without any separate cap geometry.SEE ALSO Signed distance field18
- 15Vertex shader
- The small GPU program deciding where each corner of a shape lands on screen. It runs once per vertex, before anything is coloured in. Here it does nothing but stretch a unit quad to cover one segment.SEE ALSO Fragment shader17 · Instance13
- 16Deposit
- The third stage: deciding how much pigment lands on each pixel. Paper texture, edge erosion and the pen's failure modes all apply here, and the answer accumulates as density.
- 17Fragment shader
- The GPU program that runs once for every pixel a shape covers and decides what that pixel gets. Almost all of this engine's character lives in a single fragment shader, about forty lines long.SEE ALSO Vertex shader15 · Deposit16
- 18Signed distance field
- A function giving the distance from a point to a shape's surface — negative inside, positive outside, zero exactly on the boundary. Because it is continuous, you can move the boundary anywhere by simply adding to the distance, which is precisely how the eroded edge is made.
- 19Arc length
- Distance travelled along a stroke from its start. Indexing noise by arc length rather than by screen position makes a feature stay put on the mark, so a swelling caused by nib wear travels with the stroke rather than sitting at a fixed spot on the page.SEE ALSO Resampling10 · Grain6
- 20Octave
- One layer in a stack of noise, each finer and fainter than the one before. Adding octaves gives detail at several scales at once. Two are enough here: one for the shape of the erosion, one for its crunch.SEE ALSO Value noise21
- 21Value noise
- Random values placed on a grid and smoothly interpolated between. Cheap to evaluate, and unlike white noise it has a controllable feature size — which is what makes it possible to tune the grain until it matches a measurement.
- 22Tooth
- The roughness of a sheet's surface — the peaks and pits its fibres make. A heavily toothed paper catches pigment on the peaks and leaves the valleys bare, which is why a crayon rubbing reproduces the texture underneath it.SEE ALSO Height field1 · Grain6
- 23Blend equation
- The rule for combining what a shader outputs with whatever is already in the buffer. Addition is the usual choice. This engine uses MAX inside a stroke, so overlapping segments do not compound, and addition between strokes, so repeated passes do.SEE ALSO Scratch buffer24 · Density5
- 24Scratch buffer
- A temporary render target used to finish one thing before merging it into another. A stroke is assembled here at full coverage first, so that overlap within it costs nothing, and only the finished mark is added to the page.SEE ALSO Blend equation23 · Float render target8
- 25Beer–Lambert law
- Light is absorbed exponentially with the amount of material it passes through. Applied to ink it means opacity approaches 1 without ever reaching it, so the first pass does nearly all the darkening and later ones add almost nothing — a saturation ceiling that falls out of the maths rather than being clamped in.SEE ALSO Density5 · Saturation ceiling9
- 26Autocorrelation
- How much a signal still resembles itself once shifted along by a step. Measured across a stroke's edge it says how far apart two wobbles stay related, which turns a vague sense of “about the right roughness” into a feature size in pixels.SEE ALSO Correlation length27 · Value noise21
- 27Correlation length
- The shift at which a signal stops resembling itself — the size of its features. A fineliner's edge roughness loses correlation by about three pixels, which is what sets the grain size to 2.5px.SEE ALSO Autocorrelation26 · Grain6
- 28Scissor rectangle
- A clip region confining drawing to part of a buffer. Each stroke only clears and composites its own bounding box, which is what keeps redrawing thousands of strokes cheap when a parameter changes.SEE ALSO Scratch buffer24
- 29Kubelka–Munk
- A model of how layered pigments actually combine, using absorption and scattering coefficients per pigment rather than simple alpha blending. It is the difference between layered colours going muddy grey and going the way real ink does.SEE ALSO Density5