ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

双缓冲技术:游戏状态同步的并发利器

双缓冲技术:游戏状态同步的并发利器

文档定位:双缓冲(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 发布不可变快照

一句话总结

🔁双缓冲的核心是"读写分离 + 整体切换":写的一方改后缓冲,读的一方读前缓冲,在明确的时间点交换身份,读取方永远看到完整一致的状态。

⚙️ 它同时解决三个问题:画面撕裂、更新顺序依赖、多线程读写冲突;扩展成三缓冲可以让双方都不等待,扩展成环形缓冲就成了网络插值和延迟补偿的基础。

⚠️ 代价也很明确:**内存翻倍、一帧延迟、交换时机

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进