7.3 LOD 切换的视觉跳变

LOD 切换那一帧的跳变不是玄学。同一段相机轨迹下,单阈值选级在阈值附近抖了 17 次,双阈值迟滞只切 1 次;相邻级的误差跳变里,LOD2 到 LOD3 的 75 千分比超过 60 的上限,被门禁判违例。本节把「切几次」与「跳多大」都变成整数账。

7.3 LOD 切换的视觉跳变

7.2 的阈值表只说了「什么时候切」,没说切换的代价。资产换级那一帧,三角数与轮廓同时突变,画面会「跳」一下——这就是 popping。消除它的常见手段是交叉淡入淡出,但那属于引擎实现,外链 《GPU Driven 渲染与 LOD》 与 《生产环境的性能优化》 。本节只做美术侧的两笔账:切换该发生几次,以及每次跳多大。

7.3.1 跳变来自两处:误差台阶与阈值抖动

popping 有两个独立来源,混淆它们会开错药方:

  • 误差台阶:相邻两级的轮廓差多少。7.1 的损失是 0 / 5 / 23 / 98 千分比,相邻差就是 5 / 18 / 75——台阶越大,跳得越狠。这一项由分级表决定,与相机无关。
  • 阈值抖动:相机在阈值附近来回微动时,选级在两级之间反复横跳。这一项由选级策略决定,与形状无关。

第一项要靠加密分级(把大台阶拆成小台阶)压下去;第二项要靠迟滞(给升降两个方向不同的阈值)压下去。本节把两项都算出来。

7.3.2 轨迹与迟滞策略(D 层配置)

相机轨迹是确定性的整数序列:缓慢接近 + 一个固定抖动序列,让占比在 LOD1/LOD2 阈值 40 附近反复穿越。迟滞带宽是千分比:只有占比跌破 th[i] - band*th[i]//1000 才允许降级:

{
  "project": "stardust-inn",
  "ruleSet": "lod-switch-hysteresis",
  "lodRef": "out/ta07_lod.json",
  "note": "7.3 LOD 切换的视觉跳变:一段确定性的整数相机轨迹(缓慢接近 + 固定抖动序列),逐帧算屏幕占比千分比并选级,比较「单阈值」与「双阈值迟滞」两种策略的切换次数与振荡次数。迟滞带宽 hysteresisBandPermille 是千分比:降到 th[i] - band*th[i]//1000 才允许降级。另算相邻级的误差跳变(popping)幅度表,超过 maxPopPermille 即判违例。全部整数,跨语言逐位一致。",
  "screen": { "width": 1920, "height": 1080 },
  "camera": { "focal": 1000 },
  "asset": { "worldSize": 96 },
  "lodThresholdsPermille": [120, 40, 12, 0],
  "hysteresisBandPermille": 250,
  "maxPopPermille": 60,
  "trajectory": {
    "frames": 60,
    "baseDistance": 333,
    "driftPerTenFrames": -1,
    "shake": [3, -3, 2, -2, 1, -1],
    "note": "distance(f) = baseDistance + driftPerTenFrames*(f//10) + shake[f % 6];确定、可播种、无随机数。333 处屏幕占比恰好卡在 LOD1/LOD2 阈值 40 上,抖动序列让占比在 39/40 间反复穿越——单阈值必抖,迟滞锁住。"
  }
}

迟滞带宽 250 千分比意味着:当前在 LOD1(阈值 40),要降到 LOD2 得先跌破 40 - 250*40//1000 = 30。占比只在 39/40 间晃,永远碰不到 30,于是锁住不动。

7.3.3 S 层:两种策略的切换计数

下面这段是 S 层脚本,逐帧选级,统计两种策略的 switchCount(级变化次数)与 flapCount(振荡:相邻两次切换方向相反),并算相邻级的误差跳变表:

#!/usr/bin/env python3
# file: sim/ta07_switch.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta07_switch.py
"""7.3 LOD 切换的视觉跳变的 S 层仿真:读 config/ta07_switch.json 的确定性相机轨迹,逐帧算屏幕
占比千分比并选级,比较 single(单阈值:占比 >= th[i] 即取该级)与 hysteresis(双阈值迟滞:维持
当前级,只有跌破 th[i] - band*th[i]//1000 才降级)两种策略的 switchCount(级变化次数)与
flapCount(振荡:相邻两次切换方向相反)。另算相邻级误差跳变(popping = |面积损失之差| 千分比),
超过 maxPopPermille 判违例。全部整数,跨语言逐位一致。落 out/ta07_switch.json。
"""

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_switch.json")
LOD = os.path.join(ROOT, "out/ta07_lod.json")
OUT = os.path.join(ROOT, "out")


