Skip to main content
Technical preview

Redraw is currently in technical preview, available to start-react-native.dev subscribers. API is unstable.

Custom Strokes

fn(...) lets you define a custom TypeGPU function: TypeScript authored 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 writes back what the renderer needs at this pixel. For stroke-width control, that's a single f32 written to canvas.field (the local stroke half-width).

This page covers stroke-width functions. See Colors and Feather for the other two roles a fn can play.

note

Custom effects require the unplugin-typegpu bundler plugin. See the Installation page.

Anatomy

import { fn } from "redraw";
import { d, std } from "typegpu";

const StrokeWidth = fn(
(canvas, ctx, props) => {
"use gpu";
canvas.field = std.mix(8.0, 32.0, ctx.t);
},
{ /* default props */ },
{ /* options */ },
);

The signature is fn(body, defaultProps, options?). The "use gpu" directive at the top of the body is what tells unplugin-typegpu to transpile the function into WGSL at build time.

The body receives four parameters:

ParameterTypeNotes
canvasmutableWrite canvas.field (this page) or canvas.color (Colors). Also reads canvas.width, canvas.height, canvas.shapeBounds.
ctxread-onlyThe geometry context: ctx.t, ctx.sdf, ctx.tan, ctx.grad, ctx.pos.
propsread-onlyThe instance's uniforms (see Props below).
posvec2fScreen position in pixels. Equivalent to ctx.pos.

What you write

For a stroke-width function, you write a single f32 to canvas.field:

canvas.field = 12.0; // constant
canvas.field = std.mix(4, 24, ctx.t); // taper

canvas.field is the half-width at this pixel; the total stroke is field * 2.

What you read

The geometry context (ctx) exposes:

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 centerline (negative inside the stroke).Outline thresholds, edge falloff.
ctx.tanvec2fTangent vector along the path.Calligraphy, direction-aware effects.
ctx.gradvec2fNormal (perpendicular to the path).Lighting / sidedness combined with tan.
ctx.posvec2fScreen position in pixels.Spatial effects (rare for stroke width).

These are all defined in packages/redraw/src/core/TypeGPU.ts. The tan, grad, and t fields are populated only when you use SingleStrokeBrush. See Brushes.

Props

Props are typed uniforms that flow from CPU to GPU. Declare types via the TypeGPU d namespace; mutate instance.props.fieldName between frames and the new value is uploaded automatically:

const StrokeWidth = fn(
(canvas, ctx, props) => {
"use gpu";
canvas.field = std.mix(props.minWidth, props.maxWidth, ctx.t);
},
{
minWidth: 4, // f32 inferred from number
maxWidth: 24,
pos: d.vec2f(0, 0), // explicit type
},
);

const stroke = new StrokeWidth({ minWidth: 8, maxWidth: 30 });
brush.addStroke(color, stroke);

// Animate every frame:
stroke.props.minWidth = 4 + Math.sin(ctx.time) * 2;

Supported types: d.f32 (or plain number), d.vec2f, d.vec3f, d.vec4f. Mutations through instance.props.foo = … are tracked via Proxy and flushed to a uniform buffer on the next render.

Options

The third argument of fn(...) is { maxCullDistance?: number }. Bump it when your stroke can extend past Redraw's default culling band; for example, an animated head that grows to twice the base width:

const StrokeWidth = fn(
(canvas, ctx, props) => { /* ... */ },
{ progress: 0 },
{ maxCullDistance: 50 }, // base width 25 → max 50
);

If you see the edges of your stroke clipped at high width, this is the knob.

Recipes

Linear taper from arc length

const Taper = fn(
(canvas, ctx) => {
"use gpu";
canvas.field = std.mix(4.0, 24.0, ctx.t);
},
{},
);

Calligraphy from the tangent

The angle between ctx.tan and a reference direction simulates a flat pen: the stroke is widest perpendicular to the pen, narrowest along its axis.

const CalligraphyWidth = fn(
(canvas, ctx, props) => {
"use gpu";
const cosA = std.cos(props.penAngle);
const sinA = std.sin(props.penAngle);
const rx = ctx.tan.x * cosA - ctx.tan.y * sinA;
const ry = ctx.tan.x * sinA + ctx.tan.y * cosA;
const angle = std.atan2(ry, rx);
const raw = std.abs(angle / Math.PI);
const t = raw * raw * (3.0 - 2.0 * raw); // smoothstep removes V-kink
canvas.field = std.mix(props.minWidth, props.maxWidth, t);
},
{ minWidth: 0, maxWidth: 0, penAngle: 0 },
);
Stroke width follows the angle between path tangent and pen directionOpen in editor →

Animated head with path.segment

Pair canvas.drawPath(...).segment(0, progress) with a width function that swells near ctx.t = progress:

const AnimatedHead = fn(
(canvas, ctx, props) => {
"use gpu";
const baseWidth = d.f32(25.0);
const head = std.mix(baseWidth * 2, baseWidth,
std.saturate((props.progress - 0.9) * 10));
canvas.field = std.mix(baseWidth, head,
std.smoothstep(0.5, 1.0, ctx.t));
},
{ progress: 0 },
{ maxCullDistance: 50 },
);
Animated head plus palette interpolation along the curveOpen in editor →