网易首页 > 网易号 > 正文 申请入驻

做 10w 量级粒子的模拟与渲染需要什么样的技术实现方案?

0
分享至

粒子系统是增强开发视觉效果的关键工具。然而,过多的粒子数量往往会增加 CPU、GPU 的负担,导致性能下降和卡顿现象。升级与迭代粒子系统,帮助开发者更好地处理大量粒子成了引擎设计中的重要内容。在 2024 年 Unity User Group 北京站活动中,Unity 中国 DOTS 技术主管李中元、Unity 中国 VFX 团队工程师陈冰玉向开发者们分享了《10w 量级粒子的模拟与渲染》,介绍团结引擎粒子系统的设计目标与实现思路。


对于一个粒子系统来讲,有些典型特点,首先是它数据量大,二是它模拟的对象都是很相似而且很简单的。它会有非常高频率的产生和销毁,伴随大量相同半透明物体的渲染。这也就意味着粒子系统会同时给CPU、GPU造成压力,所以粒子系统做得好与不好,对 CPU、GPU 两方面都有很高要求。


Unity 内置的粒子系统用得非常广泛,在社区里非常活跃,在Asset Store约有4000多款插件。所以如果大家想获得 Unity 粒子系统特效的话,可以直接从 Asset Store 里购买到相应的、需要的资源,微调一下就可以拿来自己用。

因为 Unity 内置粒子系统其实在 Unity 5.X 的时代就已经在开发,一直维护到现在有十几年的历史,也就意味着它是背负着一些负担。比如它是基于 OpenGL ES2.0,而且它的线程模型是个单线程模型。如果说在一个粒子系统内,它整个模拟都是以单线程的方式做的,这也就意味着限制了它模拟的粒子量级在万的级别。


我们现在做了团结引擎之后有了一个机会,用更现代的方式实现过去粒子系统相似的功能,能够在实现相似功能的基础上表现更好。我们提出了新的目标要做 10w 级别的粒子系统。

10w 量级粒子的模拟

粒子系统同时对于 CPU 和 GPU 都有压力,先看一下 CPU 部分。


我们目标是 10w 量级的粒子,它跟万量级的粒子由于量变,在很多做粒子系统的时候,我们的决策就会产生一些质变。

这里我会举两个例子,首先是粒子的生成和销毁,二是粒子里非常重要的一个计算,就是它的曲线采样,我们在这方面也做了比较好的优化。

1.粒子的生成和销毁


我们来看一下内置粒子系统的内存结构,它在生成和销毁时的表现是什么样的。首先 Unity 内置粒子系统有两种内存模式,一种是数组。PositionX 数组、Y 数组、Z 数组,他们是以 SOA 的方式组织在内存里,就是有 3 个数组。还有一种是模式是RingBuffer,RingBuffer 跟数组非常像,只不过它们在用完之后会复用前面的数据。


创建的时候,数组是固定容量的,当粒子生成到达数组上限的时候,就需要创建一个新的数组,把老的数据进行拷贝,把老的数据进行销毁,这是非常正常的开销。但是,当粒子数量越来越多的时候,这个开销实际上是一个线性增加的。而且大家能看到,这时候内存的增长,在一个瞬间会同时存在老的数组和新的数组,拷贝完了之后才能把老的数组删掉。也就意味着会有个内存高峰。


RingBuffer 相对比较简单,用满之后会直接复用前面的,也就意味着 RingBuffer 粒子数量的上限是固定的,不会再增加了。


再来看销毁,对于数组模式,比如中间这个被销毁掉了,我会采用 Swapback 的方式把后面的数据复制到前面来。但是这样也会有一些奇怪的事情,比如只能在单线程里做这些事情,因为 Swapback 的顺序决定了最终数据长得甚至不一样,比如把最后一个挪到这儿来和倒数第二个挪到前面来得到的结果是不一样的。

在 RingBuffer 情况下直接选择了躺平,就意味着它是牺牲了最终模拟的准确度,保证数据不出问题。在 RingBuffer 情况下不会真正删除数据,比如这里提供两种模式,一是 Pause,粒子的生成周期到了之后就停在那儿,不再动了,直到它被复用掉;另一个是 Loop 模式,直接在这个位置出生一个新的粒子,其实也是牺牲了模拟的精度,做到不会产生内存的 swapback,其实都是以一种牺牲模拟效果的方式换取更高的效能。


我们现在想采用分治的想法,以 128 个粒子为单位,组成了一堆内存分组,比如前面的每三格是 128 个 X。当我再去都用完之后,会再生成一组新的内存,把它们连接在一起。比如 10w 粒子大概有 780 段的内存,这样就可以规避掉前面所说的不管是数组也好,还是 RingBuffer 也好它们的缺点。


这里对内存的分配有个特点,128 个粒子内存永远是一样大的,就意味着对内存的管理提出了更高的要求,我需要内存的分配更快。还要付出更加复杂的代码管理这些内存的分段。这 128 个内存大小是一样的,大小是 128 个数组,都是以 SOA 的方式存储的。


为了实现更高效的内存管理,我们自己实现了定制化的内存 Allocator。这是我们测试的性能数据,这三个实际上是 Unity 给我们提供 3 种内置的内存分配器,分别是 Persistoent、Tempjob、Temp,TileAllocator 是我们团结引擎自己实现的内存分配器表现,比 Unity 最快的内存分配器表现还要快一倍左右。从这个层面上,通过自定义 Allocator 就满足了对于固定分段内存大小分配的性能要求。


这里再介绍一下思路,刚才反复提我们分配的内存是固定大小的,有个非常适合的叫Pool Allocator,它是固定大小段的分配。这是我在网上找到的 Pool Allocator 算法,就是分配一大块内存,切成固定大小的小段,通过链表的形式链在一起,这是大家能找到的非常常见的算法。但是它有个问题,它的性能非常不好,通过链表,就意味这我们是通过指针把它链在一起的。

指针有什么问题?当我把链表并在一起的时候,实际每个地方都要访问一次内存。对于内存分配器来讲,访问内存的代价是非常高的。我们看到刚才统计的内存分配器的时间,它的单位是微秒,对于在微秒级别访问内存的开销已经是个很重的操作了。所以对于内存的访问要尽量减少。


在我们的实现里是通过两层 Bitmap。通过第一层的 bitmap 查询上面的 Block 哪个是空闲的。比如第一个位是 1,就意味这第一个 Block 是满的。第二个这个位置是 0,意味着第二个 Block 是可用的,就直接拿了第二个 Block。第二个 Block 它依然是 bitmap,通过这个 bitmap 我能知道刚才那一段里第几个、第几段是没有被使用的,然后再拿指针的地址,加上偏移量,可以做到在只用访问两次内存的情况下就能够拿到我想用的地址。所以从整体上来讲,我们用最大的可能性排除掉了对于内存的访问。


这个才是我们真正实现的样子,暴露了更多细节。首先最底下有 64 个 ulong 表达这一块能够管理多少 Block,这个 Block 里面有 3 块内存。为什么是三块?这里就牵扯到我们进行 Cache line 的对齐。意味着访问了这块地址之后,这块地址往后面的 64 个字节对它们的访问都是免费的。因为它已经对于 CPU 来讲,它的内存访问是按照 Cache line 访问的。所以访问这三块的时候,访问速度非常快的。

同时,对于 Bitmap 的操作要访问到第几位是 0。这个操作是可以通过 CPU 指令级级别的函数,直接能够拿到这位是 0。能知道这里有几个 1,其实也是硬件给我们提供了相应的指令。

同时,我们还实现了多线程的线程安全访问,对于一个 Allocator 来说,线程安全也是个非常重要的特性。我们通过硬件级别的自旋锁 +interlocked,实现了非常高效的线程安全。对于整个内存分配器,我们通过 Cache line 的对齐、减少对于内存的访问、包括对于硬件指令的使用,最大化了 Allocator 的性能。


再来看销毁,销毁就比较直观,因为依然采用 Swapback 的方式,保证前面数据是紧凑的,能够让我们最大化使用 SIMD 指令进行运算。但是这里跟 Unity 内置粒子系统不一样的是我们的内存天生是分段的,意味着对于每一段进行 Swapback都可以进行多线程的操作,对于销毁来讲也可以做非常直观的加速。


2.曲线采样

另外一个粒子系统里比较重的操作就是曲线采样,右边是大家使用粒子系统的时候经常用到的界面。它有几个特点:

它的 Key 比较少,底下是 3 个上面也是 3 个,估计 5-10 个就已经很多了,因为纯粹是人做出来的曲线。

它的采样频率很高,10w 个粒子去采样曲线,这里是两个曲线,意味着会有 20w 次的采样操作。

Unity 为了优化这个东西提供了Optimize的选项,Optimize 是做什么的?它有一些限定,首先它只允许 key 是 3 个以内,就是 2 个或者 3 个 Key,它的限定必须是 [0-1],也就是说至少头上那个 Key 是 0,尾上 Key 是 1,还限定了 Clamp 模式,必须是两头裁掉的模式。所以它的限制是比较大的,如果不满足这些条件,就会降级到 AnimationCurve 采样。


我们来看一下 AnimationCurve 是什么样的。这个就是最简单的动画的曲线,很复杂。但是它有一些很明显的特点,首先曲线很多,Key 也很多,但是它所有曲线的 Key 共享 Segments。能发现如果我们用 AnimationCurve 算法去采样上面的曲线,其实是有点大材小用了,因为算法对应的场景是不太一样的。所以我们就针对上面粒子系统的特点,做了算法层面的优化。


这个是我们最终得到的效果,AnimalionCurve 大概是 6ms 左右,在最后一行,我们自己实现的 Curve 采样大概在第四行,是 0.06ms,最短的是 0.03ms。也就意味着在粒子系统里,用新的OptimizedCurve实现采样的话,10w 次基本上 0.03,几乎可以认为是免费的过程。如果不是 OptimizedCurve,也是个非常短的时间。


我们是怎么做的?曲线采样的本质就是对下面的公式进行多项式求解,这是算法的本质。abcd 我也不知道,先把 abcd 求出来,然后是 x,x 就是采样的时间 t,先求出 abcd,然后把 t 带进去,就得到了最终采样的结果。

求 abcd 的过程实际上是可以被预计算的,意味着我不需要采样的时候才计算 abcd,而是创建一条曲线的时候把 abcd 算出来。采样的时候不需要再把 abcd 重新算一遍,这样节省了大量时间。在 AnimationCurve 里,实际上它是每个 abcd 都要求的。我不是说它实现得不好,而是它是为了它的那个场景做的。因为它的 Key 特别多,如果预计算的话,一开始就计算出来成本受不了。但是在我们场景里,我们完全可以把 abcd 预计算出来。


采样的过程也做了优化,ax3+bx2+cx+d =[(ax + b)x + c]x + d,这两边是等价的,但右边的式子乘法会少一半。上面是左边的式子代码,下面是右边式子的代码。我们优化出来会发现同样的代码,用不同的写法,速度就会快3倍,这是非常神奇的地方。

为什么乘法少一半,速度会快了 3 倍?是因为 mad 函数,它就是乘加函数,它在硬件里也有对应的指令。所有的计算都是硬件里有对应指令的,所以它不是快了 1 倍,而是快了 3 倍,整体做下来优化效果还是非常棒的。所以有很多细节能够让整体粒子系统的采样速度或运算模拟速度都会变得更快。


曲线采样 Tradeoff,我们需要预计算 abcd,这样预计算会导致 Curve 创建开销更高一点。我们测下来 3000 条曲线创建需要 2.57ms,目前看下来我们可以接受这个成本。因为 3000 条曲线同时被创建是少见的场景。

10w 量级粒子的渲染

上面讲到了怎样在 CPU 中高效地组织内存,来做到更快地创建和销毁,以及怎么优化 Curve 采样。剩下的部分就是怎么样把 10w 粒子更快地画出来。


Built-in Particle System 的渲染方案在 1w 数量以下的粒子图表现上是非常好的。如果我强行用它画 50w 的 cube 它只能在二三帧跑了,已经卡成 PPT 了。

这个粒子的配置,CPU 是 i5 的 13600,GPU 用的 4070,在 Direct3D 12 跑的,开了 11 Modules,shader 用的 lit 类型。其中一个比较大的原因是受限于它本身的渲染方案,因为 Built-in Particle System 它是通过把一个粒子系统的所有粒子拼成一个大的 Mesh,它有计算大的 Mesh 的过程。计算每个顶点的位置、上传数据这些,都是在一个单线程跑的。当粒子数量增加的时候,这就会成为一个卡点。

所以我们选择了完全不同的渲染方案。我们是基于BatchRendererGroupBatchRendererGrpup API 可以从 C# 层高效生成绘制命令,生成 GPU instance drawcall,相比传统的 SRP Batching,能够带来更少的批次和更少的 drawcall。ECS 中的 entities.graphics 包也是基于 BRG 构建的,熟悉 DOTS 的同学应该对它不陌生。它对于我们当前这种需要渲染大量相似物体的需求来说,这是一个非常好的选择。

所以我们用了一个 RendererManager 来组织场景,所有需要渲染的对象,包括粒子、所有的 Trails,让它来组织数据穿传,裁减、DrawCommand 等所有工做,它最核心的任务就是让各个阶段的工作能够并行调度起来。


我们先来看看对于粒子系统来说,哪些选项会影响合批。

这边红框里可以设置材质和 mesh,不同材质和 mesh 一定是不同的 darwCommand,这个是共识,而蓝框中是一些是否生成阴影,是否生成 Motion Vectors ,不同 layer 等设置,这些 filterSettings 也会影响合批。

BRG 为了避免将类似的 filterSettings 参数复制到多个 darwCommand 中,它其实提供了range 和 batch 两层结构,range 用来描述了一组连续的拥有相同 filterSettings 的 batch。

而对于一个粒子系统中的所有粒子来说,蓝框中的设置都是一样的,而红框中的 mesh 有 1-4 的限制,所以它们一定是同一个 range,最多 4 种 batch 就能描述了。

那再换一个思路,也就是说,当参数确定的时候,最佳批次也就随之确定了,只有当参数发生修改的时候,我们才需要去重新维护这个结构,然后再根据这个结构去生成 drawcommand。


我们再来看一下 GPU 内存管理,BRG 要求我们自己管理 GPU 内存,定义各种属性的分配和偏移 。在这里我们以 SOA 的方式组织数据,并且使用持久的 GPU 内存。也就是说,当粒子的数量上限确定时,这段内存的 Layout 就确定了,每帧只更新需要更新的部分。


而为了进一步减少上传 GPU 的数据量,我们区分了 Shared 数据和 Instance 数据,Shared 数据是每个粒子系统一份,而 Instance 数据是每个粒子一份。所以真正的 GPU 内存大概是这个样子的,所有 Shared 数据会放在所有 Instance 数据的前面。

当然这里只是举了几种属性作例子,由于粒子系统允许用户自定义上传到 GPU 的属性类型非常之多,所以我们也提供了一套结构能够去灵活地修改这个布局。


我们还将部分模拟转移到 GPU,包括矩阵变换、UV 动画、Random。这样做的好处有很多,一方面自然是加速计算,通常粒子数量是远大于顶点数量的,粒子的速度也是更快的。

另一方面呢,我们可以用更少的数据来描述更多的属性。可以从图片里直观地感受一下这件事情的重要性,这里的 CustomData 和 CustomVertexStream 都是允许用户在shader里读到的属性。而正是由于我们把一部分计算转移到了 GPU 上,这里的很大一部分属性也可以在 GPU 上直接计算得到了。


上面我们花了一番功夫去尽可能地减少 CPU 到 GPU 需要上传的数据量。而为了避免 GPU 需要的数据在 CPU 这里又重复地多存一遍,比如说 color,我们将计算的结果直接写入 GPU。又因为我们数据都是以 SOA 的方式存放,而 GPU 的内存也是以 SOA 级别排列的,那么我们就可以很自然地做到多线程并行写入。

而对于写入的时机,我们也做了优化。对于一些比较苛刻的情况,Built-in Particle System 会有大量的重复写入。上面这张图是我在 Editor 下同时开了两个窗口,一个 SceneView 一个 GameView,并且启用了粒子系统的 shadow 选项的一张 profiler 的截图。这里的每一根白色的竖线就是 Built-in Particle System 在向 GPU 上传数据。这主要是因为 Built-in 通过 vbo 来上传数据,矩阵变换计算顶点位置这些放在 CPU 上去做。

那么窗口不同,相机信息就不同,计算得到的顶点位置就不同,所以就会出现这样的重复写入。而我们是不需要的,我们通过 GraphicsBuffer 上传必要的精简的数据,对于不同的窗口,shadowMap,Edior 下的描边来说,这些数据都是一样的。所以我们有机会做到一帧内只有一次上传。


这里我们用 10w 个 billboard,一个 billboard 四个顶点,来简单对比一下,相比 Built-in Particle System 我们究竟省了多少?对于 Shared 数据来说这是 40w 和 1 的区别;对于 Instance 数据来说是 40w 和 10w 的区别。由于转移了部分计算到 GPU,所有可能上传到 GPU 的 Instance 数据类型减少40%。由于优化了上传数据的时机,在启用阴影的状态下数据总量减少50%


那么我们这套新的实现中有没有什么瓶颈呢?有的,就是裁剪。Built-in Particle System 受限于 CPU 生成 Mesh 的方式,一个粒子系统的所有粒子作为一个完整的 Mesh,只会进行一次裁剪。对于部分粒子在视野外的情况处理地并不好,给GPU 带来了多余的负担。

我们 DrawInstance 的渲染方式有机会对一个粒子系统逐粒子级别地进行裁剪,但 10w 次裁剪和 1 次裁剪的差别还是非常大的。这里我列了一些数据展示一个粒子一帧内究竟需要和多少个裁剪面求交。我们要对相机的视锥体求交,也就是 6 次。当启用 shadow 的时候,还要对灯光的裁剪面求交,特别是点光源和直射光,这个数量已经飙到几十个了。而当 Editor 上不只有一个窗口时,上面四项的和还需要再乘窗口的数量。如果这个数字还要再乘 10w,这个代价就有点大。而我们希望在既保证精度的同时又能把速度提上去,所以我们对裁剪进行了加速。


裁剪的双方分别是裁剪面和粒子,我们从这两个角度去考虑如何加速。对于裁剪面来说,我们通过SIMD 加速,将裁剪面打包成 4 个 x,4 个 y,4 个 z,4 个 distance,这样就可以利用 burst 编译器,将每 4 个裁剪面的裁剪算法变成一条指令。而对于粒子来说,它本身具有两个特点:首先,绝大部分粒子都是动态的,每帧都会发生变化。其次,粒子之间经常重叠,很难用某个平面将它们划分开。所以像 kdTree 等空间划分的思路并不适合的。

回到加速的核心需求,是要将数据组织成某种结构,分层去做裁剪。而我们的数据天然就是分层的,我们可以利用数据本身的结构来加速裁剪。我们分成了粒子系统,page,粒子三层,由于粒子之间的裁剪结果并不会相互影响,所以这个过程是多线程并行的。而除了裁剪之外,还有别的模块也会涉及求交运算。比如说 collision,trigger 等物理相关的模块。这些模块也同样可以利用这个结构来做加速。


最后,回到我最开始的例子,在对完全相同的配置下,去画 50w 个 cube。在我们新的渲染方案下,有个更好的合批,更精简的数据上传,并行计算,裁剪加速,它会表现得更好吗?是的,可以看到帧率能够稳定在 40 帧以上,整体还是比较流畅。当然现在还是开发中的状态,后面还是需要继续优化和修改的。

Unity 官方微信

第一时间了解Unity引擎动向,学习进阶开发技能

每一个“在看”,都是我们前进的动力

特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。

Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.

Unity
Unity
Unity中国官方帐户
2198文章数 6706关注度
往期回顾 全部

科技要闻

纯血鸿蒙正式发布,余承东∶打破欧美垄断

头条要闻

涉黑恶集团头目系公安局副局长 知情人:其妻逃到国外

头条要闻

涉黑恶集团头目系公安局副局长 知情人:其妻逃到国外

体育要闻

德国首发中锋 10年前在工厂打螺丝

娱乐要闻

热巴生子风波愈演愈烈!网友扒出变化图

财经要闻

社科院建议发2万亿特别国债设平准基金

汽车要闻

换装14.6英寸大屏 吉利缤越L将11月上市

态度原创

数码
房产
教育
公开课
军事航空

数码要闻

中国电子纸平板市场变天:电子书阅读器退居二线

房产要闻

生猛!规划10万级人口规模,海口又一座超级新城曝光!

教育要闻

你还知道哪种语言的“晚安”呢 #World今日晚安

公开课

TED|如何通过讲故事编织文化传承

军事要闻

外交部:中印双方已就边境问题达成解决方案

无障碍浏览 进入关怀版