Redraw is currently in technical preview, available to wcandillon.dev subscribers. API is unstable.
Custom Strokes
The authoring helpers (strokeWidth, color, sdf, clip, feather)
let you define custom GPU functions in TypeScript: a callback with the
"use gpu" directive, compiled to WGSL by unplugin-typegpu at build
time, then executed on the GPU at draw time. The function receives the
geometry context of the shape being drawn and returns (or writes
back) what the renderer needs at this pixel.
This page covers stroke-width functions. See Colors and Feather for the other roles, and Shapes for custom SDF geometries.
Custom effects require the unplugin-typegpu bundler plugin. See the
Installation page.
Anatomy
import { strokeWidth } from "redraw";
import { std } from "typegpu";
const Taper = strokeWidth(
(ctx, tctx, props) => { // callback
"use gpu";
return std.mix(props.minWidth, props.maxWidth, ctx.t);
},
{ minWidth: 0, maxWidth: 0 }, // default props
{ maxStrokeWidth: 24 }, // options
);
The signature is strokeWidth(callback, defaults, options?). The
"use gpu" directive at the top of the callback is what tells
unplugin-typegpu to transpile it into WGSL at build time.
The callback returns the stroke width at the current point (an
f32); the pipeline bands the shape's distance field with it
(abs(sdf) - width / 2). It receives three parameters:
| Parameter | What it is |
|---|---|
ctx | The geometry context: ctx.t, ctx.sdf, ctx.tan, ctx.grad. |
tctx | The transform context: tctx.pos (local position), tctx.worldPos. |
props | The per-draw uniforms, typed from defaults (see Props below). |
What you read
| Field | Type | What it is | Use it for |
|---|---|---|---|
ctx.t | f32 | Arc-length parameter [0, 1] along the path. | Tapers, draw progress, position-aware width. |
ctx.sdf | f32 | Signed distance to the geometry. | Outline thresholds, edge falloff. |
ctx.tan | vec2f | Tangent vector along the path. | Calligraphy, direction-aware effects. |
ctx.grad | vec2f | Gradient of the distance field (normal direction). | Lighting and sidedness combined with tan. |
tctx.pos | vec2f | Position in local (drawing) space. | Spatial effects. |
The tan and t fields carry path information, so they are meaningful
when the paint strokes a drawPath call (see Paths).
Props
Props are typed uniforms that flow from CPU to GPU. The defaults
object is the single source of truth: it types the callback's props
and becomes the GPU-side struct. A number maps to f32, a
fixed-length tuple ([x, y], [x, y, z], [x, y, z, w]) to the
matching vecNf.
At draw time you provide the actual values on the paint, and since the canvas is immediate mode, animation is just different props on the next frame:
export function render(canvas: Canvas, { time }: FrameInfo) {
// time is in milliseconds: the width breathes over 2π seconds.
const paint = new Paint()
.setColor("#268BD2")
.setStroke(Taper, {
minWidth: 4 + Math.sin(time * 0.001) * 2,
maxWidth: 24,
});
canvas.drawPath(pathGeo, paint);
}
Remember to declare the function in the canvas's
Library (in React, the library prop).
Options
| Option | What it does |
|---|---|
maxStrokeWidth | The widest width, in device pixels, the function can ever return. The draw's bounds are padded by half of it so tile binning never culls the band. |
name | The WGSL function name. Omit to get a generated unique name. |
length | Fixed capacity of the props collection (default 1024). |
If you see the edges of your stroke clipped at high widths,
maxStrokeWidth is the knob.
For coverage that reaches past the stroke band itself, e.g. a glow drawn
by the paint's color function, declare the extra reach on that binding
instead, with the per-instance maxCullDistance:
paint.addShader({
fn: GlowingOutline,
props: { lineWidth: 8, glowIntensity: 1.4 },
maxCullDistance: 90, // how far the glow spreads, in device pixels
});
Recipes
Calligraphy from the tangent
The angle between ctx.tan and a fixed pen nib simulates a flat pen:
the stroke is widest when moving across the nib, hairline along it.
const CalligraphyStroke = strokeWidth(
(ctx, _tctx, props) => {
"use gpu";
const l = std.length(ctx.tan);
const t = std.select(d.vec2f(1, 0), std.div(ctx.tan, l), l > 0.000001);
const nib = d.vec2f(std.cos(props.penAngle), std.sin(props.penAngle));
// Widest when the stroke moves across the nib, hairline along it.
const across = std.abs(t.x * nib.y - t.y * nib.x);
return std.mix(props.minWidth, props.maxWidth, across);
},
{ minWidth: 0, maxWidth: 0, penAngle: 0 },
{ maxStrokeWidth: 26 },
);
Animated head along the arc length
Pair canvas.drawPath(path.segment(0, progress), paint) with a width
function that swells near ctx.t = 1:
const AnimatedHead = strokeWidth(
(ctx, _tctx, props) => {
"use gpu";
const head = props.baseWidth * 2;
return std.mix(props.baseWidth, head, std.smoothstep(0.5, 1.0, ctx.t));
},
{ baseWidth: 0 },
{ maxStrokeWidth: 50 },
);