霓虹沙尘暴的 Three.js 实现

开发领域 :前端开发 | AI 应用 | Web3D | 元宇宙
技术栈 :JavaScript、React、ThreeJs、WebGL、Go
经验经验 :8年+ 前端开发经验,专注于图形渲染和AI技术
源码地址shader.shuqin.cc

大家好!我是 数擎Ai,一位热爱探索新技术的前端开发者,在这里分享Web3D和AI技术的干货与实战经验。如果你对技术有热情,欢迎关注我的文章,我们一起成长、进步!

效果预览

穿越一片永不停歇的霓虹沙尘暴。沙丘在暗蓝色的夜空中起伏,地表的每一道波纹都被赋予了独立的霓虹色谱------从洋红到青绿再到深紫------而漫天飞舞的沙粒在光束中折射出星星点点的荧光。支持鼠标拖拽调整视角,感受风沙扑面而来的沉浸感。

Shader 实现原理

1. 整体思路:体渲染 + 光线行进

真实沙尘暴不是表面现象,而是参与介质(Participating Medium) ------光线在穿过悬浮颗粒时不断被散射和吸收。在 GPU 上精确模拟这个过程需要蒙特卡洛路径追踪,计算量不可接受。因此本特效采用光线行进(Ray Marching)结合体渲染方程的简化模型

  1. 从相机出发,沿视线方向以自适应步长向前推进
  2. 每一步计算当前位置的沙尘密度(体密度场)
  3. 根据密度累加内散射光 (in-scattering)并衰减透射率(transmittance)
  4. 如果光线命中地形表面,则计算表面光照并乘以剩余透射率

这个模型忽略了多次散射(multi-scattering),只考虑单次散射 + 自发光,但在视觉层面已经足够表现沙尘暴的核心特征:光被悬浮颗粒散射后形成的弥漫光晕 ,以及远处物体因消光而逐渐隐入黑暗

2. 地形生成 --- 正弦波的叠加艺术

2.1 沙丘的隐式曲面

glsl 复制代码
float terrain(vec2 p) {
    float dune1 = sin(p.x * 0.3 + sin(p.y * 0.2)) * sin(p.y * 0.25);
    float dune2 = sin(p.x * 0.5 + p.y * 0.8) * 0.5;
    return (dune1 + dune2) * 1.6 - 1.0;
}

数学分析:

  • dune1 是两层嵌套正弦:sin(p.x * 0.3 + sin(p.y * 0.2))。内层 sin(p.y * 0.2) 是对 x 方向频率的调制,让沙丘在 y 方向上不是简单的平行条纹,而是呈现出有机的弯曲。外层 sin(p.y * 0.25) 则控制振幅在 y 方向上的包络。
  • dune2 是高频细节层,频率 0.5 比 dune1 更高,0.5 的振幅让细节层更柔和。
  • 最终 * 1.6 - 1.0 是缩放和平移,让地形高度落在合理的视觉范围内。

选择正弦波而非 Perlin Noise 的原因是:正弦波的导数(余弦)有闭式解,法线计算更精确;同时正弦波的周期性在远距离上形成"无尽沙丘"的视觉效果,符合沙漠的空旷感。

2.2 法线扰动 --- 微观表面粗糙度

glsl 复制代码
n.x += (rand1(p.xz * 300.0) - 0.5) * 0.02;
n.z += (rand1(p.xz * 300.1) - 0.5) * 0.02;

在计算出解析法线后,叠加了一个高频随机扰动。rand1 是一个基于小数乘法的 Hash 函数,p.xz * 300 把采样频率推到足够高,只影响法线的微观结构(振幅仅 0.02),不改变宏观地形轮廓。这让沙丘表面看起来像真实沙粒的集合体,而不是数学函数的平滑表面。


3. 光线行进算法

3.1 自适应步长策略

glsl 复制代码
for (int i = 0; i < 150; i++) {
    p = ro + t * rd;
    d = map(p);
    // ...
    t += max(0.1, d * 0.5);
}

这是** Sphere Tracing 的经典变体。map(p) 返回当前点到地形的有符号距离(Signed Distance)**:

  • 如果 d > 0,说明当前点在地形上方,下一步可以安全前进 d * 0.5 的距离
  • max(0.1, ...) 保证最小步长为 0.1,避免在地形附近无限细分
  • 选择 0.5 而非 1.0 是为了留出安全余量,同时保证在 150 步内覆盖 80 单位的距离

在空旷区域(dune 顶部),步长可能达到 5-10 个单位;在靠近地表的区域,步长收缩到 0.1。这种自适应策略让性能开销集中在视觉最重要的区域。

