跨平台渲染引擎之路:Urho3D分析与数据驱动
前言
前面我们分析了 bgfx 这个项目,从这个项目里面获得了许多我们不清楚和想了解的信息,而因为在初期阶段我们更多地是想要搭建出一个能够自由切换渲染后段的渲染引擎,而对于其他如粒子等诸多系统倒不是那么在意,因此对于Urho3D的分析就会更加简单化,只主要侧重在Urho3D的主要渲染流程上,以及一些在过程中接触到的零零散散的事项。
在这段时间里面有接触数据驱动,在目前的许多引擎中都是采用这种方式来进行开发的,因此会在本文中加入对数据驱动的一些内容。
Tips:该文章基于 Urho3D 的 1569ef3247999ba4304e991a1f510826a73268b7(SHA1值)提交进行分析
使用方式
我们以 Urho3D 的 Hello World 为例,简单介绍下该项目的使用方式:
1 | URHO3D_DEFINE_APPLICATION_MAIN(HelloWorld) |
从官方提供的Sample我们可以看到首先所有的Sample都是集成自Sample这个基类的,而Urho3D会自动调用 Start() 接口,我们可以在接口里面做一些初始化、订阅事件的操作,比如订阅更新事件,在接收到事件的事件做动画、更新数值等等,而一些效果如视图(上例中的 Text)则都有定义对应的类和子系统来实现。
渲染主干
Urho3D提供了一个 Application
来控制程序的初始化、渲染等流程,在他所提供的Demo中通过 URHO3D_DEFINE_APPLICATION_MAIN
来传入Demo程序,并在 Application::Run()
函数中开始渲染循环。在 Application::Run() 中,当引擎未退出时,便会不断调用 engine_->RunFrame()
进行渲染操作,这便是Urho3D执行每次渲染操作的入口处。
首先我们带上 engine_->RunFrame() 的代码:
1 | void Engine::RunFrame() |
我们发现 Uhor3D 这个引擎自身划分了多个System,如 Time、Input、Audio等等。该接口的内部流程为获取到各个必要的子系统后,调用 time->BeginFrame(timeStep_)
来传入当前的时间戳;接下来是一些音频方面的操作,这里暂不做关心;然后直接调用 Render()
接口:
1 | void Engine::Render() |
Render() 该接口会先调用 Graphics 子系统的 BeginFrame 接口,该接口调用成功后,接着调用 Renderer 与 UI 子系统的 Render() 接口(这里可以看出Urho3D把UI如Text、Button之类的视图渲染是独立开来的,这样会更加方便控制UI的绘制和层级位置等),最后调用 graphics->EndFrame() 结束该帧渲染。
再回到 Engine::RunFrame() 接口内继续调用 ApplyFrameLimit()
,该接口用于获取下一帧渲染的时间戳以及到下一帧渲染所需要的睡眠时长,其内部是有针对平台进行适配的,具体还没有细看,但是看来是可能存在一定的坑的,后续落地时需要留心;最后该接口调用 time->EndFrame() 结束流程。
接下来我们来针对上面的主要流程,梳理一下每个接口内部的简要内容(代码占篇幅较多就不再贴出来了)。首先是 time->BeginFrame(timeStep_)
接口,该接口内部主要通过 SendEvent 发送 E_BEGINFRAME
事件,该事件携带了 VariantMap 存储的时间戳、当前第几帧之类的数据。
而在 Graphics::BeginFrame() 等接口中我们看到 Urho3D 是直接调用的 OpenGL 渲染接口,并没有对多个渲染后端进行一层抽象封装,即目前来看并不支持多种渲染后端切换的功能。
再到比较关心的 Renderer 子系统的 Render() 接口,通过大致浏览了一下代码,可以发现该接口内部主要也是通过遍历操作一个views_
这个成员变量中存储的每个 View 对象进行渲染的。
而在 UI::Render(bool renderUICommand)
接口中,UI 类又定义了 void Render(VertexBuffer* buffer, const PODVector<UIBatch>& batches, unsigned batchStart, unsigned batchEnd);
这个内部接口,在前面的Render接口中会多次调用后面的Render接口从而实现 non-modal(非模式) batches、debug以及modal(模式) batches 绘制。
在这两个系统的Render接口中有一个共同点,我们都可以看到 Urho3D 通过一个WeakPtr来持有一个Graphics,该变量命名为 graphics_
,我们可以通过该对象来设置混合模式、渲染目标、是否需要写入深度数据等等配置。
Graphics:子系统之一,主要用于管理程序渲染窗口、渲染状态以及GPU资源等,查看该类我们能看到诸如VertexBuffer、是否开启深度测试、设置着色器等等都在这里面,而 UI 与 Renderer 系统在渲染过程中会从这个子系统里面获取诸多渲染参数、资源等,再根据这些来进行实际的渲染操作。
Batch:在 UI 定义的内部 Render 接口的参数上我们看到几个batch相关的参数,我们找到一个 Batch 类的定义,类注释上写着该类是已队列化后的 3D 几何 draw call 集合,目前对这块还不是非常熟悉,也暂未深入研究,但是该类定义了一个接口
CalculateSortKey
,这个接口的命名与内部实现与 bgfx 的 SortKey 和 Encoder 的 submit 中做的事情有些相似,应该都是在做排序打包之类的优化
Urho3D 更多
Urho3D 这边个人自己研究的就差不多这些,如果有感兴趣的同学的话可以再去看一下 Urho3D 引擎框架 这边有几篇对 Urho3D 的几个方面做了简单介绍的系列文章,不过版本可能有些旧了,后面在落地实现的过程中不可避免会需要对Urho3D的各方面再做深入研究和借鉴,会再回过头来补充这块内容。
数据驱动
这里可以看一下知乎上的问答 [怎么理解游戏开发中的“Data-Driven Design”?(https://www.zhihu.com/question/26775352),这里整理下自己觉得最有收获的内容,还有在这个过程中自己去找的其他一些资料。
数据驱动,简单而言,就是在设计中,把“数据”和其处理过程分离开,这和设计模式中的封装变化其实是一样的,因为数据实体总是在变化,而同一类数据的处理方式却是不变的,我们可以把主要业务逻辑都放到配置中,再通过程序解释执行配置。ECS是游戏开发中最典型的数据驱动。
数据驱动编程的核心出发点是相对于程序逻辑,人类更擅长于处理数据。数据比程序逻辑更容易驾驭,所以我们应该尽可能的将设计的复杂度从程序代码转移至数据。
数据驱动编程中,数据不但表示了某个对象的状态,实际上还定义了程序的流程;OO看重的是封装,而数据驱动编程看重的是编写尽可能少的代码。
很多设计思路背后的原理其实都是相通的,隐含在数据驱动编程背后的实现思想包括:
- 控制复杂度。通过把程序逻辑的复杂度转移到人类更容易处理的数据中来,从而达到控制复杂度的目标。
- 隔离变化。例如某些消息系统,每个消息处理的逻辑是不变的,但是消息可能是变化的,那就把容易变化的消息和不容易变化的逻辑分离。
- 机制和策略的分离。和第二点很像,机制就是消息的处理逻辑,策略就是不同的消息处理。这和 UNIX 哲学之一「提供机制,而不是策略」是相吻合的,因为策略经常改变,而机制相对固定,在数据驱动编程中我们就可以使用数据来应对「策略」的变化,而使用数据驱动编程实现的程序就可以看做是我们所提供的「机制」。
那么数据驱动设计又有哪些表现形式呢?在游戏程序中,“数据驱动”包括但不限于:
- 各种配置表
- 各种图像资源, 比如各种贴图动画等
- 空间状态信息,比如Unity3D中,一个对象的位置状态等等
- 游戏脚本,比如魔兽世界插件
语言描述可能有点抽象,评论里也有人用代码来阐述了数据驱动设计这一理念
首先我们来介绍面向对象和面向过程两种思路:
A和B这两个class做的事情是完全一样的,但是A的实现是标准的OOP
开局,而B的实现则有点像A的“本质版”,也就是手动传递参数的面向过程
。那么这两种方法的设计有什么区别呢?区别就在于,Execute函数是否是在“类里”
的,类型A的Execute必须跟随实例化的数据走,而类型B的函数则是独立存在的,举个例子吧,假如这个类的作用是完成了理发这个工作,那么A就是直接去理发店,交钱,理发,而理发店就是一个对象,B则是将理发这个过程和你的头发这个数据分离开来,理发这个过程是独立的,要自己用推子去推,用剪刀剪,而头发又是独立的,这个过程就变成了“用剪发的技术,去剪我自己的头”。可以说这就是面向对象和面向过程的本质区别之一了。那么前者和后者分别有哪些区别呢?从字面上来看区别就在于B的实现要比A麻烦的多,实际上也确实如此,因为去理发店理发肯定要比自己用剪刀剪要方便很多,代码上调用是这样的:
如果你是第一次读这段代码,那么这两种用法高下立判,A只需要调用Execute函数,最为直观且方便,B则绕了一大圈,用了一个静态函数再传数据进去,可读性奇差。但是如果我们调用的不是1个A或B,而是100个呢,这时候情况就不一样了:
因为后者是静态的,所以我可以直接把for循环封装到静态函数中,而前者是动态的所以只能最外层手动循环调用,那么前者就要啰嗦许多了。与许多其他开发领域不同,游戏逻辑/场景/渲染的开发可能会有以下几个特点(可能会,因为实际受游戏类型影响):
- 组件及其逻辑重复度高
- 数据量大且散
- 逻辑多变,容易引起耦合
第一点比较容易理解,要渲染模型并使模型有物理效果,那么免不了这个模型要有一个Renderer组件和一个Collider组件,因此场景里会出现大量的Renderer, Collider,而且每个组件之间的差别也只是数据不同而已,实际上执行的逻辑和结构都是完全一样的。那么这时如果我们令这两个组件都只包含数据,而逻辑统一执行,代码执行就要美观许多。
第二点数据量大且散,继续沿着第一点讲,场景中的Renderer和Collider组件都是靠美术手动摆放,位置很不确定,而且大概率上层逻辑是不会扫描全图去遍历的,这种时候想要获取到某个物体的信息就比较麻烦,只能通过类似Hashmap的方法去反向追查,但是如果一开始这些数据就不是独立的个体,而是被一个全局的单例控制,那么寻找其中一个就会变得十分容易。
第三点逻辑多变,比如一个FPS游戏,计算子弹需要涉及物理,UI,网络同步等多方面,还要考虑单机模式,在线模式等等多种模式。这种多变性使得我们常常需要把要执行的逻辑抽象成一个接口,或函数指针。在面向对象设计中这一点是非常难的,因为我们只要逻辑不要数据,而一个对象常常既包含数据又包含类型。
所以我们就可以用函数指针让逻辑变得抽象,执行者并不关心逻辑是什么,只关心逻辑有多少,而逻辑并不关心谁执行的自己,只关心有哪些传入数据,数据则什么都不用担心,这三点也一一对应了上方说的三点,所以可以说这是一种适合许多游戏开发场景的设计方法:
上图代码,无论func究竟指向了哪里,传入的参数是不变的
ECS
在上面我们有说到,ECS是游戏开发中最典型的数据驱动,它相比OOP可以很容易地添加新的复杂的实体,也可以很容易地在数据中定义新实体,并且效率更高。
ECS,即 Entity-Component-System(实体-组件-系统) 的缩写,实体就是指你的游戏物体,它将隐式的被定义为一组组件的集合。这些组件都是纯数据(也就是没有方法)并将由系统里面的函数来进行操作。
ECS模式遵循组合优于继承
原则,游戏内的每一个基本单元都是一个实体,每个实体又由一个或多个组件构成,每个组件仅仅包含代表其特性的数据(即在组件中没有任何方法)。例如:移动相关的组件 MoveComponent 包含速度、位置、朝向等属性,一旦一个实体拥有了 MoveComponent 组件便可以认为它拥有了移动的能力,系统便是来处理拥有一个或多个相同组件的实体集合的工具,其只拥有行为(即在系统中没有任何数据),在这个例子中,处理移动的系统仅仅关心拥有移动能力的实体,它会遍历所有拥有 MoveComponent 组件的实体,并根据相关的数据(速度、位置、朝向等),更新实体的位置。
实体与组件是一个一对多
的关系,实体拥有怎样的能力,完全是取决于其拥有哪些组件,通过动态添加或删除组件,可以在(游戏)运行时改变实体的行为。
实体组件系统包含三个部分:
- 组件(components):一个标准的组件只包含一些基础数据属性,它不包含任何的游戏逻辑,在定义你自己的标准组件时,你的组件应该只有一些原始的属性或者数据对象.
- 实体(Entities): 一个标准的实体时一些 组件的集合
- 系统(Systems):系统通常是对一组共享的实体(Entities)进行迭代操作
让我们来做一个简单的平台游戏:拼字游戏。在这个假设的例子中,你需要处理 物理碰撞、绘制图形以及驱动字符移动等内容,为了完成这个平台游戏,我们可以采用以下组件:
Motion: 用于驱动字符如何移动
Spatial: 用于绘制字符如何出现
Physics: 用于处理字符的碰撞检测、应用物理力等
PlayerInput:处理玩家的输入
至此,我们已经定义了一个“玩家实体“,然后在运行时添加这些组件。接下来我们创建系统去操作各种组件,我们的系统可以这样定义:
- RenderSystem(渲染系统),作用于包含有物理和空间组件的任何实体。我们利用物理组件,以确认字符的空间位置。利用空间组件,以确定如何绘制字符.如果一个实体没有物理组件或者空间组件中的任何一个,那么RenderSystem将不会处理它。
- MotionSystem(运动系统), 它作用于包含有物理和运动组件的任何实体。我们利用运动组件,以确认哪些运动(left, right, jump, 等等)被施加到实体上.我们利用物理组件,在实体上施加物理力效果。如上所示,如果一个系统没有包含 物理或运动组件中的一种,那么运动系统将不会对它进行处理。
- PlayerInputSystem(玩家输入系统) ,它作用于包含有运动和玩家输入组件的任何实体。玩家输入组件可以是“本地”或者”远程“(网络或其他方式),对于一个“本地”玩家实体而已,按键更新运动实体,对于一个联网玩家实体而言,网络操作更新运动实体。当本地玩家 按下 LEFT 键,PlayerInputSystem 执行并检测更新,更新运动组件,MotionSystem 执行并检测到实体运动向左,然后向实体施加一个向左的力,RenderSystem 执行并检测实体当前的空间位置,根据空间组件将其绘制(可能包含纹理及动画)。
那么为什么我们不使用传统的OOP呢?
实体组件系统的一个优点是,它帮你拆分一件非常复杂的事物,你的代码可以专注的做某一件事,此外它可以防止类爆炸
,当你的游戏架构越来越复杂的情况下,你可以仅仅添加或删除组件,而无需面临重大的重构。
在层次上,两个不同层次的类需要用到共性的方法,它们就需要来自相同的超类,较少的层次对整个游戏的架构上来说是好的
,然而随着时间的推移,OOP会将此带入深渊。
使用实体组件系统可以分解体系结构,为我们添加、扩展、删除操作提供了一个非常方便的绝径,拿上面的例子来说,我们现在想造一些敌人,我们可以创建一个组件 EnemyAI,然后我们创建一个新的实体,这个实体包含:
- Motion component 运动组件
- Physics component 物理组件
- Spatial component 空间组件
- EnemyAI component AI组件
然后我们创建 EnemyAISystem 来操作包含有运动、物理、控件及AI组件的任何实体,该系统定义了何时以及如何移动相关的实体,并在process方法中更新运动组件的值,而我们的渲染系统及运动系统无需做任何变化。
文字太抽象了?那我们来用图说话
在传统方法中,实现游戏实体是使用面向对象编程(object-oriented programming)的方法。每个实体是一个对象,它非常直观的允许基于类的实例化系统并让实体可以通过多态(polymorphism)来进行扩展,但是这会导致庞大固化的类继承体系。随着实体数目的增长,越来越难以在整个类继承体系中放置一个新的实体,特别是在该实体需要大量不同类型的功能的时候。在下图中,你可以看到一个简单的类继承体系。一个静态敌人(static enemy)类与这个类继承树并不匹配。
那么当我们改用组合的方式来进行:
再结合上面的种种场景分析,相信大家应该都有所理解了~
组件
一个组件可以用C语言中的结构(struct)来进行类比。组件没有自己的方法并且只能用于存储数据,不能对组件本身采取什么行动(也就是对组件调用方法,让组件来执行某些行为)。在一个典型的组件实现中,每个不同类型的组件将会继承自一个抽象的组件类(abstract Component class),这个抽象的组件类会提供在运行时获取组件类型以及包含的实体的方法。每个组件描述了一个实体的某些方面和它的参数。只是单独拿出一个组件出来看是毫无意义的,但是如果把组件和实体以及系统一起使用时,它们将变得极其强大。空的组件对于标记实体也是非常有用的。
一些组件的例子
- 位置组件,用 (x, y)来描述。
- 速度组件,用 (x, y)来描述。
- 物理组件,用(物理体)来描述。
- 精灵组件,用(图像, 动画)来描述。
- 生命属性组件,(用生命值) 来描述。
- 角色组件,用(名字, 等级) 来描述。
- 玩家组件,内容为 (空)。
实体
实体是某种已经存在你的游戏世界中的物体。再强调一次,实体不仅仅只是一系列组件。因为他们是如此的简单,大部分的实现里面不会把实体定义为一个具体的数据。相反,一个实体会有一个独特的ID,所有组成这个实体的组件都会在组件的内容记录这个ID。实体其实是将用ID标记的组件隐式的聚合(implicit aggregation)起来。如果你愿意的话,可以允许实体中的组件可以动态的添加或者删除。这将使得你可以动态的“变动(mutate)”实体。举个例子来说,你可能会有一个法术可以让这个法术的目标冻结一段时间。要实现这个功能,你可以简单的移除速度组件。(我觉得这个例子举得是有点问题的,如果速度组件移除了,那么在更新物体位置的时候相对应的函数计算部分根本就没有办法取到速度组件,进而没有办法取到速度的值,这样确实是没有办法进行位置的更新,但是这要依赖于代码的具体实现,如果代码没有做足够的保护,将会导致崩溃,至少会导致对应函数的退出。一般来说,组件的动态添加是为了实现某个特殊的赋予功能,比如我有一个法术可以让法术的目标变形,那么我就将变形组件添加进去,这样实体才能够变形。等变形法术结束的时候,我就可以将变形组件从实体中移除出去。一般实现中动态删除往往是针对动态添加的组件
,将一些预设的组件移除去要么让代码不稳定,要么增加编写的时候的复杂度)。
一些实体的例子
- 岩石实体, (有位置组件、精灵组件)。
- 木箱(Crate)实体,(有位置组件、精灵组件、生命属性组件)。
- 标记(Sign)实体, (有位置组件、精灵组件、文本组件)。
- 球(Ball)实体, (有位置组件、速度组件、物理组件、精灵组件)。
- 敌人实体 ,(有位置组件、速度组件、精灵组件、角色组件、输入组件、人工智能组件)。
- 玩家实体, (有位置组件、速度组件、精灵组件、角色组件、输入组件、玩家组件)。
系统
你可能已经注意到了在前面的文章部分没有以任何形式提到游戏逻辑。因为这都是系统的任务,将由系统来处理所有的游戏逻辑。系统是在一组相关的组件上进行操作,一般来说所谓相关的组件是指属于同一个实体的组件。举个例子来说,角色移动系统可能会操作位置组件、速度组件、碰撞组件和输入组件。在逻辑顺序中每个系统将在每帧更新一次。举个例子来说,要让一个角色跳起来的话,首先要检测输入数据的keyJump成员。如果对输入数据的keyJump成员检测返回的结果为真的话,那么系统将去查看包含在碰撞数据的对应信息并检测当前角色是否站在地面上。如果检测的结果返回为真的话,它将设置速度组件的成员变量y来让角色真正的跳起来。
因为只有在整个组件集合全部出现的时候,系统才可以对组件进行操作,所以组件隐式的定义了一个实体可能会具有的行为。举个例子来说,如果一个实体只有位置组件但是没有速度组件,那么这个实体将会一直静止。因为运动系统需要使用位置组件和速度组件的信息,但是这个实体没有速度组件,所以运动系统没有办法对实体的位置来进行操作。给这个实体添加一个速度组件将使得运动系统可以对这个实体进行作用,进而让整个实体运动起来并受到重力的影响。这种行为可以被理解为“组件标签”(如同之前解释过的那样),可以在不同的上下文中重用组件。举个例子来说,一个输入组件定义了一些通用的标签比如跳跃、移动和射击。添加一个空的玩家组件(Player component)将向玩家控制系统(PlayerControl system)标记实体,这样的话,输入数据将根据控制器的输入进行填充。
一些系统的例子
- 移动系统(使用了位置组件、速度组件) – 会使用速度来更新位置。
- 重力系统(使用了速度组件) – 由于重力引起了加速度进而会改变运动速度。
- 渲染系统(使用了位置组件、精灵组件) – 会对精灵进行渲染。
- 玩家控制系统(使用了输入组件、玩家组件) – 根据控制器的输入来设置由玩家控制的实体的输入信息。
- 机器人控制系统(使用了输入组件、人工智能组件) – 根据人工智能代理的信息来设置被人工智能控制的实体的输入信息。
实现
上面对组件、实体、系统的详细介绍转自 干货来袭丨这篇文章帮你快速了解组件-实体-系统 ,因为接下来主要会先实现类似 bgfx 的渲染API分装,所以暂时对这块也没有过多的补充,更多的是让自己有一个编程观念上的转变,以避免后续继续使用OOP来进行开发导致踩了大坑。
实现的话可以看下同系列文章 实现组件-实体-系统
总结
本文主要整理介绍了两个方面的内容,一个是Urho3D自身Demo的使用方式、渲染主干以及该引擎的一些特点,从这个游戏引擎的代码出发,我们发现其内部其实就是遵循数据驱动编程理念的,也就是存在明显的ECS模式,因此针对这块我们去了解了一些资料做了整理,以便能帮助我们后面的开发。