Skip to content

feat(canvas): rasterize 2D on a render thread by default - #165

Merged
triniwiz merged 3 commits into
masterfrom
perf/frame-pacing
Sep 29, 2026
Merged

triniwiz merged 3 commits into
masterfrom
perf/frame-pacing

Conversation

@triniwiz

@triniwiz triniwiz commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

What

2D canvases now rasterize on a shared render thread by default. JS calls stay synchronous: each call records into an SkPicture on the JS thread, and once per frame the frame is handed to the nsc-2d-render thread, which replays it onto the canvas's real GPU context and presents. Opt out per canvas with getContext('2d', { threaded: false }), or everywhere with Canvas.threaded2D = false.

Android, iOS and Windows are done.

Why

On a busy screen, 2D was the bottleneck: after the JS draw, Skia's flush ran on the main thread and took most of the frame. Profiled on a Galaxy A53 (120 Hz) with one canvas drawing 300 rotated rects plus text:

  • The JS draw took about 1.3 ms.
  • The flush took about 6.3 ms of main-thread CPU (GrAAConvexTessellator for rotated strokes, path allocation, the Mali driver). Only about 0.7 ms of that was eglSwapBuffers.

The main thread had no room left, so the canvas and every view animating next to it dropped frames. WebGL was not affected.

Results

Galaxy A53, 120 Hz, median of 3 interleaved runs (tools/scripts/busy-bench.sh):

Scenario Direct Threaded
1 canvas 102 fps, 77% janky UI frames 119.7 fps, 2.8%
1 canvas + 28 animating views 79 fps, 84% 119.4 fps, 3.7%
2 canvases + 28 views 48 fps, 99% 119.1 fps, 2.7%
  • The per-frame hand-off costs 0.19 ms on the main thread, down from 6.3 ms.
  • On this deliberately heavy scene the render thread itself saturates: the canvas updates at about 85/s for one canvas and about 34/s each for two. Frames are merged rather than blocking the main thread, so the UI stays at 120.
  • iOS simulator: the swarm demo runs at 59 fps and 25% CPU, against 30 fps and 98% for a Sep 28 direct-mode build. With two canvases, main-thread time goes from 51% to 36% busy, with no flush on it.
  • No Windows numbers yet: the busy benchmark doesn't draw on Windows in either mode.

Design

Recording (crates/canvas-2d/src/context/recording.rs)

  • A Frame is an ordered list of pictures, putImageData writes (which must bypass the clip and transform), and external images (video frames).
  • The transform and clip only live on the Skia canvas, so each picture re-applies them: device-space clips per save level, plus each level's matrix.
  • A full-canvas clearRect drops everything recorded earlier in the frame.
  • Asset bytes are copied while recording.

Render thread (crates/canvas-c/src/c2d/render_thread.rs)

  • It hosts the existing CanvasRenderingContext2D (same GL and Metal code), created on the thread.
  • commit never blocks: while the thread is behind, new frames merge into the one it hasn't started.
  • The thread never waits on the JS thread. That is what makes readbacks safe: getImageData, toDataURL, drawImage(canvas), texImage2D(canvas) and the WebGPU copy all go through CanvasRenderingContext2D::with_pixels_source and wait on the thread.

Android

  • nativeCreate2DContextThreaded creates the context.
  • Surface updates are posted to the render thread; detach runs on it synchronously.
  • Canvas contents are now kept across swaps: GL window contexts draw into an offscreen texture that is copied to the window at present. This was previously broken in both modes: a canvas that never clears showed only the latest frame.
  • Video frames reach a threaded canvas as HardwareBuffers. They are imported as EGLImages on the render thread and closed after an EGL fence (utils/gl/hardware_buffer_2d.rs, VideoHelper backend 5).

iOS

  • The render thread gets the CAMetalLayer, device and queue, captured on the main thread, and presents through Rust, never [NSCMTLView present].
  • It drains an autorelease pool per batch; without one the layer runs out of drawables.
  • Video uses canvas_native_ios_context_draw_external_metal_texture, which releases the CVMetalTexture after a marker command buffer completes.

Windows

  • canvas_native_context_create_d3d_threaded creates the real context on the render thread's Direct3D 12 device. The render thread keeps its contexts boxed, so register_d3d() gets their final address. If creation fails, the host falls back to unthreaded D3D, then CPU.
  • Swapchain panels: the render thread creates the swapchain, and the UI thread calls SetSwapChain.
  • XAML SurfaceImageSources: every alpha canvas presents into one.
    • SetDevice runs on the UI thread.
    • The render thread begins, draws and suspends through ISurfaceImageSourceNativeWithD2D, then posts to a message-only window on the UI thread. That window calls EndDraw, which must run there.
    • The render thread never waits on the UI thread.
  • Presents the display wasn't ready for are retried on the render thread.
  • Device loss: the render thread flags lost contexts. Restore first releases, from the UI thread, whatever still shows a lost canvas; otherwise the removed device stays alive and no new one can be made. It then shows the canvas again and resets the recorder's state.
  • Video: canvas-media's shared frame is opened on the rasterizing device and waited on with its ready fence. It is released with its release fence once Skia submits (crates/canvas-c/src/c2d/d3d.rs).

