18. Dirty Flag

18. Dirty Flag:主数据变化时标脏,只有派生结果真正被读取前才重新计算,通过可复位因果实验和反例证据验收。

18. Dirty Flag

学习目标

  • 能说明脏标志的延迟重算
  • 能解释漏置脏的风险
  • 能选择标志清理时机与粒度

为什么"18. Dirty Flag"从问题证据开始

你的游戏有场景树:父节点移动,子节点的"世界坐标"(本地坐标乘以父级变换)全部要重新计算。最直觉的实现是:每次父节点变化,立刻重算所有子节点的世界坐标。代码今天能跑,但性能问题浮现:一个子节点一帧内可能被访问多次(渲染、碰撞、拾取),每次访问如果都重算整条链,同一个坐标被反复计算几十次——而实际上只要算一次,存起来。

本页把"无模式基线"定义为"每次访问都重算派生结果"的代码,然后引入脏标志作为候选机制,验证它是否真的让"重复计算"消失。通过条件是派生结果只在源数据变化且被真正读取时才重算一次——而不是每次访问都算。

来源、版本与独立重写边界

本页用作者完整在线正文核对正式标题、设计分叉和时代语境,并以作者源码仓库交叉检查结构。仓库许可证明确正文、HTML与样式为 CC BY-NC-ND 4.0,示例程序等其他文件为 MIT;因此下列中文解释、图示、交互和代码均为独立教学重写,不翻译、拼接或改写受 ND 限制的原文表达。

本章机制与术语

理解 Dirty Flag 需要四个核心概念:

  • :真正的数据源头(本地坐标、位置)——变化是重算的触发条件。
  • :从主数据算出来的结果(世界坐标、变换矩阵)——昂贵且可缓存。
  • :布尔标记,主数据变化时置位——提示"派生数据过期"。
  • :不立刻算,等派生数据被读取且标志为脏时才算——算一次用多次。

四个概念共同约束"主数据变化时标脏,只有派生结果真正被读取前才重新计算":任何结论都必须回到"重算次数、缓存命中、读取-重算时序"三个可观察量。

Optimization Pattern · Dirty Flag

Dirty Flag — 变了才重算

▷ 可交互
主数据变化时标脏,派生结果只在被读取前才重算渲染器每帧问"需要重算吗?"——绝大多数帧答案是否Transform(主数据)pos=(10,20) rot=45°setPosition() 改变 → 置脏dirty = true ⚑本地矩阵失效,等待重算(不立刻算,等被读取)getLocalMatrix():未变脏直接返回缓存矩阵 ✓ 零开销渲染器每帧询问都不重算getLocalMatrix():已变脏重算矩阵 → 清 dirty 标志只在真正需要时才花算力层级渲染中多数节点未变化 → 只重算脏节点,全局矩阵链大幅提速

第 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_ = truegetWorld() 先查 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_ = truegetWorld()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 →

资料与写作方式声明

本章以Robert Nystrom《Game Programming Patterns》(游戏编程模式)权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…