之前写的两篇开放世界技术栈都是公司其他同事做的,所以很多细节了解不详细。但这次是全程我自己搭建的轮子,可以讲得稍微详细些。
之前写的大规模物件渲染的 GPU 版本,虽然渲染量大效率高,但是有个很致命的缺陷:无法与游戏逻辑进行交互。因为主要渲染数据都是放在 GPU 中,为了效率要尽可能减少异步回读,也要尽量减少同步数据量,所以要物体与逻辑交互就基本不可能了。但是使用 Unity 的 Dots 系统再加上 GPU Instance 技术,就可以很好地解决这个问题。
0、观前提醒
这篇文章不是写给初学者的,甚至对于有经验的程序员难度也不小。阅读前确保你已经对 Unity 的 Dots 系统非常熟悉,且对渲染管线有些许了解,更重要的是,要有优秀的抽象能力。
后续我会把这套系统做成插件包放出来在下面的库中(现在还不是很完善),等我这段时间工作忙完了再来整理这个库。
魔术师Dix / Unity 通用库:紫苑 · GitCodeUnity 的各种通用库 : 紫苑(Aster),基于 ECS 1.0+ 2023.2.5+ 包含方便调用的简化API、数据格式。 会包含我所需要的通用工具、数学计算、编辑器方法、多线程辅助、ECS等; 未来会增加ECS渲染、寻路、BVH、OBB等功能;https://gitcode.net/cyf649669121/Aster 等我把这个库整理完善之后,再开发一些工具、调试器等供大家使用。我最终的目的,还是希望这一套系统能达到傻瓜也能用的地步。
1、原理解释
一般来讲,渲染一个物体,需要知道其网格、材质球、位置、旋转、缩放、材质球属性即可。而对于同一类物体(材质球与网格均相同),不同实体的区别也就是位置、旋转、缩放;也即 LTW: Local To World。
Unity 的 GPU Instance接口: Graphics.DrawMeshInstanced ,其中需要动态改变的参数,大部分情况下只有 Matrix4x4[] ,可能还会有 MaterialPropertyBlock 的修改。
Unity 的 GPU Instance接口有多个,这里只用 DrawMeshInstanced 举例。使用其他的方法也是可以的,但是使用条件会有所不同(比如需要Shader支持)。
这里只用 Graphics.DrawMeshInstanced 进行设计,因其适配性最好:只需要材质球能勾上 Enable GPU Instancing 即可。当然,如果条件允许,使用 Graphics.DrawMeshInstancedIndirect 是性能最好的方案。
因此,在ECS中,将所有待渲染部件的 LTW 记录下来,并计数,然后将数据传给主线程,调用GPU Instance 的API即可完成渲染。
2、部件与渲染数据
在介绍业务流程之前,需要先了解一些概念。
对于所有的预制体(Prefab),我按照单个 MeshRender 将其拆分成单个部件,以下图的一个农场模型作为示例:
这个 Prefab 一共由3个部分组成:地板、风车、房子,也就是图中的3个 MeshRender。我将每一个独立的 MeshRender 的数据收集起来视为一个独立的渲染数据(下文中的RenderData):包含网格、材质球、阴影等配置,放在主线程以备上屏时调用。
对于这个预制体,其父节点会生成一个空 Entity(Dots中的Entity),每一个子节点生成一个 Entity,然后与父节点关联。这里每一个子节点生成 Entity,就是渲染部件(RenderChild),是渲染的最小单位。
3、渲染流程
这里要注意一点,在离线时,我会预先将所有的预制烘培成适合 ECS 的数据结构,所有预制都转换成只有一个父节点的层级关系(所有带MeshRender组件的父节点都是预制的根节点),这样就可以不用考虑父子节点的旋转问题了。
之后根据离线数据和游戏逻辑(例如服务器下发单位),创建与 单位Entity(Rendre Parent Entity,游戏逻辑的最小单位)。之后根据离线烘培的数据,给单位Entity挂载渲染部件。之后经过 System 的逻辑处理,统计处需要上屏的单位,将其部件数据收集在各个渲染数据Entity(与 RenderData 对应)中。
最终上屏时,按照 Unity 的接口提供对应数据,以 DrawMeshInstanced 为例,我这里直接将 ECS 里的数据拷贝出来了:
//内存拷贝
private static unsafe void CopyTo(NativeArray<float4x4> srcVectors, Matrix4x4[] outVectors)
{
fixed (Matrix4x4* dest = outVectors)
{
void* sourceData = srcVectors.GetUnsafeReadOnlyPtr();
UnsafeUtility.MemCpy(dest, sourceData, UnsafeUtility.SizeOf<float4x4>() * srcVectors.Length);
}
}
我这么写了还是很简略,毕竟这个不是手把手教程,而且毕竟我有计划写开放库放出源码,所以解释就较为简单。
4、一些细节问题
最开始这一套是从 SLG 游戏做出来的,可以支持大量的单位渲染(包括下图中的树木、建筑、行军、地面装饰物等)。
-
如何处理LOD?
在每一个部件里都存储有 LOD 信息,LOD分级、以及各个LOD对应的部件ID(提前预烘焙好)。对于SLG游戏,一般是固定俯视角,会使用全局LOD,这种也是支持的。
在 ECS 根据相机距离计算出当前的 LodID ,然后在收集数据的时候收集当前的 LOD 对应的 MeshType 即可。这种做法在 GPU 里也是通用的。LOD 的计算参考:
【Unity】LODGroup 计算公式_unity lodgroup-CSDN博客文章浏览阅读834次。Unity 在配置 LodGroup 时,其分级切换的计算方法是按照物体在相机视野中占据的比例计算的。在运行时,如果相机视野范围(Field of View)没有改变,那么这个值可以直接换算成物体距离相机的距离。这里就讨论下如何计算得到这个距离。_unity lodgrouphttps://blog.csdn.net/cyf649669121/article/details/133308591 也有一种实现方式,是将对应的部件新增一个,当做一个新的 Entity,并在运行时判定是否显示对应的部件。如下图所示,每一个矩形代表一个 Entity,在不同 Lod 的就显示不同的 Entity,其他组件则会隐藏。
两种方式更建议第一种,但第二种实现难度小。
-
如何处理动画?
如果是 SkinMeshRender ,也就是骨骼动画,可以使用 GpuSkin,网上有很多方案这里不细讲。使用 GpuSkin,因为其本身也是并行的,和 ECS 可以很好地结合起来。但缺点就是动画状态机的控制,一般是很简单的控制,否则在 ECS 里实现很困难。同样的,动画数量也不建议太多,否则需要烘培的动画贴图也会占用很大资源。
如果是传统 Animation,这种就只有程序写动画了。所以复杂、特殊效果的动画,也不适合用这套系统。
-
如何设置Shader参数?
这里以面片树作为例子,所有的树都是用的同一个 Material 和 Mesh,只有贴图的UV不同从而实现不同树木的表现:
这里我们需要将每一个渲染部件(也就是一个面片树)的Offset、Tiling离线收集起来,然后在收集最终上屏数据的时候,给渲染实体(RenderDataEntity)挂一个额外的 IBufferElementData,之后上屏之前读取出来,通过 MaterialPropertyBlock 进行赋值即可。这样处理仍然可以合批。
-
如何定制单位渲染流程?
参考上一个面片树的例子,还有一个问题就是需要对面片树增加一个特殊的 DrawCall 来执行 PreZ,否则面片树在矩阵变换后Z轴重叠导致闪烁。
因为每一个部件都是单独的一套配置,我在这套配置里增加了一项,即可按照我配置的类型进行特殊预处理,增加一次 PreZ。
-
单位剔除问题
单位剔除没有按照部件,而是按照单位进行剔除的。一个单位(例如上面的一个农场)就按照其包围盒进行剔除,且使用 ECS 多线程并行。
【Unity】相机视锥体剔除算法_unity视椎体剔除-CSDN博客文章浏览阅读3.7k次,点赞2次,收藏10次。视锥体剔除是Unity常用的剔除方法,其原理就是通过判定目标包围盒与组成相机视锥体的6个平面进行同侧判定,只要在6个平面之间的包围盒即为可见。本文根据其原理,给出一个视锥体裁剪的剔除算法的实现,并兼容ECS。_unity视椎体剔除https://blog.csdn.net/cyf649669121/article/details/125779899 对于 SLG 游戏这种固定俯视角的,还可以使用更简单的剔除方式:
【Unity】俯视角相机地面视野范围的计算_unity相机视野范围-CSDN博客文章浏览阅读3.9k次,点赞4次,收藏16次。在SLG等游戏中,相机总是固定为俯视角(上帝视角)。为了更好地管理游戏数据,需要对地图进行分块,只处理视野内的部分。判定某个单位是否在视野内有很多方法了,但是要么不够精确,要么性能不够,要么无法与AOI配合。 一个可行的方案就是将相机在地面上的视野计算出一个AABB 2D 包围盒,然后基于此包围盒来计算 AOI、显隐等。这个方案效率够高,而且对俯视角适配较好。_unity相机视野范围https://blog.csdn.net/cyf649669121/article/details/127529668
-
如果渲染单位超过1024个了怎么办?
这里的处理方案就是在 RenderDataEntity 上挂载一个 ComponentData ,标记另一个同样类型的 RenderDataEntity ,形成一个类似链表的数据结构。如果自身需要渲染的部件数量超过设定值(例如1024),就切换到下一个渲染实体。
在实际开发过程中,超过 1024 的情况还是很少的,更多的根据项目实测,为了节约内存会限定单个 RenderDataEntity 的最大渲染数量(例如128)。毕竟,记录各个部件的 Transform 信息的 Buffer 需要一开始就初始化好,自然能省一点是一点。
-
如何将预制体转换成所需要的数据?
我自己是写了一个工具进行转换(具体参考工程里的代码),在离线情况下收集所有需要渲染的预制体,然后烘培成纯数据。虽然理论上可以在线试试转换,但我不建议这样做。
实际上转换后,原有的预制体就不需要了。所以后面我的做法,都是在专门的美术工程里进行数据整理、烘焙,正式工程里只有纯数据。
5、优势与限制
优势:
- 完美的合批!(除了 DrawMeshInstanced 有 1024 个的限制外,能完美合批);
- 高性能,支持大批量物件渲染;
- 能与主线程进行逻辑交互;
- 可以在 ECS 系统里可定制化一些特殊功能;
劣势:
- 技术水平要求较高,需要熟练掌握 Dots ECS 体系才能良好维护。
- 无法使用动画(只能程序实现一些简单效果)
- 不支持多材质球、多Mesh的模型(需要拆分)
除此之外,还有以下情况不建议使用这套系统:
- 独特物体:一般来讲,单个物体会有独特的模型、效果和控制方式,而在ECS控制起来较为困难,且没有什么效率提升。
- 静态物体:静态海量物体(不会移动、形变等)建议使用下面的方案使用GPU来处理,效率更高。GPU驱动的大规模静态物件渲染-CSDN博客文章浏览阅读745次,点赞9次,收藏18次。GPU Driven 的静态物件渲染,听起来很高级,其实具体操作很简单,基础就是直接调用 Graphics.DrawMeshInstancedIndirect 这个 Unity 内置接口就可以了。但我们项目对这个流程做了一些优化,使得支持的实体数量有大幅提升。这套系统主要也是公司的 TA 实现的,这里我也只简明扼要地介绍一下原理。https://blog.csdn.net/cyf649669121/article/details/141222437