- init_headless + RenderState::try_new_headless / Surfaces::try_new_headless: boot on CPU-raster placeholders, no GL context - render_shape_raster + render/raster.rs: CPU raster PNG export - CanvasCtx: GPU-free context shared by the PDF and raster canvas paths - get_fonts_for_shape + Shape/TextContent font enumeration - clear_fonts; ImageStore::without_gpu - _build_env: ENVIRONMENT=web,node so the module loads under Node
8.3 KiB
Rendering Architecture: Live (GPU) vs Vector/Canvas Export
Penpot's WASM engine has two render paths that must produce the same picture:
| Path | Purpose | Backend | Code |
|---|---|---|---|
| Live / GPU | On-screen workspace, thumbnails, PNG export | WebGL surfaces + Skia | render.rs::render_shape (+ render/{fills,strokes,shadows,text,...}.rs) |
| Vector / canvas | Vector PDF and raster PNG export, runnable headless (no GPU) | Single CPU Skia canvas | render/vector.rs → render/pdf.rs, render/raster.rs |
They share the same shape tree and the same low-level drawing primitives, but compose them differently. Keeping them in sync is the whole game — see Parity guards.
The vector/canvas path drives the same render_tree onto two surface kinds — a
PDF document (render/pdf.rs) or a CPU bitmap (render/raster.rs) — and, since
it never touches GPU surfaces, it can also run with no GL context at all
(see Headless mode).
Why two paths?
The live path draws each shape into many intermediate GPU surfaces (fills, strokes, shadows, …) and composites them. Compositing rasterises. That is fine for the screen and for PNG, but a PDF made that way would be a bitmap.
The vector path bypasses the GPU surface system and draws directly onto a
single Skia canvas, so for PDF output paths, text and fills come out as real
PDF vector operations (only inherently pixel-based effects — blur, blurred
shadows — are rasterised, by Skia's PDF backend). The same draw path onto a
CPU raster surface (render/raster.rs) gives a PNG without a GPU, which is also
what makes a browser-less / server-side render possible.
The two pipelines
flowchart TB
tree["Shape tree (ShapesPool)"]
subgraph GPU["Live / GPU path — render.rs"]
direction TB
g0["render_shape(shape)"]
g1["fast_mode? can_render_directly?<br/>tiles, clip stacks, nested fills/blurs"]
gF["fills::render → Surface::Fills"]
gS["strokes::render → Surface::Strokes"]
gI["shadows::* → Surface::InnerShadows"]
gD["drop shadows (tree level)<br/>→ Surface::DropShadows"]
gC["draw_shape_surface_stack_into<br/>composite surfaces → final z-order"]
g0 --> g1 --> gF --> gS --> gI --> gC
gD --> gC
end
subgraph SHARED["Shared primitives (one source of truth)"]
p1["draw_stroke_on_rect / draw_stroke_on_circle"]
p2["handle_stroke_caps (arrows, markers)"]
p3["render_inner_stroke / render_overlay_emoji (text)"]
end
subgraph VEC["Vector / canvas path — render/vector.rs"]
direction TB
v0["render_to_pdf / render_to_raster → render_tree(shape)"]
v1["render_group / render_frame / render_leaf<br/>concat centered_transform, save_layer for opacity/blur"]
v2["draw_drop_shadows (inline)"]
v3["render_leaf_content<R: ShapeRenderer><br/>fills → fill inner shadows → strokes → stroke inner shadows"]
v4["one Skia canvas (PDF doc or CPU bitmap)<br/>final z = draw call order"]
v0 --> v1 --> v2 --> v3 --> v4
end
tree --> g0
tree --> v0
gS -.uses.-> SHARED
v3 -.uses.-> SHARED
Key differences
| Aspect | Live / GPU | Vector |
|---|---|---|
| Drawing target | Many GPU surfaces, then composited | One Skia PDF canvas |
| Final z-order | Surface composite order (draw_shape_surface_stack_into) |
Order of draw calls |
| Drop shadows | Rendered at tree level into a separate surface (render_element_drop_shadows_and_composite) |
Drawn inline per shape/container (draw_drop_shadows / render_container_drop_shadows) |
| Images | GPU textures | CPU image copies (get_cpu_image) |
| Blur / blurred shadow | GPU filter passes | Rasterised by Skia's PDF backend |
| Perf machinery | tiles, fast_mode, can_render_directly |
none (one-shot export) |
Headless (GPU-free) mode
The vector/canvas path uses only fonts, images and sampling options from
RenderState — never the GPU surface system — so the engine can boot with no
WebGL context at all. This is what lets the export run in a browser without a
GL context, natively (the tests), or server-side under Node.
- Boot:
init_headless(globals.rs) builds the engine viaRenderState::try_new_headless, which skipsgpu_init()and uses CPU-raster placeholder surfaces (Surfaces::try_new_headless) the export path never reads. The interactive render loop is not available on such an instance. - Render:
render_shape_raster(PNG) andrender_shape_pdfgo through the samerender_tree, unchanged. - Images:
ImageStore::without_gpuhas no GPU context, so image fills are decoded on the CPU (Image::from_encoded) at draw time instead of uploaded as textures. As with fonts, the host must upload the image bytes (imageaddexport) for the fill to appear. - Fonts (on demand): the host uploads the fonts a shape needs before
rendering.
get_fonts_for_shape(→Shape::font_families) returns the distinct families used by a subtree — the WASM-tree equivalent of the browser'sget-content-fonts— so the host can fetch andstore_fontexactly those;clear_fontsresets the store between requests.
The native tests build a try_new_headless RenderState directly, so the whole
vector/raster path is exercised on the host with no GL context — see
render/raster_tests.rs, globals_tests.rs, state_tests.rs.
Export wiring (single vs multiple)
The client-side WASM export — rendering in the browser through the vector path
(render_shape_pdf / render_shape_pixels) — is wired only for single
exports (request-simple-export in frontend/.../exports/assets.cljs), and
only when render-wasm is active and the :wasm-export flag is set.
Multiple/batch export (request-multiple-export) always runs server-side
via the :export-shapes command; it passes an :is-wasm hint so the server can
use its own WASM renderer. That server-side renderer is exactly this engine in
headless mode (init_headless + render_shape_raster
/render_shape_pdf + get_fonts_for_shape); the host that drives it (fetching
the shape tree and fonts, calling these exports) lives outside render-wasm, in
the exporter.
Parity guards
Three compile-time guards plus shared code keep the two paths from drifting.
The contract is documented on the ShapeRenderer trait
(render/shape_renderer.rs).
- Capability guard.
ShapeRendereris the single declaration of per-shape rendering capabilities (draw_fills,draw_strokes,draw_drop_shadows, …). A new effect MUST be added as a trait method, not inline inrender_shape. Adding a method fails to compile until the vector backend handles it — so a feature can never be silently missing from PDF. - Type guard. Every
matchonshape.shape_typeinvector.rsis exhaustive (no_ =>). A newTypevariant fails to compile until handled. - Order guard. Leaf content draw order/gating lives in exactly one place:
vector::render_leaf_content<R: ShapeRenderer>. It is generic over the trait so the GPU backend could reuse it verbatim once it implementsShapeRenderer. - Shared primitives. Prefer reusing the live-render functions over
mirroring them:
draw_stroke_on_rect,draw_stroke_on_circle,handle_stroke_caps,render_inner_stroke,render_overlay_emoji. Whatever is still duplicated is the remaining drift surface.
File map
| What | Where |
|---|---|
| Vector entry / PDF | render/pdf.rs, render/vector.rs |
| Raster (PNG) entry | render/raster.rs |
| Parity trait | render/shape_renderer.rs |
| Order seam | render/vector.rs::render_leaf_content |
| Live shape render | render.rs::render_shape |
| Surface compositing | render.rs::draw_shape_surface_stack_into |
| Shared stroke geometry / caps | render/strokes.rs |
| Shared text render | render/text.rs |
| Headless boot | globals.rs::init_headless, render.rs::RenderState::try_new_headless, render/surfaces.rs::Surfaces::try_new_headless |
| GPU-free image store | render/images.rs::ImageStore::without_gpu |
| Font enumeration | wasm/fonts.rs::get_fonts_for_shape, shapes.rs::Shape::font_families |
| Headless tests | render/raster_tests.rs, globals_tests.rs, state_tests.rs |