基于 Dots + GPU Instance 的大规模物体渲染

        之前写的两篇开放世界技术栈都是公司其他同事做的,所以很多细节了解不详细。但这次是全程我自己搭建的轮子,可以讲得稍微详细些。

        之前写的大规模物件渲染的 GPU 版本,虽然渲染量大效率高,但是有个很致命的缺陷:无法与游戏逻辑进行交互。因为主要渲染数据都是放在 GPU 中,为了效率要尽可能减少异步回读,也要尽量减少同步数据量,所以要物体与逻辑交互就基本不可能了。但是使用 Unity 的 Dots 系统再加上 GPU Instance 技术,就可以很好地解决这个问题。
 

0、观前提醒

        这篇文章不是写给初学者的,甚至对于有经验的程序员难度也不小。阅读前确保你已经对 Unity 的 Dots 系统非常熟悉,且对渲染管线有些许了解,更重要的是,要有优秀的抽象能力。

        后续我会把这套系统做成插件包放出来在下面的库中(现在还不是很完善),等我这段时间工作忙完了再来整理这个库。

魔术师Dix / Unity 通用库:紫苑 · GitCodeUnity 的各种通用库 : 紫苑(Aster),基于 ECS 1.0+ 2023.2.5+ 包含方便调用的简化API、数据格式。 会包含我所需要的通用工具、数学计算、编辑器方法、多线程辅助、ECS等; 未来会增加ECS渲染、寻路、BVH、OBB等功能;icon-default.png?t=N7T8https://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、优势与限制

优势:

  1. 完美的合批!(除了 DrawMeshInstanced 有 1024 个的限制外,能完美合批);
  2. 高性能,支持大批量物件渲染;
  3. 能与主线程进行逻辑交互;
  4. 可以在 ECS 系统里可定制化一些特殊功能;

劣势:

  1. 技术水平要求较高,需要熟练掌握 Dots ECS 体系才能良好维护。
  2. 无法使用动画(只能程序实现一些简单效果)
  3. 不支持多材质球、多Mesh的模型(需要拆分)

除此之外,还有以下情况不建议使用这套系统:

  1. 独特物体:一般来讲,单个物体会有独特的模型、效果和控制方式,而在ECS控制起来较为困难,且没有什么效率提升。
  2. 静态物体:静态海量物体(不会移动、形变等)建议使用下面的方案使用GPU来处理,效率更高。GPU驱动的大规模静态物件渲染-CSDN博客文章浏览阅读745次,点赞9次,收藏18次。GPU Driven 的静态物件渲染,听起来很高级,其实具体操作很简单,基础就是直接调用 Graphics.DrawMeshInstancedIndirect 这个 Unity 内置接口就可以了。但我们项目对这个流程做了一些优化,使得支持的实体数量有大幅提升。这套系统主要也是公司的 TA 实现的,这里我也只简明扼要地介绍一下原理。https://blog.csdn.net/cyf649669121/article/details/141222437

  • 14
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Dots(Data-Oriented Technology Stack)是Unity引擎的一种数据导向技术栈,它可以让游戏的数据更加高效地处理和组织。KDTree是一种经典的数据结构,可以用于高效地处理多维空间数据的查询。本篇文章将介绍如何使用C#实现基于Dots的KDTree。 首先,我们需要定义一个点的数据结构。假设我们要处理二维空间中的点,我们可以定义一个名为“Point”的结构体: ``` public struct Point { public float x; public float y; public Point(float x, float y) { this.x = x; this.y = y; } } ``` 接下来,我们需要定义一个节点的数据结构。每个节点包含一个点、一个左子树和一个右子树。如果该节点没有子树,则对应的子树为空: ``` public struct Node { public Point point; public Node left; public Node right; } ``` 我们使用递归方法构建KDTree。具体来说,对于一个给定的点集合,我们首先找到X坐标的中位数,并将其作为根节点。然后,我们将点集合分成两个子集,一个包含所有X坐标小于中位数的点,另一个包含所有X坐标大于中位数的点。接着,我们递归地在每个子集中构建左子树和右子树,直到子集为空。在构建子树时,我们使用Y坐标的中位数来确定左右子树的分裂方式。 下面是构建KDTree的代码: ``` public static Node BuildKdTree(Point[] points, int depth = 0) { if (points == null || points.Length == 0) { return default(Node); } int axis = depth % 2; int medianIndex = points.Length / 2; Array.Sort(points, (a, b) => a.x.CompareTo(b.x)); Node node = new Node(); node.point = points[medianIndex]; node.left = BuildKdTree(points.Take(medianIndex).ToArray(), depth + 1); node.right = BuildKdTree(points.Skip(medianIndex + 1).ToArray(), depth + 1); return node; } ``` 我们可以使用以下代码测试构建KDTree的效果: ``` Point[] points = new Point[] { new Point(2, 3), new Point(5, 4), new Point(9, 6), new Point(4, 7), new Point(8, 1), new Point(7, 2) }; Node root = BuildKdTree(points); ``` 现在,我们已经成功地构建了一个KDTree。接下来,我们需要实现一个查询方法来查找最近邻点。查询方法的思想是从根节点开始向下遍历,直到叶子节点。在遍历的过程中,我们计算当前节点和目标点之间的距离,并将其与当前最近邻点的距离进行比较。如果当前节点更接近目标点,则更新最近邻点。接着,我们根据当前节点和目标点的关系,递归地遍历左子树或右子树。当我们到达叶子节点时,我们将该叶子节点作为当前最近邻点,并将其距离与当前最近邻点的距离进行比较。最终,我们找到了最近邻点。 以下是查询方法的代码: ``` public static Point FindNearestPoint(Node node, Point target) { if (node.left == default(Node) && node.right == default(Node)) { return node.point; } Point best = node.point; if (node.left != default(Node) && target.x < node.point.x) { Point leftBest = FindNearestPoint(node.left, target); if (Distance(leftBest, target) < Distance(best, target)) { best = leftBest; } } if (node.right != default(Node) && target.x > node.point.x) { Point rightBest = FindNearestPoint(node.right, target); if (Distance(rightBest, target) < Distance(best, target)) { best = rightBest; } } return best; } private static float Distance(Point a, Point b) { return Mathf.Sqrt(Mathf.Pow(a.x - b.x, 2) + Mathf.Pow(a.y - b.y, 2)); } ``` 我们可以使用以下代码测试查询方法的效果: ``` Point target = new Point(3, 5); Point nearest = FindNearestPoint(root, target); Debug.Log(nearest.x + ", " + nearest.y); // 输出 "2, 3" ``` 这就是基于Dots的KDTree的实现方法。它可以被用于高效地处理多维空间数据的查询,并且可以很容易地扩展到更高维度的情况。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值