引擎叫:StarEngine
Github:
这次的调整,主要是实装了“面向数据”和“异步框架”两个特性,以及引发的一系列重构。
这篇文章介绍了引擎的整体架构,会比较长技能特效,有笔误请见谅。
比较朴素。只完成了引擎框架,画面请无视面向数据(Data-Oriented)
StarEngine并没有运用业界流行的ECS(Entity-Component-System)框架,而是开发了全新的基于Graph的面向数据框架。
ECS存在以下一些问题:
相比之下,Graph与ECS有很多相似性,理念是相通的。
Graph与ECS有着一一对应关系
Graph与ECS的区别在于我们通过泛型实现复用,而不是依赖注入。
我们的实现有以下的特点:
前人对Graph的研究非常透彻,归纳出了以下一些基本Concepts:
Graph基本分类
我们可以选择合适的数据结构来实现相应的Concept,以达到最优的性能。一般的ECS只有一种实现,不具备如此的灵活性。同时由于boost的泛型设计,具有统一的接口。这样彻底地分离了接口与实现,使得切换实现的成本非常低。boost有着大量的图论算法比如DFS、BFS,省下的开发量非常可观。
在此基础之上,StarEngine引入了更多的Concept,来解决引擎开发中的常见问题。
通过以上6种Concept的自由组合,我们能实现引擎中的绝大部分图状功能。
异步框架
上面我们已经用Graph替代了ECS,还有多线程的问题。
Unity的DOTS通过Jobs系统实现了这点,我们也需要一个多线程/异步框架。
这里我们使用的是C++23 Executors,具体实现是libunifex。
C++23 Executors
C++23的Executors包罗万象,很难概括Executors究竟干了些什么,这里尝试着罗列一下
总之,通过Executors,我们可以大幅简化异步、多线程的编写。很多写法在以前是不可想象的。
Graph+Executors
通过Graph+Executors,我们在功能和性能上是可以和DOTS+Jobs抗衡的,毕竟我们是原生C++配合泛型的零开销抽象,打不过Jobs+Burst Compiler是有点说不过去的。
开发难易度见仁见智,C++语言更难,但心智模型简单。C#语言简单,但框架更复杂。
Executors写起来差不多是下面这种感觉。
利用libunifex实现的app流程
好吧,我承认C++确实有点难懂!我第一次看executor的代码也宛如天书,但理解了之后还是比较简单的,比任何C++异步框架都要简单。
executors的表达力非常之强,整个app的框架逻辑寥寥数行就能完成。像这样的逻辑,让我用asio写很可能是写不对的。
图形引擎新特性
在有了Graph框架支持后,能做的功能就很多了,首先是图形引擎部分。
我们的图形引擎由两张Graph组成,分别是执行图(Executor Graph)与内容图(Content Graph)。
执行图 Executor Graph
执行图是对硬件平台的抽象。
现在的游戏对图形引擎非常苛刻,有以下一些需求必须满足。
这些需求是互相矛盾的,一种硬件架构不能满足全部需求。引擎需要对硬件平台进行适当抽象、使得其可更改、可配置。
Executor Graph示意图
首先Executor Graph满足Ownership Graph概念,这是对硬件父子关系的抽象。
其次Executor Graph是一个网络,不同节点(Vertex)之间可以传输数据(带宽不同)、可以进行Gpu/Cpu同步。
有了Executor Graph,我们可以根据不同平台、不同配置、不同算法、不同用途,自定义合适的硬件架构。比如:
在合理的抽象上,Executor Graph隐藏掉实现细节,用户可以通过声明式的编程加以控制,自动化、简化开发。
内容图 Content Graph
构成图形引擎的另一张图是内容图(Content Graph)。
Content Graph是对各类内容(Content)的抽象,其建立在Executor Graph之上,相关性很高,因为资源都是存放在硬件上的。
每个Content都有各自的居住证,在Executor Graph的不同节点上创建。Content可以通过UUID索引,所以是个UUID Graph。
Content互相之间有引用关系,整体构成一个DAG(有向无圈图)地图场景,包含以下这些内容:
这里解释下几个特别的Content:
Pipeline (PPL):类似Unity的Shader,决定了物体在哪些Render Queue用哪个Shader Program渲染。包含了相应的PSO管线状态。
Root Signature Graph (RSG):包含所有Shader Program以及对应的Root Signature,描述了各个Descriptor的布局、更新频率、来源。整体是个树状结构。
Resource Graph (RESG):跟踪了所有可读写资源的状态,根据不同的硬件Tier、用途、驻留,选择合适的分配策略。
Value Graph (VG):保存了GPU需要用到的数据,结构类似JSON,是个Addressable Graph。用途类似Unity的Shader.SetGlobal。
Render Dependency Graph (RDG):描述了渲染管线的Pass/Subpass流程、资源的状态转换、在哪些Executor Graph节点运行、同步等信息。
Render Queue Graph (RQG):描述了整个场景的RenderQueue排序,是个树状结构。每个节点会绑定一个Root Signature Graph节点,是多对一的关系。
Render Graph (RG):最终的渲染任务,是Render Dependency Graph的实例。决定哪些RenderQueue在哪些RenderPass渲染。绑定所有用到的资源、场景、数据。
与Frame Graph的区别
我们的Render Graph是离线制作的,这点与Frame Graph动态计算不同。Render Graph一般只是Frame Graph的子图,不是一帧用到的所有Render Pass都拿来一起优化。这有以下一些原因:
Render Graph与Render Graph之间的数据交换、状态跟踪,由Resource Graph负责。由于不是全局优化,性能可能达不到最优,但这个取舍我觉得可以接受。
我们通过Task Graph组织需要用到Render Graph,比如ShadowMap计算、场景渲染、Post-Process渲染等。Task Graph需要每帧动态构建,但粒度比较粗,可以包含CPU任务,相当于Unity的SRP。
设计上,我们希望通过Task Graph + Render Graph实现所有渲染算法。其中还有很多功能需要Scene Graph提供,比如物件剔除、地形管理、LOD管理等。这个随着版本的升级,会慢慢加入。
资产系统(Asset)
我们的资产管理系统无耻抄袭Unity,就不赘述了。
选择Unity的原因主要是更偏好UUID而不是Path。就像身份证和户口,身份证是唯一的,但户籍地址是会变更的。用UUID的话,资产移动会容易很多。
我们用UUID来实现硬引用(Hard link),用Path来实现软引用(Soft link)。
资产图(Asset Graph)
Asset Graph用于管理我们所有的资产,它拥有文件系统的树状结构。同时也是个DAG,用于表示索引关系。最后是个UUID Graph,可以用UUID索引。
Asset Graph大致有如下一些资产类型。
相比之前的Content Graph,大部分是相同的。
但是注意,这里的Asset和上文提到的Content是不一样的。Content是(Cook)处理后的产物,而Asset是原始的资产文件,比如图片、fbx等。有时它们结构上的区别会很大,比如Cook过程会把Prefab扁平化,或者把Mesh打碎成Cluster。
和Content Graph相比,这里少了Render Graph等运行时用到的内容,多了Shader Graph和Shader Module两个Shader相关的资产。
这里解释下Shader Graph和Root Signature Graph的具体用法。
Shader Graph/Shader Modules
我们的Shader Graph之前介绍过,当时没可视化看起来不直观主流游戏开发引擎,现在可以显示啦!
Shader Graph可视化
我们的优点是可以根据命名自动连线,编辑时大致排个序就行。
Shader Graph构成
比如上图中,节点自上而下一个个拼接,运行时自下而上逐个运行。编译成功就是能用的Shader Graph。
Shader Graph里的节点我们称为Shader Module,存放在Shader Modules里统一管理。
Shader Modules
为了生成最后的Shader Program,光有单个Shader是不够的。现代图形API有很强的全局性,需要通盘考虑所有资源利用与状态变换。Shader也是如此,需要整体布局Descriptor,提高命令提交性能。
我们通过Root Signature Graph来实现这一目的。
Root Signature Graph
Root Signature Graph (RSG)根据Descriptor的更新频率、Shader使用的集中度,构成一个树状结构。根节点的Descriptor更新频率最低(比如Per-Frame)、叶节点的更新频率最高(比如Per-Instance)。
在叶节点下,挂载各个Shader Program。
Root Signature Graph例子。两个RSG节点分别为Depth和Scene
RSG从叶节点的Shader Program中收集所有用到的Attribute(Buffer、Texture、Constant等),生成Constant Buffer、Descriptor布局,然后分配寄存器(register),最后构建Root Signature。
在构建完Root Signature之后,就可以生成真正的Shader Program了。
Shader Program例子
我们的渲染管线,是自下而上逐步构建的,从最小单元的Shader Module,组合成Shader Graph,再由Root Signature Graph统筹管理,最后交给Render Graph绘制。
这样做的好处是,每个部分是解耦的。
传统引擎往往要同时兼顾所有方面,又没有自动化工具,很容易出现笔误bug。
渲染部分差不多讲完了,我们来介绍下场景组织!
Prefab (Recursive)
我们的Prefab虽然叫Prefab,但没有抄Unity。
我们抄的是Pixar的USD(Universal Scene Description)。
USD是一个用于场景表达的库,主要用于影视动画制作,Unity和UE都(部分)支持。我们的实现也仅支持USD的一小部分。
USD主要由Layer构成,有点像Unity的Prefab。主要是为了解决以下一些问题:
举个例子,下图中shot_Anim动画层引用了Buzz.usd与Woody.usd,构成了动画场景。随后shot_FX.usd特效层通过sublayer的形式组合了shot_Anim.usd,并改写了一些动画。最后shot_Lighting.usd也通过sublayer组合了shot_FX.usd,进行最后的光照合成。
USD应用实例
这样做的好处是,Lighting、FX、Anim人员可以编辑自己的usd文件,互不干扰。减少了冲突,整个DCC流程会相当畅通。
游戏场景的编辑,其实也需要这样组织,开发中有时出现场景变动导致过场动画需要重做,这其实是可以避免的。
我们的Prefab对应USD的Layer,会尽量保持兼容性,目前只实现了Sublayer与Reference。希望以后可以直接导入.usd文件。
USD概念繁多,之前提到的Addressable、Path等概念都源自USD,非常值得学习。
脚本系统(实验中)
到现在为止,我们都是在用C++开发,但C++比较难写,我们引擎的写法又相对非主流(非依赖注入主流游戏开发引擎,不反转控制),不适合一般游戏逻辑的编写。再加上有热更新需求,脚本系统是必须要有的。
我们希望最终用户使用脚本编写游戏。
游戏的脚本语言选择其实不多,Lua、JavaScript(TypeScript)、C#是比较主流的脚本语言。
我们选择的是JavaScript,原因有以下几点:
具体的落地方案,我们选择的是集成React-Native。
React与React-Native
React
React与React-Native都是facebook的开源库。
React是JavaScript的UI库,主要用于Web前端。React-Native(RN)则是基于React的跨平台App框架。
为什么选择集成这么个看起来和游戏开发没什么关系的库?有几点原因:
React Native现在的架构
ReactNative有几个线程值得注意
我们已经实现了ReactNative的后端,可以跑游戏脚本。但UI部分暂时还未全部支持。
编辑器
编辑器界面用的Dear ImGui,虽然很想用我们的React-Native来实现,但Dear ImGui实在太香了。预计短期内不会替换。
由于我们的架构比较面向数据,在没有反射、对象标注的情况下,也能实现Inspector。
我们通过Executors实现了Active Object设计模式,管理数据的读写冲突。
多亏了Executors,写编辑器还是比较容易的,整体是多线程异步的,用起来没什么卡顿。遇到有些复杂操作很难原子化的时候,我们会弹出进度条阻止用户进一步操作,比如打开场景。
展望
至此我们完成了一个最低限度的游戏引擎,有渲染、有脚本、有资产导入。
未来还有更多的功能可以实现,目前的规划是:
结语
真心感谢看完的朋友,希望这篇文章能激发大家更多的灵感。
有很多细节没有具体展开,以后再做详细介绍吧。
来源知乎专栏:Star游戏引擎开发记录
最后,各位观众,如果你有什么技术想分享给大家,可以公众号留言。