7. Rendering

render/build.ts turns derived data into a display list: a tree of draw items in absolute coordinates.

Esta página está em inglês; a tradução está a caminho.

7.1 Display list#

render/build.ts turns derived data into a display list: a tree of draw items in absolute coordinates.

ts
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#

ts
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 createImageBitmap with 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:

  1. RasterRenderer in Node (the ud render path), compared to the expected image exactly (a change here is a change of the reference and must be reviewed as such);
  2. Canvas2DRenderer in 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);
  3. Canvas2DRenderer with 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;
  4. SvgWriter and PdfWriter output, 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.