Redraw is currently in technical preview, available to wcandillon.dev subscribers. API is unstable.
Custom Colors
A color function is authored like the stroke functions in
Stroke, with the createColor() helper. The callback returns a
straight-alpha RGBA, and the same geometry context is available (ctx.t,
ctx.sdf, ctx.d, ctx.tan, ctx.grad, tctx.pos), so colors can react
to where on the shape they're being painted.
Anatomy
import { createColor, Color } from "redraw";
import { std } from "typegpu";
const PathGradient = createColor(
(ctx, _tctx, _paint, props) => {
"use gpu";
const a = Color("#3FCEBC");
const b = Color("#DE589F");
return std.mix(a, b, std.fract(ctx.t + props.shift));
},
{ shift: 0 },
);
The callback receives (ctx, tctx, paint, props) and returns its color
(a vec4f); the pipeline stores the returned value. Trailing parameters
you don't use can be omitted; prefix skipped ones with _:
| Parameter | What it is |
|---|---|
ctx | The geometry context: ctx.t, ctx.sdf, ctx.d, ctx.tan, ctx.grad. |
tctx | The transform context: tctx.pos, tctx.worldPos. |
paint | Read-only paint state: paint.color (the color the steps before this one produced) and paint.strokeWidth (the width the stroke step recorded). |
props | The per-draw uniforms, typed from the defaults object. |
Add it with addShader: the first color shader is the paint's base
color, and later ones act as filters (each reads paint.color, the color
the steps before it produced, and returns its replacement):
// 0.2 palette cycles per second (time is in milliseconds):
const paint = new Paint().addShader(PathGradient, { shift: time * 0.0002 });
// or, appended after a base color:
paint.addShader(MyFilter, { amount: 0.5 });
As with every custom function, declare it in the canvas's Library.
What you return
return d.vec4f(r, g, b, a); // straight (un-premultiplied) alpha
Anything from (0, 0, 0, 0) (transparent) to (1, 1, 1, 1) (opaque
white). Values outside [0, 1] are valid for HDR-style blending but get
clamped on output.
Helpers
Color("#hex") parses a color at compile time into a vec4f for use
inside a GPU callback:
const palette = [Color("#3FCEBC"), Color("#DE589F"), Color("#FAEC54")];
interpolateColors(t, colors) lays the palette out start to end,
clamping outside [0, 1]:
return interpolateColors(ctx.t, palette);
interpolateColorsCyclic(t, colors) walks it cyclically instead (t
wraps via fract and the last color transitions back to the first),
which suits animated color shifts:
return interpolateColorsCyclic(ctx.t + props.shift, palette);
bilinearInterpolateColors(uv, top, bottom) blends two color rows
over a (u, v) chart point: top reads along uv.x at uv.y = 0, bottom
at uv.y = 1 (the rows may have different lengths). With the stroke
chart (strokeUV(ctx), the vec2 of ctx.t and strokeV(ctx)) it is a
two-edge gradient over a stroked path, the inline sibling of the
BilinearGradient binding:
return bilinearInterpolateColors(strokeUV(ctx), topRow, bottomRow);
There are also scalar and vector interpolators over explicit stops:
interpolate(t, stops, values), interpolate2, interpolate3,
interpolate4. All are exported from redraw and generate WGSL inline;
no runtime overhead beyond the actual color math.
Recipes
For plain gradients you don't need any of this: LinearGradient,
RadialGradient, GradientAlongPath, and BilinearGradient ship as
prebuilt bindings (see Gradients). The recipes below show
the machinery they're built on, for the effects that need more.
Palette along the path
The Hello example walks an 11-color palette using ctx.t, with a
shift prop animating the offset each frame:
const PathGradient = createColor(
(ctx, _tctx, _paint, props) => {
"use gpu";
const colors = [
Color("#3FCEBC"), Color("#3CBCEB"), Color("#5F96E7"),
Color("#816FE3"), Color("#9F5EE2"), Color("#DE589F"),
Color("#FF645E"), Color("#FDA859"), Color("#FAEC54"),
Color("#9EE671"), Color("#41E08D"),
];
const pos = std.fract(ctx.t + props.shift) * 10;
const i = d.u32(std.floor(pos));
const f = std.fract(pos);
const rgb = std.mix(colors[std.min(i, 10)], colors[std.min(i + 1, 10)], f);
return rgb.rgba;
},
{ shift: 0 },
);
// Every frame, 0.2 palette cycles per second (time is in milliseconds):
const paint = new Paint().addShader(PathGradient, { shift: time * 0.0002 });
Gradients from the position
tctx.pos is the position in drawing space, so a linear gradient is a
projection onto a direction (this is exactly what the built-in
LinearGradient does):
const PositionalGradient = createColor(
(_ctx, tctx, _paint, props) => {
"use gpu";
const dir = std.sub(props.p2, props.p1);
const t = std.clamp(
std.dot(std.sub(tctx.pos, props.p1), dir) /
std.max(std.dot(dir, dir), 0.000001),
0,
1,
);
const c0 = d.vec3f(0.553, 0.22, 0.667);
const c1 = d.vec3f(0.0, 0.29, 0.663);
return d.vec4f(std.mix(c0, c1, t), 1);
},
{ p1: [0, 0], p2: [0, 0] },
);
Shading by distance
ctx.sdf is the signed distance to the shape's edge (negative inside),
and on strokes ctx.d carries the normalized cross-stroke distance the
stroke step records: 0 on the centerline, 1 at the edges (with the
cap-extended ctx.t it forms the stroke's coordinate pair). So ctx.d
is directly the 0..1 distance from the centerline (centerline
highlights, tube shading), and ctx.d * paint.strokeWidth * 0.5 the
raw distance in local pixels. The side of the travel direction comes
from the strokeSide(ctx) helper (exported from redraw; +1 or -1 by
the sign of cross(ctx.tan, ctx.grad)): ctx.d * strokeSide(ctx) is
the signed -1..1 coordinate. For the 0..1 edge-to-edge position (0 one
edge, 0.5 the centerline, 1 the other edge; two-tone or sided strokes)
use the strokeV(ctx) helper, the v of the stroke's (u, v) chart
alongside ctx.t (strokeUV(ctx) packs the pair as one vec2). For
effects whose math assumes equal units on both axes, e.g. a screen-space
shader ported onto a stroke, strokePixelUV(ctx, paint) returns the
chart isotropic in pixel space: the centered arc length and the signed
cross-stroke coordinate, both in half-stroke-width units.
One subtlety on self-crossing strokes: ctx.d follows the nearest
strand, so it can step where fused strokes meet, while the sdf-based
recipe saturate((ctx.sdf + hw) / hw) (with hw = paint.strokeWidth * 0.5) measures the composited band and stays continuous across joins
and crossings. Reach for ctx.d when you need the side or cap
continuity, and for the sdf recipe when a tube or highlight must read
seamlessly through crossings (it is what the built-in tube shadings
use).