|

Renderers

Swap one field and the same path becomes a different kind of mark.

public/lib/renderers/

The base class

A renderer turns a StrokeDef into a THREE.Object3D. One renderer instance can build any number of strokes — it carries style, never state about a particular stroke. That is why the renderer sits on the definition as a reference rather than being constructed per stroke.

StrokeRenderer is the base class. Subclasses implement build(def); dispose(object) is inherited and releases the geometry and materials of anything the renderer built. resampleSpine(def, samplesPerUnit) is exported alongside it and does the shared resampling, so tessellation behaves identically across the group rather than being reimplemented per renderer.

Common to every renderer

These hold for all renderers in public/lib/renderers/. A new renderer is checked against this list before it joins the group; if it cannot honour an item, the contract changes deliberately and every existing renderer changes with it.

Adaptive sampling

The number of spine samples is proportional to arc length, not to the control point count: clamp(round(length × samplesPerUnit), 8, 2048). A short stroke costs few triangles; a long one keeps the same visual smoothness instead of stretching a fixed budget over more distance. samplesPerUnit is exposed on each renderer so a demo can trade quality against cost at runtime.

Sampling is by arc length, so vertex density is uniform along the mark. Clustered control points do not produce clustered geometry.

Spacing is curvature-weighted: samples step uniformly in a measure that accumulates with turning as well as with arc length, so a corner earns extra vertices in proportion to how hard it turns, while straight runs keep the base spacing. Density is clamped to five times the base, so a cusp cannot demand unbounded vertices.

Samples sit at fixed arc-length steps from the start, not at even fractions of the whole. The difference only matters while a path is growing: with fractions, every added point moves every sample, and near a sharp corner a small sample shift swings the tangent, so the drawn vertices crawl. With fixed steps the settled part of the path keeps its samples, and only the tip changes.

Seeded randomness

Every renderer takes a seed. Anything random in a mark (its texture, its edge, its scatter) derives from that seed through hash functions, so the same seed with the same parameters reproduces the exact same result.

2D framing

The normal is the +90° rotation of the tangent in the XY plane. The tangent’s Z component is dropped before framing.

This matters because of how depth is used. A 2D stroke ramps its Z very slightly from start to end so that where it crosses itself, the later section sits over the earlier one. That ramp is ordering information, not shape — if it were fed into the frame, the ribbon would twist out of the plane by exactly as much as the ordering trick required. Dropping Z keeps the mark flat and camera-facing while the ordering still works.

Use an orthographic camera for 2D work. Depth is linear under orthographic projection, so offsets in the range of thousandths resolve exactly — no Z-fighting, and no need for polygonOffset.

The ramp decides crossings even when nothing looks different: with a flat opaque fill both outcomes render identically. Its job is to make the answer determined before the fill stops being flat and opaque.

Independent left and right width

Offsets always come from widthLeftAt(t) and widthRightAt(t) separately. No renderer assumes symmetry, including in its caps and joins.

UV convention

u runs 0 → 1 along the stroke by arc length. v runs 0 on the left edge to 1 on the right. Caps continue the same frame: u is 0 across the whole start cap and 1 across the whole end cap, while v sweeps 0 → 1 from the left offset round to the right.

Holding this convention across the group means a texture, gradient, or shader written for one renderer keeps its meaning under another.

Reported stats

The returned object carries userData.samples (the resampled spine positions) and userData.stats with sampleCount, vertexCount, triangleCount, and length. Demos use these to show what the tessellation is actually doing rather than asserting it in prose.

RibbonStrokeRenderer

A flat ribbon along the spine, closed at both ends by one of three caps. Two triangles per segment, plus whatever the cap adds.

The body is identical whichever cap is chosen. That is why the cap is an option on one renderer rather than three renderers repeating the ribbon build.

  • cap'rounded', 'square', or 'ragged'. Default 'rounded'.
  • color — fill color, or the start color when gradient is set. Default '#1a1a1a'.
  • gradient — optional end color. When set, the fill interpolates from color to it, written as a vertex color attribute. Default null (flat).
  • gradientAxis'along' runs the gradient on u, following the spine; 'across' runs it on v, left rail to right rail. Default 'along'.
  • opacity — below 1 switches the material to transparent. Default 1.
  • samplesPerUnit — spine samples per world unit of arc length. Default 120.
  • capSegmentsPerUnit — rounded-cap arc segments per world unit of radius, clamped to 6 through 32. Default 260.

