2026/10/22 17:06:35

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

双缓冲技术:游戏状态同步的并发利器 文档定位双缓冲Double Buffering最早用于屏幕显示后来扩展成一种通用的设计模式。本文从渲染缓冲讲起依次说明它如何解决更新顺序依赖、逻辑线程与渲染线程并发、网络状态插值等问题并给出可运行的示意代码、三缓冲无锁实现以及常见陷阱。⚠️ 代码为教学示意省略了部分边界处理。涉及具体引擎Unity、Unreal的实现细节随版本变化请以官方文档和源码为准。零、先打个比方餐厅的两块菜单板餐厅双缓冲门口挂着一块正在展示的菜单板前缓冲Front Buffer读取方正在使用的数据后厨有一块正在书写的新菜单板后缓冲Back Buffer写入方正在修改的数据写完后一次性调换两块板子交换Swap只交换指向谁不搬运内容顾客永远看不到写到一半的菜单读取方永远看不到半更新的状态一句话本质让写和读各自拥有一份数据写完之后用一次极小的操作切换身份。读的一方始终看到完整、一致的快照。一、双缓冲要解决的三类问题问题表现双缓冲的作用读到半成品屏幕撕裂渲染线程读到一半新、一半旧的位置读取方只读已完成的那一份更新顺序依赖对象 A 先更新B 读到的是 A 的新状态结果依赖遍历顺序所有对象读旧状态、写新状态结果与顺序无关并发冲突两个线程同时读写同一数据要么加锁变慢要么数据竞争读写分离到两份数据只在交换时同步一次二、起点渲染中的双缓冲1. 没有双缓冲会怎样显示器按固定频率从帧缓冲中逐行读取像素。如果程序在显示器读取过程中直接修改这块内存就会出现上半部分是旧画面、下半部分是新画面的现象也就是画面撕裂Tearing。2. 双缓冲的做法GPU 绘制 显示器读取 ┌──────────────────┐ ┌──────────────────┐ │ 后缓冲第 N1 帧│ │ 前缓冲第 N 帧 │ └──────────────────┘ └──────────────────┘ ╲ 绘制完成后 Present / Swap ╱ ╲ 交换谁是前、谁是后 ╱概念说明交换Flip / Swap通常只是切换显示控制器指向的缓冲地址不拷贝像素垂直同步VSync交换只在显示器刷新间隙进行彻底避免撕裂代价是可能等待三缓冲增加一个缓冲GPU 不必空等交换时机后文第七节详细讲 后面所有应用都沿用这个思想写一份、读一份、整体切换。三、作为设计模式解决更新顺序依赖1. 问题示例生命游戏Game of Life每个格子根据当前邻居的存活数决定下一代是否存活。如果边遍历边修改后面的格子读到的就是已经更新过的邻居结果完全错误。// ❌ 错误原地更新结果依赖遍历顺序for(inty0;yH;y)for(intx0;xW;x)cells[x,y]NextState(cells,x,y);// 后续格子读到了已更新的邻居// ✅ 双缓冲读 current写 next最后交换publicclassLifeGrid{bool[,]_current,_next;readonlyint_w,_h;publicLifeGrid(intw,inth){_ww;_hh;_currentnewbool[w,h];_nextnewbool[w,h];}publicboolthis[intx,inty]_current[x,y];// 外部只读当前代publicvoidStep(){for(inty0;y_h;y)for(intx0;x_w;x){intnCountNeighbors(_current,x,y);// 只读旧状态boolalive_current[x,y];_next[x,y]alive?(n2||n3):(n3);// 只写新状态}// 交换引用不拷贝数据vartmp_current;_current_next;_nexttmp;}intCountNeighbors(bool[,]g,intx,inty){intc0;for(intdy-1;dy1;dy)for(intdx-1;dx1;dx){if(dx0dy0)continue;intnxxdx,nyydy;if(nx0nx_wny0ny_hg[nx,ny])c;}returnc;}}2. 游戏中的同类场景场景顺序依赖的表现双缓冲做法角色互相影响《Game Programming Patterns》中互相扇耳光的例子A 扇了 BB 在同一帧内就反应换个顺序则要下一帧才反应每个角色有current和next状态本帧事件写入next帧末统一交换群体 AI / 鸟群模拟先更新的个体位置影响后更新个体的决策所有个体读上一帧位置写入新位置流体、热量扩散等网格模拟与生命游戏相同两张网格交替读写ECS 系统并行多个 Job 同时读写同一组件会产生竞争读上一帧组件、写新组件或使用变更队列判断标准如果换一个遍历顺序结果就会变而你希望结果与顺序无关就适合双缓冲。四、并发利器逻辑线程与渲染线程分离1. 为什么要分线程单线程时一帧的时间 逻辑耗时 渲染提交耗时。拆成两个线程并行后一帧的时间约等于两者中较长的那一个。单线程 帧 N: [逻辑 N][渲染 N] 帧 N1: [逻辑 N1][渲染 N1] 双线程 双缓冲 逻辑线程: [逻辑 N ][逻辑 N1][逻辑 N2] 渲染线程: [渲染 N ][渲染 N1] ↑ 交换 ↑ 交换2. 核心问题两个线程同时访问状态渲染线程读取角色位置时逻辑线程可能正在修改它。常见方案对比方案做法缺点全局加锁读写都加同一把锁两个线程互相等待并行效果大打折扣细粒度锁每个对象一把锁锁开销大容易死锁仍可能读到不一致的整体状态双缓冲逻辑写后缓冲渲染读前缓冲帧边界交换内存翻倍、延迟一帧但并行度最高3. 帧同步点交换的实现// 渲染所需的状态快照只包含渲染需要的数据越小越好publicstructRenderState{publicVector3[]positions;publicQuaternion[]rotations;publicintcount;publicintframeIndex;}publicsealedclassRenderStateBuffer{readonlyRenderState[]_buffersnewRenderState[2];int_writeIndex0;// 逻辑线程写这一份readonlyobject_swapLocknewobject();publicRenderStateBuffer(intcapacity){for(inti0;i2;i){_buffers[i].positionsnewVector3[capacity];_buffers[i].rotationsnewQuaternion[capacity];}}// 逻辑线程获取可写缓冲publicrefRenderStateWriteref_buffers[_writeIndex];// 渲染线程获取只读缓冲publicrefreadonlyRenderStateReadref_buffers[1-_writeIndex];// 在两个线程都完成本帧工作的同步点调用publicvoidSwap(){lock(_swapLock){_writeIndex1-_writeIndex;}}}// 主循环示意用两个信号量做帧同步SemaphoreSlimlogicDonenewSemaphoreSlim(0);SemaphoreSlimrenderDonenewSemaphoreSlim(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;constdoubleDt1.0/60.0;// 固定逻辑步长publicvoidFrame(doubleframeTime){_accumulatorMath.Min(frameTime,0.25);// 防止卡顿后死亡螺旋while(_accumulatorDt){_previous_current;// 旧的当前帧变成上一帧_currentSimulate(_previous,Dt);// 由上一帧推导出新的当前帧_accumulator-Dt;}floatalpha(float)(_accumulator/Dt);// 01在两份状态之间的位置Render(State.Lerp(_previous,_current,alpha));}}⚠️ 这里的State若是值类型struct赋值就是拷贝不会出现两个变量指向同一对象的问题若是引用类型需要交换两个对象或显式拷贝而不是让两个引用指向同一实例。2. 网络同步从双缓冲扩展为环形缓冲客户端显示远端玩家时通常不是只保留两份状态而是保留一段时间的快照渲染时选择当前时间减去插值延迟附近的两份进行插值收到的快照环形缓冲 [t100][t150][t200][t250][t300] ↑ 渲染时间 当前时间 - 100ms ≈ t215 → 在 t200 和 t250 两份快照之间插值结构份数用途双缓冲2固定步长插值、线程交接三缓冲3生产者和消费者互不等待环形缓冲快照缓冲N网络插值、延迟补偿回溯、输入历史与回滚环形缓冲可以看作双缓冲的推广都是读写不同的份按约定的时间点前进只是保留的历史更多。服务器端的延迟补偿回退命中盒同样依赖这种历史状态缓冲。3. 服务器端广播快照与模拟分离做法说明模拟线程写入当前帧世界状态每个 Tick 完成后生成一份只读快照网络线程读取快照做序列化和差量压缩不与模拟线程争抢同一份数据快照不可变一旦发布就不再修改多个连接的序列化可以并行读取六、交换的三种方式方式做法开销适用交换指针 / 索引只切换哪一份是前缓冲极小后缓冲每帧被完整重写时拷贝数据把后缓冲内容拷贝到前缓冲与数据量成正比读取方持有对前缓冲内部元素的长期引用、不能换地址时交换 增量补齐交换后把上一帧修改过的部分同步到新后缓冲与修改量成正比每帧只修改少量数据但不想全量重写必须注意的陈旧后缓冲问题交换指针后新的后缓冲里装的是两帧之前的数据帧 1写 A 1交换 → 前缓冲 A1后缓冲是更早的旧数据 帧 2只修改了 B没有写 A → 后缓冲中 A 仍是两帧前的值 交换后读取方看到的 A 是错的解决方式说明每帧完整重写后缓冲最简单可靠适合渲染快照这类每帧都重新收集的数据增量补齐记录脏标记交换后把上一帧的修改同步到新的后缓冲改为拷贝方式交换数据量小时直接拷贝七、三缓冲让生产者和消费者都不等待1. 双缓冲的局限双缓冲在交换时写入方必须等读取方用完前缓冲。如果渲染慢、逻辑快逻辑线程就会被拖住反之亦然。2. 三缓冲的思路缓冲持有者作用写缓冲生产者独占正在写入中间缓冲共享通过原子操作交换存放最新完成的一份读缓冲消费者独占正在读取生产者写完把写缓冲和中间缓冲交换并标记有新数据。消费者需要新数据时如果有新标记就把读缓冲和中间缓冲交换。双方都不需要等待对方消费者总是拿到最新完成的一份中间被覆盖的旧数据直接丢弃。3. 无锁实现C#includeatomic#includecstdint// 单生产者、单消费者三缓冲只关心最新值的语义templatetypenameTclassTripleBuffer{T buffers_[3];// 低 2 位中间缓冲的索引第 3 位是否有未读的新数据std::atomicuint8_tmiddle_{1};uint8_tback_2;// 只由生产者访问uint8_tfront_0;// 只由消费者访问staticconstexpruint8_tkIndexMask0x3;staticconstexpruint8_tkDirtyBit0x4;public:// ---- 生产者 ----Twrite(){returnbuffers_[back_];}voidpublish(){// 把写好的缓冲放到中间并打上新数据标记取回旧的中间缓冲继续写uint8_toldmiddle_.exchange(back_|kDirtyBit,std::memory_order_acq_rel);back_oldkIndexMask;}// ---- 消费者 ----// 有新数据时切换到最新一份并返回 true否则继续使用当前读缓冲boolacquire(){if((middle_.load(std::memory_order_relaxed)kDirtyBit)0)returnfalse;uint8_toldmiddle_.exchange(front_,std::memory_order_acq_rel);front_oldkIndexMask;returntrue;}constTread()const{returnbuffers_[front_];}};正确性要点说明只能单生产者、单消费者back_和front_分别只被一个线程访问acq_rel内存序保证生产者写入的数据在消费者看到新数据标记时已经可见新数据标记只由消费者清除生产者每次发布都会设置标记消费者交换时换回不带标记的索引会丢帧生产者连续发布两次而消费者没来得及读取时中间那份被覆盖适合只要最新状态的场景不适合每条都不能丢的消息那种情况应使用队列4. 双缓冲、三缓冲与队列的选择数据性质推荐结构每帧状态快照只要最新的位置、相机、UI 显示数值三缓冲读写严格按帧对齐允许互相等待双缓冲每一条都必须处理的事件输入、伤害事件、网络消息队列无锁环形队列等需要回溯历史环形快照缓冲八、常见陷阱陷阱表现规避交换时机不对偶发闪烁、位置跳变、读到半成品只在同步点交换或使用原子交换的三缓冲陈旧后缓冲某些数据回退到两帧前的值每帧完整重写或做增量补齐前缓冲泄露引用读取方保存了前缓冲中对象的引用交换后这个对象被写入方修改读取方只在当前帧内使用数据需要长期保存就拷贝缓冲中存放引用类型两份缓冲里存的是同一批对象的引用双份形同虚设存值类型或独立副本内存翻倍大型世界状态整体双份占用过高只对需要跨线程或需要无序依赖的数据做双缓冲并精简字段一帧延迟被忽视输入到显示的延迟增加射击游戏手感变差对延迟敏感的数据如本地相机朝向可以在渲染前用最新输入再修正一次伪共享False Sharing两个线程分别写的变量落在同一缓存行性能反而下降把读写索引、各线程私有数据按缓存行对齐分开把三缓冲当队列用事件丢失必须逐条处理的数据改用队列九、与其他并发方案的对比方案读写关系优点缺点游戏中典型用途互斥锁共享一份轮流访问简单内存省竞争时等待难以扩展低频共享数据双缓冲两份帧边界交换读写完全并行结果确定内存翻倍一帧延迟交换需同步逻辑 / 渲染分离、网格模拟三缓冲三份原子交换双方不等待内存三倍可能丢中间帧最新状态传递命令队列写入方发送命令读取方执行只传变化适合稀疏修改命令多时开销大引擎渲染命令写时复制COW修改时复制一份新版本读取无锁版本清晰频繁修改时分配多配置表、不可变快照不可变快照发布后不再修改天然线程安全可多读者并行每次生成都要分配或复用池服务器广播快照十、应用场景速查场景方案屏幕显示交换链中的双缓冲 / 三缓冲生命游戏、流体网格、扩散模拟两张网格交替读写角色之间同帧互相影响状态分 current / next帧末交换逻辑线程与渲染线程渲染快照双缓冲或三缓冲传递最新状态固定步长 可变帧率渲染previous / current 两份状态插值客户端远端实体插值环形快照缓冲服务器延迟补偿历史状态环形缓冲服务器模拟与网络序列化分离每个 Tick 发布不可变快照一句话总结双缓冲的核心是读写分离 整体切换写的一方改后缓冲读的一方读前缓冲在明确的时间点交换身份读取方永远看到完整一致的状态。⚙️ 它同时解决三个问题画面撕裂、更新顺序依赖、多线程读写冲突扩展成三缓冲可以让双方都不等待扩展成环形缓冲就成了网络插值和延迟补偿的基础。⚠️ 代价也很明确**内存翻倍、一帧延迟、交换时机