用 WebGL 实现雨打屏幕

赶着假期的尾巴整个活,在 WebGL2 上实现了一个雨打屏幕的效果。本文简单记载一下实现的思路,踩的坑以及一些优化方法。效果预览:

Raindrop Effectlab.sardinefish.com

<img src="data:image/svg+xml;utf8,” data-size=”normal” data-rawwidth=”1920″ data-rawheight=”969″ width=”1920″ data-original=”https://pic1.zhimg.com/v2-e90d137df38ec3e27c8fe4d5614b461c_r.jpg” data-actualsrc=”https://pic1.zhimg.com/v2-e90d137df38ec3e27c8fe4d5614b461c_b.jpg”>
效果图

图中所用背景来自 https://www.pixiv.net/artworks/84765992

雨滴算是一种比较常见的特效,Shadertoy 上能找到不少纯 Shader 的实现,例如 https://www.shadertoy.com/view/MdfBRXhttps://www.shadertoy.com/view/tlVGWKhttps://www.shadertoy.com/view/tlGcWG

但这些效果最大的瑕疵在于水滴之间没有任何交互作用,而现实中滑落的水滴会与其他水滴凝聚融合成一个更大的水滴加速滑落。这个复杂的逻辑很难单纯使用 Shader 实现,而结合 CPU 实现的雨滴效果个人最喜欢的是 Lucas BebberRain & Water Effect Experiments,但 Lucas Bebber 的实验性实现性能上存在缺陷,并且难以移植和复用,遂寻思着复刻一个。

<img src="data:image/svg+xml;utf8,” data-size=”normal” data-rawwidth=”1917″ data-rawheight=”935″ width=”1917″ data-original=”https://pic2.zhimg.com/v2-bb48536b05e2b5723d62e5f7f1730fc1_r.jpg” data-actualsrc=”https://pic2.zhimg.com/v2-bb48536b05e2b5723d62e5f7f1730fc1_b.jpg”>
Lucas Bebber 的 Rain &amp;amp;amp;amp; Water Effect Experiments 效果图

阅读本文需要一定的图形学和渲染管线前置知识,文中代码所用渲染器的接口设计和名称与 Unity 近似。

文中的代码为 TypeScript 语法的简化代码,仅用于辅助理解,实际代码参考 GitHub。

https://github.com/SardineFish/raindrop-fxgithub.com

Raindrop Simulation

Size & Mass

这个效果中,需要模拟雨滴在光滑物体表面的一些物理行为,我们以每一颗独立的雨滴作为一个模拟对象,雨滴以随机的时间间隔生成并保存到一个 array 中,每帧遍历更新每一个雨滴的运动状态。模拟的结果需要将每一颗雨滴的 2D 位置 position 和 2D 尺寸 size 交给渲染器渲染。在模拟中我们假定质量与尺寸的关系为 mass = size ^ 2,当然线性关系或是三次方的关系也 OK,但在测试中发现平方关系更好。

附着在屏幕上的雨滴可以随时间而蒸发减少其自身的质量,并在质量归零后予以删除,以此避免大量雨滴驻留在屏幕上造成性能压力。

在我的 Demo 中,60/s 的蒸发速度可以将整个屏幕的雨滴数量维持在400个左右,而无蒸发的情况下,长时间运行可能会积攒超过3000个雨滴,在有碰撞融合的情况下。

Motion

附着在屏幕上的水滴受重力影响向下滑落,观察现实中的水滴效果可以发现,较小的水滴通常附着在表面不会发生滑落,而较大的水滴则会受阻力和重力影响随机的向下滑落或是减速附着。这里我们可以通过每隔一定的时间间隔给雨滴设置随机的阻力实现这一效果,对于不同大小的雨滴均采用同样的阻力取值范围,如此较小较轻的水滴更难以滑落,而较大较重的水滴则更容易快速滑落。除此之外真实的水滴也不会笔直地下落,我们同样可以随机地引入水平速度偏移实现这一效果。

