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#
| Phase | Target (ms) | Description |
|---|---|---|
| Input | 0.5 | Controller and keyboard input processing |
| Logic | 2.0 | Game logic and AI updates |
| Physics | 3.0 | Physics simulation and collision detection |
| Animation | 1.5 | Skeletal and sprite animations |
| Rendering | 8.0 | Draw calls, shaders, and GPU work |
| Post-Processing | 1.0 | Effects like bloom, SSAO |
| Buffer | 0.67 | Safety margin for frame variance |
Frame Budget Warning
Monitoring Frame Budget#
import { performanceMonitor, frameProfiler } from '@stem/core/perf'; // Get frame statisticsconst 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 breakdownconsole.log('Phase times:', stats.averagePhaseTimes);console.log('Slowest systems:', stats.slowestSystems); // Check for warningsconst 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 trackingmemoryTracker.setEnabled(true); // Get memory statisticsconst 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 leaksconst 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 vectorconst position = FastVector3Pool.acquire();position.set(x, y, z); // Use it...const distance = position.distanceTo(target); // Release it back to the poolFastVector3Pool.release(position); // Create custom pools for game objectsclass 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 renderingconst 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 1000Mesh Batching#
Combine static meshes that share materials to reduce draw calls.
import { BufferGeometryUtils } from 'three/examples/jsm/utils/BufferGeometryUtils'; // Combine multiple static geometriesconst geometries = [geometry1, geometry2, geometry3];const merged = BufferGeometryUtils.mergeGeometries(geometries); // One mesh instead of threeconst 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 texturesconst material1 = new MeshStandardMaterial({ map: texture1 });const material2 = new MeshStandardMaterial({ map: texture2 }); // Use one material with a texture atlasconst atlas = createTextureAtlas([texture1, texture2]);const material = new MeshStandardMaterial({ map: atlas }); // Offset UVs to select the correct region in the atlasgeometry.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 textureconst loader = new KTX2Loader();loader.setTranscoderPath('/basis/');loader.detectSupport(renderer); const texture = await loader.loadAsync('texture.ktx2');texture.generateMipmaps = false; // Already has mipmapstexture.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 systemconst 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 frameconst distance = Math.sqrt(dx * dx + dy * dy);if (distance < 10) { // do something} // Good: Use squared distanceconst distanceSq = dx * dx + dy * dy;if (distanceSq < 100) { // 10 * 10 // do something} // Bad: Trig in loopfor (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 tableconst 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#
| Symptom | Cause | Solution |
|---|---|---|
| High system time | Complex logic or AI | Optimize algorithms, spread work across frames |
| Many GC events | Allocations in hot paths | Use object pools, TypedArrays, reuse objects |
| Slow physics | Too many rigidbodies | Use static colliders, reduce simulation frequency |
| Input lag | Blocking operations | Move expensive work to web workers |
GPU Bottlenecks#
| Symptom | Cause | Solution |
|---|---|---|
| High draw calls | Too many meshes | Use instancing, merge meshes, texture atlases |
| High triangle count | Complex geometry | Use LOD system, reduce poly count, frustum culling |
| Slow fill rate | Too many pixels | Reduce resolution, optimize shaders, disable effects |
| Shader compile time | Many material variants | Reduce material count, use uber shaders |
Identifying Bottlenecks#
import { performanceMonitor, frameProfiler } from '@stem/core/perf'; // Get performance summaryconst summary = performanceMonitor.getSummary(); // Check if CPU or GPU boundif (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 profileconst frames = frameProfiler.getRecentFrames(60); // Last 60 framesconst 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
• 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