3.2 终止条件

glsl 复制代码
if (d < 0.001 || t > 80.0 || transmittance < 0.01) break;

三个终止条件分别对应三种场景:

  • d < 0.001:命中地形表面,进入表面着色阶段
  • t > 80.0:到达视距上限,背景色渲染
  • transmittance < 0.01:沙尘密度过高,光线几乎被完全遮挡,继续步进无意义

第三个条件是性能优化的关键:在沙尘暴中心,透射率可能在 30-40 步内就衰减到接近 0,此时提前终止可以节省 60% 以上的计算。

4. 体积沙尘的光散射模型

4.1 密度场 --- 多层噪声 + 风场驱动

glsl 复制代码
float GetDustDensity(vec3 p) {
    float freq = 0.5;
    float ampl = 0.5;
    float noiseAccum = 0.0;
    vec2 scroll = p.xz - vec2(iTime * 8.0, iTime * 4.0);

    for (int i = 0; i < 3; ++i) {
        noiseAccum += ValueNoise(scroll * freq) * ampl;
        ampl *= 0.5;
        freq *= 2.0;
        scroll.x += iTime * 2.0;
    }
    // ...
}

密度场由三层**倍频噪声(Octave Noise)**叠加而成:

  • 频率倍增:每层频率翻倍(0.5 → 1.0 → 2.0),形成分形细节
  • 振幅衰减:每层振幅减半(0.5 → 0.25 → 0.125),符合自然分形的能量分布
  • 风场偏移scroll = p.xz - vec2(iTime * 8.0, iTime * 4.0) 让整个噪声场随时间移动,模拟风沙的流动感。x 方向风速 8,z 方向风速 4,暗示主要风向偏 x 轴。每层额外叠加 iTime * 2.0 让不同频率的层有不同的相对运动,增强湍流感。

4.2 高度衰减 --- 地面附近的沙尘更浓

glsl 复制代码
float heightAboveGround = p.y - terrain(p.xz);
float fog = clamp(noiseAccum * 1.5 - heightAboveGround * 0.3, 0.0, 1.0);

noiseAccum * 1.5 是原始密度,heightAboveGround * 0.3 是高度惩罚项。离地越近,惩罚越小,沙尘密度越高;离地越远,沙尘迅速消散。这符合物理直觉:沙尘主要在地表附近被风卷起,高空因重力沉降而稀疏。

4.3 沙粒闪光 --- Hash 函数的阈值化

glsl 复制代码
vec3 windMotion = p * 15.0 - vec3(iTime * 30.0, 0.0, iTime * 15.0);
float grit = hash31(windMotion);
float explicitDust = pow(grit, 3.0) * 2.5;

这是整个 shader 中最精妙的设计之一。hash31 生成一个 [0,1] 的伪随机值,pow(grit, 3.0) 把大部分值压到接近 0,只保留接近 1 的峰值。乘以 2.5 后,这些峰值成为超亮闪光点,模拟阳光照射到单个沙粒上的镜面反射。

windMotion 的构造:

  • p * 15.0 提高空间采样频率,让每个闪光点对应一个"虚拟沙粒"
  • - vec3(iTime * 30, 0, iTime * 15) 让闪光点随风移动,同时 y 方向不动(重力约束)

最终 fog * fog * explicitDust 把体积雾密度和闪光点相乘:只有在雾浓的地方,闪光点才可见。

5. 内散射与透射率 --- 简化的体渲染方程

5.1 物理模型

真实体渲染方程为:

scss 复制代码
L(x, ω) = ∫₀^∞ T(x, x') · σs(x') · ∫Ω p(x', ω, ω') · L(x', ω') dω' dx'

