perf: QA sprint v2 — 8 optimizations + responsive fixes

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)
This commit is contained in:
2026-03-19 00:27:13 +00:00
parent 916acde69c
commit 70f590ab9a
5 changed files with 89 additions and 22 deletions

View File

@@ -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; }
}
</style>
</head>
<body>

View File

@@ -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);
}

14
js/effects.js vendored
View File

@@ -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;

View File

@@ -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();
}

View File

@@ -6,6 +6,13 @@ export default defineConfig({
outDir: 'dist',
assetsDir: 'assets',
target: 'esnext',
rollupOptions: {
output: {
manualChunks: {
three: ['three'],
},
},
},
},
server: {
host: true,