feat(canvas): rasterize 2D on a render thread by default - #165
Merged
Merged
Conversation
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.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
2D canvases now rasterize on a shared render thread by default. JS calls stay synchronous: each call records into an
SkPictureon the JS thread, and once per frame the frame is handed to thensc-2d-renderthread, which replays it onto the canvas's real GPU context and presents. Opt out per canvas withgetContext('2d', { threaded: false }), or everywhere withCanvas.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:
GrAAConvexTessellatorfor rotated strokes, path allocation, the Mali driver). Only about 0.7 ms of that waseglSwapBuffers.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):Design
Recording (
crates/canvas-2d/src/context/recording.rs)Frameis an ordered list of pictures,putImageDatawrites (which must bypass the clip and transform), and external images (video frames).clearRectdrops everything recorded earlier in the frame.Render thread (
crates/canvas-c/src/c2d/render_thread.rs)CanvasRenderingContext2D(same GL and Metal code), created on the thread.commitnever blocks: while the thread is behind, new frames merge into the one it hasn't started.getImageData,toDataURL,drawImage(canvas),texImage2D(canvas)and the WebGPU copy all go throughCanvasRenderingContext2D::with_pixels_sourceand wait on the thread.Android
nativeCreate2DContextThreadedcreates the context.HardwareBuffers. They are imported as EGLImages on the render thread and closed after an EGL fence (utils/gl/hardware_buffer_2d.rs,VideoHelperbackend 5).iOS
CAMetalLayer, device and queue, captured on the main thread, and presents through Rust, never[NSCMTLView present].canvas_native_ios_context_draw_external_metal_texture, which releases theCVMetalTextureafter a marker command buffer completes.Windows
canvas_native_context_create_d3d_threadedcreates the real context on the render thread's Direct3D 12 device. The render thread keeps its contexts boxed, soregister_d3d()gets their final address. If creation fails, the host falls back to unthreaded D3D, then CPU.SetSwapChain.SurfaceImageSources: every alpha canvas presents into one.SetDeviceruns on the UI thread.ISurfaceImageSourceNativeWithD2D, then posts to a message-only window on the UI thread. That window callsEndDraw, which must run there.crates/canvas-c/src/c2d/d3d.rs).Also fixed
putImageDatawith a dirty rect now follows the spec: it previously read the wrong rows.ctx.reset()now drops clips and saves.Testing
make test, plus newcrates/canvas-2d/tests/recording.rsandcrates/canvas-c/tests/threaded_2d.rs. The recording tests check pixel-identical output against direct drawing, and were mutation-checked for clip, matrix,putImageDataand external-draw carry-over.On device: video into a threaded canvas, retained contents (the
only:trailscene), and screenshots matching direct mode, on the A53 and the iOS simulator.Windows host:
crates/canvas-c/tests/threaded_d3d.rschecks that threaded output is identical to direct D3D, presenting and resizing in a panel, and drawing a shared D3D11 video frame.threaded_d3d_lost.rschecks 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:
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.