GT 大神 | 如何高效渲染流体效果(绝对干货)

流体效果 相信大家都不陌生,实现方式中的一种是将粒子渲染成 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

实现原理

  1. 通过 box2d 产生一批粒子。Cocos Creator的物理引擎封装了box2d,通过以下代码可以创建box2d的粒子组:

var psd = new b2.ParticleSystemDef();
// ... 粒子半径、阻尼等初始化代码,具体写法可参考box2d API文档
cc.director.getPhysicsManager()._world.CreateParticleSystem(psd);
  1. 将所有粒子的坐标通过uniform变量一次性传入shader。shader写法和本文开头类似,需要根据粒子数量增加循环次数。

分析

片元着色器程序需要遍历所有metaball,500个metaball的情况下需要执行上千条指令。每一帧每个片元都需要执行这么长的程序,并且往往这种渲染方式需要全屏幕渲染,GPU压力将非常大。

方案2:cc.Node + 物理碰撞

实现原理

  1. 每个粒子是一个cc.Node,并挂上 物理碰撞组件 。

  2. 每个粒子用一张圆形渐变图渲染到内存纹理,圆形渐变图等效于粒子的密度场。

  3. 用shader处理内存纹理,剔除小于阈值的像素。

分析

cc自带的碰撞检测的性能相对于box2d的粒子组碰撞检测效率要差一些,前者算法时间复杂度是O(N^2),后者在渲染同半径的粒子组时可以优化为O(NlogN),具体算法可参考文末PPT链接。在metaball数量较多的情况下差异会显现出来。

另外由于使用cc.Node包装了粒子,对引擎带来一定的overhead,如render-flow遍历时需要逐粒子做RenderData更新(相对于碰撞检测来说这部分可以忽略)。

方案3(优化方案):box2d + 自定义assembler

实现原理

  1. 跟方案1一样,使用box2d产生粒子组。

  2. 在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

猜你喜欢

转载自blog.csdn.net/6346289/article/details/108211853