From 1d4ce148d6080d127990343443b0863ea3eebd7b Mon Sep 17 00:00:00 2001 From: John Owens Date: Sun, 30 Aug 2026 20:00:08 -0700 Subject: [PATCH 1/2] Make the interactive demo actually work on touch Follow-ups to #46, which made the demo render correctly on a phone but left it non-functional there. Touch input. Interaction was bound to mousemove/mousedown/mouseup/ mouseleave. Mobile browsers synthesize a click from a tap but never a mousemove stream from a drag, so dragging did nothing and the shift-key repel was unreachable without a keyboard. Switched to pointer events with pointer capture, added touch-action:none to the canvas so the browser doesn't take the drag for scrolling, and added a Mode button that toggles pull/push. Shift-drag still inverts the mode for mouse users. devicePixelRatio. canvas.width was window.innerWidth with no DPR scaling. Before #46 the missing viewport tag meant innerWidth was the fake 980px layout viewport, which accidentally supersampled; with the viewport tag it became ~390 and the particles got blocky. Now scales by devicePixelRatio capped at 2. Pointer coordinates and the influence radius scale by the same factor, since the simulation works in backing-store pixels. The radius needed a new uniform. Note that the existing `padding` field in Params is not struct padding - it is the layout inset used by the sort and scan arrangements - so the uniform grew from 32 to 48 bytes to carry pointerRadius alongside it. The inset now scales with dpr too. Controls panel. #46 shrank the buttons to ~24px with a 0px gap to stop the panel eating a quarter of a phone screen. That fixed the footprint but made three buttons that each launch a slow GPU operation easy to mis-tap. Collapsed the panel behind a disclosure toggle instead, so the footprint problem is solved without shrinking anything: measured at 390x844, the panel is 1.1% of the screen collapsed and 21.1% open, with every tap target at 44px. CSS-only, because interactive_demo.mjs aborts early when WebGPU is missing and the panel should still fold away on the error screen. Benchmark plots. Observable Plot was hardcoded to width:1280, which overflows a phone. It now measures its container and clamps, falling back to 1280 where there's no DOM to measure. Added an overflow-x box around #plot as a safety net for anything still too wide. Verified in headless Chrome with WebGPU at 390x844 and 1440x900: no shader or console errors, canvas backing store 780x1688 and 2880x1800 respectively, zero page overflow, mode toggle works, render loop live across a synthesized touch drag, and the desktop layout unchanged. Co-Authored-By: Claude Opus 5 --- benchmarking.mjs | 12 +++- demos/interactive_demo.html | 117 ++++++++++++++++++++++++++++++------ demos/interactive_demo.mjs | 99 ++++++++++++++++++++++++------ examples/gridwise.css | 10 +++ 4 files changed, 200 insertions(+), 38 deletions(-) diff --git a/benchmarking.mjs b/benchmarking.mjs index 578b9e2..a8c4e12 100644 --- a/benchmarking.mjs +++ b/benchmarking.mjs @@ -488,7 +488,17 @@ async function main(navigator) { }), ...(("fx" in plot || "fy" in plot) && { grid: true }), color: { type: "ordinal", legend: true }, - width: 1280, + /* Fit the plot to its container so a phone gets a readable chart + instead of a page that scrolls sideways. Falls back to the fixed + 1280 wherever there is no laid-out container to measure (no DOM, + or a container reporting 0 width). */ + width: (() => { + const avail = + typeof document === "undefined" + ? 0 + : document.querySelector("#plot")?.clientWidth ?? 0; + return avail > 0 ? Math.min(1280, avail) : 1280; + })(), title: plot?.title, subtitle: plot?.subtitle, caption: plot?.caption, diff --git a/demos/interactive_demo.html b/demos/interactive_demo.html index e16603c..0f8437b 100644 --- a/demos/interactive_demo.html +++ b/demos/interactive_demo.html @@ -26,6 +26,9 @@ height: 100%; cursor: crosshair; display: block; + /* Claim drag gestures for the simulation instead of letting the + browser scroll/zoom the page with them. */ + touch-action: none; } #controls { @@ -38,10 +41,20 @@ border-radius: 12px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); z-index: 10; + min-width: 180px; + } + + .panel-body { display: flex; flex-direction: column; gap: 10px; - min-width: 180px; + } + + /* Disclosure control: hidden entirely on large screens, where the + panel is always open. */ + .panel-trigger, + .panel-toggle { + display: none; } .control-group { @@ -188,17 +201,68 @@ box-shadow: 0 4px 16px rgba(0,0,0,0.5); } + .mode-btn { + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 6px; + animation: none; + font-size: 13px; + padding: 10px; + min-height: 44px; + } + + .mode-btn[aria-pressed="true"] { + border-color: #764ba2; + background: rgba(118, 75, 162, 0.25); + } + + /* Small screens: collapse the panel to a single button instead of + shrinking the controls. The open panel previously ate roughly a + quarter of a phone screen; folding it away costs nothing, whereas + shrinking tap targets below ~44px trades a mis-tap for a few + reclaimed pixels - and each of these buttons launches a slow GPU + operation. */ @media (height < 600px) or (width < 600px) { #controls { - gap: 5px; + top: 10px; + right: 10px; + padding: 8px; + min-width: 0; + } - .button-group { - gap: 0px; + .panel-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + color: #fff; + cursor: pointer; + border-radius: 8px; + } + + .panel-body { + display: none; + } + + .panel-trigger:checked ~ .panel-body { + display: flex; + padding-top: 8px; + min-width: 200px; + max-height: calc(100dvh - 80px); + overflow-y: auto; + } + + .button-group { + flex-direction: row; + gap: 8px; + margin-top: 4px; + } - button { - padding: 5px; - } - } + .button-group button { + flex: 1; + padding: 12px 4px; + /* Explicit floor rather than relying on font metrics. */ + min-height: 44px; } } @@ -208,19 +272,34 @@
-
- - -
10K
-
+ + + -
- - - -
+
+
+ + +
10K
+
-
Click: attract • Shift: repel
+
+ + + +
+ + + +
Drag to move particles • Mode or Shift flips pull/push
+
diff --git a/demos/interactive_demo.mjs b/demos/interactive_demo.mjs index 54f99be..a38b923 100644 --- a/demos/interactive_demo.mjs +++ b/demos/interactive_demo.mjs @@ -39,9 +39,17 @@ context.configure({ let renderUniformBuffer = null; +/* Backing-store pixels per CSS pixel. Capped at 2 so a 3x phone screen + doesn't cost 9x the fill rate for a barely visible gain. Everything in + the simulation (particle positions, pointer position, influence radius) + lives in backing-store pixels, so this is the one factor that converts + between CSS/event coordinates and simulation coordinates. */ +let dpr = 1; + function resizeCanvas() { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; + dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.round(window.innerWidth * dpr); + canvas.height = Math.round(window.innerHeight * dpr); if (device && renderUniformBuffer) { device.queue.writeBuffer(renderUniformBuffer, 0, new Float32Array([canvas.width, canvas.height, 0, 0])); } @@ -57,12 +65,16 @@ let mouseX = -1000; let mouseY = -1000; let mouseDown = false; let attractMode = true; +/* The mode the toggle button selects. attractMode is what the shader + reads for the current gesture; shift-drag inverts it for mouse users. */ +let attractDefault = true; const starSlider = document.getElementById("starSlider"); const starCountDisplay = document.getElementById("starCount"); const sortBtn = document.getElementById("sortBtn"); const scanBtn = document.getElementById("scanBtn"); const reduceBtn = document.getElementById("reduceBtn"); +const modeBtn = document.getElementById("modeBtn"); // Colors (HSL to RGB conversion for shader) const COLORS = [ @@ -176,7 +188,10 @@ function initGPUResources(count) { // Simulation Uniform Buffer simulationUniformBuffer = device.createBuffer({ - size: 32, // mousePos (vec2f), canvasSize (vec2f), mouseDown (u32), attractMode (u32), isOperating (u32), padding (f32) = 32 bytes + // mousePos (vec2f), canvasSize (vec2f), mouseDown (u32), attractMode (u32), + // isOperating (u32), padding (f32), pointerRadius (f32) = 36 bytes, + // rounded up to the 16-byte uniform stride. + size: 48, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); @@ -271,6 +286,10 @@ const simulationWGSL = ` attractMode: u32, isOperating: u32, padding: f32, + pointerRadius: f32, + _pad0: f32, + _pad1: f32, + _pad2: f32, } @group(0) @binding(0) var particles: array; @@ -288,8 +307,9 @@ const simulationWGSL = ` let dy = params.mouseY - p.pos.y; let dist = sqrt(dx * dx + dy * dy); - if (params.mouseDown == 1u && dist < 150.0 && dist > 0.1) { - let force = ((150.0 - dist) / 150.0) * select(-0.4, 0.4, params.attractMode == 1u); + let radius = params.pointerRadius; + if (params.mouseDown == 1u && dist < radius && dist > 0.1) { + let force = ((radius - dist) / radius) * select(-0.4, 0.4, params.attractMode == 1u); p.vel.x += (dx / dist) * force; p.vel.y += (dy / dist) * force; } @@ -356,6 +376,10 @@ const applySortedWGSL = ` attractMode: u32, isOperating: u32, padding: f32, + pointerRadius: f32, + _pad0: f32, + _pad1: f32, + _pad2: f32, } @group(0) @binding(0) var particles: array; @@ -429,6 +453,10 @@ const applyScanWGSL = ` attractMode: u32, isOperating: u32, padding: f32, + pointerRadius: f32, + _pad0: f32, + _pad1: f32, + _pad2: f32, } @group(0) @binding(0) var particles: array; @@ -477,6 +505,10 @@ const applyReduceWGSL = ` attractMode: u32, isOperating: u32, padding: f32, + pointerRadius: f32, + _pad0: f32, + _pad1: f32, + _pad2: f32, } @group(0) @binding(0) var particles: array; @@ -746,7 +778,7 @@ function buildBindGroups() { } function updateUniforms() { - const uniformData = new ArrayBuffer(32); + const uniformData = new ArrayBuffer(48); const f32 = new Float32Array(uniformData); const u32 = new Uint32Array(uniformData); @@ -757,7 +789,12 @@ function updateUniforms() { u32[4] = mouseDown ? 1 : 0; u32[5] = attractMode ? 1 : 0; u32[6] = isOperating ? 1 : 0; - f32[7] = 100.0; // padding + // Layout inset for the sort/scan arrangements, in backing-store pixels. + f32[7] = 100.0 * dpr; + // Influence radius, also in backing-store pixels. Scaling by dpr keeps the + // felt radius constant in CSS pixels across displays; the extra bump on + // coarse pointers accounts for a fingertip being blunter than a cursor. + f32[8] = 150.0 * dpr * (matchMedia("(pointer: coarse)").matches ? 1.5 : 1.0); device.queue.writeBuffer(simulationUniformBuffer, 0, uniformData); } @@ -976,13 +1013,39 @@ function render() { } // Event Listeners -canvas.addEventListener("mousemove", (e) => { - mouseX = e.clientX; - mouseY = e.clientY; -}); -canvas.addEventListener("mouseleave", () => { +// +// Pointer events rather than mouse events: mobile browsers synthesize a +// click from a tap but never a mousemove stream from a drag, so the +// mouse-only version was inert on touch. clientX/Y are CSS pixels and the +// simulation works in backing-store pixels, hence the dpr scaling. +function setPointer(e) { + mouseX = e.clientX * dpr; + mouseY = e.clientY * dpr; +} +function clearPointer() { mouseX = -1000; mouseY = -1000; + mouseDown = false; +} + +canvas.addEventListener("pointermove", setPointer); +canvas.addEventListener("pointerdown", (e) => { + setPointer(e); + mouseDown = true; + // Shift still works for mouse users; the toggle button drives touch. + attractMode = e.shiftKey ? !attractDefault : attractDefault; + // Keep receiving moves even if the finger slides off the canvas. + canvas.setPointerCapture?.(e.pointerId); +}); +canvas.addEventListener("pointerup", (e) => { + mouseDown = false; + canvas.releasePointerCapture?.(e.pointerId); + // A finger has no hover state, so drop the influence point on release. + if (e.pointerType !== "mouse") clearPointer(); +}); +canvas.addEventListener("pointercancel", clearPointer); +canvas.addEventListener("pointerleave", (e) => { + if (e.pointerType === "mouse") clearPointer(); }); starSlider.addEventListener("input", (e) => { particleCount = parseInt(e.target.value); @@ -999,12 +1062,12 @@ starSlider.addEventListener("change", (e) => { initGPUResources(particleCount); }, 100); }); -canvas.addEventListener("mousedown", (e) => { - mouseDown = true; - attractMode = !e.shiftKey; -}); -canvas.addEventListener("mouseup", () => { - mouseDown = false; +// Mode toggle: the only way to reach repel without a keyboard. +modeBtn.addEventListener("click", () => { + attractDefault = !attractDefault; + attractMode = attractDefault; + modeBtn.textContent = attractDefault ? "Mode: Attract" : "Mode: Repel"; + modeBtn.setAttribute("aria-pressed", String(!attractDefault)); }); function formatStarCount(count) { diff --git a/examples/gridwise.css b/examples/gridwise.css index d059d29..520c1de 100644 --- a/examples/gridwise.css +++ b/examples/gridwise.css @@ -55,6 +55,16 @@ hr { #plot { margin-top: 1em; + /* Safety net: if a chart still ends up wider than the page (a long + legend, a narrow phone), it scrolls inside this box rather than + making the whole page scroll sideways. */ + overflow-x: auto; +} + +#plot figure { + margin-left: 0; + margin-right: 0; + max-width: 100%; } #webgpu-results { From 8bdcc49fe1325f71bb6f50ca4404652d6f394a41 Mon Sep 17 00:00:00 2001 From: John Owens Date: Sun, 30 Aug 2026 20:11:47 -0700 Subject: [PATCH 2/2] Hoist the demo's shared WGSL structs and smoke-test the demo Two robustness fixes to demos/interactive_demo.mjs plus the test that would have caught the bug they address. Hoist the struct declarations. Particle (the particle storage buffer layout) was written out by hand in 7 shaders and Params (the simulationUniformBuffer layout) in 4. Every copy has to agree byte for byte with the others and with updateUniforms(), and nothing enforced that: renaming a field in the declarations but missing a shader body that used it is exactly how the previous commit broke applySorted. Both structs are now declared once and interpolated, so the copies cannot drift. updateUniforms() writes through a named slot table (P) rather than bare indices, and the buffer size is a named constant used by both createBuffer and the writer. Rename Params.padding to layoutInset. It is not struct alignment padding - it is the margin the sort and scan arrangements keep clear around the canvas edge - and reading it as padding is what caused the bug. The actual alignment padding is now explicitly _pad0.._pad2. Add a demo smoke test to misc/run_headless_tests.js, which CI already runs. The demo is not in the regression suite and not shipped in the npm package, so it can rot silently. The test is deliberately shallow: it loads the page, checks every shader compiled and no error surfaced, checks the controls exist, and clicks Sort, Scan and Reduce. Sort and Scan matter specifically because they are the only readers of Params.layoutInset. It also probes that the module finished initializing, by checking the mode button's label changes when clicked. Without that, a shader failure aborts the module before the listeners are registered and every later check passes vacuously - which is what happened on the first draft of this test. Verified both ways: green on the current tree, and red with the original field-rename bug reintroduced, reporting both the shader error and the inert mode button. Regression suite still 31/31. Co-Authored-By: Claude Opus 5 --- demos/interactive_demo.mjs | 202 +++++++++++++++---------------------- misc/run_headless_tests.js | 184 ++++++++++++++++++++++++++------- 2 files changed, 232 insertions(+), 154 deletions(-) diff --git a/demos/interactive_demo.mjs b/demos/interactive_demo.mjs index a38b923..f8d9f2a 100644 --- a/demos/interactive_demo.mjs +++ b/demos/interactive_demo.mjs @@ -188,10 +188,8 @@ function initGPUResources(count) { // Simulation Uniform Buffer simulationUniformBuffer = device.createBuffer({ - // mousePos (vec2f), canvasSize (vec2f), mouseDown (u32), attractMode (u32), - // isOperating (u32), padding (f32), pointerRadius (f32) = 36 bytes, - // rounded up to the 16-byte uniform stride. - size: 48, + // Layout is defined once by PARAMS_STRUCT_WGSL / P, below. + size: PARAMS_BYTE_LENGTH, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); @@ -267,17 +265,38 @@ function initGPUResources(count) { } // Shader Declarations -const simulationWGSL = ` - struct Particle { + +/* Shared struct declarations. + * + * Both structs describe memory that every pipeline in this demo reads: + * Particle is the layout of the particle storage buffer, Params the + * layout of simulationUniformBuffer. WGSL has no include mechanism, so + * each shader has to declare them in full, and all the copies must agree + * byte for byte with each other and with updateUniforms() below. + * + * They used to be written out by hand in each shader - 7 copies of + * Particle and 4 of Params. That is a live hazard: editing one copy and + * missing another is either a compile error or, worse, silently misread + * memory, and it has already caused one bug here (a field renamed in the + * declarations but not in the shader bodies that used it). Declaring them + * once and interpolating means the copies cannot drift. + */ +const PARTICLE_STRUCT_WGSL = ` struct Particle { pos: vec2f, origPos: vec2f, vel: vec2f, destination: vec2f, colorIndex: u32, size: f32, - } - - struct Params { + }`; + +/* layoutInset is the margin, in backing-store pixels, that the sort and + * scan arrangements keep clear around the edge of the canvas. It was + * called `padding` and was mistaken for struct alignment padding, hence + * the rename - the trailing _pad* fields are the actual alignment padding, + * rounding the struct up to the 16-byte uniform stride. + */ +const PARAMS_STRUCT_WGSL = ` struct Params { mouseX: f32, mouseY: f32, canvasWidth: f32, @@ -285,12 +304,35 @@ const simulationWGSL = ` mouseDown: u32, attractMode: u32, isOperating: u32, - padding: f32, + layoutInset: f32, pointerRadius: f32, _pad0: f32, _pad1: f32, _pad2: f32, - } + }`; + +/* Slot indices into simulationUniformBuffer, in the field order of + PARAMS_STRUCT_WGSL. The shaders read this buffer by field name and + updateUniforms() writes it by offset; nothing checks that the two agree, + so the names here exist to make a mismatch visible at the call site. */ +const P = { + mouseX: 0, + mouseY: 1, + canvasWidth: 2, + canvasHeight: 3, + mouseDown: 4, + attractMode: 5, + isOperating: 6, + layoutInset: 7, + pointerRadius: 8, +}; +// 9 x 4 bytes = 36, rounded up to the 16-byte uniform stride. +const PARAMS_BYTE_LENGTH = 48; + +const simulationWGSL = ` +${PARTICLE_STRUCT_WGSL} + +${PARAMS_STRUCT_WGSL} @group(0) @binding(0) var particles: array; @group(0) @binding(1) var params: Params; @@ -334,14 +376,7 @@ const simulationWGSL = ` `; const extractKeysWGSL = ` - struct Particle { - pos: vec2f, - origPos: vec2f, - vel: vec2f, - destination: vec2f, - colorIndex: u32, - size: f32, - } +${PARTICLE_STRUCT_WGSL} @group(0) @binding(0) var particles: array; @group(0) @binding(1) var keys: array; @@ -358,29 +393,9 @@ const extractKeysWGSL = ` `; const applySortedWGSL = ` - struct Particle { - pos: vec2f, - origPos: vec2f, - vel: vec2f, - destination: vec2f, - colorIndex: u32, - size: f32, - } +${PARTICLE_STRUCT_WGSL} - struct Params { - mouseX: f32, - mouseY: f32, - canvasWidth: f32, - canvasHeight: f32, - mouseDown: u32, - attractMode: u32, - isOperating: u32, - padding: f32, - pointerRadius: f32, - _pad0: f32, - _pad1: f32, - _pad2: f32, - } +${PARAMS_STRUCT_WGSL} @group(0) @binding(0) var particles: array; @group(0) @binding(1) var sortedIndices: array; @@ -406,24 +421,17 @@ const applySortedWGSL = ` let origIdx = sortedIndices[rank]; let progress = f32(rank) / f32(count); - let usableWidth = params.canvasWidth - params.padding * 2.0; - let usableHeight = params.canvasHeight - params.padding * 2.0; + let usableWidth = params.canvasWidth - params.layoutInset * 2.0; + let usableHeight = params.canvasHeight - params.layoutInset * 2.0; - particles[origIdx].destination.x = params.padding + progress * usableWidth; - particles[origIdx].destination.y = params.padding + (hash(rank) * 0.5 + 0.25) * usableHeight; + particles[origIdx].destination.x = params.layoutInset + progress * usableWidth; + particles[origIdx].destination.y = params.layoutInset + (hash(rank) * 0.5 + 0.25) * usableHeight; particles[origIdx].vel = vec2f(0.0, 0.0); } `; const resetTargetsWGSL = ` - struct Particle { - pos: vec2f, - origPos: vec2f, - vel: vec2f, - destination: vec2f, - colorIndex: u32, - size: f32, - } +${PARTICLE_STRUCT_WGSL} @group(0) @binding(0) var particles: array; @compute @workgroup_size(256) @@ -435,29 +443,9 @@ const resetTargetsWGSL = ` `; const applyScanWGSL = ` - struct Particle { - pos: vec2f, - origPos: vec2f, - vel: vec2f, - destination: vec2f, - colorIndex: u32, - size: f32, - } +${PARTICLE_STRUCT_WGSL} - struct Params { - mouseX: f32, - mouseY: f32, - canvasWidth: f32, - canvasHeight: f32, - mouseDown: u32, - attractMode: u32, - isOperating: u32, - padding: f32, - pointerRadius: f32, - _pad0: f32, - _pad1: f32, - _pad2: f32, - } +${PARAMS_STRUCT_WGSL} @group(0) @binding(0) var particles: array; @group(0) @binding(1) var scannedValues: array>; @@ -478,38 +466,18 @@ const applyScanWGSL = ` let normalized = f32(val) / f32(maxValue); let wavePhase = normalized * 3.14159265 * 12.0; - let usableWidth = params.canvasWidth - params.padding * 2.0; + let usableWidth = params.canvasWidth - params.layoutInset * 2.0; - particles[idx].destination.x = params.padding + normalized * usableWidth; + particles[idx].destination.x = params.layoutInset + normalized * usableWidth; particles[idx].destination.y = params.canvasHeight / 2.0 + sin(wavePhase) * (params.canvasHeight * 0.3); particles[idx].vel = vec2f(0.0, 0.0); } `; const applyReduceWGSL = ` - struct Particle { - pos: vec2f, - origPos: vec2f, - vel: vec2f, - destination: vec2f, - colorIndex: u32, - size: f32, - } +${PARTICLE_STRUCT_WGSL} - struct Params { - mouseX: f32, - mouseY: f32, - canvasWidth: f32, - canvasHeight: f32, - mouseDown: u32, - attractMode: u32, - isOperating: u32, - padding: f32, - pointerRadius: f32, - _pad0: f32, - _pad1: f32, - _pad2: f32, - } +${PARAMS_STRUCT_WGSL} @group(0) @binding(0) var particles: array; @group(0) @binding(1) var reduceResult: array; @@ -551,14 +519,7 @@ const applyReduceWGSL = ` `; const renderWGSL = ` - struct Particle { - pos: vec2f, - origPos: vec2f, - vel: vec2f, - destination: vec2f, - colorIndex: u32, - size: f32, - } +${PARTICLE_STRUCT_WGSL} struct VertexOutput { @builtin(position) pos: vec4f, @@ -778,24 +739,25 @@ function buildBindGroups() { } function updateUniforms() { - const uniformData = new ArrayBuffer(48); + const uniformData = new ArrayBuffer(PARAMS_BYTE_LENGTH); const f32 = new Float32Array(uniformData); const u32 = new Uint32Array(uniformData); - - f32[0] = mouseX; - f32[1] = mouseY; - f32[2] = canvas.width; - f32[3] = canvas.height; - u32[4] = mouseDown ? 1 : 0; - u32[5] = attractMode ? 1 : 0; - u32[6] = isOperating ? 1 : 0; - // Layout inset for the sort/scan arrangements, in backing-store pixels. - f32[7] = 100.0 * dpr; + + f32[P.mouseX] = mouseX; + f32[P.mouseY] = mouseY; + f32[P.canvasWidth] = canvas.width; + f32[P.canvasHeight] = canvas.height; + u32[P.mouseDown] = mouseDown ? 1 : 0; + u32[P.attractMode] = attractMode ? 1 : 0; + u32[P.isOperating] = isOperating ? 1 : 0; + // Margin the sort/scan arrangements keep clear, in backing-store pixels. + f32[P.layoutInset] = 100.0 * dpr; // Influence radius, also in backing-store pixels. Scaling by dpr keeps the // felt radius constant in CSS pixels across displays; the extra bump on // coarse pointers accounts for a fingertip being blunter than a cursor. - f32[8] = 150.0 * dpr * (matchMedia("(pointer: coarse)").matches ? 1.5 : 1.0); - + f32[P.pointerRadius] = + 150.0 * dpr * (matchMedia("(pointer: coarse)").matches ? 1.5 : 1.0); + device.queue.writeBuffer(simulationUniformBuffer, 0, uniformData); } diff --git a/misc/run_headless_tests.js b/misc/run_headless_tests.js index a6e3487..725cfdb 100644 --- a/misc/run_headless_tests.js +++ b/misc/run_headless_tests.js @@ -59,11 +59,146 @@ function startServer(root, port = 8000) { }); } +async function runRegressionTests(browser) { + const page = await browser.newPage(); + + // Redirect browser console logs to node console + page.on('console', msg => console.log(`[Browser Console] ${msg.text()}`)); + + console.log("Navigating to regression tests page..."); + await page.goto('http://127.0.0.1:8000/examples/regression.html', { + waitUntil: 'domcontentloaded', + timeout: 30000 + }); + + console.log("Waiting for tests to complete..."); + // Wait for #summary to change from "Running…" + await page.waitForFunction(() => { + const summary = document.getElementById('summary'); + return summary && summary.textContent !== 'Running…'; + }, { timeout: 60000 }); + + const summaryText = await page.evaluate(() => { + return document.getElementById('summary').textContent; + }); + + console.log("\n================ TEST SUMMARY ================"); + console.log(summaryText); + console.log("==============================================\n"); + + const failedTests = await page.evaluate(() => { + const fails = Array.from(document.querySelectorAll('#test-list li.fail')); + return fails.map(el => el.textContent); + }); + + await page.close(); + return failedTests; +} + +/* + * Smoke test for demos/interactive_demo.html. + * + * The demo is not covered by the regression suite and is not shipped in + * the npm package, so it can rot silently. This is deliberately shallow: + * it does not check that the simulation is correct, only that the page + * comes up, every shader compiles, and each of the three primitive + * buttons runs without raising an error. + * + * That is enough to catch the class of breakage that is easy to cause and + * easy to miss: the demo declares its Particle and Params structs once + * and interpolates them into seven shaders, so a field renamed or a + * uniform resized in one place and not another is a compile error at + * page load. Clicking Sort and Scan matters specifically because those + * are the shaders that read Params.layoutInset - a field that no other + * code path touches. + */ +async function runDemoSmokeTest(browser) { + const page = await browser.newPage(); + const failures = []; + + page.on('pageerror', err => failures.push(`uncaught exception: ${err.message}`)); + page.on('console', msg => { + if (msg.type() === 'error' && !msg.text().includes('favicon')) { + failures.push(`console error: ${msg.text()}`); + } + }); + + console.log("Navigating to interactive demo..."); + await page.goto('http://127.0.0.1:8000/demos/interactive_demo.html', { + waitUntil: 'domcontentloaded', + timeout: 30000 + }); + + // Give the demo time to request a device, compile all seven shaders and + // build its buffers before looking at anything. + await new Promise(r => setTimeout(r, 5000)); + + const hasWebGPU = await page.evaluate(() => !!navigator.gpu); + if (!hasWebGPU) { + console.log("WebGPU unavailable in this browser; skipping demo smoke test."); + await page.close(); + return []; + } + + // A non-zero backing store means resizeCanvas ran and the context was + // configured; zero means init bailed out somewhere. + const canvas = await page.evaluate(() => { + const c = document.getElementById('canvas'); + return { w: c.width, h: c.height }; + }); + if (!canvas.w || !canvas.h) { + failures.push(`canvas has no backing store (${canvas.w}x${canvas.h})`); + } + + // 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(() => { + const ids = ['canvas', 'starSlider', 'starCount', 'sortBtn', 'scanBtn', 'reduceBtn', 'modeBtn']; + return ids.filter(id => !document.getElementById(id)); + }); + if (missing.length) failures.push(`missing elements: ${missing.join(', ')}`); + + // Prove the module actually ran to completion before trusting anything + // below. The event listeners are registered at the very bottom of + // interactive_demo.mjs, so if init threw part way - a shader that failed + // to compile, say - nothing is wired up and every check after this point + // would pass vacuously. Toggling the mode button is the cheapest probe: + // its label only changes if its listener exists. + const modeLabel = await page.evaluate(() => { + const b = document.getElementById('modeBtn'); + const before = b.textContent; + b.click(); + return { before, after: b.textContent }; + }); + if (modeLabel.before === modeLabel.after) { + failures.push( + `demo did not finish initializing: mode button inert (still "${modeLabel.after}")` + ); + } + + // Run each primitive. Sort and Scan exercise the shaders that read + // Params.layoutInset; Reduce exercises the third pipeline. + for (const id of ['sortBtn', 'scanBtn', 'reduceBtn']) { + await page.evaluate(btn => document.getElementById(btn).click(), id); + await new Promise(r => setTimeout(r, 2500)); + const err = await page.evaluate(() => { + const el = document.getElementById('errorDisplay'); + return getComputedStyle(el).display !== 'none' ? el.textContent : null; + }); + if (err) failures.push(`${id} raised: ${err}`); + else console.log(` ${id}: ok`); + } + + await page.close(); + return failures; +} + async function main() { console.log("Starting local HTTP server..."); const server = await startServer(projectRoot, 8000); let browser; + let exitCode = 0; try { console.log("Launching headless browser with WebGPU support..."); browser = await puppeteer.launch({ @@ -75,46 +210,27 @@ async function main() { ] }); - const page = await browser.newPage(); - - // Redirect browser console logs to node console - page.on('console', msg => console.log(`[Browser Console] ${msg.text()}`)); - - console.log("Navigating to regression tests page..."); - await page.goto('http://127.0.0.1:8000/examples/regression.html', { - waitUntil: 'domcontentloaded', - timeout: 30000 - }); - - console.log("Waiting for tests to complete..."); - // Wait for #summary to change from "Running…" - await page.waitForFunction(() => { - const summary = document.getElementById('summary'); - return summary && summary.textContent !== 'Running…'; - }, { timeout: 60000 }); - - const summaryText = await page.evaluate(() => { - return document.getElementById('summary').textContent; - }); - - console.log("\n================ TEST SUMMARY ================"); - console.log(summaryText); - console.log("==============================================\n"); - - const failedTests = await page.evaluate(() => { - const fails = Array.from(document.querySelectorAll('#test-list li.fail')); - return fails.map(el => el.textContent); - }); - + const failedTests = await runRegressionTests(browser); if (failedTests.length > 0) { console.error("Failed tests:"); failedTests.forEach(t => console.error(`- ${t}`)); - process.exit(1); + exitCode = 1; } else { - console.log("All tests passed successfully!"); - process.exit(0); + console.log("All regression tests passed successfully!"); } + console.log("\n=========== INTERACTIVE DEMO SMOKE ==========="); + const demoFailures = await runDemoSmokeTest(browser); + if (demoFailures.length > 0) { + console.error("Interactive demo smoke test failed:"); + demoFailures.forEach(f => console.error(`- ${f}`)); + exitCode = 1; + } else { + console.log("Interactive demo smoke test passed."); + } + console.log("==============================================\n"); + + process.exit(exitCode); } catch (error) { console.error("An error occurred during test execution:", error); process.exit(1);