Three.js中实现真实感天空和体积云渲染

2,412 阅读15分钟

1. 项目简介

GitHub地址

github.com/xiaxiangfen…

在线预览地址

xiaxiangfeng.github.io/sky-cloud-3…

本文将详细讲解如何在Three.js中实现真实感的天空和体积云渲染效果。这种技术常用于游戏、可视化和交互式Web应用中,可以创建出逼真的天空场景,包括动态变化的体积云、太阳光照效果和大气透视。

微信截图_20250516200007.png

我们的实现基于着色器(Shader)技术,使用了噪声函数、光线传输模拟和分形布朗运动(FBM)等技术,最终得到了可交互调节的真实云层效果。

2. 核心原理概述

体积云的渲染主要基于以下几个核心概念:

  1. 体积渲染:云不是简单的2D贴图,而是通过在3D空间中模拟光线传输来实现
  2. 噪声函数:使用Perlin噪声或类似的噪声函数生成云的形状
  3. 光线传输:模拟阳光穿过云层时的散射和吸收
  4. 分形布朗运动(FBM):叠加不同尺度的噪声,创造更自然的云形状
  5. 光照模型:模拟云朵边缘的银光效果和内部阴影

接下来,我们将从技术实现的角度,逐步讲解这些概念。

3. 项目架构

我们的实现主要分为以下几个部分:

  1. Three.js场景设置
  2. 天空球和着色器材质
  3. 顶点着色器和片元着色器
  4. 用户交互控制界面

3.1 Three.js基础设置

// Three.js基本场景设置
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 30000);
renderer = new THREE.WebGLRenderer({ antialias: true });

// 创建天空球
const skyGeometry = new THREE.SphereGeometry(20000, 64, 32); 
skyMesh = new THREE.Mesh(skyGeometry, skyMaterial);
scene.add(skyMesh);

在这段代码中,我们创建了一个大型的球体几何体作为天空的容器,并应用自定义的着色器材质。

4. 着色器实现

着色器是本项目的核心,分为顶点着色器和片元着色器两部分。

4.1 顶点着色器

顶点着色器非常简单,主要负责传递世界坐标给片元着色器:

varying vec3 vWorldPosition;
varying vec2 vUv;

void main() {
    // 传递UV坐标给片元着色器
    vUv = uv;
    // 计算并传递世界坐标
    vec4 worldPosition = modelMatrix * vec4(position, 1.0);
    vWorldPosition = worldPosition.xyz;
    // 投影变换
    gl_Position = projectionMatrix * viewMatrix * worldPosition;
}

这个着色器的主要目的是传递每个顶点的世界空间坐标(vWorldPosition),这对于后续在片元着色器中计算光线方向至关重要。

4.2 片元着色器

片元着色器是实现云和天空渲染的核心部分。下面逐部分解析其实现:

4.2.1 基本参数和变量声明

// 从JavaScript传入的参数
uniform float uTime;                 // 当前时间,用于动画
uniform vec3 uSunDirection;          // 太阳方向
uniform sampler2D t_PerlinNoise;     // Perlin噪声纹理
uniform float uCloudCoverage;        // 云层覆盖率
uniform float uCloudHeight;          // 云层高度
uniform float uCloudThickness;       // 云层厚度
uniform float uCloudAbsorption;      // 光线吸收率
uniform float uWindSpeedX;           // X轴风速
uniform float uWindSpeedZ;           // Z轴风速
uniform float uMaxCloudDistance;     // 云的最大可见距离

// 从顶点着色器传入的变量
varying vec3 vWorldPosition;         // 世界坐标
varying vec2 vUv;                    // UV坐标

// 常量定义
#define TWO_PI 6.28318530718
#define STEPS 22                     // 光线步进次数
#define LIGHT_STEPS 5                // 光照计算步进次数

4.2.2 天空颜色计算

