8.3 划界:蒙皮数学外链

本节不是内容而是边界:第 8 章只讲骨骼数、调色板上限与蒙皮数据字节账,蒙皮矩阵数学、硬件 uniform 上限成因、骨骼贴图显存机制一律外链。把「什么归本章讲」编码成一张可断言的路由表——external 在前、this-book 在后,首个命中胜出;三条外链路径两两不同且全部在 public/ 产物中存在。

第 8 章只讲三件事:骨骼数与调色板上限(8.1)、蒙皮数据量与显存(8.2),以及本节这条边界。剩下的——蒙皮矩阵怎么算、顶点怎么被骨骼变换、逆绑定矩阵是什么、双四元数怎么解决线性混合蒙皮的塌陷——本卷一个字都不讲,一律外链给 graphics 专题。这一节把「什么归本章讲」编码成一张可断言的路由表,越界就报警。

8.3 划界:蒙皮数学外链

8.3.1 什么归本章讲,什么一律外链

为什么要把边界写成代码?因为越界是默认行为。一个朴素的路由器看到「骨骼」两个字,就会把「骨骼动画的数学」收进本卷——这正是撞车的开始。划界要反过来做:先把原理主题全部标成 external,且排在 this-book 规则之前,首个命中的规则胜出;只有真正属于本章的主题才判给 this-book。

这里最容易混的是「骨骼」这个词本身。「骨骼预算」「骨骼数分级」是本章的账;「骨骼动画原理」「骨骼贴图的采样机制」是原理。同一个名词,两半分工——光看关键词分不开,得看它在问「有多少」还是「怎么算」。判不出来的主题返回 null,逼你去补规则,而不是静默归到本章。

判据的粒度也重要。如果规则只写「骨骼」,那么「骨骼预算」和「骨骼动画原理」会同时命中同一条规则——所以 match 必须写到词组(「骨骼预算」而不是「骨骼」)。本节的三条 external 规则分别锚定「蒙皮矩阵 / 逆绑定 / 双四元数」「uniform 上限 / 寄存器上限」「骨骼贴图 / 纹理显存」,四条 this-book 规则锚定「骨骼预算 / 骨骼数」「调色板上限」「蒙皮数据 / 蒙皮字节」「影响数 / 权重精度」——每条都写到能区分语义的最短词组,不多不少。

8.3.2 外链清单:三个原理之家

本章涉及但不展开的三个原理主题,各自外链到一篇既有文章:

主题域归属外链目标
蒙皮矩阵数学 / 骨骼动画原理 / 逆绑定external骨骼动画与蒙皮
uniform 上限的硬件成因 / 移动端寄存器限制external移动端渲染优化
骨骼贴图的显存与采样机制external纹理显存

这三篇是原理的家:讲清楚「为什么一根骨骼要占 3 个 vec4」「为什么老移动 GPU 只认骨骼贴图」。本卷只讲「美术侧怎么把它算成预算与账」。要原理,去那三篇;要骨骼数、上限、字节账,留在本章。

三篇外链的分工互不重叠:graphics-skeletal-animation 是数学的家(矩阵、权重、双四元数),graphics-mobile-rendering-optimization 是硬件上限成因的家(寄存器、动态下标),graphics-texture-memory 是骨骼贴图显存的家。三篇各管一块,读者不会在两个地方看到同一段推导。

8.3.3 划界路由表(D 层)

路由表本身是一份 D 层数据:规则按顺序排列,external 在前、this-book 在后;verifiedUrls 是已核验的绝对 URL 清单:

{
  "project": "stardust-inn",
  "ruleSet": "chapter-scope",
  "specRef": "config/visual_spec.json",
  "note": "第 8 章划界路由表:把「什么归本章讲(this-book)、什么一律外链(external)」编码成可断言的数据。external 规则必须排在 this-book 规则之前(首个命中胜出)。三条 external 规则的路径两两不同、全部取自已核验清单,且都在 public/ 产物中存在。",
  "verifiedUrls": [
    "/graphics-skeletal-animation/",
    "/graphics-mobile-rendering-optimization/",
    "/graphics-texture-memory/",
    "/posts/graphics/"
  ],
  "probes": [
    "蒙皮矩阵数学",
    "逆绑定矩阵",
    "双四元数蒙皮",
    "骨骼贴图显存",
    "移动端 uniform 上限",
    "骨骼数分级预算",
    "调色板上限",
    "蒙皮数据字节账",
    "每顶点骨骼影响数",
    "完全没听过的主题"
  ],
  "rules": [
    { "id": "skin-matrix-math", "owner": "external", "path": "/graphics-skeletal-animation/", "match": ["蒙皮矩阵", "骨骼动画原理", "逆绑定", "双四元数"] },
    { "id": "uniform-limit-hw", "owner": "external", "path": "/graphics-mobile-rendering-optimization/", "match": ["uniform 上限", "寄存器上限", "移动端 uniform", "硬件上限成因"] },
    { "id": "bone-texture-vram", "owner": "external", "path": "/graphics-texture-memory/", "match": ["骨骼贴图", "纹理显存"] },
    { "id": "bone-budget", "owner": "this-book", "path": null, "match": ["骨骼预算", "骨骼数"] },
    { "id": "palette-limit", "owner": "this-book", "path": null, "match": ["调色板上限", "调色板预算"] },
    { "id": "skin-bytes", "owner": "this-book", "path": null, "match": ["蒙皮数据", "蒙皮字节", "顶点属性字节"] },
    { "id": "influence-count", "owner": "this-book", "path": null, "match": ["影响数", "权重精度"] }
  ]
}

三条 external 规则的路径两两不同、且全部取自已核验清单——这正是 §9.1 的不变量:external 规则条数 == 既有文章条数。

verifiedUrls 里还留了一条栏目页 /posts/graphics/——它不参与路由,只作「原理的家」的栏目入口,方便读者从一张表跳到整个专题。规则里的 path 指向具体文章,verifiedUrls 指向已核验的 URL 集合,两者一起构成「外链目标必须真实存在」的判据。

8.3.4 探针:正例与负例都要有

探针列表不是随便凑的,它要在一次运行里同时证明两件事:该外链的确实外链了(正例),该留下的确实没被抢走(负例)。本节的 10 条探针里,前 5 条是 external 的候选、接着 4 条是 this-book 的候选,最后 1 条是「谁都不该认领」的漏网检查:

探针期望检验什么
蒙皮矩阵数学external原理确实外链
逆绑定矩阵external同义词也能命中(不只靠「蒙皮」二字)
双四元数蒙皮external一个更冷门的原理词
骨骼贴图显存external「骨骼」+「显存」两半各沾一个词,判给原理
移动端 uniform 上限external硬件成因归原理
骨骼数分级预算this-book数量账留在本章
调色板上限this-book上限账留在本章
蒙皮数据字节账this-book字节账留在本章
每顶点骨骼影响数this-book「影响数」不被 external 抢走
完全没听过的主题None判不出来的必须返回 null,不许默认归本章

「同义词也要命中」是正例的软肋:只写「蒙皮矩阵」一条 match,遇到「逆绑定矩阵」就漏了,所以 skin-matrix-math 这条规则的 match 列了四个词。而「判不出来返回 null」是最后一道防线——它逼规则作者去补,而不是让主题静默落进某个默认类别。

8.3.5 划界表要满足的三条不变量

这张路由表不是随手写的清单,它要满足三条可断言的不变量——少一条都会让边界失守:

不变量含义失守的后果
路径两两不同每条 external 规则的路径互不相同两篇原理文章被压成一条,读者点进去只有一个家
条数等于去重条数external 规则数 == 去重后的路径数出现「一个目标被多条规则指」的冗余,改一处漏一处
路径都在产物中存在每个 external 路径在 public/ 里有对应页面外链指向死链——凭语义猜的 slug 十有八九是错的

第三条最要命。代理凭语义猜 slug 会写死链:看到「骨骼动画」就写 /graphics-skeletal-animation/,看着对,但只要真实的 slug 差一个字符,就是一条 404。所以本节不靠记忆,靠产物核对:脚本逐个 os.path.exists(public/<路径>/index.html),任一条不存在就 exit 1。