Square, rounded, ragged

Square

The ribbon stops at its last sample and no cap geometry is added.

Rounded

A triangle fan centered on the last spine point, sweeping from the left offset point, round through the outward tangent, to the right offset point:

d(φ) = n·cos φ ± t·sin φ
r(φ) = wL + (wR − wL)·φ/π

φ runs 0 to π, with the sign negative at the start cap and positive at the end. At φ=0 the fan point lands exactly on the left ribbon edge with radius wL, and at φ=π on the right edge with radius wR, so the cap and the ribbon share an edge with no seam.

Interpolating the radius rather than using a fixed one is what makes an asymmetric stroke end correctly. A circle of radius max(wL, wR) would overshoot the narrow side and a circle of radius min(wL, wR) would cut into the wide one. The interpolated sweep is a half-ellipse that meets both edges.

Cap resolution scales with radius, so a hairline does not pay for 32 triangles it cannot show and a broad mark does not end in a visible polygon. The lower clamp at 6 keeps very thin strokes from degenerating into a wedge.

Ragged

A quad strip from the ribbon’s end edge out to a torn outer edge, 20 segments wide. The inner row sits on the end edge so the cap seals against the body. The outer row is pushed past the end by a per-vertex depth between 0.05 and 1 times the average half-width.

The depth comes from two sine samples of the vertex index offset by the stroke’s seed, not from Math.random. The same seed always tears the same way, so rebuilding at a different density or color redraws the identical end. The two caps of one stroke use the seed scaled differently, so a stroke does not end symmetrically.

Shading along the stroke

Setting gradient interpolates the fill from color to gradient on the axis gradientAxis picks: u for ‘along’, v for ‘across’. Because u and v are the parameters the UV convention defines, an ‘along’ gradient follows the drawn order of the mark (caps take the color of the end they close, since a cap holds u constant), and an ‘across’ gradient runs from the left rail to the right through every cap.

Not yet handled

Self-intersection at sharp turns. Where the curvature radius drops below the local width, the two ribbon edges cross and the mark folds back on itself. This is not hypothetical. A path with a cusp, where speed passes through zero, folds into a visible notch every time. Nothing in the renderer detects it, so for now it is a constraint on the paths handed in rather than something the renderer absorbs.

ShaderStrokeRenderer

Base for renderers that shade the ribbon with their own fragment shader. Two things separate it from RibbonStrokeRenderer. The geometry can be built wider than the mark it draws, and the shader is given enough information to find the visual edge inside that margin.

Effects that reach past the stroke, a watercolor bleed or a dragged smear, need somewhere to land, and a fragment can only be shaded where a triangle covers it.

The cap is carved in the shader rather than built as geometry. Each end gets a plain quad running past the last sample, and aBeyond tells the shader how far past the end a fragment sits, in the same half-width units as aCross. capDistance() then returns the distance from the mark’s centre line where 1.0 is the boundary, closing the shape at the ends the same way it closes at the sides. A square cap needs no room past the end, so it gets no quad at all.

singleCoverage shades each pixel once per mark: where the ribbon overlaps itself, a translucent material would composite twice and darken into creases. The mark is flagged for the coverage layer, which renders it alone into an offscreen target with MAX blending, so any number of coverings leaves the strongest single one, and then composites the target into the canvas exactly once. There are no thresholds, so there is nothing for a seam to form along; the cost is one screen-sized composite per flagged mark per frame, only while the mark is live, since baking flattens it. Split pieces layer over each other as separate marks. The dry media turn it on.

Every subclass shader receives:

  • vUv — u along the stroke by arc length, v across the inflated width.
  • vCross — signed distance across the width in visual-edge units. |vCross| <= 1 is inside the mark.
  • vTangent, vWorld — unit tangent and world position.
  • screenUv() — the fragment's position in the frame, for reading the background.
  • tangentUv() — the stroke's own direction as a unit step in screen UV space.
  • capDistance() — distance from the centre line where 1.0 is the boundary, with the cap style already applied. Effects measure against this rather than abs(vCross).
  • uSeed, uLength, uWidth, plus fbm and hash helpers.