Also fixed

  • putImageData with a dirty rect now follows the spec: it previously read the wrong rows.
  • ctx.reset() now drops clips and saves.

Testing

  • Host: make test, plus new crates/canvas-2d/tests/recording.rs and crates/canvas-c/tests/threaded_2d.rs. The recording tests check pixel-identical output against direct drawing, and were mutation-checked for clip, matrix, putImageData and external-draw carry-over.
  • canvas-spec, threaded as the default:
Group A53 iOS simulator
2d 180/180 180/180
imagebitmap 18/18 18/18
bitmaprenderer 15/15 15/15
canvassource 9/9 7/9 (same 2 WebGL-source failures with threading off)
lifecycle, scaling same failures as master 10/10 and 6/6
  • On device: video into a threaded canvas, retained contents (the only:trail scene), and screenshots matching direct mode, on the A53 and the iOS simulator.

  • Windows host:

    • crates/canvas-c/tests/threaded_d3d.rs checks that threaded output is identical to direct D3D, presenting and resizing in a panel, and drawing a shared D3D11 video frame.
    • threaded_d3d_lost.rs checks device removal and restore. It is a separate test binary because removing the device affects the whole process.
  • canvas-spec on Windows, threaded as the default:

    • 345/349; the 4 failures are WebGPU video.
    • contextlost, which runs alone: 2/2.
    • drawImage(video) passes on the zero-copy path.
  • On screen (Windows): the clock demo keeps updating through the XAML surface.

2D calls now record into pictures on the JS thread; a shared render thread replays them onto
the real GPU context and presents. On a busy screen Skia's flush took ~6 ms of main-thread CPU
per frame and starved every view animating beside the canvas. Opt out with
getContext('2d', { threaded: false }) or Canvas.threaded2D = false.

- Android and iOS: threaded creation, surface updates, readbacks routed to the render thread.
- Android: 2D keeps its contents across swaps (offscreen surface copied to the window).
- Video into a threaded canvas: HardwareBuffer (Android) and CVMetalTexture (iOS), zero-copy.
- putImageData honours the dirty rect; reset() drops clips and saves.
- Busy-screen benchmark: canvas-busy page, busy-bench.sh, busy-trace.sh.
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 1f363cda-e286-44f9-be1f-4ef1cb40e3a8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Windows now takes the threaded 2D path by default too (Canvas.threaded2D = true): the real
context lives on the shared render thread's Direct3D 12 device, which presents; the JS thread
only records. The render thread never waits on the UI thread.

- Creation: canvas_native_context_create_d3d_threaded; the render thread keeps contexts boxed,
  so register_d3d() gets their final address. Falls back to unthreaded D3D, then CPU.
- Swapchain panels: the swapchain is made on the render thread and bound on the UI thread.
- XAML SurfaceImageSources (every alpha canvas): XamlHandoff. The render thread begins, draws
  and suspends through ISurfaceImageSourceNativeWithD2D and posts the UI thread's message-only
  window, which ends the draw (EndDraw must run on the UI thread). SetDevice stays there too.
- Presents the display was not ready for are retried on the render thread.
- Device loss: lost flags from the render thread; restore releases what still shows lost
  canvases from the UI thread, then shows the canvas again and resets the recorder's state.
  __simulateD3DDeviceRemoval and __d3dAdapterInfo cover the render thread's device.
- drawImage(video): canvas-media's shared frame is opened on the rasterizing device, waited on
  (ready fence) and given back after Skia submits (release fence), zero-copy.
- Tests: threaded_d3d (panel, resize, shared video frame) and threaded_d3d_lost.
@triniwiz
triniwiz merged commit b167b80 into master Sep 29, 2026
12 of 21 checks passed
triniwiz added a commit that referenced this pull request Sep 29, 2026
resize_gl read GL_FRAMEBUFFER_BINDING after a Skia flush to find the window's framebuffer. Since
2D on Android draws into an offscreen target (#165), that binding is Skia's offscreen framebuffer,
so the re-created "window" surface wrapped the offscreen and every present copied it onto itself.
Any canvas that was resized (all of them, once sized to their layout) stayed blank, threaded or not.

Take the framebuffer from the existing window surface instead; fall back to the binding when 2D
does not present through a window.
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.

1 participant