大家好!我是晓智,一位专注 Web3D、Shader、AI 方向的前端开发者。
这里会持续分享 Three.js、WebGL、Shader 特效、AI 可视化 等实战内容。
如果你也热爱前沿技术,欢迎一起交流成长 🚀
🔥 在线预览 + 源码学习:
效果预览
玻璃窗上的雨滴、雾气、折射扭曲、爱心图案、闪电暗角等特效,该特效基于threejs自定义shader实现。

效果图
Shader 实现原理
1. 整体思路与物理模型
真实雨滴效果的物理本质是光线穿过非均匀介质(水滴)后的折射与散射。在 GPU 上,我们没有光线追踪的预算,因此需要用纹理重采样的方式近似:
- 每个像素计算该位置的水滴密度场
- 从密度场提取法线(梯度方向)
- 用法线偏移 UV,重新采样背景纹理
- 水滴越密集,偏移越强,同时 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.564 和 7658.76 的选择原则:避免 sin 的周期 2π 与这些常数形成有理数倍关系,否则会出现格子状伪影。
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;
拖尾的数学结构分三层:
r = sqrt(S(1., y, st.y))--- 控制拖尾宽度随高度变化。S(1., y, st.y)在st.y < y时接近 1(拖尾根部),在st.y > y时接近 0。sqrt()把宽度压缩,让拖尾更细。S(.23*r, .15*r*r, cd)--- 横向的 smoothstep 控制拖尾轮廓。.23*r和.15*r*r分别是外边界和内边界,形成类似水滴侧面的弧形轮廓。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) ≈ 0,focus ≈ maxBlur - c.y。但c.y在无水滴处也为 0,所以focus ≈ maxBlur。等等,这不对...
实际上 maxBlur 和 minBlur 的值分别是 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 ≈ 0,focus ≈ maxBlur。模糊。 当 c.x 很大(水滴中心):S ≈ 1,focus ≈ 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 < 70时s ≈ 1,爱心正常大小T > 110时s ≈ 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 级别控制。