当前位置: 首页 > news >正文

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

1. 项目概述:从“卡顿”与“掉帧”说起

最近在带几个新人做项目,调试时他们最常问的两个问题就是:“为什么我的角色移动一卡一卡的?”和“为什么我的子弹有时候穿墙了?”。这两个看似不同的问题,其实都指向了游戏开发中一个最核心、也最容易被新手混淆的概念:逻辑帧与物理帧,或者说,整个游戏循环(Gameloop)的设计。这不仅仅是Unity或者Godot的问题,而是所有实时交互应用,从《代号 村庄保卫战》这样的独立游戏到微信小游戏,再到用C++手搓引擎,都必须直面的底层架构问题。

简单来说,你可以把游戏想象成一个永不停止的精密钟表。逻辑帧是钟表内部齿轮的“嘀嗒”声,它决定了游戏世界的时间推进、角色AI的思考、技能冷却的计数。而物理帧则是钟表指针的“跳动”,它决定了物体在屏幕上的位置、碰撞检测的时机、以及玩家最终看到的画面。如果这两个“嘀嗒”声步调不一致,你的游戏就会出现各种诡异的现象:比如角色在流畅画面上“瞬移”(逻辑太快),或者明明按了跳跃键却延迟了半秒才跳起来(逻辑太慢)。今天这篇笔记,我就结合这些年踩过的坑,把游戏循环这摊子事彻底捋清楚,让你不仅知道怎么用引擎提供的UpdateFixedUpdate,更明白为什么它们要这样设计,以及当它们“不听话”时,你该如何驯服它们。

2. 核心概念拆解:逻辑帧、物理帧与游戏循环

2.1 什么是游戏循环(Gameloop)?

游戏循环是游戏程序的心跳。它是一个无限循环,在每一“圈”中,它按顺序做三件大事:

  1. 处理输入:检查玩家按了什么键、点了哪里。
  2. 更新游戏状态:根据输入和经过的时间,计算所有游戏对象的新状态(位置、血量、分数等)。这一步的核心就是逻辑更新
  3. 渲染画面:将最新的游戏状态绘制到屏幕上。这一步产生渲染帧

一个最原始、最理想化的游戏循环伪代码如下:

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 与 LateUpdate

Unity 将游戏循环清晰地暴露给了开发者:

  • 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_process

Godot 的设计理念类似,但更显式:

  • _process(delta):对应逻辑帧。delta是可变时间间隔。你可以在项目设置中设置最大刷新率(默认为0,即无限制)。
  • _physics_process(delta):对应物理帧。delta是一个固定值,在项目设置 -> 物理 -> 公共 -> 物理帧率中定义(默认为60Hz)。所有物理相关的代码,如移动KinematicBody、检查碰撞,都应放在这里。

Godot 中的注意事项:Godot的_physics_process调用频率是固定的,但渲染帧率可能波动。引擎内部会进行插值(Interpolation),让在物理坐标间移动的物体在渲染时看起来是平滑的。这对于2D/3D的RigidBodyKinematicBody是自动的。但如果你自己用_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和T+fixedTimestep),而渲染发生在连续的时间点上。如果我们直接把逻辑状态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.对于高速物体,使用RaycastSphereCast进行连续碰撞检测(CCD)。在Unity中,可以勾选刚体的Collision DetectionContinuousContinuous Dynamic
2. 确保碰撞检测查询(如Physics.Raycast)在FixedUpdate或物理回调(如OnCollisionEnter)中进行。
游戏速度与帧率相关移动、旋转、计时等操作在Update中没有乘以Time.deltaTime在所有Update中的与时间相关的线性操作上,务必乘以Time.deltaTime。养成条件反射。
FixedUpdate执行次数不稳定游戏逻辑过于复杂,导致一帧的Update耗时超过fixedDeltaTime,物理更新为了追赶会在一帧内多次调用FixedUpdate,造成“卡顿式加速”。1. 优化UpdateFixedUpdate中的代码性能。
2. 可以考虑适当调大Time.fixedDeltaTime(如从0.02s调到0.033s,即30Hz),牺牲一些物理精度换取性能。
3. 将非紧急的逻辑移到Update中,并确保其是帧率自适应的。
移动设备发热严重,帧率下降游戏循环负载过高,没有针对移动端优化。1. 使用性能分析器(如Unity Profiler)找到瓶颈。
2. 考虑降低目标帧率。对于移动端,30FPS往往是可接受的。在Unity中,可以设置Application.targetFrameRate = 30;
3. 采用“按需更新”策略,例如远离摄像头的AI可以降低更新频率。

5.2 性能优化心得

  1. Profile First(性能分析优先):不要猜。用Profiler工具看清UpdateFixedUpdate、渲染各占多少时间。FixedUpdate调用次数过多是常见性能杀手。
  2. 逻辑帧不是越高越好:对于很多游戏类型(如回合制、卡牌、模拟经营),逻辑帧30Hz甚至10Hz都绰绰有余。可以设计一个自适应的逻辑更新系统,在负载高时自动降低非关键逻辑的更新频率。
  3. 物理帧的权衡Time.fixedDeltaTime默认是0.02s (50Hz)。对于2D游戏或对物理精度要求不高的游戏,设置为0.033s (30Hz) 或 0.04s (25Hz) 能显著减少CPU开销,且玩家通常感知不到区别。
  4. 对象池与循环内分配:严禁在Update/FixedUpdate中频繁实例化/销毁对象(如子弹、特效),这会引起GC(垃圾回收)卡顿,导致帧时间尖峰,破坏游戏循环的平稳性。务必使用对象池。

