mirror of
https://github.com/penpot/penpot.git
synced 2026-06-16 04:12:03 +00:00
🔧 Add penpot-render-wasm skill
This commit is contained in:
parent
db1e2a9cfc
commit
938a86560d
67
.opencode/skills/penpot-render-wasm/SKILL.md
Normal file
67
.opencode/skills/penpot-render-wasm/SKILL.md
Normal file
@ -0,0 +1,67 @@
|
||||
---
|
||||
name: penpot-render-wasm
|
||||
description: Penpot render-wasm context loader — architecture, conventions, gotchas, and file map for the Rust/WASM render layer (`render-wasm/`) and its ClojureScript bridge (`frontend/src/app/render_wasm/`). Trigger this aggressively whenever the user is working on, planning, reviewing, debugging, or asking about anything render-wasm in Penpot. Specifically fire on: writing or editing Rust files under `render-wasm/src/`; writing or editing CLJS files under `frontend/src/app/render_wasm/` or workspace viewport/shape rendering code; planning a render-wasm refactor or feature (PDF export, drag/render path, binary props, WASM bindings, V3 text editor, tile cache, atlas); debugging render-wasm bugs (rendering glitches, crashes, panics, perf regressions, layout bugs, drag/zoom/pan bugs, text cursor/selection bugs); answering "how does X work" questions about the render layer; reviewing render-wasm diffs or PRs. Adjacent topics that should also fire: WASM↔CLJS FFI, Skia, dual-rendering (DOM transparent text + Skia visible), `SurfaceId`, `with_current_shape!` / `with_current_shape_mut!`, binary prop alignment / `transmute` conventions, `mem::write_bytes` / `mem::free_bytes`, the V3 text editor (`text-editor-wasm/v1`), drag/atlas/tile cache, `gesture_record!` instrumentation. Lean toward firing — if the user is in this codebase and the topic touches rendering, fire.
|
||||
---
|
||||
|
||||
# Penpot render-wasm
|
||||
|
||||
A pre-loaded context bundle for working on Penpot's render-wasm layer: the Rust + Skia render engine compiled to WebAssembly and the ClojureScript code that drives it.
|
||||
|
||||
## When this skill fires
|
||||
|
||||
Aggressively. The repo has several intertwined subsystems where a small change in one corner breaks invariants in another. The point of loading this skill is to put the relevant invariants in front of you *before* you write code, plan a refactor, or chase a bug.
|
||||
|
||||
Concretely: render-wasm work is anything under `render-wasm/`, `frontend/src/app/render_wasm/`, the workspace viewport renderer, or the shape/text rendering pipeline. If you're editing those, planning changes, debugging issues, or even just answering questions about how things work, this skill is in scope.
|
||||
|
||||
## Mental model in one paragraph
|
||||
|
||||
There is a single Rust render engine compiled to WASM. It owns a `State` (with a `ShapesPool`, a `RenderState`, and friends), drives Skia to render shapes onto layered surfaces, and exposes `#[no_mangle] extern "C"` functions to JS. The ClojureScript side (`frontend/src/app/render_wasm/api.cljs` and friends) calls these functions synchronously from the main thread to push shape data, drive interactive transforms, and request renders. Text is *dual-rendered*: a contenteditable DOM tree provides editing/selection input with `color: transparent`, while Skia draws the visible glyphs. Cursor and selection overlays are drawn on a dedicated `SurfaceId::UI` surface composited on top each frame. Drag/zoom/pan use a tile cache plus an interactive backbuffer crop cache; the V3 text editor (behind `text-editor-wasm/v1`) moves all editing state and overlays into Rust + Skia.
|
||||
|
||||
## Decide what to read
|
||||
|
||||
This skill bundles six references. Don't read them all unless you're orienting from scratch — pick by what you're about to do.
|
||||
|
||||
| You are about to... | Read |
|
||||
|---|---|
|
||||
| Write or edit Rust in `render-wasm/src/` | `references/conventions.md`, then any subsystem-specific reference |
|
||||
| Add a `#[no_mangle]` WASM export | `references/conventions.md` (binary props, `with_current_shape!`, `mem::write_bytes`/`free_bytes`) |
|
||||
| Add or modify a CLJS↔WASM bridge call | `references/conventions.md` + `references/file-map.md` (api.cljs section) |
|
||||
| Plan a render-wasm refactor | `references/architecture.md`, then `references/file-map.md` |
|
||||
| Debug a rendering glitch / crash / perf | `references/architecture.md` + `references/perf.md` |
|
||||
| Touch the V3 text editor | `references/v3-text-editor.md` |
|
||||
| Touch drag/zoom/pan/atlas/tile cache | `references/perf.md` (drag instrumentation, backbuffer crop cache pitfalls, why the drag-sprite approach was abandoned) |
|
||||
| Touch blur/shadow/filter rendering | `references/perf.md` (blur perf section) |
|
||||
| Verify a change builds | `references/build.md` (project commands; rebuild matrix per change type) |
|
||||
| Answer "where is X" without making changes | `references/file-map.md` |
|
||||
|
||||
## Workflow shapes
|
||||
|
||||
### Writing or editing render-wasm code
|
||||
|
||||
1. **Identify the subsystem you're touching.** Use `references/file-map.md` to find the canonical files.
|
||||
2. **Load the conventions for that subsystem** — at minimum the global ones in `references/conventions.md`. If you're touching binary deserialization, layout (flex/grid), text, or anything that runs on the worker thread, read the matching subsection in `conventions.md`.
|
||||
3. **Make the edit.** Cite invariants when you choose between approaches (e.g., "using `with_current_shape!` because we only need to read"; "guarding `document` access because this can be called from the dashboard worker").
|
||||
4. **Build using the project scripts.** Use `./build`, `./test`, `./lint` from `render-wasm/` (see `render-wasm/AGENTS.md` and `references/build.md`). Avoid `cargo check` — it tries to rebuild Skia from source and is much slower than the project scripts. CLJS-side bridge changes are verified by CLJS compilation; Rust changes need a `./build` before the frontend picks them up.
|
||||
|
||||
### Planning a refactor
|
||||
|
||||
1. **Read `references/architecture.md`** to refresh the mental model — especially state ownership, surface layering, and the render loop shape.
|
||||
2. **Read the subsystem reference(s)** for whatever the refactor touches.
|
||||
3. **Write a plan.** Call out which invariants the refactor preserves and which it changes. Convention violations are a source of bugs that don't show in tests; surface them explicitly.
|
||||
|
||||
### Debugging
|
||||
|
||||
1. **Triage by symptom** in `references/perf.md` (perf) or by file in `references/file-map.md`.
|
||||
2. **Cross-check known pitfalls** in `references/conventions.md` — many "weird" bugs are convention violations (binary prop alignment, missing `free_bytes`, calling `document` from a worker).
|
||||
3. **Use the `gesture_record!` macro** if you need per-stage timings during a gesture; the macro lives in `render-wasm/src/performance.rs`. See `references/perf.md` for the stage list and how to wire the CLJS receiver back in.
|
||||
4. **Static analysis is usually enough.** Penpot's render-wasm is well-instrumented and well-named; reading the relevant call sites with citations is faster than capturing a profile in most cases.
|
||||
|
||||
## Relationship to AGENTS.md
|
||||
|
||||
This skill complements the per-module `AGENTS.md` files (`render-wasm/AGENTS.md`, `frontend/AGENTS.md`). AGENTS.md is the authoritative source for module commands (build, test, lint) and short-form module orientation; the skill carries the deeper architecture, conventions, V3 internals, and perf design lessons that don't fit AGENTS.md's brevity. Read AGENTS.md for "how to work in this module"; read the references here for "how the render layer actually works".
|
||||
|
||||
## What this skill does NOT cover
|
||||
|
||||
- **Backend (Clojure / Postgres) and the asset pipeline.** Different codebase, different conventions.
|
||||
- **Pure-CLJS UI work outside the render pipeline** (sidebar forms, modal dialogs, etc.). Use Penpot's general patterns; this skill is render-specific.
|
||||
- **Standalone text editor (`frontend/text-editor/`)** unless it interacts with V3 — the embedded editor is the V3 case.
|
||||
174
.opencode/skills/penpot-render-wasm/references/architecture.md
Normal file
174
.opencode/skills/penpot-render-wasm/references/architecture.md
Normal file
@ -0,0 +1,174 @@
|
||||
# Architecture
|
||||
|
||||
The mental model for render-wasm and its CLJS bridge. Read this when planning a refactor or trying to orient on a new subsystem.
|
||||
|
||||
---
|
||||
|
||||
## Overall shape
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Penpot Workspace (CLJS, main thread)│
|
||||
│ Re-frame state, viewport, sidebar │
|
||||
└────────────┬────────────────────────┘
|
||||
│ frontend/src/app/render_wasm/api.cljs
|
||||
│ (sync FFI calls, JS objects, h/call)
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ render-wasm (Rust → WASM) │
|
||||
│ STATE: Box<State> │
|
||||
│ ├─ ShapesPool │
|
||||
│ ├─ RenderState │
|
||||
│ │ ├─ Surfaces (Skia) │
|
||||
│ │ ├─ Tile cache │
|
||||
│ │ ├─ backbuffer_crop_cache │
|
||||
│ │ └─ TextEditorState (V3) │
|
||||
│ └─ Modifiers, fonts, view, etc. │
|
||||
└────────────┬────────────────────────┘
|
||||
│
|
||||
▼ Skia GPU canvas → HTMLCanvasElement
|
||||
```
|
||||
|
||||
There is exactly one `STATE` global on the Rust side, accessed only through the macros in `main.rs:60-102` (see `conventions.md`). The CLJS side serializes work into FFI calls; nothing on the Rust side runs in parallel with the JS main thread (except the dashboard thumbnail worker, which has its own WASM instance).
|
||||
|
||||
---
|
||||
|
||||
## Module layout (`render-wasm/src/`)
|
||||
|
||||
| Path | Role |
|
||||
|---|---|
|
||||
| `main.rs` | WASM entry points (init, set_browser, render driver); access macros |
|
||||
| `state.rs`, `state/` | `State` struct (`shapes_pool.rs`, `text_editor.rs`); orchestration |
|
||||
| `shapes.rs`, `shapes/` | Shape model: `Shape`, `Type`, layouts, modifiers, paths, fills, strokes, text, transform |
|
||||
| `render.rs`, `render/` | Render loop, surfaces, fills/strokes/shadows/filters drawing, tile cache, UI overlay |
|
||||
| `wasm/` | `#[no_mangle]` exports grouped by subsystem (shapes, text, layouts, paths, blurs, fills, strokes, etc.) |
|
||||
| `tiles.rs` | Tile index and tile rect math |
|
||||
| `mem.rs` | WASM memory allocation: `write_bytes`, `free_bytes` (with global lock) |
|
||||
| `performance.rs` | `run_script!`, `gesture_record!`, perf markers |
|
||||
| `error.rs` | `Result<()>` + `#[wasm_error]` macro |
|
||||
| `view.rs` | Viewbox / zoom |
|
||||
| `fonts/` | Font loading & registration |
|
||||
| `math.rs`, `math/` | Geometry primitives (Rect, Point, Matrix), shared by shape and render code |
|
||||
|
||||
---
|
||||
|
||||
## CLJS bridge (`frontend/src/app/render_wasm/`)
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `api.cljs` | The main bridge. `set-view-box`, `render-finish`, shape upload, sync-selection-rects!, update-text-rect! |
|
||||
| `text_editor.cljs` | V3 text editor JS FFI wrappers |
|
||||
| Other namespaces | Shape-type-specific exports, fonts, exports |
|
||||
|
||||
Workspace components that drive the bridge:
|
||||
|
||||
- `frontend/src/app/main/ui/workspace/viewport_wasm.cljs` — viewport component, calls `set-view-box`, manages `[@canvas-init? selected-shapes transform]` effect
|
||||
- `frontend/src/app/main/ui/workspace/viewport/actions.cljs` — pointer event handlers (`on-pointer-move`, `schedule-zoom!`, `schedule-scroll!`)
|
||||
- `frontend/src/app/main/ui/workspace/viewport/hooks.cljs` — `setup-hover-shapes`, hover stream coordination
|
||||
- `frontend/src/app/worker.cljs` — worker bridge with `ask-buffered!` (dedupe-by-cmd, 1ms debounce)
|
||||
- `frontend/src/app/worker/selection.cljs` — quadtree selection queries (worker-side)
|
||||
|
||||
---
|
||||
|
||||
## Render loop shape
|
||||
|
||||
Two main entry paths into the render loop:
|
||||
|
||||
1. **Non-interactive frame** — full render of all visible shapes into Backbuffer + tile cache, then composite to Target. Triggered after viewbox changes settle (`set_view_end` debounced), file open, edits.
|
||||
2. **Interactive transform** (drag/resize/rotate) — fast path that uses the tile cache + backbuffer crop cache where possible. Avoids re-rendering shapes whose pixels can be blitted from the snapshot.
|
||||
|
||||
The loop body (in `render.rs::start_render_loop` and downstream) follows roughly:
|
||||
|
||||
```
|
||||
start_render_loop
|
||||
├─ if non-interactive: rebuild_backbuffer_crop_cache (snapshot for next drag)
|
||||
├─ partition pending tiles: cached / uncached_visible / uncached_interest
|
||||
├─ for each pending tile (visible first):
|
||||
│ ├─ if cached: blit
|
||||
│ └─ if uncached: render_shape_tree_partial → flush_and_submit
|
||||
└─ atlas_blit (atlas → target composite)
|
||||
```
|
||||
|
||||
The frame budget is 32 ms; the loop yields between batches in some places but not all (visible tiles render without yielding, by design — they need to be on screen *now*).
|
||||
|
||||
For drag-specific paths, see `references/perf.md`.
|
||||
|
||||
---
|
||||
|
||||
## State + shape ownership
|
||||
|
||||
`State` owns:
|
||||
|
||||
- `ShapesPool` — the canonical shape store, indexed by `Uuid`. Lookups via `tree.get(&uuid)`.
|
||||
- `RenderState` — render-side caches and Skia surfaces (separate from the shape data itself).
|
||||
- `current_shape_id` — the "selected" shape for `_set_shape_*` exports (the FFI is one-shape-at-a-time).
|
||||
- View, modifiers, fonts, options.
|
||||
|
||||
The CLJS side does NOT mirror the shape pool — it pushes shapes into WASM via `_set_shape_*` exports, then queries the pool indirectly through render results. This is one-way data flow; the Rust side is authoritative once shapes are uploaded.
|
||||
|
||||
---
|
||||
|
||||
## Surface layering (`render-wasm/src/render/surfaces.rs:31`)
|
||||
|
||||
The Skia surfaces, in roughly the order they're composited:
|
||||
|
||||
```
|
||||
SurfaceId::Tiles ← per-tile cached renders
|
||||
SurfaceId::Atlas ← composited tiles for blitting
|
||||
SurfaceId::Current ← per-shape sub-surface (Fills/Strokes/InnerShadows below)
|
||||
SurfaceId::Fills ← shape fill (used inside save_layer for layer blur)
|
||||
SurfaceId::Strokes ← shape stroke
|
||||
SurfaceId::InnerShadows ← inner shadow effect surface
|
||||
SurfaceId::Backbuffer ← full-frame composite source
|
||||
SurfaceId::Target ← what the browser sees
|
||||
SurfaceId::UI ← overlays drawn on top each frame
|
||||
```
|
||||
|
||||
Filter surfaces (for blur/shadow) come from `render::filters.rs::render_into_filter_surface`.
|
||||
|
||||
---
|
||||
|
||||
## Text rendering (high level)
|
||||
|
||||
Text is split between WASM and CLJS:
|
||||
|
||||
- **Layout & glyph drawing** in WASM: `shapes/text.rs`, `render/text.rs`, `render/fonts.rs`. Uses `skia::textlayout::Paragraph`. Output stored in `TextContentLayout.paragraphs: Vec<Vec<Paragraph>>`.
|
||||
- **DOM tree** in CLJS: `frontend/src/app/util/text/content/to_dom.cljs`, `styles.cljs`. Renders contenteditable with `color: transparent`, providing input/IME and selection ranges.
|
||||
|
||||
For V2 editor: cursor and selection are native DOM (the contenteditable does it). For V3 editor: cursor and selection are Skia overlays drawn from `TextEditorState`. See `references/v3-text-editor.md`.
|
||||
|
||||
---
|
||||
|
||||
## What "interactive transform" means
|
||||
|
||||
The render path branches on whether a transform is in flight (`is_interactive_transform()`). When true:
|
||||
|
||||
- Backbuffer is NOT refreshed (would invalidate the crop snapshot).
|
||||
- Cached drag crop images are blitted in place of full re-render for shapes that pass `is_safe_for_drag_crop_cache`.
|
||||
- `gesture_record!` instrumentation (when wired) only fires under this gate.
|
||||
|
||||
This is the fast-but-fragile path; bugs here usually take the form of "stale pixels stick around because we cached too eagerly" — see `references/perf.md` for the canonical example (frames with text descendants).
|
||||
|
||||
---
|
||||
|
||||
## What "fast mode" means
|
||||
|
||||
`fast_mode` is a flag on the render state set during view changes (`set_view_start` enables it; `set_view_end` disables it) and during preview rendering. While on:
|
||||
|
||||
- Frame-level blur (`frame_clip_layer_blur`) is skipped.
|
||||
- `render_preview` engages it explicitly to skip blur/shadow during thumbnail or scroll preview.
|
||||
- Shape rendering uses cheaper paths where available.
|
||||
|
||||
Fast mode is what lets pan/zoom be smooth on large files; without it, every frame would re-render shadows/blurs.
|
||||
|
||||
---
|
||||
|
||||
## CLJS-side state coordination
|
||||
|
||||
Re-frame state holds the workspace data; the WASM bridge reads from subscriptions and pushes into Rust. Key effects to be aware of:
|
||||
|
||||
- `mf/with-effect [vbox zoom]` in `viewport_wasm.cljs` — fires on viewport changes, calls `set-view-box`.
|
||||
- `mf/with-effect [@canvas-init? selected-shapes transform]` — drives `sync-selection-rects!` for the UI overlay.
|
||||
- `setup-hover-shapes` hook in `viewport/hooks.cljs` — subscribes to `move-stream`, sends `ask-buffered!` queries to the worker.
|
||||
|
||||
Backpressure is handled mostly by `ask-buffered!` (drop stale by command) and rAF-coalescing for zoom/pan. Hot paths to watch are anything that fires on every pointermove or every modifier tick.
|
||||
73
.opencode/skills/penpot-render-wasm/references/build.md
Normal file
73
.opencode/skills/penpot-render-wasm/references/build.md
Normal file
@ -0,0 +1,73 @@
|
||||
# Build and Verification
|
||||
|
||||
How to build, test, and verify render-wasm changes. Authoritative source for module commands is `render-wasm/AGENTS.md`; this reference adds context that doesn't fit there.
|
||||
|
||||
---
|
||||
|
||||
## Module commands (`render-wasm/AGENTS.md`)
|
||||
|
||||
```bash
|
||||
./build # Compile Rust → WASM (requires Emscripten environment)
|
||||
./watch # Incremental rebuild on file change
|
||||
./test # Run Rust unit tests (cargo test)
|
||||
./lint # clippy -D warnings
|
||||
cargo fmt --check # Format check
|
||||
```
|
||||
|
||||
Single-test invocations:
|
||||
|
||||
```bash
|
||||
cargo test my_test_name # by test function name
|
||||
cargo test shapes:: # by module prefix
|
||||
```
|
||||
|
||||
Build output lands in `../frontend/resources/public/js/` and is consumed directly by the frontend dev server.
|
||||
|
||||
The `_build_env` script sets the Emscripten paths and `EMCC_CFLAGS`. `./build` sources it automatically. The WASM heap is configured to 256 MB initial with geometric growth.
|
||||
|
||||
**About `cargo check`:** running `cargo check` on this crate tries to rebuild Skia from source, which is slow (minutes) and frequently fails on environment issues. The project scripts (`./build`, `./test`, `./lint`) drive the right toolchain and are the practical fast-feedback loop. Reach for `cargo check` only when you specifically want to validate something the project scripts don't cover.
|
||||
|
||||
---
|
||||
|
||||
## What changes require which rebuild
|
||||
|
||||
| Change | Required build |
|
||||
|---|---|
|
||||
| CLJS-only (e.g., `api.cljs` bridge logic, viewport components) | CLJS hot-reload picks it up |
|
||||
| Rust in `render-wasm/src/` | `./build` (WASM rebuild) |
|
||||
| WASM export signature change (`#[no_mangle] extern "C"` function) | `./build` AND update CLJS callers |
|
||||
| Binary prop layout change (`Raw*Data` struct) | `./build` AND update JS-side encoder in lockstep |
|
||||
|
||||
**Binary prop layout changes are the highest-risk class** because the `offset_of!` tests catch Rust-side drift but there's no equivalent on the JS encoder side. Always touch both ends in the same change and call out the JS-encoder update explicitly in the commit/PR.
|
||||
|
||||
---
|
||||
|
||||
## Verifying frontend integration
|
||||
|
||||
For CLJS-only bridge changes (`frontend/src/app/render_wasm/api.cljs` and friends):
|
||||
|
||||
- CLJS shadow-cljs hot-reloads on edit; the dev server picks up the change.
|
||||
- Reload the workspace tab to re-init the WASM module if you've changed init-time code.
|
||||
- Playwright tests for render-wasm: `npx playwright test --project=render-wasm` from `frontend/`. Config at `frontend/playwright.config.js`; the render-wasm project runs at 1920x1080, 2x DPR. Fixtures at `frontend/playwright/data/render-wasm/get-file-*.json`.
|
||||
|
||||
---
|
||||
|
||||
## Debugging tools
|
||||
|
||||
- **Browser DevTools — Performance tab.** Capture during the gesture you're investigating. The flamegraph names the suspect functions directly.
|
||||
- **`gesture_record!`** (see `perf.md`) — in-Rust per-stage timing emitted via `run_script!` to a JS receiver (`window.__penpotGestureRecord`). The macro is always compiled (cheap when the receiver is undefined); the CLJS-side buffer/report code lived at `frontend/src/app/util/perf.cljs` and is recoverable from history.
|
||||
- **`#[wasm_error]`** macro on exports surfaces panics as JS exceptions. Without it, a panic crashes the whole module — the workspace dies.
|
||||
- **`run_script!("console.log(...)")`** for ad-hoc Rust → console prints during dev. Don't ship these.
|
||||
|
||||
---
|
||||
|
||||
## When `./build` fails
|
||||
|
||||
Most likely causes, in order:
|
||||
|
||||
1. A new `#[no_mangle] extern "C"` export with a parameter type that doesn't cross the FFI boundary cleanly.
|
||||
2. A `transmute` that violates layout assumptions — usually `RAW_*_SIZE` is wrong or a struct field was reordered. The `offset_of!` tests should catch this; if they pass and the build still fails, suspect the JS-side encoder mismatch.
|
||||
3. A missing `?` propagation on a `Result` inside a `#[wasm_error]` function.
|
||||
4. Toolchain drift, especially after a major Skia bump or Emscripten upgrade.
|
||||
|
||||
Read the build output before guessing — it usually points at the file directly.
|
||||
157
.opencode/skills/penpot-render-wasm/references/conventions.md
Normal file
157
.opencode/skills/penpot-render-wasm/references/conventions.md
Normal file
@ -0,0 +1,157 @@
|
||||
# Conventions and Gotchas
|
||||
|
||||
The recurring rules to follow when writing render-wasm code. Each section names the invariant, *why* it exists (so you can judge edge cases), and where to look in the codebase for examples.
|
||||
|
||||
---
|
||||
|
||||
## State access macros (`render-wasm/src/main.rs:60-102`)
|
||||
|
||||
The Rust side stores a single `STATE: Option<Box<State>>` global. Direct access requires `unsafe`; do not write that yourself. Use the macros:
|
||||
|
||||
| Macro | Mutability | When to use |
|
||||
|---|---|---|
|
||||
| `with_state!(state, { ... })` | read-only state | Reading state without touching the current shape |
|
||||
| `with_state_mut!(state, { ... })` | mutable state | Mutating state (render options, view, etc.) without touching a shape |
|
||||
| `with_current_shape!(state, \|shape: &Shape\| { ... })` | read-only shape, read-only state | Querying a shape's properties |
|
||||
| `with_current_shape_mut!(state, \|shape: &mut Shape\| { ... })` | mutable shape, mutable state, **calls `state.touch_current()`** | Modifying a shape — this is what most `set_shape_*` exports use |
|
||||
| `with_state_mut_current_shape!(state, \|shape: &Shape\| { ... })` | read-only shape, mutable state | Reading a shape but needing mutable state for something else (no auto-touch) |
|
||||
|
||||
The `touch_current()` call in `with_current_shape_mut!` marks the shape dirty for re-render. If you bypass this (using `_state_mut_current_shape!` for a read-modify-write), the shape will not invalidate. That's almost always a bug.
|
||||
|
||||
**Why these exist:** the alternative is `unsafe { STATE.as_mut() }` everywhere. Centralizing the access pattern keeps the unsafe footprint small and makes the dirty-tracking automatic.
|
||||
|
||||
---
|
||||
|
||||
## Binary prop deserialization (`render-wasm/src/wasm/shapes/base_props.rs`, `wasm/text/effect_props.rs`)
|
||||
|
||||
Many WASM exports take a binary blob from JS instead of individual numeric arguments. The convention is:
|
||||
|
||||
1. Define a `RawXxxData` struct mirroring the JS-side binary layout. Use `#[repr(C)] #[repr(align(4))]`.
|
||||
2. Implement `From<[u8; SIZE]>` via `unsafe { std::mem::transmute(bytes) }`.
|
||||
3. Add helper methods on the struct for flag parsing, enum conversion, etc. — keep the struct dumb, put logic in helpers.
|
||||
4. Write tests that assert:
|
||||
- `std::mem::size_of::<RawXxxData>()` matches expected size
|
||||
- `std::mem::align_of::<RawXxxData>()` is 4
|
||||
- `std::mem::offset_of!(RawXxxData, field)` for each field — guards against silent reordering
|
||||
- A round-trip `from_bytes(known_bytes) → RawXxxData` produces expected field values
|
||||
|
||||
**Worked example:** `RawBasePropsData` at `render-wasm/src/wasm/shapes/base_props.rs:19-100`, with the test fixture starting at line 175.
|
||||
|
||||
**Why so strict:** the JS side encodes these blobs by layout-aware bit packing. Any silent struct reordering (e.g., a Rust compiler decision to move a `u8` field for alignment) corrupts every shape upload without a compile error. The `offset_of!` tests are the only thing that catches this before runtime.
|
||||
|
||||
---
|
||||
|
||||
## WASM memory: `mem::write_bytes` and `mem::free_bytes` (`render-wasm/src/mem.rs`)
|
||||
|
||||
`mem::write_bytes(bytes)` allocates a buffer in WASM memory and returns a pointer the JS side reads. **It uses a global lock.** You must call `mem::free_bytes()` before the next `write_bytes` call, or the lock blocks.
|
||||
|
||||
The pattern (e.g., `wasm/text.rs:307-343`):
|
||||
|
||||
```rust
|
||||
mem::free_bytes()?; // release any prior allocation
|
||||
let ptr = mem::write_bytes(bytes)?; // allocate + copy + return pointer
|
||||
// ... JS reads from ptr ...
|
||||
// next time: free_bytes() before write_bytes() again
|
||||
```
|
||||
|
||||
If you forget the `free_bytes`, the second call deadlocks (or panics, depending on the path) and the symptom is a frozen WASM call from JS. If you double-`free_bytes`, that's a no-op and harmless.
|
||||
|
||||
---
|
||||
|
||||
## Worker-safe `run_script!` calls (`render-wasm/src/performance.rs`)
|
||||
|
||||
The `run_script!` macro evaluates a JS expression from Rust. It's used heavily for performance markers, console logs, and the `gesture_record!` instrumentation.
|
||||
|
||||
**`render_sync` and `render_sync_shape` run inside the dashboard thumbnail worker thread**, where `document` is undefined. Any `run_script!` that references `document` will crash with `wasm-critical`.
|
||||
|
||||
The fix is to guard inside the JS expression itself:
|
||||
|
||||
```rust
|
||||
run_script!(format!(
|
||||
"typeof document !== 'undefined' && document.{}",
|
||||
something
|
||||
));
|
||||
```
|
||||
|
||||
**Why this is easy to miss:** the `run_script!` invocation looks identical between main-thread and worker call paths. The crash only triggers when a thumbnail is rendered, which may not happen in interactive testing — so a missing guard can ship and only fail when a teammate hits the dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Layout `MIN_SIZE` sentinel (`shapes/modifiers/flex_layout.rs:16`, `shapes/modifiers/grid_layout.rs:15`)
|
||||
|
||||
Both flex and grid layout use `const MIN_SIZE: f32 = 0.01;` as a non-zero sentinel for tracks/lines. **Don't suggest replacing it with `0.0`.** The 0.01 is intentional: division and clamping later in the layout pipeline produce NaN or infinities at exactly zero, and propagating a small positive sentinel through the math is cheaper than scattering `if x == 0.0` guards.
|
||||
|
||||
If the value annoys you (it propagates into rounded sizes that show up off-by-a-pixel), compensate around it (snap to integer at the boundary), don't remove it.
|
||||
|
||||
---
|
||||
|
||||
## Build commands and verification (`references/build.md` for the full version)
|
||||
|
||||
The render-wasm crate has its own build/test/lint commands documented in `render-wasm/AGENTS.md` (`./build`, `./test`, `./lint`, `cargo fmt --check`). `cargo check` against this crate is slow because it rebuilds Skia from source — prefer the project scripts. CLJS-side bridge changes are verified by CLJS compilation; Rust changes need a WASM rebuild before the frontend picks them up.
|
||||
|
||||
---
|
||||
|
||||
## Error handling: `Result<()>` and `#[wasm_error]`
|
||||
|
||||
WASM exports return `Result<()>` and are wrapped in `#[wasm_error]` (see `render-wasm/src/error.rs`). The macro converts a Rust error into a JS-visible exception. Inside an export, prefer `?` propagation; only return `Ok(())` at the end.
|
||||
|
||||
For panics (which shouldn't happen but do): they crash the WASM module, taking the workspace with them. If you're tempted to `unwrap()` something speculative, prefer `if let Some(...)` and an early return.
|
||||
|
||||
---
|
||||
|
||||
## Surface layering and `SurfaceId`
|
||||
|
||||
`SurfaceId` (`render-wasm/src/render/surfaces.rs:31`) names the layered Skia surfaces. The relevant ones:
|
||||
|
||||
- `SurfaceId::Target` — the final canvas the browser sees
|
||||
- `SurfaceId::Backbuffer` — full-frame composite of all shapes
|
||||
- `SurfaceId::Current` — per-shape sub-surface used during shape rendering
|
||||
- `SurfaceId::UI` — overlays composited on top of `Target` each frame (selection rects, drag visuals, debug)
|
||||
- `SurfaceId::Atlas`, `SurfaceId::Tiles` — the tile cache surfaces
|
||||
- `SurfaceId::Fills`, `SurfaceId::Strokes`, `SurfaceId::InnerShadows` — sub-surfaces for shape effects
|
||||
|
||||
**Convention:** `SurfaceId::UI` is composited on top of the render target each frame in `render/ui.rs`. Anything drawn there should be world-space-transformed before draw (apply `scale(zoom*dpr) + translate(-vbox.left,-vbox.top)`). Selection rects use `skia::Path::polygon` + `canvas.draw_path`.
|
||||
|
||||
The CLJS side syncs UI overlays via `sync-selection-rects!` in `api.cljs` (clears + adds 4 corners per selected shape), driven by an effect in `viewport_wasm.cljs` that watches `[@canvas-init? selected-shapes transform]`.
|
||||
|
||||
---
|
||||
|
||||
## Dual rendering: DOM transparent + Skia visible
|
||||
|
||||
Text is rendered twice. The DOM contenteditable tree (V2 or V3) has `color: transparent` — it exists to capture native input/IME, selection ranges, and (in V2) cursor blink. Skia draws the visible glyphs on the render target.
|
||||
|
||||
**Implications:**
|
||||
|
||||
- DOM measurements (getBoundingClientRect, range rects) and Skia paragraph layout must stay in sync. The hook point is `update-text-rect!` in `api.cljs`, called when WASM layout is up-to-date.
|
||||
- The editor instance lives at `:workspace-editor` in app state; the root DOM is `(.-root editor)`.
|
||||
- `TextContentLayout.paragraphs` is `Vec<Vec<skia::textlayout::Paragraph>>`; the first inner element has the geometry the renderer uses.
|
||||
- For V3 (`text-editor-wasm/v1`), the cursor and selection are *not* DOM — they're Skia overlays drawn on `SurfaceId::UI`, driven by `TextEditorState`. See `references/v3-text-editor.md`.
|
||||
|
||||
---
|
||||
|
||||
## Drag rendering: tile cache vs backbuffer crop cache
|
||||
|
||||
Two caches are involved during drag:
|
||||
|
||||
- **Tile cache** (`render-wasm/src/render.rs`, `tiles.rs`) — chunks the frame into tiles, caches rendered tiles, invalidates on modifier fan-out.
|
||||
- **Backbuffer crop cache** (`render::rebuild_backbuffer_crop_cache`, `is_safe_for_drag_crop_cache`) — for non-overlapping top-level shapes, snapshots a region of `Backbuffer` once at drag start and blits it during the drag instead of re-rendering the whole shape.
|
||||
|
||||
`is_safe_for_drag_crop_cache` (`shapes.rs:1394`) gates whether a candidate's cached image is *used*. It rejects: shape itself is `Type::Text`, frames with `clip_content=false` whose visible content exceeds bounds, shapes with blur, shadows, opacity != 1.0, or non-default blend mode.
|
||||
|
||||
**Historical pitfall:** a frame containing text *children* passes the gate (it's `Type::Frame`, not `Type::Text`), but the snapshot may have been taken before the text's paragraph layout completed — yielding a glyph-less fill rect during drag. The lesson is that `is_safe_for_drag_crop_cache` doesn't recurse through descendants; if the snapshot timing matters for what's inside, you need additional capture-time gating, not read-time gating.
|
||||
|
||||
---
|
||||
|
||||
## Don't add features beyond what's asked
|
||||
|
||||
This isn't a render-wasm-specific rule, but it bites here often: render-wasm has half-finished WIPs (PDF export, abandoned drag-sprite work). Don't extrapolate from one example to a generalization in your edit. If a function does X and the task is "do Y", do Y; don't refactor the X path "while you're here". See the root `AGENTS.md` for the broader principle.
|
||||
|
||||
---
|
||||
|
||||
## Pointer hygiene and `unsafe`
|
||||
|
||||
The Rust side has several `unsafe` blocks (state global, transmute for binary props, FFI call sites). When adding more:
|
||||
|
||||
- Document the safety invariant inline (`// SAFETY: ...`) — what makes this `unsafe` block sound.
|
||||
- Prefer wrapping into a safe macro/function so the `unsafe` is in one place.
|
||||
- For binary deserialization, the safety invariant is "the byte buffer matches the struct layout" — and the `offset_of!` tests are what enforces that invariant.
|
||||
111
.opencode/skills/penpot-render-wasm/references/file-map.md
Normal file
111
.opencode/skills/penpot-render-wasm/references/file-map.md
Normal file
@ -0,0 +1,111 @@
|
||||
# File Map
|
||||
|
||||
Quick lookup of where things live. Organized by subsystem rather than by alphabet — when you're answering "where does X happen", scan the relevant section.
|
||||
|
||||
---
|
||||
|
||||
## WASM entry points + state
|
||||
|
||||
- `render-wasm/src/main.rs` — `init`, `set_browser`, `clean_up`, render driver, **state access macros** (lines 60-102: `with_current_shape!`, `with_current_shape_mut!`, `with_state_mut_current_shape!`)
|
||||
- `render-wasm/src/state.rs` + `state/` — `State` struct, `ShapesPool`, `TextEditorState`
|
||||
- `render-wasm/src/error.rs` — `Result<()>`, `#[wasm_error]` macro
|
||||
- `render-wasm/src/mem.rs` — `write_bytes`, `free_bytes` (with global lock)
|
||||
- `render-wasm/src/performance.rs` — `run_script!`, `gesture_record!`, perf markers
|
||||
- `render-wasm/src/wapi.rs` — JS API helpers (request_animation_frame, cancel_animation_frame, etc.)
|
||||
|
||||
## Render path (Rust)
|
||||
|
||||
- `render-wasm/src/render.rs` — main render loop, `start_render_loop`, `process_animation_frame`, `render_shape_tree_partial`, tile cache, `rebuild_backbuffer_crop_cache`, `is_safe_for_drag_crop_cache` (read path)
|
||||
- `render-wasm/src/render/surfaces.rs` — `SurfaceId` enum + `Surfaces` struct, surface alloc/canvas access
|
||||
- `render-wasm/src/render/fills.rs`, `strokes.rs`, `shadows.rs` — per-effect drawing
|
||||
- `render-wasm/src/render/filters.rs` — `render_into_filter_surface` (with `extra_downscale` for adaptive blur)
|
||||
- `render-wasm/src/render/text.rs` — Skia text drawing
|
||||
- `render-wasm/src/render/text_editor.rs` — V3 cursor + selection overlay (`render_overlay`, `calculate_cursor_rect`, `calculate_selection_rects`)
|
||||
- `render-wasm/src/render/ui.rs` — `SurfaceId::UI` overlay compositing
|
||||
- `render-wasm/src/render/grid_layout.rs` — grid layout debug viz
|
||||
- `render-wasm/src/render/fonts.rs` — font management for rendering
|
||||
- `render-wasm/src/render/images.rs` — image fill rendering
|
||||
- `render-wasm/src/render/options.rs` — render options state
|
||||
- `render-wasm/src/render/debug.rs` — debug overlay
|
||||
- `render-wasm/src/render/gpu_state.rs` — Skia GPU context
|
||||
- `render-wasm/src/tiles.rs` — tile rect math, tile index
|
||||
|
||||
## Shapes (Rust model)
|
||||
|
||||
- `render-wasm/src/shapes.rs` + `shapes/` — `Shape`, `Type`, properties
|
||||
- `render-wasm/src/shapes/frames.rs`, `groups.rs`, `rects.rs`, `text.rs`, `bools.rs` — per-type
|
||||
- `render-wasm/src/shapes/paths.rs` + `paths/` — path geometry
|
||||
- `render-wasm/src/shapes/fills.rs`, `strokes.rs`, `shadows.rs`, `blurs.rs`, `blend.rs`, `corners.rs` — effects/properties
|
||||
- `render-wasm/src/shapes/layouts.rs` + `modifiers.rs` + `modifiers/` (`flex_layout.rs`, `grid_layout.rs`) — layout computation
|
||||
- `render-wasm/src/shapes/transform.rs` — shape transform (matrix)
|
||||
- `render-wasm/src/shapes/text_paths.rs`, `stroke_paths.rs` — derived paths
|
||||
- `render-wasm/src/shapes/svg_attrs.rs`, `svgraw.rs` — SVG-specific attrs
|
||||
|
||||
## WASM exports (the FFI surface)
|
||||
|
||||
- `render-wasm/src/wasm.rs` + `wasm/` — `#[no_mangle] extern "C"` functions grouped by subsystem
|
||||
- `render-wasm/src/wasm/shapes/base_props.rs` — `set_shape_base_props` + `RawBasePropsData` (canonical binary-prop example)
|
||||
- `render-wasm/src/wasm/text.rs`, `text/` — text shape exports
|
||||
- `render-wasm/src/wasm/text_editor.rs` — V3 editor exports (lifecycle, cursor, selection, editing, navigation, render overlay)
|
||||
- `render-wasm/src/wasm/text/helpers.rs` — V3 word boundary, cursor movement, deletion, insertion helpers
|
||||
- `render-wasm/src/wasm/layouts.rs`, `layouts/grid.rs` — layout exports
|
||||
- `render-wasm/src/wasm/paths.rs`, `paths/` — path exports
|
||||
- `render-wasm/src/wasm/fills.rs`, `fills/` — fill exports
|
||||
- `render-wasm/src/wasm/strokes.rs`, `shadows.rs`, `blurs.rs`, `blend.rs`, `transforms.rs`, `svg_attrs.rs`, `fonts.rs`, `mem.rs`
|
||||
|
||||
## Frontend bridge (CLJS)
|
||||
|
||||
- `frontend/src/app/render_wasm/api.cljs` — bridge: `set-view-box`, `render-finish`, `set-shape-*`, `sync-selection-rects!`, `update-text-rect!`, shape upload pipeline (`process-object`, `process-shapes-chunk`, `process-next-chunk`, `yield-to-browser`)
|
||||
- `frontend/src/app/render_wasm/text_editor.cljs` — V3 editor JS FFI wrappers
|
||||
- `frontend/src/app/render_wasm/exports/wasm.cljs` — exports/snapshot wiring
|
||||
- `frontend/src/app/render_wasm/...` — other bridge namespaces
|
||||
|
||||
## Workspace components (CLJS)
|
||||
|
||||
- `frontend/src/app/main/ui/workspace/viewport_wasm.cljs` — viewport, calls `set-view-box`, manages overlay effects, editor selection logic at lines 467-480 (V3→V2→V1)
|
||||
- `frontend/src/app/main/ui/workspace/viewport/actions.cljs` — pointer/wheel handlers (`on-pointer-move`, `schedule-zoom!`, `schedule-scroll!`, `on-mouse-wheel`)
|
||||
- `frontend/src/app/main/ui/workspace/viewport/hooks.cljs` — `setup-hover-shapes`, `over-shapes-stream`, hover query coordination
|
||||
- `frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs` — V3 contenteditable component (input wrapper)
|
||||
- `frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs` — V2 editor component
|
||||
- `frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss` — V2/V3 editor styles
|
||||
- `frontend/src/app/main/ui/workspace/shapes/text/editor.cljs` — V1 (legacy Draft-JS) editor
|
||||
|
||||
## Worker side
|
||||
|
||||
- `frontend/src/app/worker.cljs` — `ask-buffered!`, dedupe-by-cmd, 1ms debounce
|
||||
- `frontend/src/app/worker/selection.cljs` — quadtree selection queries
|
||||
- `frontend/src/app/util/worker.cljs` — `ask-buffered!` send wrapper
|
||||
|
||||
## Text content (CLJS)
|
||||
|
||||
- `frontend/src/app/util/text/content/to_dom.cljs` — DOM builder
|
||||
- `frontend/src/app/util/text/content/styles.cljs` — style mapping
|
||||
- `frontend/text-editor/src/editor/TextEditor.css` — standalone editor CSS
|
||||
|
||||
## Feature flags
|
||||
|
||||
- `frontend/src/app/main/features.cljs:38-41` — `render-wasm/v1`, `text-editor/v2`, `text-editor-wasm/v1` (V3)
|
||||
|
||||
## Tests
|
||||
|
||||
- `frontend/playwright/ui/render-wasm-specs/shapes.spec.js`, `texts.spec.js` — render-wasm Playwright tests
|
||||
- `frontend/playwright/data/render-wasm/get-file-*.json` — transit-encoded test fixtures
|
||||
- `frontend/playwright/ui/pages/WasmWorkspacePage.js` — page object
|
||||
- `frontend/playwright.config.js` — render-wasm project config (1920x1080, 2x DPR)
|
||||
|
||||
---
|
||||
|
||||
## Symptom → file map
|
||||
|
||||
| If you're chasing... | Open first |
|
||||
|---|---|
|
||||
| A drag visual glitch | `render.rs::rebuild_backbuffer_crop_cache`, `shapes.rs::is_safe_for_drag_crop_cache` |
|
||||
| A `wasm-critical` panic from a worker | `performance.rs::run_script!` call sites — guard `document` access |
|
||||
| A binary-prop deserialization mismatch | The relevant `Raw*Data` struct + its `offset_of!` tests |
|
||||
| A WASM call that hangs | `mem::write_bytes` without a preceding `mem::free_bytes` |
|
||||
| A flex/grid sizing oddity that's exactly 0.01 off | `MIN_SIZE` sentinel in `flex_layout.rs:16` / `grid_layout.rs:15` — don't remove it |
|
||||
| Hover/zoom/pan freezing the UI | `api.cljs::render-finish` → `_set_view_end` → `render.rs::rebuild_tile_index` (sync, main thread) |
|
||||
| Filter / blur / shadow perf | `render.rs::render_drop_black_shadow`, `render/filters.rs::render_into_filter_surface` (with `extra_downscale`) |
|
||||
| V3 cursor not where you expect | `render/text_editor.rs::calculate_cursor_rect`, Skia paragraph layout dependency |
|
||||
| Selection rect drawing oddly | `render/ui.rs`, `api.cljs::sync-selection-rects!` |
|
||||
| A text shape's measured rect being stale | `update-text-rect!` in `api.cljs` — hook point after WASM layout |
|
||||
137
.opencode/skills/penpot-render-wasm/references/perf.md
Normal file
137
.opencode/skills/penpot-render-wasm/references/perf.md
Normal file
@ -0,0 +1,137 @@
|
||||
# Performance Patterns
|
||||
|
||||
Recurring performance patterns in render-wasm and how to think about them. This isn't a bug catalog — it's the design lessons that have stuck.
|
||||
|
||||
---
|
||||
|
||||
## The frame budget
|
||||
|
||||
The render loop's frame budget is **32 ms** (two frames at 60Hz; nominal target is 16 ms but the loop tolerates the doubled budget). Anything that needs to happen "this frame" must fit, including:
|
||||
|
||||
- The full shape tree walk (or partial walk for the visible region)
|
||||
- Any modifier fan-out and tile invalidation
|
||||
- Skia draw calls for fills, strokes, shadows, blurs
|
||||
- Compositing surfaces and submitting to GPU
|
||||
|
||||
When a single sync WASM call exceeds 32 ms on the main JS thread, DOM events queue and the user perceives a freeze. See "Main-thread blocking" below.
|
||||
|
||||
---
|
||||
|
||||
## Main-thread blocking from sync FFI
|
||||
|
||||
The single most common Penpot perf failure mode. `api.cljs` is on the main JS thread; any sync WASM call inside it blocks DOM events for the duration.
|
||||
|
||||
**Hot offenders:**
|
||||
|
||||
- `_set_view_end()` → `rebuild_tile_index()` (called via `render-finish`, debounced 100 ms). Walks all top-level shapes; cost scales with N.
|
||||
- Shape upload pipeline (`process-object`, `process-shapes-chunk`). Chunks of ~100 shapes with `yield-to-browser` (MessageChannel-based, ~0 ms minimum) between batches, but each chunk blocks for its own duration.
|
||||
- Per-pointermove sync calls (`text-editor-pointer-move`, hit testing). Cheap individually; lethal when other work is also blocking.
|
||||
|
||||
**Diagnostic signal:** events arrive in bursts after a quiet period.
|
||||
|
||||
**Solution patterns:** chunk + yield (`yield-to-browser` via `MessageChannel`), throttle the trigger, defer until interaction end. `OffscreenCanvas` + worker-side rendering is the heavy variant; nothing in render-wasm currently uses it.
|
||||
|
||||
---
|
||||
|
||||
## Tile cache invalidation
|
||||
|
||||
Two failure shapes:
|
||||
|
||||
**Over-invalidation** — modifier fan-out walks ancestors + descendants + tile coverage. If `expanded_count >> input_count` or `invalidated_tiles >> expected`, the bug is the walk.
|
||||
|
||||
**Under-rendering** — pending tiles don't reach the visible priority bucket fast enough. The 4-way split (`pending:cached`, `pending:uncached_visible`, `pending:uncached_interest`, `pending:total`) is what to instrument.
|
||||
|
||||
Files: `render-wasm/src/render.rs::rebuild_touched_tiles` (modifier-driven), `start_render_loop` (pending-tile partitioning).
|
||||
|
||||
---
|
||||
|
||||
## Backbuffer crop cache (drag fast path)
|
||||
|
||||
`backbuffer_crop_cache` (`render.rs::rebuild_backbuffer_crop_cache`) snapshots a region of `Backbuffer` at non-interactive frame time, then blits it during drag instead of re-rendering. Big perf win when correct.
|
||||
|
||||
Two failure shapes:
|
||||
|
||||
- **Capture-before-layout** — snapshot taken before async work (font load, paragraph layout) finishes. Result: cache holds the container's fill but no glyphs. *Visual: dragging a frame with text children shows a colored rect with no text.* Fix is capture-time gating, not read-time gating (the cache is never modified during drag, so use-time checks are too late).
|
||||
- **Reading the cache when shape moved past invalidation** — stale tile reads. Less common.
|
||||
|
||||
Read-side gating: `is_safe_for_drag_crop_cache` in `shapes.rs` (rejects Type::Text, frames-with-overflow, blur, shadow, opacity, blend mode). It does NOT recurse into descendants — that's the trap with text children.
|
||||
|
||||
---
|
||||
|
||||
## Filter / blur / shadow
|
||||
|
||||
Historically: filters used `render_with_filter_surface`, which `surface_clone`s per filtered shape. Surface cloning is expensive; per-shape during pan/zoom dominates.
|
||||
|
||||
Current state (commit `337cfc2`, "Improve performance on shapes with blur"):
|
||||
|
||||
- **Layer blur via `save_layer`** — opens a `save_layer` with an `ImageFilter` on the Fills/Strokes/InnerShadows sub-surfaces directly. Avoids the surface clone, preserves clip correctness. (`render.rs:~793` `blur_sigma_for_layers`, `~1024` `save_layer` open.)
|
||||
- **Drop shadow adaptive downscale** — `blur_downscale = (BLUR_DOWNSCALE_THRESHOLD / shadow.blur).max(MIN_BLUR_DOWNSCALE)` for blur > 8 px. Gaussian blur is scale-equivariant, so downscaling + smaller sigma yields identical output with ~k³ less GPU work. Constants: `BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0`, `MIN_BLUR_DOWNSCALE: f32 = 0.125`.
|
||||
- **`render_into_filter_surface(extra_downscale, ...)`** — generalized parameter passed by callers. `extra_downscale = 1.0` → no change; `< 1.0` → pre-scale the filter canvas before drawing. Constants `MIN_FIT_SCALE = 0.1` (per-axis overflow clamp), `MIN_COMBINED_SCALE = 0.03` (sub-pixel surface floor).
|
||||
- **Fast mode** — `render_preview` engages `fast_mode` to skip blur/shadow during preview. Frame-level `frame_clip_layer_blur` is gated on `!fast_mode`.
|
||||
|
||||
If a filter regression appears, first check whether `fast_mode` is being engaged on the path you'd expect, then check the `extra_downscale` value flowing into the filter surface.
|
||||
|
||||
Files: `render-wasm/src/render.rs` (`blur_sigma_for_layers`, `save_layer` open, `render_drop_black_shadow`), `render-wasm/src/render/filters.rs` (`render_into_filter_surface`).
|
||||
|
||||
---
|
||||
|
||||
## `gesture_record!` instrumentation
|
||||
|
||||
The `gesture_record!` macro (`render-wasm/src/performance.rs`) emits `(stage, value)` tuples via `run_script!` into `window.__penpotGestureRecord`. The JS side (`frontend/src/app/util/perf.cljs` when wired) buffers and reports.
|
||||
|
||||
Always compiled (no feature flag) because the call site is cheap when `__penpotGestureRecord` is undefined. Pattern at the call site:
|
||||
|
||||
```rust
|
||||
let _instrument = self.options.is_interactive_transform();
|
||||
let _t = if _instrument { performance::get_time_ms() } else { 0.0 };
|
||||
// ... measured work ...
|
||||
if _instrument {
|
||||
let dt = performance::get_time_ms() - _t;
|
||||
crate::gesture_record!("stage_name", dt);
|
||||
}
|
||||
```
|
||||
|
||||
Stages from a past instrumentation pass (since removed; the macro and these names are documented here so they can be restored quickly when needed):
|
||||
|
||||
- `render:rebuild_touched_tiles`, `render:start_render_loop`, `render:atlas_blit`, `render:pending_tiles_update`
|
||||
- `render:shape_tree_partial`, `render:flush_and_submit`
|
||||
- `render:tile_cached`, `render:tile_uncached_shapes`, `render:apply_to_canvas`
|
||||
- `pending:total`, `pending:cached`, `pending:uncached_visible`, `pending:uncached_interest`
|
||||
- `nodes:initial_queue`, `nodes:processed`, `nodes:skipped_hidden`, `nodes:skipped_invisible`
|
||||
- `drag_subtree:modified`, `drag_subtree:total`
|
||||
- `drag_backdrop:capture_tiles`, `drag_backdrop:capture_ms`
|
||||
- `modifier_tiles:input_count`, `modifier_tiles:expanded_count`, `modifier_tiles:invalidated_tiles`
|
||||
|
||||
When restoring, gate on `is_interactive_transform()` unless you specifically want zoom/pan timing too. The CLJS-side receiver (`__penpotGestureRecord`) lived in `frontend/src/app/util/perf.cljs`; recover that file from history together with the Rust call sites if you bring back the full pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Drag-sprite approach: abandoned
|
||||
|
||||
The drag-sprite approach (captured sprite + atlas backdrop) was tried and abandoned. It was too fragile — every tile-cache eviction path needed a "drag active?" guard, and the surface area for "we forgot one" was unbounded.
|
||||
|
||||
The decision was to optimize the normal drag render path instead (backbuffer crop cache, fast mode, filter perf work above). 439 lines were removed from `render-wasm/src/{render,main,render/surfaces}.rs` when this was abandoned.
|
||||
|
||||
**Lesson:** when a perf optimization requires invariants enforced across many call sites, count the call sites first. If you can't enumerate them, the optimization will leak.
|
||||
|
||||
---
|
||||
|
||||
## CLJS-side perf: subscription churn
|
||||
|
||||
Subscriptions that re-evaluate on every modifier or every frame can dominate sidebar interaction cost. Patterns to watch:
|
||||
|
||||
- A sub depends on `objects-modified` (changes every modifier tick) when it could depend on a coarser signal.
|
||||
- A render component reads a sub that returns a fresh map/vector each call → equality breaks, re-renders cascade.
|
||||
- `with-meta` wrapping a value upstream → defeats memoization. (`viewport_wasm.cljs`'s `frame-titles*` had a spurious `with-meta` removed in the blur perf commit.)
|
||||
- Outline/overlay components rendering during pan when they could be hidden — gate on `(not panning)` like `show-frame-outline?` and `show-outlines?` in `viewport_wasm.cljs`.
|
||||
|
||||
---
|
||||
|
||||
## Decision principles
|
||||
|
||||
When optimizing render-wasm:
|
||||
|
||||
- **Prefer to make the slow path skip-able rather than make it faster.** `fast_mode`, `is_interactive_transform()`, and the cache check before render are all this pattern.
|
||||
- **Cache at non-interactive moments; blit during interactive moments.** Backbuffer crop cache, tile cache, atlas all follow this.
|
||||
- **Keep the JS main thread free.** Anything that doesn't need the main thread (worker queries, deferred WASM calls) should be off it. `OffscreenCanvas` is the eventual destination for the render loop itself if main-thread work becomes the dominant cost.
|
||||
- **Measure before refactoring.** `gesture_record!` is cheap to wire up; speculative optimizations have a poor ROI in this codebase because the hot paths are usually obvious in the timings once instrumented.
|
||||
133
.opencode/skills/penpot-render-wasm/references/v3-text-editor.md
Normal file
133
.opencode/skills/penpot-render-wasm/references/v3-text-editor.md
Normal file
@ -0,0 +1,133 @@
|
||||
# V3 Text Editor (`text-editor-wasm/v1`)
|
||||
|
||||
The V3 editor is the WASM-native text editor: all editing logic in Rust, cursor and selection rendered as Skia overlays on `SurfaceId::UI`. The CLJS side is a thin contenteditable wrapper that captures input events and forwards them to WASM.
|
||||
|
||||
---
|
||||
|
||||
## Editor generations
|
||||
|
||||
| Gen | File | Feature flag | Notes |
|
||||
|-----|------|--------------|-------|
|
||||
| V1 | `frontend/src/app/main/ui/workspace/shapes/text/editor.cljs` | (none / default) | Legacy Draft-JS |
|
||||
| V2 | `frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs` | `text-editor/v2` | DOM-based contenteditable, JS logic |
|
||||
| V3 | `frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs` | `text-editor-wasm/v1` | WASM-native, Skia overlay |
|
||||
|
||||
Selection logic in `viewport_wasm.cljs:467-480` checks flags in order: V3 → V2 → V1.
|
||||
|
||||
## Feature flags (`frontend/src/app/main/features.cljs:38-41`)
|
||||
|
||||
- `render-wasm/v1` — enables WASM rendering; **automatically enables** `text-editor/v2`
|
||||
- `text-editor/v2` — V2 editor (auto-enabled with render-wasm)
|
||||
- `text-editor-wasm/v1` — V3 editor (independent flag, only functional with render-wasm active)
|
||||
|
||||
---
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
User input on contenteditable div
|
||||
→ v3_editor.cljs event handler (keydown / beforeinput / compositionend / paste / cut / copy / dblclick)
|
||||
→ wasm.api/text-editor-* call (via render_wasm/text_editor.cljs)
|
||||
→ state/text_editor.rs processes operation (insert, delete, move, select)
|
||||
→ TextEditorState updated (selection, content, blink)
|
||||
→ text_editor_export_content() → JSON {paragraphs, spans, text}
|
||||
→ CLJS merge-exported-texts-into-content (preserves per-span styling)
|
||||
→ v2-update-text-shape-content Redux action
|
||||
→ request-render → Skia draws text + overlay (cursor / selection)
|
||||
```
|
||||
|
||||
Two key implications:
|
||||
|
||||
1. After every text mutation, the CLJS side must call `sync-wasm-text-editor-content!` to export and update Redux state. Forgetting this leaves the DOM and WASM out of sync.
|
||||
2. Cursor and selection are NOT DOM. They live in `TextEditorState` and render via `text_editor_render_overlay()` to `SurfaceId::UI`.
|
||||
|
||||
---
|
||||
|
||||
## Key files
|
||||
|
||||
### Rust / WASM
|
||||
|
||||
- `render-wasm/src/state/text_editor.rs` — `TextEditorState` struct: selection, blink, active shape, pointer tracking
|
||||
- `render-wasm/src/wasm/text_editor.rs` — `#[no_mangle]` exports: start/stop, cursor, selection, editing, navigation, render overlay
|
||||
- `render-wasm/src/wasm/text/helpers.rs` — word boundary detection, cursor movement (forward/backward/up/down/line-start/end), deletion, insertion
|
||||
- `render-wasm/src/render/text_editor.rs` — `render_overlay()`, `calculate_cursor_rect()`, `calculate_selection_rects()` using Skia
|
||||
|
||||
### Frontend (CLJS)
|
||||
|
||||
- `frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs` — V3 component (contenteditable + event handlers)
|
||||
- `frontend/src/app/render_wasm/text_editor.cljs` — JS FFI wrappers for all WASM text editor functions
|
||||
- `frontend/src/app/render_wasm/api.cljs` — public API re-exports, render loop integration (blink, overlay, poll-event)
|
||||
|
||||
---
|
||||
|
||||
## WASM export functions (`render-wasm/src/wasm/text_editor.rs`)
|
||||
|
||||
| Group | Exports |
|
||||
|---|---|
|
||||
| **Lifecycle** | `text_editor_start(a,b,c,d)`, `text_editor_stop()` |
|
||||
| **Cursor** | `text_editor_set_cursor_from_offset(x,y)`, `text_editor_set_cursor_from_point(x,y)` |
|
||||
| **Selection** | `text_editor_pointer_down/move/up(x,y)`, `text_editor_select_word_boundary(x,y)`, `text_editor_select_all()` |
|
||||
| **Editing** | `text_editor_insert_text()`, `text_editor_delete_backward(word_boundary)`, `text_editor_delete_forward(word_boundary)`, `text_editor_insert_paragraph()` |
|
||||
| **Navigation** | `text_editor_move_cursor(direction, word_boundary, extend_selection)` |
|
||||
| **Rendering** | `text_editor_render_overlay()`, `text_editor_update_blink(timestamp_ms)` |
|
||||
| **Events** | `text_editor_poll_event()` → `TextEditorEvent` (`ContentChanged`, `SelectionChanged`, `NeedsLayout`) |
|
||||
| **Export** | `text_editor_export_content()`, `text_editor_export_selection()` |
|
||||
|
||||
`text_editor_move_cursor` direction enum: `0`=Backward, `1`=Forward, `2`=LineBefore, `3`=LineAfter, `4`=LineStart, `5`=LineEnd. Boolean flags: `word_boundary`, `extend_selection`.
|
||||
|
||||
**Direction/flag enum drift is a common source of bugs** — keep CLJS-side constants in lockstep with Rust enums.
|
||||
|
||||
---
|
||||
|
||||
## V2 vs V3 differences
|
||||
|
||||
| Aspect | V2 | V3 |
|
||||
|---|---|---|
|
||||
| Text operations | JS / DOM mutations | Rust / WASM |
|
||||
| Cursor | DOM native | WASM state + Skia overlay |
|
||||
| Selection | DOM native | WASM state + Skia overlay |
|
||||
| State location | JS objects + Redux | WASM `TextEditorState` |
|
||||
| Styling | Direct DOM style manipulation | WASM stores per-span; exports JSON |
|
||||
| Synchronization | Event-driven DOM | Explicit export after each WASM op |
|
||||
|
||||
---
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
### Skia layout dependency for cursor rect
|
||||
|
||||
`calculate_cursor_rect` calls `get_rects_for_range()` and `get_line_metrics()` on the Skia paragraph. These return correct values only after the paragraph has been laid out (`paragraph.layout(width)`). If a cursor query fires before layout, it can return zeroed or stale rects.
|
||||
|
||||
**Fix shape:** always check that the paragraph is laid out before querying. The hook point in CLJS is `update-text-rect!` (in `api.cljs`), which fires after WASM layout completes.
|
||||
|
||||
### Cursor blink across start/stop
|
||||
|
||||
`text_editor_update_blink(timestamp_ms)` is called every frame. Verify that blink doesn't break on start/stop transitions — the blink phase resets on cursor movement, but a missed reset can leave the cursor invisible.
|
||||
|
||||
### Multi-line / cross-paragraph selection
|
||||
|
||||
`calculate_selection_rects()` must handle multi-line spans, cross-paragraph selections, and RTL text. RTL is the easy thing to forget; if RTL is in scope, write a test for it.
|
||||
|
||||
### Word boundary
|
||||
|
||||
`is_word_char()` (in `wasm/text/helpers.rs`) treats alphanumeric + underscore as word chars. Verify behavior matches platform conventions when adding new word-boundary cases (CJK, combining marks, emoji).
|
||||
|
||||
### Content export format
|
||||
|
||||
`text_editor_export_content()` returns JSON with `{paragraphs, spans, text}`. Verify it matches what `merge-exported-texts-into-content` in CLJS expects — schema drift here silently corrupts text content.
|
||||
|
||||
### Event handlers in `v3_editor.cljs`
|
||||
|
||||
Should handle: `keydown` (navigation, deletion), `beforeinput` (text insert), `compositionend` (IME), `paste`, `copy`, `cut`, `dblclick` (word select). Missing one of these doesn't immediately break the editor but leaves a feature gap.
|
||||
|
||||
### Feature flag gating
|
||||
|
||||
New V3 behavior should be behind `text-editor-wasm/v1`. Don't leak V3 changes into V2 paths — V2 stays as it is until V3 fully replaces it.
|
||||
|
||||
---
|
||||
|
||||
## Architectural distinctions
|
||||
|
||||
- **Dual rendering still applies.** The contenteditable DOM has `color: transparent`; Skia draws the visible text. V3 changes *only the cursor and selection* from DOM-native to Skia overlay; the visible text is still Skia-rendered in both V2 and V3.
|
||||
- **Editor instance** stored at `:workspace-editor` in app state; root DOM via `(.-root editor)`.
|
||||
- **State export** is the synchronization mechanism. After any WASM mutation, export → merge into CLJS content → dispatch to Redux. The merge is non-trivial because spans carry styling that the WASM side stores per-span.
|
||||
10
AGENTS.md
10
AGENTS.md
@ -32,6 +32,16 @@ precision while maintaining a strong focus on maintainability and performance.
|
||||
5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects
|
||||
`.gitignore` by default.
|
||||
|
||||
## Skills
|
||||
|
||||
Project-shipped skills live under `.opencode/skills/`. Notable skills for
|
||||
domain-specific work:
|
||||
|
||||
- `.opencode/skills/penpot-render-wasm/` — deeper architecture, conventions,
|
||||
V3 text editor internals, and performance design lessons for the
|
||||
Rust/WASM render layer and its CLJS bridge. Complements
|
||||
`render-wasm/AGENTS.md`.
|
||||
|
||||
## Changelogs
|
||||
|
||||
The project has two changelogs:
|
||||
|
||||
@ -30,8 +30,13 @@ configured to 256 MB initial with geometric growth.
|
||||
## Architecture
|
||||
|
||||
**Global state** — a single `unsafe static mut State` accessed
|
||||
exclusively through `with_state!` / `with_state_mut!` macros. Never
|
||||
access it directly.
|
||||
exclusively through the `with_state!`, `with_state_mut!`,
|
||||
`with_current_shape!`, `with_current_shape_mut!`, and
|
||||
`with_state_mut_current_shape!` macros (defined at the top of
|
||||
`src/main.rs`). Never access the global directly. Use
|
||||
`with_current_shape_mut!` when modifying a shape — it calls
|
||||
`state.touch_current()` to mark the shape dirty for re-render;
|
||||
read-modify-writes that bypass it will silently skip invalidation.
|
||||
|
||||
**Tile-based rendering** — only 512×512 tiles within the viewport
|
||||
(plus a pre-render buffer) are drawn each frame. Tiles outside the
|
||||
@ -48,11 +53,12 @@ parent/child relationships are tracked separately.
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `src/lib.rs` | WASM exports — all functions callable from JS |
|
||||
| `src/state.rs` | Global `State` struct definition |
|
||||
| `src/render/` | Tile rendering pipeline, Skia surface management |
|
||||
| `src/shapes/` | Shape types and Skia draw logic per shape |
|
||||
| `src/wasm/` | JS interop helpers (memory, string encoding) |
|
||||
| `src/main.rs` | WASM entry points (`init`, `clean_up`, render driver) and the state-access macros |
|
||||
| `src/state.rs`, `src/state/` | Global `State` struct, `ShapesPool`, `TextEditorState` |
|
||||
| `src/wasm/` | `#[no_mangle] extern "C"` exports grouped by subsystem (shapes, text, layouts, paths, fills, ...) |
|
||||
| `src/render.rs`, `src/render/` | Render loop, tile cache, Skia surface management |
|
||||
| `src/shapes.rs`, `src/shapes/` | Shape types, layouts, modifiers, transforms, per-shape draw inputs |
|
||||
| `src/mem.rs` | WASM memory allocation (`write_bytes`, `free_bytes`) used by the FFI |
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
@ -60,3 +66,10 @@ The WASM module is loaded by `app.render-wasm.*` namespaces in the
|
||||
frontend. ClojureScript calls exported Rust functions to push shape
|
||||
data, then calls `render_frame`. Do not change export function
|
||||
signatures without updating the corresponding ClojureScript bridge.
|
||||
|
||||
## Deeper Context
|
||||
|
||||
For longer-form architecture, conventions, V3 text editor internals,
|
||||
and performance design lessons, see the `penpot-render-wasm` skill at
|
||||
`.opencode/skills/penpot-render-wasm/`. This `AGENTS.md` is the
|
||||
short-form module guide; the skill carries the depth.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user