Performance Optimization

Advanced

Advanced 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 total

Performance 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#

ObjectPool.ts
typescript
import { Vector3Pool, QuaternionPool } from "@web-engine-dev/core";
// ❌ BAD - Allocates every frame
function updatePosition(eid: number) {
const offset = new THREE.Vector3(1, 0, 0);
const position = getPosition(eid);
position.add(offset);
}
// ✅ GOOD - Uses object pool
function 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:

AvailablePools.ts
typescript
// 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 calculations
const temp = FastVector3Pool.acquire();
temp.set(x, y, z);
// No need to release - automatically recycled

Pre-Allocated Buffers#

PreAllocatedArrays.ts
typescript
// ❌ BAD - Creates new array every frame
function getVisibleEntities(entities: number[]): number[] {
return entities.filter(eid => isVisible(eid));
}
// ✅ GOOD - Reuses buffer
const 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#

AvoidClosures.ts
typescript
// ❌ BAD - Creates closure every frame
entities.forEach(eid => {
updateTransform(eid, delta);
});
// ✅ GOOD - No closure allocation
for (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:

ComponentStorage.ts
typescript
// Components are backed by TypedArrays
const 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 data
const 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:

AssetStreaming.ts
typescript
// Enable asset streaming in system config
initSystems({
enableAssetStreaming: true,
enableComputeCulling: true,
});
// Assets are loaded/unloaded based on camera distance
// Distant objects use LOD meshes or are culled entirely

Profiling Tools#

FrameProfiler#

FrameProfiler.ts
typescript
import { frameProfiler } from "@web-engine-dev/core";
// Get recent frame statistics
const 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 systems
stats.slowestSystems.forEach(sys => {
console.log(`${sys.name}: avg=${sys.avgTime.toFixed(2)}ms max=${sys.maxTime.toFixed(2)}ms`);
});
// Get phase breakdown
Object.entries(stats.averagePhaseTimes).forEach(([phase, time]) => {
console.log(`${phase}: ${time.toFixed(2)}ms`);
});

PerformanceMonitor#

PerformanceMonitor.ts
typescript
import { PerformanceMonitor } from "@web-engine-dev/logging";
import { logger } from "./logger";
const perfMonitor = new PerformanceMonitor(logger);
// In your game loop
function gameLoop(timestamp: number) {
perfMonitor.beginFrame(timestamp);
perfMonitor.beginUpdate();
// ... update logic
perfMonitor.endUpdate();
perfMonitor.beginRender();
// ... rendering
perfMonitor.endRender();
perfMonitor.endFrame();
requestAnimationFrame(gameLoop);
}
// Get performance snapshot
const 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#

PerformanceBestPractices.ts
typescript
// 1. Pre-allocate outside loops
const _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 datasets
const positions = new Float32Array(10000 * 3);
const velocities = new Float32Array(10000 * 3);
// 3. Batch operations
// ❌ BAD - Updates each object individually
objects.forEach(obj => scene.add(obj));
// ✅ GOOD - Single batch update
const group = new THREE.Group();
objects.forEach(obj => group.add(obj));
scene.add(group);
// 4. Defer expensive operations
let frameCount = 0;
function update() {
frameCount++;
// Only run expensive operations every N frames
if (frameCount % 10 === 0) {
updateNavMesh();
}
if (frameCount % 30 === 0) {
rebuildSpatialIndex();
}
}
Advanced | Web Engine Docs | Web Engine Docs