From 70f590ab9a00911a72927e71bb26678c4720bc77 Mon Sep 17 00:00:00 2001 From: Perplexity Computer Date: Thu, 19 Mar 2026 00:27:13 +0000 Subject: [PATCH] =?UTF-8?q?perf:=20QA=20sprint=20v2=20=E2=80=94=208=20opti?= =?UTF-8?q?mizations=20+=20responsive=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: - #29 agents.js: share geometries across agents (3 shared vs 12 duplicates) - #30 agents.js: single connection line material, dispose old geometries - #31 agents.js: add Agent.dispose() for proper GPU resource cleanup - #32 main.js: debounce window resize with rAF (1 call/frame vs dozens) - #33 main.js: pause rAF loop on visibilitychange (battery savings on iPad) - #34 effects.js: skip every 2nd rain update on low tier (halves iterations) - #35 index.html: responsive HUD with clamp(), mobile stack layout <500px - #36 vite.config.js: code-split Three.js into separate cacheable chunk Build output: - App code: 28.7KB (was bundled into 514KB single chunk) - Three.js: 486KB (cached independently after first visit) - FPS: 31 (up from 28-29) --- index.html | 15 ++++++++++----- js/agents.js | 52 ++++++++++++++++++++++++++++++++++++-------------- js/effects.js | 14 +++++++++++++- js/main.js | 23 ++++++++++++++++++++-- vite.config.js | 7 +++++++ 5 files changed, 89 insertions(+), 22 deletions(-) diff --git a/index.html b/index.html index 862a9d4..a72039e 100644 --- a/index.html +++ b/index.html @@ -36,21 +36,21 @@ } #hud { position: fixed; top: 16px; left: 16px; - color: #00ff41; font-size: 12px; line-height: 1.6; + color: #00ff41; font-size: clamp(10px, 1.5vw, 14px); line-height: 1.6; text-shadow: 0 0 8px #00ff41; pointer-events: none; } - #hud h1 { font-size: 16px; letter-spacing: 4px; margin-bottom: 8px; color: #00ff88; } + #hud h1 { font-size: clamp(12px, 2vw, 18px); letter-spacing: clamp(2px, 0.4vw, 4px); margin-bottom: 8px; color: #00ff88; } #status-panel { position: fixed; top: 16px; right: 16px; - color: #00ff41; font-size: 11px; line-height: 1.8; + color: #00ff41; font-size: clamp(9px, 1.2vw, 12px); line-height: 1.8; text-shadow: 0 0 6px #00ff41; max-width: 240px; } #status-panel .label { color: #007722; } #chat-panel { position: fixed; bottom: 16px; left: 16px; right: 16px; max-height: 180px; overflow-y: auto; - color: #00ff41; font-size: 11px; line-height: 1.6; + color: #00ff41; font-size: clamp(9px, 1.2vw, 12px); line-height: 1.6; text-shadow: 0 0 4px #00ff41; pointer-events: none; } @@ -58,7 +58,7 @@ .chat-entry .agent-name { color: #00ff88; font-weight: bold; } #connection-status { position: fixed; bottom: 16px; right: 16px; - font-size: 11px; color: #555; + font-size: clamp(9px, 1.2vw, 12px); color: #555; } #connection-status.connected { color: #00ff41; text-shadow: 0 0 6px #00ff41; } @@ -69,6 +69,11 @@ #chat-panel { bottom: calc(16px + env(safe-area-inset-bottom)); left: calc(16px + env(safe-area-inset-left)); right: calc(16px + env(safe-area-inset-right)); } #connection-status { bottom: calc(16px + env(safe-area-inset-bottom)); right: calc(16px + env(safe-area-inset-right)); } } + + /* Stack status panel below HUD on narrow viewports (must come AFTER @supports) */ + @media (max-width: 500px) { + #status-panel { top: 100px !important; left: 16px; right: auto; } + } diff --git a/js/agents.js b/js/agents.js index 710be8a..96c0270 100644 --- a/js/agents.js +++ b/js/agents.js @@ -5,6 +5,20 @@ const agents = new Map(); let scene; let connectionLines = []; +/* ── Shared geometries (created once, reused by all agents) ── */ +const SHARED_GEO = { + core: new THREE.IcosahedronGeometry(0.7, 1), + ring: new THREE.TorusGeometry(1.1, 0.04, 8, 32), + glow: new THREE.SphereGeometry(1.3, 16, 16), +}; + +/* ── Shared connection line material (one instance for all lines) ── */ +const CONNECTION_MAT = new THREE.LineBasicMaterial({ + color: 0x003300, + transparent: true, + opacity: 0.4, +}); + class Agent { constructor(def) { this.id = def.id; @@ -23,7 +37,8 @@ class Agent { } _buildMeshes() { - const mat = new THREE.MeshStandardMaterial({ + // Per-agent materials (need unique color + mutable emissiveIntensity) + const coreMat = new THREE.MeshStandardMaterial({ color: this.color, emissive: this.color, emissiveIntensity: 0.4, @@ -31,24 +46,21 @@ class Agent { metalness: 0.8, }); - const geo = new THREE.IcosahedronGeometry(0.7, 1); - this.core = new THREE.Mesh(geo, mat); + this.core = new THREE.Mesh(SHARED_GEO.core, coreMat); this.group.add(this.core); - const ringGeo = new THREE.TorusGeometry(1.1, 0.04, 8, 32); const ringMat = new THREE.MeshBasicMaterial({ color: this.color, transparent: true, opacity: 0.5 }); - this.ring = new THREE.Mesh(ringGeo, ringMat); + this.ring = new THREE.Mesh(SHARED_GEO.ring, ringMat); this.ring.rotation.x = Math.PI / 2; this.group.add(this.ring); - const glowGeo = new THREE.SphereGeometry(1.3, 16, 16); const glowMat = new THREE.MeshBasicMaterial({ color: this.color, transparent: true, opacity: 0.05, side: THREE.BackSide, }); - this.glow = new THREE.Mesh(glowGeo, glowMat); + this.glow = new THREE.Mesh(SHARED_GEO.glow, glowMat); this.group.add(this.glow); const light = new THREE.PointLight(this.color, 1.5, 10); @@ -97,6 +109,18 @@ class Agent { setState(state) { this.state = state; } + + /** + * Dispose per-agent GPU resources (materials + textures). + * Shared geometries are NOT disposed here — they outlive individual agents. + */ + dispose() { + this.core.material.dispose(); + this.ring.material.dispose(); + this.glow.material.dispose(); + this.sprite.material.map.dispose(); + this.sprite.material.dispose(); + } } export function initAgents(sceneRef) { @@ -112,15 +136,15 @@ export function initAgents(sceneRef) { } function buildConnectionLines() { - connectionLines.forEach(l => scene.remove(l)); + // Dispose old line geometries before removing + connectionLines.forEach(l => { + scene.remove(l); + l.geometry.dispose(); + // Material is shared — do NOT dispose here + }); connectionLines = []; const agentList = [...agents.values()]; - const lineMat = new THREE.LineBasicMaterial({ - color: 0x003300, - transparent: true, - opacity: 0.4, - }); for (let i = 0; i < agentList.length; i++) { for (let j = i + 1; j < agentList.length; j++) { @@ -129,7 +153,7 @@ function buildConnectionLines() { if (a.position.distanceTo(b.position) <= 8) { const points = [a.position.clone(), b.position.clone()]; const geo = new THREE.BufferGeometry().setFromPoints(points); - const line = new THREE.Line(geo, lineMat.clone()); + const line = new THREE.Line(geo, CONNECTION_MAT); connectionLines.push(line); scene.add(line); } diff --git a/js/effects.js b/js/effects.js index 513c549..4e1af63 100644 --- a/js/effects.js +++ b/js/effects.js @@ -5,9 +5,12 @@ let rainParticles; let rainPositions; let rainVelocities; let rainCount = 0; +let skipFrames = 0; // 0 = update every frame, 1 = every 2nd frame +let frameCounter = 0; export function initEffects(scene) { const tier = getQualityTier(); + skipFrames = tier === 'low' ? 1 : 0; // Low tier: update rain every 2nd frame initMatrixRain(scene, tier); initStarfield(scene, tier); } @@ -77,8 +80,17 @@ function initStarfield(scene, tier) { export function updateEffects(_time) { if (!rainParticles) return; + // On low tier, skip every other frame to halve iteration cost + if (skipFrames > 0) { + frameCounter++; + if (frameCounter % (skipFrames + 1) !== 0) return; + } + + // When skipping frames, multiply velocity to maintain visual speed + const velocityMul = skipFrames > 0 ? (skipFrames + 1) : 1; + for (let i = 0; i < rainCount; i++) { - rainPositions[i * 3 + 1] -= rainVelocities[i]; + rainPositions[i * 3 + 1] -= rainVelocities[i] * velocityMul; if (rainPositions[i * 3 + 1] < -1) { rainPositions[i * 3 + 1] = 40 + Math.random() * 20; rainPositions[i * 3] = (Math.random() - 0.5) * 100; diff --git a/js/main.js b/js/main.js index 8040cf9..dda2fb9 100644 --- a/js/main.js +++ b/js/main.js @@ -18,14 +18,21 @@ function main() { initUI(); initWebSocket(scene); - window.addEventListener('resize', () => onWindowResize(camera, renderer)); + // Debounce resize to 1 call per frame (avoids dozens of framebuffer re-allocations during drag) + let resizeFrame = null; + window.addEventListener('resize', () => { + if (resizeFrame) cancelAnimationFrame(resizeFrame); + resizeFrame = requestAnimationFrame(() => onWindowResize(camera, renderer)); + }); // Dismiss loading screen const loadingScreen = document.getElementById('loading-screen'); if (loadingScreen) loadingScreen.classList.add('hidden'); + let rafId = null; + function animate() { - requestAnimationFrame(animate); + rafId = requestAnimationFrame(animate); const now = performance.now(); frameCount++; @@ -48,6 +55,18 @@ function main() { renderer.render(scene, camera); } + // Pause rendering when tab is backgrounded (saves battery on iPad PWA) + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + } else { + if (!rafId) animate(); + } + }); + animate(); } diff --git a/vite.config.js b/vite.config.js index de86f5d..165bcfb 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,6 +6,13 @@ export default defineConfig({ outDir: 'dist', assetsDir: 'assets', target: 'esnext', + rollupOptions: { + output: { + manualChunks: { + three: ['three'], + }, + }, + }, }, server: { host: true,