From bc1178cd2c8701ea79a07371951bb49de663990f Mon Sep 17 00:00:00 2001 From: Sepehr Mahmoudian Date: Mon, 29 Jun 2026 10:19:08 +0200 Subject: [PATCH] perf: splat performance mode (key 'p') + perf-smoke regression guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1 Splat performance mode: press 'p' to cap splats loaded (maxSplats=1.5M) and reload the current scene in place; bounds GPU render cost on multi-million-splat captures. Measured devils-tower (3.4M): 13.7 -> 18.2 fps (+33%) with the cap. Opt-in (full quality by default). #10 scripts/perf-smoke.mjs: a Playwright frame-time regression guard (empty / light splat / splat+feeds FPS floors) — the harness used to find the lag and verify the round-robin + auto-frame + perf-mode fixes. #8 detection under round-robin: verified no fix needed — exportCameraFeed reads the persisted (round-robin-rendered) target, so per-camera detection degrades gracefully to ~12/N fps on valid pixels. tsc passes. --- scripts/perf-smoke.mjs | 90 ++++++++++++++++++++++++++++++++ src/components/CrebainViewer.tsx | 26 ++++++++- 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 scripts/perf-smoke.mjs diff --git a/scripts/perf-smoke.mjs b/scripts/perf-smoke.mjs new file mode 100644 index 0000000..c84d4e9 --- /dev/null +++ b/scripts/perf-smoke.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/** + * CREBAIN performance smoke test / regression guard. + * + * Boots the running dev server in a real browser, measures render frame-time with + * a requestAnimationFrame probe across scenes (empty → light splat → splat + + * camera feeds), and fails if FPS regresses below documented thresholds. This is + * the harness used to find the splat-render + camera-feed lag and verify the + * round-robin feed + auto-frame + performance-mode fixes. + * + * Requires Playwright (not a runtime dep): npx playwright install chromium + * Run against a live dev server: bun run dev # in another shell + * node scripts/perf-smoke.mjs + * Optional env: BASE_URL (default http://localhost:5173), SPLAT (default a light splat). + */ +import { chromium } from 'playwright' + +const BASE_URL = process.env.BASE_URL ?? 'http://localhost:5173' +const SPLAT = process.env.SPLAT ?? '/splats/bicycle-mini.splat' + +// FPS floors (mean over a few seconds). Tune as the renderer evolves; these guard +// against gross regressions (e.g. a per-frame alloc or an N-camera feed re-render). +const THRESHOLDS = { empty: 50, lightSplat: 25, splatWithFeeds: 12 } + +const PROBE = `() => { + const w = window; w.__perf = { t: [] }; + const loop = () => { w.__perf.t.push(performance.now()); if (w.__perf.t.length > 6000) w.__perf.t.shift(); requestAnimationFrame(loop); }; + requestAnimationFrame(loop); + w.__reset = () => { w.__perf.t = []; }; + w.__fps = () => { const a = w.__perf.t; if (a.length < 5) return { fps: 0, frames: a.length }; + const d = []; for (let i = 1; i < a.length; i++) d.push(a[i] - a[i - 1]); + const mean = d.reduce((s, x) => s + x, 0) / d.length; d.sort((x, y) => x - y); + return { fps: +(1000 / mean).toFixed(1), meanMs: +mean.toFixed(2), p95: +d[Math.floor(0.95 * d.length)].toFixed(1), frames: d.length }; }; + return true; +}` + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +async function measure(page, seconds) { + await page.evaluate('() => window.__reset()') + await sleep(seconds * 1000) + return page.evaluate('() => window.__fps()') +} +async function dropSplat(page, path) { + await page.evaluate((p) => { + const url = location.origin + p + const c = document.querySelector('div[tabindex="0"]') + const dt = new DataTransfer(); dt.setData('text/plain', url) + c.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true })) + }, path) +} +async function placeCamera(page, fx, fy) { + await page.keyboard.press('1') + await page.evaluate(([x, y]) => { + const c = document.querySelector('div[tabindex="0"]'); const r = c.getBoundingClientRect() + c.dispatchEvent(new MouseEvent('click', { clientX: r.left + r.width * x, clientY: r.top + r.height * y, bubbles: true, cancelable: true, view: window })) + }, [fx, fy]) +} + +const results = {} +const browser = await chromium.launch() +try { + const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }) + await page.goto(BASE_URL, { waitUntil: 'load' }) + await page.evaluate(PROBE) + await sleep(2000) + + results.empty = await measure(page, 5) + await dropSplat(page, SPLAT) + await sleep(8000) // load + auto-frame + results.lightSplat = await measure(page, 5) + + await placeCamera(page, 0.5, 0.6) + await placeCamera(page, 0.42, 0.6) + await page.keyboard.press('v') // feeds on + await sleep(1000) + results.splatWithFeeds = await measure(page, 5) +} finally { + await browser.close() +} + +let failed = false +console.log('\nCREBAIN perf smoke:') +for (const [k, floor] of Object.entries(THRESHOLDS)) { + const r = results[k] || { fps: 0 } + const ok = r.fps >= floor + if (!ok) failed = true + console.log(` ${ok ? 'PASS' : 'FAIL'} ${k.padEnd(16)} fps=${r.fps} (floor ${floor}) p95=${r.p95 ?? '-'}ms`) +} +process.exit(failed ? 1 : 0) diff --git a/src/components/CrebainViewer.tsx b/src/components/CrebainViewer.tsx index 77f47f2..324f7f9 100644 --- a/src/components/CrebainViewer.tsx +++ b/src/components/CrebainViewer.tsx @@ -242,6 +242,12 @@ export default function CrebainViewer({ onDetectionComplete }: CrebainViewerProp }) const velocity = useRef(new THREE.Vector3()) const lastFrameTime = useRef(performance.now()) + // Splat performance mode (key 'p'): cap splats loaded to bound GPU render cost on + // multi-million-splat scenes (render scales with count). 0 = unlimited (full quality). + const perfMaxSplatsRef = useRef(0) + // Last splat source/name so toggling performance mode can reload it in place. + const lastSplatSourceRef = useRef(null) + const lastSplatNameRef = useRef(undefined) const scratchVectors = useRef({ forward: new THREE.Vector3(), right: new THREE.Vector3(), @@ -1102,6 +1108,8 @@ export default function CrebainViewer({ onDetectionComplete }: CrebainViewerProp const loadSplat = useCallback( async (source: File | string | ArrayBuffer, name?: string) => { if (!sceneRef.current) return + lastSplatSourceRef.current = source + lastSplatNameRef.current = name const displayName = name || (source instanceof File ? source.name : 'OBJEKT') setIsLoading(true) setLoadingName(displayName) @@ -1213,6 +1221,7 @@ export default function CrebainViewer({ onDetectionComplete }: CrebainViewerProp const newSplat = new SplatMesh({ fileBytes, fileName: splatFileName, + ...(perfMaxSplatsRef.current > 0 ? { maxSplats: perfMaxSplatsRef.current } : {}), onLoad: () => { loadCompleted = true clearTimeout(loadTimeout) @@ -1928,6 +1937,21 @@ export default function CrebainViewer({ onDetectionComplete }: CrebainViewerProp e.preventDefault() cycleCamera() break + case 'p': { + // Toggle splat performance mode: cap/uncap splats, then reload in place. + const enabling = perfMaxSplatsRef.current === 0 + perfMaxSplatsRef.current = enabling ? 1_500_000 : 0 + addMessage( + 'tactical', + enabling + ? 'LEISTUNGSMODUS: AN (max 1.5M Splats)' + : 'LEISTUNGSMODUS: AUS (volle Qualität)' + ) + if (lastSplatSourceRef.current) { + void loadSplat(lastSplatSourceRef.current, lastSplatNameRef.current) + } + break + } case 'o': if (e.ctrlKey || e.metaKey) { e.preventDefault() @@ -1938,7 +1962,7 @@ export default function CrebainViewer({ onDetectionComplete }: CrebainViewerProp } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [resetCamera, focusOnContent, cycleCamera, addMessage, clearSelection]) + }, [resetCamera, focusOnContent, cycleCamera, addMessage, clearSelection, loadSplat]) useEffect(() => { if (!cameraPlacementMode && !dronePlacementMode) return