// 天空颜色计算函数
vec3 Get_Sky_Color(vec3 rayDir) {
    // 计算太阳影响因子(点积)
    float sunAmount = max(0.0, dot(rayDir, uSunDirection));
    // 天空渐变强度(基于y轴高度)
    float skyGradient = pow(max(0.0, rayDir.y), 0.5);
    
    // 混合深蓝和阳光颜色
    vec3 skyColor = mix(
        vec3(0.1, 0.2, 0.4),         // 深蓝色天空
        vec3(0.8, 0.7, 0.5),         // 柔和的阳光色
        pow(sunAmount, 12.0)          // 阳光影响的分布曲线
    );
    
    // 地平线效果
    vec3 horizonColor = vec3(0.7, 0.75, 0.8);  // 较淡的地平线颜色
    skyColor = mix(horizonColor, skyColor, skyGradient);
    
    // 太阳光晕效果
    if (sunAmount > 0.999) {
        // 添加一个亮点作为太阳
        skyColor += vec3(1.0, 0.7, 0.3) * pow(sunAmount, 1000.0) * 3.0;
    }
    
    return skyColor;
}

在这个函数中,我们根据视线方向和太阳方向计算天空的颜色,实现了从地平线到天顶的自然渐变,以及太阳周围的光晕效果。

4.2.3 噪声和分形布朗运动(FBM)函数

// 从噪声纹理采样
float noise3D(in vec3 p) {
    vec2 uv = p.xz * 0.01;
    return texture(t_PerlinNoise, uv).x;
}

// 分形布朗运动实现
float fbm(vec3 p) {
    float t;
    float mult = 2.76434;  // 频率倍增因子
    
    // 叠加不同频率和振幅的噪声
    t  = 0.51749673 * noise3D(p); p = m * p * mult;
    t += 0.25584929 * noise3D(p); p = m * p * mult;
    t += 0.12527603 * noise3D(p); p = m * p * mult;
    t += 0.06255931 * noise3D(p);
    
    return t;
}

分形布朗运动(FBM)是一种通过叠加不同尺度的噪声函数来创建自然纹理的技术。在这里,我们使用它生成云的自然形状,每一层噪声的权重递减(0.517, 0.255, 0.125, 0.062),这符合自然界中的分形特性。

4.2.4 云密度计算

// 计算给定位置的云密度
float cloud_density(vec3 pos, vec3 offset, float h) {
    // 应用偏移和缩放
    vec3 p = pos * 0.0212242 + offset;
    // 使用FBM计算基础密度
    float dens = fbm(p);
    
    // 应用云覆盖率阈值
    float cov = 1.0 - uCloudCoverage;
    dens *= smoothstep(cov, cov + 0.05, dens);
    
    // 高度衰减
    float height = pos.y - uCloudHeight;
    float heightAttenuation = 1.0 - clamp(height / uCloudThickness, 0.0, 1.0);
    // 平方加强边界过渡效果
    heightAttenuation = heightAttenuation * heightAttenuation;
    dens *= heightAttenuation;
    
    return clamp(dens, 0.0, 1.0);
}

这个函数计算空间中任意点的云密度。首先,它使用FBM生成基础密度值,然后应用两个重要修改:

  1. 云覆盖率阈值:使用smoothstep函数根据覆盖率参数过滤低密度区域
  2. 高度衰减:云在垂直方向上的密度应该在云层顶部和底部减小

4.2.5 光线传输模拟

// 模拟光线传输,计算从给定点到光源的透明度
float cloud_light(vec3 pos, vec3 dir_step, vec3 offset, float cov) {
    float T = 1.0;  // 初始透射率为1(完全透明)
    
    // 沿着光源方向进行采样
    for (int i = 0; i < LIGHT_STEPS; i++) {
        // 获取当前点的云密度
        float dens = cloud_density(pos, offset, 0.0);
        // 计算透射率衰减
        float T_i = exp(-uCloudAbsorption * dens);
        // 累积透射率
        T *= T_i;
        // 向光源方向前进
        pos += dir_step;
    }
    
    return T;  // 返回最终透射率
}

