Three.js 的较小瀑布实现

效果预览

似势气磅礴的瀑布在屏幕中央奔流而下,水面泛起层层涟漪与泡沫。整个场景仅由SDF(Signed Distance Field,有向距离场)光线行进算法实时渲染,无模型、无纹理、无贴图------纯粹的数学之美。

地址:shader.shuqin.cc/

Shader实现原理

1. 整体架构:从两个循环到最终像素

这个着色器的核心结构分为两个阶段:

  1. 「外层循环」---SDF光线行进,求出场景中每个像素对应的3D表面点
  2. 「内层循环」---在该表面点附近做纹理/噪声合成,生成瀑布的流动细节

sqrt(tanh(...))经过色调映射后输出的最终颜色。


2. SDF 光线行进 --- 外层循环的数学

2.1 射线生成

ini 复制代码
vec2 P = (C + C - r) / r.x;
vec4 rayDir = normalize(vec4(P, 2, 0));

这里C是像素坐标,r是分辨率。(C + C - r)等价于2*C - r,将像素坐标从[0, resolution]映射到[-resolution, +resolution]的范围。除以r.x保持宽高比。vec4(P, 2, 0)表示灯泡位于 z = -2 的位置,看向原点。

normalize()随后得到单位化的光束方向。

2.2 距离场的定义

ini 复制代码
for (; ++i < 39. && d > 1e-4; z += d = 1. - sqrt(L(O*O)))
    O = z * rayDir - U.xwyx / 4.5;

U = vec4(0, 1, 2, 4),所以U.xwyx = vec4(0, 4, 1, 0),除以 4.5 后得到偏移vec4(0, 0.889, 0.222, 0)

O = z * rayDir - offset表示从地球出发,沿着方向前进z距离后的3D点。

距离场的计算是d = 1. - sqrt(L(O*O)),展开后:

scss 复制代码
d = 1. - sqrt(O.x² + O.y² + O.z² + O.w²)
  = 1. - length(O)

这是一个**「四维超球体的SDF」**:length(O) = 1d = 0(表面),length(O) < 1d > 0(内部),length(O) > 1d < 0(外部)。

是四维?作者的想法是利用vec4w 的最小值作为额外的"时间"或"形状"维度。这里的 w 的最小值为什么rayDir.w = 0归零,所以实际上废为三维球体 SDF 加上一个固定的偏移。

2.3 光线行进的收敛条件

css 复制代码
++i < 39. && d > 1e-4
  • 「最大步数39」:防止无限循环,保证性能有上界
  • 「收敛阈值 1e-4」:当距离场值小于 0.0001 时认为到达表面

z += d是经典的**球面追踪(Sphere Tracing)**步进策略:每一步前进的距离恰好等于当前点到表面的估计距离,不会保证表面。


3.表面坐标转换

光线行进结束后,我们得到了交点O。接下来的代码将其转换为瀑布的纹理坐标:

ini 复制代码
C = vec2(O.x, atan(O.z, O.y));

这是**「柱坐标变换」**:

  • C.x = O.x--- 保留x作为水平坐标
  • C.y = atan(O.z, O.y)--- 计算(y, z)平面上的极角

也就是说,瀑布被"展开"为一个圆柱面的展开图。想象一下:一个球体被切开、展平,其经度对应atan(O.z, O.y),纬度对应O.x


4. 衍射纹理合成------内层循环的数学

4.1 环境光与基础色调

ini 复制代码
O = vec4(4, 16, 99, 0) / (1e3 * dot(P, P) + 6.);

这里P = U.zy * (P - r.y/r.x * U.xy)经过一系列变换后,计算的是**「错误衰减」**。

dot(P, P)是到中心的距离。分母1000 * dist² + 6创建了一个从中心向衰减的环境光场:

  • 中心区域(dist ≈ 0):O ≈ vec4(4,16,99,0) / 6 ≈ vec4(0.67, 2.67, 16.5, 0)--- 强烈的蓝色调(99 在 B 通道)
  • 边缘区域(距离增加):O急剧衰减至接近0

