Skip to main content
Technical preview

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

Paths

canvas.drawPath(path, paint, options?) draws a Path. The paint picks the mode: with a stroke set, the path is stroked; without one, it is filled.

Under the hood a fill is one command whose shape is a tree over the path's segments, cut into small chunks: the union of the chunks' distance fields, signed by the sum of their winding contributions. The GPU prunes that tree per screen tile, so a pixel only measures the chunks within the paint's reach and only walks the chunks its winding ray can cross; tiles with no chunk in reach resolve to inside or outside from the winding alone. The chunking is computed once per path and cached, so the CPU does no per-frame work beyond staging it. Each fill costs a tile a couple of shape-program instructions plus a few per chunk near its outline, so scenes that layer many fills over one tile raise maxDrawingsPerTile (the instruction budget follows it) and can check readOverflow or the debug report for dropped commands.

A fill's field is a true distance only within the paint's reach of the outline (its stroke and feather spread, plus whatever its shaders declare); deeper inside it reads as far inside. A color shader that shades by ctx.sdf over some depth declares that depth as its maxCullDistance (on the function or on the binding), and the fill keeps exact distances that far in.

Building paths​

You can build a path from SVG data:

import { parseSVG } from "redraw";

const path = parseSVG("M 10 10 L 90 90 C 100 50, 50 50, 10 10 Z");

or with the path builder API:

import { PathBuilder, vec } from "redraw";

const cx = 100, cy = 100, size = 80;

const heart = new PathBuilder();
heart.moveTo(vec(cx, cy - size * 0.4));
heart.cubicTo(
vec(cx + size * 0.5, cy - size * 0.8),
vec(cx + size, cy - size * 0.2),
vec(cx, cy + size * 0.6),
);
heart.cubicTo(
vec(cx - size, cy - size * 0.2),
vec(cx - size * 0.5, cy - size * 0.8),
vec(cx, cy - size * 0.4),
);
heart.close();

// Paths are immutable
const path = heart.makePath();

PathBuilder is the imperative API: moveTo, lineTo, quadTo, cubicTo, close. Use close() when you want a closed contour; omit it for open ones. close() also starts a fresh contour, so chaining close().moveTo(...) works as expected for multi-contour paths.

Filling​

A paint without a stroke fills the path by winding number. Open contours are implicitly closed. The fillRule option selects the rule, "nonzero" (default) or "evenodd":

import { Paint, parseSVG } from "redraw";

const paint = new Paint().setColor("#e8a33d");
canvas.drawPath(path, paint);
canvas.drawPath(ring, paint, { fillRule: "evenodd" });

The tiger below is 138 SVG paths drawn as fills, re-recorded every frame under an animated zoom:

The Ghostscript Tiger: 138 path fills re-recorded every frameOpen in editor →

Stroking​

A paint with a stroke bands the path's distance field with the width your stroke function returns, so the width can vary along the path (read ctx.t for the arc-length position, ctx.tan for the tangent):

import { Paint, fitPath, createStrokeWidth } from "redraw";

const HelloStroke = createStrokeWidth(
(_ctx, _tctx, props) => {
"use gpu";
return props.width;
},
{ width: 0 },
{ maxStrokeWidth: 25 },
);

const paint = new Paint()
.addShader(PathGradient, { shift: 0 })
.setStroke(HelloStroke, { width: 25 });
canvas.drawPath(pathGeo, paint);
A stroked path with a gradient along the arc lengthOpen in editor →

See Custom Effects: Stroke for width functions (tapers, calligraphy pens, animated heads).

Stroke grouping​

In stroke mode, each group of segments composites as its own unit, and crossing groups composite in path order: translucent strokes darken where independent strands overlap, while self-intersections within a group blend as one coverage. The grouping option decides what joins a group:

  • "contour" (default): each contour is its own group.
  • "strand": follows pen continuity across contours; closed seams and pen-down contour joins continue the same strand.
canvas.drawPath(path, strokePaint, { grouping: "strand" });

Animating a reveal​

path.segment(t0, t1) returns the sub-path between two normalized arc-length parameters. Since the canvas is immediate mode, a draw-in effect is just a different segment each frame:

export function render(canvas: Canvas, { width, height, time }: FrameInfo) {
canvas.fill(background);
// Draw on over 4 seconds (time is in milliseconds).
const progress = Math.min(time / 4000, 1);
canvas.drawPath(pathGeo.segment(0, progress), paint);
}

Path helpers​

Available on any Path instance:

  • path.bounds(): axis-aligned bounding box.
  • path.segment(t0, t1): sub-path between two arc-length parameters in [0, 1].
  • path.splitContours(): array of single-contour paths, useful when an SVG has multiple disconnected sub-paths you want rendered independently.
  • path.fit(mode, src, dst): explicit fit ("contain" / "cover" / "fill").

And as module-level functions:

  • fitPath(svgStringOrPath, dst, fit = "contain"): parse-and-fit in one call. dst is a rect, either a Float32Array or { x?, y?, width, height } (x and y default to 0).
  • deflate(rect, amount): insets a rect on all sides, e.g. deflate({ width, height }, 100) for a padded canvas area.
  • capWidth(rect, maxWidth): limits a rect's width, keeping it centered.
  • fitbox(mode, src, dst): returns the fit matrix without applying it.