这个函数模拟了光线从给定点传输到光源的过程。通过多次沿光源方向采样,并根据比尔-朗伯定律(Beer-Lambert law)计算光的衰减,得到达到该点的光量,这是产生真实感云朵所必须的。

4.2.6 云渲染主函数

// 主云渲染函数
vec4 render_clouds(vec3 rayOrigin, vec3 rayDirection) {
    // 计算光线与云层平面相交点
    float t = (uCloudHeight - rayOrigin.y) / rayDirection.y;
    if (t < 0.0) return vec4(0.0);  // 光线不与云层相交
    
    // 距离过远就不渲染
    if (t > uMaxCloudDistance) return vec4(0.0);
    
    // 计算距离淡出系数
    float distanceFade = 1.0 - smoothstep(uMaxCloudDistance * 0.6, uMaxCloudDistance, t);
    
    // 云层起始点
    vec3 startPos = rayOrigin + rayDirection * t;
    
    // 风的偏移动画
    vec3 windOffset = vec3(uTime * -uWindSpeedX, 0.0, uTime * -uWindSpeedZ);
    vec3 pos = startPos;
    
    // 步进大小计算
    float march_step = uCloudThickness / float(STEPS);
    vec3 dir_step = rayDirection * march_step;
    vec3 light_step = uSunDirection * 5.0;
    
    // 变化云层覆盖率
    float covAmount = (sin(mod(uTime * 0.02, TWO_PI))) * 0.1 + 0.5;
    float coverage = mix(0.4, 0.6, clamp(covAmount, 0.0, 1.0));
    
    // 初始化光线传输变量
    float T = 1.0;  // 透射率
    vec3 C = vec3(0);  // 积累的颜色
    float alpha = 0.0;  // 积累的不透明度
    
    // 光线步进渲染
    for (int i = 0; i < STEPS; i++) {
        // 跳过云层范围外的部分
        if (pos.y < uCloudHeight || pos.y > uCloudHeight + uCloudThickness) {
            pos += dir_step;
            continue;
        }
        
        // 计算当前样本高度比例
        float h = float(i) / float(STEPS);
        // 获取当前位置云密度
        float dens = cloud_density(pos, windOffset, h);
        
        // 只处理有意义的密度值
        if (dens > 0.01) {
            // 计算透射率
            float T_i = exp(-uCloudAbsorption * dens * march_step);
            T *= T_i;
            
            // 计算光照
            float cloudLight = cloud_light(pos, light_step, windOffset, coverage);
            
            // 模拟高度光照效果
            float lightFactor = (exp(h) / 1.75);
            
            // 计算边缘银光效果
            float sunContribution = pow(max(0.0, dot(rayDirection, uSunDirection)), 2.0);
            vec3 edgeColor = mix(vec3(1.0), vec3(1.0, 0.8, 0.5), sunContribution);
            
            // 颜色混合
            vec3 cloudColor = mix(
                vec3(0.15, 0.15, 0.2),  // 暗部颜色
                edgeColor,              // 亮边颜色
                cloudLight * lightFactor
            );
            
            // 积累颜色和不透明度
            C += T * cloudColor * dens * march_step * 1.5;
            alpha += (1.0 - T_i) * (1.0 - alpha);
        }
        
        // 前进一步
        pos += dir_step;
        if (T < 0.01) break;  // 透明度太低就提前结束
    }
    
    // 应用太阳光颜色
    vec3 sunColor = vec3(0.9, 0.7, 0.5);
    vec3 skyColor = vec3(0.4, 0.5, 0.6);
    C = C * mix(skyColor, sunColor, 0.5 * pow(max(0.0, dot(rayDirection, uSunDirection)), 2.0));
    
    // 应用距离淡出
    alpha *= distanceFade;
    C *= distanceFade;
    
    return vec4(C, alpha);
}