BrushStrokeRenderer

Bristle streaks along the mark, an eroded edge, and dry patches where the brush ran out. The stroke carries two colors rather than one.

One noise field draws the bristles, pushes the edge, and decides which pigment shows. Separate fields would let the streaks, the edge and the color disagree, and the mark would stop reading as one gesture.

The eroded edge is translucent, so the mark renders through the coverage layer by default (singleCoverage, on): a self-overlapping gesture keeps single coverage instead of darkening where it crosses itself.

  • colorA / colorB — the two pigments.
  • bristles — lanes across the width. Default 26.
  • streak — how far lanes stretch along the mark. Default 5.0.
  • rough — edge erosion depth. Default 0.35.
  • dry — how much of the mark drops out. Default 0.30.

DryMediaStrokeRenderer

Pencil, charcoal, and pastel are one renderer at different settings. Paper tooth is a screen-space noise, because it belongs to the paper rather than to the stroke, and coverage is the tooth thresholded, so a light line breaks into speckle instead of fading evenly. A low-frequency pressure noise along the stroke scales both the darkness and the drawn width.

What separates the media is scale: tooth is in pixels, and softness and edge set the falloff and the wobble of the boundary. Takes color, grain, tooth, pressure, softness, edge, and opacity.

With a colors list (up to four are used) the media turn multicolor, by blend: 'along' shifts the color along the stroke, cycling the list with arc length and blending at the joins, like a pencil with a rainbow lead; 'grain' colors each cell of the paper tooth from the list, with a slight per-cell value jitter, so the flecks read as mixed pigment.

StrokeHalo

A blurred silhouette of one or more strokes, presented as a tinted plane. Not a renderer: it takes finished meshes, renders them into a private low-resolution target, blurs there, and hands back a plane to place in the scene. Offset and dark beneath a stroke the plane is a drop shadow; wide and bright around one, a glow.

Blurring a silhouette is the second design. Expanding the stroke’s own geometry outward was the first, and it folds wherever the reach exceeds the curvature radius, which every soft shadow on a wavy path does. Takes color, opacity, blur, downsample, and additive; update() runs before the frame, from the stage’s pre-render hook.

HaloStrokeRenderer

A ribbon with a soft silhouette around it, built as one mark. The silhouette is a shader falloff on inflated geometry rather than StrokeHalo’s blurred target, so the mark builds once like any other renderer’s and needs no per-frame pass. Two modes: shadow puts the silhouette dark and offset toward the lower right, so the mark reads as floating over the canvas; glow puts it wide, bright, and centered. Takes mode, color (the ribbon), haloColor, opacity, and spread, the silhouette’s reach in widths past the mark.

The falloff folds with the path where the reach exceeds the curvature radius, so the silhouette renders through the coverage layer: overlaps keep single coverage instead of stacking into creases. The ribbon renders through the same layer, because layered marks draw after the main pass in depth order among themselves, and only another layered mark can composite above the silhouette.

DebossStrokeRenderer

A flat fill with an inner shadow, so the stroke reads as cut out of the paper. A band inside the boundary darkens where its outward direction faces a fixed light, the shadow the lit rim of a cutout casts onto its floor. There is no highlight: a hole has nothing to catch the light with. The outward direction comes from the stroke frame, so the ends shade the same way the sides do. Takes color, bevel, amount, and angle.

Background samplers

Three renderers that read what is underneath and move it. All take a background texture and sample it by screen position, because a stroke does not know what is under it and asking in pixels is the only question that has an answer.

WatercolorStrokeRenderer

Reads a pre-blurred copy of the background rather than gathering a neighbourhood per fragment. A thirty pixel radius costs hundreds of taps on every covered fragment, while the same result is one texel read against a copy blurred once for the whole frame.

Takes blurred alongside background, plus pigment, rim, granulation, edge and bleed. The rim darkens just inside the boundary, where water dries back and leaves pigment.

bleed picks the background up through taps displaced by a 2D noise field, unrelated to the stroke’s direction, so what lies underneath seeps into the wash in blotches rather than streaks. Wetter blotches bend the taps farther, and some taps read the sharp background, so edges underneath grow warped tendrils instead of staying put. Where the blotch noise runs wet, the pigment thins and more background shows through.

