LOD System
Learn how to use Level of Detail (LOD) to optimize rendering performance by displaying different mesh complexity based on camera distance.
Level of Detail (LOD) is a rendering optimization technique that displays different versions of a mesh based on its distance from the camera. Distant objects use simpler geometry, reducing GPU load while maintaining visual quality.
Performance Impact
Level of Detail Concepts#
LOD works by creating multiple versions of the same mesh at different levels of detail. As the camera moves away from an object, the system automatically switches to lower-detail versions.
LOD Levels#
STEM supports 5 LOD levels (0-4) plus culling:
| Level | Distance | Quality | Triangle Budget |
|---|---|---|---|
| LOD 0 (Ultra) | < 10m | Full detail | 100% (1000 tris) |
| LOD 1 (High) | 10-25m | High quality | 50% (500 tris) |
| LOD 2 (Medium) | 25-50m | Medium quality | 25% (250 tris) |
| LOD 3 (Low) | 50-100m | Low quality | 10% (100 tris) |
| LOD 4 (Minimal) | 100-200m | Minimal/Billboard | 1% (10 tris) |
| Culled | > 200m | Not rendered | 0 tris |
Benefits of LOD#
- Reduced triangle count — Only render high detail where needed (close to camera)
- Better frame rates — Less GPU work for distant objects that occupy few pixels
- Scalable complexity — Support more objects in scene without performance hit
- Automatic optimization — System handles switching based on camera distance
- Visual quality maintained — Distant objects use less detail but look the same on screen
LOD Component Setup#
STEM provides RenderLOD and PhysicsLOD components for automatic LOD management. These work seamlessly with the InstancedRenderSystem.
Basic LOD Setup#
import { addComponent, RenderLOD, PhysicsLOD, Transform } from '@stem/core/ecs';import { addLODComponents } from '@stem/core/rendering/LODSystem'; // Create entity with LODconst entity = world.createEntity();addComponent(world, Transform, entity); // Add LOD components (automatically configured)addLODComponents(world, entity, { enableRenderLOD: true, // Render detail based on distance enablePhysicsLOD: true, // Physics complexity based on distance lodBias: 0, // 0 = default, positive = higher quality, negative = lower}); // LOD system automatically:// - Calculates distance to camera each frame// - Switches LOD levels based on thresholds// - Updates render quality and physics complexityInstanced LOD#
import { InstancedLODManager } from '@stem/core/rendering/InstancedLODManager'; // Configure LOD for an instanced assetInstancedLODManager.configureAsset({ assetId: treeAssetId, // Distance thresholds (squared for performance) distancesSq: new Float32Array([ 0, // LOD 0 starts at 0m 20 * 20, // LOD 1 at 20m (400 sq meters) 50 * 50, // LOD 2 at 50m (2500 sq meters) 100 * 100, // Cull at 100m (10000 sq meters) ]), // Optional: Different geometries per LOD level geometries: [ highDetailGeometry, // LOD 0 mediumDetailGeometry, // LOD 1 lowDetailGeometry, // LOD 2 null, // LOD 3 (culled) ], // Optional: Different materials per LOD level materials: [ standardMaterial, // LOD 0-1 standardMaterial, billboardMaterial, // LOD 2 (billboard) null, // LOD 3 (culled) ], // Enable smooth LOD transitions enableMorphing: true, morphDuration: 0.25, // 250ms transition}); // Simpler version (just distance thresholds)InstancedLODManager.configureAssetSimple( rockAssetId, [15, 40, 80], // LOD 1, LOD 2, Cull distances true // Enable morphing);LOD Distances#
Choosing appropriate LOD distances is crucial for balancing performance and visual quality. Consider object size and screen coverage.
Distance Guidelines#
| Object Type | LOD 1 | LOD 2 | LOD 3 | Cull |
|---|---|---|---|---|
| Small (rocks) | 5m | 15m | 40m | 80m |
| Medium (trees) | 15m | 40m | 80m | 150m |
| Large (buildings) | 40m | 100m | 200m | 400m |
| Huge (mountains) | 100m | 300m | 600m | 1000m |
| Tiny (grass) | 2m | 5m | 10m | 20m |
Screen Size Calculation#
// Calculate LOD distance based on screen sizefunction calculateLODDistance( objectRadius: number, targetScreenSize: number, // pixels fov: number = 60, screenHeight: number = 1080): number { const fovRadians = (fov * Math.PI) / 180; const tanHalfFov = Math.tan(fovRadians / 2); // Distance where object occupies targetScreenSize pixels const distance = (objectRadius * screenHeight) / (targetScreenSize * tanHalfFov); return distance;} // Example: Tree with 3m radius// LOD 1: When tree occupies ~100 pixelsconst lod1Distance = calculateLODDistance(3, 100);console.log('LOD 1 distance:', lod1Distance.toFixed(1), 'm'); // ~35m // LOD 2: When tree occupies ~50 pixelsconst lod2Distance = calculateLODDistance(3, 50);console.log('LOD 2 distance:', lod2Distance.toFixed(1), 'm'); // ~70m // Cull: When tree occupies ~10 pixelsconst cullDistance = calculateLODDistance(3, 10);console.log('Cull distance:', cullDistance.toFixed(1), 'm'); // ~350mHysteresis#
Hysteresis prevents rapid LOD switching (popping) when objects are near threshold distances. STEM uses a 5-meter margin by default.
import { LOD_CONFIG } from '@stem/core/rendering/LODSystem'; // Default hysteresis marginconsole.log('Hysteresis:', LOD_CONFIG.HYSTERESIS_MARGIN, 'm'); // 5m // Example with 25m LOD threshold:// - Distance decreasing: Switch to higher LOD at 20m (25 - 5)// - Distance increasing: Switch to lower LOD at 30m (25 + 5) // Configure per-asset hysteresisimport { InstancedLODManager } from '@stem/core/rendering/InstancedLODManager'; InstancedLODManager.setAssetHysteresis( assetId, 0.15 // 15% hysteresis band (prevents flickering)); // Cooldown prevents rapid switchingInstancedLODManager.setAssetCooldown( assetId, 0.5 // Minimum 0.5 seconds between LOD changes);LOD Transitions#
STEM supports both instant switching and smooth morphing between LOD levels to reduce visual popping.
Instant Switching#
// Default behavior: Instant LOD switch// Fast and efficient, but may have visible pop import { InstancedLODManager } from '@stem/core/rendering/InstancedLODManager'; InstancedLODManager.configureAsset({ assetId: rockAssetId, distancesSq: new Float32Array([0, 400, 2500, 10000]), geometries: [highGeo, medGeo, lowGeo, null], materials: [mat, mat, mat, null], enableMorphing: false, // Instant switching morphDuration: 0,}); // Benefits:// - Zero overhead// - No additional GPU work// - Simplest to implement // Drawbacks:// - Visible pop when switching// - More noticeable for nearby objectsLOD Morphing#
// Smooth transitions between LOD levels// Interpolates geometry over time to reduce popping import { InstancedLODManager } from '@stem/core/rendering/InstancedLODManager'; InstancedLODManager.configureAsset({ assetId: treeAssetId, distancesSq: new Float32Array([0, 400, 2500, 10000]), geometries: [highGeo, medGeo, lowGeo, null], materials: [mat, mat, mat, null], enableMorphing: true, // Smooth transitions morphDuration: 0.25, // 250ms fade}); // LOD morphing uses lodOffset attribute (0.0 - 1.0)// Shader interpolates between LOD levels: // Vertex shader example:// attribute float instanceLodOffset;// uniform sampler2D lodPosition0; // Current LOD// uniform sampler2D lodPosition1; // Next LOD//// vec3 pos0 = texture2D(lodPosition0, uv).xyz;// vec3 pos1 = texture2D(lodPosition1, uv).xyz;// vec3 position = mix(pos0, pos1, instanceLodOffset); // Benefits:// - Smooth, nearly invisible transitions// - Professional quality// - Reduces pop-in artifacts // Cost:// - ~0.5ms per 10K instances// - Requires LOD attribute in shader// - Slight GPU overhead during transitionAlpha Fading#
// Alternative: Fade LOD levels using opacity// Useful when morphing isn't possible // Custom shader approach:const fadingMaterial = new THREE.ShaderMaterial({ uniforms: { lodOffset: { value: 0 }, }, vertexShader: ` attribute float instanceLodOffset; varying float vLodOffset; void main() { vLodOffset = instanceLodOffset; // ... rest of vertex shader } `, fragmentShader: ` varying float vLodOffset; void main() { vec4 color = vec4(1.0); // Fade out during LOD transition color.a = 1.0 - smoothstep(0.8, 1.0, vLodOffset); gl_FragColor = color; } `, transparent: true,}); // Note: Alpha fading has fillrate cost// Only use for small objects or low instance countsAutomatic LOD Generation#
STEM can automatically generate LOD levels from high-detail meshes using decimation algorithms.
Mesh Decimation#
import { SimplifyModifier } from 'three/examples/jsm/modifiers/SimplifyModifier'; function generateLODs( geometry: THREE.BufferGeometry, reductionLevels: number[] = [0.5, 0.25, 0.1]): THREE.BufferGeometry[] { const modifier = new SimplifyModifier(); const lods: THREE.BufferGeometry[] = [geometry]; // LOD 0 = original reductionLevels.forEach(reduction => { const targetVertices = Math.floor( geometry.attributes.position.count * reduction ); const simplified = modifier.modify(geometry, targetVertices); lods.push(simplified); }); return lods;} // Usageconst highDetail = loadedGeometry;const lods = generateLODs(highDetail); console.log('LOD 0:', lods[0].attributes.position.count, 'vertices');console.log('LOD 1:', lods[1].attributes.position.count, 'vertices'); // ~50%console.log('LOD 2:', lods[2].attributes.position.count, 'vertices'); // ~25%console.log('LOD 3:', lods[3].attributes.position.count, 'vertices'); // ~10%Blender LOD Generation#
# Blender Python script to generate LOD levelsimport bpy def generate_lods(obj, ratios=[0.5, 0.25, 0.1]): """Generate LOD levels using Decimate modifier""" for i, ratio in enumerate(ratios): # Duplicate object lod_obj = obj.copy() lod_obj.data = obj.data.copy() lod_obj.name = f"{obj.name}_LOD{i+1}" bpy.context.collection.objects.link(lod_obj) # Add Decimate modifier decimate = lod_obj.modifiers.new(name="Decimate", type='DECIMATE') decimate.ratio = ratio decimate.use_collapse_triangulate = True # Apply modifier bpy.context.view_layer.objects.active = lod_obj bpy.ops.object.modifier_apply(modifier="Decimate") print(f"LOD {i+1}: {len(lod_obj.data.polygons)} faces ({ratio*100}%)") # Usageobj = bpy.context.active_objectgenerate_lods(obj) # Export each LOD separately# File > Export > glTF 2.0 (.glb)# tree_LOD0.glb (original)# tree_LOD1.glb (50%)# tree_LOD2.glb (25%)# tree_LOD3.glb (10%)Performance Impact#
LOD provides significant performance benefits, especially in large open worlds with many visible objects.
Triangle Reduction#
import { getLODStatistics } from '@stem/core/rendering/LODSystem'; // Get LOD distributionconst stats = getLODStatistics(world); console.log('Total entities:', stats.totalEntities);console.log('Render LOD distribution:', stats.renderLOD);// Example output:// {// 0: 50, // 50 entities at highest detail// 1: 200, // 200 at high detail// 2: 500, // 500 at medium detail// 3: 300, // 300 at low detail// 5: 150 // 150 culled (not rendered)// } // Calculate triangle savingsconst trianglesPerLOD = [1000, 500, 250, 100, 10, 0];let totalTriangles = 0;let baselineTriangles = 0; Object.entries(stats.renderLOD).forEach(([level, count]) => { const tris = trianglesPerLOD[Number(level)]; totalTriangles += count * tris; baselineTriangles += count * trianglesPerLOD[0]; // All at LOD 0}); console.log('Triangles with LOD:', totalTriangles.toLocaleString());console.log('Triangles without LOD:', baselineTriangles.toLocaleString());console.log('Reduction:', ((1 - totalTriangles / baselineTriangles) * 100).toFixed(1) + '%'); // Example output:// Triangles with LOD: 285,000// Triangles without LOD: 1,200,000// Reduction: 76.3%Frame Rate Impact#
| Scene | Without LOD | With LOD | Improvement |
|---|---|---|---|
| Forest (5K trees) | 25 FPS | 60 FPS | 2.4x faster |
| City (2K buildings) | 35 FPS | 60 FPS | 1.7x faster |
| Rocks (10K rocks) | 18 FPS | 60 FPS | 3.3x faster |
| Open world | 30 FPS | 60 FPS | 2.0x faster |
LOD System Overhead#
import { InstancedLODManager } from '@stem/core/rendering/InstancedLODManager';import { getInstancedChunkTable } from '@stem/core/ecs/systems/InstancedRenderSystem'; const chunkTable = getInstancedChunkTable(); // Update LOD levels (called per frame)const camera = getCamera();const deltaTime = 0.016; // 60 FPS const transitions = InstancedLODManager.update(camera, chunkTable, deltaTime); console.log('LOD transitions this frame:', transitions); // Overhead measurements:// - 10K entities: ~0.3ms per frame// - 50K entities: ~1.2ms per frame// - 100K entities: ~2.5ms per frame // Optimization tips:// - LOD system uses spatial chunking (O(visible chunks))// - Only visible chunks are processed// - Distance calculated with squared distance (no sqrt)// - Hysteresis prevents thrashing// - Update rate configurable per LOD levelBest Practices#
LOD Authoring Guidelines#
- Keep silhouette — LODs should maintain the overall shape and silhouette
- Remove interior detail first — Simplify hidden geometry before affecting visible edges
- Test at distance — View LODs at their intended distances to verify quality
- Consistent poly reduction — Each LOD should have ~50% fewer triangles than previous
- Use billboards for distant objects — LOD 3+ can use 2-triangle billboards (90%+ reduction)
Distance Tuning#
// Start with conservative distances (late transitions)// Then gradually increase until you notice quality loss // Debug LOD transitions in developmentimport { InstancedLODManager } from '@stem/core/rendering/InstancedLODManager'; const stats = InstancedLODManager.getStats(chunkTable); // Monitor LOD distributionconsole.log('Entities per LOD:', stats.entitiesPerLevel);// [50, 200, 500, 300] = LOD 0, 1, 2, 3 // Goal: Pyramid distribution// - Most entities at LOD 1-2 (medium quality)// - Fewer at LOD 0 (nearby)// - Fewer at LOD 3 (distant/culled) // Bad distribution:// [800, 100, 50, 50] // Too many at LOD 0 (not optimized)// [50, 50, 50, 850] // Too many culled (distances too aggressive) // Good distribution:// [100, 300, 400, 200] // Balanced pyramidCombining LOD with Other Techniques#
// Combine LOD with instancing for maximum performanceimport { addComponent, Instanced, Transform } from '@stem/core/ecs';import { addLODComponents } from '@stem/core/rendering/LODSystem';import { InstancedLODManager } from '@stem/core/rendering/InstancedLODManager'; // Configure LOD for instanced assetInstancedLODManager.configureAsset({ assetId: treeAssetId, distancesSq: new Float32Array([0, 400, 2500, 10000]), geometries: [highGeo, medGeo, lowGeo, null], materials: [mat, mat, billboardMat, null], enableMorphing: true, morphDuration: 0.25,}); // Create instanced entities with LODfor (let i = 0; i < 1000; i++) { const entity = world.createEntity(); addComponent(world, Transform, entity); Transform.position[entity][0] = Math.random() * 500; Transform.position[entity][2] = Math.random() * 500; addComponent(world, Instanced, entity); Instanced.assetId[entity] = treeAssetId; // LOD is automatically applied to instanced entities // No need to manually add LOD components} // Result: 1000 trees in 1-4 draw calls (one per LOD level)// with automatic quality scaling based on distanceLOD Optimization Checklist
• Each LOD has ~50% fewer triangles
• Test LOD transitions at runtime
• Use hysteresis to prevent popping
• Monitor triangle reduction (target 60-80%)
• Combine with instancing for best results
• Use billboards for distant objects (LOD 3+)