这是最复杂的函数,实现了体积云的渲染。主要步骤如下:

  1. 光线求交:计算视线与云层平面的交点
  2. 距离检查:过滤太远的云以提高性能
  3. 光线步进:沿视线方向进行多次采样
  4. 密度采样:在每个采样点计算云密度
  5. 光照计算:模拟光线穿过云层的散射和吸收
  6. 颜色累积:根据密度和光照,累积每个采样点的贡献
  7. 提前退出:当累积不透明度很高时,停止采样以提高性能

4.2.7 主函数

void main() {
    // 计算从相机到当前片元的光线方向
    vec3 rayDirection = normalize(vWorldPosition - cameraPosition);
    
    // 计算基础天空颜色
    vec3 skyColor = Get_Sky_Color(rayDirection);
    
    // 只有向上的光线才渲染云
    vec4 clouds = vec4(0.0);
    if (rayDirection.y > 0.0) {
        clouds = render_clouds(cameraPosition, rayDirection);
    }
    
    // 混合天空和云
    vec3 finalColor = mix(skyColor, clouds.rgb, clouds.a);
    
    // 大气透视效果
    float t = pow(1.0 - max(0.0, rayDirection.y), 5.0);
    finalColor = mix(finalColor, vec3(0.65, 0.7, 0.75), 0.5 * t);
    
    // 简单的Reinhard色调映射
    finalColor = finalColor / (finalColor + vec3(1.0));
    
    gl_FragColor = vec4(finalColor, 1.0);
}

主函数将所有部分组合在一起:首先计算天空的基础颜色,然后在适当的方向上渲染云层,最后混合这些元素,并应用大气透视和色调映射以增强真实感。

4.3 核心数学函数详解

本节将深入解析渲染过程中使用的几个关键数学函数和算法。

4.3.1 分形布朗运动(FBM)详解

分形布朗运动是一种模拟自然现象的强大技术,它通过叠加多个不同频率和振幅的噪声函数来创建复杂的纹理。在我们的实现中,FBM用于生成自然的云形状。

数学原理

FBM的基本数学表达式为:

FBM(P) = ∑ A_i * noise(F_i * P)

其中:

  • P是采样点坐标
  • noise是基础噪声函数
  • A_i是每层噪声的振幅(影响强度)
  • F_i是每层噪声的频率(影响细节尺度)

在我们的实现中:

float fbm(vec3 p) {
    float t;
    float mult = 2.76434;  // 频率倍增因子
    
    t  = 0.51749673 * noise3D(p); p = m * p * mult;
    t += 0.25584929 * noise3D(p); p = m * p * mult;
    t += 0.12527603 * noise3D(p); p = m * p * mult;
    t += 0.06255931 * noise3D(p);
    
    return t;
}

这里我们使用了4个倍频(octave),每层的振幅大约减半(0.517, 0.256, 0.125, 0.063),而频率通过每次应用矩阵变换和乘以倍增因子(约2.76)来增加。

视觉效果

FBM视觉效果示意图:

分形布朗运动(FBM)视觉效果可以想象为一系列噪声叠加的过程:

  1. 第一层:基础噪声,呈现平滑变化,犹如波浪般的起伏
  2. 增加第二层:开始出现更多细节,形成更复杂的纹理结构
  3. 增加第三层:更加丰富的细节出现,轮廓变得更加自然
  4. 最终四层叠加:展现出自然的、丰富的、具有分形特性的云状结构

fbm_visual.png

这种分层叠加的方法创造出的噪声具有自相似性,与自然界中的云非常接近。

4.3.2 光线传输和比尔-朗伯定律

在体积渲染中,光线如何通过云层传播是真实感的关键。我们使用比尔-朗伯定律(Beer-Lambert law)来模拟这一过程。

数学原理

比尔-朗伯定律描述了光线通过吸收介质时的衰减:

T = e^(-σd)

其中:

  • T是透射率(剩余光量与入射光量的比值)
  • σ是吸收系数(在我们的实现中由uCloudAbsorption控制)
  • d是介质中的行进距离(在我们的实现中结合了云密度dens)

