18. Dirty Flag
18. Dirty Flag:主数据变化时标脏,只有派生结果真正被读取前才重新计算,通过可复位因果实验和反例证据验收。
18. Dirty Flag
学习目标
- 能说明脏标志的延迟重算
- 能解释漏置脏的风险
- 能选择标志清理时机与粒度
为什么"18. Dirty Flag"从问题证据开始
你的游戏有场景树:父节点移动,子节点的"世界坐标"(本地坐标乘以父级变换)全部要重新计算。最直觉的实现是:每次父节点变化,立刻重算所有子节点的世界坐标。代码今天能跑,但性能问题浮现:一个子节点一帧内可能被访问多次(渲染、碰撞、拾取),每次访问如果都重算整条链,同一个坐标被反复计算几十次——而实际上只要算一次,存起来。
本页把"无模式基线"定义为"每次访问都重算派生结果"的代码,然后引入脏标志作为候选机制,验证它是否真的让"重复计算"消失。通过条件是派生结果只在源数据变化且被真正读取时才重算一次——而不是每次访问都算。
来源、版本与独立重写边界
本页用作者完整在线正文核对正式标题、设计分叉和时代语境,并以作者源码仓库交叉检查结构。仓库许可证明确正文、HTML与样式为 CC BY-NC-ND 4.0,示例程序等其他文件为 MIT;因此下列中文解释、图示、交互和代码均为独立教学重写,不翻译、拼接或改写受 ND 限制的原文表达。
本章机制与术语
理解 Dirty Flag 需要四个核心概念:
- ↡:真正的数据源头(本地坐标、位置)——变化是重算的触发条件。
- ↡:从主数据算出来的结果(世界坐标、变换矩阵)——昂贵且可缓存。
- ↡:布尔标记,主数据变化时置位——提示"派生数据过期"。
- ↡:不立刻算,等派生数据被读取且标志为脏时才算——算一次用多次。
四个概念共同约束"主数据变化时标脏,只有派生结果真正被读取前才重新计算":任何结论都必须回到"重算次数、缓存命中、读取-重算时序"三个可观察量。
Dirty Flag — 变了才重算
第 1 / 4 步 · ① 状态变化:位置/朝向改变 → 置脏标志
缓存 + 脏标志:算一次、用多次;变了才重算——把重复计算从 O(N) 降到 O(变化数)。
💡 对着动画看:动画左侧是 Transform(主数据,setPosition 时置脏),右上"dirty = true"框(等待重算),右下两个结果框——"未变脏:返回缓存 ✓ 零开销"与"已变脏:重算并清标志"。读"缓存的本地变换"一节时,记住关键动作:变化只标脏,不立刻算;读取时才决定算不算。
官方结构逐项深读
18. Dirty Flag
Dirty Flag 的主张:用缓存 + 标志,把"重复计算"变成"算一次用多次"。主数据变化时,不立刻重算昂贵的派生数据,只置一个脏标志;派生数据被读取时,先查标志——脏则重算并清标志,不脏则直接返回缓存。核心洞察:如果派生数据在两次变化之间被读取多次,缓存就省下了中间所有的重算。
Intent
意图:延迟昂贵的计算,让它在"真正被需要"时才发生。代价(重算一次)在"被读取一次"时付出;收益(省下中间重复计算)在"被读取多次但没变化"时兑现。适用场景:派生计算昂贵(矩阵乘法、路径查找)、且"变化次数远小于读取次数"——变化稀疏、读取频繁,是脏标志最赚钱的组合。
Motivation
回到场景树:父节点移动一次,渲染器、碰撞系统、拾取系统可能各访问子节点世界坐标一次——一帧内同一个坐标被读 3 次。无缓存实现每次读都重算(3 次矩阵乘);脏标志实现:父移动置脏 → 第一次读重算(1 次)→ 后两次读直接返回缓存(0 次)。一帧省 2/3 的计算——层级越深、读取越频繁,省得越多。
Local and world transforms
场景树的数据模型:每个节点有本地坐标(相对父节点,自己设的)和世界坐标(相对世界根,由本地 × 父链推导)。世界坐标 = 昂贵派生数据的典型:world = parent.world * local。父节点一动,整条子链的世界坐标全部失效。这里"主数据"是本地坐标,"派生数据"是世界坐标。
Cached world transforms
直觉的缓存:给每个节点存一份世界坐标,父变化时立刻重算并更新缓存。这消除了"每次读都算",但引入了新问题——父变化但子坐标从没被读取时,重算是白做的(缓存算了没人用)。更进一步:如果父一帧内变多次(连续移动),每次变化都立刻重算,中间的重算全浪费。缓存的下一步是"延迟"。
Deferred recalculation
延迟重算 = 脏标志:父变化时不重算,只置子节点脏标志;子节点世界坐标被读取时,检查标志——脏则重算并清标志,不脏则返回缓存。计算时机从"变化时"推迟到"读取时":变化但不被读 → 永远不重算(省);变化且被读一次 → 重算一次(正常);变化多次但只读一次 → 只算一次(赚)。这就是"算一次用多次"。
The Pattern
模式结构:对象持派生数据缓存(worldTransform_)+ 脏标志(dirty_)+ 主数据(local_)。setLocal() 修改主数据并 dirty_ = true;getWorld() 先查 dirty_——脏则重算缓存并清标志,返回缓存;不脏直接返回缓存。一切读取走 getter,一切修改走 setter——标志管理被封装在访问器里。
When to Use It
何时用脏标志:派生计算昂贵(矩阵、路径、渲染几何)、读取远多于变化(每帧读几十次、变化偶尔)、变化可能不被读取(节点移动了但没人问坐标)。何时不用:计算廉价(重算一次的成本 ≈ 检查标志的成本,标志是浪费)、读取与变化频率相当(缓存不赚钱)。判断标准:"读取次数 / 变化次数"的比值——大,用脏标志。
Keep in Mind
三个代价:延迟过久(派生结果"过时"——如果变化后必须立刻看到新结果,脏标志的延迟语义不适用,需要立即重算模式)、必须保证每次变化都置脏(漏置一个标志,缓存返回过期数据——最隐蔽的 bug)、缓存要占内存(保留上一份派生数据,即使它"没用")。
There is a cost to deferring for too long
延迟的代价:派生数据在"变化"与"读取"之间是过时的。如果中间有代码需要"最新结果"(比如物理系统要立刻用新世界坐标),脏标志的延迟会让它拿到旧数据。解法:混合模式——关键路径用"立即重算",非关键路径用"脏标志延迟";或提供 forceRecompute() 强制刷新。作者提醒:延迟优化了性能,但改变了"何时看到新结果"的语义。
You have to make sure to set the flag every time the state changes
脏标志最危险的坑:漏置标志。任何修改主数据的代码路径都必须置脏——漏一条路径,缓存就静默返回过期数据,bug 极难排查(结果看起来"偶尔不对")。防御:主数据字段私有化(只能经 setter 修改,setter 统一置脏),别让外部直接改字段绕过标志。这是"封装换正确性"的典型:置脏逻辑集中在 setter。
You have to keep the previous derived data in memory
缓存要占内存:每个节点存一份世界坐标(可能 4×4 矩阵 = 64 字节),场景树几千节点就是几百 KB——为了省计算,付出了内存。权衡:矩阵变换很贵(省的值),内存几百 KB 很便宜(付得起的价)。但如果派生数据巨大(如渲染几何),缓存内存成本要重新评估——可能"算一次用一次"更划算。
Sample Code
示例代码:Transform 类——setLocal(x,y) 置 dirty_ = true;getWorld() 里 if (dirty_) { recompute(); dirty_ = false; } return world_;。场景树遍历:渲染器 for (node : nodes) render(node->getWorld())——getter 内部自动判断重算,调用方完全无感。这是脏标志的优雅:优化逻辑封装在访问器,业务代码不变。
An unoptimized traversal
对照实现:无优化的场景树遍历——每次 node->getWorld() 都现算 parent.world * local。层级深 10 的节点,每次读取做 10 次矩阵乘;一帧读 100 次就是 1000 次乘法。这个"能跑但慢"的基线,是脏标志优化的起点——对照它才能量化"算一次用多次"省了多少。
Let's get dirty
优化后的遍历:getWorld() 带脏检查——多数帧节点没动(dirty=false),读取直接返回缓存,零矩阵乘;只有父节点动过的帧,第一次读取重算。实测效果:静态场景 100% 缓存命中(完全零计算),动态场景只算变化的节点。渲染管线中绝大多数的 getWorld 变成一次字段读取。
Design Decisions
两个关键设计决策:脏标志何时清理(读取时 vs 批处理结束时)和 脏追踪粒度(整对象 vs 字段级)。前者决定"何时重算",后者决定"重算什么"——下面两节展开。
When is the dirty flag cleaned?
清理时机:读取时清理(getter 查到脏 → 重算 → 清标志——最直观,"需要时才算一次")与批处理结束时清理(如帧末统一重算所有脏节点——一次重算多处用,但要管理"帧末"这个时间点)。作者倾向:读取时清理最符合"延迟"语义(谁读谁触发);批处理适合"多个消费者需要一致快照"的场景。
How fine-grained is your dirty tracking?
追踪粒度:整对象粒度(一个标志管所有派生数据——简单,但任一字段变化重算全部派生数据)与字段级粒度(每个派生数据一个标志——精确,只重算受影响的部分,但标志管理复杂)。作者建议:从整对象粒度起步(多数情况够用),派生数据之间独立变化频繁时再升级字段级。粒度越细越精确,代价是复杂度。
See Also
- Data Locality:脏标志决定"算不算",数据局部性决定"算多快"——都优化热循环,一个省计算一个省带宽。
- Observer:观察者让"源变化"通知"依赖者",脏标志是"依赖者被读取时才自查"——一个推送一个拉取。
- 缓存(Caching):脏标志是"失效驱动的缓存"——广义缓存(LRU、脏页)的同类,理解脏标志就理解缓存失效。
可迁移实现或计算骨架
initial state -> 主数据变化 -> 置脏标志(不重算)-> 派生数据被读取 -> 脏则重算并清标志、不脏返回缓存
fault injection -> 每次读取都重算派生结果
pass condition -> 派生结果只重算一次、重复读取零计算、缓存永不过期
reset -> initial state对应实现要点:主数据 setter 统一置脏(私有化防绕过);派生 getter 查脏决定重算或返回缓存;主数据变化后必须走 setter。该骨架只保存实验合同;真实项目还要固定派生数据清单、读取频率基线与缓存内存预算,并保留基线实现以便回退。
本章练习与节点验证矩阵
练习
问题 1:重复计算对比。
操作:模拟父节点变一次、子坐标读 3 次:无缓存 vs 脏标志
问题 2:漏置脏注入。
操作:让某个 setter 漏置脏标志,再读取派生数据
问题 3:延迟语义。
操作:变化后立即读取 vs 变化后延迟读取,对比看到的新旧结果
问题 4:粒度选择。
操作:整对象粒度与字段级粒度各实现一次,对比重算范围
术语复核
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 主数据
- 派生数据
- 脏标志
练习答案参考
练习判据即答案:每个练习的"验证判据"列给出了通过标准——先自己动手,再对照判据核验。
术语复核与本章回顾
掌握"18. Dirty Flag"意味着能从"每次访问都重算派生结果让同一计算重复几十次"出发,解释主数据、派生数据、脏标志与延迟重算四者的关系,再用"重算次数、缓存命中、读取-重算时序"三个可观察量推翻或保留实现。若三个判据不能同时满足,本章仍未通过。
一句话回顾:变化只标脏,读取才算账——派生结果算一次用多次,漏置脏是唯一大敌,setter 统一置脏是它的护身符。
阅读导航
← 上一页:17. Data Locality · 下一页:19. Object Pool → ← 上一页:17. Data Locality · 下一页:19. Object Pool →