Skip to content

feat(canvas): replace Canvas 2D with a native recording-backed architecture - #414

Open
BibekPathak wants to merge 12 commits into
lexmount:mainfrom
BibekPathak:canvas_2D_moli
Open

feat(canvas): replace Canvas 2D with a native recording-backed architecture#414
BibekPathak wants to merge 12 commits into
lexmount:mainfrom
BibekPathak:canvas_2D_moli

Conversation

@BibekPathak

Copy link
Copy Markdown
Contributor

Deliver the complete Chromium-style Canvas 2D re-architecture for Moli,
replacing the scattered pixel-ownership model with a native, recording-backed
core in moli-canvas. This is the full M0–M6 migration: baseline, geometry
extraction, surface/backend, single-owner integration, ordered recording,
observation/invalidation, and removal of the old implementation.

Crate architecture

  • moli-canvas becomes the browser-independent native core: drawing state,
    current-path geometry, ordered recording, persistent premultiplied-RGBA8
    surface, region readback, and encoding/export helpers. It depends only on
    AnyRender/Vello-CPU/peniko/kurbo/mol-image; it never touches V8, the DOM,
    layout, or page paint.
  • moli-renderer-v8 remains the browser adapter (WebIDL, JS identity,
    invalidation, page-image publication, lifecycle). moli-paint responsibility
    for page painting is unchanged.

Milestones

  • baseline cost model (per-draw full-plane copy is O(canvas area)) and the
    routing/ownership inventory used as the final-review checklist.
  • native CanvasPath + per-context path state in moli-canvas; renderer
    adapter reduced.
  • CanvasSurface (premultiplied RGBA8, lazy materialization, reusable
    Vello CPU backend, cached straight-alpha snapshot, region readback,
    reset/resize) with native surface/backend tests.
  • a single weak-keyed native surface replaces the mutable V8
    Uint8ClampedArray backing store; GC/isolation reclamation, resize, and all
    115 canvas JS regressions pass.
  • ordered DrawRecording captures every ordinary draw op (path fill/stroke,
    rect fill/stroke, clear, drawImage, text, putImageData) with frozen inputs,
    batching contiguous scene ops into one backend submission while preserving
    order through direct-step segments for clears/blits/writes. Integrated into
    the renderer; dead rasterize/composite/color helpers removed.
  • consistent flush-before-read on all pixel observations, snapshot-cache
    invalidation on every write path, and VisualResourceGeneration bumped on
    every draw record (not just at flush) so screenshots/screencast see current
    content and geometry-only ops neither flush nor invalidate.
  • remove the full-surface unpremultiply/premultiply round-trip from all
    four direct ops by compositing in premultiplied space (O(operation area) not
    O(canvas area)); delete CanvasSurface::with_straight_pixels_mut; verify
    same-size reset; declare STUBs out of scope.

Correctness hardening

  • putImageData now does a true raw overwrite (premultiplied straight-alpha
    replacement) instead of an accidental source-over composite, matching the
    spec even for translucent ImageData over existing content.
  • Add a pending-recording resource budget: after a draw, flush early when
    recorded bytes (including pinned source images) exceed the limit, mirroring
    Chromium's FlushIfRecordingLimitExceeded.

Checks

  • cargo fmt --all, clippy (moli-canvas, moli-renderer-v8) with -D warnings, and
    the moli-canvas native suite (45 tests) pass.

@BibekPathak

Copy link
Copy Markdown
Contributor Author

Hey @ldm0 , look at this!!

@ldm0
ldm0 self-requested a review September 7, 2026 13:04
apply_global_alpha(rgba, context_global_alpha(scope, context))
}

fn apply_global_alpha(rgba: [u8; 4], global_alpha: f64) -> [u8; 4] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apply globalAlpha only to the alpha channel of this straight-RGBA color. Scaling RGB here causes Vello to premultiply already-darkened channels. A red path with globalAlpha = 0.5 reads back as [128,0,0,128], whereas its straight RGB should remain red, approximately [255,0,0,128]. This affects fill, stroke, and strokeRect. Please add a regression that checks RGB as well as alpha.

let mut surface = cell.borrow_mut();
let surface = surface.as_mut()?;
let snapshot = surface.snapshot().ok()?;
Some((snapshot.rgba.clone(), snapshot.width, snapshot.height))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please route getImageData through readback_region() API. Its callback still calls this helper and then extracts the requested rectangle, so every clean 1×1 read from a 2048×2048 Canvas copies 16 MiB. The new readback_region() remains unused by the browser. Flush pending commands and read the requested region without forcing full-image snapshot publication. This is an explicit performance acceptance criterion in the migration plan.

if rec.is_empty() {
return;
}
let (width, height) = match canvas_like_dimensions(scope, canvas) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid JS re-entry while holding the recording borrow. canvas_like_dimensions() reads JS-visible width/height properties here. A width getter that calls ctx.fillRect() once triggers RefCell already borrowed and aborts the process with exit code 134;

I reproduced this on the current HEAD with a recursion guard. The same pattern exists in flush_all_recordings(). Flush must obtain dimensions from native Canvas state without executing page JavaScript.

dirty_width,
dirty_height,
);
let clipped_width = dirty_width.max(0) as u32;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Store the actual clipped dimensions with the clipped pixels. The pixel buffer is intersected with the source bounds, but these dimensions still describe the original dirty rectangle. The resulting image fails the buffer-length check and the write is silently skipped. With a 1×1 red ImageData, putImageData(image, 0, 0, 0, 0, 2, 1) still leaves the destination transparent on the current HEAD.

}

fn touch(&mut self, element: DomHandle) {
if self.pixels_by_element.contains_key(&element) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make record-time invalidation independent of snapshot publication.

A new canvas is absent from pixels_by_element, so this guard skips the generation bump when its first fillRect() is recorded. Screencast currently compensates by flushing before comparing visual state, but the plan requires invalidation at record time. Please remove this dependency on an existing snapshot and add an integration test verifying that the generation advances after the first draw, before any flush or readback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants