流体效果
相信大家都不陌生,实现方式中的一种是将粒子渲染成 metaball
。
什么是metaball
metaball
就是粒子加上其周围的 密度场
(density field)。两个 metaball
靠近时,其密度场会叠加。当屏幕上某个像素的"密度"大于阈值时,将其着色,小于阈值的像素按透明处理。
一个简单的 metaball
片元着色器实现如下:
void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
vec2 uv = (fragCoord * 2. - iResolution.xy) / iResolution.y;
// 指定两个metaball的坐标以及半径
float r = 0.08;
vec3 metaballs[2];
metaballs[0] = vec3(-0.4, 0.0, r);
metaballs[1] = vec3(0.4, 0.0, r);
float density = 0.0;
float sum = 0.0;
for (int i = 0; i < 2; i++) {
// 当前uv在第i个metaball密度场内的密度值
// 密度场和距离相关,公式可以视情况调整
density = metaballs[i].z / distance(metaballs[i].xy, uv);
// 累加密度值
sum += density;
}
// 不显示密度和小于阈值的片元,阈值可以视情况调整
sum = step(0.8, sum);
fragColor = vec4(vec3(sum), 1.0);
}
如果要渲染有一定体积的流体,需要实时渲染几十上百甚至上千个metaballs,此时渲染的效率就需要考虑进来。
在Cocos论坛里已经有不少相关的实现,本文将对两种不同的方案进行学习分析并提出优化方案。相关链接均放在文章底部。
优化方案只针对运行效率,本文不讨论渲染的视觉效果优化。
方案同样适用于流体之外的其他粒子特效。
方案1:box2d + shader
实现原理
通过
box2d
产生一批粒子。Cocos Creator的物理引擎封装了box2d,通过以下代码可以创建box2d的粒子组:
var psd = new b2.ParticleSystemDef();
// ... 粒子半径、阻尼等初始化代码,具体写法可参考box2d API文档
cc.director.getPhysicsManager()._world.CreateParticleSystem(psd);
将所有粒子的坐标通过uniform变量一次性传入shader。shader写法和本文开头类似,需要根据粒子数量增加循环次数。
分析
片元着色器程序需要遍历所有metaball,500个metaball的情况下需要执行上千条指令。每一帧每个片元都需要执行这么长的程序,并且往往这种渲染方式需要全屏幕渲染,GPU压力将非常大。
方案2:cc.Node + 物理碰撞
实现原理
每个粒子是一个cc.Node,并挂上
物理碰撞组件
。每个粒子用一张圆形渐变图渲染到内存纹理,圆形渐变图等效于粒子的密度场。
用shader处理内存纹理,剔除小于阈值的像素。
分析
cc自带的碰撞检测的性能相对于box2d的粒子组碰撞检测效率要差一些,前者算法时间复杂度是O(N^2),后者在渲染同半径的粒子组时可以优化为O(NlogN),具体算法可参考文末PPT链接。在metaball数量较多的情况下差异会显现出来。
另外由于使用cc.Node包装了粒子,对引擎带来一定的overhead,如render-flow遍历时需要逐粒子做RenderData更新(相对于碰撞检测来说这部分可以忽略)。
方案3(优化方案):box2d + 自定义assembler
实现原理
跟方案1一样,使用box2d产生粒子组。
在assembler里获取所有粒子坐标,批量组装成顶点数据。
针对每个粒子生成一个四边形,附带它在世界坐标里的原心位置,同时省略了uv和color属性。
顶点格式如下:
var vfmtPosCenter = new gfx.VertexFormat([
{ name: gfx.ATTR_POSITION, type: gfx.ATTR_TYPE_FLOAT32, num: 2 }, // 粒子顶点(1个粒子有3个或4个顶点)
{ name: "a_center", type: gfx.ATTR_TYPE_FLOAT32, num: 2 } // 原粒子中心(每个顶点相同数据)
]);
box2d坐标空间到cc世界坐标的转换方法如下
// 获取粒子在cc世界空间里的半径大小
let PTM_RATIO = cc.PhysicsManager.PTM_RATIO;
let r = particles.GetRadius() * PTM_RATIO;
let posBuff = particles.GetPositionBuffer();
let particleCount = particles.GetParticleCount();
for (let i = 0; i < particleCount; ++i) {
// 获取粒子在cc世界空间里的坐标
let x = posBuff[i].x * PTM_RATIO;
let y = posBuff[i].y * PTM_RATIO;
// ... 拼装第i个粒子的顶点数据
}
可以学习方案2对每个粒子使用圆形纹理图,本方案为了简单起见直接在shader里画圆。
更加高效的渲染方式应该是用 GL_POINTS
模式配合纹理渲染一个粒子,但是我还没掌握在cc里实现的方法。
分析
避开了方案1的GPU瓶颈和方案2的CPU碰撞计算瓶颈。
缺点是代码量相对较高。完整实现见文末Demo。
性能对比
测试环境:华为P9手机,chrome访问,开发模式
测试数据均来自cc自带调试面板数据的目测。
数据解释
方案1的Game Logic很低,但是帧率较低,结合其实现原理不难推断出是GPU压力影响了整体帧率;
方案2的Game Logic偏高,主要是物理碰撞检测导致的CPU压力;
方案3整体运行相对更加流畅。在粒子流动的过程中fps较高,但是当粒子积压在场景底部时fps降低。这是因为在流体里面物理计算量和粒子之间的连接点数成正比。在水流动过程中连接点较少,当粒子全部落到底部趋于静止时每个粒子周围都有多个连接点,计算量最大。
代码低级优化
进一步对方案3进行profile,可以发现,最耗时的仍然是 粒子碰撞检测
部分,其中最耗时的部分是box2d里寻找粒子之间的连接点,见下图。
经实际运行统计,在1000个粒子的场景下将产生7000+个碰撞点,函数内部循环次数加到14000+。这是一帧的运算量,所以内部循环是热点代码。
可以做一些代码低级优化进一步提升性能,包括
将函数调用展开,包含大量的向量计算函数
用临时变量代替公共表达式,减少重复计算
优化后的profile如下图,在整体CPU耗时占比中下降了10%,但是仍然是大头。
iOS微信小游戏测试数据
方案3 Demo在iPhone SE2上实测,1000个粒子流动过程中为60fps,粒子积压在底部时为5fps。直接在Safari浏览器里面跑可以达到60fps。
由于iOS微信小游戏环境无法开启jit,在计算密集型场景下效率非常差,目前没有较好的解决方案。
Demo地址
https://github.com/caogtaa/CCBatchingTricks