执行顺序
本文主要对Unigine的执行顺序细节进行讲解。在文章中可了解到Unigine引擎中究竟发生了什么。
Engine Architecture(引擎结构)一文对Unigine工作流作出了高级概述。
下图分别对Unigine引擎的内部代码和项目脚本在单线程模式和多线程模式中的执行顺序进行展示。主要阶段是:
Unigine运行脚本#
Unigine有三种不同生命周期的运行脚本:
- World脚本。随同新的世界对象被自动创建,在其后进行命名(<world_name>.cpp) 被保存在相同的文件夹中。仅当世界对象得到加载后,世界脚本才会发挥作用。WorldLogic 类中的新方法会在相应的世界脚本方法得到调用之后才被调用。
- System 脚本。其在应用程序的整个生命周期内存在。SystemLogic类中的新方法会在相应的系统脚本方法得到调用之后才被调用。
- Editor 脚本。仅当完成编辑器的加载后,此脚本才会起效。EditorLogic类中的新方法会在相应的编辑器脚本方法得到调用之后才被调用。
初始化#
当主可执行应用程序(.exe文件32位 (x86)或 64位版本,,调试或发布版本)加载好相应的Unigine库后(.dll, .so 或者 .dylib)才开始对引擎进行初始化。从此刻开始,会发生下列事件:
- 与默认的系统分配器相比,对Unigine内存分配器初始化是为了达到更快更优的分配效果。在引擎代码中,通过USE_MEMORY指令对其进行设置。
- C++/C# API 被初始化。
- 应用程序目录路径(<Unigine SDK>/bin/) 及设置用户主目录路径(如果有地话)。 在 Windows系统中,主目录为C:/Users/<username>/。在Lunix系统中主目录为/home/<username>/,在Mac OS X系统中,主目录为/Users/<username>/。
- Command-line选项 会被解析,即:设置data 文件夹路径 外部包 和 目录, 插件目录路径以及日志 和配置文件还有 项目名称的选项。
Command-line 的值会覆盖默认值以及在配置文件中指定的值。
- 创建4线程(4) :声音,世界,渲染及文件系统线程。
- 默认情况下所有应用程序数据被保存的路径:
- 创建一个日志文件 。如果在命令行选项中未进行指定,默认的日志文件会放在名为log.html下的应用程序目录中。
- 如果使用了密码对外部包和 目录进行保护并在命令行中进行了指定,引擎会检测此密码是否与二进制文件的密码相匹配并加载这些包和目录。
-
配置文件会被加载并被解析:
-
如果在命令行中指定了-engine_config选项,将从指定的*.cfg文件按下列方式加载配置文件:
- 如果指定了项目名称 且使用了所给名称的配置文件在使用应用程序数据的文件夹中,此配置文件将从此处得以加载。
- 否则,保存在文件夹中并指定了-engine_config选项的配置文件将得到加载。
- 如果未指定配置文件,引擎会使用应用程序文件夹路径。这种情况下,引擎将加载默认的unigine.cfg配置文件。
在配置文件内保存着数种与世界相关的设置:渲染标记和参数,编辑器设置等等。如果在项目中调整了设置,所作出的改变将被保存在此文件中。
-
如果在命令行中指定了-engine_config选项,将从指定的*.cfg文件按下列方式加载配置文件:
- 会对Command-line选项进行解析,即先前未被解析的选项。
这些选项指定了基本的视频设置,比如将被用于渲染的图形(DirectX 或 OpenGL;当然也可以禁用图形API ),应用程序窗口大小等。
可通过命令行传递任意的外部#define指令及控制台变量。 通过控制台对任意外部#define进行设置。
Command-line的值将覆盖默认值和在配置文件中指定的值。 - 对项目*.cache文件路径进行设置。通常缓存文件包括编译过的着色器,系统脚本以及编辑器脚本。
- -extern_plugin命令行选项中指定的外部插件 库以及保存在由-plugin_path选项指定的插件目录内的插件库会得到加载。
可在Unigine运行期间的任意时刻加载自定义插件库。
- 初始化文件系统。所有在指定data_path目录下的文件会按照文件名进行缓存。如果项目文件被打包压缩为UNG或ZIP文档并使用密码保护文档,引擎会核对密码是否与二进制文件(如果有地话)设置的密码相匹配并加载这些文档。
- 初始化会自动对渲染及声音进行组织及处理的渲染管理器和声音管理器。
- 基于规定的设置创建应用程序窗口。
- 引擎所有子系统(例如观察仪,物理,声音,渲染,寻路子系统等)会得以创建。
- 运行声音系统线程和文件系统线程。
- 在-extern_define 命令行中传递的外部定义得到设置。
- 调用所有加载插件中的 init() 函数。
- 来自命令行的控制台命令需在队列中进行排队以便得到进一步执行。
- 加载并启用系统脚本。系统脚本主要执行必要的内务处理来启用基于Unigine的应用程序并使程序保持运行状态。在整个Unigine运行期间,系统脚本都保持加载状态。
默认的系统脚本在初始化时会执行下列操作:
- 加载本地化文件。
- 初始化主菜单。
- 如果有必要,设置闪现画面。在加载世界对象之前最好设置一个闪现画面。这样,闪现画面的演示可以为引擎提供时间用来加载资源及编译着色器。默认情况下,会显示一个标准的Unigine画面。
由于OpenGL 不支持着色器的缓存,因此每次启用应用程序时都会重新对其编译。相反DirectX支持着色器缓存,因此,在相同硬件配置的情况下分布式应用程序的启动速度更快。
事实上在执行期间的任意时刻都可以加载着色器,但对着色器的编译可能需要耗费一定的时间。 - 加载标准属性库。
可使用位于data/core/unigine.cpp中的默认系统脚本并使用自定义脚本对其进行取代。在此脚本中,您可以设置属于自己的闪现画面并将此画面用到项目上,指定加载的自定义模块或材质库等。对于大型项目指定世界对象并将此对象加载到自己的系统脚本中具有一定的意义。如果使用了自定义系统脚本,此脚本会执行您实施的逻辑。
- 运行来自先前排队命令行的控制台命令。
如果想运行来自系统脚本的控制台命令,这些命令会在队列中的控制台命令之后得到执行。
-
如果在命令行中指定了render_manager_check_shaders控制台变量,会对着色器进行编译及缓存。例如如果在系统脚本的init()函数中加载自定义材质库,在此步骤中所有的这些库会得到编译且不必在运行时加载这些库。
然而,着色器的缓存文件很大,如果在引擎初始化时加载此文件会降低引擎的性能表现。
在完成所有步骤以后,引擎进入主循环阶段。
主循环#
Unigine进入主循环时,引擎的所有动作可划分为三个阶段,这三个阶段会在一个周期内一个接着一个执行,它们分别是:
在性能分析器中,主循环所花费的总时间会使用Total计数器进行显示。
主循环的执行顺序有2种模式:
单线程模式#
大多数情况下,Unigine会较快速地在主循环中完成所有阶段接下来GPU才能真正地对帧进行渲染。这也是使用双重缓存的原因:通过将GPU缓存(前后方的GPU)互换成执行渲染的GPU从而能更快地对帧进行渲染。
当所有脚本都得到更新升级,所有CPU上的计算都已完成时,GPU仍然会对在CPU上计算的帧进行计算。因此CPU必须一直等到GPU完成对帧的渲染以及渲染缓存得到调换。这段等待时间会在性能 分析器 的Present计数器中显示出来。
如果Present时间太长,可能就意味着存在GPU瓶颈,需对美术内容进行优化。但如果帧率一直较高,这意味着仍有可用的CPU资源可对更多的数字进行处理。
自所有脚本得到升级更新,所有CPU上的计算得以完成到GPU完成对帧的渲染期间需要多长时间取决于是否启用垂直同步(VSync), (也可通过系统菜单得以完成)。 如果启用,CPU会等到GPU完成渲染且执行垂直同步之时。这样的情况下Present 计数器的值会较高。
4种方案演示如下:
- 前两种方案演示未启用VSync时,帧的计算和渲染(两种情况下监视器的垂直回扫会被忽略):
- 在第一种方案中,与GPU对帧的渲染相比,CPU对帧的计算更快。因此CPU会一直等待GPU (Present的时间值高)。
- 在第二种方案中,与GPU对帧的渲染相比,CPU对帧的计算更慢。因此GPU不得不等待而在等待期间CPU完成计算。这种情况下Present显示的时间值小。
- 方案3和方案4演示当VSync启用时 (考虑到监视器垂直回扫),帧的计算和渲染:
- 在第三种方案中,与GPU对帧的渲染相比,CPU对帧的计算速度更快,因此CPU不得不等待GPU。 然而,这种情况下,CPU和GPU都得等待VSync。
- 在第四种方案中,与GPU对帧的渲染相比,CPU对帧的计算速度更慢。这种情况下GPU既需要等待CPU完成自身计算任务,还需要等待VSync。
更新#
更新部分包括下列内容:
-
开始计算的FPS值。
FPS的计算随同第二张渲染帧一同开始,而会跳过第一帧。
- 在上一帧中被调用的所有搁置的控制台命令会得到执行。在update()周期的一开始便会执行这些命令,但在脚本得到更新之前,因为这些命令可能会妨碍到当前的渲染进程或物理计算。
-
如果video_restart控制台命令先前就得到执行(并不在当前的更新阶段中),世界对象的着色器得以创建。
在重启视频时(例如更改视频模式时),应用程序窗口会调用世界对象的destroy()方法,系统,编辑器脚本以及插件。但此操作不会在当前更新阶段中执行,而在更早的阶段内执行。
- 调用插件update()函数。在调用期间会发生什么仅取决于此自定义函数的内容。
- 处理一切与编辑器相关的GUI和逻辑的编辑器脚本得到更新。
-
系统脚本得到更新。默认系统脚本的 update() 函数会执行下列操作:
- 系统脚本处理鼠标操作,在点击鼠标时(默认情况下)是否抓取鼠标,有时不发生移动时鼠标光标是否应消失(通过MOUSE_SOFT 的定义进行设置)或者不为系统所处理(通过MOUSE_USER 的定义进行设置,其允许通过某个自定义模块对输入进行处理)。
- 主菜单逻辑得到更新(如果未设置MENU_USER定义)。
- 对其它与系统相关的用户输入进行处理。例如,如果需要保存或还原世界对象状态时或者对当前Unigine窗口的内容进行截屏。
- 如果初始化 GPU Monitor插件(设置了HAS_GPU_MONITOR定义),插件输出会在应用程序窗口中得到显示,额外的控制台命令变为可用状态。
- GUI和环境声音资源得到更新。
- 如果加载了世界对象(可从命令行或通过系统脚本完成此操作 ), 世界对象脚本 就会得到更新。在世界脚本的update()函数中,可使用应用程序的逐帧行为进行编码(在此处查看详情)。
世界对象和世界对象的脚本会以下列顺序进行更新:如果有地话,物理和非渲染游戏逻辑会在flush()函数中被单独执行,使用固定的帧率调用此函数(而每帧都会调用update()函数)。
- 执行世界对象脚本的 update()函数:更新节点参数以及对用于非物理节点的转换进行设置等。
- 世界对象中的节点状态得到更新(大多数情况下用于可见节点):播放蒙皮骨骼动画,粒子系统生成新的粒子等。触发的世界回调会被添加到堆栈上(这些回调稍后会得到执行)。
- 调用世界对象脚本的render() 函数。
- 调用编辑器脚本的render()函数。
- 在有必要的情况下(在此处查看详情)执行系统脚本的render()函数。其可访问在节点状态上得到更新的数据并相应地在相同的帧内纠正行为。
- 更新世界对象中的空间树。
- 调用插件的render()函数,在此函数存在的情况下。
在性能 分析器中,更新阶段的总时间会使用Update计数器进行显示。
渲染#
只要更新阶段结束,Unigine才可开始对世界对象进行渲染。同时,物理和游戏的逻辑也会得到计算。这种方法可有效实现CPU与GPU之前的平衡加载,这样才能在基于Unigine的应用程序内使用更高的帧率。
渲染阶段在单线程模式中的详细工作方式如下:
-
Unigine对图形场景(世界对象)和声音场景进行渲染,因为这两种场景应在当前帧中。图形场景被发送给GPU,而声音场景被发送给声卡。CPU一完成数据的准备工作并发送渲染命令给GPU,GPU就会忙于对帧的渲染。
在性能分析器中,渲染阶段的总时间会通过Render计数器进行显示。此后,CPU处于空闲状态,这样就可以在进行所需计算的同时对其进行加载。
- 物理模块调用插件的flush()函数,在此函数存在的情况下。
-
物理模块调用将被执行的世界脚本flush()函数。在此函数中您可以:
flush()函数不会为每帧调用。物理模块有自己固定的帧率,这种帧率并不依赖于渲染帧率。对于每一个这样的物理帧(或标记),会执行大量的计算迭代(在每个迭代之前调用flush())。
-
物理模块得到更新:内部物理仿真启动。在此步骤操作期间,Unigine会为所有对象执行碰撞检测,这些对象具有物理实体和碰撞形状。
在性能分析器中, flush()和物理仿真的总时间通过Physics计数器进行显示。 - pathfinding模块得到更新。在线程性能分析器中,pathfinding的总时间通过PathFind计数器进行显示。
- 插件的GUI(如果存在地话)会得到渲染。
- 最终在有需要的情况下所有的GUI得到渲染。在性能分析器中,接口渲染的总时间通过Interface计数器进行显示。
调换#
此阶段是主循环的最后一个阶段,其包含下列内容:
- 在此阶段如果已预先执行了 video_grab控制台命令,则所选取的截屏会被保存在所有应用程序数据所保存的文件夹中。
- 插件的swap()函数会被调用,在存在此函数的情况下。
- 使用渲染世界对象的物理同步。物理计算的结果会被运用到世界对象上。即在上一步中我们已经计算过拥有碰撞形状的物理实体的位置及朝向发生变化的方式(由于我们基于清除的逻辑或交互作用)。如今这些转换最终可被运用到节点上,即渲染网格。
由于物理的同步跟随着渲染阶段之后,运用的物理转换将仅在下一帧中的屏幕上可见。
- 在性能分析器中显示的数值会得到更新。
在swap()完成之后,应用程序窗口会以如上所描述的方式启动GPU缓冲区交换。
多线程模式#
下列方案演示启用VSync时,帧的计算和渲染:
在多线程模式中,在某些点的执行管线会以不同的方式得到执行:
-
在更新阶段,世界对象脚本使用所有可用线程对可见节点进行更新,而不是逐一地进行更新。那样,Unigine会分析节点的依赖性,这样才能确保所有操作的线程完全安全。world_threaded命令对模式进行设置:多线程模式或单线程模式。
1个父类下的子节点总是会在1个线程中得到更新。为了能从多线程的优势中获益,需要大量计算的节点(比如粒子系统)应具有不同的父类或无父类。
当每个节点参考被处理为无父类的根节点时,会自动对其进行优化以便用于多线程。 -
当然在更新阶段的末期,多线程物理和寻路会在其单独的线程中开始进行更新。接下来才能在所有可用线程中执行自己的任务并同时进行渲染。使用physics_threaded 和pathfind_threaded命令来选择计算模式。
需要大量计算的节点(比如布匹和绳索)不应只具有一个父类,否则这些节点会在一个线程中得到更新。
- 此方案中的调换阶段包括等待所有线程完成其自身任务。在此之后,对将物理和寻路线程进行同步处理并将计算运用到世界对象上。
主循环迭代结束。
关闭#
Unigine停止执行应用程序时,其会执行下列内容:
- 如果运行编辑器脚本,其调用shutdown()函数。
- 世界脚本调用 shutdown()函数。
- Unigine调用系统脚本的shutdown() 函数。例如此处若是存在基于Unigine的应用程序,便可使用信用设置一个将要显示的启动画面。
- 调用 插件的shutdown()函数。
- 如果用户对任意设置进行了调整,引擎会将作出的变化保存到配置文件中。
- 终止世界对象,声音及文件系统流程。
- 所有为Unigine分配的资源都得到释放。
- 所有为应用程序设置的路径被破坏。
- C++/C# API is shut down.
- 关闭内存分配器。
渲染及物理帧率间的相互关系#
如之前所提及一样,渲染帧率会通常会发生变化而物理仿真帧率为固定值。这意味着使用不同的频率调用来自世界对象脚本的update() 和 flush()函数。
上述图片对物理帧率为60FPS时,所发生的事情及渲染帧率发生的变化进行了描述。通常情况下,可能有三种情况:
- 渲染帧率非常高。这种情况下会为2张帧或更多的帧执行一次物理计算。这样不会产生任何问题,因为移动物体的位置会在两个计算之间进行穿插。
- 渲染帧率相同或几近于相同。为每张帧进行一次计算也行;物理会与图形保持相同的步伐,反之亦然。
- 渲染帧率更慢。这正是问题开始出现的地方。首先如图所见,应为每张帧进行两次或更多次的物理计算,这样做并不会加速整个渲染过程。第二,不要将物理帧率设置地太低,因为这种情况下的计算精度会大大被降低。
同样将物理帧率设置地太高也没什么意义,因为如果计算耗费的时间超过40 毫秒,将无法完全对物理进行计算。因此如果需要额外的迭代,会跳过这些迭代。
将渲染FPS限制为物理FPS#
如果渲染FPS更高,渲染FPS可被限制为物理FPS。因为engine.physics.isFixed(1)标记在代码中得到设置。这样的FPS限制允许为每张渲染帧的物理进行计算(而不是进行对其进行插值,在将此标记设置为0的情况下)。这种模式中如果有非线性速度就不会对物理对象进行抽动。(如果渲染的FPS比物理FPS低,此标记无任何影响。)