尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

游戏开发核心:逻辑帧与物理帧的深度解析与实战优化

游戏开发核心:逻辑帧与物理帧的深度解析与实战优化 1. 项目概述从“卡顿”与“掉帧”说起最近在带几个新人做项目调试时他们最常问的两个问题就是“为什么我的角色移动一卡一卡的”和“为什么我的子弹有时候穿墙了”。这两个看似不同的问题其实都指向了游戏开发中一个最核心、也最容易被新手混淆的概念逻辑帧与物理帧或者说整个游戏循环Gameloop的设计。这不仅仅是Unity或者Godot的问题而是所有实时交互应用从《代号 村庄保卫战》这样的独立游戏到微信小游戏再到用C手搓引擎都必须直面的底层架构问题。简单来说你可以把游戏想象成一个永不停止的精密钟表。逻辑帧是钟表内部齿轮的“嘀嗒”声它决定了游戏世界的时间推进、角色AI的思考、技能冷却的计数。而物理帧则是钟表指针的“跳动”它决定了物体在屏幕上的位置、碰撞检测的时机、以及玩家最终看到的画面。如果这两个“嘀嗒”声步调不一致你的游戏就会出现各种诡异的现象比如角色在流畅画面上“瞬移”逻辑太快或者明明按了跳跃键却延迟了半秒才跳起来逻辑太慢。今天这篇笔记我就结合这些年踩过的坑把游戏循环这摊子事彻底捋清楚让你不仅知道怎么用引擎提供的Update和FixedUpdate更明白为什么它们要这样设计以及当它们“不听话”时你该如何驯服它们。2. 核心概念拆解逻辑帧、物理帧与游戏循环2.1 什么是游戏循环Gameloop游戏循环是游戏程序的心跳。它是一个无限循环在每一“圈”中它按顺序做三件大事处理输入检查玩家按了什么键、点了哪里。更新游戏状态根据输入和经过的时间计算所有游戏对象的新状态位置、血量、分数等。这一步的核心就是逻辑更新。渲染画面将最新的游戏状态绘制到屏幕上。这一步产生渲染帧。一个最原始、最理想化的游戏循环伪代码如下while (gameIsRunning) { processInput(); // 处理输入 updateGameLogic(); // 更新逻辑逻辑帧 renderGraphics(); // 渲染画面渲染帧/物理帧 }这个循环会以硬件所能达到的最快速度运行。在古老的DOS时代这很有效因为大家的CPU速度差不多。但在现代不同设备的性能天差地别一台顶级游戏PC的循环速度可能是老旧手机的几十倍。如果直接这么写在快机器上游戏会像开了加速齿轮在慢机器上则慢如蜗牛。这就引出了我们对时间控制的需求。2.2 逻辑帧 vs. 物理帧职责与分离为了解决上述问题现代游戏引擎将“更新状态”这一步进行了精细的拆分核心就是区分逻辑帧和物理帧。逻辑帧职责处理游戏规则、业务逻辑。例如角色状态机切换 idle - run - jump、技能冷却计时、AI的决策过程、游戏分数计算、道具生成逻辑等。特点与渲染解耦。它的更新频率理想情况下是固定的但也可以根据实际情况进行动态调整或补偿。在Unity中它对应着Update()函数在Godot中对应着_process(delta)函数。它的执行间隔deltaTime是可变的取决于上一帧渲染花了多长时间。物理帧职责处理与物理模拟相关的一切。例如刚体运动受重力、力的影响、碰撞检测与响应、关节约束、射线检测等。物理世界需要一个稳定、可预测的时间步进来进行精确的积分计算否则模拟会“爆炸”比如物体获得无限速度。特点必须固定频率。物理引擎要求在一个固定的时间间隔如每秒50次或60次更新以确保模拟的稳定性和可重复性。在Unity中它对应着FixedUpdate()函数和物理引擎的更新在Godot中对应着物理进程_physics_process(delta)这里的delta在项目设置中是一个固定值默认为1/60秒。注意这里需要澄清一个常见的术语混用。严格来说“物理帧”特指物理模拟的更新。而玩家通常说的“帧”或“FPS”指的是渲染帧。但在很多开发语境下尤其是当物理更新与渲染紧密绑定时如一些简单的游戏大家也会用“物理帧”来泛指与物体运动、碰撞相关的这一套固定频率的更新体系。在本文中我们主要讨论开发层面的“逻辑更新”与“物理更新”的区分。为什么必须分离想象一个场景你的游戏逻辑在高端PC上每秒更新200次在低端手机上每秒只能更新30次。如果物理模拟绑在逻辑更新上那么PC上的小球会以“正常”速度的200/30≈6.7倍速度下落这显然不对。分离后无论逻辑帧快慢物理世界都按照固定的每秒60次例如更新小球的下落速度在所有设备上都是一致的。这就是分离的核心价值确定性与稳定性。2.3 时间步长Delta Time 与 Fixed Delta Time这是理解帧率控制的关键。Delta Time可变时间间隔。指完成上一帧所花费的真实时间秒。它主要用在逻辑更新中。例如你想让一个物体每秒移动5个单位在Update中你应该写// Unity C# 示例 void Update() { float movement 5.0f * Time.deltaTime; // 这帧花了0.02秒就移动0.1单位花了0.1秒就移动0.5单位。 transform.Translate(movement, 0, 0); }这样无论帧率是30还是60物体每秒移动的距离都是5个单位实现了帧率无关的平滑移动。Fixed Delta Time固定时间间隔。这是物理更新的步长在Unity中默认是0.02秒即每秒50次FixedUpdate。这个值一般不要轻易改动因为物理引擎的很多参数如重力、力都是基于这个固定步长调优的。改动它可能导致物理行为剧变。3. 主流引擎中的实现与“坑点”不同的引擎对游戏循环的封装程度不同但核心理念相通。了解它们的具体实现能帮你更好地使用和调试。3.1 Unity 中的 Update, FixedUpdate 与 LateUpdateUnity 将游戏循环清晰地暴露给了开发者Update()逻辑帧的主力。每渲染一帧前调用一次。Time.deltaTime是可变值。FixedUpdate()物理帧的主力。在固定的时间间隔被调用由Time.fixedDeltaTime定义默认0.02s。物理引擎的更新如刚体位置计算、碰撞检测发生在FixedUpdate之间而不是严格在FixedUpdate函数内部。这意味着你在FixedUpdate中施加的力会在接下来的物理更新步中被计算。LateUpdate()在Update之后渲染之前调用。常用于跟随摄像机、或确保所有对象在Update中移动完毕后再进行依赖它们位置的计算。Unity 中最经典的“坑”void Update() { // 错误示范在Update中直接以帧率相关的方式移动刚体 rigidbody.position Vector3.right * 0.1f; // 帧率高移动快帧率低移动慢且绕过物理引擎 } void FixedUpdate() { // 正确示范在FixedUpdate中使用力或速度来控制刚体 rigidbody.AddForce(Vector3.right * 10f); // 或者如果必须设置位置/速度也应使用物理相关API rigidbody.velocity new Vector3(5f, rigidbody.velocity.y, 0); }在Update中直接修改rigidbody.position会与物理引擎的内部计算产生冲突导致抖动、穿墙等不可预测行为。所有对物理组件的直接操作原则上都应放在FixedUpdate中。3.2 Godot 中的 _process 与 _physics_processGodot 的设计理念类似但更显式_process(delta)对应逻辑帧。delta是可变时间间隔。你可以在项目设置中设置最大刷新率默认为0即无限制。_physics_process(delta)对应物理帧。delta是一个固定值在项目设置 - 物理 - 公共 - 物理帧率中定义默认为60Hz。所有物理相关的代码如移动KinematicBody、检查碰撞都应放在这里。Godot 中的注意事项Godot的_physics_process调用频率是固定的但渲染帧率可能波动。引擎内部会进行插值Interpolation让在物理坐标间移动的物体在渲染时看起来是平滑的。这对于2D/3D的RigidBody和KinematicBody是自动的。但如果你自己用_process做动画一定要乘以delta。3.3 微信小游戏与C手搓循环对于微信小游戏这类基于Web技术的平台其游戏循环依赖于requestAnimationFrame(rAF)。rAF 的回调频率通常与浏览器刷新率同步通常是60Hz但它不保证固定间隔尤其在页面不可见或机器负载高时。因此你必须在rAF回调中根据实际经过的时间来计算逻辑更新。一个简单的、帧率自适应的游戏循环模式如下let lastTime 0; function gameLoop(currentTime) { const deltaTime (currentTime - lastTime) / 1000; // 转换为秒 lastTime currentTime; // 使用累积时间步进固定逻辑更新 updateGame(deltaTime); render(); requestAnimationFrame(gameLoop); } function updateGame(deltaTime) { // 这里可以实现“固定时间步长”的逻辑更新见下文第4章 }而对于用C等语言从零开始编写游戏循环比如开发《代号 村庄保卫战》这样的项目你将拥有完全的控制权但也必须亲手处理所有时间步进、逻辑与渲染分离的细节挑战更大但理解也最深。4. 高级模式如何处理帧率波动与追赶在实际运行中渲染一帧的时间deltaTime是波动的。如果简单地将这个波动的deltaTime直接用于逻辑更新可能会带来问题。例如某一帧因为GC垃圾回收卡顿了0.5秒你的游戏逻辑会认为“过去了0.5秒”并一次性计算这0.5秒内发生的所有事情。这可能导致角色“瞬移”过远或者AI在一瞬间做出大量决策。4.1 固定时间步长逻辑更新为了解决这个问题一个更健壮的模式是对逻辑更新也采用固定时间步长独立于渲染帧。这就是“固定时间步长变渲染”架构。// 伪代码概念 float fixedTimestep 0.016f; // 逻辑固定步长例如60Hz float accumulatedTime 0f; void Update() { // 这个Update是引擎的渲染循环入口 float frameTime Time.deltaTime; accumulatedTime frameTime; while (accumulatedTime fixedTimestep) { UpdateGameLogic(fixedTimestep); // 以固定步长更新逻辑 accumulatedTime - fixedTimestep; } // 渲染前可以计算一个插值Alpha用于平滑渲染 float interpolationAlpha accumulatedTime / fixedTimestep; Render(interpolationAlpha); }原理我们用一个“时间蓄水池”accumulatedTime积累真实经过的时间。每当池子里的水超过一个固定步长如0.016s我们就舀出一瓢水执行一次固定步长的逻辑更新直到池子里的水不够一瓢为止。剩下的水留到下一帧。这样无论渲染帧率是快是慢游戏逻辑的更新频率和速度都是稳定的。适用场景对逻辑确定性要求高的游戏如RTS需要同步大量单位、物理谜题游戏、网络游戏客户端预测与回滚等。Unity的FixedUpdate对于物理是这么做的但对于你自己的游戏逻辑你可能需要手动实现类似机制。4.2 渲染插值注意上面伪代码中的interpolationAlpha。因为逻辑更新是离散的发生在时间点T和TfixedTimestep而渲染发生在连续的时间点上。如果我们直接把逻辑状态T时刻的位置画出来在逻辑更新频率低于渲染频率时画面会抖动。渲染插值就是为了解决这个我们不是渲染上一逻辑帧的状态也不是渲染当前逻辑帧的状态而是渲染这两个状态之间的一个插值状态。// 假设上一逻辑帧位置是prevPosition当前逻辑帧位置是currentPosition Vector3 renderPosition Vector3.Lerp(prevPosition, currentPosition, interpolationAlpha);这样即使逻辑帧只有30Hz渲染帧是60Hz画面也能看起来是平滑的60Hz运动。很多网络游戏同步和高级物理引擎都会用到这个技术。5. 实战问题排查与性能优化理解了原理我们来看看实战中那些头疼的问题怎么解决。5.1 典型问题速查表问题现象可能原因排查思路与解决方案物体移动抖动、抽搐1. 在Update中修改刚体位置与FixedUpdate的物理计算冲突。2. 渲染帧率不稳定且没有使用插值。3. 逻辑帧与物理帧频率不匹配如逻辑帧远高于物理帧。1.确保所有Rigidbody的位置/速度修改只在FixedUpdate中进行。2. 对于非物理的运动在Update中使用Transform.Translate并乘以Time.deltaTime。3. 考虑启用或实现渲染插值。碰撞检测不可靠穿墙1. 物体移动速度过快在一帧内穿越了碰撞体厚度子弹穿墙。2. 碰撞检测的代码放在了错误的更新循环中。1.对于高速物体使用Raycast或SphereCast进行连续碰撞检测CCD。在Unity中可以勾选刚体的Collision Detection为Continuous或Continuous Dynamic。2. 确保碰撞检测查询如Physics.Raycast在FixedUpdate或物理回调如OnCollisionEnter中进行。游戏速度与帧率相关移动、旋转、计时等操作在Update中没有乘以Time.deltaTime。在所有Update中的与时间相关的线性操作上务必乘以Time.deltaTime。养成条件反射。FixedUpdate执行次数不稳定游戏逻辑过于复杂导致一帧的Update耗时超过fixedDeltaTime物理更新为了追赶会在一帧内多次调用FixedUpdate造成“卡顿式加速”。1. 优化Update和FixedUpdate中的代码性能。2. 可以考虑适当调大Time.fixedDeltaTime如从0.02s调到0.033s即30Hz牺牲一些物理精度换取性能。3. 将非紧急的逻辑移到Update中并确保其是帧率自适应的。移动设备发热严重帧率下降游戏循环负载过高没有针对移动端优化。1. 使用性能分析器如Unity Profiler找到瓶颈。2. 考虑降低目标帧率。对于移动端30FPS往往是可接受的。在Unity中可以设置Application.targetFrameRate 30;。3. 采用“按需更新”策略例如远离摄像头的AI可以降低更新频率。5.2 性能优化心得Profile First性能分析优先不要猜。用Profiler工具看清Update、FixedUpdate、渲染各占多少时间。FixedUpdate调用次数过多是常见性能杀手。逻辑帧不是越高越好对于很多游戏类型如回合制、卡牌、模拟经营逻辑帧30Hz甚至10Hz都绰绰有余。可以设计一个自适应的逻辑更新系统在负载高时自动降低非关键逻辑的更新频率。物理帧的权衡Time.fixedDeltaTime默认是0.02s (50Hz)。对于2D游戏或对物理精度要求不高的游戏设置为0.033s (30Hz) 或 0.04s (25Hz) 能显著减少CPU开销且玩家通常感知不到区别。对象池与循环内分配严禁在Update/FixedUpdate中频繁实例化/销毁对象如子弹、特效这会引起GC垃圾回收卡顿导致帧时间尖峰破坏游戏循环的平稳性。务必使用对象池。6. 设计模式与架构思考当你对基础的游戏循环有了掌控力后可以思考更优雅的架构让逻辑更新更清晰、更易维护。6.1 状态更新与命令模式可以将每一帧的逻辑更新视为对游戏世界状态的一次“应用命令”。例如将输入、网络消息、AI决策都转化为一个个“命令对象”如MoveCommand、AttackCommand在逻辑更新循环中统一处理这些命令。这有利于实现回放、录像、网络同步和测试。6.2 分层更新系统不是所有对象都需要每帧更新。可以设计一个更新管理器将游戏对象注册到不同的更新层高频层每帧更新玩家控制、UI动画。固定层固定时间步长更新物理相关、核心游戏循环。低频层每N帧更新一次远处的NPC AI、环境粒子系统。休眠层暂停更新远离视界的物体。这种设计能极大优化性能也是大型游戏引擎的常见做法。6.3 应对卡顿时间缩放与追赶策略当游戏出现不可避免的卡顿如加载资源时直接让游戏“冻住”体验很差。可以考虑时间缩放临时将Time.timeScale降低如调到0.5让游戏进入慢动作状态给系统喘息之机这比直接卡住要好。逻辑追赶对于网络游戏或强同步游戏在卡顿恢复后可能需要以更快速度但仍是固定步长执行逻辑更新以追赶服务器或其他客户端的进度而不是直接“跳帧”。游戏循环是游戏开发的基石理解逻辑帧与物理帧的辨析是写出稳定、流畅、可预测游戏代码的前提。它不是一个可以死记硬背的API调用而是一种需要根据你的游戏类型、目标平台和性能要求去灵活设计和调整的底层思维模型。从今天起在写每一行Update里的代码时都问问自己“这个操作是帧率依赖的吗它应该放在这里吗有没有更稳定、更高效的方式” 多问几个为什么你就能避开很多坑写出更专业的代码。
返回列表