From 047bf0441c4a335a7382a9c5464c5fd8843b89e6 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sat, 24 Oct 2026 15:31:51 -0600 Subject: [PATCH 1/2] Add a ?bench mode to the browser page: a scripted frame-rate run Opening the page with ?bench opens the walkers (seed 7) from their first tick and turns the camera once around the grid, closing in and back out, for 15 seconds after a warm-up (at least 1 s and 30 frames), then shows a card with the frame rate, frame time (median, p95), the page's own work per frame, GPU time from EXT_disjoint_timer_query_webgl2 where offered, the canvas size and device pixel ratio, the GPU's name and the user agent, with a "Copy results" button. Nothing leaves the device. strata/web/bench.js holds the harness; it wraps requestAnimationFrame before the engine loads so the engine's frames can be timed. strata/web/smoke/bench.cjs checks in CI that a short run finishes and shows its card on a desktop and an iPhone 13 profile. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S6kaEL7kt5Fverjb6didBH --- .github/workflows/browser.yml | 7 +- docs/STRATA.md | 12 ++ strata/CMakeLists.txt | 2 + strata/web/bench.js | 300 ++++++++++++++++++++++++++++++++++ strata/web/shell.html | 21 ++- strata/web/smoke/bench.cjs | 85 ++++++++++ 6 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 strata/web/bench.js create mode 100644 strata/web/smoke/bench.cjs diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml index 1615143..a451977 100644 --- a/.github/workflows/browser.yml +++ b/.github/workflows/browser.yml @@ -57,7 +57,7 @@ jobs: run: | mkdir -p web-site cp build-web/envlib-strata.html web-site/index.html - cp build-web/envlib-strata.js build-web/envlib-strata.wasm web-site/ + cp build-web/envlib-strata.js build-web/envlib-strata.wasm build-web/bench.js web-site/ ls -l web-site echo "site: $(du -sh web-site | cut -f1)" @@ -78,6 +78,11 @@ jobs: node strata/web/smoke/smoke.cjs http://127.0.0.1:8080/ smoke-out/desktop node strata/web/smoke/smoke.cjs http://127.0.0.1:8080/ smoke-out/phone --phone + - name: Bench mode completes and shows its card (desktop, iPhone 13 profile) + run: | + node strata/web/smoke/bench.cjs "http://127.0.0.1:8080/?bench=3" smoke-out/bench-desktop + node strata/web/smoke/bench.cjs "http://127.0.0.1:8080/?bench=3" smoke-out/bench-phone --phone + - name: Upload the site and the smoke screenshots if: always() uses: actions/upload-artifact@v4 diff --git a/docs/STRATA.md b/docs/STRATA.md index b7ed23d..ce20091 100644 --- a/docs/STRATA.md +++ b/docs/STRATA.md @@ -143,6 +143,18 @@ desktop and an iPhone 13 profile (`strata/web/smoke/smoke.cjs`: both worlds tick and draw, Pause and Step, and a real click or tap picks the location under it). +To measure the frame rate on a real device, open the page with `?bench` +(). It opens the walkers +from their first tick and turns the camera once around the grid, closing in +and back out, for 15 seconds after a short warm-up, then shows the frame +rate, the frame time (median and p95), the page's own work per frame, the GPU +time where the browser offers WebGL 2 timer queries, the canvas size and +device pixel ratio and the GPU's name, with a button that copies it all as +text. `?bench=30` runs for 30 seconds. Nothing is sent anywhere. +`strata/web/bench.js` is the harness; `strata/web/smoke/bench.cjs` checks in +CI that a run finishes and shows its card on both profiles (it keeps no +numbers: headless Chromium draws on the CPU). + ## Tests - `[view]`: the copy, the no-pointer rule (an entity freed after `sync()`), an diff --git a/strata/CMakeLists.txt b/strata/CMakeLists.txt index 7ea3b9e..98f450c 100644 --- a/strata/CMakeLists.txt +++ b/strata/CMakeLists.txt @@ -103,6 +103,8 @@ else() -sEXPORTED_RUNTIME_METHODS=ccall,cwrap "--shell-file=${CMAKE_CURRENT_SOURCE_DIR}/web/shell.html") set_property(TARGET envlib_strata_web APPEND PROPERTY LINK_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/web/shell.html") + # The page's ?bench mode (web/bench.js) is served next to it. + configure_file(web/bench.js "${CMAKE_CURRENT_BINARY_DIR}/bench.js" COPYONLY) endif() if(ENVLIB_STRATA_BUILD_TESTS) diff --git a/strata/web/bench.js b/strata/web/bench.js new file mode 100644 index 0000000..d742dc9 --- /dev/null +++ b/strata/web/bench.js @@ -0,0 +1,300 @@ +// ?bench: a fixed, scripted frame-rate measurement for a Strata page. +// +// Open the page with ?bench and, once it has loaded, it flies a fixed camera +// path for 15 seconds (after a warm-up of at least 1 second and 30 frames at +// the start of the path), timing every frame, then +// shows a card with the results and a "Copy results" button. Nothing is sent +// anywhere: the numbers stay on the screen and on the clipboard. +// +// What is measured, per frame: +// - the frame interval: the time between requestAnimationFrame callbacks +// (capped by the display's refresh rate, so 60 or 120 fps is the ceiling); +// - the page's own work: how long the page's requestAnimationFrame +// callbacks (the engine's frame) ran on the main thread; +// - GPU time: what the engine's frame took on the GPU, from WebGL 2 timer +// queries (EXT_disjoint_timer_query_webgl2), where the browser offers them. +// +// This file must load before the engine's script: in bench mode it wraps +// window.requestAnimationFrame so the page's frames can be timed. +// +// The page calls StrataBench.run({...}) once its engine is ready: +// name the page's name, for the results +// scene what is drawn (a dataset, a world, a replay) +// canvas the engine's canvas +// setup() puts the page in its fixed starting state (optional) +// path(u) puts the camera where the path is at u: 0 through the warm-up, +// then from 0 to 1 over the measured run; called once per frame +// onDone() (optional) after the card is shown +// It returns false when the page was not opened with ?bench. +'use strict'; +(function () { + const params = new URLSearchParams(location.search); + const requested = params.has('bench'); + const WARMUP_MS = 1000, WARMUP_FRAMES = 30; + const RUN_MS = Math.min(60000, Math.max(3000, (Number(params.get('bench')) || 15) * 1000)); + + const nativeRaf = window.requestAnimationFrame.bind(window); + // The page's frames: [{ts, cpu}] while measuring, with the GPU queries. + let measuring = false; + let gl = null, timer = null, activeQuery = null; + const pendingQueries = []; // [query, rAF timestamp] + const gpuByFrame = new Map(); // rAF timestamp -> GPU ms of the page's callbacks + let gpuDisjoint = 0; + const cpuByFrame = new Map(); // rAF timestamp -> ms of the page's callbacks + + if (requested) { + window.requestAnimationFrame = function (cb) { + return nativeRaf(function (ts) { + if (!measuring) return cb(ts); + let query = null; + if (timer && !activeQuery) { + query = gl.createQuery(); + gl.beginQuery(timer.TIME_ELAPSED_EXT, query); + activeQuery = query; + } + const t0 = performance.now(); + try { + return cb(ts); + } finally { + cpuByFrame.set(ts, (cpuByFrame.get(ts) || 0) + performance.now() - t0); + if (query) { + gl.endQuery(timer.TIME_ELAPSED_EXT); + activeQuery = null; + pendingQueries.push([query, ts]); + } + } + }); + }; + } + + function pollQueries() { + if (!timer) return; + for (let i = 0; i < pendingQueries.length;) { + const [q, ts] = pendingQueries[i]; + if (!gl.getQueryParameter(q, gl.QUERY_RESULT_AVAILABLE)) { i++; continue; } + if (gl.getParameter(timer.GPU_DISJOINT_EXT)) gpuDisjoint++; + else gpuByFrame.set(ts, (gpuByFrame.get(ts) || 0) + gl.getQueryParameter(q, gl.QUERY_RESULT) / 1e6); + gl.deleteQuery(q); + pendingQueries.splice(i, 1); + } + } + + function stats(values) { + if (!values.length) return null; + const s = values.slice().sort((a, b) => a - b); + const at = (p) => s[Math.min(s.length - 1, Math.max(0, Math.ceil(p * s.length) - 1))]; + return { n: s.length, median: at(0.5), p95: at(0.95), p99: at(0.99), max: s[s.length - 1] }; + } + const ms = (v) => (v === null || v === undefined ? 'n/a' : v.toFixed(2) + ' ms'); + + function rendererInfo(context) { + if (!context) return { renderer: 'n/a', vendor: 'n/a' }; + const dbg = context.getExtension('WEBGL_debug_renderer_info'); + if (dbg) { + return { renderer: String(context.getParameter(dbg.UNMASKED_RENDERER_WEBGL)), + vendor: String(context.getParameter(dbg.UNMASKED_VENDOR_WEBGL)) }; + } + return { renderer: String(context.getParameter(context.RENDERER)) + ' (unmasked name not exposed)', + vendor: String(context.getParameter(context.VENDOR)) }; + } + + async function pageVersion() { + // The arcade serves the deployed version next to the page. + try { + const r = await fetch('version.txt', { cache: 'no-store' }); + const t = r.ok ? (await r.text()).trim() : ''; + if (t && t.length < 80 && !/[<\s]/.test(t)) return t; + } catch (e) { /* a local build */ } + return 'local build'; + } + + // ---- the card ---- + const CSS = ` +#strata-bench, #strata-bench-pill { position: fixed; z-index: 2147483000; font: 15px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; + color: #e9edf4; background: #0e1118; border: 1px solid #39425a; border-radius: 12px; + -webkit-user-select: text; user-select: text; box-shadow: 0 8px 28px rgba(0,0,0,0.45); } +#strata-bench-pill { top: max(10px, env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); padding: 6px 12px; + font-size: 13px; pointer-events: none; white-space: nowrap; } +#strata-bench { left: max(12px, env(safe-area-inset-left)); right: max(12px, env(safe-area-inset-right)); + top: max(12px, env(safe-area-inset-top)); max-width: 560px; margin: 0 auto; padding: 16px; + max-height: calc(100% - 24px); overflow-y: auto; -webkit-overflow-scrolling: touch; } +#strata-bench h2 { margin: 0 0 4px; font-size: 18px; } +#strata-bench .big { font-size: 30px; font-weight: 700; font-variant-numeric: tabular-nums; } +#strata-bench .dim { color: #a7b0c2; font-size: 13px; } +#strata-bench dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; margin: 12px 0; } +#strata-bench dt { color: #a7b0c2; } +#strata-bench dd { margin: 0; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } +#strata-bench textarea { width: 100%; box-sizing: border-box; min-height: 9em; font: 12px/1.35 ui-monospace, Menlo, Consolas, monospace; + color: inherit; background: #0a0c11; border: 1px solid #39425a; border-radius: 8px; padding: 8px; resize: vertical; } +#strata-bench .row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } +#strata-bench-shade { position: fixed; inset: 0; z-index: 2147482999; background: rgba(0, 0, 0, 0.55); } +@media (max-width: 640px) { #strata-bench { padding: 12px 14px; } #strata-bench .big { font-size: 26px; } #strata-bench dl { margin: 8px 0; } } +#strata-bench button { font: 600 15px system-ui, sans-serif; min-height: 44px; padding: 0 16px; border-radius: 8px; cursor: pointer; + color: #e9edf4; background: #232a3a; border: 1px solid #4a5572; touch-action: manipulation; } +#strata-bench button.primary { background: #2f6fde; border-color: #2f6fde; color: #fff; flex: 1 1 auto; } +#strata-bench button:focus-visible { outline: 2px solid #ffc23a; outline-offset: 2px; } +#strata-bench .note { margin-top: 8px; color: #ffc23a; font-size: 13px; } +#strata-bench .note:empty { margin: 0; }`; + + function el(tag, attrs, text) { + const e = document.createElement(tag); + for (const k in attrs || {}) e.setAttribute(k, attrs[k]); + if (text !== undefined) e.textContent = text; + return e; + } + + function showCard(r) { + const card = el('div', { id: 'strata-bench', role: 'dialog', 'aria-label': 'Benchmark results' }); + card.append(el('h2', {}, `${r.name} benchmark`)); + card.append(el('div', { class: 'dim' }, `${r.scene} · ${r.version}`)); + card.append(el('div', { class: 'big' }, `${r.fps.toFixed(1)} fps`)); + // The button first: on a phone it is on the screen without scrolling. + const row = el('div', { class: 'row' }); + const copy = el('button', { type: 'button', class: 'primary' }, 'Copy results'); + const again = el('button', { type: 'button' }, 'Run again'); + const close = el('button', { type: 'button' }, 'Close'); + row.append(copy, again, close); + card.append(row); + const note = el('div', { class: 'note', 'aria-live': 'polite' }); + card.append(note); + const dl = el('dl'); + const add = (k, v) => dl.append(el('dt', {}, k), el('dd', {}, v)); + add('Frame time', `median ${ms(r.interval && r.interval.median)}, p95 ${ms(r.interval && r.interval.p95)}`); + add('Frames', `${r.frames} in ${(r.elapsedMs / 1000).toFixed(1)} s, ${r.slowFrames} over 2× median`); + add('Page work', r.cpu ? `median ${ms(r.cpu.median)}, p95 ${ms(r.cpu.p95)}` : 'n/a'); + add('GPU time', r.gpu ? `median ${ms(r.gpu.median)}, p95 ${ms(r.gpu.p95)}` : r.gpuNote); + add('Canvas', r.canvas); + add('Renderer', r.renderer); + card.append(dl); + if (r.warning) card.append(el('div', { class: 'note' }, r.warning)); + const text = el('textarea', { readonly: '', rows: '8', 'aria-label': 'Results as text', spellcheck: 'false' }); + text.value = r.text; + card.append(text); + const selectAll = () => { text.focus(); text.select(); text.setSelectionRange(0, text.value.length); }; + copy.addEventListener('click', () => { + const fallback = () => { + selectAll(); + let ok = false; + try { ok = document.execCommand('copy'); } catch (e) { ok = false; } + note.textContent = ok ? 'Copied.' : 'Selected: copy it with Ctrl+C or the Copy menu.'; + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(r.text).then(() => { note.textContent = 'Copied.'; }, fallback); + } else { + fallback(); + } + }); + again.addEventListener('click', () => location.reload()); + const shade = el('div', { id: 'strata-bench-shade' }); + close.addEventListener('click', () => { card.remove(); shade.remove(); }); + // The engine's canvas takes key and pointer events; the card keeps its own. + for (const type of ['keydown', 'keyup', 'pointerdown', 'mousedown', 'touchstart', 'wheel']) + card.addEventListener(type, (e) => e.stopPropagation()); + document.body.append(shade, card); + return card; + } + + async function run(opts) { + if (!requested) return false; + const state = { state: 'running', result: null, text: '' }; + window.__strataBench = state; + const style = el('style'); + style.textContent = CSS; + document.head.append(style); + const pill = el('div', { id: 'strata-bench-pill', 'aria-live': 'polite' }, 'Benchmark: starting…'); + document.body.append(pill); + + gl = opts.canvas.getContext('webgl2'); + timer = gl && gl.getExtension('EXT_disjoint_timer_query_webgl2'); + const info = rendererInfo(gl); + const versionP = pageVersion(); + try { if (opts.setup) opts.setup(); } catch (e) { console.warn('bench setup:', e); } + + const intervals = []; + let hidden = false; + const onHidden = () => { if (document.hidden) hidden = true; }; + document.addEventListener('visibilitychange', onHidden); + let t0 = null, last = null, lastLabel = '', runStart = null, runStartNow = 0, runFrames = 0, warmFrames = 0; + + await new Promise((resolve) => { + const tick = (ts) => { + const now = performance.now(); + if (t0 === null) t0 = now; + if (runStart === null && now - t0 >= WARMUP_MS && warmFrames >= WARMUP_FRAMES) { + runStart = ts; + runStartNow = now; + measuring = true; + cpuByFrame.clear(); + } else if (runStart !== null) { + intervals.push(ts - last); + runFrames++; + } else { + warmFrames++; + } + last = ts; + const run = runStart === null ? 0 : now - runStartNow; + try { opts.path(Math.min(1, run / RUN_MS)); } catch (e) { console.warn('bench path:', e); } + pollQueries(); + const label = runStart === null ? 'Benchmark: warming up…' : `Benchmark: ${Math.ceil((RUN_MS - run) / 1000)} s left`; + if (label !== lastLabel) pill.textContent = lastLabel = label; + if (run >= RUN_MS) { measuring = false; resolve(); return; } + nativeRaf(tick); + }; + nativeRaf(tick); + }); + // The last GPU results arrive a frame or two later. + for (let i = 0; i < 30 && pendingQueries.length; i++) { + await new Promise((r) => setTimeout(r, 16)); + pollQueries(); + } + document.removeEventListener('visibilitychange', onHidden); + pill.remove(); + + const interval = stats(intervals); + const cpu = stats([...cpuByFrame.values()]); + // A callback that drew nothing (not the engine's) times 0 on the GPU. + const gpu = stats([...gpuByFrame.values()].filter((v) => v > 0)); + const elapsedMs = intervals.reduce((a, b) => a + b, 0); + const fps = elapsedMs > 0 ? (1000 * runFrames) / elapsedMs : 0; + const slowFrames = interval ? intervals.filter((v) => v > 2 * interval.median).length : 0; + const dpr = window.devicePixelRatio || 1; + const rect = opts.canvas.getBoundingClientRect(); + const drawW = gl ? gl.drawingBufferWidth : opts.canvas.width, drawH = gl ? gl.drawingBufferHeight : opts.canvas.height; + const canvas = `${Math.round(rect.width)}×${Math.round(rect.height)} CSS px × DPR ${+dpr.toFixed(3)} → drawn at ${drawW}×${drawH}`; + const gpuNote = timer ? 'no results (timer queries disjoint)' : 'n/a (no EXT_disjoint_timer_query_webgl2)'; + const version = await versionP; + let warning = ''; + if (hidden) warning = 'The tab was hidden during the run: these numbers are not comparable. Run again with the page in view.'; + else if (runFrames < 30) warning = 'Very few frames were drawn: the page may have stalled.'; + const result = { + name: opts.name, scene: opts.scene, version, fps, frames: runFrames, elapsedMs, interval, cpu, gpu, gpuNote, + slowFrames, canvas, renderer: info.renderer, vendor: info.vendor, warning, + gpuDisjoint, userAgent: navigator.userAgent, when: new Date().toISOString(), url: location.href, + }; + const lines = [ + `strata-bench 1 · ${opts.name} ${version}`, + `scene: ${opts.scene} (${RUN_MS / 1000} s path after a ${warmFrames}-frame warm-up)`, + `fps: ${fps.toFixed(1)} (${runFrames} frames in ${(elapsedMs / 1000).toFixed(2)} s)`, + `frame time: median ${ms(interval && interval.median)}, p95 ${ms(interval && interval.p95)}, p99 ${ms(interval && interval.p99)}, max ${ms(interval && interval.max)}; ${slowFrames} over 2× median`, + `page work: ${cpu ? `median ${ms(cpu.median)}, p95 ${ms(cpu.p95)}, max ${ms(cpu.max)}` : 'n/a'}`, + `GPU time: ${gpu ? `median ${ms(gpu.median)}, p95 ${ms(gpu.p95)}, max ${ms(gpu.max)} (${gpu.n} frames${gpuDisjoint ? `, ${gpuDisjoint} disjoint` : ''})` : gpuNote}`, + `canvas: ${canvas}`, + `renderer: ${info.renderer}`, + `vendor: ${info.vendor}`, + `user agent: ${navigator.userAgent}`, + `screen: ${screen.width}×${screen.height}, hardware threads: ${navigator.hardwareConcurrency || 'n/a'}`, + `when: ${result.when}`, + ]; + if (warning) lines.push(`warning: ${warning}`); + result.text = lines.join('\n'); + state.result = result; + state.text = result.text; + console.log(result.text); + showCard(result); + state.state = 'done'; + if (opts.onDone) opts.onDone(result); + return true; + } + + window.StrataBench = { requested, run, runMs: RUN_MS }; +})(); diff --git a/strata/web/shell.html b/strata/web/shell.html index 903fc50..1db3e60 100644 --- a/strata/web/shell.html +++ b/strata/web/shell.html @@ -81,6 +81,8 @@
Loading env-lib-cpp on Strata…
+ +