penpot/render-wasm
Belén Albeza 0581c35452
🐛 Fix drop shadows in masks (#11754)
* 🐛 Fix shadows on a masked group in the WASM renderer

A masked group renders in two passes: its content, then the mask shape
composited with DstIn so everything outside the mask silhouette is
erased. Both happen inside one save_layer, and the group's drop shadow
was composited into that same layer before the mask pass — so the mask
erased it. A drop shadow lives mostly outside the silhouette, so it
disappeared entirely.

Inner shadows never drew at all: render_fill_inner_shadows needs fill
geometry to paint into, and a group has none.

Both now ride on an image filter set on the masked-group layer, which
Skia evaluates after the mask is composited, so the shadow comes from
the real masked pixels rather than the group's own, empty geometry.
The effects compose in the order the SVG renderer uses for a group:
drop shadows, then the source, then inner shadows, with the layer blur
over all of it.

That layer is opened on a canvas carrying no transform, so the filter
is built in device units. Shadow::scale_to_device does that rather
than scale_content, because radius_to_sigma is affine: scaling the
radius applies its constant term once at device scale, while a filter
built in document space has the term scaled by the canvas matrix. The
two would blur differently by 0.5 · (scale - 1) sigma, visible as a
masked group's own shadow being narrower than the same shadow on its
parent. The masked-group layer blur had the same flaw.

Three paths are suppressed for masked groups so nothing is drawn twice:
the silhouette composite, the nested_shadows inheritance that would
reach text descendants, and the fill inner-shadow pass.

Every save and restore around that layer is keyed on the shape alone.
Enter and exit run on different walker passes, and a pan or zoom in
between changes fast mode, so deriving them from render state could
leave the canvas clip stack unbalanced.

Refs #11697

AI-assisted-by: claude-opus-5

* 🐛 Fix a container's drop shadow over a masked group in WASM

A container builds its drop shadow by drawing each descendant as a
black silhouette and blurring the result. The walk descends only
through children that can be flattened, and a masked group never can,
so it stopped there and asked the group to draw its own geometry. A
group has none, so nothing was drawn and the shadow layer stayed
blank: no shadow at all for a group, board or frame holding a masked
group. This is what the file attached to the issue reproduces.

render_drop_black_shadow now draws the masked silhouette for such a
group — content children flat black, DstIn the mask, and only then the
offset, blur and spread. Masking after the blur would trim the shadow
along the wrong edge.

The walk recurses, so it narrows the clip the way the main walker
does: content a clipping container hides must not widen the shadow.
The clip rule now lives in one place, shared with the walker, and a
test pins the two against each other. The shadow layer is sized to the
silhouette plus the shadow's reach rather than falling back to the
clip, so a wide blur is not cut at the tile edge.

Spread keeps the renderer's existing behaviour: the silhouette goes
through the same get_drop_shadow_filter every other shadow uses, so a
masked group gains no ordering of its own.

Closes #11697

AI-assisted-by: claude-opus-5
2026-09-18 13:04:11 +02:00
..
2026-03-18 18:05:30 +01:00
2026-09-18 13:04:11 +02:00
2026-08-06 16:13:06 +02:00
2026-08-06 16:13:06 +02:00
2026-08-06 16:13:06 +02:00
2026-08-06 16:13:06 +02:00
2026-08-06 16:13:06 +02:00
2026-02-25 10:30:29 +01:00

Penpot WASM render

This is the canvas-based WebAssembly render engine for Penpot.

Rust & Emscripten

This project is a Rust crate that targets Emscripten (wasm32-unknown-emscripten).

We use wasm32-unknown-emscripten compilation target:

  • It compiles Rust code into WASM
  • It generates the JavaScript code (“glue”) to load and run the WASM code

How Rust, Emscripten, and WASM are connected

Skia

We use Skia, an Open Source 2D graphics library. In particular, the render engine uses Skia via custom binaries of the rust-skia crate.

How to build

With the Penpot Development Environment running, create a new tab in the tmux.

cd penpot/render-wasm
./build

You can also use ./watch to run the build on every change.

The build script will compile the project and copy the .js and .wasm files to their correct location within the frontend app.

Render targets

The same Rust source produces two artifacts, which differ only in compiler options:

Target Tuned for Cargo profile Consumed by
frontend speed release (-O3) frontend/resources/public/js
export size size (-Oz) exporter/resources/wasm
./build            # both targets, frontend first
./build frontend   # workspace / viewer renderer
./build export     # headless exporter renderer

./watch still follows a single target (frontend unless you pass one), since watching both would rebuild twice on every keystroke.

Each target keeps its own CARGO_TARGET_DIR (target/<target>), so switching between them does not invalidate the other's cache. Set BUILD_MODE=release (or NODE_ENV=production) for an optimized build; the default is debug.

Each target writes its own generated shared.js (the enum discriminants the CLJS side compiles against) next to the code that imports it — respectively frontend/src/app/render_wasm/api/shared.js and exporter/src/app/wasm/shared.js. Neither build writes to the other's paths.

Architecture overview

Edit your local frontend/resources/public/js/config.js to add the following flags:

  • enable-feature-render-wasm to enable this render engine.
  • enable-render-wasm-dpr (optional), to enable using the device pixel ratio.

How to test

We currently have two types of tests:

  • Unit tests
cd penpot/render-wasm
./test

Technical documentation