脏标记
Dirty Flag
脏标记 (Dirty Flag) 用一个布尔标记记录“派生数据是否已经过时”:原始数据变化时置位;需要派生数据时,如果标记已置位就重新计算并清除标记,否则直接用缓存的结果。场景图里由局部变换求世界变换是经典例子。它只在原始数据变化比派生数据被使用更频繁、而且难以增量更新时才划算,最大的风险是漏置标记导致使用过时数据。
意图
把工作推迟到确实需要结果的时候,从而避免不必要的工作。(Game Programming Patterns — dirty-flag)
机制
一组原始数据随时间变化,一组派生数据由它经昂贵的过程算出。脏标记记录派生数据是否与原始数据不同步:原始数据变化时置位;需要派生数据时,若标记已置位就重新处理并清除标记,否则使用上次缓存的派生数据。(Game Programming Patterns — dirty-flag)
场景图例子:海盗船上有桅杆瞭望台,瞭望台里有海盗,海盗肩上有鹦鹉。每个对象只存相对父对象的局部变换,渲染时需要的世界变换是从根到该对象一路把局部变换乘起来:
每帧为所有对象重算世界变换,对没动过的对象是浪费,所以要缓存世界变换;但如果局部变换一改就立即递归重算自己和所有子孙,一帧里船、瞭望台、海盗、鹦鹉各动一次,总共只动了四个对象,却做了十次世界变换计算,其中六次在渲染前就被丢弃,鹦鹉的世界变换被算了四遍、只渲染一次。脏标记的做法是:设置局部变换时只把自己标记为脏;渲染遍历场景图时,把“父节点是否脏”向下传递,只有路径上确实脏了的节点才重新相乘,并缓存结果。仅凭一个比特,它做到了三件事:把父链上多次局部修改合并为一次重算;不重算没动的对象;对象在渲染前被删除就完全不算。(Game Programming Patterns — dirty-flag)
细节(Game Programming Patterns — dirty-flag):
- 新节点的标记初始为
true,因为刚创建时还从未算过世界变换。 setTransform()不递归标记子节点(递归又慢),而是在渲染时用dirty参数把“父链上是否有节点脏了”往下传,就像parentWorld一路累积世界变换一样。- 这个技巧之所以成立,是因为
render()是GraphNode中唯一需要最新世界变换的地方;如果别处也要读,就得换做法。 - 这里还隐含一个假设:
if检查比矩阵乘法快。直觉如此,但分支预测失败会冲刷流水线,见 数据局部性。
下图表示一次渲染遍历:标记沿着父子关系向下传播,只重算脏的子树。
flowchart TD Ship["船:局部变换被修改 → dirty"] --> Nest["瞭望台:继承父 dirty,重算世界变换"] Nest --> Pirate["海盗:继承父 dirty,重算"] Pirate --> Parrot["鹦鹉:继承父 dirty,重算"] Root["根"] --> Ship Root --> Other["其他未修改的子树:直接用缓存的世界变换"]
一帧里不管船和海盗各被移动了多少次,每个节点的世界变换最多只算一次,没被动过的子树完全跳过。
何时使用
只在性能问题大到足以抵消额外复杂度时使用(Game Programming Patterns — dirty-flag)。它用于两类工作:计算(场景图的大量矩阵运算)和同步(派生数据在磁盘或网络另一端,搬运本身就昂贵)。另外两个前提:
- 原始数据变化比派生数据被使用更频繁。如果每次修改之后都马上要用派生数据,这个模式帮不上忙。
- 难以增量更新。船舱货物的总重量直接维护一个累加值(加货就加,卸货就减)更简单,不需要脏标记。
在一般游戏代码库里搜索 “dirty”,常常能找到这个模式。(Game Programming Patterns — dirty-flag)
取舍与坑(Game Programming Patterns — dirty-flag)
- 推迟太久也有代价:需要结果时往往是“马上就要”,而计算本身又慢,可能造成可见的停顿;推迟还意味着出问题时工作可能根本没做(文本编辑器“未保存”的小圆点就是可视化的脏标记,断电就丢了,于是有了后台自动保存)。
- 每次状态变化都必须置位:这本质上是缓存失效 (cache invalidation) 问题,漏一处就会用到过时数据,而且很难查。缓解办法是让所有修改都经过一个窄接口,在那里统一置位。
- 要在内存里保留上次的派生数据:这是用内存换速度,适合计算慢而内存便宜的情况;时间比内存多时就按需计算。(用于同步时,派生数据通常本来就不在内存里,这一点不成问题。)
- 推迟与否是一条连续谱,作者用垃圾回收类比:引用计数一不用就立即释放,但每次改引用都耗 CPU;简单的回收器推迟到真正需要时,代价是 GC 停顿;延迟引用计数、增量 GC 介于两者之间。自动保存的频率同理,是在“崩溃时少丢点工作”与“别频繁写盘”之间取点。
设计决策(Game Programming Patterns — dirty-flag)
| 决策 | 选项 | 要点 |
|---|---|---|
| 何时清除标记 | 需要结果时 | 结果从未被使用就完全不算;但计算慢时会造成停顿 |
| 在明确的检查点(进港时存档、加载画面、过场动画) | 不影响体验;但无法保证玩家一定会到达检查点 | |
| 在后台(第一次修改时启动定时器) | 可以调节频率;可能做更多冗余工作;需要异步支持,还要考虑并发修改 | |
| 粒度 | 细粒度(每块甲板木板一个标记) | 只处理真正变化的数据 |
| 粗粒度(每层甲板一个标记) | 会处理未变化的数据;但标记更省内存,固定开销(如标识修改位置的元数据)更少 |
物理引擎里“物体是否在运动”的标记也是一种脏标记:静止的刚体在受到冲量之前不需要处理。游戏之外,Angular 这类浏览器端框架也用脏标记追踪哪些数据改了、需要推送到服务器。(Game Programming Patterns — dirty-flag)后台方式中,程序收到输入与响应之间的有意延迟在人机交互里叫滞后 (hysteresis)。
代码草图(C++)
按书中示例整理(先是未优化的遍历,再加脏标记):
class Transform {
public:
static Transform origin(); // 单位变换
Transform combine(Transform& other);
};
// 未优化:每帧每个节点都重新组合
void GraphNode::render(Transform parentWorld) {
Transform world = local_.combine(parentWorld);
if (mesh_) renderMesh(mesh_, world);
for (int i = 0; i < numChildren_; i++) children_[i]->render(world);
}
// 加入脏标记
class GraphNode {
public:
GraphNode(Mesh* mesh)
: mesh_(mesh), local_(Transform::origin()), dirty_(true) {} // 新节点一出生就是脏的
void setTransform(Transform local);
void render(Transform parentWorld, bool dirty);
private:
Transform world_; // 缓存的世界变换
bool dirty_;
Transform local_;
Mesh* mesh_; // 可为 NULL:只用于分组的节点
static const int MAX_CHILDREN = 16;
GraphNode* children_[MAX_CHILDREN];
int numChildren_;
};
void GraphNode::setTransform(Transform local) {
local_ = local;
dirty_ = true; // 只置位,不递归标记子节点
}
void GraphNode::render(Transform parentWorld, bool dirty) {
dirty |= dirty_; // 父链上任一节点脏,则自己也要重算
if (dirty) {
world_ = local_.combine(parentWorld);
dirty_ = false;
}
if (mesh_) renderMesh(mesh_, world_);
for (int i = 0; i < numChildren_; i++) children_[i]->render(world_, dirty);
}
// 从根开始:graph_->render(Transform::origin(), false);(书中未给出带 dirty 参数的调用,此处 false 为整理者补全)
UE 与理论页中的对应
MarkRenderStateDirty()→ UE5 渲染架构:游戏线程修改组件属性后,必须调用MarkRenderStateDirty()才会同步到渲染线程的场景代理。这是“同步”型脏标记:原始数据在游戏线程,派生数据(场景代理)在渲染线程,漏标就会让渲染读到旧状态,与上面“漏置标记”的坑完全一样。- 场景图的变换更新 → 空间数据结构:RTR4 场景图一节写到,变换从根向叶传播、相乘后存储在节点里,变换改变后包围体失效,需要从叶向根回传更新,正是本模式的理论背景。
- 驱动层的冗余状态过滤 → 绘制调用与状态切换优化:现代驱动通常把状态设置推迟到第一次绘制,并用脏标记过滤冗余调用。
- 页缓存与失效 → 虚拟阴影贴图:虚拟阴影贴图的页跨帧缓存,只有被移动的物体或光源失效 (Invalidate) 时才重画,也是“缓存派生数据、按需失效”的同一思路。
出处
- Game Programming Patterns — dirty-flag
- Game Programming Patterns — dirty-flag(全文版)
- Robert Nystrom《Game Programming Patterns》(2014) dirty-flag 章,gameprogrammingpatterns.com/dirty-flag.html