Cocos Creator流体渲染效率优化

一种粒子渲染优化方法

Posted by GT on August 17, 2020


流体效果相信大家都不陌生,一种常用的实现方式是基于metaballs,可以参考这里这里获取metaballs的shader代码。
如果要渲染有一定体积的流体,需要实时渲染几十上百甚至上千个metaball,此时渲染的效率就需要考虑进来。
论坛里已经有不少相关的实现,本文将对两种不同的实现进行学习分析并提出优化方案。
优化方案只针对运行效率,本文不讨论metaballs渲染的视觉效果优化。

方案1:box2d + shader

实现原理

通过box2d产生一批粒子, 将N个粒子的坐标通过uniform变量一次性传入shader 。
shader的片元着色器中,累积计算每个粒子对当前片元产生的“势能”(势能和距离相关),“势能”大于阈值时输出颜色,否则透明。

示例代码(shader)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
uniform ARGS{
    // ...
    // ts中从box2d直接获取每个粒子的坐标,进行简单处理后传入shader
    vec4 metaballs[500];
};

void main () {
    // ...
    float v = 0.0;			// 所有metaball对当前片元的影响将累加到变量v中
    for(int i = 0; i < 500; i++){
        vec4 mb = metaballs[i];
        // ...
        // ss是第i个metaball 对当前片元影响的“势能”, (cx, cy)为metaball坐标,r为半径
        float ss = r * r / (cx * cx + cy * cy);
        
        // 累加势能
        v += ss;
    }
    
    // 势能超过阈值则渲染为水流颜色,否则渲染为透明
    // ... 略
}

分析

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

方案2:PhysicsManager

实现原理

每个粒子是一个cc.Node,并挂上 物理碰撞组件 。
每个粒子用一张圆形渐变图渲染到内存纹理,纹理的半透明部分将叠加,相当于metaball里的势能叠加效果。然后用一个简单的shader按阈值处理颜色。

示例代码


分析

cc自带的碰撞检测的性能相对于box2d的粒子组碰撞检测效率要差一些,前者算法时间复杂度是O(N^2),后者在渲染同半径的粒子组时可以优化为O(NlogN)。在metaball数量较多的情况下差异会显现出来。
另外由于使用cc.Node包装了粒子,对引擎带来一定的overhead,如render-flow遍历时需要逐粒子做RenderData更新(相对于碰撞检测来说这部分可以忽略)。

方案3:box2d + assembler

实现原理

  1. 跟方案1一样,使用box2d产生粒子组。
  2. 在assembler里获取所有粒子坐标,批量组装成 RenderData 。
    针对每个粒子生成一个四边形,附带它在世界坐标里的原心位置,同时省略了uv和color属性。
    可以学习方案2对每个粒子使用圆形纹理图,本方案为了简单起见直接在shader里画圆。
    顶点格式如下:
    1
    2
    3
    4
    
    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 }           // 原粒子中心(每个顶点相同数据)
    ]);
    

示例代码

Demo内的 SceneMetaBalls 

分析

避开了方案1的GPU瓶颈和方案2的CPU碰撞计算瓶颈。
缺点是代码量相对较高,在自定义assembler内需要处理box2d坐标空间到屏幕空间的换算。

性能对比

测试环境:华为P9手机,chrome访问,开发模式
测试数据均来自cc自带调试面板数据的目测。

数据解释

WebGL的数据不知道为啥一直是0,以上没有记录。
方案1的Game Logic很低,但是帧率较低,结合不难推断出是GPU压力影响了整体帧率;
方案2的Game Logic偏高,主要是物理碰撞检测导致的CPU压力;
进一步对方案3进行profile,可以发现,最耗时的仍然是粒子碰撞检测部分,其中最耗时的部分是box2d里寻找粒子之间的连接点,见下图。

经实际运行统计,在1000个粒子的场景下将产生7000+个碰撞点,函数内部循环次数加到14000+。这是一帧的运算量,所以内部循环是热点代码。利用临时变量合并一些公共表达式 可以实现少量性能提升,不过需要改引擎内部。

Demo地址

https://github.com/caogtaa/CCBatchingTricks

参考

https://forum.cocos.org/t/topic/92305
https://forum.cocos.org/t/shader/92906
https://forum.cocos.org/t/happy-glass/72468/46
https://docs.google.com/presentation/d/1fEAb4-lSyqxlVGNPog3G1LZ7UgtvxfRAwR0dwd19G4g/htmlpresent