Skip to content

wasm: harden the GPU surface and renderer - #17

Merged
albertwoo merged 1 commit into
mainfrom
investigate/wasm-gpu-fixes
Nov 11, 2026
Merged

albertwoo merged 1 commit into
mainfrom
investigate/wasm-gpu-fixes

Conversation

@albertwoo

Copy link
Copy Markdown
Contributor

wasm: harden the GPU surface and renderer

Follow-up to #16, applying its review findings. Stacked on investigate/wasm-gpu so the diff here is only the fixes.

Texture-path bugs (browser-only, would corrupt or crash on real pages)

  • updateTexture over-read the atlas (main.js): the view was built as stride * h, but CUI hands the callback a pointer to the first pixel of the damaged rect (cui_text.c:880 passes atlas + y0 * atlas_w + x0), so the span ends at the last pixel of the last row: (h - 1) * stride + w * fmt. The old math read up to x * fmt bytes past the end of the atlas for any rect not flush left — and threw RangeError outright when the source ended near the top of the wasm heap. Same formula CUI's own runtime.js uses.
  • createTexture uploaded wasm address 0 when CUI creates an empty texture with a null pixel pointer (a fresh emoji atlas does exactly that, cui_emoji.c:714), which would have uploaded the heap's first bytes as pixels. Null now stays null, as in runtime.js:83.

Pixel-size agreement at fractional display scales

  • CuiSurface.PixelWidth/PixelHeight truncated while CUI rounds width_px = vw * scale + 0.5f (cui_core.c:849); the JS resize() truncated too. At devicePixelRatio 1.5 with an odd CSS width the canvas and the frame disagreed by a pixel, and GLRenderer.draw threw every frame. C# and JS now round the way CUI does, and GLRenderer.draw follows the draw data for the backing store instead of refusing the frame (same behavior as CUI's preview.js:192).

Surface contract

  • GetDrawData now throws ObjectDisposedException: cui_get_draw_data returns &ui->draw_data, so a destroyed context yields a plausible pointer into freed memory, not null.
  • Restored BeginFrame's <summary>, which CreateGpu had clobbered when inserted before it.
  • Incremental now rejects a GPU surface even when assigning the value it already has.
  • CreateGpu names the offending parameter in its ArgumentException.

Test/CI gaps closed

  • New GpuSurfaceTests (7 tests, desktop CI): font-atlas upload on first text, in-place texture_update when new glyphs appear, draw-data disposal guard, software-only member guards, missing-callback rejection, software surface Kind, and the 1.5-scale pixel rounding.
  • The scratch-canvas webgl2 probe in main.js is gone — it spent the one attempt the retry loop existed to repeat, so under --disable-gpu a slow GPU-process start silently chose software forever. The retry now asks the real canvas.
  • test-browser-smoke.py takes --renderer webgl2|2d and asserts the renderer the page reports, so a silent software fallback can no longer pass as the GPU path. The Pages workflow smoke-tests both renderers, gains a pull_request trigger (browser regressions caught before merge), and keeps the deploy jobs gated to main.
  • GLRenderer deletes its shader objects after linking (as cui_render_gl.c does), takes keepDrawingBuffer explicitly, and the smoke-state machine drops a dead assignment.

Verification

  • 697 + 28 tests, build with warnings-as-errors, dotnet format --verify-no-changes, and regen -NoNative -NoDiff all green.
  • test-browser-smoke.py --renderer webgl2 and --renderer 2d both pass with the new renderer assertion active.
  • Live headless Chrome: clicking the name field, selecting all, and typing glyphs missing from the atlas (∑∫€§Ω≈√∀∃) produced two texSubImage2D calls through the corrected span with no exception, and the rendered field updated.

Review of the WebGL2 integration found real bugs in the texture path and
pixel-size rounding, plus gaps in what the smoke test could prove.

Texture path:
- updateTexture built its source view as stride * h, but CUI hands the
  pointer to the first pixel of the damaged rect (cui_text.c:880), so the
  span ends at the last pixel of the last row: (h - 1) * stride + w * fmt.
  The old math read up to x * fmt bytes past the end of the atlas when the
  rect was not flush left, and threw outright when the source ended near
  the top of the wasm heap. Same formula CUI's runtime.js uses.
- createTexture passed wasm address 0 to texImage2D when CUI creates an
  empty texture with a null pixel pointer (a fresh emoji atlas does), which
  uploaded the heap's first bytes as pixels instead of leaving it empty.

Pixel size:
- PixelWidth/PixelHeight truncated while CUI rounds (vw * scale + 0.5f), so
  a fractional display scale (1.5) disagreed by a pixel and the renderer
  refused the frame. Both sides now round, and GLRenderer follows the draw
  data for the canvas backing store instead of throwing when they drift.
- resize() rounds the same way.

Surface contract:
- GetDrawData now throws ObjectDisposedException: cui_get_draw_data returns
  &ui->draw_data, so a destroyed context yields a plausible pointer into a
  freed struct rather than null. BeginFrame's summary, clobbered when
  CreateGpu was inserted, is restored; Incremental now rejects a GPU surface
  even when the value is unchanged.

Host and test:
- The scratch-canvas webgl2 probe is gone: it spent the one attempt the
  retry loop existed to repeat and under --disable-gpu could silently turn
  the whole fallback decision. The retry now asks the real canvas.
- test-browser-smoke.py asserts the renderer the page reports (--renderer
  webgl2|2d), so a silent software fallback can no longer pass as the GPU
  path, and the Pages workflow smoke-tests both renderers and gains a
  pull_request trigger with the deploy jobs gated to main.
- New GpuSurfaceTests pin the contract on desktop CI: atlas upload on first
  text, in-place update for new glyphs, draw-data lifetime after dispose,
  the software-only guards, callback validation, and the 1.5-scale pixel
  rounding.

Verified: 697 + 28 tests and format green; both smoke paths pass with the
renderer assertion active; a live headless run typed glyphs missing from
the atlas and observed two texSubImage2D calls with no exception.
Base automatically changed from investigate/wasm-gpu to main November 11, 2026 11:25
@albertwoo
albertwoo merged commit 6a09ec7 into main Nov 11, 2026
24 checks passed
@albertwoo
albertwoo deleted the investigate/wasm-gpu-fixes branch November 11, 2026 11:26
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