LOD Systems
Implement multi-level LOD with seamless transitions and billboard fallbacks.
Introduction
Every AAA game uses LOD (Level of Detail) — rendering distant objects at lower quality. This guide explains how professional engines implement LOD groups, dithered transitions, and impostor billboards — then builds an LOD system in Appley.
Advanced Guide 17 of 25
Screenshot
How Professional Games Do It
C
r
E
a
S
e
C
o
U
s
C
r
I
m
F
o
U
s
G
r
M
e
F
o
FluxScript Implementation
Here is how to build this system in Appley using FluxScript (Appley Scripting):
// LOD system with dithered transitions and billboards
const LOD_DISTANCES = [0, 15, 40, 80, 150] // 5 LOD levels
const LOD_FADE_RANGE = 3 // 3m crossfade zone
on start {
// Configure LOD for all objects with LOD data
objects = scene.findObjectsWithLOD()
for obj in objects {
setupLOD(obj)
}
}
func setupLOD(obj) {
// Load LOD meshes
lods = []
for i = 0 to 4 {
meshName = obj.name + "_lod" + i
lods.push(scene.loadMesh(meshName))
}
// Load billboard (last LOD)
billboard = captureBillboard(obj)
lods.push(billboard)
obj.setLODGroup({
lods: lods,
distances: LOD_DISTANCES,
fadeRange: LOD_FADE_RANGE,
transitionMode: "dithered"
})
}
on update(dt) {
// Update LOD for each visible object
camera = scene.find("MainCamera")
objects = scene.getVisibleObjects()
for obj in objects {
if obj.lodGroup == null continue
dist = math.distance(camera.position, obj.position)
updateObjectLOD(obj, dist)
}
}
func updateObjectLOD(obj, dist) {
lod = obj.lodGroup
// Find current and next LOD index
currentIdx = lod.currentLOD || 0
nextIdx = currentIdx
for i = 0 to lod.distances.length {
if dist >= lod.distances[i] nextIdx = i
}
if nextIdx != currentIdx {
// Check if in fade range
transitionDist = lod.distances[nextIdx]
distFromTransition = math.abs(dist - transitionDist)
if distFromTransition < lod.fadeRange {
// Dithered crossfade between LODs
fadeT = distFromTransition / lod.fadeRange
obj.setActiveLOD(currentIdx, nextIdx, fadeT)
} else {
// Fully transitioned
lod.currentLOD = nextIdx
obj.setActiveLOD(nextIdx, null, 0)
}
}
}
// Capture billboard (impostor) for distant rendering
func captureBillboard(obj) {
// Render object from 8 angles to a texture atlas
atlasSize = 512
angles = 8
billboardAtlas = renderTarget.create(atlasSize, atlasSize)
for i = 0 to angles {
angle = (i / angles) * 360
camera.setPosition(obj.position + vec3(math.cos(angle) * 5, 2, math.sin(angle) * 5))
camera.lookAt(obj.position)
renderTarget.activate(billboardAtlas)
renderTarget.setViewport(i * (atlasSize / angles), 0, atlasSize / angles, atlasSize)
obj.render()
}
renderTarget.deactivate()
return createBillboardMesh(billboardAtlas, angles)
}
// HLOD: merge nearby static objects into combined meshes
func buildHLODs() {
// Group static objects by sector
sectors = {}
for obj in scene.findStaticObjects() {
sx = math.floor(obj.position.x / 50)
sz = math.floor(obj.position.z / 50)
key = sx + "," + sz
if sectors[key] == null sectors[key] = []
sectors[key].push(obj)
}
// Merge each sector into HLOD mesh
for sectorKey in sectors {
objects = sectors[sectorKey]
if objects.length < 5 continue // Not worth merging
// Merge all LOD2 meshes into one
mergedMesh = meshBuilder.merge(objects.map(o => o.getLODMesh(2)))
hlod = scene.createObject("HLOD_" + sectorKey)
hlod.setMesh(mergedMesh)
hlod.setMaterial("hlod_combined")
hlod.setVisibleAtDistance(80) // Only visible when distant
// Hide individual objects at this distance
for obj in objects {
obj.setHideAtDistance(80)
}
}
}Node Graph Equivalent
The same system implemented as a Node Graph in Appley Node Scripting:
[On Start] --> [For Each Object with LOD]
--> [Load: 5 LOD meshes + billboard]
--> [Set: LOD group with distances + fade range]
[On Update] --> [For Each Visible Object]
--> [Calculate: distance to camera]
--> [Determine: LOD index from distance thresholds]
--> [Branch: LOD changed]
+-- True --> [Check: in fade range?]
+-- True --> [Dithered crossfade between old + new LOD]
+-- False --> [Switch: to new LOD]
[Function: captureBillboard]
--> [For 8 angles around object]
--> [Position: camera at angle]
--> [Render: object to texture atlas]
--> [Create: billboard mesh from atlas]
[Function: buildHLODs]
--> [Group: static objects by 50m sector]
--> [For Each Sector with 5+ objects]
--> [Merge: all LOD2 meshes into one]
--> [Create: HLOD object, visible at 80m+]
--> [Hide: individual objects at 80m+]Professional Optimization Tips
How AAA studios optimize these systems — applicable to your Appley project:
- Use dithered LOD transitions, not instant swaps. Instant LOD changes cause visible pop-in. Dithering crossfades old and new LODs over 2-3 frames using noise-based alpha. The transition is barely visible.
- Use impostor billboards for very distant objects. A tree at 200m as a billboard is 2 triangles vs 5000 for LOD0. Billboards are captured from 8 angles and selected based on camera view direction.
- Use HLOD (Hierarchical LOD) for static clusters. Instead of 50 individual tree LODs, merge them into one HLOD mesh at distance. This reduces draw calls from 50 to 1 for distant forests.
- Merge static geometry at build time. Rocks, buildings, and props that never move should be merged into combined meshes per sector. This is the most effective draw call reduction technique.
Common Mistakes
Instant LOD swaps without dithering
Instant LOD changes cause visible pop-in. Use dithered crossfade transitions over 2-3 frames. The player barely notices the transition.
No billboards for distant objects
Without billboards, distant trees render at LOD2 (still 1000+ polygons). Billboards reduce this to 2 polygons. For 500 distant trees, this is 500k vs 1k polygons.
