Optimization Guide

Learn how to optimize your game for maximum performance with frame budget management, memory optimization, and draw call reduction.

STEM is designed for high-performance game development with a strict 16.67ms frame budget (60 FPS). This guide covers essential optimization techniques to keep your game running smoothly.

Frame Budget Management#

The frame budget is the maximum time available to render a single frame. For 60 FPS, you have 16.67ms per frame. STEM helps you stay within this budget.

Budget Breakdown#

PhaseTarget (ms)Description
Input0.5Controller and keyboard input processing
Logic2.0Game logic and AI updates
Physics3.0Physics simulation and collision detection
Animation1.5Skeletal and sprite animations
Rendering8.0Draw calls, shaders, and GPU work
Post-Processing1.0Effects like bloom, SSAO
Buffer0.67Safety margin for frame variance

Frame Budget Warning

If any phase consistently exceeds its budget, you'll drop below 60 FPS. Use the PerformanceMonitor to identify bottlenecks.

Monitoring Frame Budget#

import { performanceMonitor, frameProfiler } from '@stem/core/perf';
// Get frame statistics
const stats = frameProfiler.getStatistics();
console.log('Average frame time:', stats.averageFrameTime, 'ms');
console.log('P95 frame time:', stats.p95FrameTime, 'ms');
console.log('P99 frame time:', stats.p99FrameTime, 'ms');
// Get phase breakdown
console.log('Phase times:', stats.averagePhaseTimes);
console.log('Slowest systems:', stats.slowestSystems);
// Check for warnings
const warnings = performanceMonitor.checkPerformance();
warnings.forEach(warning => {
console.log(`[${warning.severity}] ${warning.message}`);
console.log('Recommendation:', warning.recommendation);
});

Memory Optimization#

Memory management is critical for maintaining consistent frame rates. STEM uses object pooling and typed arrays to minimize garbage collection.

Zero-GC Hot Paths#

Hot paths (code executed every frame) should avoid allocations. STEM provides tools to achieve zero garbage collection in critical systems.

import { memoryTracker } from '@stem/core/perf';
// Enable memory tracking
memoryTracker.setEnabled(true);
// Get memory statistics
const stats = memoryTracker.getStats();
console.log('Current:', stats.current.usedJSHeapSize / 1024 / 1024, 'MB');
console.log('Peak:', stats.peak.usedJSHeapSize / 1024 / 1024, 'MB');
console.log('Trend:', stats.trend); // 'increasing' | 'decreasing' | 'stable'
console.log('GC events:', stats.gcEvents);
// Detect memory leaks
const leak = memoryTracker.detectLeak();
if (leak?.isLeaking) {
console.warn('Memory leak detected!');
console.log('Leak rate:', leak.leakRate, 'bytes/sec');
console.log('Confidence:', leak.confidence);
}

Object Pooling#

Use object pools for frequently created/destroyed objects like projectiles, particles, and temporary math objects.

import { FastVector3Pool } from '@stem/core/utils';
// Acquire a pooled vector
const position = FastVector3Pool.acquire();
position.set(x, y, z);
// Use it...
const distance = position.distanceTo(target);
// Release it back to the pool
FastVector3Pool.release(position);
// Create custom pools for game objects
class BulletPool {
private pool: Bullet[] = [];
acquire(): Bullet {
return this.pool.pop() || new Bullet();
}
release(bullet: Bullet): void {
bullet.reset();
this.pool.push(bullet);
}
}

Memory Best Practices#

  • Avoid allocations in update loops — Pre-allocate arrays and reuse objects instead of creating new ones each frame
  • Use TypedArrays for large datasets — Float32Array and Uint32Array are more memory-efficient than regular arrays
  • Clear references to unused objects — Help the garbage collector by nulling references to large objects
  • Monitor memory trends — Watch for gradual memory growth that indicates leaks
  • Dispose of Three.js resources — Call dispose() on geometries, materials, and textures when done

Draw Call Reduction#

Draw calls are expensive. Each draw call has CPU overhead for state changes and GPU commands. Target 100-200 draw calls for optimal performance.

GPU Instancing#

Instancing renders multiple copies of the same mesh in a single draw call. STEM's InstancedRenderSystem handles this automatically.

import { addComponent, Instanced } from '@stem/core/ecs';
// Mark entity for instanced rendering
const treeEntity = world.createEntity();
addComponent(world, Instanced, treeEntity);
Instanced.assetId[treeEntity] = treeAssetId;
// All trees with the same asset will be batched automatically
// 1000 trees = 1 draw call instead of 1000

Mesh Batching#

Combine static meshes that share materials to reduce draw calls.

import { BufferGeometryUtils } from 'three/examples/jsm/utils/BufferGeometryUtils';
// Combine multiple static geometries
const geometries = [geometry1, geometry2, geometry3];
const merged = BufferGeometryUtils.mergeGeometries(geometries);
// One mesh instead of three
const mesh = new THREE.Mesh(merged, sharedMaterial);

Texture Atlases#

Combine multiple textures into a single atlas to reduce material switches and draw calls.

// Instead of multiple materials with different textures
const material1 = new MeshStandardMaterial({ map: texture1 });
const material2 = new MeshStandardMaterial({ map: texture2 });
// Use one material with a texture atlas
const atlas = createTextureAtlas([texture1, texture2]);
const material = new MeshStandardMaterial({ map: atlas });
// Offset UVs to select the correct region in the atlas
geometry.setAttribute('uv', new BufferAttribute(atlasUVs, 2));

