雨滴特效的 Three.js 实现

大家好!我是晓智,一位专注 Web3D、Shader、AI 方向的前端开发者。

这里会持续分享 Three.js、WebGL、Shader 特效、AI 可视化 等实战内容。

如果你也热爱前沿技术,欢迎一起交流成长 🚀

🔥 在线预览 + 源码学习:

👉 点击查看《雨滴特效》完整源码与效果演示

效果预览

玻璃窗上的雨滴、雾气、折射扭曲、爱心图案、闪电暗角等特效,该特效基于threejs自定义shader实现。

效果图

Shader 实现原理

1. 整体思路与物理模型

真实雨滴效果的物理本质是光线穿过非均匀介质(水滴)后的折射与散射。在 GPU 上,我们没有光线追踪的预算,因此需要用纹理重采样的方式近似:

  1. 每个像素计算该位置的水滴密度场
  2. 从密度场提取法线(梯度方向)
  3. 用法线偏移 UV,重新采样背景纹理
  4. 水滴越密集,偏移越强,同时 LOD 级别越高(越模糊)

这个模型忽略了几何光学中的斯涅尔定律和菲涅尔方程,但在视觉层面已经足够可信 ------ 因为它抓住了人眼最关心的两个特征:折射扭曲景深模糊

2. 伪随机数生成 --- Hash 函数的数学

Shader 中没有真正的随机数,所有"随机"都来自确定性 Hash。代码中使用了三种变体:

2.1 N13 --- 3D Hash(Dave Hoskins 经典实现)

scss 复制代码
vec3 N13(float p) {
    vec3 p3 = fract(vec3(p) * vec3(.1031, .11369, .13787));
    p3 += dot(p3, p3.yzx + 19.19);
    return fract(vec3((p3.x + p3.y)*p3.z, (p3.x+p3.z)*p3.y, (p3.y+p3.z)*p3.x));
}

数学分析:

  • 第一步 :输入 p 乘以一个无理数向量。选择 .1031.11369.13787 是因为它们的小数部分在二进制表示中分布均匀,避免了周期性模式。
  • 第二步dot(p3, p3.yzx + 19.19)非线性混合yzx 是分量轮换,19.19 是偏置常数。这一步把三个通道的信息交叉融合在一起。
  • 第三步 :交叉相乘后取小数部分,输出三个在 [0,1) 上近似均匀分布的值。

这个 Hash 的质量在于雪崩效应 :输入哪怕改变 1e-6,输出也会完全不同。在 GPU 上,这是用浮点运算模拟离散 Hash 的最经济方式。

2.2 N / N14 --- 1D/4D Hash

arduino 复制代码
float N(float t) {
    return fract(sin(t*12345.564)*7658.76);
}

这是正弦 Hash 的最简形式:sin(x) 在大量程下呈现混沌行为,fract() 把输出折叠到 [0,1)。常数 12345.5647658.76 的选择原则:避免 sin 的周期 与这些常数形成有理数倍关系,否则会出现格子状伪影。

3. 网格系统与空间索引

3.1 为什么要分网格?

如果每个像素都遍历所有水滴,复杂度是 O(n²)。分网格后,每个像素只计算所在网格内的水滴,复杂度降到 O(1)

ini 复制代码
vec2 a = vec2(6., 1.);      // 网格长宽比
vec2 grid = a * 2.;          // 实际网格密度
vec2 id = floor(uv * grid);  // 网格 ID

选择 6:1 的长宽比是因为雨滴在垂直方向更密集(受重力影响,竖直拖尾更长)。grid = a * 2 表示每个单元进一步细分,提供更多随机位置。

3.2 列偏移(Column Shift)

ini 复制代码
float colShift = N(id.x);
uv.y += colShift;
id = floor(uv * grid);

这一步的目的是打破网格的周期性 。如果不做列偏移,所有列的水滴都会水平对齐,看起来像栅栏。通过每列施加不同的垂直偏移 N(id.x),水滴在垂直方向上交错分布,更接近真实的随机雨幕。