SmearStrokeRenderer

Walks backward along the stroke’s own direction in screen space and averages what it finds, so the background is streaked the way the mark travelled. drag sets the reach in pixels and variation how much it differs from lane to lane.

The variation is what stops the result reading as motion blur. A real brush drags hard under some bristles and barely at all under others.

WetBrushStrokeRenderer

The drag runs first, over a mix of the sharp and softened background set by wet, and the wash then tints what the drag produced. Blending two finished results would wash out the streaks, because an even blur and a directional smear cancel each other where they disagree.

Height field materials

HeightFieldStrokeRenderer gives the mark a height built from distance to the edge, which rounds the cross-section into a bead, and noise stretched along the path, which reads as liquid dragged by the brush. Subclasses shade the resulting normal.

The bead is parabolic, not a hemisphere. A hemisphere's slope runs to infinity at the rim, which turns every fragment near the edge into noise once a finite difference is taken across it.

Height is measured in units of the half-width, and so is the across coordinate, so the gradient is dimensionless and needs no correction factor at any width.

Normals come from finite differences in the stroke's own frame, not from dFdx. Screen-space derivatives break down along the silhouette, which is exactly where the bead turns over fastest.

ChromeStrokeRenderer

An assumed environment of two tones split at a horizon. A mirror shows mostly a bright sky and a dark ground, and the eye reads the boundary sweeping across a curved surface as metal.

MirrorStrokeRenderer

The reflected direction is used as a screen-space offset into the background rather than as a ray into a cube map. It is not a correct reflection and cannot show anything outside the frame, but for a flat mark lying on a surface the difference is invisible. contrast is pushed after sampling, since a mirror does not return a muted copy.

GlassStrokeRenderer

Refraction offsets the lookup along the normal, so the bead acts as a lens and displaces most where it tilts hardest. Reflection is mixed in by a Fresnel term, which is what stops the result reading as a smudge.

OilStrokeRenderer

Thick paint: the smear’s drag under a dominant paint color, lit through the height field. The dragged background is mixed under color at the paint ratio, thinner where the height field dips, and the relief is lit with diffuse and specular terms from a fixed light.

The drag and the ridges share one lane noise, so the paint that moved furthest also sits highest. Coverage varies by the same lanes: loaded lanes lay solid paint, dug lanes carry the dragged background through nearly bare. Takes background, drag, paint, gloss, and shininess, plus the height field options.

Shaped strokes

Three renderers whose outline is a signed-distance field evaluated per fragment, rather than a thickened path. The geometry is only a canvas wide enough to cover the shape.

CloudStrokeRenderer

Large discs scattered along the stroke, drawn as one union. Size, spacing, and throw direction are all seeded per disc, and a union has one well-defined outline whatever the placement, so the boundary never crosses itself. Every third disc stays near the spine at full radius, so the chain cannot break. Takes color, blob, and offset, both in half-widths.

RoundedSquareStrokeRenderer

Rounded squares on a fixed grid, stamped from the spine like the pixel stroke and drawn as a smooth minimum over every cell’s rounded-box distance. Adding a square reshapes the outline around it instead of overlapping it. Takes color, cell, corner, and blend.

SpikeStrokeRenderer

The boundary pushed outward by a power of a triangle wave. The corner at each tip survives any power while the valley’s derivative goes to zero, so the tips stay sharp and the valleys stay rounded. Each spike hashes its own height and lean from its index, spacing is warped by a low-frequency noise, and the two sides hash independently, so the edges do not mirror. spikes is a rate that scales with the stroke’s width, so spikes-per-width holds and a wide stroke is not left with a few spikes spaced far apart. Takes color, spikes, amp, and sharp.

Pattern strokes

