双缓冲
Double Buffer
双缓冲 (Double Buffer) 让一系列按顺序执行的修改,在外界看来像是瞬间、同时完成的:类内部保留“当前缓冲”和“下一缓冲”两份状态,读取总是读当前缓冲,写入总是写下一缓冲,改完后用一次原子交换让新状态整体生效。图形里的前后缓冲是最经典的例子;同样的思路也能让一帧内依次更新的游戏对象互相看到的都是上一帧的一致状态。
意图
让一系列顺序操作看起来是瞬间或同时完成的。(Game Programming Patterns — double-buffer)
机制
被缓冲的类封装一块会被修改的状态(缓冲区)。这块状态是逐步修改的,但希望外部代码把整次修改看成一个原子变化。做法是保留两份:下一缓冲和当前缓冲。读取永远读当前缓冲,写入永远写下一缓冲;修改完成后执行交换,新缓冲立即对外可见,旧的当前缓冲则作为下一次的写入缓冲复用。(Game Programming Patterns — double-buffer)
为什么需要:显示器逐像素读取帧缓冲的同时,程序也在逐像素写入。读取速度超过写入时,就会读到还没画好的像素,画面上出现半新半旧的撕裂 (tearing)。程序是一个像素一个像素地画,但必须让显示驱动一次看到整幅画面。书中的类比是两座舞台:观众看 A 台演第一幕时,B 台在黑暗中布置第二幕,幕间只需切换灯光;随后又在暗下来的 A 台布置第三幕。作者也提到,并非所有游戏都这么做,内存有限的老式简单主机会小心地让绘制与显示刷新同步。(Game Programming Patterns — double-buffer)
不只用于图形:状态在修改过程中被访问,有两种常见原因。一是状态被另一个线程或中断直接访问(图形例子);二是做修改的代码本身也在读它正在修改的状态,这在实体互相作用的物理和 AI 中很常见。(Game Programming Patterns — double-buffer)
下图是一帧内的读写分工与交换;交换期间两个缓冲都不能被访问。
sequenceDiagram participant W as 写入方(渲染/更新代码) participant N as 下一缓冲 participant C as 当前缓冲 participant R as 读取方(显示/其他对象) W->>N: 逐步写入本帧内容 R->>C: 读取上一帧的完整内容 Note over N,C: 写完后 swap():交换指针或拷贝 R->>C: 之后读到的是新的完整帧
图中读取方从不接触写到一半的缓冲;代价是多一份内存,外加一次必须原子完成的交换。
何时使用
同时满足以下条件时使用(Game Programming Patterns — double-buffer):
- 有一块状态被逐步修改;
- 这块状态可能在修改过程中被访问;
- 不希望访问方看到修改到一半的结果;
- 希望读取时不必等待写入完成。
书中第二个例子是几个互相“扇耳光”的喜剧演员:如果在同一帧里按顺序更新,被打者是否在本帧就做出反应,取决于它在数组里排在打人者前面还是后面。给“被打”状态加双缓冲后,每个人在本帧读到的都是上一帧的结果,更新顺序不再影响行为。代价是每个演员都要到被打的下一帧才看到这一耳光。这里交换采用拷贝:swap() 把“下一”状态拷进“当前”再清空“下一”,Stage::update() 改为先更新所有演员、再统一交换。(Game Programming Patterns — double-buffer)
取舍与坑
- 交换本身要时间,而且必须是原子的,期间任何代码都不能访问两个缓冲中的任何一个。通常只是一次指针赋值;如果交换比修改本身还慢,就得不偿失。(Game Programming Patterns — double-buffer)
- 内存翻倍:两份状态始终常驻,在内存受限的设备上代价可能很高。(Game Programming Patterns — double-buffer)
设计决策(Game Programming Patterns — double-buffer)
| 决策 | 选项 | 特点 |
|---|---|---|
| 如何交换 | 交换指针或引用 | 快,与缓冲大小无关;外部代码不能长期持有指向缓冲内部的指针(显卡驱动要求帧缓冲地址固定时就不能用);缓冲里残留的是两帧前的数据(若要复用旧数据,例如把上一帧混合进来模拟运动模糊,就要注意这一点) |
| 在两个缓冲之间拷贝数据 | 喜剧演员例子用的就是这种,因为一个布尔值拷贝起来不比指针慢;下一缓冲里的数据只旧一帧;缓冲很大时交换很慢,期间两边都不可访问 | |
| 缓冲粒度 | 整块缓冲 | 交换简单,一次即可 |
| 分散在许多对象里 | 交换要遍历所有对象;可以把“当前/下一”变成每个对象内数组的下标,用一个静态的 current_ 一次切换全部对象 |
代码草图(C++)
按书中示例整理:图形例子(交换指针)、喜剧演员例子(拷贝交换),以及“分散缓冲 + 静态下标”的变体。
class Scene {
public:
Scene() : current_(&buffers_[0]), next_(&buffers_[1]) {}
void draw() {
next_->clear();
next_->draw(1, 1);
// ...
next_->draw(4, 3);
swap();
}
Framebuffer& getBuffer() { return *current_; } // 显示驱动只读当前缓冲
private:
void swap() { // 只交换指针
Framebuffer* temp = current_;
current_ = next_;
next_ = temp;
}
Framebuffer buffers_[2];
Framebuffer* current_;
Framebuffer* next_;
};
// 喜剧演员:细粒度缓冲“被打”状态,交换时拷贝
class Actor {
public:
Actor() : currentSlapped_(false) {}
virtual ~Actor() {}
virtual void update() = 0;
void swap() {
currentSlapped_ = nextSlapped_; // 交换缓冲
nextSlapped_ = false; // 清空新的“下一”缓冲
}
void slap() { nextSlapped_ = true; }
bool wasSlapped() { return currentSlapped_; }
private:
bool currentSlapped_;
bool nextSlapped_;
};
void Stage::update() {
for (int i = 0; i < NUM_ACTORS; i++) actors_[i]->update();
for (int i = 0; i < NUM_ACTORS; i++) actors_[i]->swap();
}
// 变体:每个对象存两份,用静态下标一次切换所有对象
class Actor2 { // 书中仍名为 Actor
public:
static void init() { current_ = 0; }
static void swap() { current_ = next(); }
void slap() { slapped_[next()] = true; }
bool wasSlapped() { return slapped_[current_]; }
private:
static int current_;
static int next() { return 1 - current_; }
bool slapped_[2];
};
渲染与 UE 中的对应
- 图形 API 的前后缓冲 → GPU 内存、缓冲压缩与多重缓冲:前缓冲显示已完成的图像,后缓冲用于绘制,通常在 vsync 时交换指针;不等 vsync 立即交换可以拉高帧率,但会撕裂。Nystrom 也指出 OpenGL 的
swapBuffers()、Direct3D 的交换链都是这个模式。(Game Programming Patterns — double-buffer) - 游戏线程与渲染线程的状态镜像 → UE5 渲染架构:UE 的渲染线程用“场景代理”镜像游戏线程的组件状态,默认流水线“落后一帧”(游戏线程处理第 N+1 帧时,渲染线程处理第 N 帧)。游戏线程改了组件属性必须调用
MarkRenderStateDirty()才会同步,渲染线程不得直接读UPrimitiveComponent。这与双缓冲的思想相近:读方只看一份已经确定的状态,写方在另一份上修改。但它是线程间的状态复制,不是严格的两个缓冲加一次交换,这里只作类比。
出处
- Game Programming Patterns — double-buffer
- Game Programming Patterns — double-buffer(全文版)
- Robert Nystrom《Game Programming Patterns》(2014) double-buffer 章,gameprogrammingpatterns.com/double-buffer.html