Skip to main content
Technical preview

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 _:

ParameterWhat it is
ctxThe geometry context: ctx.t, ctx.sdf, ctx.d, ctx.tan, ctx.grad.
tctxThe transform context: tctx.pos, tctx.worldPos.
paintRead-only paint state: paint.color (the color the steps before this one produced) and paint.strokeWidth (the width the stroke step recorded).
propsThe per-draw uniforms, typed from the defaults object.

A color that shades by ctx.sdf over some depth inside a path fill (a depth gradient, an inner glow) declares that depth as its maxCullDistance, in the createColor options or per binding (addShader({ fn, props, maxCullDistance })): a fill keeps its true distance only that far in from the outline, and reads as far inside beyond it. Reading the field within a pixel or two of the edge needs no declaration.

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 });
Eleven-color palette interpolated along ctx.t, drifting over timeOpen in editor →

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] },
);
A positional gradient shading a disc (inside a feathered layer)Open in editor →

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).