又是一个流体渲染方案,但这次让它跑得更流畅


流体效果相信大家都不陌生,一种常用的实现方式是基于metaballs,可以参考这里这里获取metaballs的shader代码。

如果要渲染有一定体积的流体,需要实时渲染几十上百甚至上千个metaball,此时渲染的效率就需要考虑进来。

论坛里已经有不少相关的实现,本文将对两种不同的实现进行学习分析并提出优化方案。
优化方案只针对运行效率,本文不讨论metaballs渲染的视觉效果优化。

方案1:box2d + shader

实现原理

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

示例代码(shader)


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里画圆。
    顶点格式如下:
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

20200819更新

iOS微信小游戏测试数据

方案3 Demo在iPhone SE2上实测,1000个粒子流动过程中为60fps,粒子积压在底部时为5fps。
直接在Safari浏览器里面跑可以达到60fps。

分析

由于iOS微信小游戏无法开启jit,在计算密集型场景下效率非常差。
在流体里面物理计算量和粒子之间的连接点数成正比。
在水流动过程中连接点较少,当粒子全部落到底部趋于静止时每个粒子周围都有多个连接点,计算量最大。

期待物理大佬能提供一些解决思路。

40赞

:blush: 大赞…

牛逼!!!!!!

大佬牛逼!!!

1赞

虽然没全部看完,但是应该是很牛逼的,先点赞后再看

终于等到GT的流体啦

1赞

先赞为敬!

大佬 , 收下我的膝盖 。

大佬牛逼6666

NB a DL

太赞了,向大佬致敬,申请转载『Creator星球游戏开发社区』微信公众号:yum:

GT大佬不愧是你!!!

大佬牛逼!!!

大佬,为什么我定义一组粒子没办法跟挂了物理组件的物体碰撞啊。粒子是放在cc.director.getPhysicsManager()._world这个世界下面的。但是我在这个世界下面自己用box2d去创建一个body是可以正常碰撞的。

你得看引擎是怎么同步坐标的,要和引擎的同步的话,你绘制的坐标也要一起同步:smiling_imp:

https://mp.weixin.qq.com/s/Ht0kIbaeBEds_wUeUlu8JQ

7赞

MARK后再看

当然可以:laughing: ,我还要继续整理下,可以等我先发到公众号直接转载。

可以检查一下cc.Node的group属性。

解决了,感谢指点

赞后再看。