7.2 屏幕占比与三角预算
7.1 定了每级允许多大误差,但没回答什么时候切到哪一级,也没回答一屏到底能塞多少三角。这一节把这两件事合成一本账:屏幕占比决定选级,选级决定三角数,三角数之和必须落进帧预算。移动端一帧三角预算怎么定、不同机型差多少,外链 《移动端与 PC 的性能预算》 ;投影与缩放的渲染侧细节外链 《移动端渲染优化》 。本节只做美术侧的整数账。
「离远了换低模」的判据如果写成「距离 > 30 米」,一换相机、一换 FOV 就全乱。可移植的判据是屏幕占比——资产投影到屏幕上的面积占整个画面的比例。它同时吃进了资产尺寸、相机距离与焦距,换分辨率也能重算。占比越大越该用高模,越小越可以用低模。
7.2.1 从距离到占比:一个整数针孔相机
针孔模型只有一步除法:投影线性像素 projPx = (worldSize * focal) // distance。屏幕占比用面积千分比 ratioPermille = (projPx * projPx * 1000) // (W * H)。两处都是整数地板除——这是全节能跨语言逐位复算的前提。若改成浮点占比,floor 与 round、Python 与 JS 会在边界上分道扬镳(P 层有一条负例专守这一点)。
选级用一张递减阈值表 [120, 40, 12, 0]:占比 ≥ 120 取 LOD0,≥ 40 取 LOD1,≥ 12 取 LOD2,否则 LOD3。阈值递减保证「首个命中胜出」的写法一定选到最精细的合格级。
7.2.2 场景与 D 层配置
《星屑旅店》一屏大厅 + 一间客房放 12 件家具,每件给世界尺寸与相机距离;帧三角预算定为 1200:
{
"project": "stardust-inn",
"ruleSet": "screen-triangle-budget",
"lodRef": "out/ta07_lod.json",
"note": "7.2 屏幕占比与三角预算:《星屑旅店》一屏大厅 + 一间客房的 12 个家具实例。相机为整数针孔模型:projPx = (worldSize * focal) // distance(地板除)。屏幕占比用面积千分比 = (projPx^2 * 1000) // (W * H)(地板除)。按 lodThresholdsPermille 选级:占比 >= 阈值即取该级(阈值随 LOD 序号递减)。每级的三角数来自 7.1 的 out/ta07_lod.json。全部整数,跨语言逐位一致。",
"screen": { "width": 1920, "height": 1080 },
"camera": { "focal": 1000, "note": "projPx = (worldSize * focal) // distance;worldSize 为资产的世界尺寸(sub-tile 单位)" },
"lodThresholdsPermille": [120, 40, 12, 0],
"frameTriangleBudget": 1200,
"instances": [
{ "id": "counter_main", "worldSize": 160, "distance": 200 },
{ "id": "rug_main", "worldSize": 256, "distance": 420 },
{ "id": "lamp_floor", "worldSize": 128, "distance": 300 },
{ "id": "stool_a", "worldSize": 96, "distance": 260 },
{ "id": "shelf_wall", "worldSize": 192, "distance": 700 },
{ "id": "plant_pot", "worldSize": 80, "distance": 360 },
{ "id": "chair_b", "worldSize": 96, "distance": 480 },
{ "id": "vase", "worldSize": 72, "distance": 600 },
{ "id": "painting_1", "worldSize": 64, "distance": 520 },
{ "id": "book_stack", "worldSize": 40, "distance": 300 },
{ "id": "clock", "worldSize": 48, "distance": 640 },
{ "id": "candle", "worldSize": 32, "distance": 400 }
]
}
7.2.3 S 层:逐实例选级与三角合计
下面这段是 S 层脚本,逐个实例算投影像素与占比、选级、取该级三角数,再合计并与预算比对:
#!/usr/bin/env python3
# file: sim/ta07_budget.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta07_budget.py
"""7.2 屏幕占比与三角预算的 S 层账本:读 config/ta07_budget.json 的 12 个家具实例与整数针孔相机,
逐个算 projPx(投影像素 = (worldSize*focal)//distance)、ratioPermille(屏幕占比面积千分比 =
(projPx^2*1000)//(W*H))、lod(占比 >= 阈值即取该级,阈值随序号递减)、tris(来自 7.1 的
out/ta07_lod.json)。合计 sceneTris 与 frameTriangleBudget 比对;再算最坏视角 worstTris(全部实例
拉到 LOD0)——预算必须按最坏视角算。落 out/ta07_budget.json + out/ta07_budget.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_budget.json")
LOD = os.path.join(ROOT, "out/ta07_lod.json")
OUT = os.path.join(ROOT, "out")
def select_lod(thresholds, ratio_permille):
"""占比 >= 阈值即取该级;阈值随序号递减,首个命中胜出;都不命中取最后一级。"""
for i, th in enumerate(thresholds):
if ratio_permille >= th:
return i
return len(thresholds) - 1
def main() -> int:
cfg = json.load(open(CONFIG, encoding="utf-8"))
lod = json.load(open(LOD, encoding="utf-8"))
tris_by_level = [lv["tris"] for lv in lod["levels"]]
ids = [lv["id"] for lv in lod["levels"]]
W, H = cfg["screen"]["width"], cfg["screen"]["height"]
focal = cfg["camera"]["focal"]
th = cfg["lodThresholdsPermille"]
screen_area = W * H
rows = []
for inst in cfg["instances"]:
proj = (inst["worldSize"] * focal) // inst["distance"]
ratio = (proj * proj * 1000) // screen_area
lv = select_lod(th, ratio)
rows.append({
"id": inst["id"], "worldSize": inst["worldSize"], "distance": inst["distance"],
"projPx": proj, "ratioPermille": ratio, "lod": lv, "lodId": ids[lv],
"tris": tris_by_level[lv],
})
scene_tris = sum(r["tris"] for r in rows)
worst_tris = len(rows) * tris_by_level[0] # 最坏视角:全部实例都拉到 LOD0
by_level = {ids[i]: sum(1 for r in rows if r["lod"] == i) for i in range(len(ids))}
digest = hashlib.sha256("\n".join(
f"{r['id']}:{r['ratioPermille']}:{r['lod']}" for r in rows).encode("utf-8")).hexdigest()[:16]
report = {
"project": cfg["project"], "ruleSet": cfg["ruleSet"],
"instanceCount": len(rows), "screenArea": screen_area,
"trisByLevel": tris_by_level, "countByLevel": by_level,
"sceneTris": scene_tris, "worstTris": worst_tris,
"frameTriangleBudget": cfg["frameTriangleBudget"],
"sceneOk": scene_tris <= cfg["frameTriangleBudget"],
"worstOk": worst_tris <= cfg["frameTriangleBudget"],
"digest": digest, "instances": rows,
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "ta07_budget.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_budget.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "distance", "projPx", "ratioPermille", "lodId", "tris"])
for r in rows:
w.writerow([r["id"], r["distance"], r["projPx"], r["ratioPermille"], r["lodId"], r["tris"]])
print(f"instances={len(rows)} screenArea={screen_area} trisByLevel={tris_by_level}")
print(f"countByLevel={by_level}")
for r in rows:
print(f" {r['id']:<12} d={r['distance']:4d} projPx={r['projPx']:4d} "
f"ratio={r['ratioPermille']:4d} {r['lodId']} tris={r['tris']:3d}")
print(f"sceneTris={scene_tris} budget={cfg['frameTriangleBudget']} sceneOk={report['sceneOk']}")
print(f"worstTris={worst_tris} worstOk={report['worstOk']}")
print(f"digest={digest}")
print("wrote out/ta07_budget.json out/ta07_budget.csv")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta07_budget.py:
instances=12 screenArea=2073600 trisByLevel=[252, 124, 60, 28]
countByLevel={'LOD0': 2, 'LOD1': 2, 'LOD2': 3, 'LOD3': 5}
counter_main d= 200 projPx= 800 ratio= 308 LOD0 tris=252
rug_main d= 420 projPx= 609 ratio= 178 LOD0 tris=252
lamp_floor d= 300 projPx= 426 ratio= 87 LOD1 tris=124
stool_a d= 260 projPx= 369 ratio= 65 LOD1 tris=124
shelf_wall d= 700 projPx= 274 ratio= 36 LOD2 tris= 60
plant_pot d= 360 projPx= 222 ratio= 23 LOD2 tris= 60
chair_b d= 480 projPx= 200 ratio= 19 LOD2 tris= 60
vase d= 600 projPx= 120 ratio= 6 LOD3 tris= 28
painting_1 d= 520 projPx= 123 ratio= 7 LOD3 tris= 28
book_stack d= 300 projPx= 133 ratio= 8 LOD3 tris= 28
clock d= 640 projPx= 75 ratio= 2 LOD3 tris= 28
candle d= 400 projPx= 80 ratio= 3 LOD3 tris= 28
sceneTris=1072 budget=1200 sceneOk=True
worstTris=3024 worstOk=False
digest=4c68257e603187b5
wrote out/ta07_budget.json out/ta07_budget.csv
当前视角合计 1072 三角,落进 1200 的预算;countByLevel 是 2 / 2 / 3 / 5——12 件里 5 件已经掉到最粗的 LOD3。
7.2.4 P 层:同一套选级规则的 TS 内核
下面这段是 P 层 TS 内核,与 Python 逐条对应——同一个整数除法、同一张阈值表、同一个「首个命中胜出」:
// file: core/src/ta07/budget.ts
// tier: P
// verify: node --test 'core/test/ta07_*.test.ts'
// 7.2 屏幕占比与三角预算的 P 层内核。与 sim/ta07_budget.py 同一套整数针孔相机与选级规则:
// projPx = (worldSize * focal) // distance;ratioPermille = (projPx^2 * 1000) // (W*H);
// 占比 >= 阈值即取该级(阈值随序号递减)。整数类,跨语言逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type Instance = { id: string; worldSize: number; distance: number };
export type BudgetCfg = {
screen: { width: number; height: number };
camera: { focal: number };
lodThresholdsPermille: number[];
frameTriangleBudget: number;
instances: Instance[];
};
export type InstanceRow = {
id: string; worldSize: number; distance: number; projPx: number;
ratioPermille: number; lod: number; lodId: string; tris: number;
};
export function selectLod(thresholds: number[], ratioPermille: number): number {
for (let i = 0; i < thresholds.length; i++) if (ratioPermille >= thresholds[i]) return i;
return thresholds.length - 1;
}
export function buildInstances(cfg: BudgetCfg, trisByLevel: number[], ids: string[]): InstanceRow[] {
const W = cfg.screen.width, H = cfg.screen.height;
const screenArea = W * H;
const focal = cfg.camera.focal;
return cfg.instances.map((inst) => {
const proj = Math.floor((inst.worldSize * focal) / inst.distance);
const ratio = Math.floor((proj * proj * 1000) / screenArea);
const lod = selectLod(cfg.lodThresholdsPermille, ratio);
return {
id: inst.id, worldSize: inst.worldSize, distance: inst.distance, projPx: proj,
ratioPermille: ratio, lod, lodId: ids[lod], tris: trisByLevel[lod],
};
});
}
export function budgetLines(rows: InstanceRow[]): string[] {
return rows.map((r) => `${r.id}:${r.ratioPermille}:${r.lod}`);
}
7.2.5 跨语言对照:digest 逐位一致
P 层测试读 7.1 的 out/ta07_lod.json(取每级三角数)与本节 Python 落盘的 out/ta07_budget.json:
// file: core/test/ta07_budget.test.ts
// tier: P
// verify: node --test 'core/test/ta07_*.test.ts'
// 跨语言对照:读 sim/ta07_budget.py 落盘的 out/ta07_budget.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 { buildInstances, budgetLines, selectLod } from "../src/ta07/budget.ts";
import type { BudgetCfg } from "../src/ta07/budget.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta07_budget.json"), "utf8")) as BudgetCfg;
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta07_budget.json"), "utf8"));
const lod = 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);
const trisByLevel = lod.levels.map((l: { tris: number }) => l.tris);
const ids = lod.levels.map((l: { id: string }) => l.id);
test("跨语言:TS 选级与三角合计 == Python 产物(逐位一致)", () => {
const rows = buildInstances(cfg, trisByLevel, ids);
assert.deepEqual(rows.map((r) => r.projPx), py.instances.map((i: { projPx: number }) => i.projPx));
assert.deepEqual(rows.map((r) => r.ratioPermille), py.instances.map((i: { ratioPermille: number }) => i.ratioPermille));
assert.deepEqual(rows.map((r) => r.lod), py.instances.map((i: { lod: number }) => i.lod));
assert.equal(rows.reduce((n, r) => n + r.tris, 0), py.sceneTris);
assert.equal(digest16(budgetLines(rows)), py.digest);
console.log(`budget digest = ${py.digest}`);
});
test("正例:当前视角 1072 三角过预算,最坏视角 3024 爆预算", () => {
const rows = buildInstances(cfg, trisByLevel, ids);
assert.equal(py.sceneTris, 1072);
assert.equal(py.worstTris, 3024);
assert.equal(py.frameTriangleBudget, 1200);
assert.equal(py.sceneOk, true);
assert.equal(py.worstOk, false);
assert.deepEqual(py.countByLevel, { LOD0: 2, LOD1: 2, LOD2: 3, LOD3: 5 });
});
test("负例:用浮点占比选级会与整数地板除分道扬镳", () => {
// 找一个恰好卡在阈值边界的实例:投影 609、屏幕 1920x1080
const proj = 609;
const intRatio = Math.floor((proj * proj * 1000) / (1920 * 1080));
const floatRatio = ((proj * proj) / (1920 * 1080)) * 1000;
assert.equal(intRatio, 178);
assert.ok(floatRatio >= 178 && floatRatio < 179); // 浮点值落在 [178,179)
// 取一个会让两种口径分岔的阈值:把阈值设成 179(浮点占比仍可能被判过线)
assert.equal(selectLod([179, 40, 12, 0], intRatio), 1); // 整数口径:178 < 179 -> LOD1
assert.ok(floatRatio < 179); // 浮点口径同判 LOD1(此例一致)
// 真正分岔:阈值 178 时整数判过线(>=178),浮点 178.6 也过线;阈值 179 都不过。
// 用「取整方向」差异构造:floor(178.6)=178,若误用 ceil 得 179 就会误判过线
assert.equal(Math.ceil(floatRatio), 179);
assert.notEqual(Math.ceil(floatRatio), intRatio);
});
test("负例:阈值顺序写反(升序)会让远景反而吃高模", () => {
const bad = [...cfg.lodThresholdsPermille].reverse(); // [0,12,40,120]
assert.equal(selectLod(bad, 20), 0); // 20 >= 0 命中第一项 -> LOD0,明显错误
assert.equal(selectLod(cfg.lodThresholdsPermille, 20), 2); // 正确应为 LOD2
});
实跑 node --test 'core/test/ta07_*.test.ts'(本节 4 条;本章三节合计 14 条):
budget digest = 4c68257e603187b5
✔ 跨语言:TS 选级与三角合计 == Python 产物(逐位一致)
✔ 正例:当前视角 1072 三角过预算,最坏视角 3024 爆预算
✔ 负例:用浮点占比选级会与整数地板除分道扬镳
✔ 负例:阈值顺序写反(升序)会让远景反而吃高模
ℹ pass 14
ℹ fail 0
两侧 digest 都是 4c68257e603187b5,投影像素、占比、选级、三角合计全是整数,逐位一致。
7.2.6 预算必须按最坏视角算
1072 过线,但脚本同时算了 worstTris = 3024——如果玩家把镜头拉到最近,12 件家具全落进 LOD0,三角数立刻是预算的 2.5 倍。这是 LOD 预算最容易被忽略的一条:当前视角过线不等于安全。真实的帧预算要按「最坏视角」定,或者反过来规定「近景时最多允许几件资产同时进 LOD0」,把超支提前变成一条规则而不是运行时的一次掉帧。
7.1 的误差上限与本节的距离阈值是同一件事的两面:阈值定「什么时候切」,误差上限定「切过去之后画面差多少」。7.3 接着处理切换本身的代价——切换那一帧的视觉跳变。
阅读导航:上一节:7.1 LOD 分级与误差指标 · 下一节:7.3 LOD 切换的视觉跳变 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。