Skip to content

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:

ts
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 more Object3Ds. 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:

LODModeMeasureThreshold
ScreenSizeDefault. 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 distanceThe minimum fraction for which the level stays visible, e.g. 0.5, 0.15, 0.03
DistanceWorld-space distance from the camera to the objectThe 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:

ts
import { LODMode } from '@orillusion/core';

lod.mode = LODMode.Distance;
lod.setThresholds([25, 60, 200]); // high-poly within 25, low-poly 25 ~ 60, box 60 ~ 200

Properties and Methods ​

PropertyDefaultDescription
modeLODMode.ScreenSizeWhat drives the switch, see above
bias1Global scale factor. Above 1 keeps detailed levels longer (in the same direction in both modes); can serve as a global quality setting
hysteresis0.1Dead 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
cullBeyondLasttrueWhether to hide every level once the last threshold is crossed; with false the coarsest level stays visible
forceLevelnullForce a level (for debugging): null is automatic, a number is a level index, LODGroup.CULLED hides everything
cameranullCamera used for the measure; defaults to the camera of the current view
radiusnullBounding-sphere radius used in ScreenSize mode; null computes it automatically
Read-only propertyDescription
levelsAll levels, from most to least detailed
currentLevelIndex of the level shown, or LODGroup.CULLED (-1) when everything is hidden
lastMetricThe screen fraction or distance measured on the last update
MethodDescription
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:

ts
lod.onLevelChange = (level, previous) => {
    // level is LODGroup.CULLED when everything is hidden
    effect.enable = level === 0;
};

TIP

  • When the LODGroup component is destroyed, the objects of every level are shown again;
  • when an object with a LODGroup is 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.

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
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();

Released under the MIT License