6. 设计模式与架构思考

当你对基础的游戏循环有了掌控力后,可以思考更优雅的架构,让逻辑更新更清晰、更易维护。

6.1 状态更新与命令模式

可以将每一帧的逻辑更新视为对游戏世界状态的一次“应用命令”。例如,将输入、网络消息、AI决策都转化为一个个“命令对象”(如MoveCommandAttackCommand),在逻辑更新循环中统一处理这些命令。这有利于实现回放、录像、网络同步和测试。

6.2 分层更新系统

不是所有对象都需要每帧更新。可以设计一个更新管理器,将游戏对象注册到不同的更新层:

  • 高频层:每帧更新(玩家控制、UI动画)。
  • 固定层:固定时间步长更新(物理相关、核心游戏循环)。
  • 低频层:每N帧更新一次(远处的NPC AI、环境粒子系统)。
  • 休眠层:暂停更新(远离视界的物体)。

这种设计能极大优化性能,也是大型游戏引擎的常见做法。

6.3 应对卡顿:时间缩放与追赶策略

当游戏出现不可避免的卡顿(如加载资源)时,直接让游戏“冻住”体验很差。可以考虑:

  • 时间缩放:临时将Time.timeScale降低(如调到0.5),让游戏进入慢动作状态,给系统喘息之机,这比直接卡住要好。
  • 逻辑追赶:对于网络游戏或强同步游戏,在卡顿恢复后,可能需要以更快速度(但仍是固定步长)执行逻辑更新,以追赶服务器或其他客户端的进度,而不是直接“跳帧”。

游戏循环是游戏开发的基石,理解逻辑帧与物理帧的辨析,是写出稳定、流畅、可预测游戏代码的前提。它不是一个可以死记硬背的API调用,而是一种需要根据你的游戏类型、目标平台和性能要求去灵活设计和调整的底层思维模型。从今天起,在写每一行Update里的代码时,都问问自己:“这个操作是帧率依赖的吗?它应该放在这里吗?有没有更稳定、更高效的方式?” 多问几个为什么,你就能避开很多坑,写出更专业的代码。

http://www.jsqmd.com/news/1397579/

相关文章:

  • AI巡检、自动巡检、智能巡检,到底差在哪?很多企业一开始就搞错了
  • 短视频高价回收陷阱丛生,深圳处置闲置黄金,实测合规线下回收门店 - 日常前沿快讯
  • Kali Linux渗透测试入门:10天从零搭建实验环境到独立实战
  • 行星齿轮非线性动力学分析与工程应用
  • 从“会写代码”到“能碰硬件”:GaryCLI + GaryProbe 在工业场景中的开发作用与前景
  • 推荐一下青岛EMC代理正规公司:升级 - 品牌推广大师
  • 绝区零日常不再熬夜:一条龙自动化工具从零到挂机的实战配置指南
  • 2026年郑州能做智慧燃气安全监测管理系统的公司有哪些?
  • 西北环线旅游攻略七日游,青甘7天跟团游,纯玩小团玩转盐湖戈壁,新手出行必读 - 跟我去旅游
  • SGS见证兰州兰石1000Nm3/h高效低成本PEM电解水制氢系统72小时工业性试验
  • SCI投稿状态全解析:从ADM、AE到Under Review,读懂编辑部“暗号”
  • 显卡驱动清理不彻底?用 DDU 把三大品牌显卡驱动的残留一次扫净
  • 三星硬盘维修工具包下载|SHTV 4.0.6与2.2版软件+多语言教程
  • 免费解锁Wand专业版:三步永久移除2小时限制,附手机远程控制实战指南
  • Ubuntu U盘无法识别?从硬件到内核的完整排查与修复指南
  • 微串口调试工具 集成| Modbus/CAN/DBC/蓝牙 SPP/波形/脚本
  • # 避坑指南|奢侈品回收,哪些话术是商家的小圈套 - 朝夕热点速报
  • Ubuntu 22.04手动编译安装最新版CMake完整指南
  • AI专著撰写必备!精选工具助你一键生成20万字专著,快速完成出版目标! - AI写论文
  • 抖音批量下载工具完整教程(30分钟上手)
  • 2026 年南昌砸墙|微挖机租赁|废品拆除回收服务问答 - LYL仔仔
  • 家用车节气门高频脏污的底层逻辑,正确养护方式分享!
  • 不动产租赁管理系统核心能力拆解:一套系统应具备的6大模块 - 资讯报道
  • Mapshaper 完整实测:把 300MB 卡顿地图变成秒开页面,只差一条命令
  • 证券买卖五档行情接口开发与优化实战
  • DriverStoreExplorer 驱动清理实战:闯过三关,轻松释放C盘数GB空间
  • 印象笔记 token 七天就过期,我写了五版程序让它自己换
  • 基于OpenClaw Agent框架构建智能内容分发系统,实现多平台自动化发布
  • 联想拯救者工具箱(Lenovo Legion Toolkit)实战拆解:从接管性能模式到自动化管线的 7 个硬核操作
  • Blazor组件开发:C#构建现代Web应用实践