Three.js 实时雨水与水坑效果实现教程

0 阅读11分钟

rain.png

本教程中的核心效果实现来自于 Faraz-Portfolio/demo-2023-rain-puddle 项目。

我自己简单的复刻版本 github.com/xiaxiangfen… 项目简洁一个html便于学习

在这篇教程中,我们将深入剖析如何使用 Three.js 和 GLSL 着色器来创建一个逼真的、带有动态雨滴和交互式水坑的场景。本文将详细介绍 rain.html 中使用的关键技术,并深入其背后的数学和图形学原理

  • PBR 与 IBL:物理渲染 (PBR) 和基于图像的照明 (IBL) 如何协同工作。
  • 程序化噪声与分形:深入理解分形布朗运动 (FBM) 的数学构造。
  • 法线扰动:Bump Mapping 的核心思想,以及如何通过向量运算在着色器中实现。
  • 程序化波形与 SDF:解构涟漪着色器中的波形函数和符号距离函数 (SDF) 的应用。
  • 实例化渲染:探讨 InstancedMesh 的底层 GPU 优势。
  • 后期处理原理:辉光 (Bloom) 效果的实现步骤。

1. 基础:PBR 与基于图像的照明 (IBL)

一个可信的场景始于正确的光照。我们不仅使用了直接光照(DirectionalLight),更关键的是通过加载 HDR 环境贴图实现了基于图像的照明 (IBL)

// HDR环境贴图提供真实感的反射
new RGBELoader().load('/public/shanghai_bund_2k.hdr', (texture) => {
    texture.mapping = THREE.EquirectangularReflectionMapping;
    scene.environment = texture;
});

图形学原理: scene.environment 将这张 HDR 贴图作为整个场景的光源。对于场景中的 PBR 材质(如 MeshPhysicalMaterial),渲染器会对其表面上的每个点进行光照计算。IBL 的工作原理是:渲染器会根据该点的法线方向视角方向,到这张环境贴图中进行采样,计算出该点应该接收到的环境光(包括漫反射和镜面反射)。

HDR (高动态范围) 贴图至关重要,因为它能记录比普通图片更宽广的亮度信息(从太阳的刺眼强光到阴影的微弱光线)。这使得水坑能够反射出夜空中霓虹灯的璀璨高光,这是实现真实感的决定性因素。

2. 水坑材质:扩展物理材质与着色器原理

水坑效果是整个场景的核心。我们使用了 three-custom-shader-material 库来扩展 MeshPhysicalMaterial。这让我们能保留 PBR 的高级光照计算,同时注入自定义逻辑。

PBR 核心概念: PBR 的目标是基于物理属性来模拟光线与物体表面的交互。对于水坑效果,最重要的属性是 粗糙度 (Roughness)

  • 高粗糙度(如干燥的沥青路面):表面在微观层面是凹凸不平的,光线射入后会向各个方向散射,形成模糊的反射。数学上,这是通过微表面理论中的法线分布函数 (NDF) 来建模的。
  • 低粗糙度(如平静的水面):表面非常光滑,光线射入后会以接近镜面的方式反射,形成清晰的环境倒影。

我们的核心任务就是在着色器中,利用程序化遮罩来动态修改模型特定区域的 roughness 值。

2.1 水坑形状:分形布朗运动 (FBM) 的数学原理

水坑的自然轮廓是通过 分形布朗运动 (Fractal Brownian Motion, FBM) 生成的。FBM 是一种经典的程序化噪声算法。

float getPuddle(vec2 uv) {
    gln_tFBMOpts puddleNoiseOpts = gln_tFBMOpts(1.0, 0.5, 2.0, 0.5, 1.0, 3, false, false);
    float puddleNoise = gln_sfbm((uv + vec2(3.0, 0.0)) * 0.2, puddleNoiseOpts);
    puddleNoise = gln_normalize(puddleNoise);
    puddleNoise = smoothstep(0.0, 0.7, puddleNoise); // 关键的塑形步骤
    return puddleNoise;
}

