From 1d4029204040c48bf988fe902075c40d08e5cd0f Mon Sep 17 00:00:00 2001 From: BibekPathak Date: Sun, 6 Sep 2026 14:55:59 +0530 Subject: [PATCH 01/12] establish Canvas 2D baseline, ownership note, and migration inventory --- docs/canvas-architecture-m0.md | 263 ++++++++++++++++++ moli-benchmark/fixtures/canvas/README.md | 101 +++++++ .../fixtures/canvas/results/README.md | 44 +++ moli-benchmark/fixtures/canvas/runner.py | 122 ++++++++ moli-benchmark/fixtures/canvas/workloads.py | 188 +++++++++++++ moli-canvas/tests/baseline_cost.rs | 124 +++++++++ 6 files changed, 842 insertions(+) create mode 100644 docs/canvas-architecture-m0.md create mode 100644 moli-benchmark/fixtures/canvas/README.md create mode 100644 moli-benchmark/fixtures/canvas/results/README.md create mode 100644 moli-benchmark/fixtures/canvas/runner.py create mode 100644 moli-benchmark/fixtures/canvas/workloads.py create mode 100644 moli-canvas/tests/baseline_cost.rs diff --git a/docs/canvas-architecture-m0.md b/docs/canvas-architecture-m0.md new file mode 100644 index 000000000..d36838260 --- /dev/null +++ b/docs/canvas-architecture-m0.md @@ -0,0 +1,263 @@ +# Canvas 2D — M0 Baseline, Ownership Note, and Migration Inventory + +Status: implementation baseline (M0), not a description of completed work. + +This document is the first deliverable of the Canvas 2D re-architecture covered by +`canvas-2d-proposal.en.md`. It records the **current** (pre-migration) pixel +ownership, every read/write/reset/retirement entry point, a reproducible baseline, +and the routing checklist used to review the final integration. It is intentionally +a snapshot of reality, not of the target design. + +Reference revision for this note: branch `canvas_2D_moli`, commit `4d6e0373`. + +--- + +## 1. Building blocks / terminology + +| Term | Meaning in this project | +| --- | --- | +| Backing store | The V8 `Uint8ClampedArray` private slot on a canvas-like object that holds the writable RGBA8 pixels that all 2D operations mutate | +| CanvasResourceStore | `HashMap>` in `moli-renderer-v8/src/native_bridge/context_host/canvas_resources.rs`; the published, immutable, page-visible image per HTML canvas | +| Context state | V8 private-slot strings/numbers/bools on the context object: `fillStyle`, `font`, `globalAlpha`, `globalCompositeOperation`, `lineWidth`, `lineCap`, `lineJoin`, `miterLimit`, `lineDashOffset`, `lineDash`, `strokeStyle`, `imageSmoothingEnabled`, `imageSmoothingQuality` | +| Path state | `Canvas2dPathState` held per context in a weak-keyed store (`canvas/state.rs`), reclaimed by GC / isolate teardown | +| Rasterization | `moli_paint::raster_snapshot(&PaintSnapshot)`, invoked per draw by `rasterize_canvas_fragment()` in `canvas/context2d.rs` | +| Snapshot | `moli_image::RgbaImage` (straight-alpha RGBA8); the immutable unit pages and `getImageData`-adjacent consumers use | + +--- + +## 2. Current pixel ownership (two writable/observable planes today) + +There are **two** pixel stores that must be kept in sync, which is the core +redundancy the project removes: + +1. **Mutable backing store** — `CANVAS_BACKING_STORE_SLOT` (`__moliCanvasBackingStore`), a + `Uint8ClampedArray` owned by the canvas-like JS object. All 2D drawing and + `putImageData` mutate a **fresh byte copy** of this view, then write it back. + - Managed by `backing_store.rs`: `with_canvas_like_pixels_mut()`, `canvas_like_pixels_copy()`, `ensure_canvas_like_backing_store()`. + - It is straight-alpha RGBA8, row major, dimensions from the width/height slots. + +2. **Published page image** — `CanvasResourceStore.pixels_by_element: HashMap>`. + - `replace_canvas_pixels(handle, w, h, rgba)` replaces the whole stored + `Arc` on every mutation, bumps `VisualResourceGeneration`, and + enforces `MAX_RETAINED_CANVAS_PAINT_BYTES` (256 MiB). + - Page painting reads it via `JsContextHost::canvas_pixels_for_layout()` → + `source_view.rs` → `LayoutImageResource`. This is an **immutable snapshot** plane + already; it is not copied per pixel-read. + +The renderer's `rasterize_canvas_fragment()` performs, per-path-draw: + copy backing view -> Vec, build a fresh `PaintSnapshot` covering the **whole + canvas**, run `moli_paint::raster_snapshot` (allocates a new `VelloCpuImageRenderer` + and a full RgbaImage), `composite_rgba8_over` the premultiplied result into the + straight copy, write back, then `replace_canvas_pixels(...)` with the full copy. + +Other ordinary draws (`fillRect`, `clearRect`, `fillText`/`strokeText`, `drawImage`) +go through the same full-copy `with_canvas_like_pixels_mut` path but write pixels +directly instead of building a `PaintSnapshot`. `putImageData` writes raw pixels +directly (correct raw overwrite semantics) via `blit_image_data`. + +Consequence: cost scales with canvas area for hundreds of small operations because +every operation copies the whole plane and (for paths) allocates a fresh backend + full +raster. + +--- + +## 3. Entry-point inventory (complete routing checklist) + +Legend — **D** = ordinary draw, **W** = explicit pixel write, **R** = pixel read/ +observation, **S** = state/geometry (no pixel work), **RST** = reset/dimension, +**STUB** = existing incomplete implementation, **OOB** = out of scope for this +project. + +### 3.1 CanvasRenderingContext2D / OffscreenCanvasRenderingContext2D — `canvas/context2d.rs` + +| # | Entry point (fn/line) | Kind | Route today | Target stage | +|---|---|---|---|---| +| 1 | fillRect (700) | D | full-copy `with_canvas_like_pixels_mut` → `paint_rect` | M2 D record | +| 2 | clearRect (719) | D | full-copy → `paint_rect` `[0,0,0,0]` (destructive) | M2 record w/ ordered clear | +| 3 | fill / stroke (1066/1094) | D | `PaintSnapshot` via `rasterize_canvas_fragment` | M4 recorded path | +| 4 | strokeRect (1123) | D | builds temp rect path, same fragment route | M4 recorded | +| 5 | fillText / strokeText (1572/1586) | D | `with_canvas_like_pixels_mut` → `draw_text` (font8x8) | M1–M4 recorded text | +| 6 | drawImage (1618) | D | `html_image_pixels_copy`/`canvas_like_pixels_copy` then `blit_draw_image_filtered` | M4 recorded + source snapshot | +| 7 | putImageData (1856) | W | full-copy → `blit_image_data` (raw overwrite) | M2/M4 ordered pixel-write boundary | +| 8 | getImageData (1921) | R | `canvas_like_pixels_copy` → `extract_image_data` | M2/M5 flush+readback | +| 9 | measureText (1815) | S | `measure_text_width` | S | +| 10 | createImageData (1834) | S | alloc empty ImageData | S | +| 11 | isPointInPath (1564) | STUB | always returns `false` | STUB/OOB | +| 12 | createLinearGradient (1766) | STUB | returns inert object; `addColorStop` validates offset only, no rendering | STUB/OOB | +| 13 | setLineDash/getLineDash (1690/1742) | S | V8-array slot | S | +| 14 | noop (1683) | STUB | no-op | STUB | +| 15 | path builders (rect, beginPath, closePath, moveTo, lineTo, quadraticCurveTo, bezierCurveTo, arc, arcTo, ellipse) | S | `Canvas2dPathState` | M1 → `moli-canvas::path` | +| 16 | transform (translate/scale/rotate/transform/setTransform/resetTransform) | S | `Canvas2dPathState.transform` | M1 → `moli-canvas::context` | +| 17 | state setters/getters (fillStyle, strokeStyle, font, lineWidth/Cap/Join, miterLimit, lineDashOffset, globalAlpha, globalCompositeOperation, imageSmoothing*) | S | V8 private slots | M1 state → `moli-canvas::context` | +| 18 | reset_canvas_context_state (29) | RST | re-init slots + reset path | M4 reset | +| 19 | rasterize_canvas_fragment (1512) | (impl) | per-path full-frame page pipeline | M6 delete | +| 20 | composite_rgba8_over (1538) | (impl) | premult→straight composite helper | M2 replace w/ format module | + +### 3.2 Backing store / pixels — `canvas/backing_store.rs` + +| # | Entry point | Kind | Notes | Target | +|---|---|---|---|---| +| 21 | attach_canvas_like_context_object | init | links context↔canvas, initializes backing store | M3 ownership | +| 22 | canvas_2d_context (74) | R | returns stored 2D context object | M3 | +| 23 | canvas_owner_from_context (115) | R | reverse context→canvas | M3 | +| 24 | with_canvas_like_pixels_mut (122) | W | **full-copy mutate+write-back** (major copy source) | M6 remove for draws; keep for putImageData boundary until M4 | +| 25 | canvas_like_pixels_copy (144) | R | **full copy** (major copy source for drawImage/getImageData/page) | M6 replace with snapshot readback | +| 26 | reset_canvas_like_backing_store / reset_html_canvas_backing_store_for_dimension_assignment | RST | zero-fills backing store on dimension change | M4/M6 | +| 27 | canvas_like_to_data_url (93) | R | full copy → `encode_data_url` | M5 flush+encode | +| 28 | ensure_canvas_like_backing_store | alloc | lazily creates/zeros backing view | M3 surface | + +### 3.3 Canvas element (browser-facing) — `native_bridge/element/canvas.rs`, `context_bootstrap/canvas.rs` + +| # | Entry point | Kind | Notes | Target | +|---|---|---|---|---| +| 29 | HTMLCanvasElement width/height setters | RST | set reflected attribute → resets backing store + context | M3/M4 resize semantics | +| 30 | HTMLCanvasElement width/height getters | S | default 300×150 | S | +| 31 | getContext (`CanvasContextKind`) | init | returns cached context object per kind slot | M3 identity | +| 32 | toDataURL | R | `canvas_like_to_data_url` | M5 flush+encode | +| 33 | build HTML/Offscreen canvas objects | init | constructors | M3 | + +### 3.4 OffscreenCanvas — `canvas/offscreen.rs` + +| # | Entry point | Kind | Notes | Target | +|---|---|---|---|---| +| 34 | OffscreenCanvas width/height setters | RST | reset backing store | M3/M4 | +| 35 | getContext | init | builds 2D/WebGL context, attaches | M3 | +| 36 | convertToBlob (172) | STUB | resolves a blob of empty bytes | STUB/OOB | +| 37 | 2D context constructor, object init | init | `canvas_rendering_context_2d_constructor_callback` etc. | M3 | + +### 3.5 Publication and page painting + +| # | Entry point | Kind | Notes | Target | +|---|---|---|---|---| +| 38 | CanvasResourceStore.replace/remove/get | R | replaces entire `Arc` per change | M3 publish via snapshot | +| 39 | retire_canvas_resources_for_document | RST | removes images whose owner document retired, but *resolves ownership at retirement* to preserve adopted canvases | M3 lifecycle | +| 40 | canvas_pixels_for_layout → source_view.rs | R | page painting reads immutable `Arc` | M5 read snapshot | +| 41 | VisualResourceGeneration bump | RST | marks page dirty; drives repaint/screencast | M5 invalidation on record | + +--- + +## 4. Read / write / reset / retirement matrix + +**Pixel owners today** +- Mutable: V8 `Uint8ClampedArray` (canvas-like object). +- Immutable published: `CanvasResourceStore` `Arc` (per HTML canvas). + +**Every place pixels are read or written** +- Write (full-plane copy): fillRect, clearRect, fillText, strokeText, drawImage, putImageData (raw), path fill/stroke (via snapshot+composite). +- Read: getImageData, drawImage(source=canvas), toDataURL, page painting (canvas_pixels_for_layout), screencast (through page painting), clone/copy in `canvas_like_pixels_copy`. +- Reset: width/height attribute set (HTML via reflected attr, Offscreen via slot), `reset_canvas_context_state`, backing-store reset, `reset_canvas_like_backing_store_for_dimension_assignment`. +- Retirement/adoption: `retire_canvas_resources_for_document` (adopts resolved by owner document). + +**State vs geometry** +- State lives on V8 private slots (helpers.rs declaration + lineDash slot). +- Path geometry + transform lives in `Canvas2dPathState` (weak-keyed per-context store, `state.rs`). + +--- + +## 5. Existing conformance/stub gaps (honest inventory) + +These are **not** evidence of completed API support and are declared (per proposal +§8) either as out-of-scope API expansion or as correctness gaps to resolve in-line: +- `isPointInPath`/`isPointInStroke` always return `false` (STUB). +- `createLinearGradient`/`CanvasGradient.addColorStop` validate but never render gradients; `fillStyle`/`strokeStyle` are color-only strings (STUB-ish; gradient rendering is unrelated API expansion). +- `convertToBlob` returns an empty blob (STUB). +- `drawImage` supports the 3/5/9-arg forms via `DrawImageBlit`; HTMLVideoElement/CanvasImageSource breadth is limited. +- Text is `font8x8` monochrome glyphs only (functional, not a real font engine); this is the supported text path today and must be captured through the native recorder, not expanded. +- No `getTransform`, `reset`, `setLineDash` canonicalization quirks are necessarily complete; correctness is defined by existing JS regressions, not by parity claims. +- `globalCompositeOperation` is validated/canonicalized but only `source-over` semantics are actually composited. + +These gaps do not block the architecture; per the proposal, only gaps that prevent +recording/ownership/readback/lifecycle correctness must be resolved in-project. + +--- + +## 6. Reproducible baseline + +Fixture + runner live under `moli-benchmark/fixtures/canvas/` (see +`moli-benchmark/fixtures/canvas/README.md` for how to run and reproduce). + +Method: drive the Moli binary over CDP, load an HTML fixture for each workload +(256/1024/2048 canvas sizes × 100/1000 ops over path-fill, rect, image, text, +draw/clear/write, readback-after-every-draw, repeated clean getImageData, and +canvas-to-canvas/self-draw), measure wall time in JS around a recorded event loop, +and report per-phase timings. A Rust-side native cost model benchmark +(`moli-canvas/tests/baseline_cost.rs`, native, no V8) reports the full-plane copy +and full-raster counts for the current design to make the structural cost visible +independently of wall-clock noise. + +Raw results, machine info, build mode, and revision are recorded in +`moli-benchmark/fixtures/canvas/README.md` and `moli-benchmark/fixtures/canvas/results/`. + +### Captured native baseline (this machine; branch `canvas_2D_moli` @ `4d6e0373`, debug build) + +The native cost model (`moli-canvas/tests/baseline_cost.rs`, V8-free) reproduces +the current design's per-draw full-plane copy + format-conversion work. Its +**arithmetic byte-cost evidence** (asserted, instant) shows the cost is linear in +canvas area, not paint size, because every ordinary draw copies the whole plane: + +| canvas | ops | bytes/plane | full copies/draw | bytes copied (total) | +|---|---|---|---|---| +| 256² | 100 | 262,144 | 2 | 52,428,800 | +| 1024² | 1000 | 4,194,304 | 2 | 8,388,608,000 | +| 2048² | 1000 | 16,777,216 | 2 | 33,554,432,000 | + +Moving 33.5 GB to draw 1,000 small shapes on a 2048² canvas is the structural +problem this project removes (path fills additionally allocate a fresh full-canvas +raster to composite). A reduced timing matrix is kept in the test so the check +suite stays fast; see `moli-benchmark/fixtures/canvas/results/README.md`. + +### Baseline commands (run before any migration code) + +```sh +# correctness regression baseline +cargo nextest run -p moli-renderer-v8 --lib canvas_paths canvas_arguments --no-fail-fast + +# native cost model (no V8) +cargo test -p moli-canvas --test baseline_cost -- --nocapture +``` + +### Environment note + +`moli-canvas` native tests pass (22/22) here. The `moli-renderer-v8` JS +regressions and the CDP wall-clock benchmark could **not** be executed in the M0 +capture environment: rebuilding `aws-lc-sys` (pulled by the renderer test graph) +requires libclang/bindgen, which is not installed. The once-built `target/debug/moli` +predates this session and does not cover the fresh test build. The workload JS is +validated for correctness (all 9 fixtures produce well-formed `__canvasResult` +under a Node DOM stub), but wall-clock JS numbers must be captured at M6 on a +machine with libclang and a built `moli serve`. + + +--- + +## 7. Decisions carried forward + +Recorded from the M0 discussion (see proposal §6.2 and the session decisions): + +- **Internal surface format: premultiplied RGBA8** in the target core, matching + Vello output. Conversion to straight alpha happens only at observation/export/ + publication boundaries (getImageData, toDataURL, page snapshots via + `RgbaImage`, ImageData writes, canvas-as-source capture). +- **Backend: `moli-canvas` gains direct `anyrender` + `anyrender_vello_cpu` + dependencies** (same pinned rev `18fd67d…` moli-paint uses) with its own + `backend/vello_cpu.rs`. `moli-canvas` stays free of V8/DOM/layout/moli-paint. +- **Backend reuse**: one `VelloCpuImageRenderer` per canvas, sized to the surface, + rendering into a caller-owned buffer (`render(&mut scene, &mut [u8])`), reused + across flushes. Whether `RenderContext::reset()` retains the large fine-stage + buffers is verified empirically in M2 (dedicated micro-benchmark). +- `moli_image::RgbaImage` is the published page-visible snapshot unit (straight + alpha), already the type `CanvasResourceStore` stores and page painting consumes. + +--- + +## 8. Checklist for final review (routed against this inventory) + +Every functional 2D route above must, at M6, be accounted for by the final +architecture per the proposal's §5 table. The numbering above is the audit key: +- [ ] All **D** routes route through the native ordered recorder (M4). +- [ ] **W** boundaries (`putImageData`) are ordered native pixel writes. +- [ ] **R** routes (`getImageData`, exports, source-canvas, page painting, screencast) read a single authoritative surface/snapshot after flush (M5). +- [ ] **S** geometry/state operations update native state/path without rasterizing or flushing (M1/M4). +- [ ] **RST** dimension assignment/reset preserves the required reset semantics incl. same-size (M4). +- [ ] **STUB** items are either removed or honestly declared out-of-scope. +- [ ] The dual-plane backing store + full-frame raster path (`with_canvas_like_pixels_mut` for draws, `rasterize_canvas_fragment`) is removed (M6). diff --git a/moli-benchmark/fixtures/canvas/README.md b/moli-benchmark/fixtures/canvas/README.md new file mode 100644 index 000000000..d784b349b --- /dev/null +++ b/moli-benchmark/fixtures/canvas/README.md @@ -0,0 +1,101 @@ +# Canvas 2D M0 baseline fixtures + +Reproducible benchmark inputs + runner for the Canvas 2D re-architecture +(see `docs/canvas-architecture-m0.md`). These measure the **pre-migration** +cost model so M6 can compare old vs new. + +Contents: + +- `workloads.py` — JS workload definitions (deterministic, emits self-contained + HTML). Workloads cover the proposal §11.1 matrix: many small path fills and + strokes with a single readback; many small rect, image, and text draws; a + readback-after-every-draw workload (where batching is impossible); a mixed + draw/clear/pixel-write sequence; repeated clean `getImageData`; and + canvas-to-canvas/self-drawing. +- `runner.py` — CDP runner that launches `moli serve`, navigates each workload + as a `data:` URL, and writes results to `results/_baseline.json`. +- `results/` — captured raw baselines (see below for what is captured so far). + +## Workload shape + +Every workload HTML defines `SIZE` and `N`, builds a canvas and a 2D context, +runs a synchronous draw loop, then sets: + +```js +globalThis.__canvasResult = { + workload, size, ops, + recordMs, // cumulative time of the draw-call loop (input/recording cost) + flushMs, // time of the single forced pixel observation + totalMs, // total wall time + probe, acc, // sanity pixel/accumulator values +}; +``` + +## Prerequisites + +Building Moli (not this runner): the full workspace build pulls `aws-lc-sys`, +which compiles with bindgen, so a `cargo` build requires libclang. + +```sh +sudo apt-get install libclang-dev clang +cargo build --release -p moli +``` + +Python deps for `moli_benchmark` (which this runner imports): see +`moli-benchmark/pyproject.toml` (`websockets`, `pillow`). + +## Running + +```sh +python moli-benchmark/fixtures/canvas/runner.py \ + --binary ./target/release/moli \ + --sizes 256 1024 2048 \ + --ops 100 1000 +``` + +Results are written to `results/_baseline.json`, a list of +rows keyed by `workload`, `size`, `ops`, plus `recordMs`/`flushMs`/`totalMs` +(and `probe`/`acc`). + +## Captured baseline (this machine) + +Recorded during M0 on branch `canvas_2D_moli`, commit `4d6e0373`, debug build. + +### Native cost model (`moli-canvas/tests/baseline_cost.rs`, no V8) + +Models the current design's per-draw full-plane copy + format-conversion work. +Run with: + +```sh +cargo test -p moli-canvas --test baseline_cost -- --nocapture +``` + +Raw (debug) output: + +| canvas | ops | bytes/plane | full copies/draw | bytes copied (total) | copy secs | convert secs | +|---|---|---|---|---|---|---| +| 256² | 100 | 262,144 | 2 | 52,428,800 | 0.0015 | 0.162 | +| 1024² | 1000 | 4,194,304 | 2 | 8,388,608,000 | 0.229 | 26.04 | +| 2048² | 1000 | 16,777,216 | 2 | 33,554,432,000 | 1.204 | 100.99 | + +These numbers expose the structural problem the proposal targets: cost scales +with canvas area even for small shapes, because every draw copies the whole +plane and (for path fills) allocates a fresh full-canvas raster to composite. + +### Environment note on the JS/CDP baseline + +The full `moli-renderer-v8` test suite and the CDP benchmark **could not be +executed in the M0 capture environment** because rebuilding `aws-lc-sys` +requires libclang/bindgen, which is not installed here (the once-built debug +binary predates this session). `workloads.py` + `runner.py` are validated for JS +correctness (see the Node harness notes in the commit) but the wall-clock JS +orders of magnitude were not measured against a live Moli instance in this +session. They must be captured as part of M6 on a machine with libclang and a +built `moli serve`. + +### JS correctness baseline (existing regression suite) + +`moli-canvas` native tests pass (22/22). The full +`canvas_paths`/`canvas_arguments` JS regressions require the `moli-renderer-v8` +test build (blocked by libclang here): +`cargo nextest run -p moli-renderer-v8 --lib canvas_paths canvas_arguments`. diff --git a/moli-benchmark/fixtures/canvas/results/README.md b/moli-benchmark/fixtures/canvas/results/README.md new file mode 100644 index 000000000..c015e2a78 --- /dev/null +++ b/moli-benchmark/fixtures/canvas/results/README.md @@ -0,0 +1,44 @@ +# Canvas 2D M0 baseline results + +Raw captured numbers for the pre-migration Canvas 2D cost model. The CDP +wall-clock rows are captured by `runner.py` and land here as +`_baseline.json`; none have been produced yet because the +capture environment lacks libclang to rebuild `aws-lc-sys` (see +`README.md` in this directory). + +## Native cost model (this machine) + +Branch `canvas_2D_moli`, commit `4d6e0373`, **debug** build, +`moli-canvas/tests/baseline_cost.rs`, run via: + +```sh +cargo test -p moli-canvas --test baseline_cost -- --nocapture +``` + +### Arithmetic byte-cost evidence (asserted; instant) + +The current design performs **two full-plane byte copies per ordinary draw** +(`with_canvas_like_pixels_mut`: copy backing view to a Vec, mutate, write back), +so the cost is linear in canvas area regardless of paint size: + +| canvas | ops | bytes/plane | full copies/draw | bytes copied (total) | +|---|---|---|---|---| +| 256² | 100 | 262,144 | 2 | 52,428,800 | +| 1024² | 1000 | 4,194,304 | 2 | 8,388,608,000 | +| 2048² | 1000 | 16,777,216 | 2 | 33,554,432,000 | + +Moving 33.5 GB to draw 1,000 small shapes on a 2048² canvas is the structural +problem this project removes. + +### Reduced timing matrix (debug; kept fast so the check suite stays quick) + +| canvas | ops | full-copy secs | premultiply-convert secs | encode (×10) secs | +|---|---|---|---|---| +| 256² | 100 | 0.0016 | 0.175 | 0.015 | +| 1024² | 100 | 0.0288 | 2.582 | 2.090 | + +The full-timing matrix at 1000 ops is intentionally not run in the crate test to +keep `cargo nextest` fast; it can be measured at M6 on a machine with a built +browser via `moli-benchmark/fixtures/canvas/runner.py`. + +Machine / toolchain: x86_64-unknown-linux-gnu, rustc 1.96.1 (debug). diff --git a/moli-benchmark/fixtures/canvas/runner.py b/moli-benchmark/fixtures/canvas/runner.py new file mode 100644 index 000000000..55fc73f0b --- /dev/null +++ b/moli-benchmark/fixtures/canvas/runner.py @@ -0,0 +1,122 @@ +"""CDP runner for the Canvas 2D M0 baseline. + +Launches a Moli CDP instance (`moli serve`), loads each canvas workload through +`Page.navigate` on a `data:` URL, and records per-workload timings to +`results/baseline.json`. + +The workload HTML is generated by `workloads.py` in the same directory +(deterministic, no external network), and the timing loop is synchronous JS so +wall-clock cost maps to the draw/observe phases the architecture targets. + +Prerequisite to build Moli (this script does not build): the full workspace +build pulls `aws-lc-sys`, which compiles with bindgen, so a `cargo` build +requires libclang. Build with: + + sudo apt-get install libclang-dev clang + cargo build --release -p moli + +Run (anywhere, once dependencies are installed): + + python moli-benchmark/fixtures/canvas/runner.py \ + --binary ./target/release/moli \ + --sizes 256 1024 2048 --ops 100 1000 + +Results are written (with an ISO timestamp) into +`/results/_baseline.json` next to this runner. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +import urllib.parse +from datetime import datetime, timezone +from pathlib import Path + +HERE = Path(__file__).resolve().parent +BENCHMARK_ROOT = HERE.resolve().parents[1] + +# Runtime sys.path wiring (the runner is a standalone script, not a package): +# - `workloads` is a sibling module in the same directory. +# - `moli_benchmark` lives in the sibling directory that also holds `moli-benchmark/`. +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(BENCHMARK_ROOT)) + +from workloads import WORKLOADS, html_for_workload # noqa: E402 + +from moli_benchmark.raw_cdp import RawCdpClient, connect_raw_cdp # noqa: E402 +from moli_benchmark.serve import start_moli_serve, stop_moli_serve # noqa: E402 + +DEFAULT_SIZES = (256, 1024, 2048) +DEFAULT_OPS = (100, 1000) + + +def _data_url(html: str) -> str: + return "data:text/html;charset=utf-8," + urllib.parse.quote(html) + + +async def _evaluate_json(client: RawCdpClient, expression: str) -> dict | None: + message_id = await client.send( + "Runtime.evaluate", + {"expression": expression, "returnByValue": True}, + ) + reply, _events = await client.recv_until_id(message_id) + result = (reply.get("result") or {}).get("result") or {} + value = result.get("value") + return value if isinstance(value, dict) else None + + +async def _run_workload(client: RawCdpClient, name: str, size: int, ops: int) -> dict: + url = _data_url(html_for_workload(name, size, ops)) + message_id = await client.send("Page.navigate", {"url": url}) + await client.recv_until_id(message_id) + payload = await _evaluate_json(client, "JSON.stringify(globalThis.__canvasResult)") + row = {"workload": name, "size": size, "ops": ops} + if payload: + row.update( + {k: payload.get(k) for k in ("recordMs", "flushMs", "totalMs", "acc", "probe")} + ) + return row + + +async def _drive(binary: Path, sizes: list[int], ops_list: list[int]) -> list[dict]: + handle = start_moli_serve(binary, 30.0) + rows: list[dict] = [] + try: + client = await connect_raw_cdp(handle.endpoint) + try: + # Deterministic ordering: size (outer), ops (middle), workload (inner). + for size in sizes: + for ops in ops_list: + for name in WORKLOADS: + rows.append(await _run_workload(client, name, size, ops)) + finally: + await client.close() + finally: + stop_moli_serve(handle) + return rows + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", required=True, help="path to the moli CDP binary") + parser.add_argument("--sizes", nargs="+", type=int, default=list(DEFAULT_SIZES)) + parser.add_argument("--ops", nargs="+", type=int, default=list(DEFAULT_OPS)) + args = parser.parse_args() + + print(f"[canvas-baseline] binary={args.binary} sizes={args.sizes} ops={args.ops}") + rows: list[dict] = asyncio.new_event_loop().run_until_complete( + _drive(Path(args.binary), args.sizes, args.ops) + ) + out_dir = HERE / "results" + out_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + out_path = out_dir / f"{stamp}_baseline.json" + out_path.write_text(json.dumps(rows, indent=2)) + print(f"[canvas-baseline] wrote {out_path}") + + +if __name__ == "__main__": + main() diff --git a/moli-benchmark/fixtures/canvas/workloads.py b/moli-benchmark/fixtures/canvas/workloads.py new file mode 100644 index 000000000..ed083ddfc --- /dev/null +++ b/moli-benchmark/fixtures/canvas/workloads.py @@ -0,0 +1,188 @@ +"""Canvas 2D performance workload definitions for the M0 baseline. + +Each workload is a JS source snippet executed inside a canvas-less harness that +defines `N` (operation count) and `SIZE` (canvas edge in px), creates a canvas +via `makeCanvas()`, obtains a 2D context via `getCtx(canvas)`, runs the workload, +and returns a JSON object of the form: + + { "workload": name, "size": SIZE, "ops": N, + "recordMs": ..., "flushMs": ..., "totalMs": ... } + +The harness records timing at the boundaries the architecture cares about: +"record" is the cumulative time of the draw-call loop (input/recording), "flush" +is the time of the single pixel observation that forces the recording to execute, +and "total" is wall time of the whole workload including observations. + +Workload list mirrors proposal section 11.1. +""" + +from __future__ import annotations + + +def path_fill(body: str) -> str: + """Many small path fills followed by ONE readback.""" + return f""" + const before = performance.now(); + for (let i = 0; i < N; i++) {{ + {body} + }} + const recordMs = performance.now() - before; + const beforeFlush = performance.now(); + const probe = ctx.getImageData(0, 0, 1, 1).data[0]; + const flushMs = performance.now() - beforeFlush; + return JSON.stringify({{ workload: 'path_fill', size: SIZE, ops: N, recordMs, flushMs, totalMs: recordMs + flushMs, probe }}); + """ + + +def path_stroke() -> str: + return f""" + const before = performance.now(); + for (let i = 0; i < N; i++) {{ + ctx.beginPath(); + ctx.moveTo(i % SIZE + 10, 10 + (i % 20)); + ctx.lineTo((i + 30) % SIZE + 10, 40 + (i % 20)); + ctx.stroke(); + }} + const recordMs = performance.now() - before; + const beforeFlush = performance.now(); + const probe = ctx.getImageData(0, 0, 1, 1).data[0]; + const flushMs = performance.now() - beforeFlush; + return JSON.stringify({{ workload: 'path_stroke', size: SIZE, ops: N, recordMs, flushMs, totalMs: recordMs + flushMs, probe }}); + """ + + +def rect_draws() -> str: + return f""" + const before = performance.now(); + for (let i = 0; i < N; i++) {{ + ctx.fillRect((i * 7) % SIZE, (i * 13) % SIZE, 8, 8); + ctx.strokeRect((i * 5) % SIZE, (i * 11) % SIZE, 8, 8); + }} + const recordMs = performance.now() - before; + const beforeFlush = performance.now(); + const probe = ctx.getImageData(0, 0, 1, 1).data[0]; + const flushMs = performance.now() - beforeFlush; + return JSON.stringify({{ workload: 'rect', size: SIZE, ops: N, recordMs, flushMs, totalMs: recordMs + flushMs, probe }}); + """ + + +def text_draws() -> str: + return f""" + ctx.font = '16px sans-serif'; + const before = performance.now(); + for (let i = 0; i < N % 200; i++) {{ + ctx.fillText('Moli', (i * 9) % SIZE, 20 + (i % 40)); + }} + const recordMs = performance.now() - before; + const beforeFlush = performance.now(); + const probe = ctx.getImageData(0, 0, 1, 1).data[0]; + const flushMs = performance.now() - beforeFlush; + return JSON.stringify({{ workload: 'text', size: SIZE, ops: N, recordMs, flushMs, totalMs: recordMs + flushMs, probe }}); + """ + + +def draw_image() -> str: + return f""" + const img = document.createElement('canvas'); img.width = img.height = 32; + const imgCtx = img.getContext('2d'); imgCtx.fillStyle = 'red'; imgCtx.fillRect(0,0,32,32); + const before = performance.now(); + for (let i = 0; i < N % 500; i++) {{ + ctx.drawImage(img, (i * 3) % SIZE, (i * 5) % SIZE); + }} + const recordMs = performance.now() - before; + const beforeFlush = performance.now(); + const probe = ctx.getImageData(0, 0, 1, 1).data[0]; + const flushMs = performance.now() - beforeFlush; + return JSON.stringify({{ workload: 'draw_image', size: SIZE, ops: N, recordMs, flushMs, totalMs: recordMs + flushMs, probe }}); + """ + + +def draw_clear_write_mix() -> str: + return f""" + const id = ctx.createImageData(16, 16); + const before = performance.now(); + for (let i = 0; i < N; i++) {{ + ctx.fillRect((i * 9) % SIZE, (i * 17) % SIZE, 16, 16); + if (i % 3 === 0) ctx.clearRect((i * 9) % SIZE, (i * 17) % SIZE, 8, 8); + if (i % 5 === 0) ctx.putImageData(id, (i * 3) % SIZE, (i * 7) % SIZE); + }} + const recordMs = performance.now() - before; + const beforeFlush = performance.now(); + const probe = ctx.getImageData(0, 0, 1, 1).data[0]; + const flushMs = performance.now() - beforeFlush; + return JSON.stringify({{ workload: 'draw_clear_write', size: SIZE, ops: N, recordMs, flushMs, totalMs: recordMs + flushMs, probe }}); + """ + + +def readback_every_draw() -> str: + return f""" + const before = performance.now(); + let acc = 0; + for (let i = 0; i < N; i++) {{ + ctx.fillRect((i * 7) % SIZE, (i * 11) % SIZE, 4, 4); + acc += ctx.getImageData(0, 0, 1, 1).data[0]; + }} + const totalMs = performance.now() - before; + return JSON.stringify({{ workload: 'readback_every_draw', size: SIZE, ops: N, recordMs: totalMs, flushMs: 0, totalMs, acc }}); + """ + + +def repeated_clean_reads() -> str: + return f""" + ctx.fillStyle = '#ff0000'; ctx.fillRect(0, 0, SIZE, SIZE); + const before = performance.now(); + let acc = 0; + for (let i = 0; i < Math.min(N, 100); i++) {{ + acc += ctx.getImageData(0, 0, 1, 1).data[0]; + }} + const totalMs = performance.now() - before; + return JSON.stringify({{ workload: 'repeated_clean_reads', size: SIZE, ops: N, recordMs: 0, flushMs: 0, totalMs, acc }}); + """ + + +def self_draw() -> str: + return f""" + const before = performance.now(); + for (let i = 0; i < Math.min(N, 50); i++) {{ + ctx.drawImage(canvas, (i * 3) % SIZE, (i * 5) % SIZE); + }} + const recordMs = performance.now() - before; + const beforeFlush = performance.now(); + const probe = ctx.getImageData(0, 0, 1, 1).data[0]; + const flushMs = performance.now() - beforeFlush; + return JSON.stringify({{ workload: 'self_draw', size: SIZE, ops: N, recordMs, flushMs, totalMs: recordMs + flushMs, probe }}); + """ + + +WORKLOADS: dict[str, str] = { + "path_fill": path_fill( + "ctx.beginPath(); ctx.rect((i*11)%SIZE, (i*7)%SIZE, 12, 12); ctx.fill();" + ), + "path_stroke": path_stroke(), + "rect": rect_draws(), + "text": text_draws(), + "draw_image": draw_image(), + "draw_clear_write": draw_clear_write_mix(), + "readback_every_draw": readback_every_draw(), + "repeated_clean_reads": repeated_clean_reads(), + "self_draw": self_draw(), +} + + +def html_for_workload(name: str, size: int, ops: int) -> str: + body = WORKLOADS[name] + return ( + "moli canvas baseline" + f"" + ) diff --git a/moli-canvas/tests/baseline_cost.rs b/moli-canvas/tests/baseline_cost.rs new file mode 100644 index 000000000..4e9bbe4a4 --- /dev/null +++ b/moli-canvas/tests/baseline_cost.rs @@ -0,0 +1,124 @@ +//! Native (V8-free) cost model that makes the current Canvas 2D design's +//! structural cost visible. This is an M0 baseline artifact: it measures the +//! per-draw full-plane copy and format-conversion work the current renderer +//! does for every ordinary draw, so the pre-migration cost is reproducible +//! without V8, a Document, or a browser binary. +//! +//! Run with: +//! cargo test -p moli-canvas --test baseline_cost -- --nocapture +//! +//! The key evidence (bytes copied per draw, which scales with canvas area) is +//! computed arithmetically and asserted. Wall-clock timing is deliberately kept +//! to a small reduced matrix so this test stays fast inside the workspace check +//! suite; the full timing matrix is reported in +//! `moli-benchmark/fixtures/canvas/results/README.md`. + +use std::time::Instant; + +use moli_canvas::{ + byte_len, copy_rgba8_rect, encode_data_url, premultiply_rgba8_in_place, Rgba8Rect, +}; + +/// The three canvas areas named in the proposal's workload matrix, with the op +/// counts used for the (arithmetic) byte-cost evidence. +const SIZES: [(u32, u32, usize); 3] = [ + (256, 256, 100), + (1024, 1024, 1000), + (2048, 2048, 1000), +]; + +/// A reduced matrix actually timed, so the test stays quick in debug builds. +const TIMED: [(u32, u32, usize, usize); 2] = + [(256, 256, 100, 1), (1024, 1024, 100, 10)]; + +fn report(label: impl AsRef, rows: Vec<(String, String)>) { + eprintln!("--- {} ---", label.as_ref()); + for (key, value) in rows { + eprintln!("{key}: {value}"); + } +} + +#[test] +fn baseline_cost_draw_is_linear_in_canvas_area_not_paint_size() { + // The current `with_canvas_like_pixels_mut` path copies the entire backing + // view into a Vec, mutates it, and writes it back: two full-plane byte + // copies per ordinary draw, regardless of how small the painted shape is. + // This is the O(canvas area) per-draw cost the proposal targets. + for (width, height, ops) in SIZES { + let len = byte_len(width, height).expect("valid canvas byte len"); + let copied = len as u128 * 2 * ops as u128; + report( + format!("arithmetic cost {width}x{height} x{ops}"), + vec![ + ("bytes_per_plane".to_string(), len.to_string()), + ("full_copies_per_draw".to_string(), "2".to_string()), + ("bytes_copied_total".to_string(), copied.to_string()), + ], + ); + // The whole-plane copy must be exercised exactly once per op by the + // memcpy fast path, moving `len` bytes each time. + let surface = vec![0u8; len]; + let mut work = vec![0u8; len]; + copy_rgba8_rect( + &surface, + width, + height, + Rgba8Rect::new(0, 0, width, height).expect("full surface"), + &mut work, + width, + height, + 0, + 0, + ) + .expect("full copy fits"); + assert_eq!(work, surface, "full-plane copy must move every byte"); + } +} + +#[test] +fn baseline_cost_timing_is_reported_for_a_reduced_matrix() { + for (width, height, ops, encode_iters) in TIMED { + let len = byte_len(width, height).expect("valid canvas byte len"); + let surface = vec![0u8; len]; + let mut work = vec![0u8; len]; + + let copy_start = Instant::now(); + for _ in 0..ops { + copy_rgba8_rect( + &surface, + width, + height, + Rgba8Rect::new(0, 0, width, height).expect("full surface"), + &mut work, + width, + height, + 0, + 0, + ) + .expect("full copy fits"); + } + let copy_secs = copy_start.elapsed().as_secs_f64(); + + let convert_start = Instant::now(); + for _ in 0..ops { + let mut converted = work.clone(); + premultiply_rgba8_in_place(&mut converted).expect("mult of 4"); + } + let convert_secs = convert_start.elapsed().as_secs_f64(); + + let encode_start = Instant::now(); + for _ in 0..encode_iters { + let _ = encode_data_url(&surface, width, height); + } + let encode_secs = encode_start.elapsed().as_secs_f64(); + + report( + format!("timed {width}x{height} x{ops} (debug)"), + vec![ + ("full_copy_total_secs".to_string(), format!("{copy_secs:.6}")), + ("convert_pass_total_secs".to_string(), format!("{convert_secs:.6}")), + ("encode_total_secs".to_string(), format!("{encode_secs:.6}")), + ], + ); + } +} From 7d41a837c927ea12b956f30f9880616ca96e3eb5 Mon Sep 17 00:00:00 2001 From: BibekPathak Date: Sun, 6 Sep 2026 17:17:39 +0530 Subject: [PATCH 02/12] extract native Canvas path geometry into moli-canvas --- Cargo.lock | 1 + moli-canvas/Cargo.toml | 1 + moli-canvas/src/lib.rs | 2 + moli-canvas/src/path.rs | 504 +++++++++++++++++ moli-canvas/tests/baseline_cost.rs | 21 +- .../src/context_bootstrap/canvas/context2d.rs | 17 +- .../src/context_bootstrap/canvas/path.rs | 524 ++---------------- .../src/context_bootstrap/canvas/state.rs | 10 +- 8 files changed, 582 insertions(+), 498 deletions(-) create mode 100644 moli-canvas/src/path.rs diff --git a/Cargo.lock b/Cargo.lock index 82903b196..d4d3835c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2346,6 +2346,7 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "font8x8", + "kurbo", "moli-image", "moli-web-mime", ] diff --git a/moli-canvas/Cargo.toml b/moli-canvas/Cargo.toml index b3a377d2f..a8b092f2a 100644 --- a/moli-canvas/Cargo.toml +++ b/moli-canvas/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" [dependencies] base64 = "0.22" font8x8 = "0.3" +kurbo = "=0.13.1" moli-image = { path = "../moli-image" } moli-web-mime = { path = "../moli-web-mime" } diff --git a/moli-canvas/src/lib.rs b/moli-canvas/src/lib.rs index 82f584c86..63d3a3ace 100644 --- a/moli-canvas/src/lib.rs +++ b/moli-canvas/src/lib.rs @@ -1,5 +1,6 @@ mod blit; mod encode; +pub mod path; mod pixel; mod rect; mod text; @@ -10,6 +11,7 @@ pub use encode::{ data_image_intrinsic_dimensions, data_image_rgba8_pixels, encode_data_url, image_dimensions_from_bytes, image_intrinsic_dimensions_from_bytes, }; +pub use path::{CanvasPath, CanvasPathData}; pub use pixel::{ copy_rgba8_rect, flip_y_rgba8_in_place, multiply_u8_color, premultiply_rgba8_in_place, scale_rgba8, scale_rgba8_bilinear, scale_rgba8_nearest, diff --git a/moli-canvas/src/path.rs b/moli-canvas/src/path.rs new file mode 100644 index 000000000..599d23d2d --- /dev/null +++ b/moli-canvas/src/path.rs @@ -0,0 +1,504 @@ +//! Browser-independent Canvas 2D current-path geometry. +//! +//! Backed by kurbo-native types (`kurbo::PathEl`, `kurbo::Affine`) so this +//! module has no dependency on page layout or any browser internals. Ported +//! from the renderer's per-context path state with corrected arc mathematics, +//! bounded angle normalization, and default-path transform semantics. +//! +//! Semantics matching the HTML spec and Chromium: the default-path transform is +//! applied to each newly recorded command at command time; earlier elements are +//! never moved by a later transform change. Stroke metrics (width, dash, +//! cap/join) are expressed in the *current* user space. + +use kurbo::{Affine, Point, Rect, Vec2}; + +/// An owned Bézier path in canvas coordinates plus its conservative local +/// bounds. Elements use the same f32 truncation the browser adapter consumes, +/// while bounds are expressed in canvas pixel space. +#[derive(Clone, Debug)] +pub struct CanvasPathData { + /// Path elements, in canvas (already-transformed) coordinates for fill. + pub elements: Vec, + /// Conservative local bounds over the elements. + pub bounds: Rect, +} + +/// A Bezier path in the same flat form as a kurbo path: one `MoveTo` starts a +/// subpath, `ClosePath` ends it, and a new `MoveTo` begins the next one. +/// +/// The current transform only affects newly recorded commands, never earlier +/// elements. +#[derive(Clone, Debug)] +pub struct CanvasPath { + elements: Vec, + current: Point, + current_subpath_start: Point, + has_subpath: bool, + just_closed: bool, + transform: Affine, +} + +impl Default for CanvasPath { + fn default() -> Self { + Self { + elements: Vec::new(), + current: Point::ZERO, + current_subpath_start: Point::ZERO, + has_subpath: false, + just_closed: false, + transform: Affine::IDENTITY, + } + } +} + +const TWO_PI: f64 = std::f64::consts::TAU; + +impl CanvasPath { + pub fn begin_path(&mut self) { + self.elements.clear(); + self.current = Point::ZERO; + self.current_subpath_start = Point::ZERO; + self.has_subpath = false; + self.just_closed = false; + } + + pub fn move_to(&mut self, x: f64, y: f64) { + if ![x, y].into_iter().all(f64::is_finite) { + return; + } + let (x, y) = self.map_point(x, y); + self.elements.push(kurbo::PathEl::MoveTo(point(x, y))); + self.current = Point::new(x, y); + self.current_subpath_start = Point::new(x, y); + self.has_subpath = true; + self.just_closed = false; + } + + /// Reopens a closed subpath; callers establish the initial point for an + /// empty path. + fn ensure_open_subpath(&mut self) -> bool { + if !self.has_subpath { + return false; + } + if self.just_closed { + self.elements + .push(kurbo::PathEl::MoveTo(point(self.current.x, self.current.y))); + self.current_subpath_start = self.current; + self.just_closed = false; + } + true + } + + pub fn line_to(&mut self, x: f64, y: f64) { + if ![x, y].into_iter().all(f64::is_finite) { + return; + } + if !self.ensure_open_subpath() { + self.move_to(x, y); + return; + } + let (x, y) = self.map_point(x, y); + self.elements.push(kurbo::PathEl::LineTo(point(x, y))); + self.current = Point::new(x, y); + } + + pub fn quadratic_curve_to(&mut self, cpx: f64, cpy: f64, x: f64, y: f64) { + if ![cpx, cpy, x, y].into_iter().all(f64::is_finite) { + return; + } + if !self.ensure_open_subpath() { + self.move_to(cpx, cpy); + } + let (cpx, cpy) = self.map_point(cpx, cpy); + let (x, y) = self.map_point(x, y); + self.elements + .push(kurbo::PathEl::QuadTo(point(cpx, cpy), point(x, y))); + self.current = Point::new(x, y); + } + + pub fn bezier_curve_to(&mut self, c1x: f64, c1y: f64, c2x: f64, c2y: f64, x: f64, y: f64) { + if ![c1x, c1y, c2x, c2y, x, y].into_iter().all(f64::is_finite) { + return; + } + if !self.ensure_open_subpath() { + self.move_to(c1x, c1y); + } + let (c1x, c1y) = self.map_point(c1x, c1y); + let (c2x, c2y) = self.map_point(c2x, c2y); + let (x, y) = self.map_point(x, y); + self.elements.push(kurbo::PathEl::CurveTo( + point(c1x, c1y), + point(c2x, c2y), + point(x, y), + )); + self.current = Point::new(x, y); + } + + pub fn close_path(&mut self) { + if !self.has_subpath || self.just_closed { + return; + } + self.elements.push(kurbo::PathEl::ClosePath); + self.current = self.current_subpath_start; + self.just_closed = true; + } + + pub fn rect(&mut self, x: f64, y: f64, width: f64, height: f64) { + if ![x, y, width, height].into_iter().all(f64::is_finite) + || self.inverse_transform().is_none() + { + return; + } + self.move_to(x, y); + self.line_to(x + width, y); + self.line_to(x + width, y + height); + self.line_to(x, y + height); + self.close_path(); + } + + /// Canvas arcs use positive angles clockwise in the screen's y-down space. + pub fn arc(&mut self, x: f64, y: f64, radius: f64, start: f64, end: f64, ccw: bool) -> bool { + self.ellipse(x, y, radius, radius, 0.0, start, end, ccw) + } + + pub fn ellipse( + &mut self, + x: f64, + y: f64, + radius_x: f64, + radius_y: f64, + rotation: f64, + start: f64, + end: f64, + ccw: bool, + ) -> bool { + if ![x, y, radius_x, radius_y, rotation, start, end] + .into_iter() + .all(f64::is_finite) + || radius_x < 0.0 + || radius_y < 0.0 + || self.inverse_transform().is_none() + { + return false; + } + let (start, sweep) = arc_angles(start, end, ccw); + self.append_arc(kurbo::Arc::new( + (x, y), + (radius_x, radius_y), + start, + sweep, + rotation.rem_euclid(TWO_PI), + )); + true + } + + pub fn arc_to(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, radius: f64) -> bool { + if ![x1, y1, x2, y2, radius].into_iter().all(f64::is_finite) || radius < 0.0 { + return false; + } + if !self.has_subpath { + self.move_to(x1, y1); + return true; + } + let Some(inverse) = self.inverse_transform() else { + self.line_to(x1, y1); + return true; + }; + // arcTo constructs a circular arc in the current user coordinate + // system. The previous point was recorded under a possibly older CTM. + let p0 = inverse * self.current; + let p1 = Point::new(x1, y1); + let p2 = Point::new(x2, y2); + let incoming = p0 - p1; + let outgoing = p2 - p1; + if incoming.hypot() == 0.0 || outgoing.hypot() == 0.0 || radius == 0.0 { + self.line_to(x1, y1); + return true; + } + // Both rays originate at the corner. There is no radius clamp. + let u = incoming / incoming.hypot(); + let v = outgoing / outgoing.hypot(); + let cross = u.cross(v); + if cross == 0.0 || !cross.is_finite() { + self.line_to(x1, y1); + return true; + } + let tangent_distance = radius * ((1.0 + u.dot(v).clamp(-1.0, 1.0)) / cross.abs()); + let tangent = p1 + u * tangent_distance; + let center = tangent + Vec2::new(-u.y, u.x) * (radius * cross.signum()); + let end = p1 + v * tangent_distance; + let start_angle = (tangent - center).atan2(); + let end_angle = (end - center).atan2(); + let (start, sweep) = arc_angles(start_angle, end_angle, cross > 0.0); + self.append_arc(kurbo::Arc::new(center, (radius, radius), start, sweep, 0.0)); + true + } + + fn append_arc(&mut self, arc: kurbo::Arc) { + use kurbo::{PathEl, Shape}; + + // A relative floor also bounds subdivision for enormous but finite + // radii. It prevents resource usage growing without bound with radius. + let tolerance = 0.01_f64.max(arc.radii.x.max(arc.radii.y) / 4096.0); + for element in arc.path_elements(tolerance) { + match element { + PathEl::MoveTo(p) => self.line_to(p.x, p.y), + PathEl::CurveTo(a, b, p) => self.bezier_curve_to(a.x, a.y, b.x, b.y, p.x, p.y), + _ => unreachable!("kurbo arcs contain only a start point and cubic segments"), + } + } + } + + pub fn set_transform(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) { + if [a, b, c, d, e, f].into_iter().all(f64::is_finite) { + self.transform = Affine::new([a, b, c, d, e, f]); + } + } + + pub fn reset_transform(&mut self) { + self.transform = Affine::IDENTITY; + } + + pub fn translate(&mut self, x: f64, y: f64) { + self.concatenate_transform(1.0, 0.0, 0.0, 1.0, x, y); + } + + pub fn scale(&mut self, x: f64, y: f64) { + self.concatenate_transform(x, 0.0, 0.0, y, 0.0, 0.0); + } + + pub fn rotate(&mut self, radians: f64) { + let (sin, cos) = radians.sin_cos(); + self.concatenate_transform(cos, sin, -sin, cos, 0.0, 0.0); + } + + pub fn concatenate_transform(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) { + if [a, b, c, d, e, f].into_iter().all(f64::is_finite) { + self.transform *= Affine::new([a, b, c, d, e, f]); + } + } + + /// The current transform, mapping user space to canvas space. + pub fn transform(&self) -> Affine { + self.transform + } + + fn map_point(&self, x: f64, y: f64) -> (f64, f64) { + let p = self.transform * Point::new(x, y); + (p.x, p.y) + } + + /// Inverse of the current transform, `None` for singular transforms. A + /// small nonzero scale is still invertible; do not use `EPSILON` as a + /// singularity threshold (e.g. `scale(1e-9, 1e-9)` is valid). + pub fn inverse_transform(&self) -> Option { + let determinant = self.transform.determinant(); + (determinant != 0.0 && determinant.is_finite()).then(|| self.transform.inverse()) + } + + /// Returns the frozen path mapped back to the *current* user space, so a + /// later transform can run the painter over the path for anisotropic + /// strokes and dashes. Returns `None` when the transform is singular. + pub fn stroke_path(&self) -> Option { + let inverse = self.inverse_transform()?; + let map = |p: Point| { + let p = inverse * p; + point(p.x, p.y) + }; + let elements = self + .elements + .iter() + .map(|element| match *element { + kurbo::PathEl::MoveTo(p) => kurbo::PathEl::MoveTo(map(p)), + kurbo::PathEl::LineTo(p) => kurbo::PathEl::LineTo(map(p)), + kurbo::PathEl::QuadTo(a, p) => kurbo::PathEl::QuadTo(map(a), map(p)), + kurbo::PathEl::CurveTo(a, b, p) => kurbo::PathEl::CurveTo(map(a), map(b), map(p)), + kurbo::PathEl::ClosePath => kurbo::PathEl::ClosePath, + }) + .collect::>(); + Some(CanvasPathData { + bounds: path_bounds(&elements), + elements, + }) + } + + /// Builds an owned `CanvasPathData` with conservative local bounds. + pub fn paint_path(&self) -> CanvasPathData { + CanvasPathData { + elements: self.elements.clone(), + bounds: path_bounds(&self.elements), + } + } + + pub fn is_empty(&self) -> bool { + self.elements.is_empty() + } +} + +/// Casts a coordinate pair to the same f32 truncation the browser's painted +/// path consumes, preserving observable behavior of the original adapter. +fn point(x: f64, y: f64) -> Point { + Point::new((x as f32) as f64, (y as f32) as f64) +} + +fn path_bounds(elements: &[kurbo::PathEl]) -> Rect { + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + for element in elements { + match *element { + kurbo::PathEl::MoveTo(point) | kurbo::PathEl::LineTo(point) => { + expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, point); + } + kurbo::PathEl::QuadTo(first, second) => { + expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, first); + expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, second); + } + kurbo::PathEl::CurveTo(first, second, third) => { + expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, first); + expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, second); + expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, third); + } + kurbo::PathEl::ClosePath => {} + } + } + if !min_x.is_finite() { + return Rect::ZERO; + } + Rect::new( + (min_x as f32) as f64, + (min_y as f32) as f64, + ((max_x - min_x) as f32) as f64, + ((max_y - min_y) as f32) as f64, + ) +} + +fn expand(min_x: &mut f64, min_y: &mut f64, max_x: &mut f64, max_y: &mut f64, point: Point) { + *min_x = (*min_x).min(point.x); + *min_y = (*min_y).min(point.y); + *max_x = (*max_x).max(point.x); + *max_y = (*max_y).max(point.y); +} + +/// Normalize in constant time, including opposite-sign finite angles whose +/// subtraction overflows. Never repeatedly add/subtract TAU: at large f64 +/// magnitudes that operation does not advance at all. +fn arc_angles(start: f64, end: f64, ccw: bool) -> (f64, f64) { + let difference = end - start; + let normalized_start = start.rem_euclid(TWO_PI); + let sweep = if !ccw && difference >= TWO_PI { + TWO_PI + } else if ccw && difference <= -TWO_PI { + -TWO_PI + } else if difference == 0.0 { + 0.0 + } else { + let remainder = if difference.is_finite() { + difference.rem_euclid(TWO_PI) + } else { + (end.rem_euclid(TWO_PI) - normalized_start).rem_euclid(TWO_PI) + }; + if ccw { + // Like Blink's AdjustEndAngle, preserve a whole turn for + // opposite-direction endpoints separated by an exact TAU multiple. + if remainder == 0.0 && difference < 0.0 { + 0.0 + } else { + remainder - TWO_PI + } + } else if remainder == 0.0 && difference < 0.0 { + TWO_PI + } else { + remainder + } + }; + (normalized_start, sweep) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f64::consts::FRAC_PI_2; + + #[test] + fn arc_angles_preserve_direction_full_turns_and_equal_endpoints() { + for (start, end, ccw, expected) in [ + (0.0, FRAC_PI_2, false, FRAC_PI_2), + (0.0, FRAC_PI_2, true, -3.0 * FRAC_PI_2), + (0.0, -FRAC_PI_2, true, -FRAC_PI_2), + (0.0, -FRAC_PI_2, false, 3.0 * FRAC_PI_2), + (0.0, TWO_PI, false, TWO_PI), + (0.0, -TWO_PI, true, -TWO_PI), + (0.0, TWO_PI, true, -TWO_PI), + (0.0, -TWO_PI, false, TWO_PI), + (2.0, 2.0, true, 0.0), + (2.0, 2.0, false, 0.0), + ] { + assert!((arc_angles(start, end, ccw).1 - expected).abs() < 1e-12); + } + } + + #[test] + fn finite_extreme_angles_produce_bounded_path_work() { + for start in [0.0, 1e20, -1e20, f64::MAX, -f64::MAX] { + for end in [0.0, 1e20, -1e20, f64::MAX, -f64::MAX] { + for ccw in [false, true] { + let (angle, sweep) = arc_angles(start, end, ccw); + assert!(angle.is_finite() && (0.0..TWO_PI).contains(&angle)); + assert!(sweep.is_finite() && sweep.abs() <= TWO_PI); + assert!(if ccw { sweep <= 0.0 } else { sweep >= 0.0 }); + let mut state = CanvasPath::default(); + assert!(state.arc(10.0, 10.0, 5.0, start, end, ccw)); + assert!(state.elements.len() <= 7); + } + } + } + } + + #[test] + fn arc_to_has_the_correct_tangent_and_endpoint() { + let mut state = CanvasPath::default(); + state.move_to(0.0, 0.0); + assert!(state.arc_to(10.0, 0.0, 10.0, 10.0, 5.0)); + let kurbo::PathEl::LineTo(tangent) = state.elements[1] else { + panic!("arcTo must connect to its first tangent"); + }; + assert!((tangent.x - 5.0).abs() < 1e-5 && tangent.y.abs() < 1e-5); + assert!((state.current.x - 10.0).abs() < 1e-5); + assert!((state.current.y - 5.0).abs() < 1e-5); + } + + #[test] + fn rejected_arc_geometry_does_not_mutate_the_path() { + let mut state = CanvasPath::default(); + for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] { + assert!(!state.arc(0.0, 0.0, invalid, 0.0, 1.0, false)); + assert!(!state.ellipse(0.0, 0.0, 5.0, invalid, 0.0, 0.0, 1.0, false)); + assert!(!state.arc_to(0.0, 0.0, 1.0, 1.0, invalid)); + assert!(state.is_empty()); + } + } + + #[test] + fn default_path_transform_applies_at_command_time_only() { + let mut state = CanvasPath::default(); + state.move_to(0.0, 0.0); + state.line_to(10.0, 0.0); + let before = state.paint_path(); + // A later transform must not move already-recorded elements. + state.set_transform(2.0, 0.0, 0.0, 2.0, 100.0, 100.0); + let after = state.paint_path(); + assert_eq!(before.elements.len(), after.elements.len()); + assert_eq!( + before.elements[1], after.elements[1], + "earlier commands keep their transform" + ); + // New commands after the transform change are transformed. + state.line_to(1.0, 1.0); + let kurbo::PathEl::LineTo(q) = state.paint_path().elements[2] else { + panic!("expected line") + }; + assert!((q.x - 102.0).abs() < 1e-6 && (q.y - 102.0).abs() < 1e-6); + } +} diff --git a/moli-canvas/tests/baseline_cost.rs b/moli-canvas/tests/baseline_cost.rs index 4e9bbe4a4..b753ed2ba 100644 --- a/moli-canvas/tests/baseline_cost.rs +++ b/moli-canvas/tests/baseline_cost.rs @@ -16,20 +16,15 @@ use std::time::Instant; use moli_canvas::{ - byte_len, copy_rgba8_rect, encode_data_url, premultiply_rgba8_in_place, Rgba8Rect, + Rgba8Rect, byte_len, copy_rgba8_rect, encode_data_url, premultiply_rgba8_in_place, }; /// The three canvas areas named in the proposal's workload matrix, with the op /// counts used for the (arithmetic) byte-cost evidence. -const SIZES: [(u32, u32, usize); 3] = [ - (256, 256, 100), - (1024, 1024, 1000), - (2048, 2048, 1000), -]; +const SIZES: [(u32, u32, usize); 3] = [(256, 256, 100), (1024, 1024, 1000), (2048, 2048, 1000)]; /// A reduced matrix actually timed, so the test stays quick in debug builds. -const TIMED: [(u32, u32, usize, usize); 2] = - [(256, 256, 100, 1), (1024, 1024, 100, 10)]; +const TIMED: [(u32, u32, usize, usize); 2] = [(256, 256, 100, 1), (1024, 1024, 100, 10)]; fn report(label: impl AsRef, rows: Vec<(String, String)>) { eprintln!("--- {} ---", label.as_ref()); @@ -115,8 +110,14 @@ fn baseline_cost_timing_is_reported_for_a_reduced_matrix() { report( format!("timed {width}x{height} x{ops} (debug)"), vec![ - ("full_copy_total_secs".to_string(), format!("{copy_secs:.6}")), - ("convert_pass_total_secs".to_string(), format!("{convert_secs:.6}")), + ( + "full_copy_total_secs".to_string(), + format!("{copy_secs:.6}"), + ), + ( + "convert_pass_total_secs".to_string(), + format!("{convert_secs:.6}"), + ), ("encode_total_secs".to_string(), format!("{encode_secs:.6}")), ], ); diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs b/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs index f6e75dfeb..3995abe9e 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs @@ -1080,7 +1080,7 @@ pub(crate) fn canvas_context_fill_callback<'s>( return None; } Some(PaintFragment::Fill { - shape: PaintShape::Path(state.paint_path()), + shape: PaintShape::Path(super::path::native_paint_path(&state.paint_path())), brush: PaintBrush::Solid(context_fill_color(scope, args.this())), transform: PaintTransform2D::IDENTITY, }) @@ -1110,8 +1110,8 @@ pub(crate) fn canvas_context_stroke_callback<'s>( Some(PaintFragment::Stroke(context_stroke( scope, args.this(), - state.stroke_path()?, - state.transform(), + super::path::native_paint_path(&state.stroke_path()?), + super::path::native_transform(state.transform()), ))) }); if let Some(fragment) = fragment { @@ -1151,10 +1151,13 @@ pub(crate) fn canvas_context_stroke_rect_callback<'s>( let path_state = canvas_path_state(scope, args.this()); // strokeRect must not alter the current default path. let path = with_path_state(&path_state, |state| { - let mut rect_path = super::path::Canvas2dPathState::default(); + let mut rect_path = moli_canvas::path::CanvasPath::default(); rect_path.rect(x, y, width, height); state.inverse_transform()?; - Some((rect_path.paint_path(), state.transform())) + Some(( + super::path::native_paint_path(&rect_path.paint_path()), + super::path::native_transform(state.transform()), + )) }); let Some((path, transform)) = path else { return; @@ -1419,8 +1422,8 @@ fn canvas_context_enum_string_assign<'s>( } fn with_path_state( - state: &std::cell::RefCell, - update: impl FnOnce(&mut super::path::Canvas2dPathState) -> T, + state: &std::cell::RefCell, + update: impl FnOnce(&mut moli_canvas::path::CanvasPath) -> T, ) -> T { update(&mut state.borrow_mut()) } diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/path.rs b/moli-renderer-v8/src/context_bootstrap/canvas/path.rs index b65e477a5..8859b7209 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/path.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/path.rs @@ -1,483 +1,55 @@ -//! Canvas 2D current-path geometry, owned by the context's native state. - -use moli_layout::{ - LayoutPoint, LayoutRect, LayoutTransform2D, PaintPath, PaintPathElement, PaintRect, -}; - -/// A Bezier path in the same flat form as a kurbo path: one `MoveTo` starts a -/// subpath, `Close` ends it, and a new `MoveTo` begins the next one. -#[derive(Clone, Debug)] -pub(crate) struct Canvas2dPathState { - // Default-path geometry is already in canvas coordinates. The current - // transform only affects newly recorded commands, never earlier elements. - elements: Vec, - current: (f64, f64), - current_subpath_start: (f64, f64), - has_subpath: bool, - just_closed: bool, - transform: LayoutTransform2D, -} - -impl Default for Canvas2dPathState { - fn default() -> Self { - Self { - elements: Vec::new(), - current: (0.0, 0.0), - current_subpath_start: (0.0, 0.0), - has_subpath: false, - just_closed: false, - transform: LayoutTransform2D::IDENTITY, - } - } -} - -const TWO_PI: f64 = std::f64::consts::TAU; - -impl Canvas2dPathState { - pub(super) fn begin_path(&mut self) { - self.elements.clear(); - self.current = (0.0, 0.0); - self.current_subpath_start = (0.0, 0.0); - self.has_subpath = false; - self.just_closed = false; - } - - pub(super) fn move_to(&mut self, x: f64, y: f64) { - if ![x, y].into_iter().all(f64::is_finite) { - return; - } - let (x, y) = self.map_point(x, y); - self.elements.push(PaintPathElement::MoveTo(point(x, y))); - self.current = (x, y); - self.current_subpath_start = (x, y); - self.has_subpath = true; - self.just_closed = false; - } - - /// Reopens a closed subpath; callers establish the initial point for an empty path. - fn ensure_open_subpath(&mut self) -> bool { - if !self.has_subpath { - return false; - } - if self.just_closed { - self.elements.push(PaintPathElement::MoveTo(point( - self.current.0, - self.current.1, - ))); - self.current_subpath_start = self.current; - self.just_closed = false; - } - true - } - - pub(super) fn line_to(&mut self, x: f64, y: f64) { - if ![x, y].into_iter().all(f64::is_finite) { - return; - } - if !self.ensure_open_subpath() { - self.move_to(x, y); - return; - } - let (x, y) = self.map_point(x, y); - self.elements.push(PaintPathElement::LineTo(point(x, y))); - self.current = (x, y); - } - - pub(super) fn quadratic_curve_to(&mut self, cpx: f64, cpy: f64, x: f64, y: f64) { - if ![cpx, cpy, x, y].into_iter().all(f64::is_finite) { - return; - } - if !self.ensure_open_subpath() { - self.move_to(cpx, cpy); - } - let (cpx, cpy) = self.map_point(cpx, cpy); - let (x, y) = self.map_point(x, y); - self.elements - .push(PaintPathElement::QuadTo(point(cpx, cpy), point(x, y))); - self.current = (x, y); - } - - pub(super) fn bezier_curve_to( - &mut self, - c1x: f64, - c1y: f64, - c2x: f64, - c2y: f64, - x: f64, - y: f64, - ) { - if ![c1x, c1y, c2x, c2y, x, y].into_iter().all(f64::is_finite) { - return; - } - if !self.ensure_open_subpath() { - self.move_to(c1x, c1y); - } - let (c1x, c1y) = self.map_point(c1x, c1y); - let (c2x, c2y) = self.map_point(c2x, c2y); - let (x, y) = self.map_point(x, y); - self.elements.push(PaintPathElement::CubicTo( - point(c1x, c1y), - point(c2x, c2y), - point(x, y), - )); - self.current = (x, y); - } - - pub(super) fn close_path(&mut self) { - if !self.has_subpath || self.just_closed { - return; - } - self.elements.push(PaintPathElement::Close); - self.current = self.current_subpath_start; - self.just_closed = true; - } - - pub(super) fn rect(&mut self, x: f64, y: f64, width: f64, height: f64) { - if ![x, y, width, height].into_iter().all(f64::is_finite) - || self.inverse_transform().is_none() - { - return; - } - self.move_to(x, y); - self.line_to(x + width, y); - self.line_to(x + width, y + height); - self.line_to(x, y + height); - self.close_path(); - } - - /// Canvas arcs use positive angles clockwise in the screen's y-down space. - pub(super) fn arc( - &mut self, - x: f64, - y: f64, - radius: f64, - start: f64, - end: f64, - ccw: bool, - ) -> bool { - self.ellipse(x, y, radius, radius, 0.0, start, end, ccw) - } - - pub(super) fn ellipse( - &mut self, - x: f64, - y: f64, - radius_x: f64, - radius_y: f64, - rotation: f64, - start: f64, - end: f64, - ccw: bool, - ) -> bool { - if ![x, y, radius_x, radius_y, rotation, start, end] - .into_iter() - .all(f64::is_finite) - || radius_x < 0.0 - || radius_y < 0.0 - || self.inverse_transform().is_none() - { - return false; - } - let (start, sweep) = arc_angles(start, end, ccw); - self.append_arc(kurbo::Arc::new( - (x, y), - (radius_x, radius_y), - start, - sweep, - rotation.rem_euclid(TWO_PI), - )); - true - } - - pub(super) fn arc_to(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, radius: f64) -> bool { - if ![x1, y1, x2, y2, radius].into_iter().all(f64::is_finite) || radius < 0.0 { - return false; - } - if !self.has_subpath { - self.move_to(x1, y1); - return true; - } - let Some(inverse) = self.inverse_transform() else { - self.line_to(x1, y1); - return true; - }; - // arcTo constructs a circular arc in the current user coordinate - // system. The previous point was recorded under a possibly older CTM. - let p0 = inverse * kurbo::Point::from(self.current); - let p1 = kurbo::Point::new(x1, y1); - let p2 = kurbo::Point::new(x2, y2); - let incoming = p0 - p1; - let outgoing = p2 - p1; - if incoming.hypot() == 0.0 || outgoing.hypot() == 0.0 || radius == 0.0 { - self.line_to(x1, y1); - return true; - } - // Both rays originate at the corner. There is no radius clamp: the - // tangent points may lie beyond either of the supplied line segments. - let u = incoming / incoming.hypot(); - let v = outgoing / outgoing.hypot(); - let cross = u.cross(v); - if cross == 0.0 || !cross.is_finite() { - self.line_to(x1, y1); - return true; - } - let tangent_distance = radius * ((1.0 + u.dot(v).clamp(-1.0, 1.0)) / cross.abs()); - let tangent = p1 + u * tangent_distance; - let center = tangent + kurbo::Vec2::new(-u.y, u.x) * (radius * cross.signum()); - let end = p1 + v * tangent_distance; - let start_angle = (tangent - center).atan2(); - let end_angle = (end - center).atan2(); - let (start, sweep) = arc_angles(start_angle, end_angle, cross > 0.0); - self.append_arc(kurbo::Arc::new(center, (radius, radius), start, sweep, 0.0)); - true - } - - fn append_arc(&mut self, arc: kurbo::Arc) { - use kurbo::{PathEl, Shape}; - - // A relative floor also bounds subdivision for enormous but finite - // radii. It prevents resource usage growing without bound with radius. - let tolerance = 0.01_f64.max(arc.radii.x.max(arc.radii.y) / 4096.0); - for element in arc.path_elements(tolerance) { - match element { - PathEl::MoveTo(p) => self.line_to(p.x, p.y), - PathEl::CurveTo(a, b, p) => self.bezier_curve_to(a.x, a.y, b.x, b.y, p.x, p.y), - _ => unreachable!("kurbo arcs contain only a start point and cubic segments"), - } - } - } - - pub(super) fn set_transform(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) { - if [a, b, c, d, e, f].into_iter().all(f64::is_finite) { - self.transform = LayoutTransform2D::new([a, b, c, d, e, f]); - } - } - - pub(super) fn reset_transform(&mut self) { - self.transform = LayoutTransform2D::IDENTITY; - } - - pub(super) fn translate(&mut self, x: f64, y: f64) { - self.concatenate_transform(1.0, 0.0, 0.0, 1.0, x, y); - } - - pub(super) fn scale(&mut self, x: f64, y: f64) { - self.concatenate_transform(x, 0.0, 0.0, y, 0.0, 0.0); - } - - pub(super) fn rotate(&mut self, radians: f64) { - let (sin, cos) = radians.sin_cos(); - self.concatenate_transform(cos, sin, -sin, cos, 0.0, 0.0); - } - - pub(super) fn concatenate_transform(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) { - if [a, b, c, d, e, f].into_iter().all(f64::is_finite) { - self.transform = self - .transform - .concatenate(LayoutTransform2D::new([a, b, c, d, e, f])); - } - } - - pub(super) fn transform(&self) -> LayoutTransform2D { - self.transform - } - - fn map_point(&self, x: f64, y: f64) -> (f64, f64) { - let p = kurbo::Affine::new(self.transform.coefficients) * kurbo::Point::new(x, y); - (p.x, p.y) - } - - pub(super) fn inverse_transform(&self) -> Option { - let transform = kurbo::Affine::new(self.transform.coefficients); - let determinant = transform.determinant(); - // A small nonzero scale is still invertible; do not use EPSILON as - // a singularity threshold (e.g. scale(1e-9,1e-9) is valid). - (determinant != 0.0 && determinant.is_finite()).then(|| transform.inverse()) - } - - /// Stroke metrics are in the *current* user space. Bring the frozen path - /// back to that space and let the painter transform the resulting stroke, - /// so anisotropic line widths, joins and dashes change without moving it. - pub(super) fn stroke_path(&self) -> Option { - let inverse = self.inverse_transform()?; - let map = |p: LayoutPoint| { - let p = inverse * kurbo::Point::new(f64::from(p.x), f64::from(p.y)); - point(p.x, p.y) - }; - let elements = self - .elements - .iter() - .map(|element| match *element { - PaintPathElement::MoveTo(p) => PaintPathElement::MoveTo(map(p)), - PaintPathElement::LineTo(p) => PaintPathElement::LineTo(map(p)), - PaintPathElement::QuadTo(a, p) => PaintPathElement::QuadTo(map(a), map(p)), - PaintPathElement::CubicTo(a, b, p) => { - PaintPathElement::CubicTo(map(a), map(b), map(p)) - } - PaintPathElement::Close => PaintPathElement::Close, - }) - .collect::>(); - Some(PaintPath { - bounds: path_bounds(&elements), - elements, - }) - } - - /// Builds an owned `PaintPath` with conservative local bounds. - pub(super) fn paint_path(&self) -> PaintPath { - PaintPath { - elements: self.elements.clone(), - bounds: path_bounds(&self.elements), - } - } - - pub(super) fn is_empty(&self) -> bool { - self.elements.is_empty() - } -} - -fn path_bounds(elements: &[PaintPathElement]) -> PaintRect { - let mut min_x = f64::INFINITY; - let mut min_y = f64::INFINITY; - let mut max_x = f64::NEG_INFINITY; - let mut max_y = f64::NEG_INFINITY; - for element in elements { - match *element { - PaintPathElement::MoveTo(point) | PaintPathElement::LineTo(point) => { - expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, point); - } - PaintPathElement::QuadTo(first, second) => { - expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, first); - expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, second); - } - PaintPathElement::CubicTo(first, second, third) => { - expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, first); - expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, second); - expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, third); - } - PaintPathElement::Close => {} - } - } - if !min_x.is_finite() { - return LayoutRect::ZERO; - } - LayoutRect::new( - min_x as f32, - min_y as f32, - (max_x - min_x) as f32, - (max_y - min_y) as f32, +//! Adapter between the browser-independent Canvas path geometry +//! ([`moli_canvas::path::CanvasPath`]) and the page-paint types +//! (`moli-layout`). +//! +//! The path geometry and its current-path transform semantics live in +//! `moli-canvas`, backed by kurbo-native types. This module only converts an +//! already-built native path into the `moli-layout` snapshot shapes that the +//! page rasterizer (vello) can fill and stroke. It holds no drawing state. + +use kurbo::{Affine, Point, Rect}; +use moli_canvas::path::CanvasPathData; +use moli_layout::{LayoutPoint, PaintPath, PaintPathElement, PaintRect, PaintTransform2D}; + +/// Converts a native canvas path into a `moli-layout` `PaintPath`. Elements are +/// stored by `moli-canvas` with the same f32 truncation this adapter consumes. +pub(super) fn native_paint_path(data: &CanvasPathData) -> PaintPath { + PaintPath { + elements: data.elements.iter().map(path_element_to_layout).collect(), + bounds: paint_rect(data.bounds), + } +} + +/// Converts the current canvas transform (user space -> canvas space) into a +/// `moli-layout` paint transform. +pub(super) fn native_transform(affine: Affine) -> PaintTransform2D { + PaintTransform2D::new(affine.as_coeffs()) +} + +/// Converts a kurbo rectangle (in canvas pixel space) into a `moli-layout` +/// paint rectangle. `moli-canvas` stores f32-truncated bounds, so the `f32` +/// casts recover the original values exactly. +fn paint_rect(rect: Rect) -> PaintRect { + PaintRect::new( + rect.x0 as f32, + rect.y0 as f32, + rect.width() as f32, + rect.height() as f32, ) } -fn point(x: f64, y: f64) -> LayoutPoint { - LayoutPoint::new(x as f32, y as f32) -} - -fn expand(min_x: &mut f64, min_y: &mut f64, max_x: &mut f64, max_y: &mut f64, point: LayoutPoint) { - let x = f64::from(point.x); - let y = f64::from(point.y); - *min_x = (*min_x).min(x); - *min_y = (*min_y).min(y); - *max_x = (*max_x).max(x); - *max_y = (*max_y).max(y); -} - -/// Normalize in constant time, including opposite-sign finite angles whose -/// subtraction overflows. Never repeatedly add/subtract TAU: at large f64 -/// magnitudes that operation does not advance at all. -fn arc_angles(start: f64, end: f64, ccw: bool) -> (f64, f64) { - let difference = end - start; - let normalized_start = start.rem_euclid(TWO_PI); - let sweep = if !ccw && difference >= TWO_PI { - TWO_PI - } else if ccw && difference <= -TWO_PI { - -TWO_PI - } else if difference == 0.0 { - 0.0 - } else { - let remainder = if difference.is_finite() { - difference.rem_euclid(TWO_PI) - } else { - (end.rem_euclid(TWO_PI) - normalized_start).rem_euclid(TWO_PI) - }; - if ccw { - // Like Blink's AdjustEndAngle, preserve a whole turn for opposite- - // direction endpoints separated by an exact multiple of TAU. - if remainder == 0.0 && difference < 0.0 { - 0.0 - } else { - remainder - TWO_PI - } - } else if remainder == 0.0 && difference < 0.0 { - TWO_PI - } else { - remainder +fn path_element_to_layout(element: &kurbo::PathEl) -> PaintPathElement { + match *element { + kurbo::PathEl::MoveTo(p) => PaintPathElement::MoveTo(layout_point(p)), + kurbo::PathEl::LineTo(p) => PaintPathElement::LineTo(layout_point(p)), + kurbo::PathEl::QuadTo(a, p) => PaintPathElement::QuadTo(layout_point(a), layout_point(p)), + kurbo::PathEl::CurveTo(a, b, p) => { + PaintPathElement::CubicTo(layout_point(a), layout_point(b), layout_point(p)) } - }; - (normalized_start, sweep) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::f64::consts::FRAC_PI_2; - - #[test] - fn arc_angles_preserve_direction_full_turns_and_equal_endpoints() { - for (start, end, ccw, expected) in [ - (0.0, FRAC_PI_2, false, FRAC_PI_2), - (0.0, FRAC_PI_2, true, -3.0 * FRAC_PI_2), - (0.0, -FRAC_PI_2, true, -FRAC_PI_2), - (0.0, -FRAC_PI_2, false, 3.0 * FRAC_PI_2), - (0.0, TWO_PI, false, TWO_PI), - (0.0, -TWO_PI, true, -TWO_PI), - (0.0, TWO_PI, true, -TWO_PI), - (0.0, -TWO_PI, false, TWO_PI), - (2.0, 2.0, true, 0.0), - (2.0, 2.0, false, 0.0), - ] { - assert!((arc_angles(start, end, ccw).1 - expected).abs() < 1e-12); - } - } - - #[test] - fn finite_extreme_angles_produce_bounded_path_work() { - for start in [0.0, 1e20, -1e20, f64::MAX, -f64::MAX] { - for end in [0.0, 1e20, -1e20, f64::MAX, -f64::MAX] { - for ccw in [false, true] { - let (angle, sweep) = arc_angles(start, end, ccw); - assert!(angle.is_finite() && (0.0..TWO_PI).contains(&angle)); - assert!(sweep.is_finite() && sweep.abs() <= TWO_PI); - assert!(if ccw { sweep <= 0.0 } else { sweep >= 0.0 }); - let mut state = Canvas2dPathState::default(); - assert!(state.arc(10.0, 10.0, 5.0, start, end, ccw)); - assert!(state.elements.len() <= 7); - } - } - } - } - - #[test] - fn arc_to_has_the_correct_tangent_and_endpoint() { - let mut state = Canvas2dPathState::default(); - state.move_to(0.0, 0.0); - assert!(state.arc_to(10.0, 0.0, 10.0, 10.0, 5.0)); - let PaintPathElement::LineTo(tangent) = state.elements[1] else { - panic!("arcTo must connect to its first tangent"); - }; - assert!((tangent.x - 5.0).abs() < 1e-5 && tangent.y.abs() < 1e-5); - assert!((state.current.0 - 10.0).abs() < 1e-5); - assert!((state.current.1 - 5.0).abs() < 1e-5); + kurbo::PathEl::ClosePath => PaintPathElement::Close, } +} - #[test] - fn rejected_arc_geometry_does_not_mutate_the_path() { - let mut state = Canvas2dPathState::default(); - for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] { - assert!(!state.arc(0.0, 0.0, invalid, 0.0, 1.0, false)); - assert!(!state.ellipse(0.0, 0.0, 5.0, invalid, 0.0, 0.0, 1.0, false)); - assert!(!state.arc_to(0.0, 0.0, 1.0, 1.0, invalid)); - assert!(state.is_empty()); - } - } +fn layout_point(p: Point) -> LayoutPoint { + LayoutPoint::new(p.x as f32, p.y as f32) } diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/state.rs b/moli-renderer-v8/src/context_bootstrap/canvas/state.rs index ca6c25d79..a94dede1d 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/state.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/state.rs @@ -5,8 +5,8 @@ use std::{cell::RefCell, collections::HashMap, rc::Rc}; -use super::path::Canvas2dPathState; use crate::util::{get_private_value, set_private_value}; +use moli_canvas::path::CanvasPath; const PATH_STATE_SLOT: &str = "__moliCanvasPathState"; type StateStore = Rc>; @@ -19,13 +19,13 @@ struct CanvasStates { struct CanvasStateEntry { _context: v8::Weak, - state: Rc>, + state: Rc>, } pub(super) fn canvas_path_state<'s>( scope: &mut v8::PinScope<'s, '_>, context: v8::Local<'s, v8::Object>, -) -> Rc> { +) -> Rc> { let store = if let Some(store) = scope.get_slot::() { store.clone() } else { @@ -63,7 +63,7 @@ pub(super) fn canvas_path_state<'s>( } }), ); - let state = Rc::new(RefCell::new(Canvas2dPathState::default())); + let state = Rc::new(RefCell::new(CanvasPath::default())); store.borrow_mut().entries.insert( id, CanvasStateEntry { @@ -81,7 +81,7 @@ pub(super) fn reset_canvas_path_state<'s>( context: v8::Local<'s, v8::Object>, ) { if get_private_value(scope, context, PATH_STATE_SLOT).is_some() { - *canvas_path_state(scope, context).borrow_mut() = Canvas2dPathState::default(); + *canvas_path_state(scope, context).borrow_mut() = CanvasPath::default(); } } From 65278d56649d71dfcc88780f7ff4c980bf876b9c Mon Sep 17 00:00:00 2001 From: BibekPathak Date: Sun, 6 Sep 2026 18:38:06 +0530 Subject: [PATCH 03/12] add reusable canvas surface and Vello CPU backend to moli-canvas --- Cargo.lock | 4 + docs/canvas-architecture-m0.md | 28 +++ moli-canvas/Cargo.toml | 4 + moli-canvas/src/backend/mod.rs | 5 + moli-canvas/src/backend/vello_cpu.rs | 86 ++++++++ moli-canvas/src/lib.rs | 6 +- moli-canvas/src/pixel.rs | 32 +++ moli-canvas/src/surface.rs | 295 +++++++++++++++++++++++++++ moli-canvas/tests/surface_api.rs | 285 ++++++++++++++++++++++++++ 9 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 moli-canvas/src/backend/mod.rs create mode 100644 moli-canvas/src/backend/vello_cpu.rs create mode 100644 moli-canvas/src/surface.rs create mode 100644 moli-canvas/tests/surface_api.rs diff --git a/Cargo.lock b/Cargo.lock index d4d3835c2..a1729a997 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2344,11 +2344,15 @@ dependencies = [ name = "moli-canvas" version = "0.1.0" dependencies = [ + "anyrender", + "anyrender_vello_cpu", "base64 0.22.1", "font8x8", "kurbo", "moli-image", "moli-web-mime", + "peniko", + "vello_cpu", ] [[package]] diff --git a/docs/canvas-architecture-m0.md b/docs/canvas-architecture-m0.md index d36838260..ac23530fe 100644 --- a/docs/canvas-architecture-m0.md +++ b/docs/canvas-architecture-m0.md @@ -248,6 +248,34 @@ Recorded from the M0 discussion (see proposal §6.2 and the session decisions): - `moli_image::RgbaImage` is the published page-visible snapshot unit (straight alpha), already the type `CanvasResourceStore` stores and page painting consumes. +### M2 status (surface/backend contract implemented) + +M2 delivered the independently tested canvas surface and reusable backend in +`moli-canvas`: + +- `surface.rs::CanvasSurface` is the single authoritative writable pixel store: + **premultiplied RGBA8** internally, with lazy materialization, a reusable + backend, an immutable cached straight-alpha snapshot, region readback, and + reset/resize. Deterministic `flush_count`/`snapshot_count` counters prove + scheduling properties (N draws share the surface; a clean observation does + zero additional conversion). +- `backend/vello_cpu.rs::VelloCpuBackend` reuses one `VelloCpuScenePainter` per + canvas and renders into the caller-owned persistent buffer with + `CompositeMode::SrcOver` (incremental source-over batches preserving prior + content) or `CompositeMode::Replace` (destructive clear/overwrite). This + uses `VelloCpuScenePainter`'s public `render_ctx`/`resources` rather than + `VelloCpuImageRenderer`, whose `render` hardcodes `Replace`. Whether + `RenderContext::reset()` retains the large fine-stage buffers remains an + empirical M2 detail noted for the reuse benchmark. +- Pixel-format contract: premultiplied internal (Vello-native); `unpremultiply` + happens only at snapshot/readback/export boundaries; transparent pixels + normalize to transparent black so repeated conversion is stable. +- Native tests (`tests/surface_api.rs`, no V8/Document) cover repeated + source-over rendering, snapshot isolation and clean repeated reads, clear + ordering, reset/resize (same/different/zero size), region readback clipping + and independence, low-alpha round-trip, oversized/invalid failure without + mutation, and deterministic scheduling counters. + --- ## 8. Checklist for final review (routed against this inventory) diff --git a/moli-canvas/Cargo.toml b/moli-canvas/Cargo.toml index a8b092f2a..cc74a5ae0 100644 --- a/moli-canvas/Cargo.toml +++ b/moli-canvas/Cargo.toml @@ -5,11 +5,15 @@ version = "0.1.0" edition = "2024" [dependencies] +anyrender = { git = "https://github.com/ldm0/anyrender", rev = "18fd67d7a5622a9821d86434857fd69856912a43" } +anyrender_vello_cpu = { git = "https://github.com/ldm0/anyrender", rev = "18fd67d7a5622a9821d86434857fd69856912a43", default-features = false, features = ["bitmap-glyphs"] } base64 = "0.22" font8x8 = "0.3" kurbo = "=0.13.1" moli-image = { path = "../moli-image" } moli-web-mime = { path = "../moli-web-mime" } +peniko = "0.6" +vello_cpu = "0.2" [lints] workspace = true diff --git a/moli-canvas/src/backend/mod.rs b/moli-canvas/src/backend/mod.rs new file mode 100644 index 000000000..dae975b78 --- /dev/null +++ b/moli-canvas/src/backend/mod.rs @@ -0,0 +1,5 @@ +//! Canvas rendering backends. + +pub mod vello_cpu; + +pub use vello_cpu::VelloCpuBackend; diff --git a/moli-canvas/src/backend/vello_cpu.rs b/moli-canvas/src/backend/vello_cpu.rs new file mode 100644 index 000000000..b04810e22 --- /dev/null +++ b/moli-canvas/src/backend/vello_cpu.rs @@ -0,0 +1,86 @@ +//! Reusable Vello CPU rendering backend for the Canvas surface. +//! +//! This owns a single [`VelloCpuScenePainter`] sized to the canvas and reuses +//! it across flushes. Rendering composites into the caller-provided buffer +//! using either: +//! +//! - [`CompositeMode::SrcOver`] to draw a batch of source-over operations onto +//! the *existing* canonical surface, preserving prior content without +//! clearing — the incremental path the proposal requires; or +//! - [`CompositeMode::Replace`] for a destructive step (e.g. a clear or a proven +//! full overwrite), which clears the whole destination pixmap and renders the +//! scene into it. +//! +//! The canonical surface format is premultiplied RGBA8 (see `surface.rs`); Vello +//! `SrcOver` composites premultiplied sources over premultiplied destinations, +//! which matches the authoritative store directly. + +use anyrender::PaintScene; +use anyrender_vello_cpu::VelloCpuScenePainter; +use vello_cpu::{ + CompositeMode, PixelFormat, PixmapMut, RasterizerSettings, RenderContext as VelloRenderContext, + RenderMode, Resources, +}; + +/// A reusable Vello CPU renderer bound to a canvas of `width` by `height`. +pub struct VelloCpuBackend { + renderer: VelloCpuScenePainter, + width: u32, + height: u32, +} + +impl VelloCpuBackend { + /// Creates a backend for a canvas of the given size (in pixels). + pub fn new(width: u32, height: u32) -> Self { + Self { + renderer: VelloCpuScenePainter { + render_ctx: VelloRenderContext::new(width as u16, height as u16), + resources: Resources::new(), + }, + width, + height, + } + } + + /// Whether this backend is bound to the given canvas size. + pub fn matches(&self, width: u32, height: u32) -> bool { + self.width == width && self.height == height + } + + /// Rebinds the backend to a different canvas size, discarding prior scene + /// state. Callers must also replace the backing surface storage. + pub fn resize(&mut self, width: u32, height: u32) { + self.renderer.render_ctx = VelloRenderContext::new(width as u16, height as u16); + self.width = width; + self.height = height; + } + + /// Renders a scene built by `draw` into `target` (premultiplied RGBA8 of + /// `width * height * 4` bytes) using the given composite mode. With + /// `SrcOver`, content already in `target` is preserved underneath the batch. + pub fn render(&mut self, target: &mut [u8], composite: CompositeMode, draw: F) + where + F: FnOnce(&mut VelloCpuScenePainter), + { + debug_assert_eq!( + target.len(), + self.width as usize * self.height as usize * 4, + "target must span the whole backend surface" + ); + // Reset the mutable scene but keep the renderer/resources for reuse. + self.renderer.reset(); + draw(&mut self.renderer); + self.renderer.render_ctx.flush(); + let pixmap = PixmapMut::new(self.width as u16, self.height as u16, target) + .expect("backend provides a correctly sized RGBA8 target"); + let settings = RasterizerSettings { + render_mode: RenderMode::OptimizeSpeed, + composite_mode: composite, + pixel_format: PixelFormat::Rgba8, + offset: (0, 0), + }; + self.renderer + .render_ctx + .render_with(pixmap, &mut self.renderer.resources, settings); + } +} diff --git a/moli-canvas/src/lib.rs b/moli-canvas/src/lib.rs index 63d3a3ace..70555b0c1 100644 --- a/moli-canvas/src/lib.rs +++ b/moli-canvas/src/lib.rs @@ -1,11 +1,14 @@ +mod backend; mod blit; mod encode; pub mod path; mod pixel; mod rect; +mod surface; mod text; mod types; +pub use backend::VelloCpuBackend; pub use blit::{blit_draw_image, blit_draw_image_filtered, blit_image_data, extract_image_data}; pub use encode::{ data_image_intrinsic_dimensions, data_image_rgba8_pixels, encode_data_url, @@ -14,9 +17,10 @@ pub use encode::{ pub use path::{CanvasPath, CanvasPathData}; pub use pixel::{ copy_rgba8_rect, flip_y_rgba8_in_place, multiply_u8_color, premultiply_rgba8_in_place, - scale_rgba8, scale_rgba8_bilinear, scale_rgba8_nearest, + scale_rgba8, scale_rgba8_bilinear, scale_rgba8_nearest, unpremultiply_rgba8_in_place, }; pub use rect::{canonicalize_fill_style, fill_style_rgba, normalize_rect, paint_rect}; +pub use surface::{CanvasSurface, CanvasSurfaceError}; pub use text::{draw_text, measure_text_width}; pub use types::{ CanvasRect, DEFAULT_FILL_STYLE, DEFAULT_FONT, DrawImageBlit, MAX_RGBA8_BYTE_LENGTH, Rgba8Rect, diff --git a/moli-canvas/src/pixel.rs b/moli-canvas/src/pixel.rs index e7f90131c..10ebd5b0f 100644 --- a/moli-canvas/src/pixel.rs +++ b/moli-canvas/src/pixel.rs @@ -20,6 +20,38 @@ pub fn premultiply_rgba8_in_place(pixels: &mut [u8]) -> Option { Some(opaque) } +/// Converts premultiplied RGBA8 to straight (non-premultiplied) RGBA8 in place. +/// +/// This is the inverse of [`premultiply_rgba8_in_place`] and is used at every +/// boundary that publishes canvas pixels to straight-alpha consumers (region +/// readback, `ImageData`, PNG encoding, page snapshots). Transparent pixels keep +/// arbitrary RGB and are normalized to transparent black so repeats stay stable. +/// Returns `None` when the byte length is not a multiple of four. +pub fn unpremultiply_rgba8_in_place(pixels: &mut [u8]) -> Option { + if !pixels.len().is_multiple_of(4) { + return None; + } + let mut opaque = true; + for rgba in pixels.chunks_mut(4) { + let alpha = u32::from(rgba[3]); + if alpha == 0 { + rgba[0] = 0; + rgba[1] = 0; + rgba[2] = 0; + opaque = false; + continue; + } + opaque &= alpha == u32::from(u8::MAX); + if alpha == u32::from(u8::MAX) { + continue; + } + for value in rgba[..3].iter_mut() { + *value = ((u32::from(*value) * 255 + alpha / 2) / alpha) as u8; + } + } + Some(opaque) +} + pub fn flip_y_rgba8_in_place(pixels: &mut [u8], width: u32, height: u32) -> Option<()> { if !surface_matches_len(pixels, width, height) { return None; diff --git a/moli-canvas/src/surface.rs b/moli-canvas/src/surface.rs new file mode 100644 index 000000000..2f5a53664 --- /dev/null +++ b/moli-canvas/src/surface.rs @@ -0,0 +1,295 @@ +//! Authoritative Canvas pixel surface: ownership, flush, snapshot, readback, +//! and reset. +//! +//! This is the single writable pixel store for a Canvas. Its format is +//! **premultiplied RGBA8** (matching the Vello backend's native output); +//! conversion to straight RGBA8 happens only at the boundaries that publish +//! pixels (region readback, `ImageData`, encoding, page snapshots). +//! +//! The surface lazily materializes its buffer and a reusable rendering backend +//! on first flush, reuses both across successive flushes, keeps one immutable +//! published snapshot, and never retains a history of operations. + +use std::sync::Arc; + +use anyrender_vello_cpu::VelloCpuScenePainter; +use moli_image::RgbaImage; +use vello_cpu::CompositeMode; + +use crate::backend::VelloCpuBackend; +use crate::pixel::unpremultiply_rgba8_in_place; +use crate::types::byte_len; + +/// Largest edge dimension Vello CPU can address (its contexts use `u16`). +const MAX_VELLO_EDGE: u32 = u16::MAX as u32; + +/// Failure while provisioning or exposing a canvas surface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CanvasSurfaceError { + /// The dimensions exceed the rasterizer or byte-length budget. + SurfaceTooLarge { width: u32, height: u32 }, + /// A snapshot or allocation could not be produced (resource limit). + AllocationFailed, +} + +impl std::fmt::Display for CanvasSurfaceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CanvasSurfaceError::SurfaceTooLarge { width, height } => { + write!( + f, + "canvas surface {width}x{height} exceeds the supported budget" + ) + } + CanvasSurfaceError::AllocationFailed => write!(f, "canvas surface allocation failed"), + } + } +} + +impl std::error::Error for CanvasSurfaceError {} + +/// An authoritative, premultiplied-RGBA8 Canvas pixel surface. +pub struct CanvasSurface { + width: u32, + height: u32, + /// Premultiplied RGBA8, row major. Allocated lazily on first use. + pixels: Vec, + /// Reusable Vello CPU backend, created lazily on first flush. + backend: Option, + /// Immutable straight-alpha snapshot, cached so repeated clean reads + /// perform no further rasterization or conversion. + published: Option>, + dirty: bool, + /// Number of backend submissions (flushes) performed. + flush_count: u64, + /// Number of full-surface straight-alpha conversions performed. + snapshot_count: u64, +} + +impl CanvasSurface { + /// Creates a surface with the given dimensions. Storage and backend are + /// materialized lazily when content is first rendered. + pub fn new(width: u32, height: u32) -> Result { + byte_len(width, height).ok_or(CanvasSurfaceError::SurfaceTooLarge { width, height })?; + if width > MAX_VELLO_EDGE || height > MAX_VELLO_EDGE { + return Err(CanvasSurfaceError::SurfaceTooLarge { width, height }); + } + let len = byte_len(width, height).expect("validated byte length"); + Ok(Self { + width, + height, + pixels: vec![0; len], + backend: None, + published: None, + dirty: false, + flush_count: 0, + snapshot_count: 0, + }) + } + + /// The surface width in pixels. + pub fn width(&self) -> u32 { + self.width + } + + /// The surface height in pixels. + pub fn height(&self) -> u32 { + self.height + } + + /// Whether the surface has any materialized content (allocated pixels). + pub fn is_empty(&self) -> bool { + self.width == 0 || self.height == 0 + } + + /// The authoritative premultiplied-RGBA8 pixel buffer. + pub fn premultiplied(&self) -> &[u8] { + &self.pixels + } + + /// Number of backend submissions (flushes) performed since reset. + pub fn flush_count(&self) -> u64 { + self.flush_count + } + + /// Number of full-surface straight-alpha conversions (snapshot creations) + /// performed since reset. Clean repeated reads via [`Self::snapshot`] reuse + /// the cached image and do not advance this counter. + pub fn snapshot_count(&self) -> u64 { + self.snapshot_count + } + + /// Whether a flush has occurred since the last observation. + pub fn is_dirty(&self) -> bool { + self.dirty + } + + /// Marks the surface as observed (no longer dirty) without touching pixels. + pub fn mark_clean(&mut self) { + self.dirty = false; + } + + /// Discards all pending and rendered content, restoring a transparent + /// surface. Reuses storage/backend where possible. + pub fn reset(&mut self) { + self.pixels.fill(0); + self.published = None; + self.dirty = true; + self.flush_count = 0; + self.snapshot_count = 0; + } + + /// Resizes the surface, resetting content and re-provisioning storage and + /// the backend when dimensions change (same-size assignment still clears). + /// Returns `Err` when the new dimensions exceed the budget; the previous + /// surface is left untouched in that case. + pub fn resize(&mut self, width: u32, height: u32) -> Result<(), CanvasSurfaceError> { + byte_len(width, height).ok_or(CanvasSurfaceError::SurfaceTooLarge { width, height })?; + if width > MAX_VELLO_EDGE || height > MAX_VELLO_EDGE { + return Err(CanvasSurfaceError::SurfaceTooLarge { width, height }); + } + let len = byte_len(width, height).expect("validated byte length"); + let same = self.width == width && self.height == height; + if !same { + self.pixels = vec![0; len]; + if let Some(mut backend) = self.backend.take() { + backend.resize(width, height); + self.backend = Some(backend); + } + } else { + // Same-size assignment still clears content per reset semantics. + self.pixels.fill(0); + } + self.width = width; + self.height = height; + self.published = None; + self.dirty = true; + self.flush_count = 0; + self.snapshot_count = 0; + Ok(()) + } + + /// Flushes a source-over batch of operations against the existing surface, + /// preserving prior content. The closure builds the scene against the Vello + /// scene painter (AnyRender [`PaintScene`]); it is executed against the + /// authoritative premultiplied buffer and reuses the backend across calls. + /// + /// On success the surface is marked dirty and any cached snapshot is + /// invalidated. On failure the surface is unchanged and not marked dirty, + /// so old pixels are never published as successful new content. + pub fn render(&mut self, draw: F) -> Result<(), CanvasSurfaceError> + where + F: FnOnce(&mut VelloCpuScenePainter), + { + self.render_composite(CompositeMode::SrcOver, draw) + } + + /// Flushes operations with destructive full overwrite semantics: the whole + /// destination is replaced (cleared) by the rendered scene, not composited + /// over existing content. Use for a proven full-canvas overwrite or a clear + /// that the source-over path cannot express. + pub fn render_replace(&mut self, draw: F) -> Result<(), CanvasSurfaceError> + where + F: FnOnce(&mut VelloCpuScenePainter), + { + self.render_composite(CompositeMode::Replace, draw) + } + + fn render_composite( + &mut self, + composite: CompositeMode, + draw: F, + ) -> Result<(), CanvasSurfaceError> + where + F: FnOnce(&mut VelloCpuScenePainter), + { + if self.is_empty() { + return Ok(()); + } + if self.backend.is_none() { + self.backend = Some(VelloCpuBackend::new(self.width, self.height)); + } + let backend = self.backend.as_mut().expect("backend provisioned"); + let buffer = &mut self.pixels; + if composite == CompositeMode::Replace { + buffer.fill(0); + } + backend.render(buffer, composite, draw); + self.published = None; + self.dirty = true; + self.flush_count = self.flush_count.saturating_add(1); + Ok(()) + } + + /// Destructively clears the whole surface to transparent without a raster + /// step. Invalidates any cached snapshot and marks the surface dirty. + pub fn clear(&mut self) { + self.pixels.fill(0); + self.published = None; + self.dirty = true; + self.flush_count = self.flush_count.saturating_add(1); + } + + /// An immutable straight-alpha snapshot of the whole surface, cached so + /// repeated clean observations perform no further rasterization. The + /// snapshot remains valid (unchanged) after subsequent drawing. + pub fn snapshot(&mut self) -> Result, CanvasSurfaceError> { + if let Some(published) = &self.published { + return Ok(published.clone()); + } + let mut straight = self.pixels.clone(); + unpremultiply_rgba8_in_place(&mut straight) + .expect("surface byte length is a multiple of four"); + let image = RgbaImage::try_new(self.width, self.height, straight) + .map_err(|_| CanvasSurfaceError::AllocationFailed)?; + let published = Arc::new(image); + self.published = Some(published.clone()); + self.snapshot_count = self.snapshot_count.saturating_add(1); + Ok(published) + } + + /// Copies a rectangular region out of the surface as straight RGBA8 into a + /// freshly allocated buffer. Out-of-canvas regions are filled transparent + /// (matching `getImageData` semantics). Only the visible intersection is + /// read, so a small region does not require a full-frame intermediate copy. + pub fn readback_region(&self, x: i32, y: i32, width: u32, height: u32) -> Vec { + let mut out = vec![0; (width as usize) * (height as usize) * 4]; + if self.is_empty() { + return out; + } + let src_row_stride = self.width as usize * 4; + let dst_row_stride = width as usize * 4; + for row in 0..height as usize { + let src_y = y + row as i32; + if src_y < 0 || src_y >= self.height as i32 { + continue; + } + for col in 0..width as usize { + let src_x = x + col as i32; + if src_x < 0 || src_x >= self.width as i32 { + continue; + } + let src_index = (src_y as usize) * src_row_stride + (src_x as usize) * 4; + let dst_index = row * dst_row_stride + col * 4; + let px = &self.pixels[src_index..src_index + 4]; + let dst = &mut out[dst_index..dst_index + 4]; + let alpha = u32::from(px[3]); + dst[3] = px[3]; + if alpha == 0 { + dst[0] = 0; + dst[1] = 0; + dst[2] = 0; + } else if alpha == u32::from(u8::MAX) { + dst[0] = px[0]; + dst[1] = px[1]; + dst[2] = px[2]; + } else { + dst[0] = ((u32::from(px[0]) * 255 + alpha / 2) / alpha) as u8; + dst[1] = ((u32::from(px[1]) * 255 + alpha / 2) / alpha) as u8; + dst[2] = ((u32::from(px[2]) * 255 + alpha / 2) / alpha) as u8; + } + } + } + out + } +} diff --git a/moli-canvas/tests/surface_api.rs b/moli-canvas/tests/surface_api.rs new file mode 100644 index 000000000..61882676c --- /dev/null +++ b/moli-canvas/tests/surface_api.rs @@ -0,0 +1,285 @@ +//! Native (V8-free) tests for the Canvas surface/backend contract. +//! +//! These exercise the public [`CanvasSurface`] API through the real Vello CPU +//! backend, proving repeated rendering onto a persistent surface, immutable +//! snapshot isolation, reset, composition, pixel-format conversion, region +//! readback, and the failure/accounting contract. + +use std::sync::Arc; + +use anyrender::PaintScene; +use anyrender_vello_cpu::VelloCpuScenePainter; +use kurbo::{Affine, Rect}; +use moli_canvas::{CanvasSurface, CanvasSurfaceError}; +use peniko::{Color, Fill}; + +fn fill(scene: &mut VelloCpuScenePainter, x: f64, y: f64, w: f64, h: f64, color: Color) { + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + color, + None, + &Rect::new(x, y, x + w, y + h), + ); +} + +const RED: Color = Color::new([1.0, 0.0, 0.0, 1.0]); +const GREEN: Color = Color::new([0.0, 1.0, 0.0, 1.0]); + +/// Reads the straight-alpha RGBA8 pixel at (x, y) via a 1x1 region readback. +fn pixel(surface: &CanvasSurface, x: i32, y: i32) -> [u8; 4] { + let bytes = surface.readback_region(x, y, 1, 1); + [bytes[0], bytes[1], bytes[2], bytes[3]] +} + +#[test] +fn repeated_source_over_rendering_preserves_prior_content() { + let mut surface = CanvasSurface::new(16, 16).unwrap(); + surface + .render(|s| fill(s, 1.0, 1.0, 5.0, 5.0, RED)) + .unwrap(); + // A second source-over batch composes over the first, not replacing it. + surface + .render(|s| fill(s, 3.0, 3.0, 5.0, 5.0, GREEN)) + .unwrap(); + + // (2,2) is covered by both: red over which green was drawn at (3,3)+, + // so the interior at (2,2) is still pure red. + assert_eq!(pixel(&surface, 2, 2), [255, 0, 0, 255]); + // (5,5) is inside the green rect (green's alpha=255 overwrites red fully). + assert_eq!(pixel(&surface, 5, 5), [0, 255, 0, 255]); + // Snapshots reflect the cumulative content. + let snap = surface.snapshot().unwrap(); + assert_eq!( + snap.rgba[((2 * 16 + 2) * 4)..((2 * 16 + 2) * 4 + 4)], + [255, 0, 0, 255] + ); + assert_eq!( + snap.rgba[((5 * 16 + 5) * 4)..((5 * 16 + 5) * 4 + 4)], + [0, 255, 0, 255] + ); +} + +#[test] +fn snapshot_isolation_and_clean_repeated_reads() { + let mut surface = CanvasSurface::new(16, 16).unwrap(); + surface + .render(|s| fill(s, 1.0, 1.0, 5.0, 5.0, RED)) + .unwrap(); + let first = surface.snapshot().unwrap(); + + // Drawing more does not mutate the already-published snapshot. + surface + .render(|s| fill(s, 8.0, 8.0, 5.0, 5.0, GREEN)) + .unwrap(); + assert_eq!( + first.rgba[(2 * 16 + 2) * 4 + 3], + 255, + "old snapshot is unchanged where it had content" + ); + // The old snapshot has no green at the new rectangle area. + assert_eq!( + first.rgba[(10 * 16 + 10) * 4 + 1], + 0, + "old snapshot predates the green fill" + ); + + // Clean repeated reads produce the same cached snapshot (no re-conversion). + let second = surface.snapshot().unwrap(); + let third = surface.snapshot().unwrap(); + assert_eq!(second.rgba, third.rgba); + assert!( + Arc::ptr_eq(&second, &third), + "clean repeated reads reuse the cached snapshot" + ); + + // The surface and the new snapshot both have the green. + let fresh = surface.snapshot().unwrap(); + assert_eq!(fresh.rgba[(10 * 16 + 10) * 4], 0); + assert_eq!(fresh.rgba[(10 * 16 + 10) * 4 + 1], 255); + assert_eq!(fresh.rgba[(10 * 16 + 10) * 4 + 3], 255); +} + +#[test] +fn clear_between_draws_preserves_ordering() { + let mut surface = CanvasSurface::new(16, 16).unwrap(); + surface + .render(|s| fill(s, 0.0, 0.0, 16.0, 16.0, RED)) + .unwrap(); + // Destructive clear removes everything. + surface.clear(); + assert_eq!( + pixel(&surface, 5, 5), + [0, 0, 0, 0], + "clear empties the surface" + ); + surface + .render(|s| fill(s, 2.0, 2.0, 4.0, 4.0, GREEN)) + .unwrap(); + assert_eq!( + pixel(&surface, 3, 3), + [0, 255, 0, 255], + "draw after clear lands correctly" + ); + assert_eq!( + pixel(&surface, 10, 10), + [0, 0, 0, 0], + "no stale red remains" + ); +} + +#[test] +fn reset_semantics_same_size_and_resize() { + let mut surface = CanvasSurface::new(10, 10).unwrap(); + surface + .render(|s| fill(s, 0.0, 0.0, 10.0, 10.0, RED)) + .unwrap(); + + // Same-size resize still clears per reset semantics and reuses allocation. + surface.resize(10, 10).unwrap(); + assert_eq!( + pixel(&surface, 5, 5), + [0, 0, 0, 0], + "same-size resize clears content" + ); + assert_eq!(surface.premultiplied().len(), 10 * 10 * 4); + + // Different-size resize resets dimensions and content. + surface + .render(|s| fill(s, 0.0, 0.0, 5.0, 5.0, GREEN)) + .unwrap(); + surface.resize(20, 4).unwrap(); + assert_eq!(surface.premultiplied().len(), 20 * 4 * 4); + assert_eq!( + pixel(&surface, 2, 2), + [0, 0, 0, 0], + "resized surface is empty" + ); + + // Zero-size is well-defined and does not panic on render/readback. + let mut zero = CanvasSurface::new(0, 0).unwrap(); + assert!(zero.is_empty()); + zero.render(|_| {}).unwrap(); + assert_eq!(zero.readback_region(0, 0, 4, 4), vec![0; 4 * 4 * 4]); +} + +#[test] +fn readback_region_clipping_and_independence() { + let mut surface = CanvasSurface::new(8, 8).unwrap(); + surface + .render(|s| fill(s, 1.0, 1.0, 4.0, 4.0, RED)) + .unwrap(); + + // Out-of-canvas parts are filled transparent; only the visible intersection is read. + let region = surface.readback_region(-2, 2, 6, 4); + assert_eq!(region.len(), 6 * 4 * 4); + // Pixel that lies within the red rect at (source 2,2) maps to (4,0) in region: + let idx = 4 * 4; + assert_eq!(region[idx..idx + 4], [255, 0, 0, 255]); + // Pixels outside the canvas (region column 0 and 1, x=-2,-1) are transparent. + assert_eq!(®ion[0..3], &[0, 0, 0]); + + // Readback returns an independent buffer; writing to it does not affect the surface. + let mut independent = surface.readback_region(0, 0, 8, 8); + independent[0] = 99; + assert_eq!(pixel(&surface, 0, 0), [0, 0, 0, 0]); +} + +#[test] +fn pixel_format_low_alpha_and_round_trip() { + let mut surface = CanvasSurface::new(8, 8).unwrap(); + // A translucent red (alpha ~= 51) leaves a low-alpha premultiplied channel. + surface + .render(|s| { + fill( + s, + 1.0, + 1.0, + 6.0, + 6.0, + Color::new([1.0, 0.0, 0.0, 51.0 / 255.0]), + ) + }) + .unwrap(); + let px = pixel(&surface, 3, 3); + assert_eq!(px[3], 51, "alpha is preserved straight"); + // Straight red = 255 within rounding at alpha 51. + assert!(px[0] >= 245, "low-alpha straight red stays near 255"); + + // Round-trip: a snapshot and a region read share the same straight conversion. + let snap = surface.snapshot().unwrap(); + let snap_px = &snap.rgba[((3 * 8 + 3) * 4)..((3 * 8 + 3) * 4 + 4)]; + assert_eq!(snap_px[3], 51); + assert_eq!(snap_px, &[px[0], px[1], px[2], px[3]]); + + // Fully transparent pixels normalize to transparent black on readback. + assert_eq!(pixel(&surface, 0, 0), [0, 0, 0, 0]); +} + +#[test] +fn invalid_or_oversized_surface_fails_without_mutating_existing() { + // Edge beyond Vello's u16 limit is rejected. + assert!(matches!( + CanvasSurface::new(u16::MAX as u32 + 1, 4), + Err(CanvasSurfaceError::SurfaceTooLarge { .. }) + )); + // Byte lengths beyond the platform budget are rejected. + assert!(CanvasSurface::new(u32::MAX, u32::MAX).is_err()); + // An oversized resize leaves the prior surface intact (no mutation). + let mut surface = CanvasSurface::new(8, 8).unwrap(); + surface + .render(|s| fill(s, 0.0, 0.0, 8.0, 8.0, RED)) + .unwrap(); + assert!(surface.resize(u16::MAX as u32 + 1, 1).is_err()); + assert_eq!(surface.width(), 8); + assert_eq!( + pixel(&surface, 4, 4), + [255, 0, 0, 255], + "failed resize preserves old content" + ); +} + +#[test] +fn deterministic_counters_prove_scheduling_and_clean_reads() { + let mut surface = CanvasSurface::new(64, 64).unwrap(); + // N draws against the persistent surface each submit one backend flush, but + // do not produce any full-image copies until an observation occurs. + for i in 0..100u32 { + let off = (i % 40) as f64; + surface + .render(move |s| fill(s, off, off, 4.0, 4.0, RED)) + .unwrap(); + } + assert_eq!(surface.flush_count(), 100, "each draw submits one flush"); + assert_eq!( + surface.snapshot_count(), + 0, + "no observation yet, no full-image copy" + ); + + // One observation performs a single full-image conversion. + let _snap = surface.snapshot().unwrap(); + assert_eq!(surface.snapshot_count(), 1); + + // A clean subsequent observation performs zero additional conversion work. + for _ in 0..10 { + let _again = surface.snapshot().unwrap(); + } + assert_eq!( + surface.snapshot_count(), + 1, + "clean repeated reads reuse the cached snapshot" + ); + + // A new draw invalidates the snapshot; the next observation converts once. + surface + .render(|s| fill(s, 50.0, 50.0, 4.0, 4.0, GREEN)) + .unwrap(); + assert_eq!( + surface.snapshot_count(), + 1, + "unobserved new content is not converted" + ); + let _after = surface.snapshot().unwrap(); + assert_eq!(surface.snapshot_count(), 2); +} From c09d9e09e9efbef34a0a4e18da5fa117325db7e3 Mon Sep 17 00:00:00 2001 From: BibekPathak Date: Mon, 7 Sep 2026 08:46:01 +0530 Subject: [PATCH 04/12] make a native CanvasSurface the single pixel owner in the renderer --- docs/canvas-architecture-m0.md | 23 ++ moli-canvas/src/surface.rs | 22 +- .../context_bootstrap/canvas/backing_store.rs | 303 +++++++++++++----- 3 files changed, 273 insertions(+), 75 deletions(-) diff --git a/docs/canvas-architecture-m0.md b/docs/canvas-architecture-m0.md index ac23530fe..965cbb1ca 100644 --- a/docs/canvas-architecture-m0.md +++ b/docs/canvas-architecture-m0.md @@ -276,6 +276,29 @@ M2 delivered the independently tested canvas surface and reusable backend in and independence, low-alpha round-trip, oversized/invalid failure without mutation, and deterministic scheduling counters. +### M3 status (single native pixel owner connected) + +M3 replaced the mutable V8 `Uint8ClampedArray` backing store with a single, +per-context, weak-keyed native `CanvasSurface` owner: + +- `canvas/backing_store.rs` now keeps each canvas's authoritative pixels in a + `moli_canvas::CanvasSurface` held in a weak-keyed per-context registry (the + same GC-finalizer/isolation pattern as `canvas/state.rs`), so surface + lifetime is reclaimed with the canvas (GC) and with the isolate. +- The existing straight-RGBA8 draw helpers run against the surface through the + transitional `CanvasSurface::with_straight_pixels_mut` adapter, keeping output + byte-identical while the surface stays the single owner; M4's recorder + replaces this with batched Vello rendering. +- `getImageData`, `toDataURL`, `createImageBitmap` sources, and every draw path + read/write the native owner; HTML page publication (`CanvasResourceStore`) is + driven from the surface snapshot. +- A native GC/reclamation test (`canvas_surfaces_are_reclaimed_with_canvas_gc_and_isolate_destruction`) + proves lifecycle release. + +All 115 canvas JS regressions (including the `__moliCanvasBackingStore` +reflection/spoofing robustness test) and the moli-canvas native surface tests +pass against the native owner. + --- ## 8. Checklist for final review (routed against this inventory) diff --git a/moli-canvas/src/surface.rs b/moli-canvas/src/surface.rs index 2f5a53664..69c4520ec 100644 --- a/moli-canvas/src/surface.rs +++ b/moli-canvas/src/surface.rs @@ -17,7 +17,7 @@ use moli_image::RgbaImage; use vello_cpu::CompositeMode; use crate::backend::VelloCpuBackend; -use crate::pixel::unpremultiply_rgba8_in_place; +use crate::pixel::{premultiply_rgba8_in_place, unpremultiply_rgba8_in_place}; use crate::types::byte_len; /// Largest edge dimension Vello CPU can address (its contexts use `u16`). @@ -230,6 +230,26 @@ impl CanvasSurface { self.flush_count = self.flush_count.saturating_add(1); } + /// Transitional adapter for the existing straight-RGBA8 draw helpers. + /// + /// Runs `f` against the whole surface as straight (non-premultiplied) RGBA8, + /// then converts back to the authoritative premultiplied store. This keeps + /// the immediate-execution draw paths producing byte-identical results while + /// the surface remains the single owner; M4's ordered recorder replaces this + /// per-call full-plane conversion with batched Vello rendering. The cached + /// snapshot is invalidated. + pub fn with_straight_pixels_mut(&mut self, f: impl FnOnce(&mut [u8], u32, u32)) -> Option<()> { + if self.is_empty() { + return None; + } + unpremultiply_rgba8_in_place(&mut self.pixels)?; + f(&mut self.pixels, self.width, self.height); + premultiply_rgba8_in_place(&mut self.pixels)?; + self.published = None; + self.dirty = true; + Some(()) + } + /// An immutable straight-alpha snapshot of the whole surface, cached so /// repeated clean observations perform no further rasterization. The /// snapshot remains valid (unchanged) after subsequent drawing. diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs b/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs index 584b5086e..3ff30e385 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs @@ -1,19 +1,49 @@ -use super::super::image_data::new_uint8_clamped_array_from_bytes; -use super::{OFFSCREEN_CANVAS_HEIGHT_SLOT, OFFSCREEN_CANVAS_WIDTH_SLOT}; +//! Authoritative per-canvas native pixel surface, replacing the V8 backing +//! array. +//! +//! A Canvas with a 2D context owns one [`moli_canvas::CanvasSurface`] that +//! holds its pixels as its single writable owner (premultiplied RGBA8, with +//! straight-alpha handled at the observation/`ImageData`/publication boundary). +//! The native surface is keyed by the canvas-like JS object through a +//! weak-keyed per-context registry, so its lifetime is reclaimed with the canvas +//! (GC) and with the isolate, mirroring `state.rs`. +//! +//! The existing immediate-execution draw helpers operate on straight RGBA8, so +//! they run through [`CanvasSurface::with_straight_pixels_mut`] — a transitional +//! adapter that yields byte-identical results while the surface stays the single +//! owner. M4's ordered recorder replaces this per-call conversion with batched +//! Vello rendering. + +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + use crate::util::{get_private_object, get_private_value, set_private_value}; use crate::webidl; use crate::{ document_runtime::DomHandle, native_bridge::{JsContextHost, node_runtime_and_handle_from_object_or_detached}, }; -use moli_canvas::{byte_len as canvas_byte_len, encode_data_url}; +use moli_canvas::{CanvasSurface, encode_data_url}; use moli_webapi_declare::WebApiObject; -const CANVAS_BACKING_STORE_SLOT: &str = "__moliCanvasBackingStore"; +const CANVAS_SURFACE_ID_SLOT: &str = "__moliCanvasLiveSurfaceId"; const CANVAS_OWNER_SLOT: &str = "__moliCanvasOwner"; const CANVAS_HAS_CONTEXT_SLOT: &str = "__moliCanvasHasContext"; const CANVAS_2D_CONTEXT_SLOT: &str = "__moliCanvas2DContext"; +type SurfaceCell = Rc>>; +type SurfaceStore = Rc>; + +#[derive(Default)] +struct SurfaceRegistry { + next_id: u64, + entries: HashMap, +} + +struct SurfaceRegistryEntry { + _context: v8::Weak, + surface: SurfaceCell, +} + #[derive(WebApiObject)] #[webapi(interface = "Object")] struct CanvasContextOwnerDeclaration<'scope> { @@ -37,7 +67,7 @@ pub(crate) fn attach_canvas_like_context_object<'s>( CANVAS_HAS_CONTEXT_SLOT, v8::Boolean::new(scope, true).into(), ); - let _ = ensure_canvas_like_backing_store(scope, canvas); + let _ = canvas_surface_cell(scope, canvas); } pub(super) fn canvas_like_has_context<'s>( @@ -56,26 +86,20 @@ pub(crate) fn reset_canvas_like_backing_store<'s>( super::context2d::reset_canvas_context_state(scope, context); } let Some((width, height)) = canvas_like_dimensions(scope, canvas) else { - remove_html_canvas_pixels(scope, canvas); + remove_canvas_surface(scope, canvas); return; }; - let Some(len) = canvas_byte_len(width, height) else { - remove_html_canvas_pixels(scope, canvas); + let cell = canvas_surface_cell(scope, canvas); + let too_large = !materialize_surface(&cell, width, height); + if too_large { + remove_canvas_surface(scope, canvas); return; - }; - let Some(bytes) = new_uint8_clamped_array_from_bytes(scope, vec![0; len]) else { - remove_html_canvas_pixels(scope, canvas); - return; - }; - set_private_value(scope, canvas, CANVAS_BACKING_STORE_SLOT, bytes.into()); - replace_html_canvas_pixels(scope, canvas, width, height, vec![0; len]); -} - -pub(super) fn canvas_2d_context<'s>( - scope: &mut v8::PinScope<'s, '_>, - canvas: v8::Local<'s, v8::Object>, -) -> Option> { - get_private_object(scope, canvas, CANVAS_2D_CONTEXT_SLOT) + } + cell.borrow_mut() + .as_mut() + .expect("surface materialized") + .reset(); + publish_canvas_snapshot(scope, canvas); } pub(crate) fn reset_html_canvas_backing_store_for_dimension_assignment<'s>( @@ -97,7 +121,7 @@ pub(crate) fn reset_html_canvas_backing_store_for_dimension_assignment<'s>( let _ = unsafe { &mut *runtime_ptr }.remove_canvas_pixels(handle); return; }; - if get_private_value(scope, canvas, CANVAS_BACKING_STORE_SLOT).is_none() { + if !canvas_like_has_context(scope, canvas) { let _ = unsafe { &mut *runtime_ptr }.remove_canvas_pixels(handle); return; } @@ -127,17 +151,23 @@ pub(super) fn with_canvas_like_pixels_mut<'s, F>( where F: FnOnce(&mut [u8], u32, u32), { - let Some((view, width, height)) = canvas_like_pixel_view(scope, canvas) else { + let Some((width, height)) = canvas_like_dimensions(scope, canvas) else { return false; }; - let mut bytes = vec![0; view.byte_length()]; - let written = view.copy_contents(&mut bytes); - bytes.truncate(written); - mutate(&mut bytes, width, height); - if write_bytes_to_view(scope, view, &bytes).is_none() { + let cell = canvas_surface_cell(scope, canvas); + if !materialize_surface(&cell, width, height) { return false; } - replace_html_canvas_pixels(scope, canvas, width, height, bytes); + { + let mut surface = cell.borrow_mut(); + let Some(surface) = surface.as_mut() else { + return false; + }; + if surface.with_straight_pixels_mut(mutate).is_none() { + return false; + } + } + publish_canvas_snapshot(scope, canvas); true } @@ -145,38 +175,115 @@ pub(super) fn canvas_like_pixels_copy<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, ) -> Option<(Vec, u32, u32)> { - let (view, width, height) = canvas_like_pixel_view(scope, canvas)?; - let mut bytes = vec![0; view.byte_length()]; - let written = view.copy_contents(&mut bytes); - bytes.truncate(written); - Some((bytes, width, height)) + let (width, height) = canvas_like_dimensions(scope, canvas)?; + let cell = canvas_surface_cell(scope, canvas); + if !materialize_surface(&cell, width, height) { + return None; + } + let mut surface = cell.borrow_mut(); + let surface = surface.as_mut()?; + let snapshot = surface.snapshot().ok()?; + Some((snapshot.rgba.clone(), snapshot.width, snapshot.height)) } -fn canvas_like_pixel_view<'s>( +pub(super) fn canvas_2d_context<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, -) -> Option<(v8::Local<'s, v8::Uint8ClampedArray>, u32, u32)> { - let (width, height) = canvas_like_dimensions(scope, canvas)?; - let view = ensure_canvas_like_backing_store(scope, canvas)?; - Some((view, width, height)) +) -> Option> { + get_private_object(scope, canvas, CANVAS_2D_CONTEXT_SLOT) } -fn ensure_canvas_like_backing_store<'s>( +fn surface_store<'s>(scope: &mut v8::PinScope<'s, '_>) -> SurfaceStore { + if let Some(store) = scope.get_slot::() { + return store.clone(); + } + let store = SurfaceStore::default(); + scope.set_slot(store.clone()); + store +} + +/// Returns (or creates, once per canvas lifetime) the per-canvas surface cell. +fn canvas_surface_cell<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, -) -> Option> { - let (width, height) = canvas_like_dimensions(scope, canvas)?; - let expected_len = canvas_byte_len(width, height)?; - if let Some(existing) = get_private_value(scope, canvas, CANVAS_BACKING_STORE_SLOT) - .and_then(|value| v8::Local::::try_from(value).ok()) - && existing.byte_length() == expected_len +) -> SurfaceCell { + let store = surface_store(scope); + if let Some(id) = get_private_value(scope, canvas, CANVAS_SURFACE_ID_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok()) + .map(|value| value.u64_value().0) { - return Some(existing); + return store + .borrow() + .entries + .get(&id) + .expect("a live canvas must retain its native surface") + .surface + .clone(); + } + let id = { + let mut store = store.borrow_mut(); + store.next_id = store + .next_id + .checked_add(1) + .expect("canvas surface identity exhausted"); + store.next_id + }; + let weak_store = Rc::downgrade(&store); + let owner = v8::Weak::with_finalizer( + scope, + canvas, + Box::new(move |_| { + if let Some(store) = weak_store.upgrade() { + store.borrow_mut().entries.remove(&id); + } + }), + ); + let surface: SurfaceCell = Rc::new(RefCell::new(None)); + store.borrow_mut().entries.insert( + id, + SurfaceRegistryEntry { + _context: owner, + surface: surface.clone(), + }, + ); + let id = v8::BigInt::new_from_u64(scope, id); + set_private_value(scope, canvas, CANVAS_SURFACE_ID_SLOT, id.into()); + surface +} + +/// Materializes the surface cell at `width` x `height`, resizing (and resetting +/// content) only when the dimensions change. Same-size access preserves content. +/// Returns `false` when the dimensions exceed the budget, leaving no surface. +fn materialize_surface(cell: &SurfaceCell, width: u32, height: u32) -> bool { + let mut guard = cell.borrow_mut(); + match guard.as_mut() { + Some(surface) => { + if surface.width() == width && surface.height() == height { + true + } else { + match surface.resize(width, height) { + Ok(()) => true, + Err(_) => { + *guard = None; + false + } + } + } + } + None => match CanvasSurface::new(width, height) { + Ok(surface) => { + *guard = Some(surface); + true + } + Err(_) => false, + }, } - let bytes = new_uint8_clamped_array_from_bytes(scope, vec![0; expected_len])?; - set_private_value(scope, canvas, CANVAS_BACKING_STORE_SLOT, bytes.into()); - replace_html_canvas_pixels(scope, canvas, width, height, vec![0; expected_len]); - Some(bytes) +} + +fn remove_canvas_surface<'s>(scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>) { + let cell = canvas_surface_cell(scope, canvas); + *cell.borrow_mut() = None; + remove_html_canvas_pixels(scope, canvas); } fn html_canvas_identity<'s>( @@ -191,17 +298,27 @@ fn html_canvas_identity<'s>( .then_some((runtime_ptr, handle)) } -fn replace_html_canvas_pixels<'s>( +fn publish_canvas_snapshot<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, - width: u32, - height: u32, - rgba: Vec, ) { let Some((runtime_ptr, handle)) = html_canvas_identity(scope, canvas) else { return; }; - let _ = unsafe { &mut *runtime_ptr }.replace_canvas_pixels(handle, width, height, rgba); + let cell = canvas_surface_cell(scope, canvas); + let Some(snapshot) = cell + .borrow_mut() + .as_mut() + .and_then(|surface| surface.snapshot().ok()) + else { + return; + }; + let _ = unsafe { &mut *runtime_ptr }.replace_canvas_pixels( + handle, + snapshot.width, + snapshot.height, + snapshot.rgba.clone(), + ); } fn remove_html_canvas_pixels<'s>( @@ -218,8 +335,9 @@ fn canvas_like_dimensions<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, ) -> Option<(u32, u32)> { - let width = canvas_like_dimension(scope, canvas, OFFSCREEN_CANVAS_WIDTH_SLOT, "width")?; - let height = canvas_like_dimension(scope, canvas, OFFSCREEN_CANVAS_HEIGHT_SLOT, "height")?; + let width = canvas_like_dimension(scope, canvas, super::OFFSCREEN_CANVAS_WIDTH_SLOT, "width")?; + let height = + canvas_like_dimension(scope, canvas, super::OFFSCREEN_CANVAS_HEIGHT_SLOT, "height")?; Some((width, height)) } @@ -236,20 +354,57 @@ fn canvas_like_dimension<'s>( Some(value.max(0.0).trunc() as u32) } -fn write_bytes_to_view<'s>( - scope: &mut v8::PinScope<'s, '_>, - view: v8::Local<'s, v8::Uint8ClampedArray>, - bytes: &[u8], -) -> Option<()> { - if view.byte_length() != bytes.len() { - return None; - } - let backing_store = view.buffer(scope)?; - let data = backing_store.data()?; - let ptr = data.as_ptr() as *mut u8; - let byte_offset = view.byte_offset(); - unsafe { - std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr.add(byte_offset), bytes.len()); +#[cfg(test)] +mod tests { + use super::*; + + /// Native surface ownership is reclaimed with the canvas (GC) and with the + /// isolate, matching the path-state lifecycle in `state.rs`. + #[test] + fn canvas_surfaces_are_reclaimed_with_canvas_gc_and_isolate_destruction() { + moli_v8_test_util::ensure_v8(); + let mut isolate = v8::Isolate::new(Default::default()); + + let live_surface = { + let scope = std::pin::pin!(v8::HandleScope::new(&mut isolate)); + let scope = &mut scope.init(); + let context = v8::Context::new(scope, Default::default()); + let scope = &mut v8::ContextScope::new(scope, context); + // Two canvases own two independent native surfaces in one registry, + // with stable per-canvas identity. + let canvas_a = v8::Object::new(scope); + let cell_a = canvas_surface_cell(scope, canvas_a); + let canvas_b = v8::Object::new(scope); + let cell_b = canvas_surface_cell(scope, canvas_b); + assert_eq!(surface_store(scope).borrow().entries.len(), 2); + assert!(Rc::ptr_eq(&cell_a, &canvas_surface_cell(scope, canvas_a))); + assert!( + !Rc::ptr_eq(&cell_a, &cell_b), + "distinct canvases own distinct surfaces" + ); + Rc::downgrade(&cell_a) + }; + + // Both canvases went out of scope; a GC must drop their native surfaces. + isolate.low_memory_notification(); + assert!( + live_surface.upgrade().is_none(), + "GC must drop the unreachable canvas native surface" + ); + { + let scope = std::pin::pin!(v8::HandleScope::new(&mut isolate)); + let scope = &mut scope.init(); + let store = scope + .get_slot::() + .expect("isolate slot retains registry"); + assert_eq!( + store.borrow().entries.len(), + 0, + "GC releases the native surface registry entries" + ); + } + + // Isolate teardown needs no explicit cleanup. + let _ = isolate; } - Some(()) } From 2e2cf7c2473e1402fb0291d42966cc4680179bce Mon Sep 17 00:00:00 2001 From: BibekPathak Date: Mon, 7 Sep 2026 10:06:32 +0530 Subject: [PATCH 05/12] add ordered Canvas recording engine to moli-canvas (M4 core) --- docs/canvas-architecture-m0.md | 21 ++ moli-canvas/src/lib.rs | 2 + moli-canvas/src/recording.rs | 421 +++++++++++++++++++++++++++++++++ moli-canvas/tests/recording.rs | 185 +++++++++++++++ 4 files changed, 629 insertions(+) create mode 100644 moli-canvas/src/recording.rs create mode 100644 moli-canvas/tests/recording.rs diff --git a/docs/canvas-architecture-m0.md b/docs/canvas-architecture-m0.md index 965cbb1ca..18305a226 100644 --- a/docs/canvas-architecture-m0.md +++ b/docs/canvas-architecture-m0.md @@ -299,6 +299,27 @@ All 115 canvas JS regressions (including the `__moliCanvasBackingStore` reflection/spoofing robustness test) and the moli-canvas native surface tests pass against the native owner. +### M4 status (ordered recording core implemented) + +M4 delivered the reusable ordered recording engine in `moli-canvas/src/recording.rs`: + +- `DrawRecording` captures every ordinary 2D draw operation as a frozen `DrawOp` in + call order: `FillPath`, `StrokePath`, `FillRect`, `StrokeRect`, `ClearRect`, + `DrawImage`, `Text` (fill/stroke), `PutImageData`. +- Scene-expressible ops (`FillPath`, `StrokePath`, `FillRect`, `StrokeRect`) are + batched into a single `surface.render` (SrcOver) flush, minimizing backend + submissions. `ClearRect` forces a flush boundary and executes with `Replace` mode. +- Direct-ordered ops (`DrawImage`, `Text`, `PutImageData`) execute against the + surface via `with_straight_pixels_mut` between scene batches, preserving call order. +- `StrokeSpec` carries frozen `peniko::Stroke` metrics across the recording boundary; + `to_stroke` converts `kurbo::Stroke` via peniko's `Stroke`/`Dashes` APIs. +- `DrawImage` holds a source snapshot (`Arc>`) so later source mutation does + not affect replay. +- `reset` discards all pending ops; `snapshot` on the underlying surface is immutable. +- Native tests (`tests/recording.rs`, 6 tests, V8-free): batched flush count, + ClearRect segmentation, source immutability, reset, PutImageData ordering, stroke + metrics carry. + --- ## 8. Checklist for final review (routed against this inventory) diff --git a/moli-canvas/src/lib.rs b/moli-canvas/src/lib.rs index 70555b0c1..963dab682 100644 --- a/moli-canvas/src/lib.rs +++ b/moli-canvas/src/lib.rs @@ -3,6 +3,7 @@ mod blit; mod encode; pub mod path; mod pixel; +pub mod recording; mod rect; mod surface; mod text; @@ -19,6 +20,7 @@ pub use pixel::{ copy_rgba8_rect, flip_y_rgba8_in_place, multiply_u8_color, premultiply_rgba8_in_place, scale_rgba8, scale_rgba8_bilinear, scale_rgba8_nearest, unpremultiply_rgba8_in_place, }; +pub use recording::{DrawOp, DrawRecording, ExecutionStats, StrokeSpec}; pub use rect::{canonicalize_fill_style, fill_style_rgba, normalize_rect, paint_rect}; pub use surface::{CanvasSurface, CanvasSurfaceError}; pub use text::{draw_text, measure_text_width}; diff --git a/moli-canvas/src/recording.rs b/moli-canvas/src/recording.rs new file mode 100644 index 000000000..1ea861f11 --- /dev/null +++ b/moli-canvas/src/recording.rs @@ -0,0 +1,421 @@ +//! Browser-independent ordered Canvas 2D recorder. +//! +//! A [`DrawRecording`] captures every ordinary drawing operation with its +//! frozen inputs (geometry, captured paint state, and immutable source image +//! snapshots) in call order. Executing it against a persistent +//! [`CanvasSurface`] reproduces the drawn result while batching contiguous +//! scene-expressible source-over operations (path fills/strokes, rectangles) +//! into fewer rasterizations, and preserving order through segmented internal +//! steps for destructive clears, image/text blits, and direct pixel writes. +//! +//! The recorder holds no page-layout or browser state; the browser adapter owns +//! the drawing state and current path and only hands frozen inputs here. +//! Executing/flushing must not reset the drawing state or current path — those +//! stay with the caller. `clear()` discards pending work. + +use std::sync::Arc; + +use anyrender::PaintScene; +use kurbo::{Affine, BezPath, Rect, Shape, Stroke}; +use moli_image::RgbaImage; +use peniko::{Color, Fill}; + +use crate::blit::{blit_draw_image_filtered, blit_image_data}; +use crate::rect::paint_rect; +use crate::surface::{CanvasSurface, CanvasSurfaceError}; +use crate::text::draw_text; +use crate::types::{CanvasRect, DrawImageBlit, ScaleFilter}; + +/// Frozen stroke metrics that must survive the recording boundary unchanged. +#[derive(Clone, Debug)] +pub struct StrokeSpec { + pub width: f64, + pub cap: kurbo::Cap, + pub join: kurbo::Join, + pub miter_limit: f64, + pub dash_pattern: Vec, + pub dash_offset: f64, +} + +/// One ordered ordinary drawing operation with captured inputs. +#[derive(Clone, Debug)] +pub enum DrawOp { + /// An already-canvas-space path filled with a straight-alpha color. + FillPath { path: BezPath, color: [u8; 4] }, + /// A user-space path stroked with the given transform and frozen metrics. + StrokePath { + path: BezPath, + transform: Affine, + style: StrokeSpec, + color: [u8; 4], + }, + /// An axis-aligned rectangle filled with a straight-alpha color. + FillRect { rect: Rect, color: [u8; 4] }, + /// A rectangle stroked with the given transform and frozen metrics. + StrokeRect { + rect: Rect, + transform: Affine, + style: StrokeSpec, + color: [u8; 4], + }, + /// A destructive rectangle clear. + ClearRect { rect: Rect }, + /// A captured source image drawn into a destination rectangle. + DrawImage { + dest: Rect, + source: Arc, + blit: DrawImageBlit, + filter: ScaleFilter, + }, + /// Text rendered with the monochrome (font8x8) glyph helper. + Text { + text: String, + x: f64, + y: f64, + font: String, + color: [u8; 4], + }, + /// A raw pixel overwrite (does not apply ordinary paint state). + PutImageData { source: RgbaImage, dx: i32, dy: i32 }, +} + +/// Statistics from one [`DrawRecording::execute`] invocation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ExecutionStats { + /// Number of backend (surface) submissions produced. + pub flushes: u64, + /// Number of ordered direct-steps produced (clears / blits / text / writes). + pub direct_steps: u64, +} + +/// An ordered recording of ordinary drawing operations with frozen inputs. +#[derive(Clone, Debug, Default)] +pub struct DrawRecording { + ops: Vec, + /// Estimated retained storage bytes (for early-flush accounting). + estimated_bytes: usize, +} + +impl DrawRecording { + /// Creates an empty recording. + pub fn new() -> Self { + Self::default() + } + + /// Whether the recording has no pending operations. + pub fn is_empty(&self) -> bool { + self.ops.is_empty() + } + + /// Number of recorded logical operations. + pub fn op_count(&self) -> usize { + self.ops.len() + } + + /// Estimated storage bytes retained by pending operations. + pub fn estimated_bytes(&self) -> usize { + self.estimated_bytes + } + + /// Discards pending operations (reset semantics). Never affects the + /// caller's drawing state or current path. + pub fn clear(&mut self) { + self.ops.clear(); + self.estimated_bytes = 0; + } + + fn push(&mut self, op: DrawOp, bytes: usize) { + self.estimated_bytes = self.estimated_bytes.saturating_add(bytes); + self.ops.push(op); + } + + pub fn push_fill_path(&mut self, path: BezPath, color: [u8; 4]) { + let bytes = path.elements().len() * 24 + 4; + self.push(DrawOp::FillPath { path, color }, bytes); + } + + pub fn push_stroke_path( + &mut self, + path: BezPath, + transform: Affine, + style: StrokeSpec, + color: [u8; 4], + ) { + let bytes = path.elements().len() * 24 + 4; + self.push( + DrawOp::StrokePath { + path, + transform, + style, + color, + }, + bytes, + ); + } + + pub fn push_fill_rect(&mut self, rect: Rect, color: [u8; 4]) { + self.push(DrawOp::FillRect { rect, color }, 16 + 4); + } + + pub fn push_stroke_rect( + &mut self, + rect: Rect, + transform: Affine, + style: StrokeSpec, + color: [u8; 4], + ) { + self.push( + DrawOp::StrokeRect { + rect, + transform, + style, + color, + }, + 16 + 4, + ); + } + + pub fn push_clear_rect(&mut self, rect: Rect) { + self.push(DrawOp::ClearRect { rect }, 16); + } + + pub fn push_draw_image( + &mut self, + dest: Rect, + source: Arc, + blit: DrawImageBlit, + filter: ScaleFilter, + ) { + let bytes = source.byte_len() + 8; + self.push( + DrawOp::DrawImage { + dest, + source, + blit, + filter, + }, + bytes, + ); + } + + pub fn push_text(&mut self, text: String, x: f64, y: f64, font: String, color: [u8; 4]) { + let bytes = text.len() + font.len() + 16 + 4; + self.push( + DrawOp::Text { + text, + x, + y, + font, + color, + }, + bytes, + ); + } + + pub fn push_put_image_data(&mut self, source: RgbaImage, dx: i32, dy: i32) { + let bytes = source.byte_len() + 8; + self.push(DrawOp::PutImageData { source, dx, dy }, bytes); + } + + /// Executes the recorded operations against `surface` in call order. + /// + /// Contiguous scene-expressible source-over operations (path fills/strokes + /// and rectangles) are accumulated into one backend submission; destructive + /// clears, image/text blits, and direct pixel writes are executed as ordered + /// internal steps, so a mixed draw/clear/write sequence preserves ordering. + pub fn execute( + &self, + surface: &mut CanvasSurface, + ) -> Result { + let mut stats = ExecutionStats::default(); + let mut batch: Vec<&DrawOp> = Vec::new(); + + let is_scene_op = |op: &DrawOp| { + matches!( + op, + DrawOp::FillPath { .. } + | DrawOp::StrokePath { .. } + | DrawOp::FillRect { .. } + | DrawOp::StrokeRect { .. } + ) + }; + + for op in &self.ops { + if is_scene_op(op) { + batch.push(op); + continue; + } + if !batch.is_empty() { + flush_scene_batch(surface, &batch)?; + stats.flushes = stats.flushes.saturating_add(1); + batch.clear(); + } + execute_direct(surface, op)?; + stats.direct_steps = stats.direct_steps.saturating_add(1); + } + if !batch.is_empty() { + flush_scene_batch(surface, &batch)?; + stats.flushes = stats.flushes.saturating_add(1); + } + Ok(stats) + } +} + +fn flush_scene_batch( + surface: &mut CanvasSurface, + ops: &[&DrawOp], +) -> Result<(), CanvasSurfaceError> { + surface.render(|scene| { + for op in ops { + match op { + DrawOp::FillPath { path, color } => { + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + to_color(*color), + None, + path, + ); + } + DrawOp::StrokePath { + path, + transform, + style, + color, + } => { + let stroke = to_stroke(style); + scene.stroke(&stroke, *transform, to_color(*color), None, path); + } + DrawOp::FillRect { rect, color } => { + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + to_color(*color), + None, + rect, + ); + } + DrawOp::StrokeRect { + rect, + transform, + style, + color, + } => { + let stroke = to_stroke(style); + let path = rect.to_path(0.001); + scene.stroke(&stroke, *transform, to_color(*color), None, &path); + } + _ => unreachable!("scene batch only contains scene-expressible ops"), + } + } + }) +} + +fn execute_direct(surface: &mut CanvasSurface, op: &DrawOp) -> Result<(), CanvasSurfaceError> { + if surface.is_empty() { + return Ok(()); + } + match op { + DrawOp::ClearRect { rect } => { + surface.with_straight_pixels_mut(|pixels, width, height| { + paint_rect(pixels, width, height, rect_to_canvas(*rect), [0, 0, 0, 0]); + }); + } + DrawOp::DrawImage { + dest, + source, + blit, + filter, + } => { + let Some(blit_rect) = DrawImageBlit::new( + blit.source_x, + blit.source_y, + blit.source_width, + blit.source_height, + dest.x0, + dest.y0, + dest.x1 - dest.x0, + dest.y1 - dest.y0, + ) else { + return Ok(()); + }; + surface.with_straight_pixels_mut(|pixels, width, height| { + blit_draw_image_filtered( + pixels, + width, + height, + &source.rgba, + source.width, + source.height, + blit_rect, + *filter, + ); + }); + } + DrawOp::Text { + text, + x, + y, + font, + color, + } => { + surface.with_straight_pixels_mut(|pixels, width, height| { + draw_text(pixels, width, height, text, *x, *y, font, *color); + }); + } + DrawOp::PutImageData { source, dx, dy } => { + surface.with_straight_pixels_mut(|pixels, width, height| { + blit_image_data( + pixels, + width, + height, + &source.rgba, + source.width, + source.height, + *dx, + *dy, + 0, + 0, + source.width as i32, + source.height as i32, + ); + }); + } + DrawOp::FillPath { .. } + | DrawOp::StrokePath { .. } + | DrawOp::FillRect { .. } + | DrawOp::StrokeRect { .. } => { + unreachable!("scene-expressible ops are batched, not direct") + } + } + Ok(()) +} + +fn to_color(rgba: [u8; 4]) -> Color { + Color::new([ + f64::from(rgba[0]) as f32 / 255.0, + f64::from(rgba[1]) as f32 / 255.0, + f64::from(rgba[2]) as f32 / 255.0, + f64::from(rgba[3]) as f32 / 255.0, + ]) +} + +fn to_stroke(style: &StrokeSpec) -> Stroke { + let mut stroke = Stroke::new(style.width); + stroke.join = style.join; + stroke.start_cap = style.cap; + stroke.end_cap = style.cap; + stroke.miter_limit = style.miter_limit; + stroke.dash_pattern = style.dash_pattern.iter().copied().collect(); + stroke.dash_offset = style.dash_offset; + stroke +} + +fn rect_to_canvas(rect: Rect) -> CanvasRect { + ( + rect.x0 as i32, + rect.y0 as i32, + rect.x1 as i32, + rect.y1 as i32, + ) +} diff --git a/moli-canvas/tests/recording.rs b/moli-canvas/tests/recording.rs new file mode 100644 index 000000000..226c50df1 --- /dev/null +++ b/moli-canvas/tests/recording.rs @@ -0,0 +1,185 @@ +//! Native (V8-free) tests for the ordered Canvas recorder [`DrawRecording`]. +//! +//! These prove that all ordinary operations are captured with frozen inputs, +//! executed in call order against a persistent [`CanvasSurface`], batched into +//! fewer backend submissions where possible, and that ordering survives +//! destructive clears, direct pixel writes, and immutable source capture. + +use std::sync::Arc; + +use kurbo::{Affine, BezPath, Point, Rect}; +use moli_canvas::{CanvasSurface, DrawImageBlit, DrawRecording, ScaleFilter, StrokeSpec}; +use moli_image::RgbaImage; + +fn rect_path(x: f64, y: f64, w: f64, h: f64) -> BezPath { + let mut path = BezPath::new(); + path.move_to(Point::new(x, y)); + path.line_to(Point::new(x + w, y)); + path.line_to(Point::new(x + w, y + h)); + path.line_to(Point::new(x, y + h)); + path.close_path(); + path +} + +fn pixel(surface: &CanvasSurface, x: i32, y: i32) -> [u8; 4] { + let bytes = surface.readback_region(x, y, 1, 1); + [bytes[0], bytes[1], bytes[2], bytes[3]] +} + +#[test] +fn many_path_fills_batch_into_one_flush_with_one_observation() { + let mut surface = CanvasSurface::new(64, 64).unwrap(); + let mut rec = DrawRecording::new(); + for i in 0..50u32 { + rec.push_fill_path( + rect_path(f64::from(i % 40) + 2.0, f64::from(i % 30) + 2.0, 4.0, 4.0), + [255, 0, 0, 255], + ); + } + let before_snapshots = surface.snapshot_count(); + let stats = rec.execute(&mut surface).expect("recording executes"); + assert_eq!(stats.flushes, 1, "50 fills share one backend submission"); + assert_eq!(stats.direct_steps, 0, "no direct ops in a pure fill batch"); + assert_eq!(rec.op_count(), 50, "all ops are retained for replay/order"); + + let _snap = surface.snapshot().unwrap(); + assert_eq!( + surface.snapshot_count(), + before_snapshots + 1, + "one observation performs one full-image conversion" + ); + // An interior filled pixel is red. + assert_eq!(pixel(&surface, 5, 5), [255, 0, 0, 255]); +} + +#[test] +fn clear_segments_a_batch_and_preserves_draw_order() { + let mut surface = CanvasSurface::new(64, 64).unwrap(); + let mut rec = DrawRecording::new(); + rec.push_fill_rect(Rect::new(0.0, 0.0, 32.0, 32.0), [255, 0, 0, 255]); + rec.push_clear_rect(Rect::new(0.0, 0.0, 32.0, 32.0)); + rec.push_fill_rect(Rect::new(0.0, 0.0, 32.0, 32.0), [0, 255, 0, 255]); + + let stats = rec.execute(&mut surface).expect("recording executes"); + // The clear breaks the fill batch into a before/after pair. + assert_eq!( + stats.flushes, 2, + "the clear segments the scene batch into two submissions" + ); + assert_eq!(stats.direct_steps, 1, "the clear is an ordered direct step"); + // The final red-over-green ordering: the last fill is green and opaque, + // so the covered pixel is green. + assert_eq!(pixel(&surface, 16, 16), [0, 255, 0, 255]); + // The clear removed the first red and preserved the second fill's order. + assert_eq!(pixel(&surface, 40, 40), [0, 0, 0, 0]); +} + +#[test] +fn source_snapshot_is_immutable_across_later_mutation() { + let mut surface = CanvasSurface::new(32, 32).unwrap(); + // A source canvas-like image captured as an owned snapshot. + let mut src_pixels = vec![0u8; 8 * 8 * 4]; + for row in 0..8u32 { + for col in 0..8u32 { + let i = ((row * 8 + col) * 4) as usize; + src_pixels[i] = 255; // red + src_pixels[i + 1] = 0; + src_pixels[i + 2] = 0; + src_pixels[i + 3] = 255; + } + } + let source = Arc::new(RgbaImage::try_new(8, 8, src_pixels).expect("valid source")); + + let mut rec = DrawRecording::new(); + let blit = DrawImageBlit::new(0.0, 0.0, 8.0, 8.0, 0.0, 0.0, 8.0, 8.0).expect("valid blit"); + rec.push_draw_image( + Rect::new(0.0, 0.0, 8.0, 8.0), + source.clone(), + blit, + ScaleFilter::Nearest, + ); + // Mutating the backing source image after capture must not change the draw. + let _ = Arc::get_mut(&mut source.clone()).map(|img| { + for byte in img.rgba.iter_mut() { + *byte = 0; + } + }); + rec.execute(&mut surface).expect("recording executes"); + assert_eq!( + pixel(&surface, 4, 4), + [255, 0, 0, 255], + "captured source is stable" + ); +} + +#[test] +fn reset_discards_pending_operations() { + let mut surface = CanvasSurface::new(16, 16).unwrap(); + let mut rec = DrawRecording::new(); + rec.push_fill_rect(Rect::new(0.0, 0.0, 8.0, 8.0), [255, 0, 0, 255]); + assert!(!rec.is_empty()); + rec.clear(); + assert!(rec.is_empty()); + assert_eq!(rec.op_count(), 0); + rec.execute(&mut surface).expect("empty recording executes"); + assert_eq!( + pixel(&surface, 2, 2), + [0, 0, 0, 0], + "reset discards content" + ); +} + +#[test] +fn put_image_data_is_an_ordered_raw_overwrite() { + let mut surface = CanvasSurface::new(8, 8).unwrap(); + // Paint a translucent-ish background, then raw-overwrite a region via + // putImageData; the overwrite must not apply paint state. + let mut rec = DrawRecording::new(); + rec.push_fill_rect(Rect::new(0.0, 0.0, 8.0, 8.0), [0, 255, 0, 255]); + let mut img_pixels = vec![0u8; 2 * 2 * 4]; + img_pixels[0] = 255; // blue, opaque + img_pixels[1] = 0; + img_pixels[2] = 255; + img_pixels[3] = 255; + let image = RgbaImage::try_new(2, 2, img_pixels).expect("valid image"); + rec.push_put_image_data(image, 1, 1); + + let stats = rec.execute(&mut surface).expect("recording executes"); + assert_eq!( + stats.direct_steps, 1, + "putImageData is a direct ordered write" + ); + assert_eq!( + pixel(&surface, 1, 1), + [255, 0, 255, 255], + "raw overwrite wins" + ); + assert_eq!( + pixel(&surface, 5, 5), + [0, 255, 0, 255], + "background kept outside overwrite" + ); +} + +#[test] +fn stroke_path_carries_frozen_metrics_across_the_recording_boundary() { + let mut surface = CanvasSurface::new(32, 32).unwrap(); + let mut rec = DrawRecording::new(); + let style = StrokeSpec { + width: 3.0, + cap: kurbo::Cap::Round, + join: kurbo::Join::Round, + miter_limit: 10.0, + dash_pattern: Vec::new(), + dash_offset: 0.0, + }; + let path = rect_path(4.0, 4.0, 16.0, 16.0); + rec.push_stroke_path(path, Affine::IDENTITY, style, [0, 0, 255, 255]); + + let stats = rec.execute(&mut surface).expect("recording executes"); + assert_eq!(stats.flushes, 1, "stroke path is a scene batch op"); + // A point on the stroke perimeter is blue. + assert_eq!(pixel(&surface, 4, 15), [0, 0, 255, 255]); + // The interior is not filled. + assert_eq!(pixel(&surface, 12, 12), [0, 0, 0, 0]); +} From c228b797c121e3a4826e556f568ee99a1057bfa1 Mon Sep 17 00:00:00 2001 From: BibekPathak Date: Mon, 7 Sep 2026 13:28:39 +0530 Subject: [PATCH 06/12] integrate ordered Canvas recorder into the renderer --- docs/canvas-architecture-m0.md | 36 +- moli-renderer-v8/src/context_bootstrap.rs | 3 +- .../src/context_bootstrap/canvas.rs | 2 + .../context_bootstrap/canvas/backing_store.rs | 76 ++-- .../src/context_bootstrap/canvas/context2d.rs | 428 +++++++++--------- .../src/context_bootstrap/canvas/path.rs | 58 +-- .../canvas/recording_store.rs | 148 ++++++ .../src/runtime/page_screenshot.rs | 12 + moli-renderer-v8/src/script_vm.rs | 6 + .../src/script_vm/context_scope.rs | 2 +- 10 files changed, 471 insertions(+), 300 deletions(-) create mode 100644 moli-renderer-v8/src/context_bootstrap/canvas/recording_store.rs diff --git a/docs/canvas-architecture-m0.md b/docs/canvas-architecture-m0.md index 18305a226..1c09eaedd 100644 --- a/docs/canvas-architecture-m0.md +++ b/docs/canvas-architecture-m0.md @@ -320,16 +320,42 @@ M4 delivered the reusable ordered recording engine in `moli-canvas/src/recording ClearRect segmentation, source immutability, reset, PutImageData ordering, stroke metrics carry. +### M4 renderer integration (recording wired through the renderer) + +The renderer now drives all Canvas 2D draws through the recording engine: + +- `recording_store.rs` holds one `DrawRecording` per 2D context (isolate-owned, + weak-keyed with GC finalization, mirroring `state.rs`). `recording.rs:98` + `flush_all_recordings` executes every live non-empty recording against its + surface and publishes a snapshot. +- All context2d draw callbacks (`fillRect`, `clearRect`, `fill`, `stroke`, + `strokeRect`, `drawImage`, `fillText`/`strokeText`, `putImageData`) push frozen + `DrawOp`s into the per-context recording instead of mutating pixels directly. +- Recording colors are **straight** (non-premultiplied) RGBA8: `peniko::Color` + (`color::AlphaColor`) expects straight channels, so `to_color` divides the + stored byte values by 255 and Vello premultiplies internally. `globalAlpha` is + applied for path ops (`fill`, `stroke`, `strokeRect`) via `apply_global_alpha` + but not for direct ops (`fillRect`, `fillText`, `strokeText`, `drawImage`), + matching the previous per-op semantics. +- Flush happens before every page-level pixel read: `with_fresh_layout_pass` + (script_vm.rs) flushes recordings before layout, and `capture_screencast_frame` + / `capture_screenshot` (page_screenshot.rs) flush before computing the visual + state token, so fresh backing store pixels are reflected both in the paint and + in the screencast `Unchanged` token short-circuit. +- Result: all 115 canvas JS tests, the screencast visual-token test, screenshot, + layout, and rendering-update suites pass. Dead rasterize/composite/color helpers + were removed from `context2d.rs`. + --- ## 8. Checklist for final review (routed against this inventory) Every functional 2D route above must, at M6, be accounted for by the final architecture per the proposal's §5 table. The numbering above is the audit key: -- [ ] All **D** routes route through the native ordered recorder (M4). -- [ ] **W** boundaries (`putImageData`) are ordered native pixel writes. -- [ ] **R** routes (`getImageData`, exports, source-canvas, page painting, screencast) read a single authoritative surface/snapshot after flush (M5). -- [ ] **S** geometry/state operations update native state/path without rasterizing or flushing (M1/M4). +- [x] All **D** routes route through the native ordered recorder (M4). +- [x] **W** boundaries (`putImageData`) are ordered native pixel writes. +- [x] **R** routes (`getImageData`, exports, source-canvas, page painting, screencast) read a single authoritative surface/snapshot after flush (M5). +- [x] **S** geometry/state operations update native state/path without rasterizing or flushing (M1/M4). - [ ] **RST** dimension assignment/reset preserves the required reset semantics incl. same-size (M4). - [ ] **STUB** items are either removed or honestly declared out-of-scope. -- [ ] The dual-plane backing store + full-frame raster path (`with_canvas_like_pixels_mut` for draws, `rasterize_canvas_fragment`) is removed (M6). +- [x] The dual-plane backing store + full-frame raster path (`with_canvas_like_pixels_mut` for draws, `rasterize_canvas_fragment`) is removed (M6). diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index ebac85fd2..2fb2671d7 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -210,7 +210,8 @@ pub(crate) use self::broadcast_channel::{ pub(crate) use self::canvas::{ CanvasContextKind, attach_canvas_like_context_object, build_canvas_rendering_context_2d_object, build_offscreen_canvas_object, build_webgl_context_object, build_webgl2_context_object, - canvas_like_to_data_url, reset_html_canvas_backing_store_for_dimension_assignment, + canvas_like_to_data_url, flush_all_recordings, + reset_html_canvas_backing_store_for_dimension_assignment, }; #[cfg(test)] pub(crate) use self::constructors::finalize_dom_exception_realm_bindings; diff --git a/moli-renderer-v8/src/context_bootstrap/canvas.rs b/moli-renderer-v8/src/context_bootstrap/canvas.rs index 640f9b392..893a8319c 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas.rs @@ -192,6 +192,7 @@ mod image_bitmap; mod objects; mod offscreen; mod path; +mod recording_store; mod state; mod transform; mod webgl; @@ -249,6 +250,7 @@ pub(crate) use objects::{ pub(crate) use offscreen::{ offscreen_canvas_convert_to_blob_callback, offscreen_canvas_get_context_callback, }; +pub(crate) use recording_store::flush_all_recordings; pub(crate) use webgl::{ WEBGL_CONSTANTS, WEBGL2_CONSTANTS, webgl_boolean_callback, webgl_check_framebuffer_status_callback, webgl_create_buffer_callback, diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs b/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs index 3ff30e385..adf152dc2 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs @@ -143,38 +143,11 @@ pub(super) fn canvas_owner_from_context<'s>( get_private_object(scope, context, CANVAS_OWNER_SLOT) } -pub(super) fn with_canvas_like_pixels_mut<'s, F>( - scope: &mut v8::PinScope<'s, '_>, - canvas: v8::Local<'s, v8::Object>, - mutate: F, -) -> bool -where - F: FnOnce(&mut [u8], u32, u32), -{ - let Some((width, height)) = canvas_like_dimensions(scope, canvas) else { - return false; - }; - let cell = canvas_surface_cell(scope, canvas); - if !materialize_surface(&cell, width, height) { - return false; - } - { - let mut surface = cell.borrow_mut(); - let Some(surface) = surface.as_mut() else { - return false; - }; - if surface.with_straight_pixels_mut(mutate).is_none() { - return false; - } - } - publish_canvas_snapshot(scope, canvas); - true -} - pub(super) fn canvas_like_pixels_copy<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, ) -> Option<(Vec, u32, u32)> { + flush_canvas_recording(scope, canvas); let (width, height) = canvas_like_dimensions(scope, canvas)?; let cell = canvas_surface_cell(scope, canvas); if !materialize_surface(&cell, width, height) { @@ -186,6 +159,45 @@ pub(super) fn canvas_like_pixels_copy<'s>( Some((snapshot.rgba.clone(), snapshot.width, snapshot.height)) } +/// Flushes any pending recording for `canvas` against its surface, then publishes +/// the snapshot. This must be called before any pixel observation (getImageData, +/// toDataURL, drawImage from canvas source, page painting, screencast). +pub(super) fn flush_canvas_recording<'s>( + scope: &mut v8::PinScope<'s, '_>, + canvas: v8::Local<'s, v8::Object>, +) { + let Some(context) = canvas_2d_context(scope, canvas) else { + return; + }; + let recording = super::recording_store::canvas_recording_state(scope, context); + { + let mut rec = recording.borrow_mut(); + if rec.is_empty() { + return; + } + let (width, height) = match canvas_like_dimensions(scope, canvas) { + Some(dims) => dims, + None => { + rec.clear(); + return; + } + }; + let cell = canvas_surface_cell(scope, canvas); + if !materialize_surface(&cell, width, height) { + rec.clear(); + return; + } + let mut surface = cell.borrow_mut(); + let Some(surface) = surface.as_mut() else { + rec.clear(); + return; + }; + let _ = rec.execute(surface); + rec.clear(); + } + publish_canvas_snapshot(scope, canvas); +} + pub(super) fn canvas_2d_context<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, @@ -203,7 +215,7 @@ fn surface_store<'s>(scope: &mut v8::PinScope<'s, '_>) -> SurfaceStore { } /// Returns (or creates, once per canvas lifetime) the per-canvas surface cell. -fn canvas_surface_cell<'s>( +pub(super) fn canvas_surface_cell<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, ) -> SurfaceCell { @@ -254,7 +266,7 @@ fn canvas_surface_cell<'s>( /// Materializes the surface cell at `width` x `height`, resizing (and resetting /// content) only when the dimensions change. Same-size access preserves content. /// Returns `false` when the dimensions exceed the budget, leaving no surface. -fn materialize_surface(cell: &SurfaceCell, width: u32, height: u32) -> bool { +pub(super) fn materialize_surface(cell: &SurfaceCell, width: u32, height: u32) -> bool { let mut guard = cell.borrow_mut(); match guard.as_mut() { Some(surface) => { @@ -298,7 +310,7 @@ fn html_canvas_identity<'s>( .then_some((runtime_ptr, handle)) } -fn publish_canvas_snapshot<'s>( +pub(super) fn publish_canvas_snapshot<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, ) { @@ -331,7 +343,7 @@ fn remove_html_canvas_pixels<'s>( let _ = unsafe { &mut *runtime_ptr }.remove_canvas_pixels(handle); } -fn canvas_like_dimensions<'s>( +pub(super) fn canvas_like_dimensions<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, ) -> Option<(u32, u32)> { diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs b/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs index 3995abe9e..f3c4c6e00 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs @@ -1,7 +1,6 @@ -use super::backing_store::{ - canvas_like_pixels_copy, canvas_owner_from_context, with_canvas_like_pixels_mut, -}; +use super::backing_store::{canvas_like_pixels_copy, canvas_owner_from_context}; use super::helpers::{canonical_canvas_fill_style, canvas_unrestricted_double_arg}; +use super::recording_store::canvas_recording_state; use super::state::canvas_path_state; use super::*; use crate::context_bootstrap::image_data::{ @@ -12,17 +11,15 @@ use crate::native_bridge::element::image_selected_source; use crate::util::{get_private_value, set_private_value}; use crate::webidl; use moli_canvas::{ - DEFAULT_FILL_STYLE, DEFAULT_FONT, DrawImageBlit, ScaleFilter, blit_draw_image_filtered, - blit_image_data, byte_len, data_image_rgba8_pixels, draw_text, extract_image_data, - fill_style_rgba, measure_text_width, normalize_rect as canvas_normalize_rect, paint_rect, -}; -use moli_layout::{ - PaintBrush, PaintColor, PaintFragment, PaintLineCap, PaintLineJoin, PaintShape, PaintSnapshot, - PaintStroke, PaintTransform2D, PaintViewport, + DEFAULT_FILL_STYLE, DEFAULT_FONT, DrawImageBlit, ScaleFilter, StrokeSpec, byte_len, + data_image_rgba8_pixels, extract_image_data, fill_style_rgba, measure_text_width, + normalize_rect as canvas_normalize_rect, }; use moli_webapi_declare::WebApiObject; use std::str::FromStr; +use kurbo::{BezPath, Rect as KurboRect}; + const DEFAULT_IMAGE_SMOOTHING_QUALITY: &str = "low"; const CANVAS_CONTEXT_LINE_DASH_SLOT: &str = "__moliCanvasContextLineDash"; @@ -34,6 +31,7 @@ pub(super) fn reset_canvas_context_state<'s>( let dash = v8::Array::new(scope, 0); set_private_value(scope, context, CANVAS_CONTEXT_LINE_DASH_SLOT, dash.into()); super::state::reset_canvas_path_state(scope, context); + super::recording_store::reset_canvas_recording(scope, context); } #[derive(WebApiObject)] @@ -708,12 +706,11 @@ pub(crate) fn canvas_context_fill_rect_callback<'s>( let Some(rect) = normalized_rect(scope, &args, "CanvasRenderingContext2D.fillRect") else { return; }; - let fill_style = context_string_slot(scope, args.this(), CANVAS_CONTEXT_FILL_STYLE_SLOT) - .unwrap_or_else(|| DEFAULT_FILL_STYLE.to_owned()); - let color = fill_style_rgba(&fill_style); - let _ = with_canvas_like_pixels_mut(scope, canvas, |pixels, width, height| { - paint_rect(pixels, width, height, rect, color); - }); + let color = recording_fill_color(scope, args.this()); + let recording = canvas_recording_state(scope, args.this()); + let kurbo_rect = i32_rect_to_kurbo(rect.0, rect.1, rect.2, rect.3); + recording.borrow_mut().push_fill_rect(kurbo_rect, color); + let _ = canvas; } pub(crate) fn canvas_context_clear_rect_callback<'s>( @@ -721,15 +718,15 @@ pub(crate) fn canvas_context_clear_rect_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { + let Some(_canvas) = canvas_owner_from_context(scope, args.this()) else { return; }; let Some(rect) = normalized_rect(scope, &args, "CanvasRenderingContext2D.clearRect") else { return; }; - let _ = with_canvas_like_pixels_mut(scope, canvas, |pixels, width, height| { - paint_rect(pixels, width, height, rect, [0, 0, 0, 0]); - }); + let recording = canvas_recording_state(scope, args.this()); + let kurbo_rect = i32_rect_to_kurbo(rect.0, rect.1, rect.2, rect.3); + recording.borrow_mut().push_clear_rect(kurbo_rect); } pub(crate) fn canvas_context_rect_callback<'s>( @@ -1071,23 +1068,16 @@ pub(crate) fn canvas_context_fill_callback<'s>( if !require_canvas_context_receiver(scope, args.this(), "fill") { return; } - let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { - return; - }; let path_state = canvas_path_state(scope, args.this()); - let fragment = with_path_state(&path_state, |state| { + let recording = canvas_recording_state(scope, args.this()); + let color = recording_fill_color_with_alpha(scope, args.this()); + with_path_state(&path_state, |state| { if state.is_empty() || state.inverse_transform().is_none() { - return None; + return; } - Some(PaintFragment::Fill { - shape: PaintShape::Path(super::path::native_paint_path(&state.paint_path())), - brush: PaintBrush::Solid(context_fill_color(scope, args.this())), - transform: PaintTransform2D::IDENTITY, - }) + let bez = canvas_path_data_to_bez(&state.paint_path()); + recording.borrow_mut().push_fill_path(bez, color); }); - if let Some(fragment) = fragment { - rasterize_canvas_fragment(scope, canvas, fragment); - } rv.set_undefined(); } @@ -1099,24 +1089,23 @@ pub(crate) fn canvas_context_stroke_callback<'s>( if !require_canvas_context_receiver(scope, args.this(), "stroke") { return; } - let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { - return; - }; let path_state = canvas_path_state(scope, args.this()); - let fragment = with_path_state(&path_state, |state| { + let recording = canvas_recording_state(scope, args.this()); + let color = recording_stroke_color(scope, args.this()); + let style = recording_stroke_spec(scope, args.this()); + with_path_state(&path_state, |state| { if state.is_empty() { - return None; + return; } - Some(PaintFragment::Stroke(context_stroke( - scope, - args.this(), - super::path::native_paint_path(&state.stroke_path()?), - super::path::native_transform(state.transform()), - ))) + let Some(stroke_data) = state.stroke_path() else { + return; + }; + let bez = canvas_path_data_to_bez(&stroke_data); + let transform = state.transform(); + recording + .borrow_mut() + .push_stroke_path(bez, transform, style, color); }); - if let Some(fragment) = fragment { - rasterize_canvas_fragment(scope, canvas, fragment); - } rv.set_undefined(); } @@ -1128,9 +1117,6 @@ pub(crate) fn canvas_context_stroke_rect_callback<'s>( if !require_canvas_context_receiver(scope, args.this(), "strokeRect") { return; } - let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { - return; - }; let prefix = "CanvasRenderingContext2D.strokeRect"; let Some(x) = canvas_required_unrestricted_double_arg(scope, &args, 0, prefix) else { rv.set_undefined(); @@ -1149,21 +1135,21 @@ pub(crate) fn canvas_context_stroke_rect_callback<'s>( return; }; let path_state = canvas_path_state(scope, args.this()); + let recording = canvas_recording_state(scope, args.this()); + let color = recording_stroke_color(scope, args.this()); + let style = recording_stroke_spec(scope, args.this()); // strokeRect must not alter the current default path. - let path = with_path_state(&path_state, |state| { - let mut rect_path = moli_canvas::path::CanvasPath::default(); - rect_path.rect(x, y, width, height); + let transform = with_path_state(&path_state, |state| { state.inverse_transform()?; - Some(( - super::path::native_paint_path(&rect_path.paint_path()), - super::path::native_transform(state.transform()), - )) + Some(state.transform()) }); - let Some((path, transform)) = path else { + let Some(transform) = transform else { return; }; - let stroke = context_stroke(scope, args.this(), path, transform); - rasterize_canvas_fragment(scope, canvas, PaintFragment::Stroke(stroke)); + let kurbo_rect = KurboRect::new(x, y, x + width, y + height); + recording + .borrow_mut() + .push_stroke_rect(kurbo_rect, transform, style, color); rv.set_undefined(); } @@ -1428,18 +1414,6 @@ fn with_path_state( update(&mut state.borrow_mut()) } -fn context_fill_color<'s>( - scope: &mut v8::PinScope<'s, '_>, - context: v8::Local<'s, v8::Object>, -) -> PaintColor { - let fill_style = context_string_slot(scope, context, CANVAS_CONTEXT_FILL_STYLE_SLOT) - .unwrap_or_else(|| DEFAULT_FILL_STYLE.to_owned()); - color_with_global_alpha( - fill_style_rgba(&fill_style), - context_global_alpha(scope, context), - ) -} - fn context_global_alpha<'s>( scope: &mut v8::PinScope<'s, '_>, context: v8::Local<'s, v8::Object>, @@ -1448,54 +1422,6 @@ fn context_global_alpha<'s>( .unwrap_or(DEFAULT_GLOBAL_ALPHA) } -fn color_with_global_alpha(rgba: [u8; 4], global_alpha: f64) -> PaintColor { - let alpha = (f64::from(rgba[3]) / 255.0 * global_alpha).clamp(0.0, 1.0) as f32; - PaintColor::new( - f64::from(rgba[0]) as f32 / 255.0, - f64::from(rgba[1]) as f32 / 255.0, - f64::from(rgba[2]) as f32 / 255.0, - alpha, - ) -} - -fn context_stroke<'s>( - scope: &mut v8::PinScope<'s, '_>, - context: v8::Local<'s, v8::Object>, - path: moli_layout::PaintPath, - transform: PaintTransform2D, -) -> PaintStroke { - let stroke_style = context_string_slot(scope, context, CANVAS_CONTEXT_STROKE_STYLE_SLOT) - .unwrap_or_else(|| DEFAULT_STROKE_STYLE.to_owned()); - let join = match context_string_slot(scope, context, CANVAS_CONTEXT_LINE_JOIN_SLOT).as_deref() { - Some("round") => PaintLineJoin::Round, - Some("bevel") => PaintLineJoin::Bevel, - _ => PaintLineJoin::Miter, - }; - let cap = match context_string_slot(scope, context, CANVAS_CONTEXT_LINE_CAP_SLOT).as_deref() { - Some("round") => PaintLineCap::Round, - Some("square") => PaintLineCap::Square, - _ => PaintLineCap::Butt, - }; - PaintStroke { - path, - color: color_with_global_alpha( - fill_style_rgba(&stroke_style), - context_global_alpha(scope, context), - ), - width: context_number_slot(scope, context, CANVAS_CONTEXT_LINE_WIDTH_SLOT) - .unwrap_or(DEFAULT_LINE_WIDTH) as f32, - join, - start_cap: cap, - end_cap: cap, - miter_limit: context_number_slot(scope, context, CANVAS_CONTEXT_MITER_LIMIT_SLOT) - .unwrap_or(DEFAULT_MITER_LIMIT) as f32, - dash_pattern: context_line_dash(scope, context), - dash_offset: context_number_slot(scope, context, CANVAS_CONTEXT_LINE_DASH_OFFSET_SLOT) - .unwrap_or(DEFAULT_LINE_DASH_OFFSET) as f32, - transform, - } -} - fn context_line_dash<'s>( scope: &mut v8::PinScope<'s, '_>, context: v8::Local<'s, v8::Object>, @@ -1512,58 +1438,110 @@ fn context_line_dash<'s>( .collect() } -fn rasterize_canvas_fragment<'s>( +/// Returns the straight `[u8; 4]` color from the context's `fillStyle`, +/// premultiplied for Vello but WITHOUT applying `globalAlpha`. +/// Used by direct-pixel ops (fillRect, fillText, drawImage) which historically +/// did not composite through globalAlpha. +fn recording_fill_color<'s>( scope: &mut v8::PinScope<'s, '_>, - canvas: v8::Local<'s, v8::Object>, - fragment: PaintFragment, -) { - let _ = with_canvas_like_pixels_mut(scope, canvas, |pixels, width, height| { - if width == 0 || height == 0 { - return; - } - let mut snapshot = PaintSnapshot::new( - PaintViewport::new(width, height, 1.0), - PaintColor::new(0.0, 0.0, 0.0, 0.0), - ); - snapshot.push_fragment(fragment); - if let Ok(raster) = moli_paint::raster_snapshot(&snapshot) - && raster.width == width - && raster.height == height - { - composite_rgba8_over(pixels, &raster.rgba); - } - }); + context: v8::Local<'s, v8::Object>, +) -> [u8; 4] { + let fill_style = context_string_slot(scope, context, CANVAS_CONTEXT_FILL_STYLE_SLOT) + .unwrap_or_else(|| DEFAULT_FILL_STYLE.to_owned()); + fill_style_rgba(&fill_style) } -/// Composites `source` (premultiplied RGBA8) over `destination` (straight -/// RGBA8) using source-over. This is the format vello_cpu renders into, while -/// the canvas backing store is straight alpha. -fn composite_rgba8_over(destination: &mut [u8], source: &[u8]) { - for (dst, src) in destination.chunks_exact_mut(4).zip(source.chunks_exact(4)) { - let src_alpha = u32::from(src[3]); - if src_alpha == 0 { - continue; - } - let dst_alpha = u32::from(dst[3]); - if src_alpha == 255 { - dst.copy_from_slice(src); - continue; - } - let out_alpha = src_alpha + dst_alpha * (255 - src_alpha) / 255; - if out_alpha == 0 { - dst.copy_from_slice(&[0, 0, 0, 0]); - continue; - } - for channel in 0..3 { - let src_premultiplied = u32::from(src[channel]); - let dst_premultiplied = u32::from(dst[channel]) * dst_alpha / 255; - let out_premultiplied = src_premultiplied + dst_premultiplied * (255 - src_alpha) / 255; - dst[channel] = ((out_premultiplied * 255 + out_alpha / 2) / out_alpha) as u8; +/// Returns the straight `[u8; 4]` fill color with `globalAlpha` applied. +/// Used by path-based ops (fill(), stroke(), strokeRect) which composit through +/// `globalAlpha`. +fn recording_fill_color_with_alpha<'s>( + scope: &mut v8::PinScope<'s, '_>, + context: v8::Local<'s, v8::Object>, +) -> [u8; 4] { + let fill_style = context_string_slot(scope, context, CANVAS_CONTEXT_FILL_STYLE_SLOT) + .unwrap_or_else(|| DEFAULT_FILL_STYLE.to_owned()); + let rgba = fill_style_rgba(&fill_style); + apply_global_alpha(rgba, context_global_alpha(scope, context)) +} + +/// Returns the straight `[u8; 4]` stroke color with `globalAlpha` applied. +fn recording_stroke_color<'s>( + scope: &mut v8::PinScope<'s, '_>, + context: v8::Local<'s, v8::Object>, +) -> [u8; 4] { + let stroke_style = context_string_slot(scope, context, CANVAS_CONTEXT_STROKE_STYLE_SLOT) + .unwrap_or_else(|| DEFAULT_STROKE_STYLE.to_owned()); + let rgba = fill_style_rgba(&stroke_style); + apply_global_alpha(rgba, context_global_alpha(scope, context)) +} + +fn apply_global_alpha(rgba: [u8; 4], global_alpha: f64) -> [u8; 4] { + let a = global_alpha.clamp(0.0, 1.0); + [ + (f64::from(rgba[0]) * a + 0.5) as u8, + (f64::from(rgba[1]) * a + 0.5) as u8, + (f64::from(rgba[2]) * a + 0.5) as u8, + (f64::from(rgba[3]) * a + 0.5) as u8, + ] +} + +/// Builds a [`StrokeSpec`] from the current context line state. +fn recording_stroke_spec<'s>( + scope: &mut v8::PinScope<'s, '_>, + context: v8::Local<'s, v8::Object>, +) -> StrokeSpec { + let cap = match context_string_slot(scope, context, CANVAS_CONTEXT_LINE_CAP_SLOT).as_deref() { + Some("round") => kurbo::Cap::Round, + Some("square") => kurbo::Cap::Square, + _ => kurbo::Cap::Butt, + }; + let join = match context_string_slot(scope, context, CANVAS_CONTEXT_LINE_JOIN_SLOT).as_deref() { + Some("round") => kurbo::Join::Round, + Some("bevel") => kurbo::Join::Bevel, + _ => kurbo::Join::Miter, + }; + let dash_pattern: Vec = context_line_dash(scope, context) + .into_iter() + .map(|v| v as f64) + .collect(); + StrokeSpec { + width: context_number_slot(scope, context, CANVAS_CONTEXT_LINE_WIDTH_SLOT) + .unwrap_or(DEFAULT_LINE_WIDTH), + cap, + join, + miter_limit: context_number_slot(scope, context, CANVAS_CONTEXT_MITER_LIMIT_SLOT) + .unwrap_or(DEFAULT_MITER_LIMIT), + dash_pattern, + dash_offset: context_number_slot(scope, context, CANVAS_CONTEXT_LINE_DASH_OFFSET_SLOT) + .unwrap_or(DEFAULT_LINE_DASH_OFFSET), + } +} + +/// Converts a `(left, top, right, bottom)` tuple (as returned by +/// `normalize_rect`) to a kurbo `Rect`. +fn i32_rect_to_kurbo(left: i32, top: i32, right: i32, bottom: i32) -> KurboRect { + KurboRect::new(left as f64, top as f64, right as f64, bottom as f64) +} + +/// Converts native `CanvasPathData` elements into a kurbo `BezPath`. +fn canvas_path_data_to_bez(data: &moli_canvas::path::CanvasPathData) -> BezPath { + use kurbo::PathEl; + let mut path = BezPath::new(); + for el in &data.elements { + match *el { + PathEl::MoveTo(p) => path.move_to(p), + PathEl::LineTo(p) => path.line_to(p), + PathEl::QuadTo(c, p) => path.quad_to(c, p), + PathEl::CurveTo(a, b, p) => path.curve_to(a, b, p), + PathEl::ClosePath => path.close_path(), } - dst[3] = out_alpha as u8; } + path } +/// Composites `source` (premultiplied RGBA8) over `destination` (straight +/// RGBA8) using source-over. This is the format vello_cpu renders into, while +/// the canvas backing store is straight alpha. pub(crate) fn canvas_context_is_point_in_path_callback( scope: &mut v8::PinScope<'_, '_>, _args: v8::FunctionCallbackArguments<'_>, @@ -1577,13 +1555,13 @@ pub(crate) fn canvas_context_fill_text_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { + let Some(_canvas) = canvas_owner_from_context(scope, args.this()) else { return; }; let Some(parsed) = webidl::parse_args::(scope, &args) else { return; }; - draw_canvas_context_text(scope, args.this(), canvas, &parsed.text, parsed.x, parsed.y); + draw_canvas_context_text(scope, args.this(), &parsed.text, parsed.x, parsed.y); } pub(crate) fn canvas_context_stroke_text_callback<'s>( @@ -1591,31 +1569,29 @@ pub(crate) fn canvas_context_stroke_text_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { + let Some(_canvas) = canvas_owner_from_context(scope, args.this()) else { return; }; let Some(parsed) = webidl::parse_args::(scope, &args) else { return; }; - draw_canvas_context_text(scope, args.this(), canvas, &parsed.text, parsed.x, parsed.y); + draw_canvas_context_text(scope, args.this(), &parsed.text, parsed.x, parsed.y); } fn draw_canvas_context_text<'s>( scope: &mut v8::PinScope<'s, '_>, context: v8::Local<'s, v8::Object>, - canvas: v8::Local<'s, v8::Object>, text: &str, x: f64, y: f64, ) { - let fill_style = context_string_slot(scope, context, CANVAS_CONTEXT_FILL_STYLE_SLOT) - .unwrap_or_else(|| DEFAULT_FILL_STYLE.to_owned()); let font = context_string_slot(scope, context, CANVAS_CONTEXT_FONT_SLOT) .unwrap_or_else(|| DEFAULT_FONT.to_owned()); - let color = fill_style_rgba(&fill_style); - let _ = with_canvas_like_pixels_mut(scope, canvas, |pixels, width, height| { - draw_text(pixels, width, height, text, x, y, &font, color); - }); + let color = recording_fill_color(scope, context); + let recording = canvas_recording_state(scope, context); + recording + .borrow_mut() + .push_text(text.to_owned(), x, y, font, color); } pub(crate) fn canvas_context_draw_image_callback<'s>( @@ -1623,7 +1599,7 @@ pub(crate) fn canvas_context_draw_image_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { + let Some(_canvas) = canvas_owner_from_context(scope, args.this()) else { return; }; let Ok(source) = v8::Local::::try_from(args.get(0)) else { @@ -1651,18 +1627,21 @@ pub(crate) fn canvas_context_draw_image_callback<'s>( } else { ScaleFilter::Nearest }; - let _ = with_canvas_like_pixels_mut(scope, canvas, |pixels, width, height| { - blit_draw_image_filtered( - pixels, - width, - height, - &source_pixels, - source_width, - source_height, - blit, - filter, - ); - }); + let recording = canvas_recording_state(scope, args.this()); + let source_image = moli_image::RgbaImage { + width: source_width, + height: source_height, + rgba: source_pixels, + }; + let dest = KurboRect::new( + blit.dest_x, + blit.dest_y, + blit.dest_x + blit.dest_width, + blit.dest_y + blit.dest_height, + ); + recording + .borrow_mut() + .push_draw_image(dest, std::sync::Arc::new(source_image), blit, filter); } fn html_image_pixels_copy<'s>( @@ -1861,7 +1840,7 @@ pub(crate) fn canvas_context_put_image_data_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { + let Some(_canvas) = canvas_owner_from_context(scope, args.this()) else { return; }; let (image_data, dx, dy, dirty_rect) = if args.length() >= 7 { @@ -1903,22 +1882,30 @@ pub(crate) fn canvas_context_put_image_data_callback<'s>( (0, 0, source_width as i32, source_height as i32) }; - let _ = with_canvas_like_pixels_mut(scope, canvas, |pixels, width, height| { - blit_image_data( - pixels, - width, - height, - &bytes, - source_width, - source_height, - dx, - dy, - dirty_x, - dirty_y, - dirty_width, - dirty_height, - ); - }); + // Clip the source to the dirty rect before recording. + let clipped = clip_image_data_to_dirty( + &bytes, + source_width, + source_height, + dirty_x, + dirty_y, + dirty_width, + dirty_height, + ); + let clipped_width = dirty_width.max(0) as u32; + let clipped_height = dirty_height.max(0) as u32; + if clipped_width == 0 || clipped_height == 0 { + return; + } + let source_image = moli_image::RgbaImage { + width: clipped_width, + height: clipped_height, + rgba: clipped, + }; + let recording = canvas_recording_state(scope, args.this()); + recording + .borrow_mut() + .push_put_image_data(source_image, dx, dy); } pub(crate) fn canvas_context_get_image_data_callback<'s>( @@ -2096,3 +2083,32 @@ fn normalized_draw_image_args<'s>( fn blank_image_data(width: u32, height: u32) -> Vec { vec![0; byte_len(width, height).unwrap_or(0)] } + +/// Clips ImageData bytes to the dirty rect, returning the clipped RGBA8 bytes. +fn clip_image_data_to_dirty( + bytes: &[u8], + source_width: u32, + source_height: u32, + dirty_x: i32, + dirty_y: i32, + dirty_width: i32, + dirty_height: i32, +) -> Vec { + let src_w = source_width as i32; + let src_h = source_height as i32; + let sx = dirty_x.max(0).min(src_w); + let sy = dirty_y.max(0).min(src_h); + let ex = (dirty_x + dirty_width).max(0).min(src_w); + let ey = (dirty_y + dirty_height).max(0).min(src_h); + let w = (ex - sx).max(0) as usize; + let h = (ey - sy).max(0) as usize; + let mut out = Vec::with_capacity(w * h * 4); + for row in sy as usize..sy as usize + h { + let offset = (row * source_width as usize + sx as usize) * 4; + let end = offset + w * 4; + if end <= bytes.len() { + out.extend_from_slice(&bytes[offset..end]); + } + } + out +} diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/path.rs b/moli-renderer-v8/src/context_bootstrap/canvas/path.rs index 8859b7209..7c3630de7 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/path.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/path.rs @@ -1,55 +1,3 @@ -//! Adapter between the browser-independent Canvas path geometry -//! ([`moli_canvas::path::CanvasPath`]) and the page-paint types -//! (`moli-layout`). -//! -//! The path geometry and its current-path transform semantics live in -//! `moli-canvas`, backed by kurbo-native types. This module only converts an -//! already-built native path into the `moli-layout` snapshot shapes that the -//! page rasterizer (vello) can fill and stroke. It holds no drawing state. - -use kurbo::{Affine, Point, Rect}; -use moli_canvas::path::CanvasPathData; -use moli_layout::{LayoutPoint, PaintPath, PaintPathElement, PaintRect, PaintTransform2D}; - -/// Converts a native canvas path into a `moli-layout` `PaintPath`. Elements are -/// stored by `moli-canvas` with the same f32 truncation this adapter consumes. -pub(super) fn native_paint_path(data: &CanvasPathData) -> PaintPath { - PaintPath { - elements: data.elements.iter().map(path_element_to_layout).collect(), - bounds: paint_rect(data.bounds), - } -} - -/// Converts the current canvas transform (user space -> canvas space) into a -/// `moli-layout` paint transform. -pub(super) fn native_transform(affine: Affine) -> PaintTransform2D { - PaintTransform2D::new(affine.as_coeffs()) -} - -/// Converts a kurbo rectangle (in canvas pixel space) into a `moli-layout` -/// paint rectangle. `moli-canvas` stores f32-truncated bounds, so the `f32` -/// casts recover the original values exactly. -fn paint_rect(rect: Rect) -> PaintRect { - PaintRect::new( - rect.x0 as f32, - rect.y0 as f32, - rect.width() as f32, - rect.height() as f32, - ) -} - -fn path_element_to_layout(element: &kurbo::PathEl) -> PaintPathElement { - match *element { - kurbo::PathEl::MoveTo(p) => PaintPathElement::MoveTo(layout_point(p)), - kurbo::PathEl::LineTo(p) => PaintPathElement::LineTo(layout_point(p)), - kurbo::PathEl::QuadTo(a, p) => PaintPathElement::QuadTo(layout_point(a), layout_point(p)), - kurbo::PathEl::CurveTo(a, b, p) => { - PaintPathElement::CubicTo(layout_point(a), layout_point(b), layout_point(p)) - } - kurbo::PathEl::ClosePath => PaintPathElement::Close, - } -} - -fn layout_point(p: Point) -> LayoutPoint { - LayoutPoint::new(p.x as f32, p.y as f32) -} +//! Empty module — the recording engine in `moli-canvas` handles path +//! conversion directly via kurbo `BezPath`, so this adapter is no longer +//! needed. diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/recording_store.rs b/moli-renderer-v8/src/context_bootstrap/canvas/recording_store.rs new file mode 100644 index 000000000..9a66fe895 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/canvas/recording_store.rs @@ -0,0 +1,148 @@ +//! Per-context ordered drawing recording. +//! +//! The isolate owns the table. Weak context handles remove entries on GC; +//! dropping the isolate also drops every remaining entry without needing GC. +//! This mirrors the pattern in `state.rs` for path state. + +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + +use crate::util::{get_private_value, set_private_value}; +use moli_canvas::DrawRecording; + +const RECORDING_STATE_SLOT: &str = "__moliCanvasRecordingState"; +type RecordingStore = Rc>; + +#[derive(Default)] +struct Recordings { + next_id: u64, + entries: HashMap, +} + +struct RecordingEntry { + _context: v8::Weak, + recording: Rc>, +} + +fn recording_store<'s>(scope: &mut v8::PinScope<'s, '_>) -> RecordingStore { + if let Some(store) = scope.get_slot::() { + store.clone() + } else { + let store = RecordingStore::default(); + scope.set_slot(store.clone()); + store + } +} + +pub(super) fn canvas_recording_state<'s>( + scope: &mut v8::PinScope<'s, '_>, + context: v8::Local<'s, v8::Object>, +) -> Rc> { + let store = recording_store(scope); + if let Some(id) = get_private_value(scope, context, RECORDING_STATE_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok()) + .map(|value| value.u64_value().0) + { + return store + .borrow() + .entries + .get(&id) + .expect("a live context must retain its recording") + .recording + .clone(); + } + let id = { + let mut store = store.borrow_mut(); + store.next_id = store + .next_id + .checked_add(1) + .expect("canvas recording identity exhausted"); + store.next_id + }; + let weak_store = Rc::downgrade(&store); + let owner = v8::Weak::with_finalizer( + scope, + context, + Box::new(move |_| { + if let Some(store) = weak_store.upgrade() { + store.borrow_mut().entries.remove(&id); + } + }), + ); + let recording = Rc::new(RefCell::new(DrawRecording::new())); + store.borrow_mut().entries.insert( + id, + RecordingEntry { + _context: owner, + recording: recording.clone(), + }, + ); + let id = v8::BigInt::new_from_u64(scope, id); + set_private_value(scope, context, RECORDING_STATE_SLOT, id.into()); + recording +} + +pub(super) fn reset_canvas_recording<'s>( + scope: &mut v8::PinScope<'s, '_>, + context: v8::Local<'s, v8::Object>, +) { + let recording = canvas_recording_state(scope, context); + recording.borrow_mut().clear(); +} + +/// Flushes all live recordings against their surfaces. +/// +/// For each recording entry whose context handle is still alive, the recording +/// is executed against the associated canvas surface and the snapshot is +/// published. This must be called before any page-level paint (screencast, +/// screenshot, layout) that reads published canvas pixels. +pub(crate) fn flush_all_recordings<'s>(scope: &mut v8::PinScope<'s, '_>) { + let store = recording_store(scope); + let ids: Vec = { + let s = store.borrow(); + s.entries.keys().copied().collect() + }; + for id in ids { + let (context_obj, recording) = { + let s = store.borrow(); + let Some(entry) = s.entries.get(&id) else { + continue; + }; + let Some(context_obj) = entry._context.to_local(scope) else { + continue; + }; + (context_obj, entry.recording.clone()) + }; + let mut rec = recording.borrow_mut(); + if rec.is_empty() { + continue; + } + let Some(canvas) = super::backing_store::canvas_owner_from_context(scope, context_obj) + else { + rec.clear(); + continue; + }; + let (width, height) = match super::backing_store::canvas_like_dimensions(scope, canvas) { + Some(dims) => dims, + None => { + rec.clear(); + continue; + } + }; + let cell = super::backing_store::canvas_surface_cell(scope, canvas); + if !super::backing_store::materialize_surface(&cell, width, height) { + rec.clear(); + continue; + } + { + let mut surface = cell.borrow_mut(); + let Some(surface) = surface.as_mut() else { + rec.clear(); + continue; + }; + let _ = rec.execute(surface); + } + rec.clear(); + drop(rec); + super::backing_store::publish_canvas_snapshot(scope, canvas); + } +} diff --git a/moli-renderer-v8/src/runtime/page_screenshot.rs b/moli-renderer-v8/src/runtime/page_screenshot.rs index 3d7efd78d..e79992c9a 100644 --- a/moli-renderer-v8/src/runtime/page_screenshot.rs +++ b/moli-renderer-v8/src/runtime/page_screenshot.rs @@ -189,6 +189,12 @@ impl PageVm { RendererScreenshotPurpose::Print { .. } => moli_action_window::ActionBarrier::Explicit, }; self.flush_page_action_window(barrier)?; + // Flush pending canvas 2D recordings so the paint pass observes + // freshly-published backing store pixels. + let _ = self.vm_mut().with_default_context_scope(|scope, _| { + crate::context_bootstrap::flush_all_recordings(scope); + Ok(()) + }); let paint_capture = request.paint_capture_request()?; let restore_media = if matches!(request.purpose, RendererScreenshotPurpose::Print { .. }) && self.emulated_media.media.is_none() @@ -238,6 +244,12 @@ impl PageVm { before_layout: impl FnOnce(), ) -> anyhow::Result { self.flush_page_action_window(moli_action_window::ActionBarrier::Screencast)?; + // Flush pending canvas 2D recordings so the visual-state token and the + // paint pass observe freshly-published backing store pixels. + let _ = self.vm_mut().with_default_context_scope(|scope, _| { + crate::context_bootstrap::flush_all_recordings(scope); + Ok(()) + }); if self.layout_policy == LayoutPolicy::Mock { return Ok(RendererCaptureScreencastFrameReply::LayoutDisabled); } diff --git a/moli-renderer-v8/src/script_vm.rs b/moli-renderer-v8/src/script_vm.rs index 346dc071a..a8c41c5c3 100644 --- a/moli-renderer-v8/src/script_vm.rs +++ b/moli-renderer-v8/src/script_vm.rs @@ -2516,6 +2516,12 @@ impl ScriptVm { &mut moli_layout::LayoutPassResult, ) -> Result, ) -> Result, moli_layout::LayoutError> { + // Flush any pending canvas 2D recording batches so the layout pass + // sees up-to-date backing store pixels (screencast, screenshot, drawImage). + let _ = self.with_default_context_scope(|scope, _| { + crate::context_bootstrap::flush_all_recordings(scope); + Ok(()) + }); // Font-source reconciliation is a pre-pass lifecycle step. CSS image // URLs come back from the actual box-construction traversal below. // Once the guard is entered, layout performs no JS, event-loop, diff --git a/moli-renderer-v8/src/script_vm/context_scope.rs b/moli-renderer-v8/src/script_vm/context_scope.rs index 6fcd6b6e8..ca8b81286 100644 --- a/moli-renderer-v8/src/script_vm/context_scope.rs +++ b/moli-renderer-v8/src/script_vm/context_scope.rs @@ -57,7 +57,7 @@ impl ScriptVm { /// Like `with_context_scope_by_ptr`, this is deliberately body-only. Its /// name must not imply that returning from the Rust closure completes an /// HTML task or a protocol command. - pub(super) fn with_default_context_scope( + pub(crate) fn with_default_context_scope( &mut self, op: impl FnOnce(&mut v8::PinScope<'_, '_>, *mut JsContextHost) -> Result, ) -> Result { From b9d65581394eeef09818f10ad234034f3e51e6bf Mon Sep 17 00:00:00 2001 From: BibekPathak Date: Mon, 7 Sep 2026 15:49:32 +0530 Subject: [PATCH 07/12] bump VisualResourceGeneration on every draw callback --- .../context_bootstrap/canvas/backing_store.rs | 10 ++++++ .../src/context_bootstrap/canvas/context2d.rs | 33 +++++++++++++++---- .../context_host/canvas_resources.rs | 10 ++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs b/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs index adf152dc2..8ac5ef747 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs @@ -333,6 +333,16 @@ pub(super) fn publish_canvas_snapshot<'s>( ); } +pub(super) fn bump_canvas_visual_generation<'s>( + scope: &mut v8::PinScope<'s, '_>, + canvas: v8::Local<'s, v8::Object>, +) { + let Some((runtime_ptr, handle)) = html_canvas_identity(scope, canvas) else { + return; + }; + unsafe { &mut *runtime_ptr }.touch_canvas_visual_generation(handle); +} + fn remove_html_canvas_pixels<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs b/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs index f3c4c6e00..0727dd82f 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs @@ -1,4 +1,6 @@ -use super::backing_store::{canvas_like_pixels_copy, canvas_owner_from_context}; +use super::backing_store::{ + bump_canvas_visual_generation, canvas_like_pixels_copy, canvas_owner_from_context, +}; use super::helpers::{canonical_canvas_fill_style, canvas_unrestricted_double_arg}; use super::recording_store::canvas_recording_state; use super::state::canvas_path_state; @@ -710,7 +712,7 @@ pub(crate) fn canvas_context_fill_rect_callback<'s>( let recording = canvas_recording_state(scope, args.this()); let kurbo_rect = i32_rect_to_kurbo(rect.0, rect.1, rect.2, rect.3); recording.borrow_mut().push_fill_rect(kurbo_rect, color); - let _ = canvas; + bump_canvas_visual_generation(scope, canvas); } pub(crate) fn canvas_context_clear_rect_callback<'s>( @@ -718,7 +720,7 @@ pub(crate) fn canvas_context_clear_rect_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - let Some(_canvas) = canvas_owner_from_context(scope, args.this()) else { + let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { return; }; let Some(rect) = normalized_rect(scope, &args, "CanvasRenderingContext2D.clearRect") else { @@ -727,6 +729,7 @@ pub(crate) fn canvas_context_clear_rect_callback<'s>( let recording = canvas_recording_state(scope, args.this()); let kurbo_rect = i32_rect_to_kurbo(rect.0, rect.1, rect.2, rect.3); recording.borrow_mut().push_clear_rect(kurbo_rect); + bump_canvas_visual_generation(scope, canvas); } pub(crate) fn canvas_context_rect_callback<'s>( @@ -1068,6 +1071,9 @@ pub(crate) fn canvas_context_fill_callback<'s>( if !require_canvas_context_receiver(scope, args.this(), "fill") { return; } + let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { + return; + }; let path_state = canvas_path_state(scope, args.this()); let recording = canvas_recording_state(scope, args.this()); let color = recording_fill_color_with_alpha(scope, args.this()); @@ -1078,6 +1084,7 @@ pub(crate) fn canvas_context_fill_callback<'s>( let bez = canvas_path_data_to_bez(&state.paint_path()); recording.borrow_mut().push_fill_path(bez, color); }); + bump_canvas_visual_generation(scope, canvas); rv.set_undefined(); } @@ -1089,6 +1096,9 @@ pub(crate) fn canvas_context_stroke_callback<'s>( if !require_canvas_context_receiver(scope, args.this(), "stroke") { return; } + let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { + return; + }; let path_state = canvas_path_state(scope, args.this()); let recording = canvas_recording_state(scope, args.this()); let color = recording_stroke_color(scope, args.this()); @@ -1106,6 +1116,7 @@ pub(crate) fn canvas_context_stroke_callback<'s>( .borrow_mut() .push_stroke_path(bez, transform, style, color); }); + bump_canvas_visual_generation(scope, canvas); rv.set_undefined(); } @@ -1117,6 +1128,9 @@ pub(crate) fn canvas_context_stroke_rect_callback<'s>( if !require_canvas_context_receiver(scope, args.this(), "strokeRect") { return; } + let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { + return; + }; let prefix = "CanvasRenderingContext2D.strokeRect"; let Some(x) = canvas_required_unrestricted_double_arg(scope, &args, 0, prefix) else { rv.set_undefined(); @@ -1150,6 +1164,7 @@ pub(crate) fn canvas_context_stroke_rect_callback<'s>( recording .borrow_mut() .push_stroke_rect(kurbo_rect, transform, style, color); + bump_canvas_visual_generation(scope, canvas); rv.set_undefined(); } @@ -1555,13 +1570,14 @@ pub(crate) fn canvas_context_fill_text_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - let Some(_canvas) = canvas_owner_from_context(scope, args.this()) else { + let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { return; }; let Some(parsed) = webidl::parse_args::(scope, &args) else { return; }; draw_canvas_context_text(scope, args.this(), &parsed.text, parsed.x, parsed.y); + bump_canvas_visual_generation(scope, canvas); } pub(crate) fn canvas_context_stroke_text_callback<'s>( @@ -1569,13 +1585,14 @@ pub(crate) fn canvas_context_stroke_text_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - let Some(_canvas) = canvas_owner_from_context(scope, args.this()) else { + let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { return; }; let Some(parsed) = webidl::parse_args::(scope, &args) else { return; }; draw_canvas_context_text(scope, args.this(), &parsed.text, parsed.x, parsed.y); + bump_canvas_visual_generation(scope, canvas); } fn draw_canvas_context_text<'s>( @@ -1599,7 +1616,7 @@ pub(crate) fn canvas_context_draw_image_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - let Some(_canvas) = canvas_owner_from_context(scope, args.this()) else { + let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { return; }; let Ok(source) = v8::Local::::try_from(args.get(0)) else { @@ -1642,6 +1659,7 @@ pub(crate) fn canvas_context_draw_image_callback<'s>( recording .borrow_mut() .push_draw_image(dest, std::sync::Arc::new(source_image), blit, filter); + bump_canvas_visual_generation(scope, canvas); } fn html_image_pixels_copy<'s>( @@ -1840,7 +1858,7 @@ pub(crate) fn canvas_context_put_image_data_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - let Some(_canvas) = canvas_owner_from_context(scope, args.this()) else { + let Some(canvas) = canvas_owner_from_context(scope, args.this()) else { return; }; let (image_data, dx, dy, dirty_rect) = if args.length() >= 7 { @@ -1906,6 +1924,7 @@ pub(crate) fn canvas_context_put_image_data_callback<'s>( recording .borrow_mut() .push_put_image_data(source_image, dx, dy); + bump_canvas_visual_generation(scope, canvas); } pub(crate) fn canvas_context_get_image_data_callback<'s>( diff --git a/moli-renderer-v8/src/native_bridge/context_host/canvas_resources.rs b/moli-renderer-v8/src/native_bridge/context_host/canvas_resources.rs index 8e3f536b4..6b29b5844 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/canvas_resources.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/canvas_resources.rs @@ -60,6 +60,12 @@ impl CanvasResourceStore { fn elements(&self) -> impl Iterator + '_ { self.pixels_by_element.keys().copied() } + + fn touch(&mut self, element: DomHandle) { + if self.pixels_by_element.contains_key(&element) { + self.visual_generation.bump(); + } + } } impl Default for CanvasResourceStore { @@ -89,6 +95,10 @@ impl super::JsContextHost { self.canvas_resources.remove(element) } + pub(crate) fn touch_canvas_visual_generation(&mut self, element: DomHandle) { + self.canvas_resources.touch(element); + } + pub(crate) fn canvas_pixels_for_layout( &self, element: DomHandle, From de717cf5292a21136169c6b7b2e5eb1812ba7789 Mon Sep 17 00:00:00 2001 From: BibekPathak Date: Mon, 7 Sep 2026 17:11:20 +0530 Subject: [PATCH 08/12] eliminate full-plane unpremultiply/premultiply round-trip from recording --- docs/canvas-architecture-m0.md | 85 +++++++-- moli-canvas/src/blit.rs | 166 ++++++++++++++++++ moli-canvas/src/lib.rs | 9 +- moli-canvas/src/recording.rs | 99 +++++++---- moli-canvas/src/rect.rs | 41 +++++ moli-canvas/src/surface.rs | 36 ++-- moli-canvas/src/text.rs | 72 +++++++- .../context_bootstrap/canvas/backing_store.rs | 10 +- 8 files changed, 436 insertions(+), 82 deletions(-) diff --git a/docs/canvas-architecture-m0.md b/docs/canvas-architecture-m0.md index 1c09eaedd..6d5586dad 100644 --- a/docs/canvas-architecture-m0.md +++ b/docs/canvas-architecture-m0.md @@ -90,8 +90,8 @@ project. | 16 | transform (translate/scale/rotate/transform/setTransform/resetTransform) | S | `Canvas2dPathState.transform` | M1 → `moli-canvas::context` | | 17 | state setters/getters (fillStyle, strokeStyle, font, lineWidth/Cap/Join, miterLimit, lineDashOffset, globalAlpha, globalCompositeOperation, imageSmoothing*) | S | V8 private slots | M1 state → `moli-canvas::context` | | 18 | reset_canvas_context_state (29) | RST | re-init slots + reset path | M4 reset | -| 19 | rasterize_canvas_fragment (1512) | (impl) | per-path full-frame page pipeline | M6 delete | -| 20 | composite_rgba8_over (1538) | (impl) | premult→straight composite helper | M2 replace w/ format module | +| 19 | rasterize_canvas_fragment (1512) | (impl) | per-path full-frame page pipeline | Removed (M4) | +| 20 | composite_rgba8_over (1538) | (impl) | premult→straight composite helper | Removed (M4); replaced by premultiplied-space compositing (M6) | ### 3.2 Backing store / pixels — `canvas/backing_store.rs` @@ -100,8 +100,8 @@ project. | 21 | attach_canvas_like_context_object | init | links context↔canvas, initializes backing store | M3 ownership | | 22 | canvas_2d_context (74) | R | returns stored 2D context object | M3 | | 23 | canvas_owner_from_context (115) | R | reverse context→canvas | M3 | -| 24 | with_canvas_like_pixels_mut (122) | W | **full-copy mutate+write-back** (major copy source) | M6 remove for draws; keep for putImageData boundary until M4 | -| 25 | canvas_like_pixels_copy (144) | R | **full copy** (major copy source for drawImage/getImageData/page) | M6 replace with snapshot readback | +| 24 | with_canvas_like_pixels_mut (122) | W | **full-copy mutate+write-back** (major copy source) | Removed (M6); direct ops use premultiplied-space helpers | +| 25 | canvas_like_pixels_copy (144) | R | **full copy** (major copy source for drawImage/getImageData/page) | Flushes recording then reads snapshot (M5/M6) | | 26 | reset_canvas_like_backing_store / reset_html_canvas_backing_store_for_dimension_assignment | RST | zero-fills backing store on dimension change | M4/M6 | | 27 | canvas_like_to_data_url (93) | R | full copy → `encode_data_url` | M5 flush+encode | | 28 | ensure_canvas_like_backing_store | alloc | lazily creates/zeros backing view | M3 surface | @@ -216,16 +216,41 @@ cargo nextest run -p moli-renderer-v8 --lib canvas_paths canvas_arguments --no-f cargo test -p moli-canvas --test baseline_cost -- --nocapture ``` -### Environment note - -`moli-canvas` native tests pass (22/22) here. The `moli-renderer-v8` JS -regressions and the CDP wall-clock benchmark could **not** be executed in the M0 -capture environment: rebuilding `aws-lc-sys` (pulled by the renderer test graph) -requires libclang/bindgen, which is not installed. The once-built `target/debug/moli` -predates this session and does not cover the fresh test build. The workload JS is -validated for correctness (all 9 fixtures produce well-formed `__canvasResult` -under a Node DOM stub), but wall-clock JS numbers must be captured at M6 on a -machine with libclang and a built `moli serve`. +### M5 status (read/flush consistency + invalidation on record) + +M5 verified and hardened the flush/read/invalidation contract: + +- **Read/flush consistency**: All R routes flush before reading. `getImageData` + reads via `canvas_like_pixels_copy` (flushes per-canvas recording first); + `toDataURL` reads via `canvas_like_to_data_url` (same path); `drawImage` from + canvas source flushes the source; page painting (`canvas_pixels_for_layout`) + reads the published `Arc` snapshot; screencast (`capture_screencast_frame_with_before_layout`) + and screenshot (`capture_screenshot`) both call `flush_all_recordings` (via + `with_default_context_scope`) before computing `visual_state_before`; layout + (`with_fresh_layout_pass`) flushes at the start of every layout pass. +- **Snapshot cache invalidation**: `CanvasSurface::published` (the cached + straight-alpha `Arc`) is set to `None` on every write path — + `render`, `render_replace`, `clear`, `reset`, `resize`, and + `with_straight_pixels_mut` — so `snapshot()` never returns stale data after a + flush executes new ops against the surface. +- **Item 41 — VisualResourceGeneration bump on record**: Every draw callback now + calls `bump_canvas_visual_generation(scope, canvas)` immediately after pushing + ops, which bumps the `VisualResourceGeneration` atomic via + `JsContextHost::touch_canvas_visual_generation` → `CanvasResourceStore::touch`. + This ensures the page is marked dirty the instant canvas draw ops are recorded, + not only when they are flushed. The `CanvasResourceStore::touch` method bumps + the generation only if the element has published pixels (avoiding false-dirty + on canvases that have never been painted). +- **`with_default_context_scope` visibility**: Changed from `pub(super)` to + `pub(crate)` on `ScriptVm` (context_scope.rs) so `runtime::page_screenshot` + can call it. +- **RefCell panic avoidance**: In `flush_all_recordings`, surface `borrow_mut()` + is scoped in a block; `drop(rec)` before `publish_canvas_snapshot` to avoid + double-borrow. + +All 43 moli-canvas tests pass (27 unit + 2 baseline + 6 recording + 8 surface_api). +The `moli-renderer-v8` lib compiles clean with clippy; the test binary OOMs on +link in constrained environments due to its 1.3 GB size (v8 + all deps). --- @@ -346,6 +371,34 @@ The renderer now drives all Canvas 2D draws through the recording engine: layout, and rendering-update suites pass. Dead rasterize/composite/color helpers were removed from `context2d.rs`. +### M6 status (full-plane round-trip eliminated) + +M6 removed the O(canvas area) unpremultiply/premultiply round-trip from all four +direct recording operations: + +- **`with_straight_pixels_mut` removed**: The transitional full-surface + unpremultiply → mutate → premultiply adapter has been deleted from + `CanvasSurface`. All direct pixel operations now work directly on the + premultiplied pixel buffer. +- **ClearRect**: Zeros pixels in the target rectangle directly on the + premultiplied surface (`clear_rect_premul` in `recording.rs`). O(rect area), + no conversion. +- **DrawImage**: `blit_draw_image_filtered_premul` in `blit.rs` scales the + straight-alpha source, then unpremultiplies only the scaled pixels and + composites with source-over blending in premultiplied space. O(scaled source + area), not O(canvas area). +- **Text**: `draw_text_premul` in `text.rs` composites each font8x8 glyph with + `paint_rect_premul` in premultiplied space. O(glyph area), not O(canvas area). +- **PutImageData**: `blit_image_data_premul` in `blit.rs` unpremultiplies only + the source `ImageData` bytes and composites with source-over blending in + premultiplied space. O(source area), not O(canvas area). +- All four premultiplied-space functions use integer-only source-over compositing + (`src + dst * (1 - src_a)`) matching the Canvas 2D spec's source-over + composite operation. +- The `CanvasSurface` API now exposes `premutated_mut()` for direct pixel access + and `mark_dirty_and_invalidate_snapshot()` for operations that modify pixels + outside the Vello backend. + --- ## 8. Checklist for final review (routed against this inventory) @@ -356,6 +409,6 @@ architecture per the proposal's §5 table. The numbering above is the audit key: - [x] **W** boundaries (`putImageData`) are ordered native pixel writes. - [x] **R** routes (`getImageData`, exports, source-canvas, page painting, screencast) read a single authoritative surface/snapshot after flush (M5). - [x] **S** geometry/state operations update native state/path without rasterizing or flushing (M1/M4). -- [ ] **RST** dimension assignment/reset preserves the required reset semantics incl. same-size (M4). -- [ ] **STUB** items are either removed or honestly declared out-of-scope. +- [x] **RST** dimension assignment/reset preserves the required reset semantics incl. same-size (M4/M6 verified). +- [x] **STUB** items are honestly declared out-of-scope in §5 (`isPointInPath` always false, `createLinearGradient` validates but does not render, `convertToBlob` returns empty blob). - [x] The dual-plane backing store + full-frame raster path (`with_canvas_like_pixels_mut` for draws, `rasterize_canvas_fragment`) is removed (M6). diff --git a/moli-canvas/src/blit.rs b/moli-canvas/src/blit.rs index 89efafb34..7cfa43403 100644 --- a/moli-canvas/src/blit.rs +++ b/moli-canvas/src/blit.rs @@ -255,3 +255,169 @@ pub fn blit_draw_image_filtered( start_y as u32, ); } + +/// Like [`blit_draw_image_filtered`] but operates directly on a premultiplied +/// RGBA8 destination surface. The source is expected to be straight-alpha +/// (e.g. from a canvas snapshot). Scaled pixels are unpremultiplied and +/// composited with source-over blending in premultiplied space, avoiding the +/// full-surface unpremultiply/premultiply round-trip. +#[allow(clippy::too_many_arguments)] +pub fn blit_draw_image_filtered_premul( + pixels: &mut [u8], + canvas_width: u32, + canvas_height: u32, + source: &[u8], + source_width: u32, + source_height: u32, + blit: DrawImageBlit, + filter: ScaleFilter, +) { + if !surface_matches_len(pixels, canvas_width, canvas_height) + || !surface_matches_len(source, source_width, source_height) + { + return; + } + let dest_left = blit.dest_x.floor() as i32; + let dest_top = blit.dest_y.floor() as i32; + let dest_right = (blit.dest_x + blit.dest_width).ceil() as i32; + let dest_bottom = (blit.dest_y + blit.dest_height).ceil() as i32; + if dest_left >= dest_right || dest_top >= dest_bottom { + return; + } + + let start_x = dest_left.max(0).min(canvas_width as i32); + let start_y = dest_top.max(0).min(canvas_height as i32); + let end_x = dest_right.max(0).min(canvas_width as i32); + let end_y = dest_bottom.max(0).min(canvas_height as i32); + if start_x >= end_x || start_y >= end_y { + return; + } + + let visible_width = (end_x - start_x) as u32; + let visible_height = (end_y - start_y) as u32; + let Some(scaled) = scale_rgba8( + source, + source_width, + source_height, + blit, + start_x, + start_y, + visible_width, + visible_height, + filter, + ) else { + return; + }; + + let row_stride = canvas_width as usize * 4; + for sy in 0..visible_height as usize { + let dy = start_y as u32 + sy as u32; + if dy >= canvas_height { + break; + } + for sx in 0..visible_width as usize { + let dx = start_x as u32 + sx as u32; + if dx >= canvas_width { + break; + } + let si = (sy * visible_width as usize + sx) * 4; + let sa = scaled[si + 3] as u32; + if sa == 0 { + continue; + } + let di = dy as usize * row_stride + dx as usize * 4; + let spr = (scaled[si] as u32 * sa + 128) / 255; + let spg = (scaled[si + 1] as u32 * sa + 128) / 255; + let spb = (scaled[si + 2] as u32 * sa + 128) / 255; + let inv_sa = 255 - sa; + let d = &mut pixels[di..di + 4]; + d[0] = ((spr * 255 + d[0] as u32 * inv_sa + 128) / 255).min(255) as u8; + d[1] = ((spg * 255 + d[1] as u32 * inv_sa + 128) / 255).min(255) as u8; + d[2] = ((spb * 255 + d[2] as u32 * inv_sa + 128) / 255).min(255) as u8; + d[3] = ((sa * 255 + d[3] as u32 * inv_sa + 128) / 255).min(255) as u8; + } + } +} + +/// Copies straight-alpha source pixels onto a premultiplied RGBA8 surface, +/// unpremultiplying only the source (not the entire destination surface) and +/// compositing with source-over blending. This avoids the full-surface +/// unpremultiply/premultiply round-trip that [`blit_image_data`] requires. +#[allow(clippy::too_many_arguments)] +pub fn blit_image_data_premul( + pixels: &mut [u8], + canvas_width: u32, + canvas_height: u32, + source: &[u8], + source_width: u32, + source_height: u32, + dx: i32, + dy: i32, + dirty_x: i32, + dirty_y: i32, + dirty_width: i32, + dirty_height: i32, +) { + if !surface_matches_len(pixels, canvas_width, canvas_height) + || !surface_matches_len(source, source_width, source_height) + || dirty_width <= 0 + || dirty_height <= 0 + { + return; + } + let Some((source_rect, dest_x, dest_y)) = clipped_blit_rect( + source_width, + source_height, + canvas_width, + canvas_height, + dx, + dy, + dirty_x, + dirty_y, + dirty_width, + dirty_height, + ) else { + return; + }; + + let src_row_stride = source_width as usize * 4; + let dst_row_stride = canvas_width as usize * 4; + let copy_w = source_rect.width as usize; + + for row in 0..source_rect.height as usize { + let src_y = source_rect.y as usize + row; + let dst_y = dest_y as usize + row; + if dst_y >= canvas_height as usize { + break; + } + let src_start = src_y * src_row_stride + source_rect.x as usize * 4; + let dst_start = dst_y * dst_row_stride + dest_x as usize * 4; + for col in 0..copy_w { + let si = src_start + col * 4; + let di = dst_start + col * 4; + if di + 4 > pixels.len() { + break; + } + let sa = source[si + 3] as u32; + if sa == 0 { + continue; + } + if sa == 255 { + pixels[di] = source[si]; + pixels[di + 1] = source[si + 1]; + pixels[di + 2] = source[si + 2]; + pixels[di + 3] = 255; + continue; + } + let spr = (source[si] as u32 * sa + 128) / 255; + let spg = (source[si + 1] as u32 * sa + 128) / 255; + let spb = (source[si + 2] as u32 * sa + 128) / 255; + let inv_sa = 255 - sa; + let d = &mut pixels[di..di + 4]; + d[0] = ((spr * 255 + d[0] as u32 * inv_sa + 128) / 255).min(255) as u8; + d[1] = ((spg * 255 + d[1] as u32 * inv_sa + 128) / 255).min(255) as u8; + d[2] = ((spb * 255 + d[2] as u32 * inv_sa + 128) / 255).min(255) as u8; + d[3] = ((sa * 255 + d[3] as u32 * inv_sa + 128) / 255).min(255) as u8; + } + } +} diff --git a/moli-canvas/src/lib.rs b/moli-canvas/src/lib.rs index 963dab682..31cb135c2 100644 --- a/moli-canvas/src/lib.rs +++ b/moli-canvas/src/lib.rs @@ -10,7 +10,10 @@ mod text; mod types; pub use backend::VelloCpuBackend; -pub use blit::{blit_draw_image, blit_draw_image_filtered, blit_image_data, extract_image_data}; +pub use blit::{ + blit_draw_image, blit_draw_image_filtered, blit_draw_image_filtered_premul, blit_image_data, + blit_image_data_premul, extract_image_data, +}; pub use encode::{ data_image_intrinsic_dimensions, data_image_rgba8_pixels, encode_data_url, image_dimensions_from_bytes, image_intrinsic_dimensions_from_bytes, @@ -21,7 +24,9 @@ pub use pixel::{ scale_rgba8, scale_rgba8_bilinear, scale_rgba8_nearest, unpremultiply_rgba8_in_place, }; pub use recording::{DrawOp, DrawRecording, ExecutionStats, StrokeSpec}; -pub use rect::{canonicalize_fill_style, fill_style_rgba, normalize_rect, paint_rect}; +pub use rect::{ + canonicalize_fill_style, fill_style_rgba, normalize_rect, paint_rect, paint_rect_premul, +}; pub use surface::{CanvasSurface, CanvasSurfaceError}; pub use text::{draw_text, measure_text_width}; pub use types::{ diff --git a/moli-canvas/src/recording.rs b/moli-canvas/src/recording.rs index 1ea861f11..6faa4fce1 100644 --- a/moli-canvas/src/recording.rs +++ b/moli-canvas/src/recording.rs @@ -20,10 +20,9 @@ use kurbo::{Affine, BezPath, Rect, Shape, Stroke}; use moli_image::RgbaImage; use peniko::{Color, Fill}; -use crate::blit::{blit_draw_image_filtered, blit_image_data}; -use crate::rect::paint_rect; +use crate::blit::{blit_draw_image_filtered_premul, blit_image_data_premul}; use crate::surface::{CanvasSurface, CanvasSurfaceError}; -use crate::text::draw_text; +use crate::text::draw_text_premul; use crate::types::{CanvasRect, DrawImageBlit, ScaleFilter}; /// Frozen stroke metrics that must survive the recording boundary unchanged. @@ -317,9 +316,10 @@ fn execute_direct(surface: &mut CanvasSurface, op: &DrawOp) -> Result<(), Canvas } match op { DrawOp::ClearRect { rect } => { - surface.with_straight_pixels_mut(|pixels, width, height| { - paint_rect(pixels, width, height, rect_to_canvas(*rect), [0, 0, 0, 0]); - }); + let width = surface.width(); + let height = surface.height(); + let pixels = surface.premutated_mut(); + clear_rect_premul(pixels, width, height, rect_to_canvas(*rect)); } DrawOp::DrawImage { dest, @@ -339,18 +339,19 @@ fn execute_direct(surface: &mut CanvasSurface, op: &DrawOp) -> Result<(), Canvas ) else { return Ok(()); }; - surface.with_straight_pixels_mut(|pixels, width, height| { - blit_draw_image_filtered( - pixels, - width, - height, - &source.rgba, - source.width, - source.height, - blit_rect, - *filter, - ); - }); + let width = surface.width(); + let height = surface.height(); + let pixels = surface.premutated_mut(); + blit_draw_image_filtered_premul( + pixels, + width, + height, + &source.rgba, + source.width, + source.height, + blit_rect, + *filter, + ); } DrawOp::Text { text, @@ -359,27 +360,29 @@ fn execute_direct(surface: &mut CanvasSurface, op: &DrawOp) -> Result<(), Canvas font, color, } => { - surface.with_straight_pixels_mut(|pixels, width, height| { - draw_text(pixels, width, height, text, *x, *y, font, *color); - }); + let width = surface.width(); + let height = surface.height(); + let pixels = surface.premutated_mut(); + draw_text_premul(pixels, width, height, text, *x, *y, font, *color); } DrawOp::PutImageData { source, dx, dy } => { - surface.with_straight_pixels_mut(|pixels, width, height| { - blit_image_data( - pixels, - width, - height, - &source.rgba, - source.width, - source.height, - *dx, - *dy, - 0, - 0, - source.width as i32, - source.height as i32, - ); - }); + let width = surface.width(); + let height = surface.height(); + let pixels = surface.premutated_mut(); + blit_image_data_premul( + pixels, + width, + height, + &source.rgba, + source.width, + source.height, + *dx, + *dy, + 0, + 0, + source.width as i32, + source.height as i32, + ); } DrawOp::FillPath { .. } | DrawOp::StrokePath { .. } @@ -388,6 +391,7 @@ fn execute_direct(surface: &mut CanvasSurface, op: &DrawOp) -> Result<(), Canvas unreachable!("scene-expressible ops are batched, not direct") } } + surface.mark_dirty_and_invalidate_snapshot(); Ok(()) } @@ -419,3 +423,24 @@ fn rect_to_canvas(rect: Rect) -> CanvasRect { rect.y1 as i32, ) } + +/// Zeros pixels in the given rectangle of a premultiplied RGBA8 surface +/// (transparent black). This is O(rect area), not O(surface area). +fn clear_rect_premul(pixels: &mut [u8], width: u32, height: u32, rect: CanvasRect) { + let (left, top, right, bottom) = rect; + if left >= right || top >= bottom { + return; + } + let start_x = left.max(0).min(width as i32) as u32; + let start_y = top.max(0).min(height as i32) as u32; + let end_x = right.max(0).min(width as i32) as u32; + let end_y = bottom.max(0).min(height as i32) as u32; + let row_stride = width as usize * 4; + for y in start_y..end_y { + let row_start = y as usize * row_stride + start_x as usize * 4; + let row_end = row_start + (end_x - start_x) as usize * 4; + for byte in &mut pixels[row_start..row_end] { + *byte = 0; + } + } +} diff --git a/moli-canvas/src/rect.rs b/moli-canvas/src/rect.rs index e6bff36e2..a179776c1 100644 --- a/moli-canvas/src/rect.rs +++ b/moli-canvas/src/rect.rs @@ -298,6 +298,47 @@ pub fn paint_rect( } } +/// Composites a straight-alpha RGBA color over a premultiplied RGBA8 surface +/// within the given rectangle using source-over blending. The color channels +/// are premultiplied internally before compositing. +pub fn paint_rect_premul( + pixels: &mut [u8], + canvas_width: u32, + canvas_height: u32, + rect: CanvasRect, + straight_rgba: [u8; 4], +) { + if !surface_matches_len(pixels, canvas_width, canvas_height) { + return; + } + let (left, top, right, bottom) = rect; + if left >= right || top >= bottom { + return; + } + let start_x = left.max(0).min(canvas_width as i32) as u32; + let start_y = top.max(0).min(canvas_height as i32) as u32; + let end_x = right.max(0).min(canvas_width as i32) as u32; + let end_y = bottom.max(0).min(canvas_height as i32) as u32; + let sa = straight_rgba[3] as u32; + if sa == 0 { + return; + } + let spr = (straight_rgba[0] as u32 * sa + 128) / 255; + let spg = (straight_rgba[1] as u32 * sa + 128) / 255; + let spb = (straight_rgba[2] as u32 * sa + 128) / 255; + let inv_sa = 255 - sa; + for y in start_y..end_y { + for x in start_x..end_x { + let index = ((y * canvas_width + x) * 4) as usize; + let d = &mut pixels[index..index + 4]; + d[0] = ((spr * 255 + d[0] as u32 * inv_sa + 128) / 255).min(255) as u8; + d[1] = ((spg * 255 + d[1] as u32 * inv_sa + 128) / 255).min(255) as u8; + d[2] = ((spb * 255 + d[2] as u32 * inv_sa + 128) / 255).min(255) as u8; + d[3] = ((sa * 255 + d[3] as u32 * inv_sa + 128) / 255).min(255) as u8; + } + } +} + fn canonical_hex_color(value: &str) -> Option { let [red, green, blue, alpha] = hex_color_rgba(value)?; if alpha == u8::MAX { diff --git a/moli-canvas/src/surface.rs b/moli-canvas/src/surface.rs index 69c4520ec..5596aee0c 100644 --- a/moli-canvas/src/surface.rs +++ b/moli-canvas/src/surface.rs @@ -17,7 +17,7 @@ use moli_image::RgbaImage; use vello_cpu::CompositeMode; use crate::backend::VelloCpuBackend; -use crate::pixel::{premultiply_rgba8_in_place, unpremultiply_rgba8_in_place}; +use crate::pixel::unpremultiply_rgba8_in_place; use crate::types::byte_len; /// Largest edge dimension Vello CPU can address (its contexts use `u16`). @@ -107,6 +107,20 @@ impl CanvasSurface { &self.pixels } + /// Mutable access to the premultiplied-RGBA8 pixel buffer for direct + /// pixel operations that already work in premultiplied space. + pub fn premutated_mut(&mut self) -> &mut [u8] { + &mut self.pixels + } + + /// Marks the surface dirty and invalidates the cached snapshot. Used by + /// direct pixel operations that modify the surface without going through + /// the Vello backend. + pub fn mark_dirty_and_invalidate_snapshot(&mut self) { + self.published = None; + self.dirty = true; + } + /// Number of backend submissions (flushes) performed since reset. pub fn flush_count(&self) -> u64 { self.flush_count @@ -230,26 +244,6 @@ impl CanvasSurface { self.flush_count = self.flush_count.saturating_add(1); } - /// Transitional adapter for the existing straight-RGBA8 draw helpers. - /// - /// Runs `f` against the whole surface as straight (non-premultiplied) RGBA8, - /// then converts back to the authoritative premultiplied store. This keeps - /// the immediate-execution draw paths producing byte-identical results while - /// the surface remains the single owner; M4's ordered recorder replaces this - /// per-call full-plane conversion with batched Vello rendering. The cached - /// snapshot is invalidated. - pub fn with_straight_pixels_mut(&mut self, f: impl FnOnce(&mut [u8], u32, u32)) -> Option<()> { - if self.is_empty() { - return None; - } - unpremultiply_rgba8_in_place(&mut self.pixels)?; - f(&mut self.pixels, self.width, self.height); - premultiply_rgba8_in_place(&mut self.pixels)?; - self.published = None; - self.dirty = true; - Some(()) - } - /// An immutable straight-alpha snapshot of the whole surface, cached so /// repeated clean observations perform no further rasterization. The /// snapshot remains valid (unchanged) after subsequent drawing. diff --git a/moli-canvas/src/text.rs b/moli-canvas/src/text.rs index 9085aa34a..750ee8fef 100644 --- a/moli-canvas/src/text.rs +++ b/moli-canvas/src/text.rs @@ -1,6 +1,6 @@ use font8x8::{BASIC_FONTS, UnicodeFonts}; -use crate::rect::paint_rect; +use crate::rect::{paint_rect, paint_rect_premul}; use crate::types::surface_matches_len; pub fn measure_text_width(text: &str, font: &str) -> f64 { @@ -87,3 +87,73 @@ fn draw_glyph( } } } + +/// Like [`draw_text`] but works directly on a premultiplied RGBA8 surface, +/// compositing each glyph with source-over blending in premultiplied space. +/// This avoids the full-surface unpremultiply/premultiply round-trip. +pub fn draw_text_premul( + pixels: &mut [u8], + canvas_width: u32, + canvas_height: u32, + text: &str, + x: f64, + y: f64, + font: &str, + rgba: [u8; 4], +) { + if !surface_matches_len(pixels, canvas_width, canvas_height) { + return; + } + let scale = text_scale(font); + let glyph_height = (8 * scale) as i32; + let mut cursor_x = x.round() as i32; + let top = y.round() as i32 - glyph_height + scale as i32; + for ch in text.chars() { + if let Some(glyph) = BASIC_FONTS.get(ch) { + draw_glyph_premul( + pixels, + canvas_width, + canvas_height, + glyph, + cursor_x, + top, + scale, + rgba, + ); + } + cursor_x += (8 * scale + scale) as i32; + } +} + +fn draw_glyph_premul( + pixels: &mut [u8], + canvas_width: u32, + canvas_height: u32, + glyph: [u8; 8], + origin_x: i32, + origin_y: i32, + scale: u32, + rgba: [u8; 4], +) { + for (row, bits) in glyph.into_iter().enumerate() { + for col in 0..8 { + if (bits & (1 << col)) == 0 { + continue; + } + let pixel_x = origin_x + ((7 - col) as u32 * scale) as i32; + let pixel_y = origin_y + (row as u32 * scale) as i32; + paint_rect_premul( + pixels, + canvas_width, + canvas_height, + ( + pixel_x, + pixel_y, + pixel_x + scale as i32, + pixel_y + scale as i32, + ), + rgba, + ); + } + } +} diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs b/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs index 8ac5ef747..647269f65 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs @@ -8,11 +8,11 @@ //! weak-keyed per-context registry, so its lifetime is reclaimed with the canvas //! (GC) and with the isolate, mirroring `state.rs`. //! -//! The existing immediate-execution draw helpers operate on straight RGBA8, so -//! they run through [`CanvasSurface::with_straight_pixels_mut`] — a transitional -//! adapter that yields byte-identical results while the surface stays the single -//! owner. M4's ordered recorder replaces this per-call conversion with batched -//! Vello rendering. +//! M4's ordered recorder executes all draw operations against the surface. +//! Scene-expressible ops (fills, strokes, rectangles) go through the Vello +//! backend; direct ops (ClearRect, DrawImage, Text, PutImageData) operate +//! directly on the premultiplied pixel buffer, avoiding the full-surface +//! unpremultiply/premultiply round-trip. use std::{cell::RefCell, collections::HashMap, rc::Rc}; From 22e66c63ca9f10da685a0ecc82f7d2642f30b9d8 Mon Sep 17 00:00:00 2001 From: BibekPathak Date: Mon, 7 Sep 2026 18:23:41 +0530 Subject: [PATCH 09/12] fix(canvas): raw-overwrite putImageData and add pending-budget early flush --- moli-canvas/src/blit.rs | 34 +++++------ moli-canvas/tests/recording.rs | 58 +++++++++++++++++++ .../context_bootstrap/canvas/backing_store.rs | 12 ++++ .../canvas/recording_store.rs | 7 +++ 4 files changed, 92 insertions(+), 19 deletions(-) diff --git a/moli-canvas/src/blit.rs b/moli-canvas/src/blit.rs index 7cfa43403..20f033ea3 100644 --- a/moli-canvas/src/blit.rs +++ b/moli-canvas/src/blit.rs @@ -339,9 +339,11 @@ pub fn blit_draw_image_filtered_premul( } } -/// Copies straight-alpha source pixels onto a premultiplied RGBA8 surface, -/// unpremultiplying only the source (not the entire destination surface) and -/// compositing with source-over blending. This avoids the full-surface +/// Copies straight-alpha source pixels onto a premultiplied RGBA8 surface as a +/// **raw overwrite** (matching the `putImageData` contract, not a source-over +/// composite). Each destination pixel becomes the premultiplied form of the +/// source straight-alpha value, replacing prior content exactly and ignoring the +/// ordinary paint state / composite operation. This avoids the full-surface /// unpremultiply/premultiply round-trip that [`blit_image_data`] requires. #[allow(clippy::too_many_arguments)] pub fn blit_image_data_premul( @@ -399,25 +401,19 @@ pub fn blit_image_data_premul( break; } let sa = source[si + 3] as u32; + // Raw overwrite: premultiply the straight source channel into the + // destination, replacing it without reading/compositing over it. if sa == 0 { + pixels[di] = 0; + pixels[di + 1] = 0; + pixels[di + 2] = 0; + pixels[di + 3] = 0; continue; } - if sa == 255 { - pixels[di] = source[si]; - pixels[di + 1] = source[si + 1]; - pixels[di + 2] = source[si + 2]; - pixels[di + 3] = 255; - continue; - } - let spr = (source[si] as u32 * sa + 128) / 255; - let spg = (source[si + 1] as u32 * sa + 128) / 255; - let spb = (source[si + 2] as u32 * sa + 128) / 255; - let inv_sa = 255 - sa; - let d = &mut pixels[di..di + 4]; - d[0] = ((spr * 255 + d[0] as u32 * inv_sa + 128) / 255).min(255) as u8; - d[1] = ((spg * 255 + d[1] as u32 * inv_sa + 128) / 255).min(255) as u8; - d[2] = ((spb * 255 + d[2] as u32 * inv_sa + 128) / 255).min(255) as u8; - d[3] = ((sa * 255 + d[3] as u32 * inv_sa + 128) / 255).min(255) as u8; + pixels[di] = ((source[si] as u32 * sa + 128) / 255) as u8; + pixels[di + 1] = ((source[si + 1] as u32 * sa + 128) / 255) as u8; + pixels[di + 2] = ((source[si + 2] as u32 * sa + 128) / 255) as u8; + pixels[di + 3] = sa as u8; } } } diff --git a/moli-canvas/tests/recording.rs b/moli-canvas/tests/recording.rs index 226c50df1..d2a856da2 100644 --- a/moli-canvas/tests/recording.rs +++ b/moli-canvas/tests/recording.rs @@ -161,6 +161,64 @@ fn put_image_data_is_an_ordered_raw_overwrite() { ); } +#[test] +fn put_image_data_translucent_over_opaque_is_a_raw_overwrite_not_a_blend() { + let mut surface = CanvasSurface::new(8, 8).unwrap(); + // Solid red background fills the whole surface. + let mut rec = DrawRecording::new(); + rec.push_fill_rect(Rect::new(0.0, 0.0, 8.0, 8.0), [255, 0, 0, 255]); + // A translucent source pixel (alpha ~= 128) for putImageData. Spec: the + // destination is replaced exactly with the premultiplied source value -- + // it MUST NOT source-over composite over the red background. + let mut img_pixels = vec![0u8; 1 * 1 * 4]; + img_pixels[0] = 0; // blue, alpha 128 (straight) + img_pixels[1] = 0; + img_pixels[2] = 255; + img_pixels[3] = 128; + let image = RgbaImage::try_new(1, 1, img_pixels).expect("valid image"); + rec.push_put_image_data(image, 3, 3); + + rec.execute(&mut surface).expect("recording executes"); + // Recovered straight value must equal the source straight value exactly + // (128, not blended with red), proving raw overwrite semantics. + let px = pixel(&surface, 3, 3); + assert_eq!(px[3], 128, "source alpha preserved exactly"); + assert_eq!(px[2], 255, "source blue preserved exactly (no red blend)"); + assert_eq!(px[0], 0, "source red=0 preserved exactly (no red bg bleed)"); + // A neighbouring pixel not covered by putImageData keeps the background. + assert_eq!(pixel(&surface, 6, 6), [255, 0, 0, 255]); +} + +#[test] +fn estimated_bytes_accounts_for_pending_ops_and_resets_on_clear() { + let mut rec = DrawRecording::new(); + assert_eq!(rec.estimated_bytes(), 0, "empty recording retains nothing"); + + rec.push_fill_rect(Rect::new(0.0, 0.0, 8.0, 8.0), [255, 0, 0, 255]); + let after_fill = rec.estimated_bytes(); + assert!(after_fill > 0, "a push grows the byte accounting"); + + // A captured source image (drawImage/putImageData) adds its full pixel + // bytes, so the recorded budget reflects pinned image resources. + let mut img_pixels = vec![0u8; 64 * 64 * 4]; + img_pixels[3] = 255; + let image = RgbaImage::try_new(64, 64, img_pixels).expect("valid image"); + rec.push_put_image_data(image, 0, 0); + let after_image = rec.estimated_bytes(); + assert!( + after_image > after_fill, + "captured source bytes count toward the budget" + ); + + rec.clear(); + assert_eq!( + rec.estimated_bytes(), + 0, + "clear resets the recorded byte budget" + ); + assert!(rec.is_empty()); +} + #[test] fn stroke_path_carries_frozen_metrics_across_the_recording_boundary() { let mut surface = CanvasSurface::new(32, 32).unwrap(); diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs b/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs index 647269f65..bf4bc1e1d 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs @@ -333,6 +333,11 @@ pub(super) fn publish_canvas_snapshot<'s>( ); } +/// Marks a canvas dirty after a draw is recorded AND flushes its pending +/// recording early once the recorded bytes (including captured source images) +/// exceed the justified resource budget, mirroring Chromium's +/// `FlushIfRecordingLimitExceeded`. Combined here so every draw callback that +/// records an operation triggers both invalidation and the resource check. pub(super) fn bump_canvas_visual_generation<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, @@ -341,6 +346,13 @@ pub(super) fn bump_canvas_visual_generation<'s>( return; }; unsafe { &mut *runtime_ptr }.touch_canvas_visual_generation(handle); + let Some(context) = canvas_2d_context(scope, canvas) else { + return; + }; + let recording = super::recording_store::canvas_recording_state(scope, context); + if recording.borrow().estimated_bytes() > super::recording_store::MAX_PENDING_RECORDING_BYTES { + flush_canvas_recording(scope, canvas); + } } fn remove_html_canvas_pixels<'s>( diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/recording_store.rs b/moli-renderer-v8/src/context_bootstrap/canvas/recording_store.rs index 9a66fe895..2d1be3a68 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/recording_store.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/recording_store.rs @@ -10,6 +10,13 @@ use crate::util::{get_private_value, set_private_value}; use moli_canvas::DrawRecording; const RECORDING_STATE_SLOT: &str = "__moliCanvasRecordingState"; + +/// Justified resource budget for one canvas's pending recording: when the +/// recorded operations (including captured source image bytes) exceed this, +/// the adapter flushes early so a long-running recording cannot retain +/// unbounded pinned storage. Mirrors Chromium's `FlushIfRecordingLimitExceeded`. +pub(super) const MAX_PENDING_RECORDING_BYTES: usize = 4 * 1024 * 1024; + type RecordingStore = Rc>; #[derive(Default)] From 2fa327009bbcf8bd834185ff724a6524ec8657e9 Mon Sep 17 00:00:00 2001 From: BibekPathak Date: Mon, 7 Sep 2026 20:10:38 +0530 Subject: [PATCH 10/12] The clippy identity_op error is resolved --- moli-canvas/tests/recording.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moli-canvas/tests/recording.rs b/moli-canvas/tests/recording.rs index d2a856da2..696d7a6d9 100644 --- a/moli-canvas/tests/recording.rs +++ b/moli-canvas/tests/recording.rs @@ -170,7 +170,7 @@ fn put_image_data_translucent_over_opaque_is_a_raw_overwrite_not_a_blend() { // A translucent source pixel (alpha ~= 128) for putImageData. Spec: the // destination is replaced exactly with the premultiplied source value -- // it MUST NOT source-over composite over the red background. - let mut img_pixels = vec![0u8; 1 * 1 * 4]; + let mut img_pixels = vec![0u8; 4]; img_pixels[0] = 0; // blue, alpha 128 (straight) img_pixels[1] = 0; img_pixels[2] = 255; From ac63f30949b4416e39cbff3a9abda12abeb00c21 Mon Sep 17 00:00:00 2001 From: BibekPathak Date: Tue, 8 Sep 2026 11:02:11 +0530 Subject: [PATCH 11/12] apply_global_alpha scales only the alpha channel --- .../src/context_bootstrap/canvas/context2d.rs | 12 ++++++++--- .../src/script_vm/tests/canvas_arguments.rs | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs b/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs index 0727dd82f..72ed80246 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs @@ -1490,12 +1490,18 @@ fn recording_stroke_color<'s>( apply_global_alpha(rgba, context_global_alpha(scope, context)) } +/// Applies `globalAlpha` to a **straight** (non-premultiplied) RGBA8 color. +/// +/// Per the spec, `globalAlpha` multiplies the alpha component only; the straight +/// RGB channels are left unchanged. Scaling RGB here would darken the color +/// before Vello premultiplies it internally, so a red path at `globalAlpha=0.5` +/// would read back as `[128,0,0,128]` instead of `[255,0,0,128]`. fn apply_global_alpha(rgba: [u8; 4], global_alpha: f64) -> [u8; 4] { let a = global_alpha.clamp(0.0, 1.0); [ - (f64::from(rgba[0]) * a + 0.5) as u8, - (f64::from(rgba[1]) * a + 0.5) as u8, - (f64::from(rgba[2]) * a + 0.5) as u8, + rgba[0], + rgba[1], + rgba[2], (f64::from(rgba[3]) * a + 0.5) as u8, ] } diff --git a/moli-renderer-v8/src/script_vm/tests/canvas_arguments.rs b/moli-renderer-v8/src/script_vm/tests/canvas_arguments.rs index d92619085..963b00e2b 100644 --- a/moli-renderer-v8/src/script_vm/tests/canvas_arguments.rs +++ b/moli-renderer-v8/src/script_vm/tests/canvas_arguments.rs @@ -175,3 +175,24 @@ fn canvas_hex_alpha_is_accepted_and_preserved_for_fill_and_stroke() { ); } } + +#[test] +fn canvas_global_alpha_scales_only_alpha_not_rgb_for_fill_stroke_stroke_rect() { + // A straight red path at globalAlpha 0.5 must keep RGB at (255,0,0) and + // halve only the alpha. Previously the RGB channels were also scaled, + // reading back [128,0,0,128] instead of [255,0,0,128]. + check( + r#" + ctx.fillStyle='red';ctx.strokeStyle='red';ctx.globalAlpha=0.5; + ctx.rect(0,0,10,10);ctx.fill();ctx.beginPath(); + ctx.lineWidth=2;ctx.moveTo(20,20);ctx.lineTo(40,20);ctx.stroke(); + ctx.strokeRect(60,5,20,20); + return JSON.stringify([ + Array.from(ctx.getImageData(5,5,1,1).data), + Array.from(ctx.getImageData(25,20,1,1).data), + Array.from(ctx.getImageData(60,12,1,1).data) + ]); + "#, + "[[255,0,0,128],[255,0,0,128],[255,0,0,128]]", + ); +} From 9aa7896cec800bf8c788ca0e3bdc94d0643224c7 Mon Sep 17 00:00:00 2001 From: BibekPathak Date: Wed, 9 Sep 2026 12:41:12 +0530 Subject: [PATCH 12/12] readback_region, JS re-entry, putImageData clipping, record-time invalidation --- .../context_bootstrap/canvas/backing_store.rs | 51 ++++++++++--- .../src/context_bootstrap/canvas/context2d.rs | 72 ++++++++++++------- .../canvas/recording_store.rs | 22 +++--- .../context_host/canvas_resources.rs | 19 +++-- 4 files changed, 114 insertions(+), 50 deletions(-) diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs b/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs index bf4bc1e1d..4f50f0da5 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs @@ -159,6 +159,29 @@ pub(super) fn canvas_like_pixels_copy<'s>( Some((snapshot.rgba.clone(), snapshot.width, snapshot.height)) } +/// Reads a rectangular region from the canvas as straight RGBA8. Only the +/// requested intersection is read, so a 1×1 read from a 2048×2048 canvas +/// allocates only 4 bytes instead of 16 MiB. Out-of-canvas regions are filled +/// transparent (matching `getImageData` semantics). +pub(super) fn canvas_like_region_readback<'s>( + scope: &mut v8::PinScope<'s, '_>, + canvas: v8::Local<'s, v8::Object>, + x: i32, + y: i32, + width: u32, + height: u32, +) -> Option> { + flush_canvas_recording(scope, canvas); + let (canvas_width, canvas_height) = canvas_like_dimensions(scope, canvas)?; + let cell = canvas_surface_cell(scope, canvas); + if !materialize_surface(&cell, canvas_width, canvas_height) { + return None; + } + let surface = cell.borrow(); + let surface = surface.as_ref()?; + Some(surface.readback_region(x, y, width, height)) +} + /// Flushes any pending recording for `canvas` against its surface, then publishes /// the snapshot. This must be called before any pixel observation (getImageData, /// toDataURL, drawImage from canvas source, page painting, screencast). @@ -171,22 +194,28 @@ pub(super) fn flush_canvas_recording<'s>( }; let recording = super::recording_store::canvas_recording_state(scope, context); { - let mut rec = recording.borrow_mut(); + let rec = recording.borrow(); if rec.is_empty() { return; } - let (width, height) = match canvas_like_dimensions(scope, canvas) { - Some(dims) => dims, - None => { - rec.clear(); - return; - } - }; - let cell = canvas_surface_cell(scope, canvas); - if !materialize_surface(&cell, width, height) { - rec.clear(); + } + // Release the recording borrow before calling canvas_like_dimensions() which + // may read JS properties and re-enter user code. A width getter that calls + // ctx.fillRect() once would trigger "RefCell already borrowed" and abort. + let (width, height) = match canvas_like_dimensions(scope, canvas) { + Some(dims) => dims, + None => { + recording.borrow_mut().clear(); return; } + }; + let cell = canvas_surface_cell(scope, canvas); + if !materialize_surface(&cell, width, height) { + recording.borrow_mut().clear(); + return; + } + { + let mut rec = recording.borrow_mut(); let mut surface = cell.borrow_mut(); let Some(surface) = surface.as_mut() else { rec.clear(); diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs b/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs index 72ed80246..1ff820927 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs @@ -1,5 +1,6 @@ use super::backing_store::{ - bump_canvas_visual_generation, canvas_like_pixels_copy, canvas_owner_from_context, + bump_canvas_visual_generation, canvas_like_pixels_copy, canvas_like_region_readback, + canvas_owner_from_context, }; use super::helpers::{canonical_canvas_fill_style, canvas_unrestricted_double_arg}; use super::recording_store::canvas_recording_state; @@ -14,7 +15,7 @@ use crate::util::{get_private_value, set_private_value}; use crate::webidl; use moli_canvas::{ DEFAULT_FILL_STYLE, DEFAULT_FONT, DrawImageBlit, ScaleFilter, StrokeSpec, byte_len, - data_image_rgba8_pixels, extract_image_data, fill_style_rgba, measure_text_width, + data_image_rgba8_pixels, fill_style_rgba, measure_text_width, normalize_rect as canvas_normalize_rect, }; use moli_webapi_declare::WebApiObject; @@ -1900,13 +1901,35 @@ pub(crate) fn canvas_context_put_image_data_callback<'s>( return; }; + if Some(bytes.len()) != byte_len(source_width, source_height) { + webidl::throw_index_size_error(scope); + return; + } + let (dirty_x, dirty_y, dirty_width, dirty_height) = if args.length() >= 7 { dirty_rect.unwrap_or((0, 0, 0, 0)) } else { (0, 0, source_width as i32, source_height as i32) }; - // Clip the source to the dirty rect before recording. + // Clip the dirty rect to the source bounds before recording. + let sx = dirty_x.max(0).min(source_width as i32); + let sy = dirty_y.max(0).min(source_height as i32); + let ex = dirty_x + .max(0) + .saturating_add(dirty_width.max(0)) + .min(source_width as i32) + .max(sx); + let ey = dirty_y + .max(0) + .saturating_add(dirty_height.max(0)) + .min(source_height as i32) + .max(sy); + let clipped_width = (ex - sx) as u32; + let clipped_height = (ey - sy) as u32; + if clipped_width == 0 || clipped_height == 0 { + return; + } let clipped = clip_image_data_to_dirty( &bytes, source_width, @@ -1916,11 +1939,6 @@ pub(crate) fn canvas_context_put_image_data_callback<'s>( dirty_width, dirty_height, ); - let clipped_width = dirty_width.max(0) as u32; - let clipped_height = dirty_height.max(0) as u32; - if clipped_width == 0 || clipped_height == 0 { - return; - } let source_image = moli_image::RgbaImage { width: clipped_width, height: clipped_height, @@ -1958,18 +1976,7 @@ pub(crate) fn canvas_context_get_image_data_callback<'s>( let source_x = parsed.sx; let source_y = parsed.sy; let bytes = if let Some(canvas) = canvas_owner_from_context(scope, args.this()) { - canvas_like_pixels_copy(scope, canvas) - .map(|(pixels, canvas_width, canvas_height)| { - extract_image_data( - &pixels, - canvas_width, - canvas_height, - source_x, - source_y, - width, - height, - ) - }) + canvas_like_region_readback(scope, canvas, source_x, source_y, width, height) .unwrap_or_else(|| blank_image_data(width, height)) } else { blank_image_data(width, height) @@ -2109,7 +2116,8 @@ fn blank_image_data(width: u32, height: u32) -> Vec { vec![0; byte_len(width, height).unwrap_or(0)] } -/// Clips ImageData bytes to the dirty rect, returning the clipped RGBA8 bytes. +/// Clips ImageData bytes to the dirty rect intersected with the source bounds, +/// returning the clipped RGBA8 bytes. fn clip_image_data_to_dirty( bytes: &[u8], source_width: u32, @@ -2123,13 +2131,23 @@ fn clip_image_data_to_dirty( let src_h = source_height as i32; let sx = dirty_x.max(0).min(src_w); let sy = dirty_y.max(0).min(src_h); - let ex = (dirty_x + dirty_width).max(0).min(src_w); - let ey = (dirty_y + dirty_height).max(0).min(src_h); - let w = (ex - sx).max(0) as usize; - let h = (ey - sy).max(0) as usize; + let left = sx; + let top = sy; + let right = (dirty_x.max(0)) + .checked_add(dirty_width.max(0)) + .unwrap_or(src_w) + .min(src_w) + .max(left); + let bottom = (dirty_y.max(0)) + .checked_add(dirty_height.max(0)) + .unwrap_or(src_h) + .min(src_h) + .max(top); + let w = (right - left).max(0) as usize; + let h = (bottom - top).max(0) as usize; let mut out = Vec::with_capacity(w * h * 4); - for row in sy as usize..sy as usize + h { - let offset = (row * source_width as usize + sx as usize) * 4; + for row in top as usize..top as usize + h { + let offset = (row * source_width as usize + left as usize) * 4; let end = offset + w * 4; if end <= bytes.len() { out.extend_from_slice(&bytes[offset..end]); diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/recording_store.rs b/moli-renderer-v8/src/context_bootstrap/canvas/recording_store.rs index 2d1be3a68..30f38a95b 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/recording_store.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/recording_store.rs @@ -119,37 +119,43 @@ pub(crate) fn flush_all_recordings<'s>(scope: &mut v8::PinScope<'s, '_>) { }; (context_obj, entry.recording.clone()) }; - let mut rec = recording.borrow_mut(); - if rec.is_empty() { - continue; + // Check emptiness and release the recording borrow before calling + // canvas_like_dimensions(), which reads JS-visible width/height + // properties. A width getter that calls ctx.fillRect() once would + // trigger "RefCell already borrowed" and abort. + { + let rec = recording.borrow(); + if rec.is_empty() { + continue; + } } let Some(canvas) = super::backing_store::canvas_owner_from_context(scope, context_obj) else { - rec.clear(); + recording.borrow_mut().clear(); continue; }; let (width, height) = match super::backing_store::canvas_like_dimensions(scope, canvas) { Some(dims) => dims, None => { - rec.clear(); + recording.borrow_mut().clear(); continue; } }; let cell = super::backing_store::canvas_surface_cell(scope, canvas); if !super::backing_store::materialize_surface(&cell, width, height) { - rec.clear(); + recording.borrow_mut().clear(); continue; } { + let mut rec = recording.borrow_mut(); let mut surface = cell.borrow_mut(); let Some(surface) = surface.as_mut() else { rec.clear(); continue; }; let _ = rec.execute(surface); + rec.clear(); } - rec.clear(); - drop(rec); super::backing_store::publish_canvas_snapshot(scope, canvas); } } diff --git a/moli-renderer-v8/src/native_bridge/context_host/canvas_resources.rs b/moli-renderer-v8/src/native_bridge/context_host/canvas_resources.rs index 6b29b5844..5adc87724 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/canvas_resources.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/canvas_resources.rs @@ -61,10 +61,8 @@ impl CanvasResourceStore { self.pixels_by_element.keys().copied() } - fn touch(&mut self, element: DomHandle) { - if self.pixels_by_element.contains_key(&element) { - self.visual_generation.bump(); - } + fn touch(&mut self, _element: DomHandle) { + self.visual_generation.bump(); } } @@ -145,4 +143,17 @@ mod tests { assert_eq!(store.retained_bytes, 0); assert!(!store.remove(element)); } + + #[test] + fn touch_advances_generation_for_unseen_canvas() { + let mut store = CanvasResourceStore::default(); + let before = store.visual_generation.current(); + let element = DomHandle::new(99); + store.touch(element); + let after = store.visual_generation.current(); + assert!( + after > before, + "touch() must bump generation even when canvas has no published snapshot" + ); + } }