PatternStrokeRenderer rebuilds the mark as many small elements filling the stroke’s band. Three modes fill the band with rows: dashes (short rounded strokes, about 20 pixels each, laid along the spine and stepped by about three quarters of their own length), dots (uneven discs of 10 to 15 pixels, wobbled by seeded harmonics of the angle so each reads as a circle drawn by hand), and strips (longer and wider than the dashes, with more rotation and placement jitter and tighter rows, so neighbors sometimes overlap). Three more sprout small strokes from the spine to both sides, swept from the local direction by angle, their length following the local width: feather (each left-right pair takes color and the next pair colorB, like a feather’s bands), leaves (tapered leaf shapes with a seeded bow, sized randomly from well below the width to well past it, textured like the brush: bristle streaks run the blade’s length, erode its edge, streak its color, and drop dry patches), and fringe (thinner and denser, color on one side of the spine and colorB on the other). Takes mode, color, colorB, angle, and size, a scale on the elements’ built-in pixel sizes. Each element carries a slight lightness variation of its color.

Elements sit on rows across the width, each row walking the arc from the start with its own seeded random sequence, so a growing stroke adds elements at the tip without reshuffling the ones already placed. Row offsets scale with the local width, so the fill follows the taper.

WetPatternStrokeRenderer keeps the placement and swaps the elements’ surface for wet marks that drag the background. A dash or strip walks backward along its own direction in screen space and averages what it finds, dragging harder toward its tail; a dot pulls the surrounding color inward, so it reads as a blot. Takes background, drag (reach in pixels), and pigment (the ratio of the element’s color over the drag) alongside the base parameters.

AroundStrokeRenderer

Paths derived from the drawn path, each drawn with the brush renderer. Three modes: spiral (the tip circles while its center moves along the path, one continuous coil), entangled (copies of the path offset by seeded low-frequency waves, their endpoints pulled back toward the base), and scattered (short strokes copying small segments of the path, moved sideways by a seeded offset). The count of sub-strokes and their offset from the base both follow the width, so a heavier stroke spreads further and splits into more parts rather than only thickening. The width is capped at 0.03 world units for the derivation, the range the formulas are calibrated for; past it the counts grow without bound. Takes mode, colorA, colorB (alternated between sub-strokes), reach (how far the derived paths stray, in widths), and turns for the spiral. The generators are documented on the Path Effects page.

Blob renderers

Renderers that fill a closed region rather than a stroke. The geometry is only a quad over the contour’s bounds; the shape lives in the fragment shader as the signed distance to the contour polygon, so a renderer can push the boundary, texture the interior, or shade it as a surface without new geometry. BlobRenderer is the base; contours come from blobOutline or the endpoint shapes on the Path Effects page. The prelude also provides uvAt(p), the background uv of an arbitrary world point, so a shader can read the canvas somewhere other than under its own fragment.

The renderers that shade a height field build it from the distance to the edge, and the edge dome’s depth caps at the contour’s inradius, measured once at build. Without the cap, a region narrower than the dome would carry the distance field’s crease along its middle into the lighting as a sharp ridge; with it, the slopes flatten before they meet.

  • ShapedBlobRenderer — a flat fill whose boundary grows spikes (an integer count around the loop, so the profile meets itself in a valley) and bumps (a noise of world position, so no seam). Each spike hashes its height and lean. Spikes only stick out: every valley returns to the base contour, so the fill always covers its region and the seam meets itself at zero. With colorB and two world points (gradientFrom, gradientTo) the fill becomes a linear gradient between them.
  • SlitScanBlobRenderer — a fill colored by slit-scanning the canvas: the background is read only along one sampling line, and every fragment takes the sample at its projection onto that line, so each sample stretches into a band orthogonal to it. The result mixes with a flat base color. Takes color, background, mix, linePoint, lineAngle; slitLineFromEnds(a, b, seed) rolls a seeded line through the endpoints' midpoint.
  • PaintBlobRenderer — two pigments mixed in smooth patches, with relief from a quintic edge dome (no corner in the shading at either end) plus low and high noise bands, the high one foldable into sharp ridges; dry erodes the fill into dense tooth speckle, split sharpens the pigment mix to a hard boundary shaped by low-frequency noise, and rag tears the edge on a fine noise. knife shapes the fill as palette-knife work: flat patches, each with its own drag direction and striations along it, meeting at hard steps, under a rim that varies from tall and steep to scraped flat, and an edge of straight cut segments. Takes colorB, fade, relief, swell, ridged, gloss, edgeSoft, dry, split, rag, knife.
  • WashBlobRenderer — a watercolor fill over the background, dragged along a wandering flow. The paint meets the background as a min (layered pigment) and a mix (covering body), balanced by wet. bristle grows brush marks at the edge along directions that wander with position. Takes pigment, feather, rim, flow, wet, bristle.
  • MaterialBlobRenderer — metal takes a ridged relief (broad swell folded with sharp creases) reflecting a chrome environment of hard-edged light bands over a dark ground; smooth glass keeps a low-frequency wave surface and bends the background; faceted glass takes one random tilt per triangle of a noise-warped lattice. Takes mode, relief, bend, facets.
  • StoneBlobRenderer — the blob as stone. Rock folds its noise into creases with mottled color patches, and its boundary breaks on the same crags; marble runs thin noise-warped veins over a near-white glossy ground; sand jitters the normal per pixel from a hashed grid, with occasional glints, and its edge dissolves into loose grains. Takes mode, colorB, relief.

