模拟经典流体解谜游戏!Cocos Creator 三步实现动态 2D 液体
游戏中可交互的液体是一种颇为吸引人的元素,比如经典人气解谜游戏《鳄鱼小顽皮爱洗澡》就通过简单的「引水入缸」玩法收获了一大票玩家。
在 Cocos Creator 3.x 中,若想实现 2D 液体、同时兼顾运行效率,可以选择使用 Box2D 的物理粒子效果来进行模拟。物理粒子系统除了适合用于模拟液体以外,我们也可以用于模拟任何可变形的物体。这里将解析由引擎技术支持中心带来的 Cocos Creator 的动态 2D 液体解决方案。
点击文末【阅读原文】下载 DEMO:
https://store.cocos.com/app/detail/
PART 1
使用方式
场景搭建
在 Cocos Creator 中新建一个空场景,并创建一个 UICanvas
:

创建一个用于液体渲染的相机 Camera-001
:

对于这个相机,使其 ClearFlag = SOLID_COLOR
:

这个相机的作用是将液体绘制到一张 RT 之后将这个动态纹理投射到 UI 内的某个 sprite
上面。
之后就在场景内布置碰撞体,既然是 Box2D,碰撞体记得选 2D,不然碰撞体就会使用 Bullet 和 Box2d 无关了:

Cocos Creator 里封装了两种物理引擎 Bullet 和 Box2D,两者处于单独的世界。
在 Box2d 里面如果希望碰撞体之间的碰撞有效,那么至少有一方需要持有 Rigidbody2D 组件。因此需要给碰撞体添加一个 RigidBody2D,其类型选择为 static
。这样物理引擎不会去模拟他的速度和受力情况。

添加液体
新建一个空的 UINode
,也就是只有 UITransform 组件的一个 Node:

为其添加 WaterRender
这个组件:

之后指定好他的一些值:
自定义材质;

- FixError :FixError 指向的是一张1个 2x2 的纯色小纹理;

- 水管:水管由多个碰撞体构成,这样可以约束液体,使其往我们想要的地方流动;


效果预览
播放预览一下效果(这里开启了物理调试,可以更清晰的观察到粒子的运行状态):

PART 2
前置知识
物理引擎
Box2D 是一款轻量级的 2D 游戏物理引擎。主流游戏引擎的 2D 物理部分大都使用 Box2D 来完成的。在物理引擎模拟中,通过质心的受力,计算出其速度和加速度等最终得到物体所在的位置,之后渲染引擎会读取物理引擎的计算结果并将其应用到渲染上。
LiquidFun

LiquidFun 是基于 Box2D 的扩展库,作用就是给 Box2D 添加了模拟液体的粒子系统。该库由谷歌高级程序员 Kentaro Suto 开发,源代码由 C++ 编写,并翻译为 JavaScript。
组装器
在游戏引擎中,绘制精灵或者模型时,都需要通过生成特定的顶点,并调用驱动方法(OpenGL,DirectX ...等)绘制到屏幕上。在 Cocos Creator 里面,如果我们要绘制一系列顶点到屏幕上,需要使用到 Assembler
组装器。
组装器顾名思义就是将顶点组装起来,以供渲染组件使用。通过这个 Assembler
,可以自定义顶点的位置、颜色、纹理坐标、索引。
Cocos Creator 里有多种 Assembler
:
/**
* simple 组装器
* 可通过 `UI.simple` 获取该组装器。
*/+
export const simple: IAssembler
/**
Tiled组装器
*/
export const tiled: IAssembler =
...
DEMO 中通过读取物理引擎内粒子的位置,计算出了顶点缓存内所有顶点的相关信息。
PART 3
原理解析
render.ts
里面有两个类 WaterRender
和 WaterAssembler
。
WaterRender 解析
WaterRender
是整个 DEMO 的核心类,负责粒子的创建和渲染。
Renderable2D
WaterRender
继承自 Renderable2D
。在 Cocos Creator 中,任何需要渲染的 Node
对象都会持有一个 RenderableComponent
,其中 Renderable2D
是 Cocos Creator 中渲染 2D 组件的基类。
通过重写 _render
方法,自定义自己的渲染方案。这里通过使用自定义的 _assembler
来组装需要绘制的几何体。
/**
*commitComp会提交当前的渲染数据给渲染管线
*/
protected _render(render: any) {
render.commitComp(this, this.fixError, this._assembler!, null);
}
创建粒子系统
我们可以把液体理解为由很多个小的水滴组成。这样对于物理引擎来说,就可以选择使用粒子系统,以一种高效的方式,来模拟大量水滴运动的行为。
创建粒子系统:
var psd_def = {
strictContactCheck: false,
density: 1.0,
gravityScale: 1.0,
radius: 0.35, //这里指定了粒子的半径
...
}
this._particles = this._world.physicsWorld.impl.CreateParticleSystem(psd);
创建粒子组:
var particleGroupDef = {
...
shape: null,
position: {
x: this.particleBox.node.getWorldPosition().x / PHYSICS_2D_PTM_RATIO,
y: this.particleBox.node.getWorldPosition().y / PHYSICS_2D_PTM_RATIO
},
// @ts-ignore
shape: this.particleBox._shape._createShapes(1.0, 1.0)[0]
};
this._particleGroup = this._particles.CreateParticleGroup(particleGroupDef);
this.SetParticles(this._particles);
粒子组为粒子发射器定义了一组粒子,这些粒子拥有自定义的形状:
//创建BoxCollider2D的几何形状
shape: this.particleBox._shape._createShapes(1.0, 1.0)[0]
通过对液体的观察,可以发现液体有一些常见的特性:
水往低处流,水滴会沿着碰撞体的表面进行移动
gravityScale: 1.0
,定义了粒子受重力影响的系数;黏连性,可观察到两个水滴靠近时,会在液体的作用力下相互吸引,通过定义
viscousStrength
来定义粒子的黏连;压缩,液体粒子间会进行压缩,由下面的值来定义粒子允许进行的压缩:
pressureStrength
staticPressureStrength
staticPressureRelaxation
staticPressureIterations
表面张力, 我们都知道在水面上放硬币,硬币不会沉底的实验。这个就是液体的表面张力。通过下面两个属性,可以调整液体的表面张力:
surfaceTensionPressureStrength: 0.2,
surfaceTensionNormalStrength: 0.2,
WaterAssembler 解析
WaterAssembler
为 RenderableComponent
提供顶点缓存的定制。
在这个类里面,通过访问粒子系统的每一个粒子的位置,生成4个单独的顶点:
let posBuff = particles.GetPositionBuffer();
let r = particles.GetRadius() * PHYSICS_2D_PTM_RATIO * 3;
for (let i = 0; i < particleCount; ++i) {
let x = posBuff[i].x * PHYSICS_2D_PTM_RATIO;
let y = posBuff[i].y * PHYSICS_2D_PTM_RATIO;
// left-bottom
vbuf[vertexOffset++] = x - r; //x
vbuf[vertexOffset++] = y - r; //y
vbuf[vertexOffset++] = 0; // z
vbuf[vertexOffset++] = x; // u
vbuf[vertexOffset++] = y; // v
...
}
最后计算索引缓存:
// fill indices
const ibuf = buffer.iData!;
for (let i = 0; i < particleCount; ++i) {
ibuf[indicesOffset++] = vertexId;
ibuf[indicesOffset++] = vertexId + 1;
ibuf[indicesOffset++] = vertexId + 2;
ibuf[indicesOffset++] = vertexId + 1;
ibuf[indicesOffset++] = vertexId + 3;
ibuf[indicesOffset++] = vertexId + 2;
vertexId += 4;
}
顶点缓存描述了顶点的数据,索引缓存指定了顶点的绘制顺序。
这样就生成了一个基于粒子中心点的矩形。但是我们最终看到的是圆形,这里的魔法就是通过材质和 Effect 系统来解决的。
材质和 Shader 解析

