Godot 4 中基于物理的大气散射天空渲染实现与优化 1. 项目概述从“贴图”到“物理”的天空渲染跃迁在游戏和实时渲染领域天空盒Skybox或天空穹顶Sky Dome是构建沉浸感世界的第一步。长久以来最主流、最快捷的方式就是使用一张360度的全景图HDR或LDR作为天空纹理。无论是Unity的Skybox Material还是Godot内置的PanoramaSkyMaterial本质上都是这种“贴图派”的做法。它的优点是性能开销极低效果立竿见影网上资源也丰富。但缺点同样明显天空是“死”的。光照方向、颜色、强度与天空完全脱节一天中的时间变化尤其是日出日落的瑰丽色彩很难通过几张静态贴图平滑、物理地模拟出来。这正是“基于Rayleigh和Mie散射的全景天空实现”这个项目的核心价值所在。它不是一个简单的资源导入教程而是一套在Godot引擎中从零构建一个动态、物理基础、可编程的天空渲染系统的完整方案。我们不再依赖外部美术资源而是通过代码模拟大气层中光线与微小粒子相互作用的物理过程实时计算天空每个像素的颜色。最终你得到的不只是一个天空背景而是一个能与场景光照特别是DirectionalLight即太阳动态联动的、拥有正确体积光Godot中常称“God Rays”和大气透视效果的真实天空系统。这套方案特别适合以下场景风格化或写实风格的3D项目需要动态昼夜循环。飞行模拟、太空探索、开放世界游戏对天空真实度要求高。任何希望摆脱预制天空盒限制追求更高自由度和一致性的Godot开发者。学习计算机图形学中大气散射模型的实践入门。接下来我将彻底拆解这个系统的每一个环节从物理原理到Godot 4中的具体实现包含完整的Shader代码解析、参数调试心得以及性能优化技巧。2. 核心物理原理光线在大气中的旅程要实现物理天空必须理解两个核心散射模型瑞利散射Rayleigh Scattering和米氏散射Mie Scattering。你可以把它们理解为光线在大气中“撞车”后发生的两种不同故事。2.1 瑞利散射蓝天与夕阳红的缔造者瑞利散射描述了光线与远小于其波长的粒子主要是空气分子如氮气、氧气之间的相互作用。其散射强度与波长的四次方成反比I ∝ 1/λ⁴。这意味着什么短波蓝光、紫光更容易被散射白天太阳光穿过大气层时蓝光在各个方向被强烈散射充斥整个天空所以我们看到天是蓝色的。而直视太阳时因为蓝光被散射掉了剩余的光线偏红黄所以太阳本身看起来是白色的其实是多种光的混合。长波红光、黄光穿透力更强清晨或黄昏太阳光需要穿过更厚的大气层才能到达我们眼睛。在这个过程中蓝光几乎被散射殆尽只剩下穿透力强的红光、橙光因此我们看到了绚丽的朝霞和晚霞。在Shader中我们通常用一个简化公式来模拟瑞利散射系数// 简化版本β_R 是海平面处的瑞利散射系数 vec3 beta_R vec3(5.8e-6, 1.35e-5, 3.31e-5); // 对应RGB通道B值最大这个vec3就体现了波长依赖性蓝色通道的值最大意味着蓝光散射最强。2.2 米氏散射朦胧感与光晕的来源米氏散射发生在光线与大小接近或大于其波长的粒子如 aerosols尘埃、水蒸气、烟雾、污染物碰撞时。它的散射强度对波长依赖较小各色光散射强度差不多因此散射光倾向于呈现白色或光源本身的颜色。它的主要视觉贡献是太阳周围的光晕Glory直视太阳时那些朦胧的、发白的光圈。大气浑浊感Haze在雾霾天或湿度大的日子里远处景物对比度下降仿佛蒙上一层灰白色的纱。体积光/上帝之光God Rays光线穿过云隙或遮挡物时形成的可见光束其边缘的朦胧感也源于米氏散射。在Shader中米氏散射系数通常用一个与波长弱相关的值来表示有时甚至简化为一个标量float或一个均匀的vec3。// 常用近似值各通道差异很小 vec3 beta_M vec3(2e-5); // 一个均匀的值2.3 整合模型视线光路的积分计算我们最终在屏幕上看到的某个天空像素的颜色是沿着从摄像机眼睛到该像素方向即视线方向上所有散射光累积的结果。这个过程需要解一个积分方程称为“视线积分”。简单来说我们沿着视线方向采样大气中的无数个点计算每个点处入射光衰减从太阳到该采样点的光经过大气被吸收和散射了多少主要是瑞利和米氏散射出去。出射光衰减从该采样点到摄像机的光又衰减了多少。散射相位函数描述光线在该点向摄像机方向散射的概率分布。瑞利散射是各向同性的前后对称而米氏散射具有强烈的向前散射特性光线更倾向于继续向前走。在实时渲染中我们无法进行无限采样通常采用单次散射Single Scattering近似并配合一些预计算或简化积分的方法如使用指数函数近似。Godot的Shader语言GLSL ES 3.0足以实现一个效果出色、性能可接受的单次散射模型。注意这里我们讨论的是“天空本身”的着色。当视线击中地形或物体时还需要考虑“大气透视”Aerial Perspective即物体颜色会因介于其间的大气散射而偏向天空色并变模糊。这通常是另一个后处理或物体着色步骤。3. Godot 4 实现全解析从Shader到资源理解了原理我们开始在Godot 4中动手构建。核心是一个自定义的ShaderMaterial它将被应用到一个覆盖整个视口的Sky资源上。3.1 项目结构与资源创建创建Sky资源在Godot编辑器中创建一个Sky资源。将其Sky Material属性设置为新建的ShaderMaterial。编写Shader在ShaderMaterial中新建一个Shader并选择语言为GLSLGodot 4默认且功能最全的Shader语言。我们将把主要代码写在这里。关联DirectionalLight太阳我们需要在Shader中获取太阳的世界空间方向。这通过一个uniform变量传入可以在脚本中动态绑定或者通过编辑器设置。3.2 顶点与片段着色器分工一个典型的天空Shader结构如下顶点着色器vertex极其简单。通常直接将屏幕空间顶点位置VERTEX赋值给POSITION并传递一个代表视线方向从摄像机指向天空像素的向量到片段着色器。这个向量可以通过摄像机逆投影矩阵计算得到。片段着色器fragment所有魔法发生的地方。在这里我们将实现大气散射计算为每个像素输出最终颜色COLOR。3.3 核心Shader代码实现与逐行解读以下是片段着色器的核心代码框架和注释。我们假设太阳方向sun_dir已归一化且从场景中指向光源即光的方向是从太阳指向地面但计算时我们常用从地面指向太阳的方向注意符号。// 定义大气参数 uniform 变量方便在编辑器中调节 uniform vec3 beta_r : source_color vec3(5.8e-6, 1.35e-5, 3.31e-5); // 瑞利散射系数 uniform float h_r : hint_range(0, 200000) 7994.0; // 瑞利散射衰减高度米 uniform vec3 beta_m : source_color vec3(2e-5); // 米氏散射系数 uniform float h_m : hint_range(0, 50000) 1200.0; // 米氏散射衰减高度米 uniform float sun_intensity : hint_range(0, 100) 22.0; // 太阳光强度 uniform vec3 sun_dir : hint_normalized; // 太阳方向从地面指向太阳 uniform float planet_radius : hint_range(1e6, 1e7) 6371e3; // 行星半径地球约6371km uniform float atmosphere_radius : hint_range(1e6, 1e7) 6471e3; // 大气层顶半径假设大气厚100km // 从顶点着色器传来的视线方向从摄像机指向片段 varying vec3 view_ray; void fragment() { vec3 ray_dir normalize(view_ray); // 归一化视线方向 vec3 ray_origin vec3(0.0, planet_radius 10.0, 0.0); // 摄像机位置假设在地面上方10米 // 1. 计算视线与大气层的交点进点和出点 float t0, t1; if (!ray_sphere_intersect(ray_origin, ray_dir, atmosphere_radius, t0, t1)) { // 视线没有穿过大气层直接返回黑色太空 COLOR vec4(0.0, 0.0, 0.0, 1.0); return; } // 如果视线与行星相交看向地面则 t1 应设为交点处的值这里简化为不考虑地面 // 实际完整实现需要先与行星求交取 min(t1, 与行星的交点t) // 2. 沿视线进行数值积分这里采用简单的步进采样 const int num_steps 16; // 采样步数影响质量和性能 float step_length (t1 - t0) / float(num_steps); vec3 total_r vec3(0.0); // 累积瑞利散射光 vec3 total_m vec3(0.0); // 累积米氏散射光 float optical_depth_r 0.0; float optical_depth_m 0.0; for (int i 0; i num_steps; i) { float t t0 (float(i) 0.5) * step_length; // 当前采样点位置步进中点 vec3 sample_pos ray_origin ray_dir * t; // 计算当前采样点的高度 float height length(sample_pos) - planet_radius; // 计算当前点的局部密度指数衰减模型 float density_r exp(-height / h_r); float density_m exp(-height / h_m); // 累积光学深度光线从采样点到摄像机/太阳所经过的“阻碍” optical_depth_r density_r * step_length; optical_depth_m density_m * step_length; // 3. 计算从太阳到当前采样点的光路衰减 // 需要另一段射线求交和积分这里为性能常采用简化 // a) 假设太阳光路的光学深度与高度有近似关系LUT预计算 // b) 或进行第二次步进积分性能代价高 // 此处使用一个高度相关的近似函数作为示例 float sun_optical_depth_r density_r * h_r; // 极度简化仅用于示意 float sun_optical_depth_m density_m * h_m; // 计算衰减项比尔-朗伯定律 vec3 attenuation exp(-(beta_r * (optical_depth_r sun_optical_depth_r) beta_m * (optical_depth_m sun_optical_depth_m))); // 4. 累加散射光 // 散射量 散射系数 * 局部密度 * 太阳光衰减 * 相位函数 * 步长 float cos_theta dot(ray_dir, sun_dir); float phase_r rayleigh_phase(cos_theta); float phase_m mie_phase(cos_theta, 0.76); // 0.76是米氏散射不对称因子 total_r attenuation * density_r * phase_r * step_length; total_m attenuation * density_m * phase_m * step_length; } // 5. 合并散射光并乘以太阳光强度 vec3 sky_color sun_intensity * (beta_r * total_r beta_m * total_m); // 6. 色调映射Tone Mapping将HDR值映射到LDR显示范围 sky_color vec3(1.0) - exp(-sky_color * 1.0); // 简单的曝光控制 COLOR vec4(sky_color, 1.0); } // 辅助函数射线与球体求交 bool ray_sphere_intersect(vec3 origin, vec3 dir, float radius, out float t0, out float t1) { // ... 标准二次方程求交代码 ... float a dot(dir, dir); float b 2.0 * dot(origin, dir); float c dot(origin, origin) - radius * radius; float discriminant b * b - 4.0 * a * c; if (discriminant 0.0) return false; t0 (-b - sqrt(discriminant)) / (2.0 * a); t1 (-b sqrt(discriminant)) / (2.0 * a); if (t0 t1) { float temp t0; t0 t1; t1 temp; } return true; } // 瑞利散射相位函数各向同性简化版实际是对称的 float rayleigh_phase(float cos_theta) { return (3.0 / (16.0 * PI)) * (1.0 cos_theta * cos_theta); } // 米氏散射相位函数Henyey-Greenstein近似g为不对称因子-1向后0各向同性1向前 float mie_phase(float cos_theta, float g) { float g2 g * g; return (1.0 - g2) / (4.0 * PI * pow(1.0 g2 - 2.0 * g * cos_theta, 1.5)); }这段代码勾勒出了单次散射积分的骨架。其中最耗性能的部分是内循环中“计算从太阳到当前采样点的光路衰减”。上面代码用了极度简化的模型在实际追求质量的项目中这里需要另一个积分循环num_light_steps或者使用预计算查找表LUT来加速。3.4 在Godot编辑器中的调试与参数调节将上述Shader代码需补充完整应用到ShaderMaterial后你会在材质 Inspector 中看到我们定义的uniform变量。调节它们就像调节旋钮能实时看到天空变化beta_r/beta_m散射系数。直接控制蓝天beta_r的B通道和雾霾beta_m的强度。调大beta_m天空会迅速变白、变朦胧。h_r/h_m衰减高度。控制散射密度随高度下降的速度。h_r~8km远大于h_m~1.2km这符合现实空气分子分布更均匀高度高而气溶胶主要集中在低空。sun_intensity太阳强度。相当于全局亮度调节。注意它需要与场景中DirectionalLight的强度匹配否则会出现天空很亮但场景很暗的割裂感。sun_dir太阳方向。这是实现动态昼夜循环的关键。你需要写一个简单的脚本根据游戏内时间例如将一天映射为0-360度计算太阳的方向向量并每帧设置到这个uniform中。# 附着在包含Sky的WorldEnvironment节点或某个全局控制器上 extends Node3D export var time_of_day: float 0.0 # 0到1代表一天 export var sky_material: ShaderMaterial func _process(delta): time_of_day delta / 60.0 # 假设60秒游戏内为一天 time_of_day fmod(time_of_day, 1.0) var sun_altitude sin(time_of_day * PI * 2.0) * 0.5 0.5 # 将时间映射为太阳高度角正弦变化 var sun_azimuth time_of_day * 360.0 # 方位角 # 将球面坐标转换为方向向量 (Y-up坐标系) var dir Vector3() dir.x cos(deg_to_rad(sun_azimuth)) * cos(deg_to_rad(sun_altitude * 90.0)) dir.y sin(deg_to_rad(sun_altitude * 90.0)) dir.z sin(deg_to_rad(sun_azimuth)) * cos(deg_to_rad(sun_altitude * 90.0)) dir dir.normalized() sky_material.set_shader_parameter(sun_dir, dir) # 同时更新场景中的DirectionalLight方向 $DirectionalLight3D.look_at_from_position(dir * -1000, Vector3.ZERO)planet_radius/atmosphere_radius星球尺寸。保持地球比例即可。改变它们会影响大气层的“厚度感”。实操心得调试时先从正午sun_dir (0, 1, 0)开始将sun_intensity调到10左右确保能看到蓝色天空。然后慢慢旋转太阳到地平线附近观察瑞利散射如何将天空染成红色/橙色。beta_m是制造“氛围感”的关键稍微增加一点比如到4e-5就能让日落时的天际线更加柔和、有层次。4. 性能优化与进阶技巧上述基础实现在移动端或复杂场景中可能成为性能瓶颈。以下是几个关键的优化方向4.1 优化策略从蛮力计算到智能近似预计算光学深度Precomputed Atmospheric Scattering核心思想将最耗时的双重积分视线积分 太阳光路积分的结果预计算成一张或多张查找表LUT。常见的LUT维度是视图高度视图仰角太阳仰角光学深度。Godot实现可以编写一个离线工具用GDScript或C#生成纹理然后在Shader中采样这张纹理来获取光学深度完全避免实时积分循环。这是工业级方案如《孤岛危机》、《地平线》系列的常用手段。折中方案预计算一个简化的一维或二维LUT例如只预计算太阳光路衰减随高度的变化视线积分仍实时计算。降低采样步数num_steps这是最直接的优化。将num_steps从32降到16甚至8性能提升立竿见影但可能会带来明显的色带Banding问题。解决方案结合抖动采样Jittered Sampling。在步进时为每个像素添加一个基于噪声纹理或屏幕坐标的微小随机偏移可以有效将固定步数产生的色带转化为细腻的噪声而噪声在视觉上比色带更容易接受。// 使用一个简单的屏幕空间噪声 float random fract(sin(dot(FRAGCOORD.xy, vec2(12.9898, 78.233))) * 43758.5453); float t t0 (float(i) random) * step_length; // 抖动采样位置利用Godot的渲染特性天空着色器是每帧全屏执行的吗在Godot中Sky资源通常被渲染到一个立方体贴图Cubemap或一个全景纹理上然后作为环境背景。这个渲染过程不是每像素每帧都进行。Godot会智能地缓存天空状态只有当天空材质参数发生变化如sun_dir改变或摄像机旋转到新角度时才触发更新。这意味着在静态天空或变化缓慢的昼夜循环中开销是可控的。降低天空纹理分辨率在Project Settings - Rendering - Environment中可以降低Sky的Radiance Size。这直接降低了Shader执行的像素数量对性能影响巨大但会损失天空细节特别是云层如果后续添加了。4.2 效果增强超越基础天空添加太阳圆盘Sun Disk目前的散射模型在太阳方向附近亮度很高但缺乏一个清晰的、过曝的太阳轮廓。我们可以单独渲染一个。在片段着色器中计算当前像素方向与sun_dir的夹角。如果夹角小于一个阈值如0.5度则直接输出一个高亮、带有光晕衰减的白色或淡黄色圆盘。float sun_angular_radius 0.0093; // 约0.53度 float cos_sun_radius cos(sun_angular_radius); float cos_theta dot(ray_dir, sun_dir); if (cos_theta cos_sun_radius) { // 绘制太阳圆盘 float sun_disk smoothstep(cos_sun_radius, cos_sun_radius 0.001, cos_theta); sky_color mix(sky_color, vec3(1.0, 0.95, 0.9) * 50.0, sun_disk); }集成云层物理天空是基础加上云才完整。云可以通过多种方式实现体积云Volumetric Clouds使用3D噪声如Worley, Perlin在Shader中进行光线步进效果最好性能开销也最大。适合PC/主机高端项目。平面云层Cloud Plane使用带有透明通道的、平铺的云层纹理在天空穹顶的特定高度渲染一个或多个平面。性能好但体积感和动态性差。天空盒云纹理叠加将计算出的物理天空颜色与一张高动态范围HDR的云层全景图进行混合。这是性能与效果的良好折中Godot的ProceduralSkyMaterial就支持添加云噪声。混合技巧在物理天空的COLOR输出前采样一张云噪声图根据噪声值决定混合比例。可以令云层部分“吸收”和“散射”背后的天空光使其看起来更融合。大气透视Aerial Perspective让远处物体融入天空。这通常在物体的着色器或后处理中完成。原理计算从摄像机到物体表面点的视线穿过的大气光学深度然后使用与天空着色相同的散射系数和相位函数计算该段大气带来的附加颜色和透射率。简化实现在物体的顶点或片段着色器中根据顶点/片段的世界坐标高度和到摄像机的距离计算一个雾效系数。但这个雾的颜色不是固定的灰色而是动态采样当前天空色在视线方向上的颜色这需要将天空着色器函数封装成可调用的。Godot 4的Fog节点已经支持与Sky颜色联动是一个很好的起点。5. 常见问题与调试实录在实现过程中你几乎一定会遇到下面这些问题。这里是我的排查笔记问题1天空全黑或颜色异常。检查清单Uniform变量绑定确保sun_dir等uniform在脚本中正确设置。在Shader顶部添加// COLOR vec4(sun_dir, 1.0);临时输出太阳方向检查是否为非零向量。球体求交确保ray_sphere_intersect函数正确且atmosphere_radius大于planet_radius。可以临时将COLOR设为vec4(t0/1000.0, t1/1000.0, 0.0, 1.0)可视化交点距离。参数尺度散射系数beta是非常小的数e-6量级太阳强度sun_intensity需要是一个较大的数如20才能平衡。如果颜色过暗先尝试将sun_intensity调到100看看。坐标系确保所有方向向量ray_dir,sun_dir在世界坐标系下且是归一化的。Godot的Sky着色器使用的坐标系可能与场景坐标系不同需要进行转换。问题2太阳附近有尖锐的亮斑或环形瑕疵。原因这通常是米氏散射相位函数在cos_theta接近1即视线与太阳方向几乎平行时分母接近零导致的数值爆炸。解决在mie_phase函数中对分母项进行钳制clamp或添加一个极小值epsilon。float denom 1.0 g2 - 2.0 * g * cos_theta; denom max(denom, 1e-4); // 防止除零或接近零导致数值过大 return (1.0 - g2) / (4.0 * PI * pow(denom, 1.5));问题3地平线处颜色突变或出现硬边。原因采样步数num_steps不足尤其是在视线方向近乎水平、穿过大气路径最长时积分精度不够。解决增加num_steps牺牲性能。采用自适应步进Adaptive Step根据当前采样点的高度动态调整步长。在低空密度大用小步长在高空密度小用大步长。更实用的方法引入指数高度雾Exponential Height Fog作为地平线处的过渡。Godot内置的Fog节点可以很好地掩盖地平线附近的不自然区域。问题4性能开销大移动端帧率下降明显。诊断在Godot编辑器的“调试器”面板中查看“GPU时间”或使用性能分析器确认瓶颈是否在天空着色器。优化步骤降低采样数将num_steps降至8结合抖动采样。简化光照计算将内循环中的太阳光路积分替换为预计算的简化公式或LUT采样。降低天空纹理分辨率如前所述在项目设置中调低Radiance Size。考虑回退方案在移动端低质量设置下可以动态切换回传统的PanoramaSkyMaterial或使用一个预烘焙的、包含多种时间段的天空立方体贴图序列。问题5如何与Godot的全局光照GI系统配合Godot的GI如VoxelGI, SDFGI和反射探针依赖于Sky资源提供的环境光Radiance和反射Reflection信息。动态天空当你的物理天空Shader输出颜色后Godot会自动将其用于环境漫反射光照和镜面反射。确保你的Sky资源被正确赋值给WorldEnvironment的Environment属性。性能考量动态天空意味着环境光照也在动态变化这会触发GI系统的更新尤其是SDFGI的更新传播带来额外的性能消耗。对于需要精确GI的静态场景可以考虑在光照烘焙Lightmap Baking时使用一个静态的天空状态。实现一个基于物理的大气散射天空是一个从“知其然”到“知其所以然”的深度学习过程。它不仅仅是给游戏加一个背景更是对光线、颜色、空间理解的一次升级。在Godot中完成这套系统后你对引擎的Shader管道、Uniform传递、性能 profiling 都会有更深刻的把握。最重要的是你获得了一个完全受程序控制、无限变化的动态天空它为你的虚拟世界注入了最基础也最灵动的一抹生命——时间。