From 0f10b319a06ee4e818c7d40befe8f1bc9153741c Mon Sep 17 00:00:00 2001 From: John Owens Date: Sun, 30 Aug 2026 20:43:37 -0700 Subject: [PATCH] Clamp the demo canvas to maxTextureDimension2D Fixes a black screen introduced by #49. That PR made the canvas backing store devicePixelRatio-scaled, capped at 2x. It did not account for maxTextureDimension2D, which is 8192 by default and which this demo never raises. Once either dimension crosses it the swap-chain texture is invalid and nothing renders at all: a black canvas with the controls still drawn on top, and no exception to notice. Because the DPR multiplies the CSS size, on any Retina display a window wider than 4096 CSS pixels crosses the limit. Before #49 the backing store was the CSS size, so it would have taken an 8192px-wide window. Measured at a 4500x1000 viewport, dpr 2: before #49 canvas 4500x1000 particles render after #49 canvas 9000x2000 nothing drawn this fix canvas 8192x1820 particles render resizeCanvas now derives the scale so neither dimension can exceed the limit, and dpr remains the true CSS-to-backing-store ratio after clamping, since pointer coordinates and the influence radius are converted with it. Normal window sizes are unaffected and still get the full 2x: 1440x900 gives 2880x1800 as before. Also adds a regression guard to the demo smoke test. The existing checks ran at the default window size, nowhere near large enough to see this, so the guard loads the demo at 5000x1000 and asserts the backing store stays within the device limit. Verified it fails with the clamp removed: "canvas 10000x2000 exceeds maxTextureDimension2D 8192". Co-Authored-By: Claude Opus 5 --- demos/interactive_demo.mjs | 20 +++++++++++++++++--- misc/run_headless_tests.js | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/demos/interactive_demo.mjs b/demos/interactive_demo.mjs index f8d9f2a..f62857d 100644 --- a/demos/interactive_demo.mjs +++ b/demos/interactive_demo.mjs @@ -47,9 +47,23 @@ let renderUniformBuffer = null; let dpr = 1; function resizeCanvas() { - dpr = Math.min(window.devicePixelRatio || 1, 2); - canvas.width = Math.round(window.innerWidth * dpr); - canvas.height = Math.round(window.innerHeight * dpr); + /* Neither dimension of the backing store may exceed the device's + maxTextureDimension2D (8192 by default, and this demo does not raise + it). Past that the swap-chain texture is invalid and nothing renders + at all - a black canvas with the controls still drawn on top. Since + the DPR scaling below multiplies the CSS size, a window wider than + maxTextureDimension2D / 2 would otherwise cross the limit on any + Retina display. */ + const maxDim = device?.limits?.maxTextureDimension2D ?? 8192; + const cssWidth = Math.max(1, window.innerWidth); + const cssHeight = Math.max(1, window.innerHeight); + const fit = Math.min(maxDim / cssWidth, maxDim / cssHeight); + + /* dpr stays the true CSS-to-backing-store scale after clamping, because + pointer coordinates and the influence radius are converted with it. */ + dpr = Math.min(window.devicePixelRatio || 1, 2, fit); + canvas.width = Math.round(cssWidth * dpr); + canvas.height = Math.round(cssHeight * dpr); if (device && renderUniformBuffer) { device.queue.writeBuffer(renderUniformBuffer, 0, new Float32Array([canvas.width, canvas.height, 0, 0])); } diff --git a/misc/run_headless_tests.js b/misc/run_headless_tests.js index 725cfdb..2268f34 100644 --- a/misc/run_headless_tests.js +++ b/misc/run_headless_tests.js @@ -150,6 +150,43 @@ async function runDemoSmokeTest(browser) { failures.push(`canvas has no backing store (${canvas.w}x${canvas.h})`); } + /* + * Regression guard: the canvas backing store must never exceed the + * device's maxTextureDimension2D. Past that the swap-chain texture is + * invalid and the demo renders nothing - a black canvas with the + * controls still drawn on top, and no thrown error to notice. + * + * This is easy to reintroduce because resizeCanvas() multiplies the CSS + * size by devicePixelRatio, so on a Retina display any window wider + * than half the limit crosses it. A wide viewport is used here because + * the default test window is nowhere near large enough to catch it. + */ + const wide = await browser.newPage(); + try { + await wide.setViewport({ width: 5000, height: 1000, deviceScaleFactor: 2 }); + await wide.goto('http://127.0.0.1:8000/demos/interactive_demo.html', { + waitUntil: 'domcontentloaded', + timeout: 30000 + }); + await new Promise(r => setTimeout(r, 5000)); + const big = await wide.evaluate(async () => { + const c = document.getElementById('canvas'); + const adapter = await navigator.gpu.requestAdapter(); + const device = await adapter.requestDevice(); + return { w: c.width, h: c.height, max: device.limits.maxTextureDimension2D }; + }); + if (big.w > big.max || big.h > big.max) { + failures.push( + `canvas ${big.w}x${big.h} exceeds maxTextureDimension2D ${big.max} ` + + `at a 5000x1000 viewport; the demo will render black` + ); + } else { + console.log(` wide viewport: canvas ${big.w}x${big.h} within limit ${big.max}`); + } + } finally { + await wide.close(); + } + // Every control the demo wires up must exist, or an addEventListener // call threw and the rest of the module never ran. const missing = await page.evaluate(() => {