Asset Optimization#

Optimized assets reduce load times, memory usage, and rendering cost.

Geometry Optimization#

  • Reduce polygon count — Use Blender's Decimate modifier or Simplygon for LOD generation
  • Remove unnecessary vertices — Clean up hidden geometry and merge duplicate vertices
  • Compress geometry — Use Draco compression for static meshes (60-80% reduction)
  • Use indexed geometry — Share vertices between triangles to reduce memory

Texture Optimization#

  • Use compressed formats — KTX2 with Basis Universal compression for web (75% smaller)
  • Generate mipmaps — Improves rendering quality and performance at distance
  • Resize to power-of-two — Better GPU memory layout and mipmap generation
  • Reduce texture resolution — 1024x1024 is often enough; 4096x4096 is rarely needed
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader';
// Load compressed texture
const loader = new KTX2Loader();
loader.setTranscoderPath('/basis/');
loader.detectSupport(renderer);
const texture = await loader.loadAsync('texture.ktx2');
texture.generateMipmaps = false; // Already has mipmaps
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();

Script Optimization#

Script performance directly impacts frame rate. Follow these guidelines for efficient scripting.

Efficient ECS Queries#

import { createTypedQuery } from '@stem/core/ecs';
// Create query once outside the system
const query = createTypedQuery([Transform, Velocity, Health]);
export const MySystem = (world: IWorld) => {
// Query entities (cached internally)
const entities = query(world);
// Process in tight loop (CPU cache friendly)
for (let i = 0; i < entities.length; i++) {
const eid = entities[i];
// Direct component access (no lookups)
const vx = Velocity.x[eid];
const vy = Velocity.y[eid];
Transform.position[eid][0] += vx;
Transform.position[eid][1] += vy;
}
return world;
};

Avoid Expensive Operations#

  • Cache Math.sqrt() calls — Use squared distances when possible: distSq = dx*dx + dy*dy
  • Avoid trigonometry in loops — Pre-compute sin/cos lookup tables for repeated angles
  • Use integer arithmetic — Bitwise operations are faster: x >> 1 instead of x / 2
  • Minimize array access — Cache array elements in local variables inside tight loops
// Bad: Expensive sqrt called every frame
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 10) {
// do something
}
// Good: Use squared distance
const distanceSq = dx * dx + dy * dy;
if (distanceSq < 100) { // 10 * 10
// do something
}
// Bad: Trig in loop
for (let i = 0; i < 360; i++) {
const angle = i * Math.PI / 180;
const x = Math.cos(angle);
const y = Math.sin(angle);
}
// Good: Pre-computed lookup table
const sinTable = new Float32Array(360);
const cosTable = new Float32Array(360);
for (let i = 0; i < 360; i++) {
const angle = i * Math.PI / 180;
sinTable[i] = Math.sin(angle);
cosTable[i] = Math.cos(angle);
}
for (let i = 0; i < 360; i++) {
const x = cosTable[i];
const y = sinTable[i];
}

Common Bottlenecks#

Learn to identify and fix the most common performance issues.

CPU Bottlenecks#

SymptomCauseSolution
High system timeComplex logic or AIOptimize algorithms, spread work across frames
Many GC eventsAllocations in hot pathsUse object pools, TypedArrays, reuse objects
Slow physicsToo many rigidbodiesUse static colliders, reduce simulation frequency
Input lagBlocking operationsMove expensive work to web workers

GPU Bottlenecks#

SymptomCauseSolution
High draw callsToo many meshesUse instancing, merge meshes, texture atlases
High triangle countComplex geometryUse LOD system, reduce poly count, frustum culling
Slow fill rateToo many pixelsReduce resolution, optimize shaders, disable effects
Shader compile timeMany material variantsReduce material count, use uber shaders

Identifying Bottlenecks#

import { performanceMonitor, frameProfiler } from '@stem/core/perf';
// Get performance summary
const summary = performanceMonitor.getSummary();
// Check if CPU or GPU bound
if (summary.frameTime > 16.67) {
// Find the slowest system
const slowest = summary.systems[0];
console.log('Bottleneck:', slowest.name, slowest.time, 'ms');
// Check allocation rate
const totalAlloc = Object.values(summary.allocations)
.reduce((sum, kb) => sum + kb, 0);
if (totalAlloc > 50) {
console.warn('High allocation rate:', totalAlloc, 'KB/frame');
}
// Check draw calls
if (summary.drawCalls > 500) {
console.warn('Too many draw calls:', summary.drawCalls);
}
}
// Get detailed frame profile
const frames = frameProfiler.getRecentFrames(60); // Last 60 frames
const avgHotPaths = {
render: frames.reduce((sum, f) => sum + f.hotPaths.render, 0) / frames.length,
physics: frames.reduce((sum, f) => sum + f.hotPaths.physics, 0) / frames.length,
script: frames.reduce((sum, f) => sum + f.hotPaths.script, 0) / frames.length,
};
console.log('Hot path breakdown:', avgHotPaths);

Performance Checklist

Before releasing your game:
• Target 60 FPS (16.67ms) on mid-range hardware
• Keep draw calls under 200
• Keep triangle count under 2M visible
• Maintain zero allocations in hot paths
• Memory usage under 512MB for web builds
• No GC pauses during gameplay
Documentation | Web Engine