这三条不变量正是 §9.1 给整卷定的纪律——external 规则条数 == 既有文章条数。不是「差不多」就行,是逐条对得上:本章涉及三个原理主题,就恰好写三条 external 规则,不多不少。

8.3.6 S 层:路由与产物核对

S 层脚本对一串探针主题逐个路由,并逐条核对 external 路径在 public/ 产物中真实存在:

#!/usr/bin/env python3
# file: sim/ta08_scope.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta08_scope.py
"""8.3 第 8 章划界路由表(S 层)。

本章只讲骨骼数、调色板上限、蒙皮数据字节账;蒙皮矩阵数学、骨骼动画原理、硬件 uniform 上限的
成因、骨骼贴图显存机制一律外链,本卷不重推。本脚本把这张划界表跑成可断言的账:对一串探针主题
逐个路由落 out/ta08_scope.json;断言 external 规则数 == 去重后的 external 路径数(路径两两不同);
逐条核对每个 external 路径在仓库 public/ 产物中真实存在(页面查 index.html)。
"""
import hashlib
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REPO = "/Users/bingrong.yan/projects/birdor/plumephp"
PUBLIC = os.path.join(REPO, "public")
CONFIG = os.path.join(ROOT, "config/ta08_scope.json")
OUT = os.path.join(ROOT, "out")


def route(rules, topic):
    """首个命中的规则胜出。"""
    for r in rules:
        if any(k in topic for k in r["match"]):
            return {"topic": topic, "owner": r["owner"], "path": r["path"], "ruleId": r["id"]}
    return {"topic": topic, "owner": None, "path": None, "ruleId": None}


def path_exists(url):
    """页面目标查 index.html;带扩展名的目标查文件本身。"""
    tail = url.rstrip("/").split("/")[-1]
    if "." in tail:
        return os.path.isfile(os.path.join(PUBLIC, url.lstrip("/")))
    return os.path.exists(os.path.join(PUBLIC, url.strip("/"), "index.html"))