在代码中的应用:

float T_i = exp(-uCloudAbsorption * dens);
T *= T_i;

光线传输模型图解

光线传输示意图:

想象一个场景:

  • 太阳位于画面左上方
  • 中间是一个椭圆形的云层,内部有不同密度的区域
  • 右下方是观察者

light_transport.png

光线在云层中的传输过程:

  1. 太阳光以初始强度100%进入云层
  2. 光线穿过不同密度的云区域,强度逐渐减弱:100% → 70% → 40% → 25%
  3. 最终到达观察者的光量取决于沿途吸收的积累效果
  4. 在云的边缘区域,由于密度较低,光线能够穿透更多,形成明亮的银光效果(Silver Lining)

4.3.3 光线步进算法详解

体积渲染的核心是光线步进(Ray Marching)算法,它沿视线方向对体积进行采样。

算法原理

光线步进算法的基本步骤:

  1. 确定光线与体积(云层)的交点
  2. 从交点开始,沿光线方向以固定步长进行采样
  3. 在每个采样点计算介质属性(如云密度)
  4. 累积这些点的贡献,得到最终的光照和颜色

光线步进示意图

光线步进算法示意图:

想象一个横截面场景:

  • 左下方有一个相机
  • 右侧是一个矩形的云层区域
  • 相机发出多条光线,其中一条与云层相交

ray_marching.png

光线步进过程:

  • 确定光线与云层的交点(绿色标记)
  • 从交点开始,沿光线方向以固定步长进行多次采样(红色点标记)
  • 在每个采样点计算云密度值(比如:0.2, 0.5, 0.7, 0.4, 0.1)
  • 根据各点的密度值和光照,累积计算最终像素颜色

其工作步骤为:

  1. 计算当前位置云密度
  2. 累积光照贡献
  3. 沿光线方向前进一步
  4. 重复直到穿过云层或达到最大步数

4.3.4 SmoothStep函数详解

SmoothStep函数是GLSL中的内置函数,用于创建平滑的过渡效果。我们在多处使用了这个函数,特别是在距离淡出和云密度阈值处理中。

数学原理

SmoothStep函数的数学表达式为:

smoothstep(edge0, edge1, x) = t * t * (3 - 2 * t)
其中t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0)

这个函数将x值从[edge0, edge1]范围映射到[0,1]范围,并应用平滑的S形曲线。

曲线图解

SmoothStep函数图:

SmoothStep函数可视为一条S形曲线:

  • 在x轴上从0到1
  • 在y轴上从0到1
  • 曲线形状为平滑的S形,而非直线

smoothstep.png

SmoothStep函数的曲线特征:

  • 当x ≤ edge0(默认0)时,结果为0
  • 当x ≥ edge1(默认1)时,结果为1
  • 在中间区域,结果沿着平滑的S形曲线从0过渡到1,避免了阶跃函数的硬边界

在云渲染中的应用:

  1. 云密度阈值过滤: dens *= smoothstep(cov, cov + 0.05, dens);
  2. 距离淡出效果: float distanceFade = 1.0 - smoothstep(uMaxCloudDistance * 0.6, uMaxCloudDistance, t);

这种平滑过渡可以产生更自然的视觉效果,避免了突变边界。

5. 用户交互控制界面

为了方便调整效果,我们添加了GUI控制面板:

function setupGUI() {
    gui = new GUI();
    
    // 云的外观控制
    const cloudFolder = gui.addFolder('Cloud Appearance');
    cloudFolder.add(params, 'cloudCoverage', 0.0, 1.0, 0.01).name('Cloud Density').onChange(value => {
        skyMaterial.uniforms.uCloudCoverage.value = value;
    });
    cloudFolder.add(params, 'cloudHeight', 100.0, 1000.0, 10.0).name('Cloud Height').onChange(value => {
        skyMaterial.uniforms.uCloudHeight.value = value;
    });
    cloudFolder.add(params, 'cloudThickness', 10.0, 100.0, 5.0).name('Cloud Thickness').onChange(value => {
        skyMaterial.uniforms.uCloudThickness.value = value;
    });
    cloudFolder.add(params, 'cloudAbsorption', 0.5, 2.0, 0.01).name('Light Absorption').onChange(value => {
        skyMaterial.uniforms.uCloudAbsorption.value = value;
    });
    
    // 云的移动控制
    const movementFolder = gui.addFolder('Cloud Movement');
    movementFolder.add(params, 'windSpeedX', 0.0, 20.0, 0.1).name('Wind Speed X').onChange(value => {
        skyMaterial.uniforms.uWindSpeedX.value = value;
    });
    movementFolder.add(params, 'windSpeedZ', 0.0, 20.0, 0.1).name('Wind Speed Z').onChange(value => {
        skyMaterial.uniforms.uWindSpeedZ.value = value;
    });
    movementFolder.add(params, 'maxCloudDistance', 1000.0, 20000.0, 500.0).name('Cloud View Distance').onChange(value => {
        skyMaterial.uniforms.uMaxCloudDistance.value = value;
    });
    
    // 太阳和光照控制
    const sunFolder = gui.addFolder('Sun & Lighting');
    sunFolder.add(params, 'sunSpeed', 0.001, 0.1, 0.001).name('Sun Cycle Speed');
    sunFolder.add(params, 'exposure', 0.1, 2.0, 0.1).name('Exposure').onChange(value => {
        renderer.toneMappingExposure = value;
    });
}

这个界面允许用户动态调整以下参数:

  1. 云的外观:密度、高度、厚度和光吸收率
  2. 云的移动:X轴和Z轴的风速,以及最大可见距离
  3. 太阳和光照:太阳循环速度和整体曝光度

6. 动画和更新

最后,我们需要每帧更新着色器的uniforms值:

function animate() {
    requestAnimationFrame(animate);

    elapsedTime = clock.getElapsedTime();

    // 更新太阳位置
    sunAngle = (elapsedTime * params.sunSpeed) % (Math.PI + 0.2) - 0.11;
    sunDirection.set(Math.cos(sunAngle), Math.sin(sunAngle), -Math.cos(sunAngle) * 2.0);
    sunDirection.normalize();
    
    // 更新着色器uniforms
    if (skyMaterial) {
        skyMaterial.uniforms.uSunDirection.value.copy(sunDirection);
        skyMaterial.uniforms.uTime.value = elapsedTime;
    }
    
    controls.update();
    renderer.render(scene, camera);
}

这个函数负责:

  1. 计算太阳的位置和方向
  2. 更新着色器中的时间和太阳方向
  3. 渲染场景

7. 性能优化

体积云渲染是计算密集型的,以下是一些性能优化策略:

  1. 距离裁剪:远处的云不渲染,使用距离淡出
  2. 提前退出:当积累的不透明度很高时,提前结束采样
  3. 密度阈值:只处理密度大于阈值的采样点
  4. 采样步数:通过GUI可以间接控制采样步数,平衡质量和性能

8. 进一步拓展

此实现还可以进一步拓展:

  1. 多层云:实现不同高度的云层,如积云、层云和卷云
  2. 天气变化:模拟从晴天到阴天的渐变过程
  3. 雨雪效果:添加降水效果
  4. 时间系统:实现日出、日落和夜晚的变化

9. 总结

通过体积渲染技术和噪声函数的结合,我们成功实现了逼真的动态天空和云效果。这种方法的核心在于模拟光线在体积介质中的传输行为,以及使用分形布朗运动创建自然的云形状。

最终的实现既美观又可交互,用户可以通过GUI轻松调整各种参数,创造出不同的天空效果。同时,我们也应用了多种性能优化策略,使其能在现代浏览器中流畅运行。

希望这篇教程能帮助你理解体积云渲染的基本原理和实现方法!