其中:

  • T(x, x') = exp(-∫ σt dx) 是透射率(Beer-Lambert 定律)
  • σs 是散射系数
  • p 是相位函数
  • 积分内层是入射光的各向异性散射

本特效做了三个简化:

  1. 单次散射:只考虑光线从光源到体素、再从体素到相机的路径,忽略多次反弹
  2. 各向同性散射p = 1/4π,方向无关
  3. 自发光近似:光源不是外部点光源,而是体素自身"发光"------霓虹色

5.2 代码实现

glsl 复制代码
float dustDensity = GetDustDensity(p);
if (dustDensity > 0.01) {
    vec3 dustColor = neon * 0.8 + vec3(0.02);
    float stepScatter = dustDensity * 0.12;
    inscatteredLight += transmittance * dustColor * stepScatter;
    transmittance *= exp(-dustDensity * 0.15);
}
  • stepScatterdustDensity * 0.12 是简化后的散射项,0.12 是经验系数
  • inscatteredLight += transmittance * dustColor * stepScatter :当前步的散射光 = 到达该点的光(transmittance)× 沙尘颜色(dustColor)× 散射强度。这里的 transmittance 确保了远处的沙尘因前置遮挡而更暗
  • transmittance *= exp(-dustDensity * 0.15) :Beer-Lambert 衰减,0.15 是消光系数。密度越高,衰减越快

5.3 表面附近的体积辉光

glsl 复制代码
if (d < 0.5) {
    inscatteredLight += transmittance * neon * 0.003 / (abs(d) + 0.01) * exp(-t * 0.05);
}

d < 0.5 表示靠近地表的区域。1 / (abs(d) + 0.01) 是一个反比衰减:越接近地表(d → 0),辉光越强。exp(-t * 0.05) 是距离衰减,远处的辉光更弱。这一项模拟了地表附近的次表面散射------光线穿过薄层沙尘后在地面附近形成的光晕。


6. 表面着色 --- 霓虹沙丘的光照

6.1 基础光照模型

glsl 复制代码
vec3 albedo = vec3(0.02);
float diff = max(dot(n, lightDir), 0.0) * shadow;
float amb = 0.05 + 0.05 * n.y;
vec3 halfDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(n, halfDir), 0.0), 8.0) * 0.02 * shadow;
vec3 surfaceCol = albedo * (diff * vec3(0.2, 0.3, 0.4) + amb) + spec;

这是一个简化的 Blinn-Phong 模型:

  • **反照率(albedo)**极低(0.02),因为沙粒本身不发光,主要靠体积散射照明
  • 漫反射 使用冷色调光源 vec3(0.2, 0.3, 0.4),增强赛博朋克的冷峻感
  • 环境光与法线 y 分量相关:面向天空的坡面更亮
  • 高光指数为 8,非常宽而柔和,模拟沙粒的漫反射表面

6.2 霓虹色带的数学

glsl 复制代码
vec3 neonBase = cos(p.z * 0.15 + p.x * 0.1 + h * 2.0 + vec3(0.0, 1.0, 2.0)) * 0.5 + 0.5;
neonBase *= 0.8 + 0.2 * sin(iTime * 2.0);
neonBase = smoothstep(0.1, 0.9, neonBase);

这是空间编码的色带

  • cos(p.z * 0.15 + p.x * 0.1 + h * 2.0 + vec3(0, 1, 2)) --- 三个通道分别偏移 012 弧度,形成 RGB 三色在空间中交替分布
  • * 0.5 + 0.5[-1, 1] 映射到 [0, 1]
  • 0.8 + 0.2 * sin(iTime * 2.0) 是时间调制,让色带亮度以 π 秒为周期轻微脉动
  • smoothstep(0.1, 0.9, neonBase) 是关键:把渐变压缩成硬边色带,消除中间过渡色,形成霓虹灯管般的离散色块

6.3 距离衰减

glsl 复制代码
float detailFade = smoothstep(60.0, 20.0, t);
surfaceCol += neonBase * 0.5 * detailFade;

smoothstep(60, 20, t) 是一个反向衰减:当 t > 60 时值为 0(远处无霓虹),当 t < 20 时值为 1(近处全霓虹)。这模拟了大气透视:远处沙丘的霓虹色被沙尘散射吸收,只剩灰暗轮廓。


7. 相机系统 --- 飞行的沉浸感

7.1 路径曲线

glsl 复制代码
float speed = iTime * 6.0;
float pathX = sin(speed * 0.05) * 12.0;
float targetPathX = sin((speed + 10.0) * 0.05) * 12.0;
vec3 ro = vec3(pathX, 6.0 + sin(speed * 0.2) * 1.5, speed);
vec3 ta = vec3(targetPathX, 1.0, speed + 10.0);

相机沿 z 轴以 6 单位/秒的速度前进,speed = iTime * 6。x 方向做正弦摆动(振幅 12,周期 2π / 0.05 ≈ 125 秒),模拟飞行器在风沙中的侧向漂移。y 方向叠加 sin(speed * 0.2) * 1.5,形成上下起伏,振幅 1.5。

lookAt 点(ta)在相机前方 10 单位,同样有 x 方向的摆动,但相位超前 10 单位,保证飞行方向始终略微偏离当前位置,形成优雅的弧线。

7.2 滚动(Roll)