def main():
    cfg = json.load(open(CONFIG, encoding="utf-8"))
    os.makedirs(OUT, exist_ok=True)
    rules = cfg["rules"]
    externals = [r for r in rules if r["owner"] == "external"]
    paths = [r["path"] for r in externals]
    verified = set(cfg["verifiedUrls"])
    missing = [p for p in paths if not path_exists(p)]
    not_verified = [p for p in paths if p not in verified]
    probes = [route(rules, t) for t in cfg["probes"]]
    routed = [p for p in probes if p["owner"]]

    digest = hashlib.sha256("\n".join(
        f"{r['id']}|{r['owner']}|{r['path']}" for r in rules).encode("utf-8")).hexdigest()[:16]

    summary = {
        "project": cfg["project"], "ruleSet": cfg["ruleSet"],
        "ruleCount": len(rules), "externalRuleCount": len(externals),
        "distinctExternalPaths": len(set(paths)), "thisBookRuleCount": len(rules) - len(externals),
        "pathsPairwiseDistinct": len(paths) == len(set(paths)),
        "allPathsInVerifiedList": not not_verified, "allPathsInPublic": not missing,
        "missingPaths": missing, "probes": probes, "routedCount": len(routed), "digest": digest,
    }
    with open(os.path.join(OUT, "ta08_scope.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"rules={len(rules)} external={len(externals)} thisBook={len(rules) - len(externals)}")
    print(f"distinctExternalPaths={len(set(paths))} pairwiseDistinct={summary['pathsPairwiseDistinct']} "
          f"allInVerified={summary['allPathsInVerifiedList']} allInPublic={summary['allPathsInPublic']}")
    for p in probes:
        print(f"  {p['topic']:<18} -> {str(p['owner']):<10} {p['path'] or '-'} ({p['ruleId'] or '-'})")
    print(f"digest={digest}")
    print("wrote out/ta08_scope.json")
    ok = summary["pathsPairwiseDistinct"] and summary["allPathsInVerifiedList"] and summary["allPathsInPublic"]
    return 0 if ok else 1


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

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

rules=7 external=3 thisBook=4
distinctExternalPaths=3 pairwiseDistinct=True allInVerified=True allInPublic=True
  蒙皮矩阵数学             -> external   /graphics-skeletal-animation/ (skin-matrix-math)
  逆绑定矩阵              -> external   /graphics-skeletal-animation/ (skin-matrix-math)
  双四元数蒙皮             -> external   /graphics-skeletal-animation/ (skin-matrix-math)
  骨骼贴图显存             -> external   /graphics-texture-memory/ (bone-texture-vram)
  移动端 uniform 上限     -> external   /graphics-mobile-rendering-optimization/ (uniform-limit-hw)
  骨骼数分级预算            -> this-book  - (bone-budget)
  调色板上限              -> this-book  - (palette-limit)
  蒙皮数据字节账            -> this-book  - (skin-bytes)
  每顶点骨骼影响数           -> this-book  - (influence-count)
  完全没听过的主题           -> None       - (-)
digest=25d1178dd0eb0833
wrote out/ta08_scope.json

3 条 external、4 条 this-book;三条外链路径两两不同、全在已核验清单内、且都在 public/ 产物里真实存在(allInPublic=True)。探针里「蒙皮矩阵数学」判给 external、「蒙皮数据字节账」判给 this-book——同一批探针里既有越界检查也有漏网检查。

输出里的 digest 是「规则 id | owner | path」七行排序后的 sha256 前 16 位——它是字符串类指纹,改了任何一条规则或路径它都会变,用来一眼看出划界表动过没有。

整张表的可复现性就落在这三件事上:路由是纯字符串包含判断、路径核对是文件系统查询、digest 是排序后哈希——没有一处依赖浮点或外部状态,换台机器跑出的数字一模一样。

8.3.7 边界靠断言守住

这张表最容易被忽略的价值在两条容易混的探针上:「蒙皮矩阵数学」与「蒙皮数据字节账」都以「蒙皮」开头,但前者问「怎么算」、后者问「多少字节」,分属两半。如果路由表把 external 规则排在后面,或者干脆没有「蒙皮矩阵」这条 external 规则,「蒙皮矩阵数学」就会被 skin-bytes 的「蒙皮数据」抢走——不,它其实不会被抢走,因为「蒙皮矩阵」里没有「蒙皮数据」四个字。真正的风险是反过来的:有人新增一条「蒙皮」的 this-book 规则,就会把「蒙皮矩阵数学」误收进本卷。external 在前、且规则要写到具体词组,这两条是防越界的全部。

至于「骨骼贴图显存」这条探针——它同时含「骨骼」与「显存」两个词,一个属于本章(骨骼数)、一个属于原理(显存机制)。路由表判给 external 的 bone-texture-vram,因为它问的是「贴图怎么占显存」而不是「骨骼有多少根」。这类「两半各沾一个词」的主题,正是划界表存在的意义:判据是语义,不是关键词计数。

反过来,新增一条「属于本章」的主题时,必须同时满足两件事:把它写进 config/ta08_scope.json 的 this-book 规则,且排在所有 external 规则之后。如果排错了位置——比如把一条 this-book 规则插到 external 前面——它就会抢走本该外链的原理主题。这就是为什么路由表要求「external 在前」,而不是「按重要性排序」。顺序本身就是判据的一部分:首个命中胜出,谁在前谁说了算。

所以本节真正的产出不是「列了三个外链」这件事,而是一张能被机器执行的边界定义。散文会过期,路由表不会——它每次运行都重新核对:路径是否真实存在、探针是否落在正确的半边、有没有判不出来的漏网主题。

一句话总结:第 8 章的边界是「数量账归本章,数学与机制外链」。把它写成一张带负例的路由表,越界就报警;判不出来的返回 null,构建期提醒你补规则。边界靠断言守住,不靠自觉。

阅读导航:上一节:8.2 蒙皮数据量与显存 · 下一节:9.1 动画资源命名与状态绑定 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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