从天空穹顶到浩瀚行星:用着色器渲染逼真大气层
1. 引言:从简单的天空穹顶到真实的大气渲染
1.1 真实感天空渲染的重要性与应用场景
在三维图形学与游戏开发中,天空往往不仅仅是背景,它是决定场景氛围、时间流逝以及沉浸感的关键元素。无论是开阔世界的冒险游戏,还是太空模拟器,一个逼真的大气层渲染能瞬间将观众带入情境之中。想象一下,站在荒凉的异星表面,看着巨大的太阳缓缓落入地平线,天空从湛蓝过渡到深邃的橙红,这种视觉冲击力是静态贴图无法比拟的。
1.2 从静态天空盒到动态大气层的进化
早期的3D应用大多采用“天空盒”技术。这是一种由六个面组成的立方体,贴上预先渲染好的全景图。虽然成本低廉,但天空盒是静态的,无法响应场景中的光照变化,也无法实现昼夜交替。随着GPU性能的提升,开发者开始追求动态的天空效果。我们不再满足于“画”一个天空,而是要“模拟”一个天空。这就引出了本文的核心——基于物理的大气散射渲染。
1.3 本文目标:基于物理的实时着色器渲染方案
本文将参考 Maxime Heckel 等前沿图形学博客的思路,带领大家从零开始构建一个可交互、基于物理的大气渲染系统。我们将从最简单的几何穹顶出发,深入探讨瑞利散射与米氏散射的数学原理,利用光线步进算法在着色器中实现实时渲染,最终将其扩展为从太空俯瞰的行星大气层。
2. 基础篇:构建初步的天空穹顶
2.1 天空网格的几何构建与UV映射
渲染天空的第一步是构建一个容器。通常我们使用一个半球体或球体包裹摄像机,这个球体被称为“天空穹顶”。在实现中,为了确保无论摄像机如何移动,天空都看起来无限远,我们需要在顶点着色器中剔除模型的位移变换,仅保留旋转。
一个简单的天空穹顶顶点着色器逻辑如下:
// 顶点着色器 varying vec3 vWorldPosition; void main() { vec4 worldPosition = modelMatrix * vec4(position, 1.0); vWorldPosition = worldPosition.xyz; // 将位置转换为裁剪空间,忽略视图矩阵的平移分量 // 这确保天空永远跟随相机,看起来像在无限远处 gl_Position = projectionMatrix * viewMatrix * worldPosition; }2.2 简单的渐变着色模型与局限性
有了几何体,最直观的方法是根据高度(Y轴坐标)进行颜色渐变。例如,顶部是深蓝,地平线是浅白。
// 片元着色器 - 简单渐变 varying vec3 vWorldPosition; void main() { float height = normalize(vWorldPosition).y; vec3 skyBlue = vec3(0.2, 0.4, 0.8); vec3 horizonWhite = vec3(0.9, 0.9, 0.95); // 简单的线性插值 vec3 color = mix(horizonWhite, skyBlue, max(height, 0.0)); gl_FragColor = vec4(color, 1.0); }这种方法虽然简单,但缺乏真实感。它无法模拟日落时的红霞,也无法表现大气层的厚度感,更看不见太阳周围的光晕。
2.3 引入太阳:计算太阳方向与基础光照
为了引入太阳,我们需要定义一个统一变量uSunDirection。我们可以计算视线方向与太阳方向的点积,来绘制一个简单的圆盘作为太阳。
uniform vec3 uSunDirection; void main() { // ... 之前的渐变代码 ... vec3 viewDir = normalize(vWorldPosition); float sunDot = dot(viewDir, uSunDirection); // 绘制一个边缘柔和的太阳圆盘 float sunIntensity = smoothstep(0.9995, 0.9998, sunDot); color += vec3(1.0, 0.9, 0.7) * sunIntensity; gl_FragColor = vec4(color, 1.0); }这看起来像是一个贴在屏幕上的贴纸,毫无体积感。要解决这个问题,我们必须深入物理学,探究光在大气中究竟发生了什么。
3. 进阶篇:深入物理大气散射原理
要实现真实的天空,必须理解光线如何与大气中的粒子相互作用。这主要涉及三种机制:瑞利散射、米氏散射和臭氧吸收。
3.1 光线在大气中的传播机制
当太阳光穿过大气层时,会撞击空气分子(氮气、氧气)和气溶胶(灰尘、水滴)。一部分光被吸收,一部分光被散射到四面八方。正是这些被散射的光线进入了我们的眼睛,才形成了我们所看到的天空颜色。
3.2 瑞利散射:模拟湛蓝天空与日落红霞
瑞利散射描述了光波与远小于其波长的微小粒子(如空气分子)发生的相互作用。其散射强度与波长的四次方成反比:
βR(λ)∝1λ4 \beta_R(\lambda) \propto \frac{1}{\lambda^4}βR(λ)∝λ41
这意味着波长较短的光(蓝光、紫光)比波长较长的光(红光)更容易被散射。
- 正午时分:太阳高悬,光线穿过的大气层较薄,蓝光被强烈散射向四面八方,因此天空是蓝色的。
- 日落时分:太阳位于地平线,光线需要穿过极厚的大气层才能到达观察者。此时,蓝光早已被散射殆尽,剩下的红光、橙光占据主导,因此天空呈现红霞。
在着色器中,我们通常使用一个简化的系数来表示瑞利散射:
const vec3 betaR = vec3(5.8e-6, 13.5e-6, 33.1e-6); // 红、绿、蓝的散射系数3.3 米氏散射:还原太阳光晕与雾霭效果
米氏散射发生在光波与尺寸相近或更大的粒子(如气溶胶、雾滴)之间。与瑞利散射不同,米氏散射具有强烈的方向性,主要是前向散射(即沿着光线传播方向散射)。
这就是为什么我们在太阳周围能看到明亮的光晕。米氏散射也是造成雾霾、阴天灰白天空的主要原因。在着色器中,我们通常使用 Henyey-Greenstein 相位函数来模拟这种方向性:
P(θ,g)=1−g24π(1+g2−2gcosθ)3/2 P(\theta, g) = \frac{1 - g^2}{4\pi(1 + g^2 - 2g\cos\theta)^{3/2}}P(θ,g)=4π(1+g2−2gcosθ)3/21−g2
其中ggg是不对称因子,通常取 0.76 左右,表示强烈的向前散射。
3.4 臭氧吸收:修正天空色彩细节
除了散射,大气中的臭氧层还会吸收特定波长的光(主要是黄绿光波段)。虽然这是一个微小的细节,但在模拟日落时,臭氧吸收能帮助消除不必要的绿色成分,使天空颜色过渡更加自然,呈现出更纯粹的深蓝到橙红的渐变。
4. 实战篇:光线步进与着色器实现
理论准备好了,如何在屏幕上画出结果?答案是光线步进。
4.1 光线步进算法原理与体渲染入门
天空不是一个实心物体,而是一个体积。对于每一个像素,我们发射一条视线。这条视线穿过大气层,我们需要计算这条路径上所有粒子散射到我们眼睛里的光的总和。
我们将视线在体积内划分为若干个小段,每一段进行一次采样积分。这就是数值积分在体渲染中的应用。
4.2 大气层模型的数学定义与参数设置
我们需要定义大气层的形状。通常假设大气层是包裹在行星表面的一个球壳。
- 行星半径 (RRR):例如 6371km。
- 大气厚度:例如 100km。
在着色器中,我们需要一个函数来检测光线与这个球壳的交点:
vec2 raySphere(vec3 ro, vec3 rd, float radius) { float b = dot(ro, rd); float c = dot(ro, ro) - radius * radius; float d = b * b - c; if (d < 0.0) return vec2(-1.0); d = sqrt(d); float near = -b - d; float far = -b + d; return vec2(near, far); }4.3 在着色器中实现大气散射积分
核心渲染逻辑如下:对于视线上的每个采样点,计算该点接收到的太阳光,再计算这些光散射到我们眼睛的量。
// 简化的散射积分伪代码 vec3 computeIncidentLight(vec3 origin, vec3 direction, vec3 sunDirection) { // 1. 计算光线在大气层中的起点和终点 vec2 atmosphereHit = raySphere(origin, direction, atmosphereRadius); float tMax = atmosphereHit.y; float tMin = atmosphereHit.x; // 2. 初始化积分变量 vec3 totalR = vec3(0.0); // 累积的瑞利散射 vec3 totalM = vec3(0.0); // 累积的米氏散射 // 3. 光线步进循环 int iSteps = 16; // 采样次数 float segmentLength = (tMax - tMin) / float(iSteps); for(int i=0; i<iSteps; i++) { // 当前采样点的位置 vec3 samplePos = origin + direction * (tMin + (float(i) + 0.5) * segmentLength); // 计算该点的高度,用于查询大气密度 float height = length(samplePos) - planetRadius; float densityR = exp(-height / 8000.0) * segmentLength; float densityM = exp(-height / 1200.0) * segmentLength; // 计算该点到太阳的透射率 // 需要再次进行光线步进或近似计算,判断该点是否在阴影中 // ... 省略内部积分代码,通常使用近似公式 ... // 累积散射光 totalR += densityR * transmittanceToSun; totalM += densityM * transmittanceToSun; } // 4. 结合相位函数计算最终颜色 float cosTheta = dot(direction, sunDirection); float phaseR = 3.0 / (16.0 * PI) * (1.0 + cosTheta * cosTheta); float phaseM = henyeyGreenstein(cosTheta, 0.76); return totalR * betaR * phaseR + totalM * betaM * phaseM; }这段代码展示了核心思路:通过步进采样,根据高度计算密度,再根据太阳方向计算散射强度,最后累加。
4.4 优化策略:在浏览器中实现实时渲染
上述的双重积分(视线积分+向太阳方向的积分)计算量巨大。为了在浏览器中实现实时帧率,我们需要优化:
- 减少采样数:主循环可以使用较少的采样点(如16次),利用线性插值平滑结果。
- 预计算透射率:向太阳方向的积分可以简化,或者通过查找纹理来预计算。
- 后处理混合:先渲染天空,再渲染地物。
5. 拓展篇:从天空穹顶到浩瀚行星
5.1 视角转换:从地面观天到太空俯瞰
当我们把摄像机从地面移到太空中,之前的算法依然有效,但需要调整起点。在地面时,摄像机位于球壳内部;在太空时,摄像机位于球壳外部。光线步进算法会自动处理这种情况:它首先计算光线何时进入大气层,然后开始积分。
5.2 行星大气层的完整渲染管线
为了渲染一个完整的行星,我们需要处理两层结构:
- 地表:一个实心的球体,可以赋予纹理。
- 大气层:一个透明的球壳,使用上述散射着色器。
关键在于混合。大气层不仅是一个发光体,它还会遮挡背后的地表。我们需要利用透射率来控制地表的可见度。当视线切向大气层边缘时,光线路径极长,透射率趋近于0,大气层变得不透明,形成了我们熟悉的“菲涅尔效应”般的边缘发光。
5.3 调整散射参数以模拟不同星球环境
这套物理模型的强大之处在于参数化。通过调整散射系数和大气厚度,我们可以模拟各种科幻场景:
- 火星:降低大气密度,增加米氏散射(尘埃),使用红色系的散射系数。
- 超级地球:增加大气厚度,瑞利散射极强,天空呈现深邃的紫色或黑色,日落时出现巨大的光晕。
- 异星风暴:通过动态改变
uSunDirection或引入噪声函数扰动密度,模拟动态的云层效果。
// 模拟火星大气参数示例 const vec3 betaR_Mars = vec3(0.0); // 极稀薄的空气 const vec3 betaM_Mars = vec3(0.5, 0.2, 0.1); // 充满红色尘埃6. 结语与展望
6.1 最终渲染效果展示与回顾
通过本文的步骤
,我们成功地在GPU中构建了一个微型宇宙。从最初简陋的颜色渐变,到引入光线步进和物理散射公式,最终渲染出具有体积感的天空和行星大气。我们可以看到太阳缓缓落下,天空从蓝变红,从地面到太空视角的无缝切换。
6.2 现有方案的局限性与改进方向
虽然基于物理的渲染效果惊人,但实时计算的消耗依然很大。目前的简化模型在极端情况下(如视线几乎平行于地平线)可能会出现采样不足导致的色带问题。未来的优化方向包括:
- 预计算查找表(LUT):将大气散射结果烘焙到2D或3D纹理中,运行时只需查表,极大提升性能。
- 高阶散射:目前主要计算单次散射,多次散射(光照在地面反射后再进入大气)能增加真实感,但计算复杂。
6.3 扩展阅读与参考资源
本文的灵感主要来源于 Maxime Heckel 的博客文章《On Rendering the Sky, Sunsets, and Planets》,以及经典的 GPU Gems 系列。对于希望深入研究的读者,推荐阅读 Sean O’Neil 关于大气渲染的论文以及 NVIDIA 的大气散射示例代码。图形学的世界浩瀚无垠,愿大家都能渲染出属于自己的那片星空。