![[简化版 GAMES 104] 现代游戏引擎 06:从Tick时序到邮局模型,拆解确定性世界的底层密码](http://pic.xiahunao.cn/yaotu/[简化版 GAMES 104] 现代游戏引擎 06:从Tick时序到邮局模型,拆解确定性世界的底层密码)
[简化版 GAMES 104] 现代游戏引擎 06从Tick时序到邮局模型拆解确定性世界的底层密码✨ 开篇你有没有想过游戏里的分手为什么不会乱 Tick是什么游戏世界的心跳1.1 两种Tick模式逐对象 vs 逐组件1.2 父子节点的潜规则爹先动儿子跟上Bilibili 同步视频 分手信悖论并行执行的致命陷阱2.1 当Tick变成多线程麻烦就来了2.2 为什么确定性这么重要2.3 直接通信的三大罪状 邮局模型游戏世界的时间管理大师3.1 解决方案引入第三方邮局3.2 PreTick / Tick / PostTick三段式精密设计3.3 时序错乱的直观后果 组件化架构灵活的代价4.1 组件化为什么火4.2 组件化的两大缺点缺点一性能损耗缺点二循环依赖⚡ 多系统交互的那些坑5.1 逻辑线程 vs 渲染线程两条时间线5.2 动画与物理的双人舞5.3 Tick耗时过长怎么办四大策略 空间划分动态世界的加速秘籍6.1 为什么需要空间划分6.2 动态物体怎么办 总结游戏引擎的时间哲学✨ 开篇你有没有想过游戏里的分手为什么不会乱想象一下这个场景 你兴冲冲地写了一封分手信正准备投到女朋友寝室楼下结果一抬头——她也拿着一封信走出来了这时候问题来了到底是你甩了她还是她甩了你 你可能觉得这是情感问题但在游戏引擎的世界里这是一个致命的技术问题。如果两个游戏对象同时给对方发消息谁先收到谁的行为先触发今天这篇文章我们就来聊聊游戏引擎里最精妙、也最容易被忽略的设计——Tick时序与事件机制。看懂了这个你才算真正入门了游戏引擎的内核。本文核心看点 Tick机制游戏世界的心跳是怎么跳动的 分手信悖论为什么直接发消息会出大问题 邮局模型如何保证并行世界的确定性 组件化架构灵活的代价是什么⚡ 性能优化C层面如何优化组件查询 Tick是什么游戏世界的心跳1.1 两种Tick模式逐对象 vs 逐组件在游戏引擎里Tick就是游戏世界的时间步长——每一帧所有游戏对象都要更新一次自己的状态。就像心脏跳动一样每跳一下世界就前进一点点。但Tick的执行顺序里面的学问可大了去了 最直观的方式是Object-based Tick逐对象更新遍历每个GameObject简称GO把它身上所有组件挨个Tick一遍。这种方式逻辑简单、好调试就像你挨个给员工派活一样。但现代引擎更常用的是Component-based Tick按组件系统批量更新先把所有Transform组件更新完再更新所有Motor组件再更新动画、物理……同一类组件集中计算。为什么要按组件批量更新答案是CPU缓存友好 多核并行。同类型数据放在一起CPU缓存命中率飙升同类任务可以分发到不同核心并行执行性能直接拉满1.2 父子节点的潜规则爹先动儿子跟上在Object-based Tick里有一个天然的时序规则父节点先Tick子节点后Tick。为什么举个例子 你坐在一辆车里 车往前开了30厘米你是不是也得跟着往前挪30厘米如果先Tick你再Tick车那你就会发现——哎我的车跑了我还在原地这就离谱了所以父子Tick的顺序本质上是依赖关系的体现被依赖的先执行依赖别人的后执行。▲ 图1父子节点Tick时序示意图——父节点先更新位置子节点再跟随同步这个规则在简单场景下很好用但一旦进入并行世界事情就复杂了……Bilibili 同步视频[简化版 GAMES 104] 现代游戏引擎 06从Tick时序到邮局模型拆解确定性世界的底层密码 “分手信悖论”并行执行的致命陷阱2.1 当Tick变成多线程麻烦就来了现代游戏引擎为了榨干CPU性能会把大量Tick任务分发到不同核心上并行执行。这就引出了一个经典的问题——如果两个对象同时给对方发消息谁先收到回到开头的分手信例子你和女朋友并行执行同时发出分手信。结果就是——你觉得是你甩了她她觉得是她甩了你两边的逻辑都正确但结果完全不一样这在游戏里叫逻辑歧义Ambiguity是并行编程的噩梦。2.2 为什么确定性这么重要你可能会说“不就是个先后顺序吗有什么大不了的”那你可就太天真了在游戏里**确定性Deterministic**是一个核心要求确定性 相同输入 → 相同输出只要玩家的操作完全一样游戏世界里发生的每一件事、每一个动作、每一次碰撞都必须100%一致。为什么这么重要举个最常见的例子——精彩回放你以为回放是把整个游戏过程录成视频太天真了那文件得多大啊真正的回放是这样的只记录玩家的输入操作然后把游戏重新跑一遍。因为有确定性相同的输入一定会跑出相同的结果所以回放和你刚才打的时候一模一样如果没有确定性……那回放就成了平行宇宙模拟器每次看都不一样那还叫回放吗 2.3 直接通信的三大罪状如果GO之间可以直接互发消息会带来什么问题问题表现后果时序不确定谁先收到消息全看线程调度每次运行结果可能不一样 ❌调试困难Bug复现全靠运气程序员头发掉光 回放失效相同输入跑出不同结果对战游戏直接没法玩▲ 表1GO直接通信带来的三大问题所以直接通信这条路走不通 邮局模型游戏世界的时间管理大师3.1 解决方案引入第三方邮局怎么解决并行通信的时序问题答案是——引入一个全局的邮局规则很简单所有组件禁止直接投递消息统一先把事件写到全局邮局事件队列里当前帧所有Tick逻辑执行完毕后邮局统一分发全部缓存的事件接收方下一帧Tick才能读取、处理消息就像现实中的邮局一样你今天寄的信对方明天才能收到。这样时序就完全确定了▲ 图2邮局模型时序图——所有消息统一在帧末分发下一帧才能被处理3.2 PreTick / Tick / PostTick三段式精密设计光有邮局还不够现代引擎还把Tick拆成了三个阶段PreTick读取消息、处理输入、准备数据 收信阶段Tick执行业务逻辑、更新状态 干活阶段PostTick发送新消息、清理资源 寄信阶段这样设计的好处是什么因为所有消息都在PreTick阶段统一读取在PostTick阶段统一发送中间的Tick阶段大家专心干活互不干扰。这样并行执行时就不会出现边读边写的混乱情况了。一句话总结邮局模型今天收到的信明天再回今天写的信明天才到。大家步调一致世界就确定了。3.3 时序错乱的直观后果如果引擎的时序设计得不好会出现什么现象最常见的就是1~2帧的逻辑延迟lag你按了跳跃键角色过了一帧才跳起来 子弹打出去了碰撞检测慢了半拍 动画切换总是慢一拍打击感全无 这些看似是优化问题本质上都是时序设计问题。 组件化架构灵活的代价4.1 组件化为什么火聊完了时序我们再说说组件化架构本身。传统的面向对象做法是用继承树来组织游戏对象。比如GameObject是基类下面派生出Character、Vehicle、Prop……然后再继续派生。但这样很快就会遇到类爆炸问题 想要一个会飞的车你是继承Vehicle还是继承Aircraft想要一个会说话的箱子这继承关系怎么画组件化架构就不一样了——用组合代替继承。GameObject就是一个空容器你往里面挂什么组件它就有什么功能挂个Transform → 有位置了 挂个MeshRenderer → 能渲染了 挂个Rigidbody → 有物理了 ️挂个AI → 有脑子了 想做会飞的车简单把飞行组件挂到车上就行了 ✈️4.2 组件化的两大缺点但是世界上没有银弹组件化也不是完美的。它有两个很明显的缺点缺点一性能损耗访问组件需要实时Query查询。比如AI组件想知道我现在血量多少它得先去GO上找有没有血量组件找到了再读取数值。这个查询过程在高频的帧循环里开销可不小我们来看一段典型的C代码 // ❌ 每帧都要查询性能开销大voidAIComponent::Tick(floatdeltaTime){// 每次都要去哈希表/数组里找组件HealthComponent*healthGetOwner()-GetComponentHealthComponent();if(healthhealth-GetCurrentHp()30.0f){SetBehavior(Behavior::Defensive);}TransformComponent*transformGetOwner()-GetComponentTransformComponent();if(transform){// 移动逻辑...}}这段代码看起来很正常对吧但在每一帧、每个AI对象上都执行的话GetComponent的查询开销会累积成一个不小的数字。优化方案是什么缓存组件指针// ✅ 初始化时缓存避免每帧查询classAIComponent:publicComponent{public:voidInitialize()override{// 只在初始化时查询一次缓存起来m_healthGetOwner()-GetComponentHealthComponent();m_transformGetOwner()-GetComponentTransformComponent();}voidTick(floatdeltaTime)override{// 直接使用缓存的指针零查询开销if(m_healthm_health-GetCurrentHp()30.0f){SetBehavior(Behavior::Defensive);}if(m_transform){// 移动逻辑...}}private:HealthComponent*m_healthnullptr;TransformComponent*m_transformnullptr;};性能小贴士组件查询的开销主要来自哈希表查找或数组遍历。对于每帧都要访问的组件一定要在初始化时缓存指针能省则省更进阶的方案是ECS实体组件系统把同类组件的数据连续存储直接用数组索引访问连指针都不用存了——这才是性能的终极形态 缺点二循环依赖组件之间经常会出现你影响我我影响你的循环依赖。举个例子 ▲ 图3组件间的循环依赖——移动影响动画动画影响物理物理又反作用于移动Motor组件说“我先动动画你跟着变”Animation组件说“我的腿伸出去了物理你检测一下”物理组件说“撞到东西了Motor你停一下”……这就形成了一个环。单靠基础的Tick分段很难完全消除这种循环带来的延迟。这也是为什么很多游戏会有一帧延迟的感觉——不是优化不到位而是架构本身的限制。⚡ 多系统交互的那些坑5.1 逻辑线程 vs 渲染线程两条时间线现代游戏引擎里逻辑和渲染通常是两个独立的线程逻辑线程Logic Thread跑游戏逻辑、物理、AI、动画状态机渲染线程Render Thread准备渲染数据、调用图形API、提交Draw Call它们的Tick顺序是先完整执行逻辑Tick再执行渲染Tick。这样设计的好处是渲染和逻辑可以并行逻辑线程算下一帧的时候渲染线程在画这一帧互不耽误。但代价是什么延迟。逻辑算完了渲染要等一帧才能画出来画面准备好了帧缓冲区交换又要等一帧。层层叠加下来你按手柄到画面有反应可能已经过了3~4帧也就是一百多毫秒 冷知识早期主机游戏的输入延迟可以达到100毫秒但玩家为什么感觉不到因为引擎用了大量的障眼法——比如按下按键立刻播放音效、手柄震动、动画预演……让你觉得很实时但实际上画面还没跟上。这就是游戏开发的魔术 ✨5.2 动画与物理的双人舞动画和物理的关系是游戏引擎里最微妙的话题之一。你可能见过游戏里的布娃娃系统——角色被打飞后整个人像没有骨头一样软塌塌地飞出去。物理上很真实但看起来……就很假像个破布娃娃 纯动画呢动作很帅、很有设计感但又不够真实——倒地姿势永远是那几个看多了就腻了。现代引擎的做法是——插值混合▲ 图4动画与物理的权重插值混合——从全动画过渡到全物理兼顾设计感与真实感具体来说受击瞬间动画权重100%保留美术设计的酷炫受击动作 过程渐变逐步降低动画权重、提升物理模拟权重倒地阶段完全交给物理刚体模拟肢体姿态随机真实 这样出来的效果就是角色被打飞的姿势很帅很英雄但倒地后的手脚位置又是随机的、真实的。玩家觉得又帅又真实但其实是两个系统跳双人舞的结果 5.3 Tick耗时过长怎么办四大策略理想很丰满现实很骨感。有时候一帧的计算量太大Tick根本算不完怎么办这里有四种常见的处理策略策略做法适用场景风险步长补偿Tick传入deltaTime位移按实际耗时插值轻度卡顿、移动类逻辑低 ✅丢弃单帧直接跳过一帧逻辑赶上下一帧非关键逻辑高 ❌ 可能丢碰撞/伤害分帧延迟处理高负载任务拆分多批分散到后续帧爆炸、批量受击、物体生成中 ⚠️ 5帧内人眼可接受架构优化重构系统、减少并行冲突、降低计算量长期优化、性能瓶颈低 ✅ 治本之策▲ 表2Tick耗时过长的四种处理策略对比其中**分帧延迟处理Deferred Processing**是最常用的技巧。比如一个爆炸产生了100个物体的受击事件没必要在一帧内全部处理完分成5帧每帧处理20个——人眼对0.2秒内的分批处理几乎感知不到但性能压力直接降了80% 空间划分动态世界的加速秘籍6.1 为什么需要空间划分想象一下一个开放世界里有10000个物体每次物理检测都要两两配对判断碰撞——那就是1亿次检测这谁顶得住啊 空间划分的核心思想就是只检测可能碰撞的物体。离得八丈远的两个物体根本不用浪费算力去检测。常见的空间划分算法有三种BSP / PVS二叉空间分割✅ 室内关卡神器❌ 动态物体更新慢Octree 八叉树立方体八等分递归✅ 动静皆宜⚠️ 中等更新开销BVH 包围盒层级包围盒合并成树✅ 动态场景首选✅ 更新开销极低6.2 动态物体怎么办静态世界的空间划分很简单建好就完事了。但动态物体呢总不能每一帧都把整棵树重建一遍吧那也太蠢了 这就是为什么**BVH包围盒层级树**在开放世界里这么受欢迎——每个物体都有自己的包围盒物体移动时只需要更新它自己的包围盒然后往上回溯更新父节点的包围盒就行了。大部分节点都不用动更新代价极低就像你搬家了只需要更新你家的地址再更新一下小区的统计信息整个城市的地图不用重画——就这么简单 →引擎设计建议一个成熟的游戏引擎至少应该支持2~3种空间划分算法交给游戏项目按需选用室内关卡 → BSP/PVS混合场景 → Octree开放世界 → BVH没有最好的算法只有最合适的场景。 总结游戏引擎的时间哲学聊了这么多我们来收个尾。游戏引擎的Tick时序、事件机制、组件化架构……这些东西看起来是技术细节但背后其实藏着一套关于时间与秩序的哲学。核心要点回顾Tick是游戏世界的心跳按组件批量更新才能发挥多核性能但并行带来了时序难题邮局模型是确定性的保障——所有消息统一收发PreTick读、PostTick写中间专心干活组件化是灵活的代价——用组合代替继承但要付出查询性能和循环依赖的代价多系统协作需要智慧——逻辑与渲染分离、动画与物理混合、空间划分按需选择最后想说游戏引擎最迷人的地方不是它用了多么高深的技术而是它用这些技术构建了一个有规则、有秩序、有确定性的虚拟世界。就像我们的现实世界一样——虽然复杂但背后有一套精密运转的规律。而引擎程序员就是这个虚拟世界的创世神 ✨ 延伸阅读想深入了解ECS架构可以看看Entity Component System的设计思想对空间划分感兴趣推荐阅读《Real-Time Collision Detection》想做自己的小引擎可以关注Piccolo引擎的开源项目如果这篇文章对你有帮助别忘了点赞收藏关注三连哦你的支持是我持续输出的最大动力 有任何问题欢迎在评论区留言我们一起探讨游戏引擎的奥秘 ✨