7.1 LOD 分级与误差指标

LOD 不是「离远了换低模」一句话,而是一张分级表加一个能算的误差指标。本节把家具轮廓归一化到规范轮廓,用整数 shoelace 逐级算面积损失千分比,给出 4 级 LOD 链的误差账,并由 P 层 TS 内核复算,两侧逐位一致,全程整数可复现。

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 才用到的)触发它的屏幕占比。本节只做前两个:

级别抽稀因子三角数上限面积损失上限(千分比)
LOD01(全精度)3000
LOD121508
LOD248040
LOD3840150

误差指标定义为轮廓面积损失千分比:把资产轮廓归一化到一个半径固定的规范轮廓上,低阶 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 屏幕占比与三角预算 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则