4. 水滴的 SDF 建模

4.1 主水滴 --- 椭圆 SDF

ini 复制代码
float d = length((st - p) * a.yx);
float mainDrop = S(.4, .0, d);

(st - p) * a.yx 是对距离做各向异性缩放a = vec2(6, 1),所以 a.yx = vec2(1, 6),x 方向压缩 1 倍,y 方向压缩 6 倍。这样 length() 计算的是椭圆距离,而不是圆形距离。

为什么用椭圆?因为真实水滴在玻璃上受重力拉伸,近似为竖直拉长的形状。

S(.4, .0, d) 是 smoothstep 的反用法:当 d < 0 时输出 1,d > 0.4 时输出 0。水滴中心密度为 1,边缘平滑过渡到 0。

4.2 拖尾 --- 分段函数建模

ini 复制代码
float r = sqrt(S(1., y, st.y));
float cd = abs(st.x - x);
float trail = S(.23*r, .15*r*r, cd);
float trailFront = S(-.02, .02, st.y - y);
trail *= trailFront * r * r;

拖尾的数学结构分三层:

  1. r = sqrt(S(1., y, st.y)) --- 控制拖尾宽度随高度变化。S(1., y, st.y)st.y < y 时接近 1(拖尾根部),在 st.y > y 时接近 0。sqrt() 把宽度压缩,让拖尾更细。
  2. S(.23*r, .15*r*r, cd) --- 横向的 smoothstep 控制拖尾轮廓。.23*r.15*r*r 分别是外边界和内边界,形成类似水滴侧面的弧形轮廓。
  3. trailFront = S(-.02, .02, st.y - y) --- 只在水滴下方生成拖尾。这是一个极窄的 smoothstep(宽度仅 0.04),作为硬截断,确保拖尾不会延伸到水滴上方。

4.3 小水珠 --- 噪声撒点

ini 复制代码
float droplets = max(0., (sin(y*(1.-y)*120.)-st.y)) * trail2 * trailFront * n.z;

sin(y*(1.-y)*120.) 是一个振荡函数y*(1-y)y=0.5 处达到最大值 0.25,乘以 120 后 sin 在拖尾区域快速振荡。max(0, ...) 只保留正值,形成一排锯齿状的小水珠。


5. Smoothstep 的数学本质

整个 shader 大量使用了 smoothstep(a, b, t),定义为:

css 复制代码
S(a,b,t) = 0                          , t ≤ a
S(a,b,t) = 1                          , t ≥ b
S(a,b,t) = hermite((t-a)/(b-a))       , a < t < b

其中 hermite(x) = 3x² - 2x³,即三次 Hermite 插值。它的导数在边界处为 0,因此过渡是 C1 连续的,没有硬边。

在 shader 中,smoothstep 被用于:

  • SDF 轮廓:把距离转换为密度
  • 参数映射:把输入值映射到输出范围(如雨量控制三层水滴的权重)
  • 时间过渡:淡入淡出、爱心出现消失

相比线性插值 mix,smoothstep 在边界处的导数为 0,不会产生视觉上的"断裂感"。


6. 法线计算 --- 从密度场到折射偏移

6.1 为什么梯度等于法线?

水滴密度场 c(x,y) 可以看作一个高度图:水滴中心密度高("凸起"),周围密度低("平坦")。真实玻璃上的水滴是凸透镜形状,光线穿过时发生折射。

在微小尺度下,折射偏移的方向与表面法线在屏幕空间的投影成正比。而法线方向恰好是密度场的负梯度方向

r 复制代码
n = -∇c = -(∂c/∂x, ∂c/∂y)

代码中用差分近似梯度:

ini 复制代码
vec2 e = vec2(.001, 0.);
float cx = Drops(uv + e, t, ...).x;
float cy = Drops(uv + e.yx, t, ...).x;
vec2 n = vec2(cx - c.x, cy - c.x);

