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.
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:
| Parameter | Type | Notes |
|---|---|---|
canvas | mutable | Write canvas.field (this page) or canvas.color (Colors). Also reads canvas.width, canvas.height, canvas.shapeBounds. |
ctx | read-only | The geometry context: ctx.t, ctx.sdf, ctx.tan, ctx.grad, ctx.pos. |
props | read-only | The instance's uniforms (see Props below). |
pos | vec2f | Screen 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:
| 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 centerline (negative inside the stroke). | Outline thresholds, edge falloff. |
ctx.tan | vec2f | Tangent vector along the path. | Calligraphy, direction-aware effects. |
ctx.grad | vec2f | Normal (perpendicular to the path). | Lighting / sidedness combined with tan. |
ctx.pos | vec2f | Screen 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 },
);
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 },
);