class Raindrop
{
    pos: vec2;
    velocity: vec2;
    mass: number;
    drag: number;
    offset: number;
    update(dt: number)
    {
        const acceleration = this.mass * GRAVITY - this.drag;
        this.velocity.y -= acceleration * dt;
        this.velocity.x = this.velocity.x * this.offset;
        this.pos += this.velocity * dt;

        // ...
    }
    // Call every 0.1s
    randomMotion()
    {
        this.drag = randomRange(0, GRAVITY * MAX_MASS * 2);
        this.offset = randomRange(-0.2, 0.2);
    }
}

Spread

雨滴打在表面的瞬间会向四周扩撒,随后很快又因为表面张力作用而收缩,我们可以引入一个属性 spread 用来描述雨滴的扩散程度,并令其随时间衰减,以实现这一效果。我们同样还可以利用这个属性让雨滴在加速下落时稍微纵向拉伸,在减速附着时纵向压缩。

class Raindrop
{
    // ...
    spread: vec2;
    onSpawn()
    {
        this.spread = vec2(0.5);
    }
    update(dt: number)
    {
        // ...

        this.spread.x *= Math.pow(0.01, dt);
        this.spread.y *= Math.pow(0.01, dt);
        this.spread.y = max(this.spread.y, this.velocity.y * SPREAD_BY_VELOCITY);
        this.size = this.baseSize * (this.spread + 1);
    }
}

Trail

雨滴在沿屏幕滑落时还会留下一道水迹,这道水迹又会因表面张力作用收缩成小水珠留在表面。我们可以给正在滑落的水滴处生成新的小水滴,并加以一定的 spread 实现这一效果,我们可以根据滑落的速度为新生成的小水滴设置不同的 spread.y 值,使得快速滑落的雨滴拥有更长更连续的水迹效果。(水迹连续的效果在后文的渲染实现部分介绍)

在这里我们可以考虑质量守恒的原则,对从滑落的水滴中减去新生成的水滴的质量。如此一来,水滴在滑落过程中不断损失质量,尺寸也随之减小,下落的速度也随之更大概率的降低。生成的小水滴尺寸越大,则滑落的水滴质量损失越快,我们这里采用的是 mass = size ^ 2 的关系,很容易在水滴滑落半个屏幕就损失掉大量的水分。于是这里我们可以引入一个表示水滴厚度的属性 density,即我们可以认为随着尾迹生成的水滴很薄,即便看上去具有较大的尺寸,但其质量可以不大。

新生成的水滴可以在水平方向加以一定的随机偏移以提升观感。

class Raindrop
{
    // ...
    lastTrailPos: vec2;
    density: vec2;
    consturctor(density: number, size: number)
    {
        this.mass = size * size * density;
        // ...
    }
    update(dt: number)
    {
        if(distance(this.pos - this.lastTrailPos) > TrailDistance)
        {
            trailDrop = new Raindrop(0.1, this.size.x * randomRange(0.3, 0.5));
            this.mass -= trailDrop.mass;
            trailDrop.spread = vec2(0.3, this.velocity.y * TrailSpreadByVelocity);
            trailDrop.position = this.pos + vec2(randomRange(-0.3, 0.3), 0.5 * this.size.y);
            trailDrop.parent = this;
            this.lastTrailPos = this.pos;
        }
        // ...
    }
}

Collision & Merge

为了提升真实感,我们需要模拟两个水滴发生碰撞时的聚合效果,朴素的实现方式可以是遍历所有雨滴进行距离计算,时间复杂度 O(N²)。当两个水滴的距离低于聚合的阈值时,取质量较大的雨滴合并他们的质量和动量,删除较轻的雨滴。在此前实现滑落水迹时,我们生成的水滴由于距离较近,可能会触发聚合,因此在前文的代码示例中加入了 trailDrop.parent 属性以避免这一问题。

collisionCheck()
{
    for(const raindrop of Raindrops)
    for(const other of Raindrops)
    {
        if (raindrop == other || raindrop.parent == other || raindrop == other.parent)
            continue;
        if(distance(raindrop.pos, other.pos) - raindrop.radius - other.radius = other.mass
                ? raindrop.merge(other)
                : other.merge(raindrop);
        }
    }
}
class Raindrop
{
    get radius() { return this.size.x * 0.6 }
    merge(other: Raindrop)
    {
        const momentum = this.mass * this.velocity + other.mass * other.velocity;
        this.mass += other.mass;
        this.velocity = momentum / this.mass;
        other.destroy();
    }
    // ...
}

