Performance Optimization
AdvancedAdvanced performance optimization techniques for Web Engine. Learn about frame budgets, memory management, zero-GC patterns, and profiling tools.
Web Engine is built for high-performance 3D games on the web. This guide covers advanced optimization techniques, memory management patterns, and profiling tools to keep your game running at 60 FPS.
Frame Budget Management#
At 60 FPS, you have 16.67ms per frame. Budget your time carefully:
Frame Budget (16.67ms at 60 FPS):├── Input ~0.5ms ✓ Minimal├── Network ~0.5ms ✓ Minimal├── Logic ~2.0ms ⚠ Watch scripts├── Physics ~3.0ms ⚠ Main bottleneck├── Animation ~1.0ms ✓ Acceptable├── Audio ~0.5ms ✓ Minimal├── Render ~7.0ms ⚠ Draw calls└── PostRender ~2.0ms ✓ Overhead ------ 16.67ms totalPerformance Budget
If any single phase exceeds 10ms, you will drop frames. Use the FrameProfiler to identify bottlenecks.
Input Phase
Keep under 1ms. Pre-allocate input buffers.
Physics Phase
Most expensive. Reduce collider count, use simpler shapes.
Render Phase
Minimize draw calls with instancing and batching.
Zero-GC Patterns#
Garbage collection pauses can cause frame drops. Follow these patterns to avoid allocations in the hot path:
Object Pooling#
import { Vector3Pool, QuaternionPool } from "@web-engine-dev/core"; // ❌ BAD - Allocates every framefunction updatePosition(eid: number) { const offset = new THREE.Vector3(1, 0, 0); const position = getPosition(eid); position.add(offset);} // ✅ GOOD - Uses object poolfunction updatePosition(eid: number) { const offset = Vector3Pool.acquire(); offset.set(1, 0, 0); const position = getPosition(eid); position.add(offset); Vector3Pool.release(offset);}Web Engine provides pre-allocated pools for common Three.js objects:
// Global pools (acquire/release pattern)import { Vector3Pool, // THREE.Vector3 pool QuaternionPool, // THREE.Quaternion pool Matrix4Pool, // THREE.Matrix4 pool Box3Pool, // THREE.Box3 pool EulerPool, // THREE.Euler pool} from "@web-engine-dev/core"; // Fast ring-buffer pools (no release needed, automatic rotation)import { FastVector3Pool, FastQuaternionPool, FastMatrix4Pool, FastColorPool,} from "@web-engine-dev/core"; // Use fast pools for temporary calculationsconst temp = FastVector3Pool.acquire();temp.set(x, y, z);// No need to release - automatically recycledPre-Allocated Buffers#
// ❌ BAD - Creates new array every framefunction getVisibleEntities(entities: number[]): number[] { return entities.filter(eid => isVisible(eid));} // ✅ GOOD - Reuses bufferconst visibleBuffer = new Uint32Array(10000);let visibleCount = 0; function getVisibleEntities(entities: number[]) { visibleCount = 0; for (let i = 0; i < entities.length; i++) { const eid = entities[i]; if (isVisible(eid)) { visibleBuffer[visibleCount++] = eid; } } return { buffer: visibleBuffer, count: visibleCount };}Avoid Closures in Hot Paths#
// ❌ BAD - Creates closure every frameentities.forEach(eid => { updateTransform(eid, delta);}); // ✅ GOOD - No closure allocationfor (let i = 0; i < entities.length; i++) { updateTransform(entities[i], delta);}Memory Optimization#
ECS Component Storage#
Web Engine uses bitECS for data-oriented storage. Components are stored in flat TypedArrays for cache-friendly iteration:
// Components are backed by TypedArraysconst Transform = defineComponent({ x: Types.f32, // Float32Array y: Types.f32, z: Types.f32, rotX: Types.f32, rotY: Types.f32, rotZ: Types.f32, rotW: Types.f32,}); // Fast iteration over packed dataconst entities = query(world, [Transform]);for (let i = 0; i < entities.length; i++) { const eid = entities[i]; // Direct array access - no object allocation Transform.x[eid] += deltaX; Transform.y[eid] += deltaY; Transform.z[eid] += deltaZ;}Asset Streaming#
Load assets progressively to reduce initial memory footprint:
// Enable asset streaming in system configinitSystems({ enableAssetStreaming: true, enableComputeCulling: true,}); // Assets are loaded/unloaded based on camera distance// Distant objects use LOD meshes or are culled entirelyProfiling Tools#
FrameProfiler#
import { frameProfiler } from "@web-engine-dev/core"; // Get recent frame statisticsconst stats = frameProfiler.getStatistics(); console.log(`Average frame time: ${stats.averageFrameTime.toFixed(2)}ms`);console.log(`95th percentile: ${stats.p95FrameTime.toFixed(2)}ms`);console.log(`99th percentile: ${stats.p99FrameTime.toFixed(2)}ms`); // Get slowest systemsstats.slowestSystems.forEach(sys => { console.log(`${sys.name}: avg=${sys.avgTime.toFixed(2)}ms max=${sys.maxTime.toFixed(2)}ms`);}); // Get phase breakdownObject.entries(stats.averagePhaseTimes).forEach(([phase, time]) => { console.log(`${phase}: ${time.toFixed(2)}ms`);});PerformanceMonitor#
import { PerformanceMonitor } from "@web-engine-dev/logging";import { logger } from "./logger"; const perfMonitor = new PerformanceMonitor(logger); // In your game loopfunction gameLoop(timestamp: number) { perfMonitor.beginFrame(timestamp); perfMonitor.beginUpdate(); // ... update logic perfMonitor.endUpdate(); perfMonitor.beginRender(); // ... rendering perfMonitor.endRender(); perfMonitor.endFrame(); requestAnimationFrame(gameLoop);} // Get performance snapshotconst snapshot = perfMonitor.getSnapshot();console.log(`FPS: ${snapshot.fps.avg.toFixed(1)}`);console.log(`Frame time: ${snapshot.frameTime.avg.toFixed(2)}ms`);Chrome DevTools Profiling#
Use Chrome DevTools Performance panel to identify bottlenecks:
- Open DevTools (F12) and go to the Performance tab
- Click Record and interact with your game for 5-10 seconds
- Stop recording and analyze the flame graph
- Look for long tasks (yellow bars) exceeding 16.67ms
- Check the Bottom-Up tab to find expensive function calls
Memory Profiler
Use the Memory tab in DevTools to take heap snapshots. Look for unexpected object retention and memory leaks. The Allocation Timeline can show you exactly when allocations occur.
Performance Metrics#
Key metrics to monitor in production:
Frame Time
Target: < 16.67ms for 60 FPS. Monitor 95th and 99th percentiles.
Heap Size
Watch for steady growth (memory leaks). Aim for stable sawtooth pattern.
Draw Calls
Target: < 100 draw calls per frame. Use instancing and batching.
Triangle Count
Target: < 500k triangles on screen. Use LOD and culling.
Optimization Checklist#
Performance Checklist
- Use object pools for temporary math objects
- Pre-allocate arrays and buffers outside loops
- Avoid array methods like .map(), .filter(), .reduce() in hot paths
- Use explicit for-loops instead of .forEach()
- Minimize physics collider count and complexity
- Enable instancing for repeated meshes
- Use LOD (Level of Detail) for distant objects
- Enable occlusion culling and frustum culling
- Compress textures (KTX2, Basis Universal)
- Reduce shader complexity and overdraw
- Profile regularly with FrameProfiler and Chrome DevTools
Best Practices#
// 1. Pre-allocate outside loopsconst _tempVec3 = new THREE.Vector3();const _tempQuat = new THREE.Quaternion(); function updateSystem(entities: number[]) { for (let i = 0; i < entities.length; i++) { // Reuse pre-allocated objects _tempVec3.set(x, y, z); }} // 2. Use TypedArrays for large datasetsconst positions = new Float32Array(10000 * 3);const velocities = new Float32Array(10000 * 3); // 3. Batch operations// ❌ BAD - Updates each object individuallyobjects.forEach(obj => scene.add(obj)); // ✅ GOOD - Single batch updateconst group = new THREE.Group();objects.forEach(obj => group.add(obj));scene.add(group); // 4. Defer expensive operationslet frameCount = 0;function update() { frameCount++; // Only run expensive operations every N frames if (frameCount % 10 === 0) { updateNavMesh(); } if (frameCount % 30 === 0) { rebuildSpatialIndex(); }}