第一个三角形!
先说说近期的进展吧,最大头的成果就是趁着五一假期把 VulkanDriver 基本写完了,经典三角形:
Triangle
基本的接口封的差不多了,后面的小问题再慢慢修修补补吧。
然后就是工程建设方面的,差不多这段时间把这些东西搞起来了:
- CI (GitHub Action)
- 三方库 Git SubModule 支持
- README / WiKi
单元测试框架本来想加上,但想到目前也只有 Driver,没什么写 UT 的必要性,想想也就算了,等到后面慢慢有需要再加吧,反正集成 UT 框架也就是几行 CMake 的事情。
接下来要做的是 RPI、Runtime、场景管理等,还得把预置的 Components、Systems 给加上,总之好好加油吧。
不省心的 RHI
RHI 是写引擎绕不开的巨坑,时常想吐槽这么多 API,你们相互之间打一架好不好,这么多年了也一直搞不出一个统一的 API,各个平台都想围绕自己打造封闭的生态。
写 RHI 最难的事情就是要拿一套统一的接口去封装多个图形 API,于是乎割舍功能和删删改改变成了常态,称 RHI 为脏活累活一点不过分。对各个 API 的了解程度将直接决定 RHI 最终接口的实用程度。
别的很多东西其实可以借鉴老哥们,但是针对 RHI 这层来说,老大哥们的封装其实也不咋地,因为以前的 API 粒度毕竟是较 High Level 的(如 OpenGL、DX9 一类的),很多情况下无法发挥完全能效,再或者看看 CryEngine,索性直接 typedef 实现多态,硬生生把 RHI 和 RPI 糊在一起,只要我没有 RHI,最不存在烦恼,嗯,没毛病。
之前也听同事吐槽过,自己造引擎如果从底层开始写,写到最后发现自己删了好几遍代码,写来写去最后还是一个三角形,上面的啥也没写。
我目前的想法是先按照 Vulkan 的概念简单封装后搭建 Driver 的公共接口,这样上面的 RPI 和游戏逻辑框架也可以先写起来了,日后再慢慢补充 DX12Driver 和 MetalDriver。
目前 VulkanDriver 封装的概念如下:
- Driver
- Device
- SwapChain
- Image
- ImageView
- Sampler
- RenderPass
- FrameBuffer
- GraphicsPipeline
- ComputePipeline
- DescriptorPool
- DescriptorSet
- GpuBuffer
- CommandBuffer
- CommandEncoder
- Signal
- Fence
基本涵盖了 Vulkan 的大部分概念,实现的过程中发现主要是 Enum 的转换与 Flags 的处理比较恶心,而且日后这部分在其他 Driver 的兼容性上估计也会出岔子,我目前的想法是尽量使用继承和多态将复杂的 Flags 组合给完全屏蔽掉,来提高兼容性。
目前 VulkanDriver 已经基本实现完全了,争取不再往上面放太多精力,先关注上面那些没做的东西,等到 DX12 和 Metal 也添加进来后,再慢慢开始写 RHI 线程管理、内存管理、 CommandQueue 一类的功能。
ECS, 曙光还是末日?
介绍 ECS 前,我们先简单回顾下传统的 GameObject 游戏编程范式,在 GameObject 的世界里,游戏对象被称为 GameObject,而 Component 被称为组件,组件是承载数据和逻辑的单位,一个 GameObject 可以挂载各种 Component 来实现不同的功能。
像这样:
代码语言:javascript复制Player
|- TransformComponent
|- PhysicsComponent
|- ScriptComponent
脚本系统中往往会给一些生命周期方法和一些工具方法,来给开发者提供控制 GameObject 的能力,比如实现一个自转的功能你需要这么写(伪代码):
代码语言:javascript复制public float SPEED = 1.f;
void onStart()
{
// ...
}
void onUpdate(float deltaTime)
{
this.getComponent<Transform>().rotate(Vector3.UP, deltaTime * SPEED);
}
可以看见之所以我说 GameObject 既承载数据,又承载逻辑,是因为多数引擎把脚本本身也作为了一种 Component,由此产生了无穷的变化,数据流变得不可预测,难以控制。
但是很明显,老朋友 GameObject 的有点在于好用,我甚至可以在任意 GameObject 上挂载一个脚本,然后用这个脚本去影响 World 中的一分一毫,基本可以说是毫无规则可言。
由此,大厂们提出了一种更为先进的编程范式,称之为 ECS,关于介绍可以看 Unity 的介绍:Unity - ECS,Unity 画了几张漫画来介绍 ECS,我认为非常生动形象:
ECS Summary
Entity
Component
System
概念如下:
- Entity:实体,只是一个 Id,不保存任何数据,也没有任何逻辑
- Component:组件,是数据的集合,不存储逻辑,数据排布按照高效的内存访问方式设计
- System:系统,是处理数据的逻辑,每一个系统只处理自己所关心(订阅)的组件,多数情况下系统之间还能并发
这样有什么好处呢?
其实可以很明显的看出来,Component 可以以一种高效的方式存储,所有类似的 Component 可以存在相邻的内存空间中,这样当 System 去尝试遍历一种类型的 Component 并处理时,大部分的 Component 都会被 Cache 在 CPU 的 LN Cache 中,大大地提高速度(当然不光这一种内存管理方法,比如 Unity 采用的方法是所有具有相同 ArcheType 的 Entity 的 Components 按照顺序存放)。
再加上每个 System 只关心自己所订阅的 Components,这样一来没有依赖的 System 之间本身可以并发,可以把 CPU 多核的能力利用起来,这也是以往的游戏引擎正欠缺的,因为多数情况下,脚本如果并发可能会导致很多问题。
所以,“快”,就是 ECS 最大的优势。
如果使用 ECS 的方法来改造章节开头的例子,那就会变成这样:
代码语言:javascript复制PlayerEntity
|- RotateComponent
struct RotateComponent {
float speed;
};
class RotateSystem {
void onUpdate(float deltaTime, Registry registry) {
let view = registry.view<RotateComponent, TransformComponent>();
view.forEach((entity, rotateComp, tranComp) -> {
Math.rotate(tranComp, Vector3.UP, deltaTime * rotateComp.speed);
});
}
};
写起来明显变得更操蛋了,这也是 ECS 一直被诟病的地方,学习成本高、难用,而且由于 Component、System 完全可以交由用户自定义,有时候写着写着会违背原则,产生一些垃圾代码,所以 ECS 推广了这么多年了也一直没真正地流行起来。
回到标题,我写的标题叫做 “ECS,曙光还是末日?” 我指的其实是对开发者来说的,从正面看,ECS 是明显的数据驱动范式,能够把数据和逻辑完全解耦,从长远来看是能提高工程的质量的,而且加上正确操作下 ECS 确确实实能提高 CPU 这边逻辑的运行速度。但是从反面来看,使用 ECS 从编程思维上就完全升维了,很多开发者很难适应,就算适应了,要写出来高质量的代码,还需要一定的学习成本,所以说,ECS 能带来曙光,也完全能带来末日。
我自己是忠实的 ECS 拥趸,我的观念中,ECS 带来的利是远远大于弊的,速度是一方面,我认为更重要的是 ECS 那种数据驱动、数据 / 逻辑分离的思想,对提高工程中代码质量、降低维护成本都是很有帮助的。
总而言之,期待 ECS 之后的发展,也期待 Explosion 与 ECS 碰撞出奇妙的火花。