LOD 细节层级
离相机越远的物体在屏幕上越小,用和近处一样多的三角形去绘制它们是一种浪费。LOD(Level of Detail,细节层级)为同一个物体准备几套精细程度不同的模型,近处显示高精度模型,远处换成简化模型,更远时直接隐藏,从而在几乎不影响画面的前提下大幅减少绘制量。
LODGroup 组件负责这种切换:给它注册若干个层级,每个层级是一组 Object3D 和一个阈值,它会在每一帧根据相机计算该物体当前的大小(或距离),只显示其中一个层级,其余层级通过 transform.enable 隐藏。
基本用法
通常把各层级的模型作为同一个父节点的子节点,在父节点上添加 LODGroup:
import { Object3D, LODGroup, MeshRenderer, SphereGeometry, BoxGeometry } from '@orillusion/core';
const root = new Object3D();
const lod = root.addComponent(LODGroup);
// 三个层级:高精度球体、低精度球体、立方体
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);
// 阈值:物体直径占视口高度的比例
lod.addLevel(high, 0.12); // 占屏幕高度 12% 以上时显示
lod.addLevel(mid, 0.05); // 5% ~ 12%
lod.addLevel(low, 0.015); // 1.5% ~ 5%,更小时隐藏
scene.addChild(root);addLevel(objects, threshold)可以传入一个或多个Object3D。层级会自动按从精细到粗糙排序,因此添加顺序不限;返回值是该层级排序后的索引;- 层级通过
transform.enable显示和隐藏,对子节点同样生效,所以一个层级可以是一整个 glTF 模型的节点树; - 注册后层级会立即隐藏,下一次更新时显示正确的那一级;
- 各个
LODGroup可以共用几何体和材质,切换层级只改变可见性,不会创建新的资源。
切换依据
mode 决定用什么来衡量物体的"大小",阈值的单位随之不同:
LODMode | 衡量方式 | 阈值含义 |
|---|---|---|
ScreenSize | 默认。物体包围球直径占视口高度的比例(0 ~ 1),已考虑相机视角(正交相机使用 frustumSize),因此不论镜头焦距和距离如何,层级都在相同的视觉大小处切换 | 该层级保持显示所需的最小比例,如 0.5、0.15、0.03 |
Distance | 相机到物体的世界空间距离 | 该层级保持显示的最大距离,如 20、60、150 |
两种方式都以 LODGroup 所在物体的世界坐标为中心。ScreenSize 所需的包围球半径默认自动计算:取该中心到各层级物体包围盒最远角的距离,也可以通过 radius 手动指定。几何体或缩放改变后,调用 recalculateBounds() 重新计算。
切换 mode 后,原来的阈值单位不再适用,需要用 setThresholds() 按从精细到粗糙的顺序重新设置:
import { LODMode } from '@orillusion/core';
lod.mode = LODMode.Distance;
lod.setThresholds([25, 60, 200]); // 25 以内高精度,25 ~ 60 低精度,60 ~ 200 立方体属性与方法
| 属性 | 默认值 | 说明 |
|---|---|---|
mode | LODMode.ScreenSize | 切换依据,见上表 |
bias | 1 | 整体缩放系数。大于 1 时精细层级保持更久(两种模式下方向一致),可用作全局的画质档位 |
hysteresis | 0.1 | 阈值附近的缓冲区比例。降级在越过阈值时立即发生,升级则需要越过 阈值 × (1 + hysteresis),避免物体停在边界附近时来回闪烁 |
cullBeyondLast | true | 越过最后一个阈值后是否隐藏所有层级;为 false 时一直显示最粗糙的一级 |
forceLevel | null | 强制显示某一级(调试用):null 为自动,数字为层级索引,LODGroup.CULLED 为全部隐藏 |
camera | null | 用于计算的相机,默认使用当前视图的相机 |
radius | null | ScreenSize 模式使用的包围球半径,null 时自动计算 |
| 只读属性 | 说明 |
|---|---|
levels | 所有层级,从精细到粗糙排列 |
currentLevel | 当前显示的层级索引,全部隐藏时为 LODGroup.CULLED(-1) |
lastMetric | 上一次更新时计算出的屏幕比例或距离 |
| 方法 | 说明 |
|---|---|
addLevel(objects, threshold) | 添加层级,返回排序后的索引 |
removeLevel(index) | 移除层级,该层级的物体会重新显示,不再受管理 |
setThreshold(index, threshold) | 修改一个层级的阈值。层级会重新排序,其他层级的索引可能变化 |
setThresholds(thresholds) | 按当前顺序一次性改写所有阈值后再排序,通常配合切换 mode 使用 |
recalculateBounds() | 丢弃自动计算的半径,下次更新时重新计算 |
measure(camera) | 计算当前对于某个相机的屏幕比例或距离,不改变状态 |
层级切换时会调用 onLevelChange 回调,可以用来同步切换其他内容,例如远处关闭粒子特效:
lod.onLevelChange = (level, previous) => {
// level 为 LODGroup.CULLED 时表示已全部隐藏
effect.enable = level === 0;
};TIP
- 销毁
LODGroup组件时,所有层级的物体都会重新显示; - 克隆带有
LODGroup的物体时,层级中属于其子节点的物体会对应到克隆体的子节点。
示例
下面的场景中有 24 × 24 个物体,每个都有三个层级:绿色高精度球体(约 4600 个三角形)、黄色低精度球体(128 个三角形)和红色立方体(12 个三角形),更远处隐藏。缩放、旋转相机可以看到颜色圈层随之变化;面板中可以实时看到各层级的数量和三角形总数,并切换衡量方式、调整 bias 与 hysteresis,或强制显示某一级。在默认视角下,场景约绘制 12 万个三角形;把 forceLevel 设为 0 让所有物体都显示高精度模型时,则需要约 265 万个。
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();
