Skip to main content
Technical preview

Redraw is currently in technical preview, available to wcandillon.dev subscribers. API is unstable.

Hello World

Redraw's API has two steps, modeled after Metal's MTLLibrary:

  1. A Library declares your drawing vocabulary once: the custom GPU functions (colors, stroke widths, geometries, clips, feathers) your scene draws with. The std vocabulary (Circle, RoundedRect, ColorFill, Path, shape operators, feathers) is always included.
  2. A Canvas is spawned from the Library for a specific GPU texture. It is an immediate-mode recorder: every frame you record the scene and call render(), which draws and resets the recorder.

There is no retained scene graph and no handles to mutate: animation is just re-recording the scene each frame with new values.

The scene

A scene module exports two things: the library (its custom functions) and a render callback that draws one frame. The demo below renders a scene written in this style.

// hello-world.ts
import type { Canvas } from "redraw";
import {
Paint,
color,
capWidth,
deflate,
fitPath,
strokeWidth,
Color,
} from "redraw";
import { d, std } from "typegpu";
import type { FrameInfo } from "react-redraw";

const helloPath = "M13.6 247.8C13.6 247.8 51.8 206.1 84.2 168.8 ..."; // SVG data

// A gradient along the path, written in TypeScript: TypeGPU compiles the
// "use gpu" callback to WGSL. The defaults object types `props` and becomes
// the GPU-side props struct; `ctx.t` carries the along-path position.
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 },
);

// The stroke width, also in TypeScript: the callback returns the width and
// the pipeline bands the path SDF with it. `maxStrokeWidth` declares the
// widest width the callback can return, so tile binning never culls the band.
const HelloStroke = strokeWidth(
(_ctx, _tctx, props) => {
"use gpu";
return props.width;
},
{ width: 0 },
{ maxStrokeWidth: 25 },
);

export const library = {
functions: [PathGradient, HelloStroke],
// drawPath records one command per segment group per tile; the stroke
// crosses itself, so give headroom over the default 32.
maxPerTile: 64,
};

// Boomerang 0→1→0 over `period` milliseconds (FrameInfo.time is in ms).
const boomerang = (time: number, period: number) => {
const phase = (time % period) / period;
return phase < 0.5 ? phase * 2 : 2 - phase * 2;
};

const background = new Paint().setColor("white");

export function render(canvas: Canvas, { width, height, time }: FrameInfo) {
canvas.fill(background);
const pathGeo = fitPath(
helloPath,
capWidth(deflate({ width, height }, 30), 800),
);
const t = boomerang(time, 8000);
const progress = Math.min(t * 3, 1);
if (progress > 0.001) {
// 0.2 palette cycles per second.
const paint = new Paint()
.addShader(PathGradient, { shift: time * 0.0002 })
.setStroke(HelloStroke, { width: 25 });
canvas.drawPath(pathGeo.segment(0, progress), paint);
}
}
Hello, RedrawOpen in editor →

The rest of this page wires that scene into two hosts. The drawing code never changes; only the canvas plumbing does.

Using React

The React bindings own the WebGPU lifecycle and the animation loop. <RedrawCanvas> builds the Library from your functions, binds a canvas to the element, and calls render every frame:

import { RedrawCanvas, RedrawProvider } from "react-redraw";

import { library, render } from "./hello-world";

export function App() {
return (
<RedrawProvider>
<RedrawCanvas
style={{ width: "100%", aspectRatio: "4 / 3" }}
library={library}
render={render}
/>
</RedrawProvider>
);
}

See the React and React Native guides for the full component API.

Vanilla JS

Without the bindings you own the WebGPU device, the canvas element configuration, and the animation loop:

<!-- index.html -->
<!DOCTYPE html>
<html>
<body style="margin: 0">
<canvas id="my-canvas-id" style="display: block; width: 100vw; height: 100vh"></canvas>
<script type="module" src="./main.ts"></script>
</body>
</html>
// main.ts
import { createLibrary } from "redraw";

import { library as helloLibrary, render } from "./hello-world";

// 1. Acquire a WebGPU device.
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter!.requestDevice();

// 2. Configure the canvas element. The pipeline renders into a storage
// texture, so the swapchain format must be storage-bindable:
// rgba8unorm always is; on bgra8unorm platforms request the
// "bgra8unorm-storage" device feature instead (the React bindings do
// this negotiation for you).
const canvasEl = document.getElementById("my-canvas-id") as HTMLCanvasElement;
const dpr = window.devicePixelRatio || 1;
const width = canvasEl.clientWidth;
const height = canvasEl.clientHeight;
canvasEl.width = Math.round(width * dpr);
canvasEl.height = Math.round(height * dpr);
const context = canvasEl.getContext("webgpu")!;
context.configure({
device,
format: "rgba8unorm",
usage: GPUTextureUsage.STORAGE_BINDING,
alphaMode: "premultiplied",
});

// 3. Declare the vocabulary and bind a canvas to the surface. The canvas
// is long-lived: make it once, reuse it across frames. The swapchain
// texture is new every frame, but its size and format stay the same,
// so compile against the current one and hand each frame's texture to
// render().
const { functions, ...options } = helloLibrary;
const lib = createLibrary(device, functions, options);
const canvas = lib.makeCanvas(context.getCurrentTexture());

// 4. Drive the animation loop: record the scene, render, repeat. The time
// is in milliseconds, matching what the React bindings pass.
let frame = 0;
const start = performance.now();
const tick = () => {
const time = performance.now() - start;
// Draw in CSS pixels; the dpr scale maps them to device pixels.
canvas.scale(dpr);
render(canvas, { width, height, time, frame: frame++, dpr });
canvas.render(context.getCurrentTexture());
requestAnimationFrame(tick);
};
tick();