Rendering

Merging

液体效果可以通过将液体粒子化,利用诸如 Metaballs 这类方法渲染出融合效果。Metaball 使用隐函数表示一个球体,例如:

F(x,y,z) = metaball(x, y, z) = \frac{1}{(x-x_0)^2 + (y-y_0)^2+(z-z_0)^2} \le Threshold

metaball 之间的融合通过加法实现,即若干个 metaball 融合后的物体隐函数表示为:

F(x,y,z) = \Sigma_{i}metaball_i(x,y,z)

我们将隐函数 F(x,y,z)=Threshold 的等值面渲染出来就可以得到如下图的效果

<img src="data:image/svg+xml;utf8,” data-caption=”” data-size=”normal” data-rawwidth=”800″ data-rawheight=”454″ width=”800″ data-original=”https://pic3.zhimg.com/v2-bdd72826285233336c660d9dc32de736_r.jpg” data-actualsrc=”https://pic3.zhimg.com/v2-bdd72826285233336c660d9dc32de736_b.jpg”>

这一方法对于 2D 上同样适用,我们可以令

<img src="https://www.zhihu.com/equation?tex=metaball%28x%2Cy%29+%3D+%5Cfrac%7B1%7D%7B%28x-x_0%29%5E2+%2B+%28y-y_0%29%5E2%7D+%3C%3D+Threshold" alt="metaball(x,y) = \frac{1}{(x-x_0)^2 + (y-y_0)^2}

作为一个 2D 的 metaball 图形,同样使用加法进行混合,如下图表示了一个 metaball(x,y) 的函数值,以及两个靠近的 metaball 叠加混合后的函数值:

<img src="data:image/svg+xml;utf8,” data-caption=”” data-size=”normal” data-rawwidth=”400″ data-rawheight=”375″ width=”400″ data-actualsrc=”https://pic4.zhimg.com/v2-93c7c97ce224627c5f0835b513bd872f_b.jpg”>

在这里,我们可以使用 texture 的 alpha 通道作为 metaball 的函数值,近似地使用一张径向渐变的 alpha 贴图作为 metaball,并使用一个 pass 过滤出混合后 alpha >= threshold 的部分

<img src="data:image/svg+xml;utf8,” data-size=”normal” data-rawwidth=”492″ data-rawheight=”318″ data-thumbnail=”https://pic3.zhimg.com/v2-8b19c71f49db56f4a9dd48743b88fece_b.jpg” width=”492″ data-original=”https://pic3.zhimg.com/v2-8b19c71f49db56f4a9dd48743b88fece_r.jpg” data-actualsrc=”https://pic3.zhimg.com/v2-8b19c71f49db56f4a9dd48743b88fece_b.gif”>
过滤前后的 alpha 通道混合效果

如图是对 alpha >= 0.95 进行过滤后的结果。

<img src="data:image/svg+xml;utf8,” data-size=”normal” data-rawwidth=”492″ data-rawheight=”318″ data-thumbnail=”https://pic1.zhimg.com/v2-1ccb435021a12628eb69ae462806ac58_b.jpg” width=”492″ data-original=”https://pic1.zhimg.com/v2-1ccb435021a12628eb69ae462806ac58_r.jpg” data-actualsrc=”https://pic1.zhimg.com/v2-1ccb435021a12628eb69ae462806ac58_b.gif”>
水滴融合的渲染效果

Refraction

透过雨滴类似凸透镜的折射效果,我们应该可以看到中心对成的背景像。要想得到更加正确的效果,可以通过计算雨滴表面法线,依照折射定律对背景采样的 UV 进行偏折。但在这里我们使用类似 Normal Mapping 的方法,将如下的图与径向模糊的 alpha 通道混合作为雨滴的纹理,渲染到一个 RenderTexture 中,随后使用这张 RenderTexture 中 r, g 通道的值对 UV 进行扭曲后采样背景贴图,实现折射效果的渲染。我们不妨在后文中称下图这张混合后的 Texture 为 DistortTexture,这张 Texture 中,的中心处 r = g = 0.5 表示偏折量为0。