模拟时,需要使用 effect.effect
特效俩来模拟。
注意这里选择的是 transparent 的 technique:

在 effect.effect
的 vert
函数内,计算了两个传输到 frag
的变量:v_corner
和 v_center
,这两个变量代表的是粒子位置的中心点和角落的位置:
out vec2 v_corner;
out vec2 v_center;
vec4 vert () {
vec4 pos = vec4(a_position.xy, 0, 1);
// no a_corner in web version
// use a_position instead of a_corner
v_corner = a_position.xy * reverseRes;
// 由于粒子是纯色的,texCoord 里面记录的是粒子的中心点位置
v_center = a_texCoord.xy * reverseRes;
v_corner.y *= yratio;
v_center.y *= yratio;
return cc_matViewProj * pos;
}
这两个变量在 frag
里面通过 smoothstep
进行插值的计算:
smoothstep(edge0, edge1, x)
这个函数会根据x计算赫尔米特插值:
t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
return t * t * (3.0 - 2.0 * t);

在 frag()
函数内通过计算像素位置和粒子中心的距离,使用 smoothstep
进行插值,粒子的半径就会被控制在3倍到1倍半径之间。同时由于是根据中心和半径计算,粒子也会从矩形变成圆形:
in vec2 v_corner;
in vec2 v_center;
vec4 frag () {
float mask = smoothstep(radius * 3., radius, distance(v_corner, v_center));
return vec4(1.0, 1.0, 1.0, mask);
}
此时绘制出来的粒子颜色是白色:

最后通过 display.effect
配合 render texture 将其渲染为蓝色:

在 display.effect
使用了属性查看器内传入的颜色 color
:
in vec4 color;
#if USE_TEXTURE
in vec2 uv0;
#pragma builtin(local)
layout(set = 2, binding = 10) uniform sampler2D cc_spriteTexture;
#endif
vec4 frag () {
vec4 o = vec4(1, 1, 1, 1);
#if USE_TEXTURE
o *= CCSampleWithAlphaSeparated(cc_spriteTexture, uv0);
#if IS_GRAY
float gray = 0.2126 * o.r + 0.7152 * o.g + 0.0722 * o.b;
o.r = o.g = o.b = gray;
#endif
#endif
o.a = smoothstep(0.95, 1.0, o.a);
o *= color;
ALPHA_TEST(o);
return o;
}
这个时候由于 alpha 的问题会出现一些毛边:

因此通过 smoothstep(0.95, 1.0, o.a)
,将像素的 alpha 值都控制在0.95到1之间。
通过这个渲染我们可以看到,其实做游戏不一定非要真实的去模拟,我们只要骗过眼睛就能做出很好的效果了!
本期分享就到这里,DEMO 地址见评论。在之前我们也和大家分享过 Cocos Creator 3.x 的 2D 动态光照、2D 实时阴影的技术实现方案,更多方案与 DEMO 请移步论坛集中贴,如果有想了解的技术或效果实现,欢迎在评论区留言,后续我们会更新更多关于游戏引擎的技术分享。
论坛集中贴
https://forum.cocos.org/t/topic/124637
参考链接&扩展阅读
Box2D 开源:
https://github.com/erincatto/box2d
LiquidFun 官网:
https://github.com/google/liquidfun
LiquidFun 参考文档:
https://google.github.io/liquidfun/Programmers-Guide/html/index.html
SmoothStep:
https://en.wikipedia.org/wiki/Smoothstep
往期精彩