The Reliable Line: Hash, Noise, and Envelopes Join the Stdlib

Part 3 of 3 in our series on variable-width strokes.

Series: Variable-Width Strokes

  1. The Swelling Line — variableOffset and compoundVariableOffset
  2. The Shape of a Stroke — envelopes, bulges, and lambdas
  3. The Reliable Line (this post) — hash, noise, and envelopes join the stdlib

The Shape of a Stroke closed on a promise: a glow whose fuzz was designed — a hash of the stop index instead of randomRange — so that recompiling the program reproduced it exactly. To get there it had to define two helper functions by hand: bulge, a raised-cosine envelope kernel, and hash01, the shader-folklore one-liner that turns an integer into a repeatable "random" number.

Those helpers did their job so well that they've stopped being helpers. As of this release, the whole toolkit is in the standard library: hash01, its signed sibling hash11, hashRange, the envelope kernel bump, smoothstep, a callable easing trio — and two functions the hand-rolled versions couldn't reach: noise() and noise2(), which trade per-index jitter for continuous texture.

This post does three things: explains why the built-in hash is deliberately not the one from part 2, rebuilds the glow shorter than ever, and then pushes past jitter into noise fields.

Why not the folklore hash?

Part 2's hash is a classic for a reason — one line, no dependencies, instantly random-looking:

fn hash01(i) {
  let s = sin(i * 12.9898) * 43758.5453;
  return s - floor(s);
}

But it has a quiet flaw for a language that promises byte-identical recompiles: it leans on sin, and the ECMAScript standard does not pin Math.sin to the bit. Engines are free to differ in the last decimal place — and multiplying by 43758.5453 amplifies that last bit into a visibly different fraction. Your glow is reproducible on your machine. Compile the same program in a different browser engine, and "byte-identical" quietly becomes "almost identical". It also degrades at large inputs, where float precision starts eating the fractional bits the hash lives on.

The built-in hash01 takes a different route: integer bit-mixing (a lowbias32 finalizer), built exclusively from operations the standard specifies exactly — Math.imul, bit operations, IEEE arithmetic. No trigonometry anywhere. The result is a hash that returns the identical value for identical arguments on every machine and JavaScript engine: CLI, playground, and VS Code preview agree, today and on every future recompile.

// viewBox="0 0 400 230" //-- Two hashes, same job: the sin-fract folklore hash from "The Shape of //-- a Stroke" (top) //-- against the stdlib hash01 (bottom), sampled at the same 72 indices. //-- Both scatter convincingly -- the difference is invisible here, and //-- that's the point. hash01 gives up nothing visually, and in exchange //-- every operation inside it is bit-specified by the ECMAScript standard, //-- so the bottom row is byte-identical on every JS engine. The top row is //-- only *almost*: Math.sin may differ in the last bit between engines. define ViewBox(0, 0, 400, 230); fn sinFract(i) { let s = sin(i * 12.9898) * 43758.5453; return s - floor(s); } let folk = PathLayer('sin-fract') ${ fill: oklch(0.6 0.15 20); stroke: none; opacity: 0.85; }; let builtin = PathLayer('hash01') ${ fill: oklch(0.55 0.18 260); stroke: none; opacity: 0.85; }; folk.apply { for (i in 0..71) { circle(calc(22 + i * 4.8), calc(38 + sinFract(i) * 56), 1.6); } } builtin.apply { for (i in 0..71) { circle(calc(22 + i * 4.8), calc(148 + hash01(i) * 56), 1.6); } } let labels = TextLayer('labels') ${ font-family: system-ui, sans-serif; font-size: 11; fill: #888; text-anchor: start; }; labels.apply { text(22, 28)`fn sinFract(i) — the folklore hash (part 2)` text(22, 138)`hash01(i) — built in, bit-exact on every engine` } //-- Value-axis ticks: vertical position IS the hash output, 0 at the top //-- of each band, 1 at the bottom. let ticks = TextLayer('value-ticks') ${ font-family: system-ui, sans-serif; font-size: 9; fill: #666; text-anchor: start; }; ticks.apply { text(372, 41)`0` text(372, 97)`1` text(372, 151)`0` text(372, 207)`1` } let scene = GroupLayer('scene') ${}; scene.append(folk, builtin, labels, ticks); 72 indices through both hashes. Visually interchangeable — the difference is contractual, not aesthetic. The bottom row is bit-specified on every engine; the top row inherits Math.sin's engine-dependence.

