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

LOD can reduce triangle count by 60-90% in typical scenes, improving frame rates by 2-5x while maintaining visual quality at all distances.

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:

LevelDistanceQualityTriangle Budget
LOD 0 (Ultra)< 10mFull detail100% (1000 tris)
LOD 1 (High)10-25mHigh quality50% (500 tris)
LOD 2 (Medium)25-50mMedium quality25% (250 tris)
LOD 3 (Low)50-100mLow quality10% (100 tris)
LOD 4 (Minimal)100-200mMinimal/Billboard1% (10 tris)
Culled> 200mNot rendered0 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 LOD
const 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 complexity

Instanced LOD#

import { InstancedLODManager } from '@stem/core/rendering/InstancedLODManager';
// Configure LOD for an instanced asset
InstancedLODManager.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 TypeLOD 1LOD 2LOD 3Cull
Small (rocks)5m15m40m80m
Medium (trees)15m40m80m150m
Large (buildings)40m100m200m400m
Huge (mountains)100m300m600m1000m
Tiny (grass)2m5m10m20m

Screen Size Calculation#

// Calculate LOD distance based on screen size
function 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 pixels
const lod1Distance = calculateLODDistance(3, 100);
console.log('LOD 1 distance:', lod1Distance.toFixed(1), 'm'); // ~35m
// LOD 2: When tree occupies ~50 pixels
const lod2Distance = calculateLODDistance(3, 50);
console.log('LOD 2 distance:', lod2Distance.toFixed(1), 'm'); // ~70m
// Cull: When tree occupies ~10 pixels
const cullDistance = calculateLODDistance(3, 10);
console.log('Cull distance:', cullDistance.toFixed(1), 'm'); // ~350m

Hysteresis#

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 margin
console.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 hysteresis
import { InstancedLODManager } from '@stem/core/rendering/InstancedLODManager';
InstancedLODManager.setAssetHysteresis(
assetId,
0.15 // 15% hysteresis band (prevents flickering)
);
// Cooldown prevents rapid switching
InstancedLODManager.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 objects

LOD 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 transition

Alpha 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 counts

Automatic 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;
}
// Usage
const 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 levels
import 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}%)")
# Usage
obj = bpy.context.active_object
generate_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 distribution
const 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 savings
const 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#

SceneWithout LODWith LODImprovement
Forest (5K trees)25 FPS60 FPS2.4x faster
City (2K buildings)35 FPS60 FPS1.7x faster
Rocks (10K rocks)18 FPS60 FPS3.3x faster
Open world30 FPS60 FPS2.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 level

Best 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 development
import { InstancedLODManager } from '@stem/core/rendering/InstancedLODManager';
const stats = InstancedLODManager.getStats(chunkTable);
// Monitor LOD distribution
console.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 pyramid

Combining LOD with Other Techniques#

// Combine LOD with instancing for maximum performance
import { 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 asset
InstancedLODManager.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 LOD
for (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 distance

LOD Optimization Checklist

• Create 3-4 LOD levels per mesh
• 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+)
Documentation | Web Engine