LOD (Level of Detail)
The farther an object is from the camera, the smaller it appears on screen, and drawing it with as many triangles as up close is wasted work. LOD (Level of Detail) prepares several models of the same object with different amounts of detail: the detailed model up close, simplified ones in the distance, and nothing at all farther away, greatly reducing the drawing work with almost no visible difference.
The LODGroup component handles the switching. You register several levels with it, each a set of Object3Ds with a threshold; every frame it measures the object's current size (or distance) from the camera, shows exactly one level and hides the others through transform.enable.
Basic Usage
Usually the models of all levels are children of one parent object, which carries the LODGroup:
import { Object3D, LODGroup, MeshRenderer, SphereGeometry, BoxGeometry } from '@orillusion/core';
const root = new Object3D();
const lod = root.addComponent(LODGroup);
// Three levels: high-poly sphere, low-poly sphere, box
const high = createMesh(new SphereGeometry(1.5, 48, 48));
const mid = createMesh(new SphereGeometry(1.5, 8, 8));
const low = createMesh(new BoxGeometry(2.4, 2.4, 2.4));
root.addChild(high);
root.addChild(mid);
root.addChild(low);
// Thresholds: the object's diameter as a fraction of the viewport height
lod.addLevel(high, 0.12); // shown above 12% of the screen height
lod.addLevel(mid, 0.05); // 5% ~ 12%
lod.addLevel(low, 0.015); // 1.5% ~ 5%; hidden when smaller
scene.addChild(root);addLevel(objects, threshold)takes one or moreObject3Ds. Levels are sorted from most to least detailed automatically, so they can be added in any order; the return value is the level's index after sorting;- levels are shown and hidden through
transform.enable, which also covers their children, so a level can be a whole glTF node tree; - a level is hidden as soon as it is registered, and the right one is shown on the next update;
LODGroups can share geometries and materials; switching levels only changes visibility and creates no new resources.
What Drives the Switch
mode decides how the object's "size" is measured; the unit of the thresholds follows it:
LODMode | Measure | Threshold |
|---|---|---|
ScreenSize | Default. The diameter of the object's bounding sphere as a fraction of the viewport height (0 ~ 1), taking the camera's field of view into account (orthographic cameras use frustumSize), so levels switch at the same apparent size whatever the lens or distance | The minimum fraction for which the level stays visible, e.g. 0.5, 0.15, 0.03 |
Distance | World-space distance from the camera to the object | The maximum distance for which the level stays visible, e.g. 20, 60, 150 |
Both measure from the world position of the object that carries the LODGroup. The bounding-sphere radius used by ScreenSize is computed automatically by default, as the distance from that position to the farthest corner of the levels' bounding boxes, or can be set with radius. Call recalculateBounds() after changing geometry or scale.
After switching mode, the old thresholds are in the wrong unit; set new ones with setThresholds(), from most to least detailed:
import { LODMode } from '@orillusion/core';
lod.mode = LODMode.Distance;
lod.setThresholds([25, 60, 200]); // high-poly within 25, low-poly 25 ~ 60, box 60 ~ 200Properties and Methods
| Property | Default | Description |
|---|---|---|
mode | LODMode.ScreenSize | What drives the switch, see above |
bias | 1 | Global scale factor. Above 1 keeps detailed levels longer (in the same direction in both modes); can serve as a global quality setting |
hysteresis | 0.1 | Dead band around the thresholds, as a fraction. Dropping to a coarser level happens as soon as a threshold is crossed, while going back to a finer level needs threshold × (1 + hysteresis), so an object parked near a boundary does not flicker |
cullBeyondLast | true | Whether to hide every level once the last threshold is crossed; with false the coarsest level stays visible |
forceLevel | null | Force a level (for debugging): null is automatic, a number is a level index, LODGroup.CULLED hides everything |
camera | null | Camera used for the measure; defaults to the camera of the current view |
radius | null | Bounding-sphere radius used in ScreenSize mode; null computes it automatically |
| Read-only property | Description |
|---|---|
levels | All levels, from most to least detailed |
currentLevel | Index of the level shown, or LODGroup.CULLED (-1) when everything is hidden |
lastMetric | The screen fraction or distance measured on the last update |
| Method | Description |
|---|---|
addLevel(objects, threshold) | Add a level; returns its index after sorting |
removeLevel(index) | Remove a level; its objects are shown again and no longer managed |
setThreshold(index, threshold) | Change one level's threshold. Levels are re-sorted, so the indices of other levels may change |
setThresholds(thresholds) | Rewrite every threshold in the current order, then sort once; usually paired with a mode switch |
recalculateBounds() | Drop the automatically computed radius; it is recomputed on the next update |
measure(camera) | Compute the current screen fraction or distance for a camera, without changing any state |
The onLevelChange callback is called when the shown level changes, for example to switch other content along with it, such as turning off particle effects in the distance:
lod.onLevelChange = (level, previous) => {
// level is LODGroup.CULLED when everything is hidden
effect.enable = level === 0;
};TIP
- When the
LODGroupcomponent is destroyed, the objects of every level are shown again; - when an object with a
LODGroupis cloned, level objects that are its children are mapped to the clone's children.
Example
The scene below has 24 × 24 objects, each with three levels: a green high-poly sphere (about 4,600 triangles), a yellow low-poly sphere (128 triangles) and a red box (12 triangles), hidden farther away. Zoom and orbit the camera to watch the colored rings change; the panel shows live counts per level and the total number of triangles, and lets you switch the measure, adjust bias and hysteresis, or force a level. From the default view the scene draws about 120,000 triangles; with forceLevel set to 0, showing every high-poly model, it would take about 2.65 million.
import { AtmosphericComponent, BoxGeometry, CameraUtil, Color, ComponentBase, DirectLight, Engine3D, GeometryBase, HoverCameraController, KelvinUtil, LitMaterial, LODGroup, LODMode, MeshRenderer, Object3D, PlaneGeometry, Scene3D, SphereGeometry, Vector3, View3D } from '@orillusion/core';
import { Stats } from '@orillusion/stats';
import dat from 'dat.gui';
/**
* LODGroup: a 24 x 24 field of objects, each with three levels of detail:
* L0 high-poly sphere (green) 48 x 48 segments, 4.6k triangles
* L1 low-poly sphere (yellow) 8 x 8 segments, 128 triangles
* L2 box (red) 12 triangles
* and culled beyond the last threshold. Zoom and orbit the camera to watch the rings change;
* the panel shows live counts per level and switches between the screen-size and distance metrics.
*/
class Sample_LOD {
private scene: Scene3D;
private groups: LODGroup[] = [];
async run() {
const engine = await Engine3D.init({ setting: { shadow: { enable: true, shadowBound: 200, shadowSize: 2048 } } });
this.scene = new Scene3D();
this.scene.addComponent(AtmosphericComponent).sunY = 0.6;
this.scene.addComponent(Stats);
const camera = CameraUtil.createCamera3DObject(this.scene);
camera.perspective(45, engine.aspect, 0.1, 2000);
const ctrl = camera.object3D.addComponent(HoverCameraController);
ctrl.setCamera(-30, -35, 90);
ctrl.maxDistance = 1500;
const lightObj = new Object3D();
lightObj.rotationX = 45;
lightObj.rotationY = 160;
const light = lightObj.addComponent(DirectLight);
light.lightColor = KelvinUtil.color_temperature_to_rgb(5355);
light.intensity = 2;
light.castShadow = true;
light.enableCSM = true;
this.scene.addChild(lightObj);
this.buildFloor();
this.buildField();
const view = new View3D();
view.scene = this.scene;
view.camera = camera;
engine.startRenderView(view);
const counter = this.scene.addComponent(LevelCounter);
counter.groups = this.groups;
this.initGUI(counter);
}
private buildFloor() {
const floor = new Object3D();
const mr = floor.addComponent(MeshRenderer);
mr.geometry = new PlaneGeometry(400, 400);
const mat = new LitMaterial();
mat.baseColor = new Color(0.55, 0.55, 0.6);
mat.roughness = 0.9;
mr.material = mat;
mr.castShadow = false;
this.scene.addChild(floor);
}
private buildField() {
// LOD only toggles visibility, so the 576 groups share 3 geometries and 3 materials
const geos: GeometryBase[] = [new SphereGeometry(1.5, 48, 48), new SphereGeometry(1.5, 8, 8), new BoxGeometry(2.4, 2.4, 2.4)];
const colors = [new Color(0.25, 0.85, 0.35), new Color(0.95, 0.85, 0.2), new Color(0.9, 0.3, 0.25)];
const mats = colors.map((c) => {
const m = new LitMaterial();
m.baseColor = c;
m.roughness = 0.6;
return m;
});
// ScreenSize thresholds: the group's diameter as a fraction of the viewport height
const thresholds = [0.12, 0.05, 0.015];
const n = 24;
const spacing = 6;
for (let ix = 0; ix < n; ix++) {
for (let iz = 0; iz < n; iz++) {
const root = new Object3D();
root.localPosition = new Vector3((ix - n / 2 + 0.5) * spacing, 1.5, (iz - n / 2 + 0.5) * spacing);
const lod = root.addComponent(LODGroup);
for (let l = 0; l < 3; l++) {
const obj = new Object3D();
const mr = obj.addComponent(MeshRenderer);
mr.geometry = geos[l];
mr.material = mats[l];
mr.castShadow = true;
root.addChild(obj);
lod.addLevel(obj, thresholds[l]);
}
this.scene.addChild(root);
this.groups.push(lod);
}
}
}
private initGUI(counter: LevelCounter) {
const state = { mode: 'ScreenSize', bias: 1, hysteresis: 0.1, cullBeyondLast: true, forceLevel: 'auto' };
// Thresholds are in the metric's unit: switching the mode needs a matching set
const sizeThresholds = [0.12, 0.05, 0.015];
const distThresholds = [25, 60, 200];
const gui = new dat.GUI();
const f = gui.addFolder('LOD');
f.add(state, 'mode', ['ScreenSize', 'Distance']).onChange((v: string) => {
const mode = v === 'Distance' ? LODMode.Distance : LODMode.ScreenSize;
const th = mode === LODMode.Distance ? distThresholds : sizeThresholds;
for (const g of this.groups) {
g.mode = mode;
g.setThresholds(th);
}
});
f.add(state, 'bias', 0.25, 4, 0.05).onChange((v: number) => this.groups.forEach((g) => (g.bias = v)));
f.add(state, 'hysteresis', 0, 0.5, 0.01).onChange((v: number) => this.groups.forEach((g) => (g.hysteresis = v)));
f.add(state, 'cullBeyondLast').onChange((v: boolean) => this.groups.forEach((g) => (g.cullBeyondLast = v)));
f.add(state, 'forceLevel', ['auto', '0', '1', '2', 'culled']).onChange((v: string) => {
const level = v === 'auto' ? null : v === 'culled' ? LODGroup.CULLED : Number(v);
for (const g of this.groups) g.forceLevel = level;
});
f.open();
const c = gui.addFolder('Live counts');
c.add(counter.counts, 'L0').listen();
c.add(counter.counts, 'L1').listen();
c.add(counter.counts, 'L2').listen();
c.add(counter.counts, 'culled').listen();
c.add(counter.counts, 'triangles').listen();
c.open();
// Start collapsed so the panel does not cover the scene
gui.close();
}
}
/** Counts how many groups show each level, and the triangles drawn. */
class LevelCounter extends ComponentBase {
public groups: LODGroup[] = [];
public readonly counts = { L0: 0, L1: 0, L2: 0, culled: 0, triangles: 0 };
private readonly trisPerLevel = [48 * 48 * 2, 8 * 8 * 2, 12];
public onUpdate(): void {
const c = this.counts;
c.L0 = c.L1 = c.L2 = c.culled = c.triangles = 0;
for (const g of this.groups) {
const l = g.currentLevel;
if (l === LODGroup.CULLED) {
c.culled++;
continue;
}
if (l === 0) c.L0++;
else if (l === 1) c.L1++;
else c.L2++;
c.triangles += this.trisPerLevel[l];
}
}
}
new Sample_LOD().run();
