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:
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);
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.dstis a rect, either aFloat32Arrayor{ x?, y?, width, height }(xandydefault 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.