7. Rendering
render/build.ts turns derived data into a display list: a tree of draw items in absolute coordinates.
7.1 Display list#
render/build.ts turns derived data into a display list: a tree of draw items in absolute coordinates.
type DrawItem =
| { kind: "group"; opacity: number; blend: BlendMode; isolate: boolean; clip?: Path; mask?: MaskSpec;
effects: Effect[]; children: DrawItem[]; nodeId: NodeId }
| { kind: "fill"; path: Path; rule: "nonzero" | "evenodd"; paint: Paint; nodeId: NodeId }
| { kind: "stroke"; path: Path; stroke: StrokeSpec; paint: Paint; nodeId: NodeId }
| { kind: "glyphs"; run: GlyphRun; paint: Paint; nodeId: NodeId }
| { kind: "image"; asset: AssetHash; transform: Matrix; filters: ImageAdjustments; nodeId: NodeId };Strokes inside and outside are converted to clipped centre strokes (inside: clip to the fill path, double width; outside: clip to the outside). Per-side stroke weights, dashes, caps and arrowheads (STY-5) become paths in the builder, so renderers only stroke simple paths or fill outlines. Display lists are cached per node and invalidated with the derived data (§4.5).
7.2 The Renderer interface#
interface Renderer {
readonly kind: "canvas2d" | "svg" | "pdf" | "raster";
begin(target: RenderTarget, viewport: Viewport): void; // device pixel ratio, transform, clip
draw(list: DrawItem, images: ImageSource, glyphs: GlyphSource): void;
end(): RenderOutput; // nothing for canvas; bytes or text otherwise
}| Implementation | Where | Used for |
|---|---|---|
Canvas2DRenderer |
webview (in the render worker, ADR-10), dev page | the canvas (Canvas2D first, ADR-1; a WebGL renderer can be added behind the same interface) |
SvgWriter |
everywhere | SVG export (EXP-2), ud render --format svg, input of the raster path. Sets color-interpolation-filters="sRGB" on the root, so filters blend in the same space as Canvas2D (ADR-8) |
RasterRenderer |
host, CLI | PNG/JPG/WebP export, ud render, get_screenshot: SvgWriter output rasterised by resvg-wasm, then encoded (PNG by resvg; JPG and WebP by the WASM encoders of §15.4). Output above 8 192 × 8 192 px is rasterised in tiles and stitched by the encoder, so a --scale 4 screenshot of a large frame never allocates one pixmap of several gigabytes (§12). This is the reference renderer (ADR-8) |
PdfWriter |
host, CLI | PDF export (EXP-1, EXP-3): our own writer, vector paths, images as XObjects, text as embedded subset fonts (CFF/TrueType subsetting by text/subset.ts) or as outlines |
Paints and effects map as follows:
| Feature | Canvas2D | SVG / resvg |
|---|---|---|
| Solid, linear, radial gradient | native gradients | linearGradient, radialGradient |
| Angular (conic) gradient | one shared raster tile computed by render/paint.ts in TypeScript, drawn with createPattern / drawImage; createConicGradient is not used, because Skia’s conic interpolation and resvg’s would never be the same pixels |
the same tile as an <image> in a <pattern> |
| Diamond gradient | the same shared tile mechanism | same |
| Image fill (fill, fit, crop, tile, adjustments, STY-3) | drawImage / createPattern after adjustments applied by render/image.ts |
<image> / <pattern> of the adjusted bitmap |
| Blend modes (STY-7) | globalCompositeOperation |
mix-blend-mode; pass-through groups are not isolated |
| Drop / inner shadow, layer blur | offscreen layer + filter: blur(σ) (sRGB) |
feGaussianBlur with the same σ (Figma radius / 2), under color-interpolation-filters="sRGB" so both blur in sRGB; the remaining kernel difference is covered by the wider tolerance inside blur bounds (ADR-8) |
| Background blur | read back the tile below, blur, clip to the shape | backdrop pass: render what lies below into a pixmap, embed it as <image> under a blur filter, clip |
| Masks (DRW-8): alpha, vector, luminance | offscreen layers + destination-in / luminance conversion |
<mask> / <clipPath> |
7.3 Tiling and level of detail (canvas)#
- The visible area is covered by 512 × 512 device-pixel tiles at the current zoom bucket (powers of √2). Tiles
are rendered into
ImageBitmaps and cached (LRU, budget by device memory, default 256 MB). - Panning blits cached tiles and renders the missing ones within the frame budget; zooming scales the nearest bucket’s tiles at once and re-renders sharp tiles progressively (CAN-1, CAN-11).
- An edit invalidates the tiles that intersect the old and new render bounds of changed nodes (from the change record). Selection, hover, handles, guides and measurements are drawn on a separate overlay canvas and never invalidate tiles.
- LOD: a node smaller than 2 device pixels is skipped; a frame smaller than 8 px is drawn as a filled
rectangle of its average colour; text whose glyphs are under 3 px tall is drawn as grey bars per line; images
use the smallest mip level (built by
createImageBitmapwith resize at decode) that is at least the drawn size. - During a drag of many nodes (CAN-11), the dragged nodes are rendered once into a layer bitmap and moved as an image until pointer-up.
7.4 Hit testing#
render/hit.ts queries the page’s R-tree for candidates under a point or inside a marquee, then tests them
top-down in paint order against their real geometry (filled area, stroke area with a tolerance of 4 screen px,
text line boxes), respecting clipping and locked and hidden flags. It is pure TypeScript, so selection logic is
tested in Node (test/render/hit.test.ts) and the canvas uses the same function.
7.5 Golden image tests#
test/golden/ holds fixture .design files covering every paint, effect, blend mode, mask, stroke option,
text corpus and layout case, with expected PNGs. The expected PNGs are RasterRenderer output (ADR-8):
resvg is the reference, and updating an expected image is a reviewed change (npm run golden:update). The
suite renders each fixture through:
RasterRendererin Node (theud renderpath), compared to the expected image exactly (a change here is a change of the reference and must be reviewed as such);Canvas2DRendererin headless Chromium through the dev page with GPU rasterisation off (--disable-gpu-rasterization, software Skia), compared to the expected image with the NFR-8 metric: fewer than 0.5 % of pixels differ by more than 2/255 in any channel, with 8/255 allowed inside the render bounds of blur and shadow effects (the two Gaussian kernels are not the same);Canvas2DRendererwith GPU rasterisation on, where the runner has a GPU (not in CI), as a smoke test with a loose tolerance (2 % of pixels, 16/255), which catches a broken path or a missing fill, not a driver;SvgWriterandPdfWriteroutput, rasterised with resvg (PDF through its embedded page content rendered back to SVG by the test harness), compared to the expected image like step 1. JPG and WebP are lossy and are not compared; their encoders are tested by decoding and comparing to the PNG with a codec-specific threshold.
The prototype player is added to the suite at 0.5, when it exists. Diff images are written to
test/golden/out/ on failure.