这是中心差分 的单侧近似:∂c/∂x ≈ (c(x+ε) - c(x)) / ε,其中 ε = 0.001。选择 0.001 是因为:

  • 太小会受浮点精度限制(32-bit float 的有效位数约 7 位)
  • 太大会丢失高频细节

6.2 dFdx/dFdy 的取舍

scss 复制代码
vec2 n = vec2(dFdx(c.x), dFdy(c.x));

dFdx/dFdy 是 GPU 硬件指令,在一个 quad(2x2 像素)内计算偏导数。优点是零额外计算开销,缺点是:

  • 精度受 quad 内像素差异限制
  • 在边缘处可能产生 artifact

CHEAP_NORMALS 模式下使用,适合性能敏感场景。


7. 景深模糊 --- Mipmap 作为预计算高斯金字塔

7.1 为什么 textureLod 能实现模糊?

textureLod(sampler, coord, lod) 显式指定 mipmap 级别采样。Mipmap 的生成规则是:

yaml 复制代码
level 0: 原始分辨率
level 1: 每 2x2 像素平均 → 分辨率 / 2
level 2: 每 2x2 像素平均 → 分辨率 / 4
...
level n: 分辨率 / 2ⁿ

每一级 mipmap 本质上是一个**盒式滤波(box filter)**的结果,级联后近似高斯模糊。lod = 2 相当于 4x4 的采样窗口,lod = 6 相当于 64x64 的采样窗口。

7.2 Focus 的混合策略

ini 复制代码
float focus = mix(maxBlur - c.y, minBlur, S(.1, .2, c.x));

这里的 c.x 是水滴密度,c.y 是拖尾强度:

  • c.x < 0.1(无水滴区域):S(.1, .2, c.x) ≈ 0focus ≈ maxBlur - c.y。但 c.y 在无水滴处也为 0,所以 focus ≈ maxBlur。等等,这不对...

实际上 maxBlurminBlur 的值分别是 6 和 2(或根据雨量变化)。maxBlur - c.y 表示拖尾区域更模糊。当 c.x 增大(进入水滴区域),S(.1, .2, c.x) 趋近于 1,focus 趋近于 minBlur(更清晰)。

这似乎和直觉相反:水滴中心应该更模糊?不对,仔细看代码逻辑:

实际上 maxBlur = mix(3., 6., rainAmount)minBlur = 2.。雨量越大,maxBlur 越大。

focus = mix(maxBlur - c.y, minBlur, S(.1, .2, c.x))

c.x 很小(无水滴):S ≈ 0focus ≈ maxBlur。模糊。 当 c.x 很大(水滴中心):S ≈ 1focus ≈ minBlur。相对清晰。

这意味着水滴本体是清晰的,周围是模糊的------ 这模拟了人眼透过水滴看物体的效果:水滴本身像一个小透镜,中心的物体相对清晰,边缘因为曲面折射而模糊。

7.3 与后处理 Blur 的对比

传统后处理模糊需要额外的 render pass(如 Gaussian blur 或 Kawase blur),而 textureLod 直接复用 GPU 已经计算好的 mipmap 链,零额外内存开销、零额外计算开销。这是本特效在性能上最精妙的设计。


8. 后期处理的信号分析

8.1 颜色调制 --- AM 调制的视角

scss 复制代码
float colFade = sin(t * .2) * .5 + .5 + story;
col *= mix(vec3(1.), vec3(.8, .9, 1.3), colFade);

sin(t * .2) 的周期是 2π / 0.2 = 10π ≈ 31.4 秒,经过 * .5 + .5 映射到 [0, 1]。这是一个极低频振荡器(LFO) ,以 31 秒为周期在冷暖色调之间缓慢摆动。

从信号处理角度,这是对颜色信号的幅度调制(AM) :基带信号是 vec3(1)vec3(.8, .9, 1.3) 的插值,载波是 sin(t * .2)

8.2 闪电 --- 混沌振荡 + 阈值化

arduino 复制代码
float lightning = sin(t * sin(t * 10.));
lightning *= pow(max(0., sin(t + sin(t))), 10.);

