AppleyAppley Docs
Docs/Optimization

LOD Systems

Implement multi-level LOD with seamless transitions and billboard fallbacks.

31 min read Advanced Updated 2026-07-01 v2.0 ~130 min to complete

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

This is part of the Advanced Documentation collection. Category: Optimization. Estimated completion: 130 minutes. Difficulty: Advanced/Professional.

Screenshot

LOD Systems
LOD Systems

How Professional Games Do It

1

C

r

2

E

a

3

S

e

4

C

o

5

U

s

6

C

r

7

I

m

8

F

o

9

U

s

10

G

r

11

M

e

12

F

o

FluxScript Implementation

Here is how to build this system in Appley using FluxScript (Appley Scripting):

LOD Systems — FluxScript
// 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:

LOD Systems — Node Graph
[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.