glsl 复制代码
float roll = -cos(speed * 0.05) * 0.3;

飞行器的滚动与路径的曲率同步。当路径向左弯曲时,机身向右倾斜(负号),产生向心力的视觉反馈。0.3 弧度的最大倾斜角(约 17 度)在真实感和不眩晕之间取得平衡。

7.3 鼠标交互

glsl 复制代码
vec2 drag = iMouse.z > 0.0 ? (iMouse.xy / iResolution.xy - 0.5) : vec2(0.0, -0.15);
ta.x -= drag.x * 15.0;
ta.y -= drag.y * 15.0;

鼠标按下时(iMouse.z > 0),根据鼠标位置偏移 lookAt 点。默认状态下 drag = (0, -0.15),让相机微微俯视(ta.y 被拉高),形成"从空中俯瞰沙丘"的经典视角。

8. 色调映射 --- ACES 近似

glsl 复制代码
col = (col * (2.51 * col + 0.03)) / (col * (2.43 * col + 0.59) + 0.14);

这是Krzysztof Narkowicz 的 ACES 拟合曲线 ,把高动态范围(HDR)的体渲染结果压缩到标准动态范围(SDR)。相比简单的 col / (col + 1) Reinhard 映射,ACES 的优势:

  • 暗部保持对比度,不会洗白
  • 亮部有平滑的 shoulder,霓虹的高光不会硬切
  • 整体色调略微偏暖,增强沙尘的质感

公式参数 2.51, 0.03, 2.43, 0.59, 0.14 是经过数值优化拟合 ACES 色彩空间转换矩阵的结果。

9. 阴影 --- SDF 软阴影

glsl 复制代码
float calcShadow(vec3 ro, vec3 rd) {
    float res = 1.0;
    float t = 0.05;
    for(int i = 0; i < 20; i++) {
        float h = map(ro + rd * t);
        if(h < 0.001) return 0.1;
        res = min(res, 6.0 * h / t);
        t += h;
        if(t > 15.0) break;
    }
    return clamp(res, 0.1, 1.0);
}

这是iq 的经典 SDF 软阴影算法 。核心思想:从表面点向光源发射射线,记录沿途的"最近距离/当前距离"比值 h/t

  • h/t 越大,说明射线离遮挡物越远,阴影越浅
  • h/t 越小,说明射线紧贴遮挡物,阴影越深
  • min(res, 6.0 * h/t) 把最小值作为 Penumbra(半影)强度
  • 20 步、15 单位距离的限制是为了性能

最终 clamp(res, 0.1, 1.0) 保证阴影最深也有 0.1 的 ambient,避免纯黑死区。

10. 性能分析

每帧计算成本:

阶段 开销 优化手段
光线行进 最多 150 步 自适应步长 + 透射率提前终止
地形 SDF 每步 1 次 纯正弦波,无纹理访问
沙尘密度 每步 1 次 3 层 ValueNoise,纯 ALU
法线计算 命中时 6 次 SDF 中心差分,解析函数
阴影 命中时 20 步 距离限制 15 单位

在现代 GPU 上,1080p 分辨率可以稳定 60fps。瓶颈主要在光线进 150 步的循环,但透射率提前终止通常在 50-80 步后就跳出,实际平均步数约 60。

总结

这个特效的精髓在于用体渲染方程的极简近似来模拟复杂的大气现象。沙丘不是模型,是正弦波的隐式曲面;沙尘不是粒子系统,是三维噪声密度场;霓虹不是后处理 bloom,是体素级别的自发光散射。

相关推荐
HUMHSX2 小时前
Vue 项目启动全流程解析:从入口文件到全局指令注册与页面渲染
前端·javascript·vue.js
有颜有货2 小时前
PMC生产排产的4种算法,一次讲清
java·服务器·前端
小虎牙0072 小时前
Android kotlin图片库Coil源码详解
android·前端
随风一样自由3 小时前
【前端领域】前端开发核心应用场景与落地实践
前端·前端框架
an317423 小时前
弹窗数据流设计的两种高阶架构实践
前端·vue.js·架构
谢尔登3 小时前
【React】 状态管理方案
前端·react.js·前端框架
用户2136610035723 小时前
Vue商品详情与放大镜组件
前端·javascript
半个落月4 小时前
从Tapas小Demo理清localStorage、事件与this
前端·javascript
李明卫杭州4 小时前
Vue2 中 v-model 处理不同数据结构的技巧
前端·javascript·vue.js
李明卫杭州4 小时前
使用 computed 处理 v-model 复杂数据结构
前端·javascript·vue.js