文档定位:双缓冲(Double Buffering)最早用于屏幕显示,后来扩展成一种通用的设计模式。本文从渲染缓冲讲起,依次说明它如何解决更新顺序依赖、逻辑线程与渲染线程并发、网络状态插值等问题,并给出可运行的示意代码、三缓冲无锁实现,以及常见陷阱。
⚠️ 代码为教学示意,省略了部分边界处理。涉及具体引擎(Unity、Unreal)的实现细节随版本变化,请以官方文档和源码为准。
零、先打个比方:餐厅的两块菜单板
| 餐厅 | 双缓冲 |
|---|---|
| 门口挂着一块正在展示的菜单板 | 前缓冲(Front Buffer):读取方正在使用的数据 |
| 后厨有一块正在书写的新菜单板 | 后缓冲(Back Buffer):写入方正在修改的数据 |
| 写完后一次性调换两块板子 | 交换(Swap):只交换"指向谁",不搬运内容 |
| 顾客永远看不到写到一半的菜单 | 读取方永远看不到半更新的状态 |
🎯一句话本质:让"写"和"读"各自拥有一份数据,写完之后用一次极小的操作切换身份。读的一方始终看到完整、一致的快照。
一、双缓冲要解决的三类问题
| 问题 | 表现 | 双缓冲的作用 |
|---|---|---|
| 读到半成品 | 屏幕撕裂;渲染线程读到一半新、一半旧的位置 | 读取方只读已完成的那一份 |
| 更新顺序依赖 | 对象 A 先更新,B 读到的是 A 的"新状态",结果依赖遍历顺序 | 所有对象读旧状态、写新状态,结果与顺序无关 |
| 并发冲突 | 两个线程同时读写同一数据,要么加锁变慢,要么数据竞争 | 读写分离到两份数据,只在交换时同步一次 |
二、起点:渲染中的双缓冲
1. 没有双缓冲会怎样
显示器按固定频率从帧缓冲中逐行读取像素。如果程序在显示器读取过程中直接修改这块内存,就会出现上半部分是旧画面、下半部分是新画面的现象,也就是画面撕裂(Tearing)。
2. 双缓冲的做法
GPU 绘制 显示器读取 ┌──────────────────┐ ┌──────────────────┐ │ 后缓冲(第 N+1 帧)│ │ 前缓冲(第 N 帧) │ └──────────────────┘ └──────────────────┘ ╲ 绘制完成后 Present / Swap ╱ ╲ 交换"谁是前、谁是后" ╱| 概念 | 说明 |
|---|---|
| 交换(Flip / Swap) | 通常只是切换显示控制器指向的缓冲地址,不拷贝像素 |
| 垂直同步(VSync) | 交换只在显示器刷新间隙进行,彻底避免撕裂;代价是可能等待 |
| 三缓冲 | 增加一个缓冲,GPU 不必空等交换时机;后文第七节详细讲 |
💡 后面所有应用都沿用这个思想:写一份、读一份、整体切换。
三、作为设计模式:解决更新顺序依赖
1. 问题示例:生命游戏(Game of Life)
每个格子根据当前邻居的存活数决定下一代是否存活。如果边遍历边修改,后面的格子读到的就是已经更新过的邻居,结果完全错误。
// ❌ 错误:原地更新,结果依赖遍历顺序for(inty=0;y<H;y++)for(intx=0;x<W;x++)cells[x,y]=NextState(cells,x,y);// 后续格子读到了已更新的邻居// ✅ 双缓冲:读 current,写 next,最后交换publicclassLifeGrid{bool[,]_current,_next;readonlyint_w,_h;publicLifeGrid(intw,inth){_w=w;_h=h;_current=newbool[w,h];_next=newbool[w,h];}publicboolthis[intx,inty]=>_current[x,y];// 外部只读当前代publicvoidStep(){for(inty=0;y<_h;y++)for(intx=0;x<_w;x++){intn=CountNeighbors(_current,x,y);// 只读旧状态boolalive=_current[x,y];_next[x,y]=alive?(n==2||n==3):(n==3);// 只写新状态}// 交换引用,不拷贝数据vartmp=_current;_current=_next;_next=tmp;}intCountNeighbors(bool[,]g,intx,inty){intc=0;for(intdy=-1;dy<=1;dy++)for(intdx=-1;dx<=1;dx++){if(dx==0&&dy==0)continue;intnx=x+dx,ny=y+dy;if(nx>=0&&nx<_w&&ny>=0&&ny<_h&&g[nx,ny])c++;}returnc;}}2. 游戏中的同类场景
| 场景 | 顺序依赖的表现 | 双缓冲做法 |
|---|---|---|
| 角色互相影响(《Game Programming Patterns》中"互相扇耳光"的例子) | A 扇了 B,B 在同一帧内就反应;换个顺序则要下一帧才反应 | 每个角色有current和next状态,本帧事件写入next,帧末统一交换 |
| 群体 AI / 鸟群模拟 | 先更新的个体位置影响后更新个体的决策 | 所有个体读上一帧位置,写入新位置 |
| 流体、热量扩散等网格模拟 | 与生命游戏相同 | 两张网格交替读写 |
| ECS 系统并行 | 多个 Job 同时读写同一组件会产生竞争 | 读上一帧组件、写新组件,或使用变更队列 |
🎯判断标准:如果"换一个遍历顺序,结果就会变",而你希望结果与顺序无关,就适合双缓冲。
四、并发利器:逻辑线程与渲染线程分离
1. 为什么要分线程
单线程时,一帧的时间 = 逻辑耗时 + 渲染提交耗时。拆成两个线程并行后,一帧的时间约等于两者中较长的那一个。
单线程: 帧 N: [逻辑 N][渲染 N] 帧 N+1: [逻辑 N+1][渲染 N+1] 双线程 + 双缓冲: 逻辑线程: [逻辑 N ][逻辑 N+1][逻辑 N+2] 渲染线程: [渲染 N ][渲染 N+1] ↑ 交换 ↑ 交换2. 核心问题:两个线程同时访问状态
渲染线程读取角色位置时,逻辑线程可能正在修改它。常见方案对比:
| 方案 | 做法 | 缺点 |
|---|---|---|
| 全局加锁 | 读写都加同一把锁 | 两个线程互相等待,并行效果大打折扣 |
| 细粒度锁 | 每个对象一把锁 | 锁开销大,容易死锁,仍可能读到不一致的整体状态 |
| 双缓冲 | 逻辑写后缓冲,渲染读前缓冲,帧边界交换 | 内存翻倍、延迟一帧,但并行度最高 |
3. 帧同步点交换的实现
// 渲染所需的状态快照(只包含渲染需要的数据,越小越好)publicstructRenderState{publicVector3[]positions;publicQuaternion[]rotations;publicintcount;publicintframeIndex;}publicsealedclassRenderStateBuffer{readonlyRenderState[]_buffers=newRenderState[2];int_writeIndex=0;// 逻辑线程写这一份readonlyobject_swapLock=newobject();publicRenderStateBuffer(intcapacity){for(inti=0;i<2;i++){_buffers[i].positions=newVector3[capacity];_buffers[i].rotations=newQuaternion[capacity];}}// 逻辑线程:获取可写缓冲publicrefRenderStateWrite=>ref_buffers[_writeIndex];// 渲染线程:获取只读缓冲publicrefreadonlyRenderStateRead=>ref_buffers[1-_writeIndex];// 在"两个线程都完成本帧工作"的同步点调用publicvoidSwap(){lock(_swapLock){_writeIndex=1-_writeIndex;}}}// 主循环示意:用两个信号量做帧同步SemaphoreSlimlogicDone=newSemaphoreSlim(0);SemaphoreSlimrenderDone=newSemaphoreSlim(1);// 初始允许逻辑先跑// 逻辑线程voidLogicLoop(){while(running){UpdateGame();// 修改游戏世界FillRenderState(refbuffer.Write);// 把渲染需要的数据写入后缓冲renderDone.Wait();// 等渲染线程用完前缓冲buffer.Swap();// 交换logicDone.Release();// 通知渲染线程有新数据}}// 渲染线程voidRenderLoop(){while(running){logicDone.Wait();// 等新的一帧Draw(inbuffer.Read);// 只读前缓冲renderDone.Release();// 告诉逻辑线程可以交换了}}| 要点 | 说明 |
|---|---|
| 交换必须在同步点进行 | 两个线程都不在访问各自缓冲时才能交换,否则仍然会读到半成品 |
| 渲染状态要精简 | 只放渲染需要的数据(变换、动画参数、材质参数),不要把整个游戏世界复制一份 |
| 逻辑领先一帧 | 屏幕上看到的是"上一帧逻辑"的结果,这是双缓冲固有的一帧延迟 |
4. 商业引擎中的类似做法
| 引擎 | 做法(概念层面) |
|---|---|
| Unreal Engine | 游戏线程与渲染线程分离;游戏对象在渲染侧有对应的代理对象(如FPrimitiveSceneProxy),游戏线程通过渲染命令把数据传给渲染线程,游戏线程通常可以领先渲染线程约一帧 |
| Unity | 启用多线程渲染时,主线程生成渲染命令,渲染线程执行图形 API 调用;脚本层一般不需要直接处理这层同步 |
| 自研引擎 | 常见"渲染快照"或"帧数据包":逻辑每帧打包一份只读数据交给渲染线程 |
💡 引擎中更常见的是"命令队列 + 渲染侧数据副本",本质仍是双缓冲思想:写的一方和读的一方各持有一份数据,在明确的时间点交接。
五、状态同步中的双缓冲:插值与平滑
1. 固定步长下的"上一帧 + 当前帧"
Glenn Fiedler 在《Fix Your Timestep》中提出:逻辑以固定步长更新,渲染帧率不固定时,保留 previous 和 current 两份状态,按剩余时间比例插值显示。
publicclassInterpolatedSimulation{State_previous,_current;// 两份状态double_accumulator;constdoubleDt=1.0/60.0;// 固定逻辑步长publicvoidFrame(doubleframeTime){_accumulator+=Math.Min(frameTime,0.25);// 防止卡顿后"死亡螺旋"while(_accumulator>=Dt){_previous=_current;// 旧的当前帧变成上一帧_current=Simulate(_previous,Dt);// 由上一帧推导出新的当前帧_accumulator-=Dt;}floatalpha=(float)(_accumulator/Dt);// 0~1:在两份状态之间的位置Render(State.Lerp(_previous,_current,alpha));}}⚠️ 这里的
State若是值类型(struct),赋值就是拷贝,不会出现两个变量指向同一对象的问题;若是引用类型,需要交换两个对象或显式拷贝,而不是让两个引用指向同一实例。
2. 网络同步:从双缓冲扩展为环形缓冲
客户端显示远端玩家时,通常不是只保留两份状态,而是保留一段时间的快照,渲染时选择"当前时间减去插值延迟"附近的两份进行插值:
收到的快照(环形缓冲): [t=100][t=150][t=200][t=250][t=300] ↑ 渲染时间 = 当前时间 - 100ms ≈ t=215 → 在 t=200 和 t=250 两份快照之间插值| 结构 | 份数 | 用途 |
|---|---|---|
| 双缓冲 | 2 | 固定步长插值、线程交接 |
| 三缓冲 | 3 | 生产者和消费者互不等待 |
| 环形缓冲(快照缓冲) | N | 网络插值、延迟补偿回溯、输入历史与回滚 |
💡环形缓冲可以看作双缓冲的推广:都是"读写不同的份,按约定的时间点前进",只是保留的历史更多。服务器端的延迟补偿(回退命中盒)同样依赖这种历史状态缓冲。
3. 服务器端:广播快照与模拟分离
| 做法 | 说明 |
|---|---|
| 模拟线程写入当前帧世界状态 | 每个 Tick 完成后,生成一份只读快照 |
| 网络线程读取快照做序列化和差量压缩 | 不与模拟线程争抢同一份数据 |
| 快照不可变 | 一旦发布就不再修改,多个连接的序列化可以并行读取 |
六、交换的三种方式
| 方式 | 做法 | 开销 | 适用 |
|---|---|---|---|
| 交换指针 / 索引 | 只切换"哪一份是前缓冲" | 极小 | 后缓冲每帧被完整重写时 |
| 拷贝数据 | 把后缓冲内容拷贝到前缓冲 | 与数据量成正比 | 读取方持有对前缓冲内部元素的长期引用、不能换地址时 |
| 交换 + 增量补齐 | 交换后,把上一帧修改过的部分同步到新后缓冲 | 与修改量成正比 | 每帧只修改少量数据,但不想全量重写 |
必须注意的"陈旧后缓冲"问题
交换指针后,新的后缓冲里装的是两帧之前的数据:
帧 1:写 A = 1,交换 → 前缓冲 A=1,后缓冲是更早的旧数据 帧 2:只修改了 B,没有写 A → 后缓冲中 A 仍是两帧前的值 交换后读取方看到的 A 是错的| 解决方式 | 说明 |
|---|---|
| 每帧完整重写后缓冲 | 最简单可靠,适合渲染快照这类每帧都重新收集的数据 |
| 增量补齐 | 记录脏标记,交换后把上一帧的修改同步到新的后缓冲 |
| 改为拷贝方式交换 | 数据量小时直接拷贝 |
七、三缓冲:让生产者和消费者都不等待
1. 双缓冲的局限
双缓冲在交换时,写入方必须等读取方用完前缓冲。如果渲染慢、逻辑快,逻辑线程就会被拖住,反之亦然。
2. 三缓冲的思路
| 缓冲 | 持有者 | 作用 |
|---|---|---|
| 写缓冲 | 生产者独占 | 正在写入 |
| 中间缓冲 | 共享,通过原子操作交换 | 存放"最新完成的一份" |
| 读缓冲 | 消费者独占 | 正在读取 |
- 生产者写完:把写缓冲和中间缓冲交换,并标记"有新数据"。
- 消费者需要新数据时:如果有新标记,就把读缓冲和中间缓冲交换。
- 双方都不需要等待对方;消费者总是拿到最新完成的一份,中间被覆盖的旧数据直接丢弃。
3. 无锁实现(C++)
#include<atomic>#include<cstdint>// 单生产者、单消费者三缓冲:"只关心最新值"的语义template<typenameT>classTripleBuffer{T buffers_[3];// 低 2 位:中间缓冲的索引;第 3 位:是否有未读的新数据std::atomic<uint8_t>middle_{1};uint8_tback_=2;// 只由生产者访问uint8_tfront_=0;// 只由消费者访问staticconstexpruint8_tkIndexMask=0x3;staticconstexpruint8_tkDirtyBit=0x4;public:// ---- 生产者 ----T&write(){returnbuffers_[back_];}voidpublish(){// 把写好的缓冲放到中间并打上"新数据"标记,取回旧的中间缓冲继续写uint8_told=middle_.exchange(back_|kDirtyBit,std::memory_order_acq_rel);back_=old&kIndexMask;}// ---- 消费者 ----// 有新数据时切换到最新一份并返回 true;否则继续使用当前读缓冲boolacquire(){if((middle_.load(std::memory_order_relaxed)&kDirtyBit)==0)returnfalse;uint8_told=middle_.exchange(front_,std::memory_order_acq_rel);front_=old&kIndexMask;returntrue;}constT&read()const{returnbuffers_[front_];}};| 正确性要点 | 说明 |
|---|---|
| 只能单生产者、单消费者 | back_和front_分别只被一个线程访问 |
acq_rel内存序 | 保证生产者写入的数据在消费者看到"新数据"标记时已经可见 |
| 新数据标记只由消费者清除 | 生产者每次发布都会设置标记,消费者交换时换回不带标记的索引 |
| 会丢帧 | 生产者连续发布两次而消费者没来得及读取时,中间那份被覆盖;适合"只要最新状态"的场景,不适合每条都不能丢的消息(那种情况应使用队列) |
4. 双缓冲、三缓冲与队列的选择
| 数据性质 | 推荐结构 |
|---|---|
| 每帧状态快照,只要最新的(位置、相机、UI 显示数值) | 三缓冲 |
| 读写严格按帧对齐,允许互相等待 | 双缓冲 |
| 每一条都必须处理的事件(输入、伤害事件、网络消息) | 队列(无锁环形队列等) |
| 需要回溯历史 | 环形快照缓冲 |
八、常见陷阱
| 陷阱 | 表现 | 规避 |
|---|---|---|
| 交换时机不对 | 偶发闪烁、位置跳变、读到半成品 | 只在同步点交换,或使用原子交换的三缓冲 |
| 陈旧后缓冲 | 某些数据"回退"到两帧前的值 | 每帧完整重写,或做增量补齐 |
| 前缓冲泄露引用 | 读取方保存了前缓冲中对象的引用,交换后这个对象被写入方修改 | 读取方只在当前帧内使用数据;需要长期保存就拷贝 |
| 缓冲中存放引用类型 | 两份缓冲里存的是同一批对象的引用,"双份"形同虚设 | 存值类型或独立副本 |
| 内存翻倍 | 大型世界状态整体双份,占用过高 | 只对需要跨线程或需要无序依赖的数据做双缓冲,并精简字段 |
| 一帧延迟被忽视 | 输入到显示的延迟增加,射击游戏手感变差 | 对延迟敏感的数据(如本地相机朝向)可以在渲染前用最新输入再修正一次 |
| 伪共享(False Sharing) | 两个线程分别写的变量落在同一缓存行,性能反而下降 | 把读写索引、各线程私有数据按缓存行对齐分开 |
| 把三缓冲当队列用 | 事件丢失 | 必须逐条处理的数据改用队列 |
九、与其他并发方案的对比
| 方案 | 读写关系 | 优点 | 缺点 | 游戏中典型用途 |
|---|---|---|---|---|
| 互斥锁 | 共享一份,轮流访问 | 简单,内存省 | 竞争时等待,难以扩展 | 低频共享数据 |
| 双缓冲 | 两份,帧边界交换 | 读写完全并行,结果确定 | 内存翻倍,一帧延迟,交换需同步 | 逻辑 / 渲染分离、网格模拟 |
| 三缓冲 | 三份,原子交换 | 双方不等待 | 内存三倍,可能丢中间帧 | 最新状态传递 |
| 命令队列 | 写入方发送命令,读取方执行 | 只传变化,适合稀疏修改 | 命令多时开销大 | 引擎渲染命令 |
| 写时复制(COW) | 修改时复制一份新版本 | 读取无锁,版本清晰 | 频繁修改时分配多 | 配置表、不可变快照 |
| 不可变快照 | 发布后不再修改 | 天然线程安全,可多读者并行 | 每次生成都要分配或复用池 | 服务器广播快照 |
十、应用场景速查
| 场景 | 方案 |
|---|---|
| 屏幕显示 | 交换链中的双缓冲 / 三缓冲 |
| 生命游戏、流体网格、扩散模拟 | 两张网格交替读写 |
| 角色之间同帧互相影响 | 状态分 current / next,帧末交换 |
| 逻辑线程与渲染线程 | 渲染快照双缓冲,或三缓冲传递最新状态 |
| 固定步长 + 可变帧率渲染 | previous / current 两份状态插值 |
| 客户端远端实体插值 | 环形快照缓冲 |
| 服务器延迟补偿 | 历史状态环形缓冲 |
| 服务器模拟与网络序列化分离 | 每个 Tick 发布不可变快照 |
一句话总结
🔁双缓冲的核心是"读写分离 + 整体切换":写的一方改后缓冲,读的一方读前缓冲,在明确的时间点交换身份,读取方永远看到完整一致的状态。
⚙️ 它同时解决三个问题:画面撕裂、更新顺序依赖、多线程读写冲突;扩展成三缓冲可以让双方都不等待,扩展成环形缓冲就成了网络插值和延迟补偿的基础。
⚠️ 代价也很明确:**内存翻倍、一帧延迟、交换时机