Two contracts worth knowing before you use it:

  • It hashes integers. hash01(0.9) equals hash01(0) — inputs truncate to 32-bit integers. For a smooth function of a continuous input, that's what noise() is for (below).
  • The seed is an argument. "The Shape of a Stroke" smuggled a per-layer stream through prime arithmetic — hash01(i * 7 + haloIndex * 1013). The built-in makes the stream a parameter: hash01(i, haloIndex). Two seeds are two genuinely independent sequences, not shifted copies.

(One naming note: the sample above calls its folklore hash sinFract, not hash01, for a load-bearing reason — a user-defined fn hash01 would shadow the built-in, which is exactly the mechanism that keeps part 2's published samples byte-stable. More on that below.)

And randomRange users get a drop-in — same call shape, an index in front:

let r = randomRange(4, 12);    // different every compile
let r = hashRange(i, 4, 12);   // pinned to index i, forever

The glow, third build, shortest yet

Here is part 2's finale rebuilt on the stdlib. Both helper fns are gone. bump(t, center, spread) is the raised cosine — term-for-term, the same formula bulge computed — and the jitter collapses to a single call: hash11(i, haloIndex) returns signed values in [-1, 1), so "±20% wobble, per-layer stream" is just 1 + hash11(i, haloIndex) * 0.2.

// viewBox="0 60 400 140" //-- Part 2's deterministic glow, third build, shortest yet. The two helper //-- fns are gone: bump() replaces fn bulge (term-for-term -- same raised //-- cosine), and hash11() replaces fn hash01 plus the *2-1 remap. The //-- per-layer stream is no longer smuggled through prime arithmetic //-- (i * 7 + haloIndex * 1013); the layer index is just the seed argument. define ViewBox(0, 60, 400, 140); let spine = @{ c 80 -100 160 100 240 0 }; let px = 80; let py = 130; let steps = 48; let taperCap = Cap.tapered(2, CurveContinuity.G0); let base = oklch(0.72 0.14 20); for (haloIndex in 16..1) { //-- Signed +/-20% jitter, stream selected by the captured layer index. let jitter = {|i| return 1 + hash11(i, haloIndex) * 0.2; }; let width1 = {|t| return 0.15 * haloIndex + 0.6 * haloIndex * bump(t, 0.35, 0.3) + 0.35 * haloIndex * pow(bump(t, 0.78, 0.18), 2); }; let width2 = {|t| return -0.15 * haloIndex - 0.3 * haloIndex * bump(t, 0.55, 0.4); }; let mk = {|vo, pb| vo.startCap(taperCap); for (i in 0..steps) { let t = i / steps; vo.stop(t, width1(t) * jitter(i * 2), CurveContinuity.G1, width2(t) * jitter(i * 2 + 1), CurveContinuity.G1); } vo.endCap(taperCap); }; let halo = spine.compoundVariableOffset() << mk; let haloColor = base.hueShift(calc(haloIndex * -6)); let haloLayer = PathLayer(`halo-${haloIndex}`) ${ fill: haloColor; stroke: none; opacity: 0.25; }; haloLayer.apply { M calc(px + halo.anchor.x) calc(py + halo.anchor.y) halo.draw(); } } Sixteen compound-offset layers, zero helper fns. bump() replaces bulge, hash11(i, haloIndex) replaces the hash-plus-remap, and the per-layer salt became the seed argument. The lambdas still capture haloIndex — that part 'The Shape of a Stroke' got right the first time. The builder mk is a named lambda, so it's applied with << (see the worker rules in the docs); samples 3 and 4 below pass their builders as literal blocks instead.

The character of the glow is unchanged; the individual sparkle differs, because integer mixing lands on different values than sin-fract. That's the trade made consciously: the published samples in "The Shape of a Stroke" keep their exact pixels — user-defined fn hash01 shadows the built-in, so old programs are untouched — while new programs get the portable hash.

One honest caveat: in this glow, the jitter is the bit-pinned part. The envelope isn't — bump uses cosine and the layer widths flow through pow, both implementation-approximated, so the shape is reproducible on any one engine rather than byte-identical across all of them. The randomness is the part that used to drift, and that's the part that's now pinned.

The envelope vocabulary, built in

"The Shape of a Stroke" spent a whole section defining envelope shapes by hand — tent, smoothstep, raised cosine — to argue that the raised cosine enters and leaves its bulge with zero slope. That vocabulary is now one call each, and it composes:

// viewBox="0 0 400 320" //-- The envelope vocabulary, built in. Part 2 defined win/tentEnv/ //-- smoothEnv/cosEnv by hand; each shape is now one stdlib call. Three //-- curves, three idioms: //-- hill: bump(t, 0.5, 0.35) the raised cosine //-- plateau: smoothstep(0.1, 0.3, t) * smoothstep(0.9, 0.7, t) //-- -- two opposing smoothsteps multiply into a flat-topped //-- window (rise 0.1->0.3, fall 0.7->0.9) //-- ramp: easeInOut(t) the Easing enum, //-- now callable //-- Below, the bump-shaped stroke on a straight spine: on a straight //-- spine the silhouette IS the envelope. define ViewBox(0, 0, 400, 320); //-- Plot geometry ----------------------------------------------------------- let plotX = 80; let plotW = 240; let plotY = 150; let plotH = 80; let samples = 48; //-- Envelopes as lambdas over the plot domain t in [0, 1]. let hill = {|t| return bump(t, 0.5, 0.35); }; let plateau = {|t| return smoothstep(0.1, 0.3, t) * smoothstep(0.9, 0.7, t); }; let ramp = {|t| return easeInOut(t); }; fn plotCurve(curveLayer, envFn) { curveLayer.apply { M plotX calc(plotY - plotH * envFn(0)) for (i in 1..samples) { let t = i / samples; L calc(plotX + plotW * t) calc(plotY - plotH * envFn(t)) } } } let axis = PathLayer('axis') ${ fill: none; stroke: #bbb; stroke-width: 1; }; axis.apply { M plotX plotY L calc(plotX + plotW) plotY } //-- Reference line at value 1.0 — bump's peak and the plateau's flat top //-- both touch it exactly, which is what makes them composable kernels. let unitLine = PathLayer('unit-line') ${ fill: none; stroke: #555; stroke-width: 0.75; stroke-dasharray: 2 4; }; unitLine.apply { M plotX calc(plotY - plotH) L calc(plotX + plotW) calc(plotY - plotH) } let unitLabel = TextLayer('unit-label') ${ font-family: system-ui, sans-serif; font-size: 10; fill: #777; text-anchor: start; }; unitLabel.apply { text(56, 73)`1.0` } let hillLayer = PathLayer('env-bump') ${ fill: none; stroke: oklch(0.55 0.18 260); stroke-width: 2; }; let plateauLayer = PathLayer('env-plateau') ${ fill: none; stroke: oklch(0.62 0.16 160); stroke-width: 1.25; stroke-dasharray: 2 4; }; let rampLayer = PathLayer('env-ease') ${ fill: none; stroke: #b0b0b0; stroke-width: 1.25; stroke-dasharray: 5 3; }; plotCurve(hillLayer, hill); plotCurve(plateauLayer, plateau); plotCurve(rampLayer, ramp); //-- The bump-shaped stroke on a straight spine. let spine = @{ l 240 0 }; let ribbon = spine.compoundVariableOffset() {|vo, pb| vo.startCap(Cap.tapered(2, CurveContinuity.G0)); for (i in 0..samples) { let t = i / samples; let e = bump(t, 0.5, 0.35); vo.stop(t, 0.75 + 13 * e, CurveContinuity.G1, -0.75 - 13 * e, CurveContinuity.G1); } vo.endCap(Cap.tapered(2, CurveContinuity.G0)); }; let strokeLayer = PathLayer('shaped-stroke') ${ fill: oklch(0.55 0.18 260); stroke: none; opacity: 0.9; }; strokeLayer.apply { M calc(plotX + ribbon.anchor.x) calc(240 + ribbon.anchor.y) ribbon.draw(); } let labels = TextLayer('labels') ${ font-family: system-ui, sans-serif; font-size: 11; fill: #888; text-anchor: start; }; labels.apply { text(78, 28)`stdlib envelopes` text(80, 172)`t=0` text(190, 172)`t=0.5` text(304, 172)`t=1` text(78, 292)`bump(t, 0.5, 0.35) stroke (straight spine)` } //-- Legend: line samples drawn with each curve's real stroke + dashes. let swatchHill = PathLayer('swatch-bump') ${ fill: none; stroke: oklch(0.55 0.18 260); stroke-width: 2; }; swatchHill.apply { M 252 27 L 268 27 } let legendHill = TextLayer('legend-bump') ${ font-family: system-ui, sans-serif; font-size: 10; text-anchor: start; fill: oklch(0.72 0.15 260); }; legendHill.apply { text(274, 30)`bump()` } let swatchPlateau = PathLayer('swatch-plateau') ${ fill: none; stroke: oklch(0.62 0.16 160); stroke-width: 1.25; stroke-dasharray: 2 4; }; swatchPlateau.apply { M 252 41 L 268 41 } let legendPlateau = TextLayer('legend-plateau') ${ font-family: system-ui, sans-serif; font-size: 10; text-anchor: start; fill: oklch(0.62 0.16 160); }; legendPlateau.apply { text(274, 44)`smoothstep window` } let swatchRamp = PathLayer('swatch-ease') ${ fill: none; stroke: #b0b0b0; stroke-width: 1.25; stroke-dasharray: 5 3; }; swatchRamp.apply { M 252 55 L 268 55 } let legendRamp = TextLayer('legend-ease') ${ font-family: system-ui, sans-serif; font-size: 10; text-anchor: start; fill: #b0b0b0; }; legendRamp.apply { text(274, 58)`easeInOut()` } let scene = GroupLayer('scene') ${}; scene.append(axis, unitLine, unitLabel, hillLayer, plateauLayer, rampLayer, strokeLayer, labels, swatchHill, legendHill, swatchPlateau, legendPlateau, swatchRamp, legendRamp); Three envelope idioms, each one stdlib call: bump() is the raised-cosine hill; two opposing smoothsteps multiply into a flat-topped plateau; easeInOut() is the Easing enum, now callable. Below: the bump-shaped stroke on a straight spine — the silhouette is the envelope.

The plateau idiom deserves a highlight: smoothstep(0.1, 0.3, t) * smoothstep(0.9, 0.7, t) — a rising ease times a falling ease (note the reversed edges) — is the standard way to build a smooth window with a flat top, and it's now a one-liner.

The easing trio (easeIn, easeOut, easeInOut) are the callable forms of the Easing enum you already use for gradient easing, with the same quadratic formulas — the curve easeInOut(t) traces is the curve the gradient renderer applies for Easing.EaseInOut. The full mapping is in the stdlib docs.

From jitter to texture: noise()

Everything so far assigns each stop its own unrelated value. That's what jitter is — and it's also its limit: adjacent stops can't cooperate, so the edge can shimmer but never undulate.

noise(x, seed?) is the continuous upgrade. It equals hash01 exactly at every integer, and blends smoothly in between (value noise with a smoothstep fade — zero slope at every lattice point). One knob controls the whole character of the result: scale the input, and you scale the frequency.

// viewBox="0 0 400 250" //-- From jitter to texture. hash01 gives every stop its own independent //-- value; noise() drives the width with a CONTINUOUS wobble instead, so //-- adjacent stops agree and the edge undulates organically. One knob //-- controls the character: the input scale is the frequency. Same seed, //-- three frequencies -- the shape family is recognizably the same wave, //-- refined three times. define ViewBox(0, 0, 400, 250); fn texturedStroke(strokeLayer, y, freq) { let spine = @{ l 320 0 }; let ribbon = spine.compoundVariableOffset() {|vo, pb| vo.startCap(Cap.tapered(2, CurveContinuity.G0)); for (i in 0..64) { let t = i / 64; //-- The end windows are smoothstep too: width eases to 0 at both //-- tips, so the ribbon tapers instead of ending in a blunt edge. let amp = smoothstep(0, 0.08, t) * smoothstep(1, 0.92, t); let w = (2 + noise(t * freq) * 14) * amp; vo.stop(t, w, CurveContinuity.G1, -w, CurveContinuity.G1); } vo.endCap(Cap.tapered(2, CurveContinuity.G0)); }; strokeLayer.apply { M calc(40 + ribbon.anchor.x) calc(y + ribbon.anchor.y) ribbon.draw(); } } let slow = PathLayer('freq-3') ${ fill: oklch(0.55 0.18 260); stroke: none; opacity: 0.9; }; let mid = PathLayer('freq-6') ${ fill: oklch(0.58 0.17 220); stroke: none; opacity: 0.9; }; let fast = PathLayer('freq-12') ${ fill: oklch(0.62 0.16 160); stroke: none; opacity: 0.9; }; texturedStroke(slow, 55, 3); texturedStroke(mid, 130, 6); texturedStroke(fast, 205, 12); let labels = TextLayer('labels') ${ font-family: system-ui, sans-serif; font-size: 11; fill: #888; text-anchor: start; }; labels.apply { text(40, 28)`noise(t * 3) — slow swells` text(40, 101)`noise(t * 6) — undulating` text(40, 176)`noise(t * 12) — chattering` } let scene = GroupLayer('scene') ${}; scene.append(slow, mid, fast, labels); The same noise stream at three input scales. noise(t * 2) is one slow swell; noise(t * 11) chatters. Frequency is just multiplication — no new API.

Because noise is built on hash01, it inherits the portability contract: identical arguments, identical results, every engine. Your organic wobble is exactly as reproducible as your straight lines.

A field of texture: noise2()

One dimension of noise textures a stroke. Two dimensions texture a family of strokes.

In the rebuilt glow above, each layer jitters independently — layer 7 has no idea what layer 8 is doing. noise2(x, y, seed?) makes the texture a field: run t along the stroke and the layer index across it, and because the field is continuous in both directions, neighboring layers sample neighboring rows — and swell together.

// viewBox="0 60 400 140" //-- A field of texture. Sample 02 jittered each layer independently -- //-- hash11(i, haloIndex) gives layer 7 no idea what layer 8 is doing. //-- Here the texture is a 2D noise FIELD: t runs along the stroke, //-- haloIndex * 0.3 runs across the layers, and because noise2 is //-- continuous in BOTH directions, neighboring layers sample neighboring //-- rows and swell together. The glow stops shimmering and starts flowing. define ViewBox(0, 60, 400, 140); let spine = @{ c 80 -100 160 100 240 0 }; let px = 80; let py = 130; let steps = 48; let taperCap = Cap.tapered(2, CurveContinuity.G0); let base = oklch(0.72 0.14 20); for (haloIndex in 16..1) { //-- One texture field for the whole glow; each layer reads its own row. //-- Centered on 1 so it scales widths by 0.7..1.3. let texture = {|t| return 1 + (noise2(t * 6, haloIndex * 0.3) - 0.5) * 0.6; }; let width1 = {|t| return (0.15 * haloIndex + 0.6 * haloIndex * bump(t, 0.35, 0.3) + 0.35 * haloIndex * pow(bump(t, 0.78, 0.18), 2)) * texture(t); }; let width2 = {|t| return (-0.15 * haloIndex - 0.3 * haloIndex * bump(t, 0.55, 0.4)) * texture(t); }; let mk = {|vo, pb| vo.startCap(taperCap); for (i in 0..steps) { let t = i / steps; vo.stop(t, width1(t), CurveContinuity.G1, width2(t), CurveContinuity.G1); } vo.endCap(taperCap); }; let halo = spine.compoundVariableOffset() << mk; let haloColor = base.hueShift(calc(haloIndex * -6)); let haloLayer = PathLayer(`halo-${haloIndex}`) ${ fill: haloColor; stroke: none; opacity: 0.25; }; haloLayer.apply { M calc(px + halo.anchor.x) calc(py + halo.anchor.y) halo.draw(); } } The finale: one noise2 field textures all sixteen layers. t runs along the spine, haloIndex * 0.3 runs across the glow, and the layers breathe together instead of shimmering independently. In the hash-jittered glow above, sixteen edges move independently; here they move as one surface.

This is the payoff of the whole series in one image. The Swelling Line gave the line a width. The Shape of a Stroke made the width a designed object. This post makes the design portable — reliable across engines, surfaces, and time — and gives it weather.

The determinism contract

What's guaranteed, precisely — the dividing line is whether the standard specifies the operations exactly or leaves them implementation-approximated:

  • Bit-exact everywhere: hash01, hash11, hashRange, noise, noise2 — and every function built only from exactly-specified operations: smoothstep, lerp, clamp, map, the easing trio, abs, floor, min, max, sqrt. Same arguments, same bits — across machines, engines, surfaces, and time.
  • Deterministic per engine: anything built on an implementation-approximated Math operation — bump (cosine), sin/cos/tan, pow, exp, log. Reproducible on the engine you're on; not contractually pinned across engines.
  • Not deterministic at all: random() and randomRange() — still there when you genuinely want fresh entropy every compile.

The full reference lives in the Hash & Noise, Interpolation & Clamping, and Easing sections of the stdlib docs.

If you have a generative sketch driven by randomRange, the one-line substitution — hashRange(i, min, max) with your loop index — makes it reproducible; everything else stays the same. And if your program defines its own fn hash01, nothing changes at all: your function shadows the built-in, by design.

The hand-rolled versions of these functions served three blog posts faithfully. They've earned the promotion.