Performance Optimization
Web games run in a constrained environment. This guide covers the tools and techniques for identifying bottlenecks and optimizing your game to run smoothly across target devices.
Profiling Tools
Always measure before optimizing. The engine provides built-in profiling at every layer.
System Timings
typescript
import { PerformanceMonitor } from '@web-engine-dev/devtools';
const perf = world.getResource(PerformanceMonitor);
// Enable per-system timing
perf.enableSystemTimings(true);
// Read timings each frame
function PerformanceLogSystem(world: World): void {
const timings = world.getResource(SystemTimings);
if (timings.anyExceeds(5)) {
// any system over 5ms?
console.table(timings.sorted()); // log sorted by duration
}
}Render Stats
typescript
import { RenderStats } from '@web-engine-dev/renderer';
function RenderStatsHUDSystem(world: World): void {
const stats = world.getResource(RenderStats);
updateDebugOverlay({
fps: stats.fps,
frameTime: `${stats.frameTimeMs.toFixed(2)}ms`,
drawCalls: stats.drawCalls,
triangles: stats.triangleCount.toLocaleString(),
texMemory: `${stats.textureMemoryMB.toFixed(1)}MB`,
vertexMem: `${stats.vertexMemoryMB.toFixed(1)}MB`,
});
}Memory Allocation Tracking
typescript
// Enable GC pressure tracking in dev mode
import { AllocationTracker } from '@web-engine-dev/devtools';
AllocationTracker.enable();
// After running for a while
const report = AllocationTracker.report();
console.log('Hot allocation sites:', report.topSites);
// Look for per-frame allocations: these drive GC pausesECS Performance
Zero-Allocation Hot Paths
The most common cause of ECS performance issues is per-frame heap allocation inside systems:
typescript
// BAD: allocates a new object every frame for every entity
function BadMovementSystem(world: World): void {
const q = world.query().with(Position, Velocity).build();
for (const {
entity,
components: [pos, vel],
} of world.run(q)) {
// This creates a new object on the heap every frame
world.insert(entity, Position, { x: pos.x + vel.x, y: pos.y + vel.y, z: pos.z + vel.z });
}
}
// GOOD: use a pre-allocated scratchpad or mutate in-place via a pool
const _scratchPos = { x: 0, y: 0, z: 0 };
function GoodMovementSystem(world: World): void {
const q = world.query().with(Position, Velocity).build();
for (const {
entity,
components: [pos, vel],
} of world.run(q)) {
_scratchPos.x = pos.x + vel.x;
_scratchPos.y = pos.y + vel.y;
_scratchPos.z = pos.z + vel.z;
world.insert(entity, Position, _scratchPos);
}
}Narrow Queries
Overly broad queries iterate more entities than needed:
typescript
// BAD: queries ALL entities with Health, most won't need updating each frame
const allHealthQuery = world.query().with(Health).build();
for (const { entity, components: [health] } of world.run(allHealthQuery)) { ... }
// GOOD: narrow with additional component filters so only relevant entities are iterated
const activeHealthQuery = world.query().with(Health, Active).build();
for (const { entity, components: [health] } of world.run(activeHealthQuery)) { ... }
// GOOD: use tags to narrow (Active enemies only)
const activeEnemyPosQuery = world.query().with(Position, Enemy, Active).build();
for (const { entity, components: [pos] } of world.run(activeEnemyPosQuery)) { ... }Parallel Systems
Mark independent systems to run in parallel using worker threads:
typescript
// Systems with non-overlapping component access can run in parallel
world.addSystemSet(
'UpdateParallel',
[
MovementSystem, // writes Position, Velocity
AnimationSystem, // writes AnimState (no overlap)
AudioSourceSystem, // writes AudioSource (no overlap)
],
{ parallel: true }
);Object Pooling
Avoid spawning/despawning frequently, pool entities instead:
typescript
import { EntityPool } from '@web-engine-dev/pooling';
// Pre-allocate a pool of 200 bullets
const bulletPool = new EntityPool(world, {
prefab: 'Bullet',
initialSize: 200,
maxSize: 500,
autoExpand: true,
});
// Acquire from pool (fast: no archetype migration)
function SpawnBulletSystem(world: World): void {
if (input.justPressed('fire')) {
const bullet = bulletPool.acquire();
world.insert(bullet, Position, { x: playerPos.x, y: playerPos.y });
world.insert(bullet, Velocity, { x: 0, y: -500 });
world.insert(bullet, Active, {});
}
}
// Release back to pool (doesn't actually despawn)
function BulletLifetimeSystem(world: World): void {
const q = world.query().with(Bullet, Active).build();
for (const {
entity,
components: [bullet],
} of world.run(q)) {
if (bullet.lifetime <= 0 || isOutOfBounds(entity)) {
bulletPool.release(entity); // marks as inactive, returns to pool
}
}
}Rendering Performance
Frustum & Occlusion Culling
Enable GPU-driven culling (default for large scenes):
typescript
world.insertResource(CullingSettings, {
frustumCulling: true, // don't render off-screen objects
occlusionCulling: true, // don't render objects behind other objects
distanceCulling: {
enabled: true,
maxDistance: 300, // cull beyond 300 world units
},
});Draw Call Batching
typescript
// Static objects (never move) are auto-batched into draw call groups
world.insert(rockEntity, StaticRenderer, {}); // tagged as static
// Dynamic objects with the same material are batched via GPU instancing
world.insertResource(InstancingSettings, {
enabled: true,
minInstancesForBatch: 4, // batch if 4+ of same mesh/material
});Texture Atlases
Reduce draw calls by packing sprites into atlases:
typescript
import { TextureAtlasBuilder } from '@web-engine-dev/texture-compression';
// Build in the asset pipeline (offline)
const atlas = await TextureAtlasBuilder.build({
inputs: ['sprites/hero/**/*.png', 'sprites/enemies/**/*.png', 'sprites/pickup/**/*.png'],
maxSize: 2048,
padding: 2,
output: 'dist/atlases/characters.png',
});LOD System
typescript
world.insertResource(LODSettings, {
auto: true, // auto-assign LOD based on screen coverage
screenSizeBias: 1.0,
// Override per quality tier
qualityPresets: {
low: { lodBias: 0.5 }, // use lower LOD earlier
medium: { lodBias: 1.0 },
high: { lodBias: 1.5 }, // use highest LOD longer
},
});Asset Streaming
typescript
import { StreamingSystem } from '@web-engine-dev/streaming';
world.addSystem(StreamingSystem);
world.insertResource(StreamingSettings, {
// Load assets in advance as the player approaches
preloadRadius: 200, // world units ahead of current chunk
unloadDelay: 10, // seconds before unloading a background chunk
maxConcurrentLoads: 4,
prioritizeVisible: true,
});Mobile Optimization Checklist
For games targeting mobile browsers:
typescript
// Detect and adapt to device performance tier
import { DeviceCapabilities } from '@web-engine-dev/platform';
const caps = world.getResource(DeviceCapabilities);
if (caps.tier === 'low') {
// Reduce quality settings
world.insertResource(RenderQuality, {
shadowQuality: 'off',
particleCount: 0.25, // 25% of full particle budget
targetFPS: 30,
textureQuality: 'half',
});
// Disable expensive post-processing
pp.setEnabled('bloom', false);
pp.setEnabled('ambientOcclusion', false);
// Use simpler physics
world.insertResource(PhysicsSettings, {
substeps: 1, // instead of 4
});
}Key mobile rules:
- Target 60fps on mid-tier, 30fps minimum on low-tier, measure on real devices
- Total draw calls ≤ 100, batch everything
- Texture memory ≤ 128MB, use compressed formats (BC7/ASTC via
@web-engine-dev/texture-compression) - Zero per-frame GC, profile allocations, eliminate all hot allocations
- Avoid large uniforms, keep shader constant buffers minimal
Performance Budget Template
| Category | Target (60fps) | Budget |
|---|---|---|
| Frame time | 16.67ms | 100% |
| ECS systems | - | 3ms |
| Physics | - | 2ms |
| Render (CPU) | - | 2ms |
| GPU | - | 8ms |
| Audio | - | 0.3ms |
| Misc | - | 1ms |
| Draw calls | ≤ 200 | - |
| Triangle count | ≤ 500K | - |
| Texture memory | ≤ 256MB | - |
Next Steps
- Deployment, build optimization flags for production
- Visual Effects, particle LOD and culling
- 2D Development, sprite batching and tilemap chunking