
2026/05/12 22:26
空、夕焼け、惑星を描画します。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
本プロジェクトは、青空、夕焼け、体積霧、惑星大気を含む高忠実度かつ現実的な大気効果を実際の Web ブラウザ内で直接提供します。実装ではビールの法則に基づいて複雑な光の相互作用をシミュレートするためのレイマーチングを採用しており、これにはレイリー散乱(青空)とミー散乱の両モデル、ならびにオゾン吸収が含まれます。広大な距離における精度を確保するため、夕焼けにはネストした光マーチングループが用いられ、惑星規模での深度ファイトを防ぐために対数深度バッファーがサポートされます。シーンジオメトリの処理にはレイ・スフィア交差テストが使用され、日食レンダリングエンジンなどの特化した機能も含まれています。重い計算コストを伴わずにリアルタイムパフォーマンスを実現するために、開発チームは Sebastian Hillaire の手法に従い、LUT ベースのテクスチャ(ルックアップテーブル)を採用し、高コストな計算を事前計算された透過率、スカビュー、空中透視のレイヤーへと分離しました。このモジュラーフレームワークは、これらの先進的光学モデルと既存のシーンジオメトリを統合することに成功し、地球や火星などの様々な惑星体上で瞬時に没入型体験を提供することを可能にしています。
本文
长期以来,我的灵感板上一直放着一张照片:阿芙罗达特(Endeavour)航天飞机悬浮在低地球轨道上,背景是黄昏时分的景象。照片展现了地球上层大气作为背景的壮丽画面,色彩斑斓的大气层从深橙色渐变至蓝色,最终消融在深邃的太空黑色之中。这不仅仅是色彩梯度的美学享受,其背后的现象——大气散射(atmospheric scattering)更是引人入胜的主题,尤其是当你开始深入研究其工作原理以及如何复现它时。
航天飞机剪影: https://www.nasa.gov/image-article/shuttle-silhouette-2/
我想要利用着色器(shaders)自行构建一种类似的视觉效果,直接在浏览器中渲染出天空独特的蓝色以及逼真的日落和日出。我的目标是在尽可能贴近原照片的同时,向游戏和其他基于着色器的媒体中常见的那种大气渲染效果靠拢。
以下是我在本月探索过程中完成成果的汇编,所有内容均支持实时运行:
起初我并未计划撰写关于此主题的文章,但近期“阿提米斯二号”(Artemis II)任务引发的热情,加上我自己对太空的浓厚兴趣,让我觉得值得对此进行深入的探讨。这也感觉是打造一个互动体验的最佳时机,能让这个题材变得更加平易近人。 在本文中,我们将一步步学习如何实现大气散射着色器的后期处理效果:从构建不同基础模块(光线步进/raymarching、瑞利散射/Rayleigh scattering、米氏散射/Mie scattering 以及臭氧吸收/ozone absorption)以渲染逼真的天空穹顶开始,再到将其调整为围绕行星的大气外壳进行渲染。最后,我们将探讨塞巴斯蒂安·伊莱尔(Sebastian Hillaire)提出的基于查找表(LUT)的方法以获得更高效的性能,或者至少是我尝试实现该方法的过程——因为这对于这个项目来说,确实是我跳出舒适区的阶段。
如何渲染天空
或许你在某个时刻尝试过简单地给作品背后叠加一个蓝色渐变背景,试图赋予其某种“大气感”便就此收手,但很快便会发现这种做法总觉得不尽如人意。若要实现更贴近真实的呈现,我们必须将天空及其颜色视为光线与空气及其成分相互作用的結果,并考虑到多个变量,例如观测者的高度、尘埃含量、一天中的时间等,所有这一切都在一个体积(volume)内进行计算。
明确了这一点后,我们第一部分的目标就是以此为指导原则,为我们的大气着色器奠定基础,从而获得在任何时间段都几乎难以与真实天空区分开的渲染结果。
采样大气密度
正如处理体云层或体光时一样,一种简单的大气采样方法是使用光线步进(raymarching)。我们可以从相机位置向场景中发射射线,并步进穿过透明介质来回答以下两个问题:
- 有多少光线能够穿越大气层?这是透射率(transmittance)项。
- 在每个采样点处有多少光线被重定向到相机方向?这也称为散射(scattering)。
为了回答第一个问题,我们需要沿射线累加所遭遇的大气密度,以获得所谓的“光深”(optical depth)。我们将使用瑞利密度函数(Rayleigh density function)对此进行建模,该函数告诉我们给定高度 $h$ 处有多少“空气”。这一点非常重要,因为随着高度增加,大气层会变薄。
const float RAYLEIGH_SCALE_HEIGHT = 8.0; const float ATMOSPHERE_HEIGHT = 100.0; const float VIEW_DISTANCE = 200.0; const int PRIMARY_STEPS = 24; const vec3 SUN_DIRECTION = normalize(vec3(0.0, 1.0, 1.0)); float rayleighDensity(float h) { return exp(-max(h, 0.0) / RAYLEIGH_SCALE_HEIGHT); }
然后,我们可以从光深中计算出沿射线给定位置处的透射率 $T$:即光线在穿过大气层传播过程中幸存下来的比例。
- $T=1.0$ 表示没有光线损失。
- $T=0.0$ 表示光线完全消失。
如果你读过我之前关于体云的文章,你可能会对这个公式感到熟悉——这就是比尔定律(Beer's Law):
float dR = rayleighDensity(h); viewOpticalDepth += dR * stepSize; vec3 transmittance = exp(-rayleighBeta * viewOpticalDepth); scattering += dR * transmittance * stepSize;
有了这些设定,我们现在可以描述光线在穿过大气层传播过程中的衰减情况。然而,密度和透射率只告诉我们要有多少光可用于散射,而没有说明这些光是如何分布到观察者眼中的。为此,我们需要考虑入射阳光与视线射线之间的夹角,这正是瑞利相位函数(Rayleigh phase function)所描述的。
const vec3 SUN_DIRECTION = normalize(vec3(0.0, 1.0, 1.0)); float rayleighPhase(float mu) { return 3.0 / (16.0 * PI) * (1.0 + mu * mu); } float phase = rayleighPhase(dot(skyDir, SUN_DIRECTION)); scattering *= SUN_INTENSITY * phase * rayleighBeta;
将以上内容整合起来,我们就可以在一定程度上准确地描述在任意给定高度沿某条射线累积的散射光量。 下方的组件展示了我们刚才描述的过程,显示了:
- 单条射线上的采样步骤
- 通过此过程获得的像素颜色(近似值)
正如你所见,我们在较低的高度累积了蓝色色调!这主要是由于瑞利散射系数的数值特性:
- 红光散射很少
- 绿光散射稍多
- 蓝光散射最多
由于较短波长的光散射更强,更多的蓝光会被重定向到观察者眼中,因此白天天空看起来是蓝色的。如果我们将这一理念扩展为完整的片段着色器(fragment shader),从单条射线扩展到每条像素一条射线,我们就可以渲染出逼真的天空,如下所示: 此光线步进过程产生了一个美丽的蓝色天空,地平线附近由于光线穿过更厚的大气层而呈现出较浅的白色薄雾,而随着高度增加、大气变薄,颜色则变得更深、更蓝。
米氏散射与臭氧吸收
仅依靠瑞利散射可以得到不错的结果,但为了使我们的大气渲染更接近现实,我们还需要考虑其他一些大气效应:
- 米氏散射(Mie Scattering):描述光线与大气中较大粒子(如尘埃或气溶胶)的相互作用。它具有密度函数来考虑介质中的材料量,以及一个相位函数——类似于瑞利散射的对偶概念,描述了光如何在不同方向上重新分布。
- 臭氧吸收(Ozone absorption):模拟臭氧如何吸收穿过高层大气的光线的一部分。这一项并不使光线发生散射;它只是沿路径移除某些波长。其主要贡献是改变和加深天空的颜色,特别是在地平线附近以及日落或黄昏时。
第一项可以通过以下两个函数进行建模:
float miePhase(float mu) { float gg = MIE_G * MIE_G; float num = 3.0 * (1.0 - gg) * (1.0 + mu * mu); float den = 8.0 * PI * (2.0 + gg) * pow(max(1.0 + gg - 2.0 * MIE_G * mu, 1e-4), 1.5); return num / den; } float mieDensity(float h) { return exp(-max(h, 0.0) / MIE_SCALE_HEIGHT); }
为了获得考虑了米氏散射和臭氧吸收的更新后的散射项,我们只需将其添加到当前天空着色器的实现中,并叠加在瑞利密度和相位函数之上:
for (int i = 0; i < PRIMARY_STEPS; i++) { float t = (float(i) + 0.5) * stepSize; float h = uObserverAltitude + t * skyDir.y; if (h > ATMOSPHERE_HEIGHT) break; float dR = rayleighDensity(h); float dM = mieDensity(h); float dO = ozoneDensity(h); viewODR += dR * stepSize; viewODM += dM * stepSize; viewODO += dO * stepSize; vec3 tau = BETA_R * viewODR + BETA_M_EXT * viewODM + BETA_OZONE_ABS * viewODO; vec3 transmittance = exp(-tau); sumR += dR * transmittance * stepSize; sumM += dM * transmittance * stepSize; sumO += dO * transmittance * stepSize; } vec3 scattering = SUN_INTENSITY * ( phaseR * BETA_R * sumR + phaseM * BETA_M_SCATTER * sumM + BETA_OZONE_SCATTER * sumO);
下方的组件展示了将这两个新项集成到我们的天空着色器后的结果: 正如你所见,这一版本提供了以下效果:
- 由于臭氧吸收,呈现出更自然的“天空蓝”色调。
- 太阳位置周围出现朦胧的光辉,特别是在太阳接近地平线时更为明显。
光照与透射率
目前,我们已经拥有一个不错的天空片段着色器,能够渲染任何高度的自然颜色,并考虑了多种透射率模型(米氏、瑞利和臭氧)。但这仍然留下了我们需要处理的光照部分。 你可能注意到在上方的组件中,当太阳移动到接近地平线时,仅仅产生了一个白色的朦胧光晕,而没有任何光线衰减或日落/日出的效果。这是预期的结果,因为我们当前的射线步进循环仅考虑了从相机到每个采样点沿视线射线的透射率衰减。它尚未计算在到达该采样点之前,阳光穿过大气层时会损失多少。 就像我们在之前的相关文章中做的那样,我们需要为任意给定的采样点引入一个独立的嵌套循环,沿着光源方向进行“光步进”(light-marching),并沿该路径采样透射率。
在我们的先前实现中,光深仅沿视线射线通过
viewODR、viewODM 和 viewODO 计算。在这个更新版本中,我们将:
- 添加一个
值,用于携带沿采样点与太阳之间路径累积的光深量。sunOD
vec3 lightMarch(float start, float sunY) { float denom = max(sunY + 0.15, 0.04); float maxDist = (ATMOSPHERE_HEIGHT - start) / denom; float stepSize = max(maxDist, 0.0) / float(LIGHTMARCH_STEPS); for (int i = 0; i < int(LIGHTMARCH_STEPS); i++) { float t = (float(i) + 0.5) * stepSize; float h = start + t * sunY; if (h < 0.0 || h > ATMOSPHERE_HEIGHT) { break; // Optimization: stop if out of bounds } odR += rayleighDensity(h) * stepSize; if (uMieEnabled) odM += mieDensity(h) * stepSize; if (uOzoneEnabled) odO += ozoneDensity(h) * stepSize; } return vec3(odR, odM, odO); }
将其与我们之前在
tau 变量中引入的每个单独的光深相加。
float dR = rayleighDensity(h); float dM = mieDensity(h); float dO = ozoneDensity(h); viewODR += dR * stepSize; viewODM += dM * stepSize; viewODO += dO * stepSize; vec3 sunOD = uSunAngle > 0.0 && uSunAngle < PI ? lightMarch(h, sunDirection.y) : vec3(1000.0); vec3 tau = BETA_R * (viewODR + sunOD.x) + BETA_M_EXT * (viewODM + sunOD.y) + BETA_OZONE_ABS * (viewODO + sunOD.z); vec3 transmittance = exp(-tau);
有了这些设定,我们现在能够在任何光照条件下渲染天空:日落、日出、天顶以及其间的所有情况。 我邀请你花点时间玩弄一下上面的组件,欣赏我们的着色器通过这个现已完整实现的天空模型所能呈现的不同天色。请注意:
- 天空的蓝色在一天的不同时刻发生变化(由太阳角度 uniform 表示),并且由于米氏散射的存在,在日落和日出时光线与地平线很好地融合。
- 当太阳位置较低时,臭氧赋予我们的天空一种宜人的紫色调。
行星大气
我们在第一部分构建的着色器已经打勾了很多项目,但我们目前拥有的只是一个平坦的背景。如果我们将它以当前状态用于 React Three Fiber 场景中,那么它将仅仅是一个不错的场景背景,除此之外别无他用。 在这一部分,我们将把这个平坦的着色器转变为一个真正的后期处理效果,使我们能够渲染大气层作为:
- 一个体积,并在过程中通过从屏幕 UV 坐标重建世界空间坐标来考虑场景的深度。
- 围绕行星网格的外壳。
世界空间重建、深度与大气雾效
要将大气散射应用于场景,我们不仅仅是在绘制天空;我们需要填充相机和屏幕上渲染的不同物体之间的空间。幸运的是,我们在第一部分已经完成了其中一部分工作:我们拥有计算我们 3D 场景中体积内所有内容所需的所有密度数据。这里唯一需要做的就是:
- 创建一个可以渲染我们天空着色器的后期处理效果。
- 获取场景的深度缓冲区以及相机的
、projectionMatrixInverse
和位置,并将它们作为效果的 uniform 传递过去。matrixWorld
通过将屏幕空间坐标转换为世界空间坐标,使用以下函数从相机的每个像素重建 3D 射线:
vec3 getWorldPosition(vec2 uv, float depth) { float clipZ = depth * 2.0 - 1.0; vec2 ndc = uv * 2.0 - 1.0; vec4 clip = vec4(ndc, clipZ, 1.0); vec4 view = projectionMatrixInverse * clip; vec4 world = viewMatrixInverse * view; return world.xyz / world.w; }
既然我们知道如何获取当前像素的世界位置(
worldPosition),我们就可以:
- 将
设置为相机的位置。rayOrigin - 将
设置为我们计算出的世界位置与rayDir
之间差值的归一化值。rayOrigin
这样做可以确保我们的射线步进循环现在沿一条 3D 射线进行推进。
float depth = readDepth(depthBuffer, uv); vec3 rayOrigin = uCameraPosition; vec3 worldPosition = getWorldPosition(uv, depth); vec3 rayDir = normalize(worldPosition - rayOrigin);
我们现在需要做的是让射线步进考虑场景中的任何几何体。为此,我们将使用场景的深度缓冲区来定义射线步进的
stepSize,而不是使用常数,以便我们能够调整采样点以适应当前正在进行的射线。
float depth = readDepth(depthBuffer, uv); vec3 rayOrigin = uCameraPosition; vec3 worldPosition = getWorldPosition(uv, depth); vec3 rayDir = normalize(worldPosition - rayOrigin); float sceneDepth = depthToRayDistance(uv, depth); float SKY_MARCH_DISTANCE_MULTIPLIER = 8.0; bool isBackground = depth >= 1.0 - 1e-7; sceneDepth = atmosphereHeight * SKY_MARCH_DISTANCE_MULTIPLIER; float rayEnd = max(sceneDepth, 0.0); if (rayDir.y < -1e-5) { tGround = observerAltitude / max(-rayDir.y, 1e-4); rayEnd = min(rayEnd, tGround); } float stepSize = (rayEnd - rayStart) / float(PRIMARY_STEPS);
这使得我们对于击中附近物体或地面的射线采样非常准确:
stepSize 会很小。对于那些传播得更远的射线,我们可以稍微不那么精确,因为它们覆盖更大的距离,并且我们会沿着它们分布同等数量的采样点。
下方的游戏场渲染了我们之前组装好的相同着色器,但这次是作为后期处理效果,使我们能够在整个场景体积中渲染大气散射,同时考虑其几何体,并以我们的天空着色器作为背景。请注意:
- 离相机较近的物体会显得更清晰。
- 离相机较远的物体则会逐渐消失。
实现了这一点后,我们可以为任何需要它的场景提供更逼真的环境天空,并享受一些有趣的互动(如下所示),这是通过 Raycaster 实现的:[现在带有可拖拽天体的大气后期处理效果](https://t.co/xejzC5SWuc https://t.co/U342icnvxz)
渲染行星
我们终于来到了你可能最初来到这里的目的所在部分:渲染围绕行星的真实大气层!幸运的是,凭借我们到目前为止所构建的一切,我们只需要两个步骤就能实现这一目标:
- 切换到对数深度缓冲区以处理更大的尺度。
- 定义沿任意给定射线大气层开始和结束的位置以确定其形状——如你所料,这将是一个球体。
由于在本节中我们要在行星尺度上工作,当我们从远处观察我们的行星时,可以预期会出现大量的“深度战斗”(depth fighting),因为我们的着色器很难从远距离区分大气层和行星外壳之间的深度(因为大气层高度只有几公里)。我们需要调整 React Three Fiber 场景中深度缓冲区的定义方式以及读取它的方式。为此,我们在包裹整个场景定义的 Canvas 组件的
gl prop 中将 logarithmicDepthBuffer 设置为 true:
// 为我们的场景启用对数深度缓冲区 canvasProps: { logarithmicDepthBuffer: true, },
然后在着色器中,我们将如下重新定义
sceneDepth,以转换后处理效果接收到的对数深度缓冲区,并将其转换回沿射线的距离。
// 更新后的 getWorldPosition 函数 float logDepthToViewZ(float depth) { float d = pow(2.0, depth * log2(cameraFar + 1.0)) - 1.0; return d; } vec3 getWorldPosition(vec2 uv, float depth) { float viewZ = logDepthToViewZ(depth); vec2 ndc = uv * 2.0 - 1.0; vec4 clipAtZ1 = vec4(ndc, -1.0, 1.0); vec4 viewAtZ1 = projectionMatrixInverse * clipAtZ1; viewAtZ1 /= viewAtZ1.w; vec3 viewPos = viewAtZ1.xyz * (viewZ / viewAtZ1.z); vec4 world = viewMatrixInverse * vec4(viewPos, 1.0); return world.xyz / world.w; }
对于第二点,我们将使用射线-球体交点测试(ray-sphere intersection test)来找到我们的视线进入和退出大气球体的位置。一旦有了这两个点,我们就可以将射线步进循环限制在该段内,而不会在大气层外浪费采样。然而,仅做一次测试是不够的。我们还希望将行星建模为围绕稍大一点的“大气球体”的球形网格,因此我们需要对行星本身执行相同的测试。如果射线在离开大气层之前就击中了地面,我们就使用该地面交点作为射线步进段的终点。
vec3 planetCenter = vec3(0.0); vec2 atmosphereHit = raySphereIntersect(rayOrigin, rayDir, planetCenter, atmosphereRadius); vec2 planetHit = raySphereIntersect(rayOrigin, rayDir, planetCenter, planetRadius); float atmosphereNear = max(atmosphereHit.x, 0.0); float atmosphereFar = atmosphereHit.y; if (planetHit.x > 0.0) { atmosphereFar = min(atmosphereFar, planetHit.x); } atmosphereFar = min(atmosphereFar, sceneDepth); if (atmosphereFar > atmosphereNear) { // Raymarching occurs here }
我们需要适应的另一个方面是我们的射线步进段的终点,以处理场景中的物体。大气层停止的原因可能有两种:
- 它可以击中行星表面(
)。planetHit.x > 0.0 - 它可以在到达地面之前就击中另一个场景对象。
if (planetHit.x > 0.0) { atmosphereFar = min(atmosphereFar, planetHit.x); if (sceneDepth < planetHit.x - 2.0) { atmosphereFar = min(atmosphereFar, sceneDepth); } else { atmosphereFar = min(atmosphereFar, sceneDepth); } }
在这两种情况下,我们都希望在最相关的物体处停止步进。
考虑场景深度之前的/之后的我们的场景:请注意,如果没有这种逻辑,行星的表面将出现在我们的对象之前。随着现在这两部分代码的实现,我们拥有了作为后期处理效果的完整大气散射实现,并可以渲染围绕行星的大气层。下方的场景在 React Three Fiber 中渲染了一个简单的“太阳 - 地球系统”,其中包含我们自定义的效果。我邀请你花点时间调整太阳的位置、缩放视图,并从不同角度欣赏该着色器所能产生的天空颜色——无论是从地面还是在轨道上。 你在本演示中看到的效果是我用来拍摄海报照片所使用的同一效果,这些海报是在今年四月初发布的,用于宣布这篇文章:关于大气散射的即将发布且非常贴合主题的文章大纲 我感到深受启发并制作了带有实际渲染照片的海报,使用了你在其中将学到的技术 :) 非常期待这个成果 https://t.co/wSjdQPyoI0
处理日食
这是一个小小的附加部分,我想在此解答一个问题:我们如何处理大型天体遮挡太阳?我们现在对光照在大气散射着色器中的作用有了相当好的理解,因此添加这个额外的测试相对容易。 我们可以在
lightMarch 函数之后添加一个函数调用,返回范围为 [0, 1] 的 sunVisibility(太阳可见性),并将透射率乘以该值。该函数本身可以很简单,只需要在以下两项之间执行点积:
- 当前采样点与月亮之间的方向。
- 当前采样点与太阳之间的方向。
如果它们非常匹配,即接近 1.0,这意味着月亮会遮挡太阳;反之亦然;如果它们是正交的,接近 0.0,则表示没有遮挡。然而,这并没有考虑到场景中物体的尺寸和比例。 下图展示了我们的太阳可见性测试所处理的不同的遮挡场景。我们需要一个能够处理上述图中描述的三种情况的函数:
- 当月亮没有遮挡太阳时。
- 当它遮挡了太阳,但其大小或接近太阳的大小(从相机视角来看)。
- 当它遮挡了太阳,但其大小小于太阳的半径(从相机视角来看)。
float sunVisibility(vec3 point) { vec3 sunDir = normalize(sunDirection); vec3 toMoon = moonPosition - point; float moonDist = length(toMoon); vec3 moonDir = normalize(toMoon); if (moonDist <= 1e-5) { return 0.0; // Too close, undefined behavior } float angularSep = acos(clamp(dot(sunDir, moonDir), -1.0, 1.0)); float sunAngularRadius = SUN_RADIUS / SUN_DISTANCE; float moonAngularRadius = moonRadius / moonDist; float outerEdge = sunAngularRadius + moonAngularRadius; if (dot(sunDir, moonDir) < 0.9) { float innerEdge = moonAngularRadius - sunAngularRadius; return max(0.075, smoothstep(innerEdge, outerEdge, angularSep)); } float innerEdge = sunAngularRadius - moonAngularRadius; float minVisibility = clamp( 1.0 - (moonAngularRadius * moonAngularRadius) / (sunAngularRadius * sunAngularRadius), 0.0, 1.0 ); return mix(minVisibility, 1.0, smoothstep(innerEdge, outerEdge, angularSep)); }
在这里,
float angularSep = acos(clamp(dot(sunDir, moonDir), -1.0, 1.0)) 代表太阳和月亮方向之间的角分离(angular separation)。dot(sunDir, moonDir) 代表两个方向的对齐程度。acos 将其转换回角度。
然后我们可以使用该值与不同的角阈值 outerEdge 和 innerEdge 进行比较,分别代表两个圆盘开始外部/内部接触的角。
下方的演示在上述示例的基础上实现了 sunVisibility 函数,并且还为系统添加了一个月亮网格。试着将月亮与太阳对齐,并注意我们的大气散射着色器如何正确处理这些情况下光线的缺失。
外星大气环境
这是另一个附加部分!今天是你的幸运日!我们在本文中一直使用的模拟大气密度和散射的模型主要由少数几个常数控制:
- 行星和大气层的半径
- RayleighScaleHeight 和 RayleighBeta。
- MieScaleHeight, MieBeta, mieBetaExt, 和 mieG
- OzoneHeight 和 OzoneWidth
这些是我们调整的主要旋钮,决定了渲染出的大气层外观。因此,通过将它们调整为正确的值集合,我们在理论上可以模拟火星的大气层,甚至是其他行星的大气层。以下是我为火星设置的参数组:
{ atmosphereRadius: 3500, rayleighScaleHeight: 11.1, rayleighBeta: new THREE.Vector3(0.019, 0.013, 0.0057), ozoneCenterHeight: 0.0, ozoneBetaAbs: new THREE.Vector3(0.0, 0.0, 0.0), planetSurfaceColor: '#8B4513', }
仅仅用这些值替换我们的常量,我们就获得了更具尘土感、橙色的大气层。更好的是,在日落时分我们得到了火星标志性的蓝色色调!以下是我在工作时拍摄的一些截图。你可以尝试将这些值输入到之前的演示中,以亲自查看结果。
基于 LUT 的大气散射
我们构建的着色器虽然直观且能够渲染小尺度和大尺度范围的大气层,但运行起来相当昂贵:
- 我们的光线步进循环中有大量的
。PRIMARY_STEPS - 我们有用于光步进的嵌套循环。
- 我们在全屏分辨率下执行所有数学计算。
除了解决这些缺点外,当我到达大气散射探索的这个阶段时,我还想了解专业人士是如何做的。Sebastian Hillaire 在他的论文《可扩展且生产就绪的天空与大气渲染技术》(A Scalable and Production Ready Sky and Atmosphere Rendering Technique)中提出了一种基于查找表(LUTs)的渲染大气方法,即可以存储昂贵散射计算的纹理,以便最终渲染样本对这些预计算纹理进行采样和合成。 在这一部分,我们将探讨以下各自实现:
- 透射率 LUT(Transmittance LUT):存储光线穿过大气层时的幸存光量。
- 天空视角 LUT(Sky-view LUT):存储给定相机位置的天空颜色。
- 空中透视 LUT(Aerial Perspective LUT):存储相机和可见场景几何体之间的氛围薄雾/雾效,包括由散射添加的光量及其对场景颜色的影响。
透射率 LUT
在我们的原始着色器中,每个采样点都会调用
lightmarch 函数以获取到达该点的来自太阳的光量,正如你所料,这是相当昂贵的。这个 LUT 的目标是预先存储这些数据,最好在低分辨率下进行,以便我们在需要这些光照数据时将其加载到后续的 LUT 中。
我对这个 LUT 的实现以及后续所有实现均包括:
- 定义一个特定分辨率的专用帧缓冲区对象(FBO)。对于这一特定的一个,我选择了 250 x 64。
- 定义一种带有自定义着色器的材质,该材质将包含生成我们 LUT 数据的逻辑。
- 将其应用于专用场景中的全屏四边形,在本例中为
。transmittanceLUTScene - 渲染场景,并将结果纹理作为 uniform 传递给下游的 LUT。
这可能看起来有点复杂,但正如前面所说,理想情况下你应该使用 WebGPU 和计算着色器来完成这项工作,因此就不需要那些 FBO 了。 对于透射率,我们将昂贵的
lightmarch 循环提取到单独的 pass 中,并将其放在 transmittanceLUTFragmentShader 中。以下代码是用于生成我的纹理的代码:
float mu = mix(-1.0, 1.0, vUv.x); float radius = mix(planetRadius, atmosphereRadius, vUv.y); vec3 rayOrigin = vec3(0.0, radius, 0.0); float sinTheta = sqrt(max(1.0 - mu * mu, 0.0)); vec3 rayDir = normalize(vec3(sinTheta, mu, 0.0)); vec2 atmosphereHit = raySphereIntersect(rayOrigin, rayDir, planetCenter, atmosphereRadius); vec2 planetHit = raySphereIntersect(rayOrigin, rayDir, planetCenter, planetRadius); float rayLength = atmosphereHit.y; if (rayLength <= 0.0) { gl_FragColor = vec4(1.0); // No atmosphere along this path } else if (planetHit.x > 0.0) { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); // Blocked by planet } else { float stepSize = rayLength / float(TRANSMITTANCE_STEPS); float rayleighOD = 0.0; float mieOD = 0.0; float ozoneOD = 0.0; for (int i = 0; i < TRANSMITTANCE_STEPS; i++) { float t = (float(i) + 0.5) * stepSize; vec3 samplePoint = rayOrigin + rayDir * t; rayleighOD += rayleighDensity(samplePoint) * stepSize; mieOD += mieDensity(samplePoint) * stepSize; ozoneOD += ozoneDensity(samplePoint) * stepSize; } float tau = rayleighBeta * rayleighOD + mieBetaExt * mieOD + ozoneBetaAbs * ozoneOD; gl_FragColor = vec4(exp(-tau), 1.0); }
对于每个像素,我们从
vec3(0.0, radius, 0.0) 开始进行光线步进,该点沿 vUv.y 坐标在行星半径和大气层半径之间增长。方向 rayDir 定义了 LUT 中任意给定像素的光线方向,它在 mu = -1(即朝向行星表面的向下方向,rayDir = vec3(0.0, -1.0, 0.0))和 mu = 1(即朝向太空的向上方向,rayDir = vec3(0.0, 1.0, 0.0))之间变化。当 mu = 0 时,rayDir = vec3(1.0, 0.0, 0.0),意味着光线水平传播,掠过大气层。
我们使用了之前引入的相同的 raySphereIntersect 和大气散射函数。
这产生了以下透射率 LUT 纹理:[Uniforms OzonePath Extinction Debug]。以下是如何解读此纹理:
- X轴代表光线的角度。左侧是直视地面的光线,因此呈现深色;右侧则代表直视天空的光线。
- Y轴代表高度。图像的底部是地面/海平面,而顶部是我们大气的边缘。
- 纯白色代表 100% 的透射率,意味着光线有清晰的路径。
- 黑色/彩色区域代表地面或空气最厚的部分,特别是在接近地面的地方,这里的光线有一部分被耗尽(extinct)。
后续的 LUT 现在可以仅通过在此纹理中查找该值,就能非常快速地回答“在给定角度和高度下,有多少光线穿过我们的大气层幸存下来”的问题。
天空视角与空中透视 LUT
这两个 LUT 利用我们在各自纹理中刚刚计算的透射率数据,并回答两个互补的问题:
- 如果我从地面看向特定方向,天空是什么颜色?(天空颜色)
- 在我当前位置和场景中任何物体之间有多少大气层?(氛围薄雾)
结合这两个 LUT 将给我们完整的大气散射效果。前者处理远场颜色,而后者计算近场薄雾。使用涉及 FBO 和离屏场景的类似过程,我们可以定义单独的着色器来生成这两个 LUT。
天空视角 LUT (Sky View LUT) 对于天空视图纹理,我最终使用了以下代码:
// 天空视角 LUT 节选 vec3 getSkyViewForward(vec3 up) { vec3 projectedSun = sunDirection - up * dot(sunDirection, up); return normalize(projectedSun); } vec3 getSkyViewRayDir(vec2 uv, vec3 up) { vec3 forward = getSkyViewForward(up); vec3 right = normalize(cross(forward, up)); float azimuth = (uv.x * 2.0 - 1.0) * PI; float elevation = (uv.y * uv.y - 0.5) * PI; float cosElevation = cos(elevation); vec3 horizontal = cos(azimuth) * forward + sin(azimuth) * right; return normalize(horizontal * cosElevation + up * sin(elevation)); } vec3 rayOrigin = uCameraPosition; vec3 up = normalize(rayOrigin); vec3 rayDir = getSkyViewRayDir(vUv, up); vec3 planetCenter = vec3(0.0); vec2 atmosphereHit = raySphereIntersect(rayOrigin, rayDir, planetCenter, atmosphereRadius); vec2 planetHit = raySphereIntersect(rayOrigin, rayDir, planetCenter, planetRadius); if (atmosphereHit.y <= 0.0) { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); // Not hitting atmosphere top } else { float atmosphereNear = max(atmosphereHit.x, 0.0); float atmosphereFar = atmosphereHit.y; if (planetHit.x > atmosphereNear) { atmosphereFar = min(atmosphereFar, planetHit.x); } float atmosphereSegmentLength = atmosphereFar - atmosphereNear; float stepSize = atmosphereSegmentLength / float(SKY_VIEW_STEPS); // 累积散射逻辑... gl_FragColor = vec4(scatteredLight, 1.0); }
这里需要强调的主要点是
getSkyViewRayDir,它定义了我们的光线步进射线方向。在这种情况下:
- X轴,
映射到方位角(azimuth),即从 [-PI, PI] 的左右方向。vUv.x - Y轴,
映射到仰角(elevation),采用二次映射vUv.y
,即我们的垂直天空角度,范围从 [-PI/2, PI/2]。 最后,我们将这两个角度转换为 3D(vUv.y * vUv.y - 0.5) * PI
:rayDir
指向天空,up
沿地平线指向太阳,而forward
让我们可以向左和向右扫描天空。right
有了这个
rayDir 的定义,这里的射线步进循环产生了一个代表整个天空穹顶各个方向天空颜色的纹理。
空中透视 LUT (Aerial Perspective LUT) 说到空中透视,如前所述,我在伊莱尔的论文上略有偏离。我的结果是每个像素对应于一个可见屏幕像素的 2D 纹理。我依赖于场景的深度缓冲区来告诉我们要沿射线行进多远并累积散射。 结果,这使我可以重新使用第一部分中引入的大部分相同散射代码,只是现在每个采样点从透射率 LUT 中提取阳光可见性。输出存储了 RGB 中的累积大气散射以及 Alpha 通道中的打包视角透射率值,我们将在后续合成中使用它。
// 空中透视 LUT 节选 float depth = texture2D(depthBuffer, vUv).x; vec3 rayOrigin = uCameraPosition; vec3 worldPosition = getWorldPosition(vUv, depth); vec3 rayDir = normalize(worldPosition - rayOrigin); float sceneDepth = logDepthToRayDistance(vUv, depth); vec2 atmosphereHit = raySphereIntersect(rayOrigin, rayDir, vec3(0.0), atmosphereRadius); vec2 planetHit = raySphereIntersect(rayOrigin, rayDir, vec3(0.0), planetRadius); if (atmosphereHit.y <= 0.0) { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); // No atmosphere overhead } else { float atmosphereNear = max(atmosphereHit.x, 0.0); float atmosphereFar = atmosphereHit.y; if (planetHit.x > 0.0) { atmosphereFar = min(atmosphereFar, planetHit.x); } if (sceneDepth < planetHit.x - 2.0) { atmosphereFar = min(atmosphereFar, sceneDepth); } else { atmosphereFar = min(atmosphereFar, sceneDepth); } float segmentLength = atmosphereFar - atmosphereNear; float stepSize = segmentLength / float(AERIAL_PERSPECTIVE_STEPS); // 带有透射率 LUT 查找的射线步进循环 for (int i = 0; i < AERIAL_PERSPECTIVE_STEPS; i++) { float t = atmosphereNear + (float(i) + 0.5) * stepSize; vec3 samplePoint = rayOrigin + rayDir * t; vec3 sunTransmittance = sampleTransmittanceLUT(samplePoint, sunDirection); // 累积散射... } gl_FragColor = vec4(scatteredLight, packedTransmittance); }
合成
生成了天空视角和空中透视 LUT 后,我们只剩下最后一步:在最终的后期处理 pass 中将它们结合起来以实现完整的大气散射结果。代码主要包括:
- 将当前
转换为天空视图 UV 坐标,这样对于天空中的任何方向,我们都知道在哪里采样预计算的 Sky-view LUT。rayDir
vec2 getSkyViewLUTUv(vec3 rayDir, vec3 planetCenter) { vec3 up = normalize(uCameraPosition - planetCenter); vec3 forward = getSkyViewForward(up); vec3 right = normalize(cross(forward, up)); float vertical = clamp(dot(rayDir, up), -1.0, 1.0); vec3 horizontal = rayDir - up * vertical; float azimuth = atan(dot(horizontal, right), dot(horizontal, forward)); float elevation = asin(vertical); float elevation01 = clamp(elevation / PI + 0.5, 0.0, 1.0); float azimuth01 = clamp(azimuth / (2.0 * PI) + 0.5, 0.0, 1.0); return vec2(azimuth01, elevation01); } vec3 sampleSkyViewLUT(vec3 rayDir, vec3 planetCenter) { vec2 uv = getSkyViewLUTUv(rayDir, planetCenter); return texture2D(skyViewLUT, uv).rgb; }
- 从深度缓冲区重建视线射线,并检查该射线是否击中行星。
- 将空中透视 LUT 应用于场景几何体,使用其 Alpha 通道作为视角透射率,RGB 通道作为散射光。
- 为背景像素采样 Sky View LUT。
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { float depth = readDepth(depthBuffer, uv); vec3 rayOrigin = uCameraPosition; vec3 rayDir = normalize(getWorldPosition(uv, depth) - rayOrigin); vec3 planetCenter = vec3(0.0); vec2 planetHit = raySphereIntersect(rayOrigin, rayDir, planetCenter, planetRadius); vec3 color = inputColor.rgb; bool isBackground = depth >= 1.0 - 1e-7; if (aerialPerspectiveEnabled && !isBackground) { vec4 aerialPerspective = sampleAerialPerspectiveLUT(uv); color = color * aerialPerspective.a + aerialPerspective.rgb; } if (skyViewEnabled && isBackground) { color = inputColor.rgb + sampleSkyViewLUT(rayDir, planetCenter); } color = ACESFilm(color); color = pow(color, vec3(1.0 / 2.2)); outputColor = vec4(color, 1.0); }
下方的游戏场包含了我们基于 LUT 的大气的完整实现:所有的 LUT 及其对应的着色器,以及最终的后期处理 pass。它有点密集,所以我建议直接检查此 GitHub 链接中的实现,你将在那里找到渲染下方场景的代码。
最后的话
这个版本的大气散射看起来与我们在本文早期部分工作的几乎完全相同,但其底层过程是不同的:我们将工作拆分为更小的 LUT,然后在最终效果中进行合成。最重要的是,我们不再需要反复进行光步进以确定到达每个采样的光线量,而是可以直接从透射率 LUT 中获取这些光照信息,用简单的纹理查找替换了昂贵的嵌套循环,从而为最终场景带来了不可忽视的性能提升。
尽管如此,我的基于 LUT 的实现与 Sébastian Hillaire 以及该领域其他专业人士提出的成果相比相形见绌:
- 出现了一些带状伪影(banding)和闪烁现象,特别是在天空视图中。
- 我采取的捷径使过程不如它本应那样优化。
- 我应该在一开始就使用 WebGPU。
如果你想要查看真正的生产级实现,我强烈推荐查看 Shoda Matsuda (@shotamatsuda) 的
three-geospatial。他在天空、云层和地理空间渲染方面的工作一直是我的重要参考,他分享的社交媒体图片也足以说明一切。
尽管如此,我在整个项目中学到了很多,特别是在基于 LUT 的方法上,这让我在创建具有屏幕空间深度感知的后期处理效果时走出了舒适区。它还整合了一些先前的学习成果,并产生了一系列美丽的视觉效果(毕竟这是最重要的)。 我对这些实验的结果感到非常满意。我还致力于在此基础上添加体云层,但结果仍然有些好坏参半,在我为此感到自豪并将其展示在一篇文章之前还需要更多的投入。这要稍后再进行。届时,我希望能够利用这些工作来补充我脑海中正在慢慢塑造的 upcoming 项目与场景。
我曾多次尝试过这一点。Real-time Cloudscapes with Volumetric Raymarching 引入了此处使用的许多概念。这是一种变通方法,以避免在远距离观看天空视图时出现过多的闪烁。