3D strokes

Strokes built from 3D shapes around the spine, lit and baked onto the canvas like any other mark. Stroke3DRenderer is the base: the spine gains depth from a seeded wave whose wavelength tracks the stroke’s width (so a wide tube snakes as gently as a thin one rather than rippling faster than it is thick), so the mark reads as an object lying over the canvas, and the shape rotates around the spine by an angle keyed to the distance from the stroke’s end, so a growing stroke visibly turns while it is drawn. A seeded offset also pushes the shape slightly off the spine, in a direction that rotates with the same angle, so the mark orbits the spine as well as turning. The rotation is the one deliberate exception to prefix stability; the depth wave and the offset’s amplitude key on distance from the start and hold still. The mark floats about 100 CSS pixels over the canvas, lit from the upper left (60 degrees down from the screen’s up axis, 30 degrees to the left of the camera). At a bend tighter than the tube is wide, the ring’s reach toward the bend’s center is clamped, so adjacent rings cannot pass through each other and fold the surface. Both members take depth, twist, zBase, and wander (the offset’s amplitude).

  • TubeStrokeRenderer — a tube closed by rounded caps, in three looks: candy (diagonal stripes from a color list, wrapping with the tube's angle, shaded like glossy plastic: the shadow side and the rim fall back to a saturated deep version of the stripe color, under a tight white highlight), wobble (the radius swells and thins on a seeded wave that advances by arc measured in widths, so the swell keeps its shape as the tube grows, and the color runs a gradient driven by the wobble and the position along the stroke), and metal (the current canvas is the environment map: the reflected direction offsets a lookup into it). Takes mode, colors, colorA, colorB, tint, background, stripes, wobbleFreq, bend.
  • TetrahedronStrokeRenderer — a chain of flat-shaded 3D tetrahedrons, each taking a random size and orientation from the stroke's seed; the step between neighbors is their two circumradii plus spacing of their sum, so they cannot touch. facets keeps the base color's hue while lightness and chroma vary per face; colors gives every face one flat random color from the colors list; metal reflects the canvas, broken per face by the flat normals. Takes mode, colorA, colors, tint, background, spacing, bend.

Geometry renderers

Three renderers that keep the path, the width and the resampling and throw away the ribbon. The same StrokeDef drives all of them.

These have no fragment shader to carve a cap out of, so they close their ends by extending vertices along the tangent by capExtent(cap, lateral). For a rounded cap that profile is a circle, so the outer lanes and facets stop short of the middle ones and the rounded end is built from the stroke’s own parts.

All three place colors with a seeded generator rather than Math.random. A drawing that looks random has to redraw identically, or a change to one control could never be compared against the frame before it.

PixelStrokeRenderer

Square cells on a fixed grid, stamped from the spine outward rather than tested from a grid inward. A grid over the bounding box would test far more empty cells than filled ones for a thin diagonal mark. A Set keyed by grid coordinate keeps overlapping stamps from emitting a cell twice. cell sets the size and jitter the chance a reachable cell is dropped.

PolygonStrokeRenderer

Large flat triangles from a deliberately coarse resample. jitter displaces vertices across the width, which is what stops the result reading as a low-resolution ribbon: the silhouette has to break, not just the shading.

LineStrokeRenderer

Parallel lines with gaps, each lane its own thin ribbon offset across the width so the lanes follow the curve. Clipping gaps out of a solid mark would leave them running straight while the stroke turned. lanes sets the count and duty the fraction of each slot that is drawn.