Skip to main content
Technical preview

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.

note

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:

ParameterWhat it is
ctxThe geometry context: ctx.t, ctx.sdf, ctx.tan, ctx.grad.
tctxThe transform context: tctx.pos (local position), tctx.worldPos.
propsThe per-draw uniforms, typed from defaults (see Props below).

What you read

FieldTypeWhat it isUse it for
ctx.tf32Arc-length parameter [0, 1] along the path.Tapers, draw progress, position-aware width.
ctx.sdff32Signed distance to the geometry.Outline thresholds, edge falloff.
ctx.tanvec2fTangent vector along the path.Calligraphy, direction-aware effects.
ctx.gradvec2fGradient of the distance field (normal direction).Lighting and sidedness combined with tan.
tctx.posvec2fPosition 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

OptionWhat it does
maxStrokeWidthThe 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.
nameThe WGSL function name. Omit to get a generated unique name.
lengthFixed 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 },
);
Stroke width follows the angle between path tangent and pen directionOpen in editor →

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 },
);
Variable width plus palette interpolation along the curveOpen in editor →