Skip to content

Android: threaded WebGL, 2D render-thread backpressure, % sizes under MasonKit - #174

Merged
triniwiz merged 9 commits into
masterfrom
fix/android-layout-and-gl-thread
Sep 30, 2026
Merged

triniwiz merged 9 commits into
masterfrom
fix/android-layout-and-gl-thread

Conversation

@triniwiz

Copy link
Copy Markdown
Member

Summary

Fixes a set of Android problems found profiling the canvas starter on a Galaxy A53 (Mali-G68): pages that stalled the UI, a three.js canvas that rendered into 1 px, and a 2D canvas that lagged and could leak several GB of GPU memory until the app was killed.

Layout

  • % sizes go to MasonKit on Android and iOS (setParentPercentSize in Canvas/utils.ts), as the Windows host already did. A canvas defaults to 100%, which its layout params can't express, so under a MasonKit parent it was measured at its surface size while the surface followed its layout. The result: three.js canvases stuck at 1 px, and shader canvases that grew to the page's height and pushed their controls out of tap range. Needs MasonKit 1.0.0-beta.106 (Mason.setPercentWidth/Height).

WebGL on its own thread (Android, default on)

  • WebGL calls and the per-frame eglSwapBuffers ran on the JS thread, which on Android is the UI thread. It blocked whenever the GPU fell behind: the dashboard dropped to about 5 UI fps.
  • A threaded context is created, used, presented and dropped on one shared nsc-webgl thread. The canvas-c FFI reaches it through WebGLState::post (queued; borrowed data copied) and WebGLState::sync (anything that returns a value or reads JS memory or another canvas). Unthreaded contexts (iOS, offscreen) still run inline, so the V8 bindings are unchanged. getContext(..., { threaded: false }) opts out.
  • Extensions, Bitmap uploads, snapshots, 2D drawImage/createPattern and WebGPU copies from a WebGL canvas go through the context's thread. The Java video helpers run there via Utils.nativeRunWithWebGL.
  • Surface updates from view callbacks are queued rather than waited on (waiting there can deadlock the buffer queue). Detaching waits, skipping the presents queued for the window that's going away.
  • WebGL EGL contexts are created at low priority (EGL_IMG_context_priority), so UI rendering is scheduled first.

Pacing

  • A canvas is behind when it has a frame waiting behind one that hasn't reached the screen (a WebGL present, or a threaded 2D frame the render thread hasn't taken). While any canvas is behind, requestAnimationFrame holds its callbacks back a frame, as a browser does when its compositor falls behind. It reads Utils.canvasesBehind(), and the hold-back is installed with the first threaded context.
  • Fix: threaded 2D merged commits into the render thread's pending frame without limit whenever the canvas didn't fully clear (e.g. fading trails). Once the render thread fell behind, each replay got slower and it fell further behind, until GPU memory reached about 3 GB and lmkd killed the app. It now counts as behind, and commit waits once 4 frames have merged.

2D rendering (Android)

  • The offscreen is 4× multisampled, so Skia draws anti-aliased paths on the GPU instead of rasterizing coverage on the render thread and uploading it every frame.
  • Opaque surfaces are RGBA8888, not RGB565. At 5–6 bits a translucent fade never converges, which left a purple tint where Windows shows blue.

Vulkan

  • AshGraphics::drop now destroys the swapchain, image views and semaphores. A leaked swapchain kept the window connected, so canvas-svg's fallback to GL failed with EGL_BAD_ALLOC.

Results (Galaxy A53, canvas starter)

Page Before After
Home (2D flow field) 12–13 ms p50, 65–70% janky; random GPU leak and kill ~120 fps, 7–8 ms p50, ~1% janky; GL memory flat
Dashboard (WebGL, 2D, SVG, three.js) 24 UI frames / 5 s, p50 250 ms 341 / 5 s, p50 14 ms
Back navigation 16% janky ~5.5% janky

Testing

  • Android arm64: built and run on device (AARs built with -PcanvasAbis=arm64-v8a). Other ABIs not built.
  • iOS: make aarch64-apple-ios builds; not run on a device. iOS keeps the unthreaded path.
  • TS: tsc --noEmit -p packages/canvas clean.
  • Windows: not built.