数学与图形学原理:

  1. 基础噪声: FBM 的基础是像 Simplex Noise 或 Perlin Noise 这样的梯度噪声。这些噪声函数生成的是平滑、连续的伪随机值。

  2. 分形叠加: FBM 的"分形"体现在它通过将多个不同频率和振幅的噪声层(称为"倍频程",Octaves)叠加在一起来创建更丰富的细节。数学上,FBM 可以表示为:

    FBM(p) = ∑(i=0 to n-1) amplitude_i * noise(frequency_i * p)
    
    其中:
    amplitude_i = persistence^i
    frequency_i = lacunarity^i
    
    • gln_tFBMOpts 中的参数控制着这个过程:

      • lacunarity (通常为 2.0) 控制下一层噪声的频率增加多快
      • gain/persistence (通常为 0.5) 控制下一层噪声的振幅降低多快
      • octaves (这里是 3) 控制叠加的层数
    • 通过叠加多层噪声,我们得到了既有大块形状、又有精细细节的、看起来自然的图案。

  3. 平滑阶梯函数 (smoothstep): smoothstep(edge0, edge1, x) 是一个非常重要的塑形函数。它在 x 小于 edge0 时返回 0,大于 edge1 时返回 1,并在两者之间进行平滑的埃尔米特插值。数学上表示为:

    smoothstep(edge0, edge1, x) = 
      0,                          x <= edge0
      3t^2 - 2t^3,               edge0 < x < edge1, 其中 t = (x - edge0) / (edge1 - edge0)
      1,                          x >= edge1
    

    这比线性过渡 ((x-a)/(b-a)) 看起来更自然,因为它在边缘处的导数为0,避免了生硬的突变。我们用它将连续的噪声值"挤压"成具有清晰但平滑边缘的遮罩。

2.2 水面质感:粗糙度与法线的着色器实现

在着色器中,我们通过修改两个关键属性来创建水坑效果:

// 在片段着色器的 main() 函数中
float puddleNoise = getPuddle(vPosition.xy * 15.0);

// 创建水坑遮罩
csm_PuddleNormalMask = smoothstep(0.2, 1.0, puddleNoise) * normalProgress;

// 修改粗糙度 - 水坑区域接近0(非常光滑)
float prevRoughness = csm_Roughness; // 保存原始粗糙度
csm_Roughness = 1.0 - csm_PuddleNormalMask; // 水坑区域粗糙度接近0
csm_Roughness = clamp(csm_Roughness, 0.0, 0.1); // 限制在0-0.1范围内
csm_Roughness = mix(prevRoughness, csm_Roughness, roughnessProgress); // 平滑过渡

核心原理:

  1. 粗糙度修改: 我们使用 1.0 - csm_PuddleNormalMask 来反转遮罩值,因为水坑区域(遮罩值高)需要低粗糙度。
  2. clamp 函数: 确保粗糙度值在合理范围内(0.0-0.1),防止极端值导致的渲染问题。
  3. mix 函数: 实现平滑过渡,数学上表示为 mix(x, y, a) = x * (1-a) + y * a

最后,通过 patchMap 将我们计算出的新法线与原始法线混合:

const patchMap = {
    "*": {
        "#include <normal_fragment_maps>": `
            #include <normal_fragment_maps>
            normal = mix(normal, csm_PuddleNormal, csm_PuddleNormalMask);
        `,
    },
};

这是一个巧妙的技术,它让我们能够在 Three.js 的标准着色器管线中"注入"自定义代码,而不必完全重写整个着色器。

2.3 涟漪:波动方程与法线扰动的数学原理

涟漪效果是场景的灵魂,它完全在着色器中通过数学计算生成。让我们深入分析 getRipples 函数的完整实现:

