7.1 LOD 分级与误差指标
上一节 6.3 划界:编译与渲染原理外链 把第 6 章的边界钉在材质参数上;这一章换到几何——LOD 分级与三角预算。本章只做美术侧的账:每级留多少三角、每级允许多大误差、切换时允许多大跳变。剔除算法与 GPU Driven 的实现一律外链:《GPU Driven 渲染与 LOD》 讲原理,《生产环境的性能优化》 讲真实项目里怎么用 LOD 换帧率。本节只回答第一个问题:LOD 到底分几级,以及**「误差」怎么变成一个能算的数**。
没有误差指标的 LOD 分级是拍脑袋。「LOD1 就减一半三角」听着合理,但减一半之后轮廓缩了多少?缩 1% 还是缩 20%?说不出来,就没法给外包定验收标准,也没法解释「为什么这件家具的 LOD2 看着还行、那件的 LOD2 已经缺角」。误差必须先被量化,分级才有意义。
7.1.1 分级链与误差指标的定义
《星屑旅店》的家具走一条 4 级 LOD 链。每一级有三个必须写死的数:三角数上限、轮廓面积损失上限、以及(7.2 才用到的)触发它的屏幕占比。本节只做前两个:
| 级别 | 抽稀因子 | 三角数上限 | 面积损失上限(千分比) |
|---|---|---|---|
| LOD0 | 1(全精度) | 300 | 0 |
| LOD1 | 2 | 150 | 8 |
| LOD2 | 4 | 80 | 40 |
| LOD3 | 8 | 40 | 150 |
误差指标定义为轮廓面积损失千分比:把资产轮廓归一化到一个半径固定的规范轮廓上,低阶 LOD 用更少的顶点近似它,损失的面积占参考面积的比例就是误差。这个定义有三个好处——它是整数(面积用整数 shoelace 公式算,顶点全整数)、它单调(抽得越狠,损失越大)、它可跨语言复算(Python 与 TS 读同一份顶点表,算同一串整数)。
面积损失不是唯一的误差口径:两块凸起与凹陷可能面积相抵,而单边的弦偏差(矢高)仍然很大。所以分级表另外用三角数上限兜底——它约束的是复杂度本身,不依赖形状。
7.1.2 规范轮廓与 D 层分级表
规范轮廓是半径 1024(sub-tile 单位)的 64 边形整数顶点,逆时针排列。抽稀就是按序取 [::2]、[::4]、[::8]——64 边形每抽一次少一半顶点,形状保持凸且不自交。顶点表与分级表都写在同一份配置里:
{
"project": "stardust-inn",
"ruleSet": "lod-tier",
"specRef": "config/visual_spec.json",
"note": "第 7 章 LOD 分级表:一条 4 级 LOD 链(LOD0..LOD3)。outline 是规范轮廓——半径 1024(sub-tile 单位)的 64 边形整数顶点,按序抽稀 [::decimate] 即得低阶轮廓。每级给「三角数上限 maxTris」与「轮廓面积损失上限 maxAreaLossPermille」(千分比,整数)。真实网格简化需网格库/DCC,本机没有,故误差指标用可解析复现的规范轮廓算(见正文 X 段)。",
"outline": {
"shape": "regular-64-gon",
"radius": 1024,
"note": "64 个整数顶点,逆时针;抽稀 [::2]/[::4]/[::8] 得 32/16/8 边形。",
"points": [
[1024, 0], [1019, 100], [1004, 200], [980, 297], [946, 392], [903, 483], [851, 569], [792, 650],
[724, 724], [650, 792], [569, 851], [483, 903], [392, 946], [297, 980], [200, 1004], [100, 1019],
[0, 1024], [-100, 1019], [-200, 1004], [-297, 980], [-392, 946], [-483, 903], [-569, 851], [-650, 792],
[-724, 724], [-792, 650], [-851, 569], [-903, 483], [-946, 392], [-980, 297], [-1004, 200], [-1019, 100],
[-1024, 0], [-1019, -100], [-1004, -200], [-980, -297], [-946, -392], [-903, -483], [-851, -569], [-792, -650],
[-724, -724], [-650, -792], [-569, -851], [-483, -903], [-392, -946], [-297, -980], [-200, -1004], [-100, -1019],
[0, -1024], [100, -1019], [200, -1004], [297, -980], [392, -946], [483, -903], [569, -851], [650, -792],
[724, -724], [792, -650], [851, -569], [903, -483], [946, -392], [980, -297], [1004, -200], [1019, -100]
]
},
"levels": [
{ "id": "LOD0", "decimate": 1, "maxAreaLossPermille": 0, "maxTris": 300 },
{ "id": "LOD1", "decimate": 2, "maxAreaLossPermille": 8, "maxTris": 150 },
{ "id": "LOD2", "decimate": 4, "maxAreaLossPermille": 40, "maxTris": 80 },
{ "id": "LOD3", "decimate": 8, "maxAreaLossPermille": 150, "maxTris": 40 }
]
}
三角数怎么来?把轮廓当成一个棱柱的截面:前后面各一圈顶点(2n),前后面各用扇形三角化(2(n-2)),侧面每个四边形拆两个三角(2n),合计 4n-4。64 边形是 252 三角,8 边形是 28——这正是低阶 LOD 省下来的量。
7.1.3 S 层:整数面积与损失千分比
下面这段是 S 层脚本,读分级表,逐级抽稀并用整数 shoelace 算面积,得出面积损失千分比与三角数,再与上限比对:
#!/usr/bin/env python3
# file: sim/ta07_lod.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta07_lod.py
"""7.1 LOD 分级与误差指标的 S 层账本:读 config/ta07_lod.json 的规范轮廓(64 个整数顶点)与
4 级 LOD 链,逐级把轮廓抽稀([::decimate]),用整数 shoelace 算面积,得到每级的 outlineVerts
(抽稀后顶点数)、meshVerts(棱柱前后面各一圈 = 2n)、tris(棱柱三角数 4n-4)、areaLossPermille
(相对参考 LOD0 的轮廓面积损失千分比,整数地板除)。面积全整数,跨语言逐位一致。
落 out/ta07_lod.json + out/ta07_lod.csv。
"""
import csv
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONFIG = os.path.join(ROOT, "config/ta07_lod.json")
OUT = os.path.join(ROOT, "out")
def shoelace2(poly):
"""两倍有向面积(整数)。顶点按序闭合。"""
s = 0
n = len(poly)
for i in range(n):
x1, y1 = poly[i]
x2, y2 = poly[(i + 1) % n]
s += x1 * y2 - x2 * y1
return abs(s)
def build_levels(cfg):
pts = [tuple(p) for p in cfg["outline"]["points"]]
ref2 = shoelace2(pts)
rows = []
for lv in cfg["levels"]:
sub = pts[:: lv["decimate"]]
n = len(sub)
a2 = shoelace2(sub)
loss = (ref2 - a2) * 1000 // ref2
rows.append({
"id": lv["id"],
"decimate": lv["decimate"],
"outlineVerts": n,
"meshVerts": 2 * n,
"tris": 4 * n - 4,
"area2": a2,
"areaLossPermille": loss,
"maxTris": lv["maxTris"],
"maxAreaLossPermille": lv["maxAreaLossPermille"],
"trisOk": (4 * n - 4) <= lv["maxTris"],
"lossOk": loss <= lv["maxAreaLossPermille"],
})
return rows, ref2
def main() -> int:
cfg = json.load(open(CONFIG, encoding="utf-8"))
rows, ref2 = build_levels(cfg)
digest = hashlib.sha256("\n".join(
f"{r['id']}:{r['outlineVerts']}:{r['tris']}:{r['areaLossPermille']}" for r in rows
).encode("utf-8")).hexdigest()[:16]
violations = [f"{r['id']}:{'tris' if not r['trisOk'] else 'loss'}"
for r in rows if not (r["trisOk"] and r["lossOk"])]
report = {
"project": cfg["project"], "ruleSet": cfg["ruleSet"],
"refArea2": ref2, "levelCount": len(rows),
"maxAreaLossPermille": max(r["areaLossPermille"] for r in rows),
"violations": violations, "gate": "pass" if not violations else "block",
"digest": digest, "levels": rows,
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "ta07_lod.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
with open(os.path.join(OUT, "ta07_lod.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "outlineVerts", "meshVerts", "tris", "areaLossPermille"])
for r in rows:
w.writerow([r["id"], r["outlineVerts"], r["meshVerts"], r["tris"], r["areaLossPermille"]])
print(f"refArea2={ref2} levels={len(rows)} gate={report['gate']}")
for r in rows:
print(f" {r['id']:5s} decimate={r['decimate']} verts={r['outlineVerts']:3d} "
f"tris={r['tris']:3d} lossPermille={r['areaLossPermille']:3d} "
f"trisOk={str(r['trisOk']).lower()} lossOk={str(r['lossOk']).lower()}")
print(f"digest={digest}")
print("wrote out/ta07_lod.json out/ta07_lod.csv")
return 0 if not violations else 1
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta07_lod.py:
refArea2=6577696 levels=4 gate=pass
LOD0 decimate=1 verts= 64 tris=252 lossPermille= 0 trisOk=true lossOk=true
LOD1 decimate=2 verts= 32 tris=124 lossPermille= 5 trisOk=true lossOk=true
LOD2 decimate=4 verts= 16 tris= 60 lossPermille= 23 trisOk=true lossOk=true
LOD3 decimate=8 verts= 8 tris= 28 lossPermille= 98 trisOk=true lossOk=true
digest=5f27d757d68fc597
wrote out/ta07_lod.json out/ta07_lod.csv
四级损失是 0 / 5 / 23 / 98 千分比——LOD3 的轮廓缩了将近一成,三角却只剩 28。这就是分级要换的东西:三角数掉到 1/9,误差涨到 98 千分比。分级表的上限(0 / 8 / 40 / 150)刚好卡在实测值上方,gate=pass。
7.1.4 P 层:同一套抽稀与面积的 TS 内核
下面这段是 P 层 TS 内核,与 Python 逐条对应——同一个抽稀口径、同一个整数 shoelace、同一个损失公式:
// file: core/src/ta07/lod.ts
// tier: P
// verify: node --test 'core/test/ta07_*.test.ts'
// 7.1 LOD 分级与误差指标的 P 层内核。与 sim/ta07_lod.py 同一套规则、同一份 config/ta07_lod.json:
// 抽稀轮廓 -> 整数 shoelace 面积 -> 面积损失千分比(地板除)。整数类,跨语言逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type Point = [number, number];
export type LodLevelCfg = { id: string; decimate: number; maxAreaLossPermille: number; maxTris: number };
export type LodCfg = { outline: { points: Point[] }; levels: LodLevelCfg[] };
export type LodRow = {
id: string; decimate: number; outlineVerts: number; meshVerts: number; tris: number;
area2: number; areaLossPermille: number; maxTris: number; maxAreaLossPermille: number;
trisOk: boolean; lossOk: boolean;
};
/** 两倍有向面积(整数)。顶点按序闭合。 */
export function shoelace2(poly: Point[]): number {
let s = 0;
const n = poly.length;
for (let i = 0; i < n; i++) {
const [x1, y1] = poly[i];
const [x2, y2] = poly[(i + 1) % n];
s += x1 * y2 - x2 * y1;
}
return Math.abs(s);
}
export function buildLevels(cfg: LodCfg): { rows: LodRow[]; refArea2: number } {
const pts = cfg.outline.points;
const ref2 = shoelace2(pts);
const rows: LodRow[] = cfg.levels.map((lv) => {
const sub = pts.filter((_, i) => i % lv.decimate === 0);
const n = sub.length;
const a2 = shoelace2(sub);
const tris = 4 * n - 4;
const loss = Math.floor(((ref2 - a2) * 1000) / ref2);
return {
id: lv.id, decimate: lv.decimate, outlineVerts: n, meshVerts: 2 * n, tris,
area2: a2, areaLossPermille: loss, maxTris: lv.maxTris,
maxAreaLossPermille: lv.maxAreaLossPermille,
trisOk: tris <= lv.maxTris, lossOk: loss <= lv.maxAreaLossPermille,
};
});
return { rows, refArea2: ref2 };
}
export function lodLines(rows: LodRow[]): string[] {
return rows.map((r) => `${r.id}:${r.outlineVerts}:${r.tris}:${r.areaLossPermille}`);
}
7.1.5 跨语言对照:digest 逐位一致
P 层测试读的是 Python 落盘的 out/ta07_lod.json(不是同一份源码)。四条测试里两条是负例,守「抽稀因子漏一级」与「上限写松」:
// file: core/test/ta07_lod.test.ts
// tier: P
// verify: node --test 'core/test/ta07_*.test.ts'
// 跨语言对照:读 sim/ta07_lod.py 落盘的 out/ta07_lod.json,断言 TS 侧的抽稀、整数面积与
// 面积损失千分比与之一致(整数类,逐位一致)。负例是核心:把抽稀因子写错(漏抽一级)或把
// 面积损失上限写松,必须被抓住。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { buildLevels, lodLines, shoelace2 } from "../src/ta07/lod.ts";
import type { LodCfg } from "../src/ta07/lod.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta07_lod.json"), "utf8")) as LodCfg;
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta07_lod.json"), "utf8"));
const digest16 = (ls: string[]): string =>
createHash("sha256").update(ls.join("\n"), "utf8").digest("hex").slice(0, 16);
test("跨语言:TS 抽稀与误差表 == Python 产物(逐位一致)", () => {
const { rows, refArea2 } = buildLevels(cfg);
assert.equal(refArea2, py.refArea2);
assert.deepEqual(rows.map((r) => r.outlineVerts), py.levels.map((l: { outlineVerts: number }) => l.outlineVerts));
assert.deepEqual(rows.map((r) => r.tris), py.levels.map((l: { tris: number }) => l.tris));
assert.deepEqual(rows.map((r) => r.areaLossPermille), py.levels.map((l: { areaLossPermille: number }) => l.areaLossPermille));
assert.equal(digest16(lodLines(rows)), py.digest);
console.log(`lod digest = ${py.digest}`);
});
test("正例:四级 LOD 的面积损失递增,且都在上限内", () => {
const { rows } = buildLevels(cfg);
assert.deepEqual(rows.map((r) => r.areaLossPermille), [0, 5, 23, 98]);
assert.deepEqual(rows.map((r) => r.tris), [252, 124, 60, 28]);
assert.deepEqual(rows.map((r) => r.id), ["LOD0", "LOD1", "LOD2", "LOD3"]);
assert.ok(rows.every((r) => r.trisOk && r.lossOk));
assert.equal(py.gate, "pass");
// 面积损失单调不减(抽得越狠,轮廓越往里收)
for (let i = 1; i < rows.length; i++) assert.ok(rows[i].areaLossPermille >= rows[i - 1].areaLossPermille);
});
test("负例:抽稀因子漏一级(把 LOD3 写成 4)会让误差表与 Python 产物不符", () => {
const bad: LodCfg = JSON.parse(JSON.stringify(cfg));
bad.levels[3].decimate = 4; // LOD3 本该抽 8 分之一,误写成 4 分之一
const { rows } = buildLevels(bad);
assert.equal(rows[3].outlineVerts, 16); // 应为 8
assert.notEqual(rows[3].areaLossPermille, py.levels[3].areaLossPermille); // 应为 98
});
test("负例:面积损失上限写松后越界不再被报(lossOk 由上限决定)", () => {
const loose: LodCfg = JSON.parse(JSON.stringify(cfg));
loose.levels[3].maxAreaLossPermille = 1000; // 1000 千分比 = 100%,形同虚设
assert.ok(buildLevels(loose).rows[3].lossOk);
// 原始上限 150 时 LOD3 的 98 仍在界内;把上限压到 50 才会越界
const tight: LodCfg = JSON.parse(JSON.stringify(cfg));
tight.levels[3].maxAreaLossPermille = 50;
assert.equal(buildLevels(tight).rows[3].lossOk, false);
});
test("负例:整数面积对顶点顺序敏感度——反向顶点面积相同但符号相反", () => {
const pts = cfg.outline.points;
assert.equal(shoelace2(pts), shoelace2([...pts].reverse()));
});
实跑 node --test 'core/test/ta07_*.test.ts'(本节 5 条;本章三节合计 14 条):
lod digest = 5f27d757d68fc597
✔ 跨语言:TS 抽稀与误差表 == Python 产物(逐位一致)
✔ 正例:四级 LOD 的面积损失递增,且都在上限内
✔ 负例:抽稀因子漏一级(把 LOD3 写成 4)会让误差表与 Python 产物不符
✔ 负例:面积损失上限写松后越界不再被报(lossOk 由上限决定)
✔ 负例:整数面积对顶点顺序敏感度——反向顶点面积相同但符号相反
ℹ tests 5
ℹ pass 5
ℹ fail 0
两侧 digest 都是 5f27d757d68fc597。面积、顶点数、三角数、损失千分比全是整数,所以是逐位一致(不是「容差内一致」;容差那套只适用于色彩派生量,见卷一第 4 章)。1/64 的几何精度对这套整数模型绰绰有余,这也是选「规范轮廓」而不是直接吃网格的原因之一。
7.1.6 边界:真实网格简化不可复现(X)
本节的误差指标算在规范轮廓上——一个可解析复现的替身。真实项目里的 LOD 是对真实网格做简化(顶点聚类、二次误差度量、烘焙法线),那一步需真实环境(DCC / 网格简化库),命令行不可复现:本机既没有 Blender / Maya,也没有 trimesh / pymeshlab 这类网格库,连读一个 .fbx 都做不到。本节把「真实网格简化」标成 X 层,只做「误差指标该怎么定义、怎么跨语言复算」的账。
这条边界不是敷衍。规范轮廓给出的是误差口径与两侧一致性的证明;换成真实网格,公式一个字不改,只是顶点表换成 DCC 导出的结果。分级表里那四个上限(0 / 8 / 40 / 150)是可谈判的阈值——它们该定多松,取决于资产在屏幕上的最小可辨尺寸,那正是 7.2 要算的东西。
阅读导航:上一节:6.3 划界:编译与渲染原理外链 · 下一节:7.2 屏幕占比与三角预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。