2.1 游戏循环
请简述Unity 3D中常用的生命周期函数,并以此为基础简述游戏循环的设计要点。
问题分析
在使用Unity 3D开发游戏时,总会涉及Unity内置的生命周期函数。弄清这些函数的调用顺序和特性十分重要,因为这影响到逻辑的执行顺序。例如,初始化要在使用之前,注册回调要在响应之前,等等。
一般来说,在大型商业项目中,我们需要自制一套游戏循环,来满足多变的游戏玩法。哪些现有的生命周期函数可以使用?哪些需要被替换掉?这些取舍贯穿了整个游戏开发过程。
生命周期
Unity 3D的函数调用有固定的顺序,对于脚本生命周期(Script Lifecycle),官方文档中给出了一张时序图,如图2.1所示。([:lifecycle])。
图2.1
深入理解这张图,可以应对大多数的时序问题。在Initialization、Disable/enable、Decommissioning区域的函数只会调用有限次数,而其他部分都是受控重复或循环调用,其中:
◎ Awake函数在构造脚本对象时调用;
◎ OnEnable/OnDisable在每次激活对象时调用;
◎ Start在第一次触发脚本时调用;
◎ OnDestory在销毁对象时调用。
主循环分为物理模拟、游戏逻辑、渲染绘制三个子循环。从Unity实现的方式来看,这三个循环是在同一个线程中的。不过自Unity 3D的5.x版本后,引擎加入了多线程渲染的选项,即把第三步循环放在另一个线程中进行,这样就可以在固定帧率下降低逻辑循环的压力,减少掉帧现象的发生。至于物理模拟循环,笔者觉得没必要拆出到另一个线程。毕竟线程间通信也需要性能,而物理模拟循环运行的速度一般要快于游戏逻辑,如果出现数据访问冲突时还需要加锁,就得不偿失了。
重写模板脚本
在实际项目中,可以通过更改代码的模板文件,来优化生命周期函数的使用方式。在Unity的安装目录中,有创建代码的模板文件。通过自定义这个文件,可以更改创建默认文件的内容。文件目录如下。
◎ Windows:Unity安装目录/Editor/Data/Resources/ScriptTemplates/81-C# Script- NewBe haviourScript. cs.txt。
◎ Mac:Unity安装目录/Contents/Resources/ScriptTemplates/81-C# Script-NewBehaviourScript. cs.txt。
根据团队开发人员的编程习惯,笔者自定义了一个代码模板,大家可以根据自己的需要做相应更改:
using UnityEngine; using System.Collections; public class #SCRIPTNAME# : MonoBehaviour { #region Public Attributes #endregion #region Private Attributes #endregion #region Unity Messages // void Awake() // { // // } // void OnEnable() // { // // } // // void Start() // { // // } // // void Update() // { // // } // // void OnDisable() // { // // } // // void OnDestroy() // { // // } #endregion #region Public Methods #endregion #region Override Methods #endregion #region Private Methods #endregion #region Inner #endregion }
代码模板中添加了常用的生命周期函数,并按照顺序进行排列。由于空函数也会产生性能消耗,因此这里采用注释的方式来规避这个弊端。另外,笔者按用途添加了几个#region分隔函数区域。#region是C#的功能,可以标定折叠区域,方便查找对应函数。
[注]本节后续内容部分参考(参考书目6)
游戏循环
了解了Unity自带的生命周期函数之后,再看看游戏循环应该如何设计。游戏归根结底是由交互序列组成的,因此它必然会有一个基础的结构:
while (true) { Input(); Update(); Render(); }
从这个层面看,游戏循环由三部分组成,分别是
(1)非阻塞的用户输入;
(2)更新游戏逻辑状态;
(3)渲染游戏画面。
每次循环完成后,会更新一次画面的绘制,这个过程也被称为帧(Frame)。帧率(FPS, Frame Per Second)可以标定游戏循环的速率与真实时间的映射关系。帧率值越小,意味着游戏越“卡”。游戏在PC上,通常为60帧/秒,在手机上为30帧/秒。另一方面,帧率的倒数即为每帧所占用的时长,单位通常为毫秒。影响帧率的主要因素是每帧需要做的工作。例如,复杂的物理计算、游戏逻辑的处理、图形细节控制等,这些都会占据CPU与GPU。如果处理操作的时长超过帧率的倒数,那么就会拖慢帧率,这种现象称为“掉帧”。
固定帧率模式
一般来说,我们有个期望的帧率,如果每帧的运行时间短,那么帧率就会超过预定的标准,因此我们通常会在循环的末尾加入延期等待。假定有个Sleep函数可以阻塞线程执行,即这个模式的代码结构如下:
while (true) { double start=getCurrentTime(); Input(); Update(); Render(); sleep(start + 1/FPS - getCurrentTime()); }
在这种结构中,帧率不会超过预定数值。在Unity 3D中可以通过下面的代码设置:
Application.targetFrameRate=FPS;
追赶模式
这种模式可以更好地处理掉帧引发的逻辑问题。大体思路是,当出现掉帧时,只运行逻辑,不绘制画面,用节省下来的时间追赶落后的帧。这种策略会降低图形绘制的频率,但可以保证逻辑的执行。
具体来说就是在每次Render执行之前,要保证累计运行时长达到阈值。如果出现卡顿,则后面的帧就会多次执行Update,直到赶上之前的帧为止。代码结构如下:
double preFrameTime=getCurrentTime(); double lag=0.0; while (true) { double current=getCurrentTime(); double elapsed=current - preFrameTime; preFrameTime=current; lag +=elapsed; Input(); while (lag >=1/FPS) { Update(); lag -=1/FPS; } Render(); }
使用这种模式时,要注意不要将FPS设置得太大,否则最慢的机器将永远赶不上时间,它将卡在死循环中。对于较差的机器,Render在逻辑循环之外,所以总体来看还是会节省一些时间。虽然看起来会比较卡,但基本能够正常运行游戏。
在Unity 3D中,对应这个模式的循环是FixedUpdate。在Unity中设置Fixed Timestep可以控制FixedUpdate速率,其数值为时长周期。如果FixedUpdate在限定的时间内执行不完,则图形绘制频率就会降低,以保证物理的执行。另一方面,设置Maximum Allowed Timestep可以防止逻辑执行时间过长,卡死线程,如图2.2所示。
图2.2
总结
Unity 3D作为完整的引擎,常见的生命周期函数与游戏循环模式都已具备。但作为特定的游戏,通常有自己的特点。例如,竞速类游戏与MMO网游在游戏循环的设计上就有很大的差别。竞速类游戏对实时反馈的要求很高,如果采用追赶模式,则抽帧造成的体验就会很差。在掉帧方面,MMO网游面临的则是角色在场景中漫游时,其他玩家的模型加载与位置同步造成的卡顿。在这种情况下,可能会使用分帧加载、AOI(Area Of Interest)等处理方法来保障游戏的流畅。
因此,游戏循环通常是每个项目根据自己的特点“独家定制”的。在深入理解Unity 3D的生命周期函数后,我们就可以在其基础上,自主独立地搭建个性化的游戏循环框架了。
扩展问题
请尝试分析多线程渲染的实现原理([::mutirender])。