The Shape of a Stroke: Envelopes, Bulges, and Lambdas
Part 2 of 3 in our series on variable-width strokes.
Series: Variable-Width Strokes
- The Swelling Line — variableOffset and compoundVariableOffset
- The Shape of a Stroke (this post) — envelopes, bulges, and lambdas
- The Reliable Line — hash, noise, and envelopes join the stdlib
The Swelling Line introduced
variableOffset and compoundVariableOffset: place stops along a path, give each one a distance,
and the stroke breathes. That post ended with ribbons. This one asks the next
question: what does it take to make a stroke genuinely rich — layered,
shaped, textured — and still reusable?
The answer turns out to be a ladder of abstractions over one idea: width as a function of position. Climbing that ladder is what this post is about, and near the top it required growing the language itself. Pathogen now has lambda expressions:
let f = {|a, b| return a + b; };
let three = f(1, 2);
The same {|...|} block syntax you already know from map and the gradient
builders, promoted to a first-class value — with true lexical capture. Here's
the stroke that earns it.
A rich stroke is a stack of thin ones
One compoundVariableOffset pass gives you a ribbon. A glow takes sixteen:
loop an index from 16 down to 1, and give each layer stop widths scaled by the
index — wide soft passes first, a narrow bright core last. Drive the widths
with randomRange and the result is pleasingly organic:
// viewBox="0 60 400 140"
//-- A layered "glow" stroke: sixteen compoundVariableOffset passes, widths
//-- driven by randomRange scaled by the layer index. Wide soft layers paint
//-- first, a narrow bright core lands last. Organic -- and unrepeatable:
//-- every compile rolls new widths.
//-- (Ranges are inclusive on both ends: 0..stepMax yields stepMax + 1 stops.)
define ViewBox(0, 60, 400, 140);
let spine = @{ c 80 -100 160 100 240 0 };
let px = 80;
let py = 130;
let base = oklch(0.72 0.14 20);
for (haloIndex in 16..1) {
let halo = spine.compoundVariableOffset() {|vo, pb|
let stepMax = 48;
for (step in 0..stepMax) {
let time = step / stepMax;
vo.stop(time,
randomRange(0.1, 0.6 * haloIndex),
CurveContinuity.G1,
randomRange(-0.3 * haloIndex, -0.1),
CurveContinuity.G1);
}
vo.startCap(Cap.tapered(2, CurveContinuity.G0));
vo.endCap(Cap.tapered(2, CurveContinuity.G0));
};
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();
}
}
Organic, and a dead end. Every knob is buried in the loop body; the randomness means no two compiles match; and the moment you try to extract a reusable function, you hit the real design question: the thing you want to pass around isn't a number. It's a shape.
Width as a function: the envelope
Call that shape an envelope — width as a function of position t along the
spine. The first envelope worth designing is a bulge: a chosen spot where
the stroke swells, entered and left smoothly. A raised cosine does it in four
lines:
fn bulge(t, center, spread) {
let d = clamp(abs(t - center) / spread, 0, 1);
return 0.5 * (1 + cos(mpi(d)));
}
d is normalized distance from the bulge center; 0.5·(1 + cos(πd)) is
easeInOutSine wearing its trig clothes. The property that matters: its
derivative is zero at both ends, so the swell leaves the base width and
rejoins it without a visible crease.
// viewBox="0 0 400 320"
//-- SEE the easing: three candidate bulge envelopes plotted as graphs, with
//-- the stroke the raised cosine produces rendered on a STRAIGHT spine below.
//-- On a straight spine the silhouette of the stroke IS its envelope; the
//-- dashed vertical guide ties the plot's peak to the stroke's widest point.
//--
//-- tent: 1 - d crease at the peak AND the edges
//-- smoothstep: u*u*(3 - 2u), u = 1-d polynomial ease, flat at both ends
//-- raised cos: 0.5 * (1 + cos(pi*d)) = easeInOutSine; derivative is 0
//-- at d=0 and d=1, so the width enters and leaves the bulge
//-- flat -- no crease where the swell meets the base width.
//-- Smoothstep tracks the raised cosine within 1% of the peak (max gap 0.010
//-- at u = 0.72) -- drawn solid it would vanish underneath, hence the dashes.
define ViewBox(0, 0, 400, 320);
//-- Normalized distance from the bulge center: 0 at the peak, 1 at the edge
//-- of the bulge window, clamped outside it.
fn win(t, center, spread) {
return clamp(abs(t - center) / spread, 0, 1);
}
fn tentEnv(t, center, spread) {
let d = win(t, center, spread);
return 1 - d;
}
fn smoothEnv(t, center, spread) {
let d = win(t, center, spread);
let u = 1 - d;
return u * u * (3 - 2 * u);
}
fn cosEnv(t, center, spread) {
let d = win(t, center, spread);
return 0.5 * (1 + cos(mpi(d)));
}
//-- The same shape-agnostic wrapper used throughout this post.
fn strokeFromPathBlock(pathBlock, steps, bulgeTime, spread,
base1, peak1, base2, peak2, continuity, cap) {
return pathBlock.compoundVariableOffset() {|vo, pb|
vo.startCap(cap);
for (i in 0..steps) {
let t = i / steps;
let e = cosEnv(t, bulgeTime, spread);
vo.stop(t, base1 + peak1 * e, continuity,
base2 + peak2 * e, continuity);
}
vo.endCap(cap);
};
}
//-- Plot geometry -----------------------------------------------------------
let plotX = 80;
let plotW = 240;
let plotY = 150;
let plotH = 80;
let samples = 48;
let center = 0.5;
let spread = 0.35;
//-- envFn arrives BY NAME — functions are first-class values in Pathogen.
fn plotEnvelope(curveLayer, envFn) {
curveLayer.apply {
M plotX calc(plotY - plotH * envFn(0, center, spread))
for (i in 1..samples) {
let t = i / samples;
L calc(plotX + plotW * t) calc(plotY - plotH * envFn(t, center, spread))
}
}
}
let axis = PathLayer('axis') ${ fill: none; stroke: #bbb; stroke-width: 1; };
axis.apply {
M plotX plotY
L calc(plotX + plotW) plotY
}
//-- Vertical dashed guide through the bulge center: plot peak -> stroke waist.
let peakGuide = PathLayer('peak-guide') ${
fill: none;
stroke: #999;
stroke-width: 0.75;
stroke-dasharray: 4 3;
};
peakGuide.apply {
M calc(plotX + plotW * center) calc(plotY - plotH - 8)
L calc(plotX + plotW * center) 280
}
//-- Smoothstep is plotted last so its dashes sit on top of the cosine.
let tentLayer = PathLayer('env-tent') ${ fill: none; stroke: #b0b0b0; stroke-width: 1.25; stroke-dasharray: 5 3; };
let cosLayer = PathLayer('env-raised-cos') ${ fill: none; stroke: oklch(0.55 0.18 260); stroke-width: 2; };
let smoothLayer = PathLayer('env-smoothstep') ${ fill: none; stroke: oklch(0.62 0.16 160); stroke-width: 1.25; stroke-dasharray: 2 4; };
plotEnvelope(tentLayer, tentEnv);
plotEnvelope(cosLayer, cosEnv);
plotEnvelope(smoothLayer, smoothEnv);
//-- The stroke shaped by the raised-cosine envelope, on a straight spine.
let spine = @{ l 240 0 };
let ribbon = strokeFromPathBlock(spine, 48, center, spread,
0.75, 13, -0.75, -13,
CurveContinuity.G1,
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)`bulge envelopes`
text(80, 172)`t=0`
text(190, 172)`t=0.5`
text(304, 172)`t=1`
text(78, 292)`raised-cosine stroke (straight spine)`
}
//-- Legend: each entry gets a line sample drawn with that curve's real
//-- stroke and dash pattern, so the key reads without relying on color.
let swatchTent = PathLayer('swatch-tent') ${ fill: none; stroke: #b0b0b0; stroke-width: 1.25; stroke-dasharray: 5 3; };
swatchTent.apply { M 252 37 L 268 37 }
let legendTent = TextLayer('legend-tent') ${
font-family: system-ui, sans-serif; font-size: 10; text-anchor: start; fill: #b0b0b0;
};
legendTent.apply { text(274, 40)`linear tent` }
let swatchSmooth = PathLayer('swatch-smoothstep') ${ fill: none; stroke: oklch(0.62 0.16 160); stroke-width: 1.25; stroke-dasharray: 2 4; };
swatchSmooth.apply { M 252 51 L 268 51 }
let legendSmooth = TextLayer('legend-smoothstep') ${
font-family: system-ui, sans-serif; font-size: 10; text-anchor: start; fill: oklch(0.62 0.16 160);
};
legendSmooth.apply { text(274, 54)`smoothstep` }
let swatchCos = PathLayer('swatch-raised-cos') ${ fill: none; stroke: oklch(0.55 0.18 260); stroke-width: 2; };
swatchCos.apply { M 252 65 L 268 65 }
let legendCos = TextLayer('legend-raised-cos') ${
font-family: system-ui, sans-serif; font-size: 10; text-anchor: start; fill: oklch(0.72 0.15 260);
};
legendCos.apply { text(274, 68)`raised cosine` }
let scene = GroupLayer('scene') ${};
scene.append(axis, peakGuide, tentLayer, cosLayer, smoothLayer, strokeLayer,
labels, swatchTent, legendTent, swatchSmooth, legendSmooth, swatchCos, legendCos);
Passing the shape
Pathogen functions are values: hand a named fn to another function and call
it through the parameter. So the general stroke-maker takes its envelopes as
functions — one per profile — and samples them at each stop:
// viewBox="0 0 400 560"
//-- Generalize the SHAPE, not the knobs: the wrapper takes envelope
//-- functions (t) -> signed offset and samples one per profile at each stop.
//-- Functions are first-class values in Pathogen -- pass them by name, call
//-- them through the parameter.
//--
//-- Convention: envelopes return SIGNED offsets (the sign picks the side),
//-- matching vo.stop's profile semantics.
define ViewBox(0, 0, 400, 560);
fn bulge(t, center, spread) {
let d = clamp(abs(t - center) / spread, 0, 1);
return 0.5 * (1 + cos(mpi(d)));
}
//-- The shape-agnostic wrapper. env1/env2 are functions.
fn strokeWithEnvelopes(pathBlock, steps, env1, env2, continuity, cap) {
return pathBlock.compoundVariableOffset() {|vo, pb|
vo.startCap(cap);
for (i in 0..steps) {
let t = i / steps;
vo.stop(t, env1(t), continuity, env2(t), continuity);
}
vo.endCap(cap);
};
}
//-- Plug-in envelopes as named fns. Before lambdas shipped, this was the
//-- only form a passable envelope could take -- note how bulged1/bulged2
//-- bake their center and peak in as constants.
fn constant1(t) { return 4; }
fn constant2(t) { return -4; }
fn taper1(t) { return lerp(9, 0.5, t); }
fn taper2(t) { return -lerp(9, 0.5, t); }
fn bulged1(t) { return 0.5 + 10 * bulge(t, 0.55, 0.3); }
fn bulged2(t) { return -(0.5 + 4 * bulge(t, 0.55, 0.3)); }
//-- Both profiles ride the SAME sine, so the band wanders side to side while
//-- its width stays near-constant — a wavy ribbon. (If an envelope crosses
//-- zero, the profile crosses the spine; same-sign stretches legally float
//-- off one side as a detached band.)
fn wave1(t) { return 5 + 4 * sin(TAU() * 3 * t); }
fn wave2(t) { return -5 + 4 * sin(TAU() * 3 * t); }
let spine = @{ c 80 -100 160 100 240 0 };
let px = 80;
let taperCap = Cap.tapered(2, CurveContinuity.G0);
let base = oklch(0.55 0.18 260);
fn drawRow(name, ribbon, py, hueOffset) {
let rowColor = base.hueShift(hueOffset);
let rowLayer = PathLayer(`row-${name}`) ${ fill: rowColor; stroke: none; opacity: 0.9; };
rowLayer.apply {
M calc(px + ribbon.anchor.x) calc(py + ribbon.anchor.y)
ribbon.draw();
}
}
drawRow('constant', strokeWithEnvelopes(spine, 48, constant1, constant2, CurveContinuity.G1, taperCap), 95, 0);
drawRow('taper', strokeWithEnvelopes(spine, 48, taper1, taper2, CurveContinuity.G1, taperCap), 225, 60);
drawRow('bulge', strokeWithEnvelopes(spine, 48, bulged1, bulged2, CurveContinuity.G1, taperCap), 355, 120);
drawRow('wave', strokeWithEnvelopes(spine, 96, wave1, wave2, CurveContinuity.G1, taperCap), 490, 180);
let labels = TextLayer('labels') ${
font-family: system-ui, sans-serif;
font-size: 11;
fill: #888;
text-anchor: start;
};
labels.apply {
text(78, 32)`constant1 / constant2`
text(78, 162)`taper1 / taper2 (lerp 9 -> 0.5)`
text(78, 292)`bulged1 / bulged2 (asymmetric peaks)`
text(78, 425)`wave1 / wave2 (same sine: wandering ribbon)`
}
This is a real abstraction — the wrapper is finished, forever, no matter what
envelope you invent next. But look at the envelopes themselves. Every one is a
named, top-level function with its constants baked in. bulged1
hard-codes its center and peak, because until now there was no way to write "a
bulge envelope for these particular parameters" as a value. Pathogen's named
functions are dynamically scoped — free names resolve in the caller's scope
at call time — so a function couldn't carry values from where it was written.
The workarounds are all familiar and all unsatisfying: top-level lets you
hope nobody shadows, spec-object arrays interpreted by a helper, or parameter
lists that grow a slot for every knob.
{|a, b| ... } grows up
The missing piece is a function literal that remembers. A block literal in expression position is now a lambda: a function value that captures the scope where it was written.
let scale = 3;
let times = {|x| return calc(x * scale); };
fn caller() {
let scale = 100; // does NOT affect the lambda
return times(2);
}
let six = caller(); // 6 — lexical capture, not the caller's scale
Three design decisions worth spelling out:
- Lambdas are lexical; named
fns stay dynamic. Changingfnscoping would silently alter existing programs, so it doesn't. Both behaviors are now documented — and if you've ever been surprised by a named function picking up a caller's variable, that section is worth two minutes. - Capture is by reference, per loop iteration. Loops create a fresh scope each pass, so lambdas born in a loop each remember their iteration — the classic capture trap from other languages resolves the friendly way here.
- Zero parameters is
{|| ... }— one grammar wrinkle, since two bare pipes otherwise lex as logical-or.
And the part that makes the feature feel native: a builtin can take a
lambda you already built, applied with the << operator — the operator
you already use to merge objects and apply style blocks, here wearing a
second hat. A literal trailing block still works exactly as before —
that's what every sample above uses. The << form is the worker spelling
of the same idea, for when the callback is a value with a name:
items.map() << f, items.sort() << cmp, grid.fill() << f, and — the
one this post has been building toward —
spine.compoundVariableOffset() << mk. The parentheses keep the builtin's
real parameters (reduce(init) << f); << supplies the worker. The full
rules live in Applying workers.
Envelopes on demand
The baked-constants problem, dissolved — twice. The first row replaces four
named taper functions with two inline lambda literals. Then the payoff: three
bulge strokes from one loop, each iteration's lambdas closing over that
iteration's center and peak:
// viewBox="0 0 400 560"
//-- Closures dissolve the baked-constants problem. A lambda {|t| ...}
//-- captures the scope where it is WRITTEN, so a parameterized envelope can
//-- be built inline, wherever its parameters live. Compare with
//-- 03-named-envelopes.pathogen: same wrapper, but the envelopes no longer
//-- need to be named top-level fns with constants baked in.
//--
//-- Rows 2-4 are the payoff: lambdas built INSIDE a loop, each closing over
//-- that iteration's center and peak. Loops create a fresh scope per
//-- iteration, so each lambda remembers its own values.
define ViewBox(0, 0, 400, 560);
fn bulge(t, center, spread) {
let d = clamp(abs(t - center) / spread, 0, 1);
return 0.5 * (1 + cos(mpi(d)));
}
//-- The identical wrapper — it neither knows nor cares whether env1 / env2
//-- arrived as named fns or closures.
fn strokeWithEnvelopes(pathBlock, steps, env1, env2, continuity, cap) {
return pathBlock.compoundVariableOffset() {|vo, pb|
vo.startCap(cap);
for (i in 0..steps) {
let t = i / steps;
vo.stop(t, env1(t), continuity, env2(t), continuity);
}
vo.endCap(cap);
};
}
let spine = @{ c 80 -100 160 100 240 0 };
let px = 80;
let taperCap = Cap.tapered(2, CurveContinuity.G0);
let base = oklch(0.55 0.18 260);
fn drawRow(name, ribbon, py, hueOffset) {
let rowColor = base.hueShift(hueOffset);
let rowLayer = PathLayer(`row-${name}`) ${ fill: rowColor; stroke: none; opacity: 0.9; };
rowLayer.apply {
M calc(px + ribbon.anchor.x) calc(py + ribbon.anchor.y)
ribbon.draw();
}
}
//-- Row 1: inline lambda envelopes — the taper, without any named
//-- top-level fns.
let taperUp = {|t| return lerp(9, 0.5, t); };
let taperDn = {|t| return -lerp(9, 0.5, t); };
drawRow('taper', strokeWithEnvelopes(spine, 48, taperUp, taperDn, CurveContinuity.G1, taperCap), 95, 0);
//-- Rows 2-4: closures born in a loop. Each iteration's lambdas capture THAT
//-- iteration's center and peak (loops create a fresh scope per iteration).
for (rowIndex in 0..2) {
let center = 0.25 + rowIndex * 0.25;
let peak = 6 + rowIndex * 3;
let envUp = {|t| return 0.5 + peak * bulge(t, center, 0.22); };
let envDn = {|t| return -(0.5 + peak * bulge(t, center, 0.22)); };
let ribbon = strokeWithEnvelopes(spine, 48, envUp, envDn, CurveContinuity.G1, taperCap);
drawRow(`loop-${rowIndex}`, ribbon, calc(225 + rowIndex * 130), calc(60 + rowIndex * 60));
}
let labels = TextLayer('labels') ${
font-family: system-ui, sans-serif;
font-size: 11;
fill: #888;
text-anchor: start;
};
labels.apply {
text(78, 32)`inline lambdas: taperUp / taperDn`
text(78, 162)`loop closures: center 0.25, peak 6`
text(78, 292)`center 0.5, peak 9`
text(78, 422)`center 0.75, peak 12`
}
The wrapper is untouched — it still just calls env1(t) and env2(t). What
changed is where envelopes can come from: anywhere, parameterized by whatever
is in scope at that spot.
The rich stroke, assembled
Now rebuild the sixteen-layer glow with intent instead of Math.random. Each
layer wants: a base width and two bulge peaks scaled by the layer index, a
deterministic jitter texture with a per-layer stream, and tapered caps. As
closures, that's three lambdas per layer — width1, width2, jitter, each
capturing the layer index — plus a builder lambda mk, applied with
spine.compoundVariableOffset() << mk:
// viewBox="0 60 400 140"
//-- The glow, rebuilt on closures -- deterministic this time. Without
//-- closures this program threads TEN parameters through a helper
//-- (pathBlock, steps, base1, bulges1, base2, bulges2, jitterAmount, salt,
//-- continuity, cap). Here each layer builds lambdas that CAPTURE the layer
//-- index and the scaled widths, and the builder lambda is applied with
//-- compoundVariableOffset() << mk -- a function value doing the trailing
//-- block's job. The closures carry the state; the parameter lists
//-- collapse.
//-- Recompiles are byte-identical: the jitter is a hash of the stop index
//-- and a captured per-layer salt, not randomRange.
define ViewBox(0, 60, 400, 140);
fn bulge(t, center, spread) {
let d = clamp(abs(t - center) / spread, 0, 1);
return 0.5 * (1 + cos(mpi(d)));
}
fn hash01(i) {
let s = sin(i * 12.9898) * 43758.5453;
return s - floor(s);
}
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) {
//-- Everything below closes over THIS iteration's haloIndex.
let jitter = {|i| return 1 + (hash01(i * 7 + haloIndex * 1013) * 2 - 1) * 0.2; };
let width1 = {|t|
return 0.15 * haloIndex
+ 0.6 * haloIndex * bulge(t, 0.35, 0.3)
+ 0.35 * haloIndex * pow(bulge(t, 0.78, 0.18), 2);
};
let width2 = {|t|
return -0.15 * haloIndex - 0.3 * haloIndex * bulge(t, 0.55, 0.4);
};
//-- The builder itself is a lambda, applied with << — the worker form
//-- of `spine.compoundVariableOffset() {|vo, pb| ... }`.
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();
}
}
Before lambdas, this program needed a ten-parameter helper function to thread base widths, bulge specs, jitter amount, salt, continuity, and caps down into the builder. The closure version was verified against that parameter-threaded build — the same deterministic design, not the randomized stroke that opened this post — by diffing the compiled SVG: byte-identical. Same geometry, radically less plumbing — which is the whole argument for closures in one sentence.
And unlike the randomized original, this stroke is a design: move a bulge, sharpen a peak, retune the jitter, recompile, and get exactly what you asked for — every time.
The fine print
Version one has honest edges, all documented:
- Call lambdas through a plain name.
fns[0](5),obj.f(1), and immediately-invoked literals aren't callable yet — bind to aletfirst. - A lambda literal can't sit inside a call in path-argument position
(
M use({|x| ...}) 0) — path arguments stop at|. Pass a name. - Constructor binding blocks (
LinearGradient(...) {|g| ...},Marker,Pattern, filters,Grid(...) {|g| ...}) still take literal blocks; the callback-style methods also accept<<workers.
Where this goes
An envelope is just the first function worth capturing. The same pattern —
small lambdas closing over local parameters, applied to a builder with << —
reaches anywhere Pathogen takes a callback: comparator families for sort,
field functions for Grid.fill, per-glyph stroke treatments built inside a
contours loop. Calligraphic nibs, pressure-simulating taper families,
multi-pass glows with per-pass texture: all of them are a lambda closing over
the parameters that make each instance this instance.
Every sample on this page is live — open the code pane, change a captured
center or peak, and watch the closure carry it into the stroke. The full
reference is in the Lambdas docs, the scoping rules in
Functions vs lambdas, and the
stroke machinery in the Variable Offset docs.
The stroke was always a function. Now the language lets you treat it like one.