中EN
概念页

脏标记

Dirty Flag

脏标记 (Dirty Flag) 用一个布尔标记记录“派生数据是否已经过时”:原始数据变化时置位;需要派生数据时,如果标记已置位就重新计算并清除标记,否则直接用缓存的结果。场景图里由局部变换求世界变换是经典例子。它只在原始数据变化比派生数据被使用更频繁、而且难以增量更新时才划算,最大的风险是漏置标记导致使用过时数据。

意图

把工作推迟到确实需要结果的时候,从而避免不必要的工作。(Game Programming Patterns — dirty-flag)

机制

一组原始数据随时间变化,一组派生数据由它经昂贵的过程算出。脏标记记录派生数据是否与原始数据不同步:原始数据变化时置位;需要派生数据时,若标记已置位就重新处理并清除标记,否则使用上次缓存的派生数据。(Game Programming Patterns — dirty-flag)

场景图例子:海盗船上有桅杆瞭望台,瞭望台里有海盗,海盗肩上有鹦鹉。每个对象只存相对父对象的局部变换,渲染时需要的世界变换是从根到该对象一路把局部变换乘起来:

Mworld(鹦鹉)=M船⋅M瞭望台⋅M海盗⋅M鹦鹉M_{\text{world}}(\text{鹦鹉}) = M_{\text{船}} \cdot M_{\text{瞭望台}} \cdot M_{\text{海盗}} \cdot M_{\text{鹦鹉}}

每帧为所有对象重算世界变换,对没动过的对象是浪费,所以要缓存世界变换;但如果局部变换一改就立即递归重算自己和所有子孙,一帧里船、瞭望台、海盗、鹦鹉各动一次,总共只动了四个对象,却做了十次世界变换计算,其中六次在渲染前就被丢弃,鹦鹉的世界变换被算了四遍、只渲染一次。脏标记的做法是:设置局部变换时只把自己标记为脏;渲染遍历场景图时,把“父节点是否脏”向下传递,只有路径上确实脏了的节点才重新相乘,并缓存结果。仅凭一个比特,它做到了三件事:把父链上多次局部修改合并为一次重算;不重算没动的对象;对象在渲染前被删除就完全不算。(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) 时才重画,也是“缓存派生数据、按需失效”的同一思路。
  1. Game Programming Patterns — dirty-flag
  2. Game Programming Patterns — dirty-flag(全文版)
  3. Robert Nystrom《Game Programming Patterns》(2014) dirty-flag 章,gameprogrammingpatterns.com/dirty-flag.html

相关页面

图形学空间数据结构Spatial Data Structures空间数据结构把几何体按 n 维空间组织起来,通常是层次化的树,使"几何体是否重叠"一类查询从 O(n) 降到大约 O(\log n)。常见类型有包围体层次 (BVH)、BSP 树(轴对齐的 k-d 树与多边形对齐两种)、四叉树/八叉树(含松散八叉树),以及更偏向组织模型关系的场景图。它们是剔除、光线求交、碰撞检测等加速算法的基础。游戏开发 · UE5UE5 渲染架构UE5 Rendering ArchitectureUE5 的渲染器是独立的 Renderer 模块,在渲染线程 (Rendering Thread) 上以「场景代理」镜像游戏线程的组件状态,每帧按固定顺序执行深度预通道、G-buffer 基础通道、光照、雾、半透明和后处理。现代渲染代码用渲染依赖图 (Render Dependency Graph, RDG) 声明通道与资源,由 RDG 自动排屏障、复用瞬态内存、并行录制;最终命令经 RHI 线程翻译成 D3D12/Vulkan 等图形 API 调用。默认走延迟着色 (Deferred Shading),也可切换前向着色 (Forward Shading)。图形学绘制调用与状态切换优化Draw Calls & State Change Optimization调用图形 API 的开销主要在 CPU 端的驱动里:状态切换昂贵,每次绘制调用有固定开销,大量小批次会让 GPU"饿死"。对策是:合理放置缓冲;按状态代价排序分组;共享纹理和着色器;合并网格和实例化以减少调用;并用一个整数排序键同时兼顾状态分组与前后排序。综合游戏编程模式选型:解耦与性能Choosing Game Programming Patterns本页把《Game Programming Patterns》里的 19 个模式放到同一张取舍图上:大多数模式要么为了解耦(降低改代码时需要装进脑子里的知识量),要么为了性能(利用对数据和硬件的具体假设),而这两个目标经常互相拉扯。下面先讲全书的定位和 Nystrom 的架构观,再给出按问题选模式的路线和与 UE 的对照。游戏开发数据局部性Data Locality数据局部性 (Data Locality) 是一种优化思路:CPU 读取内存时会把相邻的一整条缓存行一起载入,所以把数据按处理顺序连续存放,就能大幅减少缓存未命中。具体手段包括:组件按类型存进连续数组,不再到处跟随指针;把活跃对象排在前面;把冷热数据拆开。代价是要放弃一部分继承、接口与指针带来的抽象。游戏开发 · UE5虚拟阴影贴图Virtual Shadow Maps虚拟阴影贴图 (Virtual Shadow Maps, VSM) 是 UE5 默认的阴影方法:概念上就是分辨率极高(虚拟 16k×16k)的阴影贴图,切成 128×128 的页 (Page),只为屏幕上需要着色的像素分配和渲染页,并跨帧缓存;方向光用 clipmap 覆盖大范围,软阴影由阴影贴图光线追踪 (Shadow Map Ray Tracing, SMRT) 生成。它为 Nanite 设计,与 Nanite 一起使用时最高效。

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原