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 color() helper. The callback writes a straight-alpha RGBA into paint.color, and the same geometry context is available (ctx.t, ctx.sdf, ctx.tan, ctx.grad, tctx.pos), so colors can react to where on the shape they're being painted.

Anatomy

import { color, Color } from "redraw";
import { std } from "typegpu";

const PathGradient = color(
(paint, ctx, tctx, props) => {
"use gpu";
const a = Color("#3FCEBC");
const b = Color("#DE589F");
paint.color = std.mix(a, b, std.fract(ctx.t + props.shift));
},
{ shift: 0 },
);

The callback receives (paint, ctx, tctx, props):

ParameterWhat it is
paintMutable: write paint.color (a vec4f); read paint.strokeWidth.
ctxThe geometry context: ctx.t, ctx.sdf, ctx.tan, ctx.grad.
tctxThe transform context: tctx.pos, tctx.worldPos.
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 and overwrites the color written by the steps before it):

// 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 write

paint.color = 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) walks a palette cyclically (the last color wraps back to the first):

paint.color = interpolateColors(ctx.t + props.shift, palette);

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

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 = color(
(paint, ctx, _tctx, 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);
paint.color = 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:

const LinearGradient = color(
(paint, _ctx, tctx, 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);
paint.color = 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 for strokes paint.strokeWidth carries the local width, so you can build centerline highlights or two-tone strokes: compute the distance from the centerline as ctx.sdf + paint.strokeWidth * 0.5 and mix toward a highlight color where it approaches zero.