vec4(4, 16, 99, 0)的颜色选择非常讲究:R/G通道较小,B通道较大,营造出深水的幽蓝色调。

4.2 噪声网格与瀑布流

ini 复制代码
for (r = L(fwidth(C)) * U.yy; ++j < 9.; C.x += Y.x / 8.)
  • fwidth(C)abs(dFdx(C)) + abs(dFdy(C)),计算像素间坐标的差异率,用于**「抗锯齿」**
  • r = L(fwidth(C)) * U.yy获取当前像素处的钎头,U.yy = vec2(1, 1)
  • 循环9次,每次C.x增加5e-3/8,即在水平方向上采样9个偏移位置

4.3 α随机数生成

less 复制代码
i = fract(sin(dot(vec2(round(C/Y).x, j), 7. + U.xw) * 73.));

这是经典的**「基于罪的哈希」**:

  1. round(C/Y)将坐标映射到整数网格。Y = vec2(5e-3, 1),所以 x 方向网格很密(每 0.005 一个单元),y 方向每 1 一个单元
  2. dot(vec2(gridX, j), vec2(7, 4)) * 73--- 网格坐标与循环索引混合,乘以一个大质量数
  3. sin(...)把线性输入转化为混沌输出
  4. fract(...)截取小数部分,得到[0,1)伪随机数

这个哈希同时依赖**「空间位置」round(C/Y).x)和 「循环索引」**(j),保证每个采样点都有独立的随机值。

4.4 流动动画

ini 复制代码
P = C - (T + T * i) * U.xy;
P -= round(P / Y) * Y;
  • T = 0.1 * iTime + 9是时间参数
  • (T + T * i) * U.xy = vec2(T + T*i, 0)--- 水平方向以T*(1+i)的速度流动
  • i是每个网格的Hash值([0,1)),所以不同网格的速率在T2T之间随机变化
  • P -= round(P/Y)*Y是**「循环旋转」**,将坐标限制在一个网格单元内

这创造了瀑布的核心效果:水流以不同的速度向下游移动,各个单元独立运动,形成整体湍流。

4.5 颜色波动

