7.2 屏幕占比与三角预算

屏幕占比是一屏三角预算的分配依据。本节用整数针孔相机把距离换算成占比千分比,按阈值表逐实例选级,把一屏 12 件家具的三角数合计到 1072,与 1200 的帧预算对账;并算出「全部拉到 LOD0」的最坏视角会爆到 3024,说明预算必须按最坏视角算。

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 切换的视觉跳变 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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