第一层 sin(t * sin(t * 10.))频率调制(FM) :内层 sin(t * 10.) 产生快速振荡,外层 t * ... 让频率随时间变化,形成混沌信号。

第二层 pow(max(0, sin(...)), 10.)硬阈值化sin 在大部分时间为负,max(0, ...) 截断为 0;偶尔为正时,pow(..., 10.) 把接近 0 的值压得更小,只保留接近峰值的脉冲。这模拟了闪电的统计特征:大部分时间黑暗,极少瞬间爆发

8.3 暗角 --- 径向衰减函数

ini 复制代码
col *= 1. - dot(UV -= .5, UV);

展开后:UV -= .5 把坐标中心移到 (0.5, 0.5),然后 dot(UV, UV) = UV.x² + UV.y²,这是径向距离的平方 。在屏幕中心距离为 0,衰减因子为 1;在角落 (±0.5, ±0.5),距离平方为 0.5,衰减因子为 0.5。

这是一个二次径向衰减,比线性衰减更自然,因为人眼对亮度的感知是对数性的。


9. 爱心形状 --- 隐式曲面与 Morphing

ini 复制代码
vec2 hv = uv - vec2(.0, -.1);
hv.x *= .5;
float s = S(110., 70., T);
hv.y -= sqrt(abs(hv.x)) * .5 * s;
float heart = length(hv);
heart = S(.4*s, .2*s, heart) * s;

9.1 心形的隐式方程

标准心形的隐式方程为 |x|^0.5 + y = c。代码中:

  • hv.x *= .5 --- 水平压缩,让心形更修长
  • hv.y -= sqrt(abs(hv.x)) * .5 * s --- 把心形上半部分的弧形"凹"进去
  • length(hv) --- 计算到变换后中心的距离

9.2 时间 Morphing

s = S(110., 70., T) 是一个随时间变化的缩放因子:

  • T < 70s ≈ 1,爱心正常大小
  • T > 110s ≈ 0,爱心缩小至消失

S(.4*s, .2*s, heart) 同时缩放内外边界,保持边缘软化的相对比例。

9.3 时间 Remap

ini 复制代码
t = min(1., T / 70.);
t = 1. - t;
t = (1. - t * t) * 70.;

这是一个非线性时间映射

  • T / 70 归一化到 [0, 1]
  • 1 - t 反转
  • 1 - t² 是 ease-out 曲线,前半段变化快、后半段变化慢
  • 最终 * 70 恢复量纲

这让雨滴在爱心"冻结"时的运动更缓慢,增强戏剧感。

总结

这个特效的精髓在于用极少的几何体(一个全屏面片)+ 精心设计的数学函数 来模拟复杂的物理现象。水滴不是模型,是 signed distance field;折射不是光线追踪,是法线偏移后的纹理重采样;景深不是后处理 blur pass,是 textureLod 的 mipmap 级别控制。

相关推荐
问心无愧05132 小时前
ctf show web入门98
android·前端·笔记
irving同学462382 小时前
Drizzle ORM + PostgreSQL + Hono 学习笔记
前端·后端
明豆2 小时前
HTTPS / TLS 1.3 深度解析 — Web 安全传输协议生产实战
前端·安全·https
Linsk2 小时前
Rollup 官方插件 @rollup/plugin-inject 详解
前端·rollup.js·前端工程化
2601_958492552 小时前
Performance Audit of Paper Boats Racing - HTML5 Racing Game
前端·html·html5
irving同学462382 小时前
TypeScript 后端入门全景:Hono + Zod + Drizzle + PostgreSQL
前端·后端
一致性2 小时前
项目总结:桌宠(Desktop Pet)
前端
JoneBB2 小时前
ABAP上传EXCEL模板并将内表内容存到两个sheet中
java·前端·数据库
usdoc文档预览2 小时前
国产化踩坑:Vue3 / React / 小程序如何免插件实现 OFD 及复杂 Office 文档同屏预览
前端·javascript·react.js·小程序·pdf·word·office文件在线预览