第 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 动画资源命名与状态绑定 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。