ini 复制代码
o = 1. + sin(T + 7. * fract(8663. * i) + U);
  • fract(8663. * i)--- 使用另一个哈希产生[0,1)
  • 7. * fract(...)范围范围[0, 7)
  • T + ... + U--- 时间 + 随机相位 + 通道偏移(U = vec4(0,1,2,4)
  • sin(...)之间[-1, 1]震荡
  • 1. + sin(...)映射到[0, 2]

每个通道(R/G/B/A)都有不同的相位偏移(0/1/2/4),产生丰富的色彩变化。8663是一个大的质量数,避免sin与像素坐标的循环。

4.6 泡沫/水花合成

less 复制代码
O += dot(
    smoothstep(r, -r, vec2(
        L(max(P, -U.yx)),
        L(P) - z
    ) - z),
    vec2(exp(19. * P.y), 3)
) * o * o.w;

这是内层循环最核心的合成步骤,分三层分析:

「第一层:SDF形状」

scss 复制代码
L(max(P, -U.yx)) = L(max(P, vec2(-1, 0)))

max(P, vec2(-1, 0))P.x达到[-1, +∞)P.y达到[0, +∞)。然后计算长度。这定义了一个**「半无限平面」**的SDF,只在P.y > 0该区域有价值。

scss 复制代码
L(P) - z

这是**「圆形的SDF」**(到原点的距离半径)。z = 5e-4,所以极半径很小,形成细小的点状结构。

「第二层:Smoothstep抗锯齿」

scss 复制代码
smoothstep(r, -r, sdf - z)

注意参数顺序:smoothstep(r, -r, ...)是反向的(通常到大)。sdf < -r当时输出1,sdf > r时输出0。这创造了一个**「温和的半透明边缘」**。

SDF 结果组成vec2,分别对应两个:

  • x人口:区域平面的贡献
  • y数量:点状水花/泡沫的贡献

「第三层:颜色调制」

scss 复制代码
vec2(exp(19. * P.y), 3)
  • exp(19. * P.y)--- 指数增长。P.y在格子内大致[-0.5, 0.5],所以19 * P.y[-9.5, 9.5]之间。P.y > 0当时指数爆发,产生明亮的白色泡沫;P.y < 0时趋近于0
  • 3--- 点状水花的固定强度

最后* o * o.w------o是前面计算的颜色振荡(RGB三通道不同相位),o.w是alpha通道的强度调制。


5.色调映射与输出

ini 复制代码
gl_FragColor = sqrt(tanh(O - .02 * U.zwyy));

5.1 tanh--- 软压缩

tanh(x) = (e^x - e^(-x)) / (e^x + e^(-x)),特性:

  • tanh(0) = 0
  • tanh(±∞) = ±1
  • x较小时近似线性:tanh(x) ≈ x
  • x扩大时蓄水至1

相比clamp的硬截断,tanh提供**「平滑的高光压缩」**,避免过曝区域的生硬感。

5.2.02 * U.zwyy

U.zwyy = vec4(2, 4, 1, 1),乘以0.02后:vec4(0.04, 0.08, 0.02, 0.02)

这是一个**「颜色平衡偏移」**:不同通道降低不同值,调整整体的冷暖色调。

5.3 sqrt--- 伽玛校正

sqrt(x)等价于pow(x, 0.5),是近似 Gamma 2.2 → 1.0 的解码。由于 GPU 输出默认在线性空间,而显卡希望 sRGB,sqrt把颜色提亮,让暗部有更多细节。


6.关键数学技巧总结

技巧 位置 作用
四维自卫队 外层循环 利用vec4封装3D位置,w少量创造额外的自由度
柱坐标展开 atan(O.z, O.y) 将球面/柱面参数化为2D纹理坐标
辛哈希 fract(sin(dot(...))*73.) 经典GPUα随机数,零纹理依赖
旋转一周 P -= round(P/Y)*Y 无限重复,无 if 分支
反向平滑步 smoothstep(r, -r, ...) 软阈值化,直接得到[0,1]遮罩
指数泡沫 exp(19.*P.y) 基于 y 坐标的非线性爆发
Tanh 色调映射 tanh(O - bias) 平滑高光压缩,避免硬裁切

性能分析

阶段 费用 说明
SDF 光线行进 ≤39次循环 球面追踪,平均步数约10-20
合成纹理 9次循环/像素 含含 Hash + SDF + 颜色合成
总算术逻辑单元 ~200-300次浮点/像素 当代GPU上微不足道

这个着色器在1080p@60fps下运行无压力。其性能关键在于:

  1. SDF 的解析求值避免了三角形光栅化
  2. 所有"纹理"都是程序化生成,零显存带宽消耗
  3. 循环次数有硬上限(39 和 9),无动态分支

总结

小瀑布定位展示了SDF光线行进与程序噪声的精妙结合:外层循环用球面追踪表面,内层循环用Hash + SDF合成流动纹理。没有数据顶点,没有纹理贴图,没有预计算全部------视觉效果都来自实时数学损伤。

相关推荐
GISer_Jing2 天前
Three.js渲染架构:从WebGL到WebGPU的演进
javascript·架构·webgl
李伟_Li慢慢3 天前
实时动画缓冲
前端·机器人·three.js
李伟_Li慢慢3 天前
辅助对象_关节坐标系
前端·机器人·three.js
李伟_Li慢慢3 天前
辅助对象_惯性矩
前端·机器人·three.js
李伟_Li慢慢3 天前
辅助对象_碰撞体
前端·机器人·three.js
李伟_Li慢慢3 天前
信息提示面板
前端·机器人·three.js
李伟_Li慢慢3 天前
辅助对象_质心
前端·机器人·three.js
李伟_Li慢慢3 天前
usda模型的定制化解析
前端·机器人·three.js
李伟_Li慢慢3 天前
解析URDF文件
前端·机器人·three.js