AshGraphics::drop destroyed the surface, device and instance but left the
swapchain, its image views and the semaphores. A leaked swapchain keeps the
ANativeWindow connected to Vulkan, so a GL fallback on the same window
failed with EGL_BAD_ALLOC ("already connected to another API"), and a new
Vulkan surface on it failed too. device_wait_idle no longer panics in drop
after a device loss.
A canvas's default width/height is 100%, which its layout params can't
express, so under a MasonKit parent the canvas was measured to its surface
while the surface followed the layout: three.js canvases stuck at 1px and
shader canvases grew to the page's height. The Windows host already handed %
sizes to the parent's _setChildPercentSize; Android and iOS now do the same,
through a shared setParentPercentSize that also clears a % the size no
longer has.
With EGL_IMG_context_priority, the UI's own rendering is scheduled ahead of
a WebGL scene that saturates the GPU. 2D canvas contexts keep the default.
WebGL calls ran on the JS thread, which on Android is the UI thread, and the
per-frame present (eglSwapBuffers) blocked it whenever the GPU fell behind:
with a heavy scene on screen the UI dropped to a few frames a second.

A threaded context (the default, getContext(..., { threaded: false }) opts
out) is created, used, presented and dropped on one shared "nsc-webgl"
thread, like threaded 2D:

- The canvas-c FFI reaches the context through WebGLState::post/sync. Calls
  that return nothing are queued, copying any data they borrow; calls that
  return a value, read into the caller's memory or read another canvas run on
  the thread while the caller waits. An unthreaded context (iOS, offscreen)
  runs both inline, as before, so the V8 bindings are unchanged.
- The present is queued. A context with a frame queued behind an unpresented
  one counts as behind, and requestAnimationFrame is held back a frame while
  any is, rather than queueing more work; a context also waits once it has
  three presents queued.
- Surface updates from the view's callbacks are queued (blocking them can
  deadlock the buffer queue); detaching from a window waits, as the window
  goes away on return.
- Extensions, Bitmap uploads, snapshots, 2D drawImage/createPattern and
  WebGPU copies from a WebGL canvas go through the context's thread, and the
  Java GL helpers for video run there via Utils.nativeRunWithWebGL.
- Context creation waits for the thread, so the init natives are no longer
  @fastNative.
A threaded 2D canvas's commits merge into the frame the render thread hasn't
picked up yet, and a frame only replaces the pending one if it clears the
whole canvas. A canvas that fades rather than clears (trails) therefore grew
its pending frame every vsync once the render thread fell behind; each replay
got slower, so it fell further behind, until the GPU memory it used got the
app killed.

A canvas with a frame merged is now behind, which holds requestAnimationFrame
back (the counter WebGL uses, now canvas_native_canvases_behind), and commit
waits for the render thread once four frames have merged.
… a channel

Without MSAA, Skia rasterized every antialiased path's coverage on the render
thread and uploaded it each frame (glTexSubImage2D), which kept a busy canvas
from reaching the display rate. The offscreen is now 4x multisampled where the
GPU supports it.

Opaque canvases used RGB565, which the web never does: a translucent fill
can't converge on its colour at 5-6 bits, so faded trails left a permanent
tint. Opaque surfaces are now RGBA8888 marked opaque.
Letting go of the window waits for the WebGL thread; presents queued for a
window that is going away no longer each wait on the GPU first.
@coderabbitai

coderabbitai Bot commented Sep 30, 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: 4ffd3077-f037-4fcb-be4d-6a24c10ae772

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
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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.

RGBA8888 marked opaque still stored the alpha drawn into it, so an alpha:false
canvas read back transparent where nothing opaque had been drawn (a
bitmaprenderer compositing onto black), where RGB565 had read opaque. RGB888x
keeps 8 bits a colour and reads alpha as 255; a wrapped opaque framebuffer is
declared RGB8, which is what its config has and what Skia pairs with RGB888x.
@triniwiz triniwiz mentioned this pull request Sep 30, 2026
The requestAnimationFrame hold-back checked the count through a JNI call
every frame. The count is now exposed to JS as an ArrayBuffer over the
native counter, so the check is a memory read. It no longer depends on
Android, so the iOS host holds back threaded 2D frames too.

Regenerates canvas_native.h for the new export.
@triniwiz
triniwiz merged commit 63efe3c into master Sep 30, 2026
16 of 23 checks passed
@triniwiz
triniwiz deleted the fix/android-layout-and-gl-thread branch September 30, 2026 20:56
triniwiz added a commit that referenced this pull request Sep 30, 2026
* fix(canvas-core): require glutin 0.32.3

The Android GL context asks for a low priority with ContextAttributesBuilder::with_priority,
which glutin 0.32.0 does not have. A lockfile still on 0.32.0 fails to build canvas-core.

* chore: rebuild Android and Apple native libraries

canvas-release.aar and CanvasNative.xcframework, canvassvg-release.aar and CanvasSVG.xcframework,
rebuilt from f5424ba with the steps of build-native.yml, for #174, #175, #176 and #177.
canvas_native.h catches up with declarations already in the source.

* chore: 3.0.0-beta.0
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