vec3 getRipples(vec2 uv) {
    vec2 p0 = floor(uv);  // 将空间划分为整数网格
    float time = uTime * 3.0;
    vec2 circles = vec2(0.);
    
    // 遍历周围的网格单元
    for (int j = -MAX_RADIUS; j <= MAX_RADIUS; ++j) {
        for (int i = -MAX_RADIUS; i <= MAX_RADIUS; ++i) {
            // 当前网格单元的坐标
            vec2 pi = p0 + vec2(i, j);
            
            #if DOUBLE_HASH
            vec2 hsh = hash22(pi);
            #else
            vec2 hsh = pi;
            #endif
            
            // 在网格内生成随机的涟漪中心点
            vec2 p = pi + hash22(hsh);
            
            // 基于时间和位置创建动画效果 - 每个涟漪有自己的生命周期
            float t = fract(0.3*time + hash12(hsh));
            
            // 从涟漪中心指向当前像素的向量
            vec2 v = p - uv;
            
            // 计算到波前的距离 - 这是波动方程的核心
            float d = length(v) - (float(MAX_RADIUS) + 1.)*t;
            
            // 数值微分计算法线扰动
            float h = 1e-3;  // 微分步长
            float d1 = d - h;
            float d2 = d + h;
            
            // 使用正弦函数生成波形,并用smoothstep控制波的形状和衰减
            float p1 = sin(31.*d1) * smoothstep(-0.6, -0.3, d1) * smoothstep(0., -0.3, d1);
            float p2 = sin(31.*d2) * smoothstep(-0.6, -0.3, d2) * smoothstep(0., -0.3, d2);
            
            // 计算波的梯度(法线扰动方向)并考虑波的强度随时间衰减
            circles += 0.5 * normalize(v) * ((p2 - p1) / (2. * h) * (1. - t) * (1. - t));
        }
    }
    
    // 归一化总效果以避免过度扰动
    circles /= float((MAX_RADIUS*2+1)*(MAX_RADIUS*2+1));
    
    // 构建法线向量 - 确保它是单位向量
    vec3 n = vec3(circles, sqrt(1. - dot(circles, circles)));
    return n;
}

数学与图形学原理:

  1. 波的传播方程: 核心方程 d = length(v) - (R*t) 描述了一个圆形波的传播。这实际上是波动方程在二维平面上的简化形式。

    • length(v) 是到波源的距离
    • R*t 是波前已传播的距离(R是波速,t是时间)
    • d 是当前点到波前的符号距离,d=0 表示正好在波前上
  2. 波形函数: sin(k*d) 是描述波形的基本函数,其中:

    • k (这里是31) 控制波的频率/密度
    • 外层的两个 smoothstep 函数形成了一个振幅包络 (Amplitude Envelope),它控制了波的"形状"和衰减
    • 数学上,完整的波形函数可以表示为: wave(d) = sin(k*d) * envelope(d) 其中 envelope(d) 是由 smoothstep 函数组合形成的包络函数
  3. 法线计算 - 数值微分: 这是一个关键的数学技巧。想象涟漪是一个高度场 H(x,y),那么表面的法线可以通过高度场的梯度来计算:

    N = normalize((-∂H/∂x, -∂H/∂y, 1))
    

    我们使用中心差分法来近似计算这个梯度:

    ∂H/∂d ≈ (H(d+h) - H(d-h)) / (2*h)
    

    这就是代码中 (p2 - p1) / (2. * h) 的由来。

  4. 法线向量构建: 最后,我们需要确保法线是单位向量。如果 x 和 y 分量是 circles,那么 z 分量需要满足:

    x² + y² + z² = 1
    

    因此 z = sqrt(1 - (x² + y²)) = sqrt(1 - dot(circles, circles))

  5. 法线扰动应用: 最后,我们使用 perturbNormal 函数将计算出的涟漪法线应用到基础法线上:

vec3 perturbNormal(vec3 inputNormal, vec3 noiseNormal, float strength) {
    // 计算正交于原法线的噪声分量
    vec3 noiseNormalOrthogonal = noiseNormal - (dot(noiseNormal, inputNormal) * inputNormal);
    
    // 将这个正交分量投影到视图空间
    vec3 noiseNormalProjectedBump = mat3(csm_internal_vModelViewMatrix) * noiseNormalOrthogonal;
    
    // 扰动原法线并重新归一化
    return normalize(inputNormal - (noiseNormalProjectedBump * strength));
}

这个函数实现了切线空间法线扰动的核心思想:只改变法线的方向,而不改变表面的实际几何形状。

3. 雨滴粒子系统:实例化渲染与SDF

为了高效渲染成千上万的雨滴,我们使用了 THREE.InstancedMesh。但雨滴的视觉效果同样依赖于着色器中的数学技巧。

3.1 实例化渲染原理

// 创建实例化网格
rain = new THREE.InstancedMesh(rainGeometry, rainMaterial, rainCount);

