← EasyTool.me 博客

程序化天空、日落与行星渲染指南:大气散射原理与着色器实战

发布: 2026-05-13 阅读: 15 分钟 3D 图形 · 着色器 · 教程

你有没有在游戏里盯着日落发呆过 想过为什么白天天空是蓝色 日落却变成红色 为什么太空游戏里的行星边缘会有一圈朦胧的光晕 答案藏在大气散射里 这篇指南从零开始 教你用 JavaScript 和着色器程序化渲染这一切

本文基于 Maxime Heckel 的经典博客 "On Rendering the Sky, Sunsets, and Planets"(HN 392 分) 他从物理原理讲到代码实现 亲手构建了一个实时天空渲染器 我们会深入拆解这一切 从让天空变蓝的瑞利散射 到把日落烧成橙红色的米氏散射

为什么要程序化生成天空

在 3D 图形里 天空不只是一张背景图 程序化生成的天空会随着太阳位置动态变化 形成真实的昼夜交替和日出日落效果 这对以下场景至关重要

与使用静态天空盒贴图不同 程序化生成能实时适配任何光照条件 无需预烘焙素材就能创造无限变化

天空颜色的物理原理

天空的颜色来自大气散射 — 太阳光与地球大气中粒子的相互作用 你需要理解两种主要类型

瑞利散射:为什么天空是蓝色的

瑞利散射发生在光与远小于其波长的粒子相互作用时 — 在地球上就是空气分子(氮气和氧气) 关键公式:散射强度与 1/λ⁴(波长的四次方)成正比 这意味着蓝光(短波长 ~450nm)的散射强度大约是红光(长波长 ~650nm)的 16 倍

当太阳在头顶时 光线穿过相对较薄的大气层 蓝光向四面八方散射 填满整片天空 红光则基本不受影响——所以正午的太阳看起来偏黄白色

在代码中 瑞利散射建模如下

// 各波长的瑞利散射系数
const rayleighBeta = (lambda) => {
  const n = 1.00029; // 空气折射率
  const N = 2.504e25; // 分子数密度
  const pn = 0.035; // 退偏振因子
  const lambdaM = lambda * 1e-9; // 纳米转米
  return (8 * Math.PI ** 3 * (n ** 2 - 1) ** 2 * (6 + 3 * pn)) /
         (3 * N * lambdaM ** 4 * (6 - 7 * pn));
};

米氏散射:为什么日落是红色的

米氏散射发生在光与粒径接近其波长的粒子相互作用时 — 比如气溶胶、灰尘、水滴 与瑞利散射不同 米氏散射对波长依赖很小(约 λ⁻¹) 这意味着它均匀散射所有颜色的光 在太阳周围形成白色的晖光

但日落的效果另有玄机:当太阳低垂在地平线上 光线需要穿过厚得多的大气层——正午的 40 倍以上 蓝光在到达你眼睛之前 早就被瑞利散射光了 红橙光凭借更长的波长 才能一路坚持到最后 这就是为什么日落天空燃烧着温暖的颜色 而头顶的天空渐变成深蓝紫色

构建程序化天空渲染器

现在我们来动手实现 Heckel 的渲染器使用 Three.js 配合自定义的顶点着色器和片元着色器(GLSL 编写) 核心流程如下

  1. 用一个巨大的球体围绕相机作为天空
  2. 对每个片元计算视线穿过大气的路径
  3. 沿视线积分散射(入射散射 + 出射散射)
  4. 根据太阳角度混合瑞利(蓝色)和米氏(白色/红色)贡献
  5. 添加地面颜色和星星

第一步:天空球体

最简单的方法是用一个大球体包围场景 法线朝内 Three.js 代码如下

const skyGeo = new THREE.SphereGeometry(400, 64, 40);
const skyMat = new THREE.ShaderMaterial({
  vertexShader: skyVertexShader,
  fragmentShader: skyFragmentShader,
  uniforms: {
    sunDirection: { value: new THREE.Vector3(0, 0.5, -1).normalize() },
    rayleighCoefficient: { value: 0.0025 },
    mieCoefficient: { value: 0.001 },
    turbidity: { value: 2.0 }
  },
  side: THREE.BackSide
});
const sky = new THREE.Mesh(skyGeo, skyMat);

第二步:片元着色器中的大气散射

片元着色器是核心魔法所在

// GLSL — 片元着色器
uniform vec3 sunDirection;
uniform float rayleighCoefficient;
uniform float mieCoefficient;
uniform float turbidity;

varying vec3 vWorldPosition;

const float PI = 3.14159265359;

// 瑞利相位函数(偶极散射)
float rayleighPhase(float cosTheta) {
  return (3.0 / (16.0 * PI)) * (1.0 + cosTheta * cosTheta);
}

// 米氏相位函数(Henyey-Greenstein)
float miePhase(float cosTheta, float g) {
  return (3.0 / (8.0 * PI)) * ((1.0 - g * g) * (1.0 + cosTheta * cosTheta)) /
         ((2.0 + g * g) * pow(1.0 + g * g - 2.0 * g * cosTheta, 1.5));
}

