The accumulation rule
A pass that cannot darken itself
A stroke is a queue of overlapping stamps. At the chisel's spacing of 0.22, each one advances by barely a fifth of its own footprint, so any given pixel is covered by five or six of them. Multiply them onto the canvas as they land and the stroke grows corduroy β and dragging slower makes it worse, because slower means more stamps.
So the stroke in progress gets its own render target, and the blend is split down the middle:
gl.blendEquationSeparate(naive ? gl.FUNC_ADD : gl.MAX, gl.FUNC_ADD);
gl.blendFuncSeparate(gl.ONE, gl.ONE, gl.ONE, gl.ONE);
Colour takes the maximum: overlapping stamps agree instead of compounding, so one pass saturates at a flat value however much it doubles back. Alpha keeps adding, which is a separate channel used for dwell. Only when the pen lifts does that buffer get composited onto the canvas, once, as a multiply.
The consequence is that darkening happens exactly where it happens on paper β between passes, never within one. And because the palette entries are transmittances rather than paint colours, the arithmetic comes out right for free: two passes of one marker land on valueΒ², which is how Copic layering behaves; yellow over blue gives the same green as blue over yellow, because multiplication commutes; and a pale marker over a dark one does essentially nothing, which is the first thing anybody learns the hard way about alcohol markers.
The naive flag in the source flips that one blend equation back to FUNC_ADD. It is switchable in the figure two sections down, and it is the most convincing argument for the whole design: the stroke immediately grows the ribs of its own overlapping stamps.
One line of GL state is the difference between a brush and a marker. Everything below is built on the assumption that a pass is flat.
The nib
There is no stroke-width parameter
Nowhere in this engine does anything compute how wide a stroke should be. A chisel marker is a rectangle held at a fixed angle, and its width is whatever that rectangle happens to present to the direction you are dragging it.
Drag it along its long axis and you get a hairline; drag it across and you get a band; a curve sweeps continuously between the two. All the calligraphic character of a marker drawing falls out of that geometry and nothing else. The chisel's half-extents are 16px and 4.5px at size 1, held at β35Β°, so its stroke runs from a 9px line to a 33px one β the rectangle's own diagonal, exposed by dragging it corner-first β which is 3.7Γ from a tip with no width setting anywhere.
Stamp spacing has to follow, or an edge-on drag breaks into beads. The support function of the rectangle along the travel direction gives the half-extent the nib presents to the path β |aΒ·cos Ο| + |bΒ·sin Ο| β and the walk advances by twice that, times the nib's spacing fraction. Turn the plot's angle knob and watch the ring go thin where the path runs along the nib.
The brush pen is the one tip that breaks the rule, and it is worth knowing why. A felt nib is a solid: it presents the same rectangle however you handle it. A brush is a bundle β it splays when you lean on it and draws itself to a point when you whip it away, which is a different width for identical geometry. So it cannot come out of the support function; the footprint itself has to move. swellAt() keys it off speed as well as pressure, deliberately: a mouse reports no pressure at all (the app pins it at 0.62), and a tool that only comes alive for stylus owners is a tool most visitors never meet. Whipped away at full tilt, the tip narrows to about half its resting size, with a hard floor at 0.22.
The stipple tip is the same code with spacing at 1.45. Above 0.5 the stamps stop touching and the stroke is the interval β dots, not a line, each with its own wet edge from the same rim pass.
The engine's own NIBS table, support() and strokeWidth(), plotted by this page.
Hands on
Try it
This is the real Marker on a real generated page. Scribble a patch, then switch to compositing every stamp and scribble again. The rest of the article is about the switches underneath.
The actual Marker, generatePage and buildField, imported by this page.
Texture
Two grains that must not be confused
A marker stroke carries two textures that behave nothing alike, and conflating them is the most common way a digital marker gives itself away.
Paper tooth is locked to the sheet. It is sampled in canvas coordinates at 340px per wrap, so ink misses the same pits every time and a second pass re-starves in exactly the same places. Nib streaks are locked to the stroke: sampled across the nib and along the arclength, because they are the dry channels between the tip's fibres.
That second coordinate choice is not cosmetic β it is forced by the blend equation. MAX over coherent samples preserves them; MAX over per-stamp random noise washes straight out to white within a few stamps. The grain survives compositing precisely because it is a function of stroke-local position rather than of the stamp.
Both are generated from a seeded lattice rather than shipped as bitmaps: a 512px tileable value noise, four octaves for the tooth (a broad 24Γ24 undulation, a 64Γ64 mid, a 192Γ192 speckle and a faint 8Γ220 fibre) and a deliberately lopsided one for the streaks β 160 cells across the nib against 6 along it, because a channel that starts dry stays dry for a while. Each is biased with a power curve toward the wet end, so a minority of channels skip and most of the sheet catches ink. That asymmetry is what makes dragged ink read as starved rather than merely noisy.
One number in there is pure debugging scar tissue. A second, slower starve field thins the whole stroke as the nib runs dry, and it is read at 0.37Γ the streak scale across and 2.7Γ along β deliberately incommensurate. Sampled at the same rate, a back-and-forth scribble lands on nearly the same point in the field at every turn: the sweep period beats against the texture wrap and the dryness lines up into a band across the whole fill instead of scattering.
Speed starves the ink too, at min(0.6, speed / 3400 Γ starve Γ 3). The cap matters: a marker that ran fully dry at ordinary scribbling speed would make filling a region impossible.
The rim
Where the ink pools
Alcohol floods out to the edge of the wet patch and dries there. The darker rim it leaves is in every marker drawing and in almost no digital brush, and it is cheap to find.
Compare each pixel's density against the thinnest point a few pixels away β eight taps at wetRadius, 3.2px by default, taking the minimum. In the flat interior that difference is about zero, because the neighbourhood is equally saturated. Along the boundary it jumps to nearly the full density. So the difference is the rim, with no edge detection and no second pass; it is added back at 45% strength by default.
Resting the nib feeds a second, additive channel. That is what the alpha half of the split blend equation was for: each stamp adds 0.012 to it, so holding still grows a blot under the tip exactly as it would on paper, while colour is still taking its flat maximum.
Both the live preview and the pen-up bake call the same inkDensity() function. That is deliberate β the moment a stroke stops being "in progress" and becomes part of the canvas is the moment a shared function stops it visibly popping.
Turn the wet-edge slider in the figure to zero and the drawing goes instantly, unmistakably digital. It is one term.
The page
A colouring page is a partition, not a picture
The whole feel of colouring in comes from ink racing to a boundary and stopping. So before any drawing happens the page is rasterised once and two fields are precomputed from it.
Region ids come from a 4-connected flood fill over everything the linework does not cover β an explicit stack, because recursion blows up on a full-page region at this resolution. Distance to the nearest line comes from an exact squared Euclidean distance transform, one dimension at a time, after Felzenszwalb & Huttenlocher. Linear time, and unlike a chamfer pass it has no directional bias β which matters here, because a biased field makes ink slop further along diagonals than along axes and the eye reads that immediately as a diamond-shaped halo.
Both are packed into one RGBA texture so the shader needs a single fetch, and the mask is then three lines: full ink inside the region the stroke started in, plus a fading allowance within slop pixels of any line, where slop is (1 β tidiness) Γ 64. One slider runs the range from a six-year-old with a fistful of Crayolas to a hand that stops dead at the boundary. Because the ink still pools against the mask, a tidy fill gets its wet edge right up against the linework.
Two details are load-bearing. The field texture is sampled NEAREST, emphatically: the red channel is an id, and linear filtering interpolates it, so a boundary between region 5 and region 9 ramps through 6, 7 and 8 and ink leaks into any neighbour the ramp crosses. And ids are eight bits, so a busy page keeps the 250 largest areas by size and drops the slivers back to unassigned, where only the slop allowance reaches.
Click on a line rather than inside a shape and the lookup spirals outward up to 12px for a real region. Clicking the boundary of the petal you meant should colour the petal.
The pages themselves are generated, not drawn β five kinds, grown from a seed: botanical stems that find their way up, mandalas, stained glass, moths, koi. The linework is emitted as filled ribbons rather than SVG strokes, because a stroke has one width for its whole length and reads as machine-drawn, while a ribbon can swell and starve along its run. That is about twenty lines of offsetting and it is most of what separates a hand-inked line from a vector one.
Nothing downstream knows any of that. The field is built from rasterised alpha, so an SVG dropped onto the canvas works exactly as well as a generated one.
The machine
Colouring it in by itself
Given the regions, the page can colour itself. Three things separate that from something that looks machine-filled, and none of them is a rendering trick.
Direction. People stroke along a shape, not along the screen. Every region gets a covariance matrix summed over its own pixels and a 2Γ2 eigen-decomposition, and is filled along the resulting principal axis, out to two standard deviations. For a leaf that is its length; for a petal, its radius from the boss. That one measurement does most of the work.
Build-up. The base fill is a single continuous boustrophedon that never lifts the pen β under MAX accumulation the rows and their turns agree, so the fill comes out flat no matter how much it doubles back. The shading is a separate stroke, which is exactly why it darkens. The engine's one rule about passes is doing the work; the colourist just has to know when to lift.
Coherence. One light direction for the whole page, and a small harmonious set of inks rather than the whole tray. Shading every shape on the same side is most of what makes a flat fill read as rendered. The shade pass is kept deliberately light β pressure 0.2 to 0.54 β because it is the same ink, so it multiplies toward that colour squared, which on a warm pale marker is mud rather than shadow. A real hand shades with a lighter touch for the same reason.
Deciding what is even worth colouring turned out to be the fiddly part. Anything running to the canvas edge is the margin outside the frame. Anything over 8% of the page is background: measured across all five page kinds, the largest thing anybody would call a shape is a glass pane at 3.8%, and the smallest background pocket is 14%, so eight sits in the gap with room on both sides. And the shape test is occupancy of the region's own fitted ellipse, not of its bounding box β because a leaf half split off by its midrib is a perfectly good blob that happens to lie diagonally, and by bounding box it scores 0.29 against a sprawling background gap's 0.43, which is precisely the wrong way round.
On a moth, mirrored shapes are paired by nearest reflection rather than by quantising position, because mirrored centroids land a pixel or two apart and a bucket edge falling between them would break the symmetry the pairing exists to preserve.
Motion
Nothing moves
The pages sway. Bending a stem sounds like it should mean redrawing the page, and it cannot: labelling the regions and running the distance transform costs about a tenth of a second, and animating the ink separately from the linework would be worse than not animating at all, because the colour would slide straight out of the shapes holding it.
So nothing moves. The page stays exactly where it was drawn, and the final present pass samples all of it β ink, linework, the stroke under your pen β through one displacement. They travel together because they are read together, and it costs one extra function call in a shader that was already running.
Each kind of page moves its own way. Wind grows from nothing at the foot of the sheet, because stems are rooted, and squares the height so the top carries almost all the travel. A mandala turns rigidly out to its own rim and lets go over blank paper, so the frame never comes with it. A moth folds its wings about the body axis; old glass ripples on two slow out-of-phase waves. Whatever the mode, a smoothstep holds the very edge of the sheet still β without it the frame swims along and the whole thing reads as a wobbling image rather than a drawing of something that moves.
The spin oscillates rather than accumulates, and it has to. Its falloff is 1 at the core and 0 past the rim, so the turn is differential by construction, and a rotation that grows with time makes that difference grow without limit until the falloff zone shears the drawing apart. It does, and it looks like the page was torn up.
The one debt this owes the rest of the app is a way back. Pointer events arrive in screen space; masks, regions and the ink buffer all live in page space. Because the shader defines the mapping as page = screen β displace(screen), the inverse is not an inversion at all β the CPU evaluates the same expression on the same input and gets the exact answer, not an approximation. That is why displace exists twice, once in TypeScript and once as GLSL, and why a sanity test pins the invariants of the copy.
You can colour a flower while it sways, and the ink lands where you aimed. That is the entire payoff of defining the motion as an offset instead of a transform.
The bill
What this approach costs
Building everything on one blend rule buys a marker that behaves and gives up most of what a paint program can do. Some of it is a fair trade and some of it is just the cost.
There is no wet-on-wet. Ink does not diffuse into ink. The rim is found geometrically from the stroke's own density, not simulated as fluid, so two colours laid against each other meet at a clean boundary instead of bleeding. Alcohol markers mostly behave that way; watercolour does not, and this engine could not pretend to be one.
A stroke belongs to exactly one region. The mask adopts whichever region the pen went down in, so you cannot fill two shapes with a single sweep β and dragging out of a shape gets you the slop allowance rather than the neighbour. This is right for colouring in and wrong for painting.
The page cannot change shape. Every field is built once from a rasterised page, so nothing can add a line, erase one, or animate the geometry rather than the sampling. The tenth of a second the rebuild costs is why, and it puts a hard edge around what motion can be.
Region ids are eight bits. Past 250 areas the smallest ones are dropped, and on a very busy page a sliver you wanted to colour may simply not be colourable. Widening the channel is possible; nothing on the page has needed it.
The auto-colourist is a heuristic wearing a lab coat. Elongation over 2.1 means leaf, under 90 samples means accent, above the midline means bloom. It is right often enough that a page reads as deliberately coloured, and it is wrong the moment you show it a shape that is not a flower.
The distance transform is Felzenszwalb & Huttenlocher's exact linear-time algorithm, and the layering arithmetic is nothing but the physical fact that translucent ink multiplies β both older and better than anything invented here.
The engine cannot make a mark that gets darker as you draw it. Everything it does well is downstream of that refusal.