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.

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