void main() {
  vec3 viewDir = normalize(vWorldPosition - cameraPosition);
  float cosTheta = dot(viewDir, sunDirection);

  // 散射系数
  vec3 rayleighScattering = vec3(
    rayleighCoefficient / pow(0.650, 4.0),  // 红
    rayleighCoefficient / pow(0.570, 4.0),  // 绿
    rayleighCoefficient / pow(0.475, 4.0)   // 蓝
  );

  // 米氏散射(大致与波长无关)
  float mieScattering = mieCoefficient;

  // 光学深度(简化版 — 穿过大气的路径长度)
  float rayleighDepth = exp(-turbidity * 0.5);
  float mieDepth = exp(-turbidity * 0.3);

  // 最终颜色
  vec3 color = rayleighPhase(cosTheta) * rayleighScattering * rayleighDepth
             + miePhase(cosTheta, 0.76) * mieScattering * mieDepth;

  gl_FragColor = vec4(color, 1.0);
}

第三步:日落效果

要获得真实的日落 需要一个多次散射近似 单次散射模型(光只反弹一次)能渲染出蓝天 但会丢失温暖的日落颜色 Heckel 的方法增加了一个二次散射项 捕捉穿过大气向前散射的红橙色光

// 日落贡献 — 二次散射
vec3 sunsetGlow = vec3(1.0, 0.6, 0.2) *
                  exp(-3.0 * (1.0 - cosTheta)) *
                  (1.0 - exp(-turbidity * 2.0));

color += sunsetGlow * 0.5;

这就在太阳低角度时 在地平线附近制造出标志性的温暖光晕 需要调节的关键参数:turbidity(气溶胶浓度 越大日落越红) sunAngle(10° 以下进入日落模式) 以及瑞利和米氏系数的平衡

渲染行星

现在让我们给场景加上行星 一个有说服力的行星需要三层

  1. 带纹理的球体 — 行星本体 程序化或基于图片的材质
  2. 大气光晕 — 一个透明外壳 附加散射效果
  3. 阴影 — 相位效果(行星的暗面)

着色器生成行星材质

用噪声函数就可以程序化生成令人印象深刻的行星纹理

// 片元着色器 — 程序化行星纹理
uniform float seed;

float terrain(vec3 p) {
  float n = snoise(p * 0.5 + seed);
  n += snoise(p * 1.0 + seed) * 0.5;
  n += snoise(p * 2.0 + seed) * 0.25;
  return n;
}

void main() {
  vec3 p = normalize(vPosition);
  float height = terrain(p);

  // 基于高度的调色板
  vec3 ocean = vec3(0.1, 0.2, 0.5);
  vec3 land = vec3(0.2, 0.5, 0.1);
  vec3 mountain = vec3(0.4, 0.3, 0.2);

  vec3 color = mix(ocean, land, smoothstep(0.0, 0.3, height));
  color = mix(color, mountain, smoothstep(0.4, 0.6, height));

  gl_FragColor = vec4(color, 1.0);
}

大气层光晕

行星大气层渲染为一个稍大的球体包裹行星 用边缘光效果——光穿过最多大气层的边缘散射最强

// 大气层光晕着色器
float rim = 1.0 - max(0.0, dot(viewDir, normal));
float atmosphere = pow(rim, 3.0) * 0.8;

vec3 atmosColor = mix(vec3(0.5, 0.7, 1.0), vec3(1.0, 0.5, 0.2),
                       dot(viewDir, lightDir) * 0.5 + 0.5);

gl_FragColor = vec4(atmosColor, atmosphere);

这给出行星特有的蓝色或橙色边缘光晕 取决于大气成分

星空背景:噪声生成的星场

没有星星的行星场景是不完整的 使用基于哈希的噪声函数程序化生成星星 而不是静态贴图

// 使用哈希噪声生成星星
float stars(vec3 p) {
  vec3 i = floor(p * 200.0);
  vec3 f = fract(p * 200.0);

  float star = hash(i);
  return step(0.997, star); // 稀疏 — 只有最亮点
}

void main() {
  vec3 color = stars(vPosition) * vec3(1.0);
  // 添加闪烁变化
  color *= 0.5 + 0.5 * sin(time * 2.0 + hash(i) * 6.28);
  gl_FragColor = vec4(color, 1.0);
}

关键在于让星星稀疏(只有约 0.3% 的随机位置成为星星)且亮度不一 加上轻微闪烁就能打造生动的夜空

性能优化技巧

大气散射计算量较大 以下是实时渲染的优化策略

总结

渲染天空是一个"一点物理知识就能走很远"的问题 理解了瑞利散射和米氏散射 你就能用几百行着色器代码 创造出令人惊叹的实时天空、逼真的日落和有说服力的行星大气

本文涵盖的技术——多次散射近似、相位函数、光学深度积分、程序化噪声——构成了现代天空渲染的基础 从简单的天空球开始 逐步添加散射效果 最终构建完整的行星渲染系统

准备好了吗? 完整源码、交互式演示和更详细的解释 请参考 Maxime Heckel 的原博客文章(HN 392 分)