<img src="data:image/svg+xml;utf8,” data-caption=”” data-size=”normal” data-rawwidth=”900″ data-rawheight=”280″ width=”900″ data-original=”https://pic4.zhimg.com/v2-79a27f6a6067688a8cc5566e722be0b3_r.jpg” data-actualsrc=”https://pic4.zhimg.com/v2-79a27f6a6067688a8cc5566e722be0b3_b.jpg”>
raindropMaterial.Texture = DistortTexture; // ↑ DistortTexture mentioned above
renderer.setRenderTarget(raindropCompose);
for (const raindrop of Raindrops)
{
    const modelMat = mat4.rts(quat.identity(), raindrop.pos, raindrop.size);
    renderer.drawMesh(quadMesh, modelMat, raindropMaterial);
}
refractMaterial.RaindropTex = raindropCompose;
refractMaterial.Background = backgroundImage;
renderer.blit(null, CanvasOutput, refractMaterial);

refractMaterial 的 Shader 可以用以下代码简单概括

uniform sampler2D RaindropTex;
uniform sampler2D Background;

in vec2 FragUV;

main()
{
    vec4 raindrop = texture(RaindropTex, FragUV.xy).rgba;
    float mask = smoothstep(0.95, 1,0, raindrop.a);
    vec2 refract = -(raindrop.xy * 2 - 1) * REFRACT_SCALE;
    vec3 color = texture(Background, FragUV.xy + refract.xy);

    fragColor = vec4(color.rgb, mask);
}

Compose & Blending

前面提到我们将所有雨滴的 DistortTexture 渲染到一张 RT 中,我们需要一个合理的 Alpha Blending 方法确保雨滴混合叠加后的 DistortTexture 能满足我们的需要。Lucas Bebber 的 Rain & Water Effect Experiments 实现中使用默认的 Alpha Blending,即

Out.rgb = Src.a * Src.rgb + (1 - Src.a) * Dst.rgb // Blend SrcAlpha OneMinusSrcAlpha

但在实际测试中,这种混合方法得到的 DistortTexture 表现出堆叠的折射效果,难以表现连成一片的平滑液面。反复尝试后发发现 Exclusion 的混合模式恰好能够很好的满足需要。至于其数学原理,本人才学疏浅,希望了解原理的评论区解释一下。

引入 Alpha 后的混合表达式如下:

Out.rgb = Src.a * Src.rgb + Dst.rgb - 2 * Src.a * Src.rgb * Dst.rgb
        = Src.a * Src.rgb * (1 - Dst.rgb) + Dst.rgb * (1 - Src.rgb)

即我们需要在 shader 中对 fragColor 预乘 alpha 之后对 RGB 采用 OneMinusDstAlpha OneMinusSrcAlpha 的混合模式,WebGL 和 Unity 中均可以实现。

<img src="data:image/svg+xml;utf8,” data-size=”normal” data-rawwidth=”1069″ data-rawheight=”500″ width=”1069″ data-original=”https://pic4.zhimg.com/v2-a1447c07545cf2ebfe74d6460f47ee07_r.jpg” data-actualsrc=”https://pic4.zhimg.com/v2-a1447c07545cf2ebfe74d6460f47ee07_b.jpg”>
上方一行为 normal 混合模式,下一行为 exclusion 混合模式

Tiny Droplets

除了大颗的雨滴外,我们还可以随机渲染一些细小的水珠以提升画面效果,这些水珠的尺度大概在2-5像素,不会发生滑落,因此我们可以将这些水珠单独累积渲染到一张 RenderTexture 中,而滑落的雨滴将会擦除这些累积的水珠,这可以通过特殊的混合模式将上一部分得到的 raindropCompose Texture 的 alpha 通道渲染到水珠的 RenderTexture 中,对于 RGB 和 Alpha 通道均采用 Zero OneMinusSrcAlpha 的混合模式。

我们将这里渲染水珠并擦除得到的 Texture 和 raindropCompose Texture 采用 exclusion 模式混合后一并用于对背景图的折射采样,就得到下图这样的效果:

<img src="data:image/svg+xml;utf8,” data-size=”normal” data-rawwidth=”893″ data-rawheight=”452″ width=”893″ data-original=”https://pic3.zhimg.com/v2-9431ad13168c4b74cd77ebc71aca17b2_r.jpg” data-actualsrc=”https://pic3.zhimg.com/v2-9431ad13168c4b74cd77ebc71aca17b2_b.jpg”>
带有微小水珠的效果图

Mist Rendering

我们还可以进一步加一层水雾效果,营造出屏幕内外的温差感(x

在我的实现中,对背景图采用 box filter 进行降采样+超采样模糊,以实现景深效果。而水雾层需要更高的背景模糊程度作为覆盖,同时加上少量的散射光效果。对水雾层同样采取前面提到的方式用雨滴进行擦除,以表现雨滴滑落流过的擦除效果。

<img src="data:image/svg+xml;utf8,” data-size=”normal” data-rawwidth=”762″ data-rawheight=”350″ width=”762″ data-original=”https://pic1.zhimg.com/v2-1d0ef01b19bde77016e9c7d3a8a5d234_r.jpg” data-actualsrc=”https://pic1.zhimg.com/v2-1d0ef01b19bde77016e9c7d3a8a5d234_b.jpg”>
带水雾的效果

Performance & Optimise

想必看到这大家应该都能发现两个明显的性能瓶颈,雨滴的碰撞检测和渲染。朴素的碰撞检测实现具有 O(N²) 的时间复杂度,而最简单的渲染对 N 个雨滴需要 N 次 DrawCall。

未优化的版本利用 Chrome DevTools 的性能测试结果如图,2000个雨滴,整个 update 包括模拟和渲染用了60ms……

<img src="data:image/svg+xml;utf8,” data-size=”normal” data-rawwidth=”997″ data-rawheight=”341″ width=”997″ data-original=”https://pic1.zhimg.com/v2-bd6e7cc0a1d318061717df92a45d87f8_r.jpg” data-actualsrc=”https://pic1.zhimg.com/v2-bd6e7cc0a1d318061717df92a45d87f8_b.jpg”>
优化前的性能测试结果

Grid-based Collision Check

对于碰撞检测的优化,简单地将屏幕区域网格化,网格尺寸取最大尺寸雨滴的融合距离的两倍,碰撞检测只需要对雨滴所在的区块和周围8个区块中的雨滴做测试。若60像素的网格尺寸,1080p的屏幕大约600个网格,2000个雨滴平均下来每个区块中只有3个雨滴,碰撞检测的时间复杂度大幅度降低。由于每个区块内维护的雨滴列表不需要有序,因此插入删除的时间复杂度为 O(1)(将列表末尾元素替换自身即可)优化后碰撞检测部分可以在2ms内完成。

Draw Instancing

雨滴渲染的瓶颈可以利用 WebGL2 的 Instancing 解决,细小水珠的渲染则可以利用 gl_InstanceID 在 vertex shader 里计算随机 size 和 position 合成 model 矩阵进行程序化的水珠渲染,随机数采用 dcerisano 的 Gold Noise 生成。

其他部分尽可能优化掉了所有可能创建临时 object 的代码,以降低开销

综合优化下来,2000个雨滴的性能开销降低到了 6ms 左右

<img src="data:image/svg+xml;utf8,” data-size=”normal” data-rawwidth=”920″ data-rawheight=”291″ width=”920″ data-original=”https://pic1.zhimg.com/v2-686b9ccbb31e6da617221a57b910fa5c_r.jpg” data-actualsrc=”https://pic1.zhimg.com/v2-686b9ccbb31e6da617221a57b910fa5c_b.jpg”>
优化后的性能测试结果

移动端兼容性测试,Chrome 安卓,QQ 的内置浏览器都 OK,其他没有测试。在我 Mi 10 的 Chrome 安卓上性能测试跟 PC 无异,每帧update 在 7ms 左右。

代码放在了 GitHub:

https://github.com/SardineFish/raindrop-fxgithub.com

使用简单,但也具有大量可配置参数,详见 GitHub

const canvas = document.queerySelector("#canvas");
const raindropFx = new RaindropFX({
    canvas: canvas,
    background: "url/to/backgroundImage",
});
raindropFx.start();