def trajectory_distances(tr):
    """distance(f) = baseDistance + driftPerTenFrames*(f//10) + shake[f % 6];确定、无随机数。"""
    sh = tr["shake"]
    return [tr["baseDistance"] + tr["driftPerTenFrames"] * (f // 10) + sh[f % len(sh)]
            for f in range(tr["frames"])]


def ratio_permille(distance, world_size, focal, screen_area):
    proj = (world_size * focal) // distance
    return (proj * proj * 1000) // screen_area


def select_single(th, ratio):
    for i, t in enumerate(th):
        if ratio >= t:
            return i
    return len(th) - 1


def select_hysteresis(th, ratio, cur, band):
    if cur is None:
        return select_single(th, ratio)
    target = select_single(th, ratio)
    if target < cur:
        return target  # 升到更精细的级:占比 >= th[target] 即接受
    if target > cur:
        lo = th[cur] - band * th[cur] // 1000  # 降级需要跌破迟滞下限
        return cur + 1 if ratio < lo else cur
    return cur


def run_policy(th, ratios, band, hysteresis):
    lods, cur = [], None
    for r in ratios:
        cur = select_hysteresis(th, r, cur, band) if hysteresis else select_single(th, r)
        lods.append(cur)
    dirs = [1 if lods[i] > lods[i - 1] else -1 for i in range(1, len(lods)) if lods[i] != lods[i - 1]]
    flaps = sum(1 for i in range(1, len(dirs)) if dirs[i] * dirs[i - 1] < 0)
    return lods, len(dirs), flaps


def main() -> int:
    cfg = json.load(open(CONFIG, encoding="utf-8"))
    lod = json.load(open(LOD, encoding="utf-8"))
    ids = [lv["id"] for lv in lod["levels"]]
    loss = [lv["areaLossPermille"] for lv in lod["levels"]]
    W, H = cfg["screen"]["width"], cfg["screen"]["height"]
    focal, ws = cfg["camera"]["focal"], cfg["asset"]["worldSize"]
    th, band, tr = cfg["lodThresholdsPermille"], cfg["hysteresisBandPermille"], cfg["trajectory"]

    dists = trajectory_distances(tr)
    ratios = [ratio_permille(d, ws, focal, W * H) for d in dists]
    s_lods, s_sw, s_flap = run_policy(th, ratios, band, False)
    h_lods, h_sw, h_flap = run_policy(th, ratios, band, True)
    pops = [{"from": ids[i - 1], "to": ids[i], "popPermille": abs(loss[i] - loss[i - 1]),
             "ok": abs(loss[i] - loss[i - 1]) <= cfg["maxPopPermille"]} for i in range(1, len(ids))]
    pop_bad = [f"{p['from']}->{p['to']}" for p in pops if not p["ok"]]

    dg = lambda lods: hashlib.sha256(",".join(map(str, lods)).encode("utf-8")).hexdigest()[:16]
    report = {
        "project": cfg["project"], "ruleSet": cfg["ruleSet"], "frames": tr["frames"], "bandPermille": band,
        "singleSwitchCount": s_sw, "singleFlapCount": s_flap,
        "hystSwitchCount": h_sw, "hystFlapCount": h_flap,
        "singleDigest": dg(s_lods), "hystDigest": dg(h_lods),
        "maxPopPermille": cfg["maxPopPermille"], "popViolations": pop_bad,
        "popGate": "pass" if not pop_bad else "block", "pops": pops,
        "framesData": [{"f": f, "distance": dists[f], "ratioPermille": ratios[f],
                        "single": s_lods[f], "hyst": h_lods[f]} for f in range(tr["frames"])],
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "ta07_switch.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"frames={tr['frames']} bandPermille={band} thresholds={th}")
    print(f"single: switches={s_sw} flaps={s_flap} digest={dg(s_lods)}")
    print(f"hyst:   switches={h_sw} flaps={h_flap} digest={dg(h_lods)}")
    for p in pops:
        print(f"  pop {p['from']}->{p['to']}: {p['popPermille']} permille ok={str(p['ok']).lower()}")
    print(f"popGate={report['popGate']} violations={pop_bad}")
    print("wrote out/ta07_switch.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/ta07_switch.py:

frames=60 bandPermille=250 thresholds=[120, 40, 12, 0]
single: switches=17 flaps=16 digest=447728237b3b6443
hyst:   switches=1 flaps=0 digest=765db025dd871600
  pop LOD0->LOD1: 5 permille ok=true
  pop LOD1->LOD2: 18 permille ok=true
  pop LOD2->LOD3: 75 permille ok=false
popGate=block violations=['LOD2->LOD3']
wrote out/ta07_switch.json

60 帧里,单阈值切了 17 次、其中 16 次是振荡(切过去又切回来);迟滞只切 1 次、零振荡。同一段轨迹,同一份阈值表,差别只在「降级要不要多跌一段」。而误差台阶表里 LOD2→LOD3 的 75 千分比超过 60 的上限,popGate=block——这一条与相机无关,是分级表本身的缺陷:LOD3 减得太狠。

7.3.4 P 层:同一套选级规则的 TS 内核

下面这段是 P 层 TS 内核,与 Python 逐条对应——同一套轨迹、同一个迟滞判据、同一种振荡计数:

// file: core/src/ta07/switch.ts
// tier: P
// verify: node --test 'core/test/ta07_*.test.ts'
// 7.3 LOD 切换的视觉跳变的 P 层内核。与 sim/ta07_switch.py 同一套轨迹、同一套选级规则:
// 单阈值 vs 双阈值迟滞(跌破 th[i] - band*th[i]//1000 才允许降级)。整数类,跨语言逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type Trajectory = {
  frames: number; baseDistance: number; driftPerTenFrames: number; shake: number[];
};
export type SwitchCfg = {
  screen: { width: number; height: number };
  camera: { focal: number };
  asset: { worldSize: number };
  lodThresholdsPermille: number[];
  hysteresisBandPermille: number;
  maxPopPermille: number;
  trajectory: Trajectory;
};

export function trajectoryDistances(tr: Trajectory): number[] {
  const sh = tr.shake;
  const out: number[] = [];
  for (let f = 0; f < tr.frames; f++) {
    out.push(tr.baseDistance + tr.driftPerTenFrames * Math.floor(f / 10) + sh[f % sh.length]);
  }
  return out;
}

export function ratioPermille(distance: number, worldSize: number, focal: number, screenArea: number): number {
  const proj = Math.floor((worldSize * focal) / distance);
  return Math.floor((proj * proj * 1000) / screenArea);
}

export function selectSingle(th: number[], ratio: number): number {
  for (let i = 0; i < th.length; i++) if (ratio >= th[i]) return i;
  return th.length - 1;
}

export function selectHysteresis(th: number[], ratio: number, cur: number | null, band: number): number {
  if (cur === null) return selectSingle(th, ratio);
  const target = selectSingle(th, ratio);
  if (target < cur) return target;
  if (target > cur) {
    const lo = th[cur] - Math.floor((band * th[cur]) / 1000);
    return ratio < lo ? cur + 1 : cur;
  }
  return cur;
}

export function runPolicy(th: number[], ratios: number[], band: number, hysteresis: boolean): {
  lods: number[]; switchCount: number; flapCount: number;
} {
  const lods: number[] = [];
  let cur: number | null = null;
  for (const r of ratios) {
    cur = hysteresis ? selectHysteresis(th, r, cur, band) : selectSingle(th, r);
    lods.push(cur);
  }
  const dirs: number[] = [];
  for (let i = 1; i < lods.length; i++) if (lods[i] !== lods[i - 1]) dirs.push(lods[i] > lods[i - 1] ? 1 : -1);
  let flap = 0;
  for (let i = 1; i < dirs.length; i++) if (dirs[i] * dirs[i - 1] < 0) flap++;
  return { lods, switchCount: dirs.length, flapCount: flap };
}

export function popsFromLoss(ids: string[], loss: number[], maxPop: number): {
  from: string; to: string; popPermille: number; ok: boolean;
}[] {
  const out: { from: string; to: string; popPermille: number; ok: boolean }[] = [];
  for (let i = 1; i < ids.length; i++) {
    const pop = Math.abs(loss[i] - loss[i - 1]);
    out.push({ from: ids[i - 1], to: ids[i], popPermille: pop, ok: pop <= maxPop });
  }
  return out;
}

7.3.5 跨语言对照:digest 逐位一致

P 层测试(core/test/ta07_switch.test.ts,5 条,含两条负例:带宽设成 0 必重现抖动、卡在阈值上不得降级)读 Python 落盘的 out/ta07_switch.json:

switch single digest = 447728237b3b6443
✔ 跨语言:TS 轨迹与逐帧占比 == Python 产物(逐位一致)
✔ 正例:单阈值抖 17 次,迟滞只切 1 次且不振荡
✔ 正例:误差跳变表里 LOD2->LOD3 的 75 千分比超上限 60,判违例
✔ 负例:迟滞带宽设成 0 就退化成单阈值,抖动重现
✔ 负例:迟滞的降级需要跌破下限,恰好卡在阈值上时不得降级
ℹ tests 14
ℹ pass 14
ℹ fail 0

两条 LOD 序列的 digest 分别是 447728237b3b6443(单阈值)与 765db025dd871600(迟滞),两侧逐位一致。切换次数、振荡次数、误差跳变全是整数——这套账没有任何浮点。

7.3.6 结论:跳变是分级表的账,不是渲染的锅

回到美术侧:popping 的两个来源里,阈值抖动靠策略(迟滞,一行判据),误差台阶靠分级表(加密或重设上限)。本节的账给出两条可执行结论——迟滞带宽写进规范(这里是 250 千分比),把 LOD2→LOD3 的 75 千分比台阶拆开(例如加一个 LOD2.5,或把 LOD3 的抽稀从 8 收到 6)。交叉淡入淡出这类运行时手段能掩盖跳变,但掩盖不了「分级表本身有个 75 千分比的大台阶」这个事实。

下一章换到另一类几何预算:骨骼与蒙皮——骨骼数、调色板上限、蒙皮数据的字节账,与 LOD 是同一套「分级 + 上限 + 跨语言复算」的写法。

阅读导航:上一节:7.2 屏幕占比与三角预算 · 下一节:8.1 骨骼数与调色板上限 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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