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..f8d9f2a 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,8 @@ 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 + // Layout is defined once by PARAMS_STRUCT_WGSL / P, below. + size: PARAMS_BYTE_LENGTH, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); @@ -252,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, @@ -270,8 +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; @@ -288,8 +349,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; } @@ -314,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; @@ -338,25 +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, - } +${PARAMS_STRUCT_WGSL} @group(0) @binding(0) var particles: array; @group(0) @binding(1) var sortedIndices: array; @@ -382,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) @@ -411,25 +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, - } +${PARAMS_STRUCT_WGSL} @group(0) @binding(0) var particles: array; @group(0) @binding(1) var scannedValues: array>; @@ -450,34 +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, - } +${PARAMS_STRUCT_WGSL} @group(0) @binding(0) var particles: array; @group(0) @binding(1) var reduceResult: array; @@ -519,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, @@ -746,19 +739,25 @@ function buildBindGroups() { } function updateUniforms() { - const uniformData = new ArrayBuffer(32); + 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; - f32[7] = 100.0; // padding - + + 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[P.pointerRadius] = + 150.0 * dpr * (matchMedia("(pointer: coarse)").matches ? 1.5 : 1.0); + device.queue.writeBuffer(simulationUniformBuffer, 0, uniformData); } @@ -976,13 +975,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 +1024,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 { 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);