// 更新函数
function updateRain(dt) {
    if (!rain) return;
    const dummy = new THREE.Object3D();
    for (let i = 0; i < rain.count; i++) {
        rain.getMatrixAt(i, dummy.matrix);
        dummy.matrix.decompose(dummy.position, dummy.quaternion, dummy.scale);
        
        // 更新位置 - 简单的欧拉积分
        dummy.position.y -= dt * 3.0;
        
        // 如果落到地面则重置
        if (dummy.position.y <= 0) {
            dummy.position.set(
                THREE.MathUtils.randFloatSpread(1.5),  // 随机x位置
                THREE.MathUtils.randFloat(-0.1, 5),    // 随机高度
                THREE.MathUtils.randFloatSpread(1.5)   // 随机z位置
            );
            dummy.scale.setScalar(THREE.MathUtils.randFloat(0.1, 0.6));  // 随机大小
        }
        
        // 使雨滴始终面向相机 - 广告牌技术
        dummy.rotation.y = Math.atan2(
            camera.position.x - dummy.position.x, 
            camera.position.z - dummy.position.z
        );
        
        dummy.updateMatrix();
        rain.setMatrixAt(i, dummy.matrix);
    }
    rain.instanceMatrix.needsUpdate = true;  // 通知GPU更新实例数据
}

图形学原理: 实例化渲染是一种硬件加速技术,它允许GPU在一次绘制调用中渲染多个相同几何体的实例。这比传统的每个对象一次绘制调用要高效得多,因为它减少了CPU到GPU的通信开销。

3.2 雨滴形状:SDF(符号距离函数)

雨滴的视觉效果使用了符号距离函数 (SDF) 技术,这是一种在片段着色器中高效表示形状的方法。

// 雨滴材质的片段着色器
float sdUnevenCapsule(vec2 p, float r1, float r2, float h) {
    p.x = abs(p.x);
    float b = (r1-r2)/h;
    float a = sqrt(1.0-b*b);
    float k = dot(p,vec2(-b,a));
    if(k < 0.0) return length(p) - r1;
    if(k > a*h) return length(p-vec2(0.0,h)) - r2;
    return dot(p, vec2(a,b)) - r1;
}

float blur(float steps) {
    vec2 coord = vUv - 0.5;
    coord *= 10.0;
    float total = 0.0;
    for (float i = 0.0; i < steps; i++) {
        float dropletDistance = sdUnevenCapsule(coord, 0.05, 0.0, 2.0);
        dropletDistance = 1.0 - smoothstep(0.0, 0.05, dropletDistance);
        total += dropletDistance;
        coord += vec2(0.0, 0.2);
    }
    return total / steps;
}

数学原理:

  1. SDF(符号距离函数): 返回从任意点到形状表面的最短距离,内部为负,外部为正。这使得可以轻松地创建平滑边缘和混合形状。

  2. 不均匀胶囊体SDF: sdUnevenCapsule 函数定义了一个两端半径不同的胶囊体。这是通过组合圆形SDF和线段SDF实现的:

    • 如果点在底部圆外,返回到底部圆的距离
    • 如果点在顶部圆外,返回到顶部圆的距离
    • 否则,返回到连接线的距离
  3. 模糊效果: blur 函数通过多次采样和叠加来创建柔和的边缘效果,模拟雨滴的半透明感。

4. 后期处理:辉光效果的数学原理

辉光效果(Bloom)是一种模拟真实摄像机镜头和人眼对高亮区域的反应的后期处理效果。

const bloomPass = new UnrealBloomPass(
    new THREE.Vector2(window.innerWidth, window.innerHeight), 
    0.1,  // 强度
    0.3,  // 半径
    0.7   // 阈值
);
composer.addPass(bloomPass);

图形学原理: 辉光效果的实现通常包括以下步骤:

  1. 亮度提取: 首先,渲染一个只包含超过特定亮度阈值的像素的版本。数学上,这可以表示为: brightColor = max(0, color - threshold)

  2. 高斯模糊: 对提取的亮度图应用多次高斯模糊。高斯模糊的核函数为: G(x,y) = (1/(2πσ²)) * e^(-(x²+y²)/(2σ²)) 其中 σ 控制模糊的半径。

  3. 多尺度模糊: 为了获得更好的效果,通常会对图像进行多次降采样,在每个尺度上应用高斯模糊,然后再上采样并混合。这创造了不同大小的"光晕"效果。

  4. 加法混合: 最后,将模糊后的亮度图与原始场景图像相加: finalColor = sceneColor + bloomColor * intensity

这种后期处理极大地增强了场景的视觉效果,特别是水坑反射的高光部分,使整个场景看起来更加真实和有氛围感。