
1. 项目概述为什么我们需要一个“会呼吸”的天空盒在Unity里做项目尤其是开放世界、飞行模拟或者任何需要长时间观察天空的场景一个静态的天空盒Skybox很快就会让人感到乏味。太阳永远挂在同一个位置天空的颜色一成不变云朵也像被钉在了画布上——这显然不是我们生活的真实世界。所以“动态天空盒”这个概念就变得至关重要。它不仅仅是为了好看更是为了营造沉浸感让虚拟世界的时间和天气系统“活”起来。简单来说一个基础的动态天空盒至少要能模拟从黎明、正午、黄昏到夜晚的天空颜色渐变。而更高级的实现则会引入“大气散射”Atmospheric Scattering这种物理现象。你可以把它想象成现实中的“丁达尔效应”当阳光穿过大气层时会被空气中的分子和尘埃散射短波长的蓝光散射得更厉害所以晴朗的天空是蓝色的而在日出日落时阳光需要穿过更厚的大气层蓝光被散射殆尽剩下的长波长红光和橙光就造就了绚丽的朝霞和晚霞。在Unity里模拟这个过程能让你的天空颜色变化不再是简单的插值而是有物理依据的、极其逼真的渐变。我接手过不少需要动态天空的项目从手机上的休闲游戏到PC端的大型模拟器踩过的坑数不胜数。很多教程只教你怎么用代码改颜色但没告诉你性能怎么优化也没说不同平台比如移动端和PC该怎么区别对待。这篇文章我就把我从最基础的Color.Lerp渐变到实现一个简化版大气散射模型的完整过程、核心思路和那些“教科书里不会写”的实操细节系统地梳理一遍。无论你是刚接触Shader的新手还是想优化现有天空系统的老手相信都能找到有用的东西。2. 核心思路与方案选型性能、效果与可控性的三角平衡做动态天空盒本质上是在实时地修改一个包裹整个场景的、巨大球体或立方体六个面的颜色和纹理。我们的目标很明确用可接受的性能开销实现足够逼真且可控的动态效果。市面上大概有三种主流思路各有优劣。2.1 纯Shader顶点/片元着色器方案这是最灵活、性能潜力也最高的方案。我们编写一个自定义的Skybox Shader在Shader内部根据时间、太阳高度角等参数实时计算每个像素的颜色。实现大气散射模型也必须走这条路。它的好处是运算在GPU上并行完成效率极高且效果可以做到非常精细。缺点是开发门槛高需要扎实的图形学知识和Shader编程能力调试也相对麻烦。如果你的项目对天空效果有极致要求比如《微软模拟飞行》那种级别或者目标是PC/主机平台这是不二之选。2.2 脚本动态修改材质属性方案这是最快速上手的方案。我们准备一个支持多个参数的Skybox材质可能是Procedural Skybox也可能是自定义的6面图材质然后通过C#脚本在Update或协程中根据游戏内时间使用Material.SetColor或Material.SetFloat来动态调整材质的颜色、曝光、太阳位置等属性。Unity内置的Procedural Skybox就非常适合用这种方式驱动。它的优点是简单、直观、易于与游戏逻辑如时间系统、天气系统集成。缺点是灵活性受限于材质本身的参数且每帧通过脚本设置材质属性会有一定的CPU开销如果参数很多可能会成为性能瓶颈。2.3 渲染到立方体贴图Render to Cubemap方案这是一种相对“重型”但效果独特的方案。我们创建一个空的Cubemap在运行时用一个位于场景中心的摄像机朝六个方向分别渲染一次将场景可能包含体积云、远处地形等捕获到这张Cubemap上然后将其设置为天空盒。这样可以实现天空盒与场景物体的动态交互比如云朵飘过山巅。但它的性能开销很大每帧或每隔几帧要渲染六次通常用于需要天空与场景高度融合的特定场合而非单纯的天空颜色变化。我的选择与理由对于大多数项目尤其是需要从基础过渡到高级效果的情况我推荐采用“方案一为主方案二为辅”的混合策略。即核心的天空颜色计算、大气散射模拟在自定义Shader中完成以保证最好的性能和效果而一些高层控制参数如“时间流速”、“天气状态切换”则通过C#脚本传递给Shader。这样既发挥了GPU的并行计算优势又保留了脚本控制的便利性。接下来我们就从最简单的脚本驱动渐变开始逐步深入到Shader实现的大气散射。3. 基础实现用脚本驱动天空颜色渐变我们先实现一个最直观的需求让天空的颜色随着游戏内时间比如一天24小时循环平滑地变化。假设我们定义了四个关键时间点的天空色午夜、黎明、正午、黄昏。3.1 搭建基础框架首先在Unity中创建一个空物体命名为“SkyboxController”并挂载一个C#脚本。我们需要定义一天的时间长度秒数和当前时间。using UnityEngine; public class BasicSkyboxGradient : MonoBehaviour { public Material skyboxMaterial; // 拖入你的天空盒材质 public float dayDurationInSeconds 120f; // 游戏内一天对应的真实秒数 [Range(0f, 1f)] public float currentTimeOfDay 0.3f; // 0:午夜, 0.25:黎明, 0.5:正午, 0.75:黄昏, 1:次日午夜 private int _sunDirectionId; private int _skyTintId; void Start() { // 提前获取Shader属性ID避免每次Update都进行字符串查找这是个小优化点 _sunDirectionId Shader.PropertyToID(_SunDirection); _skyTintId Shader.PropertyToID(_SkyTint); // 初始化天空盒 RenderSettings.skybox skyboxMaterial; } void Update() { // 更新时间这里用按键模拟实际项目中可能来自你的游戏时间管理器 if (Input.GetKey(KeyCode.Equals)) currentTimeOfDay Time.deltaTime / dayDurationInSeconds; if (Input.GetKey(KeyCode.Minus)) currentTimeOfDay - Time.deltaTime / dayDurationInSeconds; currentTimeOfDay Mathf.Repeat(currentTimeOfDay, 1f); // 循环 UpdateSkyboxParameters(); } void UpdateSkyboxParameters() { // 计算太阳方向一个简单的圆形轨道 float sunAngle currentTimeOfDay * 360f; Vector3 sunDirection Quaternion.Euler(sunAngle, 0, 0) * Vector3.forward; // 注意在天空盒Shader中方向通常是从表面指向光源所以这里可能需要取反取决于Shader实现 skyboxMaterial.SetVector(_sunDirectionId, -sunDirection.normalized); // 根据时间插值天空色调 Color targetSkyTint CalculateSkyTint(currentTimeOfDay); skyboxMaterial.SetColor(_skyTintId, targetSkyTint); } Color CalculateSkyTint(float t) { // 定义关键色 Color midnight new Color(0.05f, 0.05f, 0.1f); // 深蓝黑 Color dawn new Color(0.8f, 0.5f, 0.2f); // 橙红 Color noon new Color(0.5f, 0.7f, 1.0f); // 天蓝 Color dusk new Color(0.9f, 0.4f, 0.1f); // 红橙 // 分段线性插值为了简单演示实际可以用更平滑的曲线 if (t 0.25f) // 午夜 - 黎明 return Color.Lerp(midnight, dawn, t * 4f); else if (t 0.5f) // 黎明 - 正午 return Color.Lerp(dawn, noon, (t - 0.25f) * 4f); else if (t 0.75f) // 正午 - 黄昏 return Color.Lerp(noon, dusk, (t - 0.5f) * 4f); else // 黄昏 - 午夜 return Color.Lerp(dusk, midnight, (t - 0.75f) * 4f); } }这个脚本提供了最基础的控制逻辑。你可以通过按键调整currentTimeOfDay脚本会计算对应的太阳方向和天空色调并传递给材质。注意这里我们直接修改了skyboxMaterial的实例属性。在Unity中这会影响所有使用该材质的对象。如果你希望每个摄像机有不同的天空盒或者需要动态切换材质更安全的做法是使用MaterialPropertyBlock来覆盖每个渲染器的属性避免修改共享的材质资产。但对于全局的RenderSettings.skybox直接修改其材质是常规做法。3.2 创建支持渐变的简易天空盒Shader现在我们需要一个能接收这些参数的Shader。创建一个新的Unlit Shader将其类型改为Skybox/6 Sided或Skybox/Procedural的变体。这里我们从一个最简单的Skybox/Procedural风格Shader开始。Shader Custom/Skybox/BasicGradient { Properties { _SkyTint (Sky Tint, Color) (0.5, 0.7, 1.0, 1.0) _GroundColor (Ground Color, Color) (0.369, 0.349, 0.341, 1.0) _SunDirection (Sun Direction, Vector) (0, 1, 0, 0) // 默认太阳在头顶 _SunSize (Sun Size, Range(0,1)) 0.04 _SunSizeConvergence (Sun Size Convergence, Range(1,10)) 5 } SubShader { Tags { QueueBackground RenderTypeBackground PreviewTypeSkybox } Cull Off ZWrite Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; float3 vertexWorld : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); // 将顶点从物体空间转换到世界空间作为视线方向 o.vertexWorld mul((float3x3)unity_ObjectToWorld, v.vertex.xyz); return o; } fixed4 _SkyTint; fixed4 _GroundColor; float3 _SunDirection; float _SunSize; float _SunSizeConvergence; // 一个非常简单的基于视角和太阳方向的渐变 fixed4 frag (v2f i) : SV_Target { float3 viewDir normalize(i.vertexWorld); // 计算天空和地面的混合因子看向天空y0部分为天空色看向地面y0部分为地面色 float horizon smoothstep(-0.1, 0.1, viewDir.y); // 使用smoothstep让地平线过渡更自然 fixed3 color lerp(_GroundColor.rgb, _SkyTint.rgb, horizon); // 简单模拟太阳光晕一个基于角度的亮斑 float sunDot saturate(dot(viewDir, normalize(_SunDirection))); float sunDisk smoothstep(1.0 - _SunSize, 1.0 - _SunSize * 0.99, sunDot); color sunDisk * fixed3(1.0, 0.9, 0.7) * 5.0; // 给太阳盘一个强光 return fixed4(color, 1.0); } ENDCG } } Fallback Off }将这个Shader赋给一个材质并把材质拖到脚本的skyboxMaterial字段运行游戏你就能通过按键看到天空颜色和太阳位置的基础变化了。实操心得在脚本中更新材质属性时一个常见的性能陷阱是每帧都使用SetColor(“_PropertyName”, value)。字符串查找在每帧进行是低效的。最佳实践是在Start或Awake中使用Shader.PropertyToID获取属性名称对应的整数ID然后在Update中使用SetColor(propertyId, value)。这个细节对移动平台尤其重要。4. 进阶核心在Shader中实现简化的大气散射基础渐变看起来还行但缺乏物理真实感颜色过渡生硬。接下来我们进入核心环节在Shader中实现一个简化但视觉效果显著的大气散射模型。我们采用经典的“瑞利散射”Rayleigh Scattering和“米氏散射”Mie Scattering的近似模型。瑞利散射负责天空的蓝色和日落红色米氏散射负责太阳周围的光晕。4.1 大气散射原理简述想象一下你站在地球上望向天空。你的视线View Ray从眼睛出发穿过大气层指向太空。这条路径上的每一点都会受到太阳光的照射并发生散射。最终进入你眼睛的光是这条路径上所有点散射光的总和积分。实时计算这个积分是不可能的所以图形学中常用的是“单次散射”Single Scattering近似并假设大气是均匀的球壳。我们的简化模型将做以下计算计算视线的光学深度Optical Depth视线在大气中穿行的距离决定了有多少光被衰减。计算光线的光学深度从大气中的采样点到太阳的光线在大气中穿行的距离。结合散射系数瑞利散射系数与波长的四次方成反比所以蓝光散射强米氏散射系数相对均匀。相位函数Phase Function描述散射光在不同方向上的分布。瑞利散射前后对称米氏散射主要向前散射形成光晕。4.2 实现简化的大气散射Shader我们将创建一个新的、更复杂的Shader。为了清晰我会将关键计算封装成函数。Shader Custom/Skybox/AtmosphericScattering { Properties { // 大气参数 _RayleighCoeff (Rayleigh Scattering Coefficient, Vector) (5.8e-6, 13.5e-6, 33.1e-6, 0) // R, G, B 对应波长 _MieCoeff (Mie Scattering Coefficient, Float) 2.1e-5 _SunIntensity (Sun Intensity, Float) 22.0 _PlanetRadius (Planet Radius (km), Float) 6371.0 _AtmosphereHeight (Atmosphere Height (km), Float) 80.0 // 瑞利散射层高度 _MieHeight (Mie Scattering Height (km), Float) 1.2 // 米氏散射气溶胶层高度 // 太阳参数 _SunDirection (Sun Direction, Vector) (0, 1, 0, 0) _SunSize (Sun Size (angular radius), Float) 0.00465 // ~0.533度 // 色调调整 _Exposure (Exposure, Float) 1.5 } SubShader { Tags { QueueBackground RenderTypeBackground PreviewTypeSkybox } Cull Off ZWrite Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 3.0 #include UnityCG.cginc #include Lighting.cginc struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; float3 viewDir : TEXCOORD0; // 标准化后的世界空间视线方向 float3 worldPos : TEXCOORD1; // 相机在世界空间的位置近似为星球中心上方 }; // 属性变量声明 float3 _RayleighCoeff; float _MieCoeff; float _SunIntensity; float _PlanetRadius; float _AtmosphereHeight; float _MieHeight; float3 _SunDirection; float _SunSize; float _Exposure; // 常量 static const float PI 3.14159265359; static const float MAX 100000.0; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); // 将模型空间顶点方向转换到世界空间作为视线方向 // 对于天空盒相机在球心顶点方向就是世界空间方向 o.viewDir normalize(mul((float3x3)unity_ObjectToWorld, v.vertex.xyz)); // 简化假设相机始终在星球表面上方一定高度。更精确的做法是传入相机真实世界坐标。 o.worldPos float3(0, _PlanetRadius 0.001, 0); // 相机在北极点上方1米处 return o; } // 计算从点pos出发沿方向dir的射线与球体圆心0半径R的交点 // 返回是否相交以及近交点距离t0远交点距离t1 bool RaySphereIntersect(float3 pos, float3 dir, float R, out float t0, out float t1) { float3 oc pos; // 球心在原点 float a dot(dir, dir); float b 2.0 * dot(oc, dir); float c dot(oc, oc) - R * R; float discriminant b * b - 4.0 * a * c; if (discriminant 0.0) { t0 t1 MAX; return false; } float sqrtDisc sqrt(discriminant); t0 (-b - sqrtDisc) / (2.0 * a); t1 (-b sqrtDisc) / (2.0 * a); // 确保t0 t1 if (t0 t1) { float temp t0; t0 t1; t1 temp; } return true; } // 瑞利散射相位函数 float RayleighPhase(float cosTheta) { return (3.0 / (16.0 * PI)) * (1.0 cosTheta * cosTheta); } // 米氏散射相位函数Henyey-Greenstein近似g为不对称因子向前散射为正 float MiePhase(float cosTheta, float g) { float g2 g * g; return (1.0 / (4.0 * PI)) * ((1.0 - g2) / pow(1.0 g2 - 2.0 * g * cosTheta, 1.5)); } // 计算大气中一段路径的光学深度指数衰减近似 float OpticalDepth(float3 startPos, float3 dir, float t, float scaleHeight) { // 这是一个高度简化的模型假设密度随高度指数衰减 // 更精确的做法需要沿射线积分这里我们用起点和终点的平均高度来近似 float h0 length(startPos) - _PlanetRadius; float3 endPos startPos dir * t; float h1 length(endPos) - _PlanetRadius; float avgHeight (h0 h1) * 0.5; // 密度 exp(-高度 / 尺度高度) float density exp(-avgHeight / scaleHeight); return density * t; } fixed4 frag (v2f i) : SV_Target { float3 viewDir i.viewDir; float3 camPos i.worldPos; float3 planetCenter float3(0,0,0); // 1. 计算视线与大气层外球的交点 float atmosphereRadius _PlanetRadius _AtmosphereHeight; float t0, t1; bool hitAtmo RaySphereIntersect(camPos, viewDir, atmosphereRadius, t0, t1); if (!hitAtmo || t1 0.0) { // 视线没有穿过大气层直接返回黑色太空 return fixed4(0,0,0,1); } t0 max(t0, 0.0); // 确保起点在相机前方 float rayLengthInAtmo t1 - t0; if (rayLengthInAtmo 0.0) return fixed4(0,0,0,1); // 2. 沿视线进行数值积分采样 int numSamples 8; // 采样数影响质量和性能 float stepSize rayLengthInAtmo / numSamples; float3 totalRayleigh float3(0,0,0); float totalMie 0; // 太阳方向 float3 sunDir normalize(_SunDirection); float cosSunView dot(viewDir, sunDir); float sunAngularRadius _SunSize; // 弧度 // 判断视线是否指向太阳盘内 bool viewToSun (acos(cosSunView) sunAngularRadius); for (int s 0; s numSamples; s) { // 当前采样点在大气中的位置 float t t0 (s 0.5) * stepSize; // 中点采样 float3 samplePos camPos viewDir * t; // 3. 计算当前采样点到大气的顶部太阳方向的光学深度 float3 toSunDir sunDir; float tSun0, tSun1; RaySphereIntersect(samplePos, toSunDir, atmosphereRadius, tSun0, tSun1); float sunRayLengthInAtmo tSun1 - tSun0; // 简化假设太阳光线在大气中的路径是直到顶部 float opticalDepthSun OpticalDepth(samplePos, toSunDir, sunRayLengthInAtmo, _AtmosphereHeight); // 4. 计算当前采样点到相机的光学深度已经走过的路径 float opticalDepthView OpticalDepth(camPos, viewDir, t, _AtmosphereHeight); // 5. 组合光学深度并应用散射系数 float3 rayleighAttenuation exp(-(_RayleighCoeff * (opticalDepthSun opticalDepthView))); float mieAttenuation exp(-(_MieCoeff * (opticalDepthSun opticalDepthView))); // 6. 累加散射光考虑相位函数 float height length(samplePos) - _PlanetRadius; float rayleighDensity exp(-height / _AtmosphereHeight); float mieDensity exp(-height / _MieHeight); totalRayleigh rayleighAttenuation * rayleighDensity * stepSize; totalMie mieAttenuation * mieDensity * stepSize; } // 7. 应用相位函数和太阳强度 float rayleighPhase RayleighPhase(cosSunView); float miePhase MiePhase(cosSunView, 0.76); // g0.76 表示较强的向前散射 float3 rayleighScattering _RayleighCoeff * rayleighPhase * totalRayleigh; float mieScattering _MieCoeff * miePhase * totalMie; // 8. 组合最终颜色 float3 sunColor _SunIntensity * float3(1.0, 0.95, 0.9); // 略带黄色的太阳光 float3 finalColor sunColor * (rayleighScattering mieScattering); // 9. 色调映射和曝光调整 finalColor 1.0 - exp(-_Exposure * finalColor); // 简单的Reinhard色调映射 // 添加一个基础的环境光避免完全黑暗 finalColor fixed3(0.02, 0.02, 0.04); return fixed4(finalColor, 1.0); } ENDCG } } Fallback Off }这个Shader已经是一个功能相当完整的简化大气散射模型。它包含了瑞利散射和米氏散射能模拟出天空的蓝色渐变、日落时的红色以及太阳周围的光晕。4.3 参数调优指南这个Shader有很多参数调参是获得理想效果的关键_RayleighCoeff瑞利散射系数。增大它天空整体会更蓝日落更红。通常蓝通道值最大。_MieCoeff米氏散射系数。增大它太阳周围的光晕会更明显雾蒙蒙的感觉更强。_SunIntensity太阳光强度。控制整体亮度。_AtmosphereHeight大气高度。降低这个值天空颜色在地平线附近变化会更剧烈。_MieHeight气溶胶层高度。值越小米氏散射越集中在地面附近地平线附近的“雾感”越强。_SunSize太阳视大小。影响太阳光晕的角半径。_Exposure曝光。用于调整最终输出的明暗。注意事项这个Shader的积分采样数numSamples设置为8是一个性能和质量折衷的值。在PC上可以尝试增加到16或32以获得更平滑的效果但在移动端务必谨慎测试。另外计算OpticalDepth的函数是高度简化的真正的物理模型需要沿射线进行更精确的积分计算量会大很多。我们这个简化版本在大多数情况下视觉上已经足够可信。5. 性能优化与移动端适配实战一个好看但跑不动的天空盒是没有意义的。将上述Shader直接用到移动端项目很可能会造成帧率下降。我们需要进行针对性的优化。5.1 精度优化移动端GPU如OpenGL ES对float全精度运算的支持不如PC大量使用可能导致性能下降。在Shader中对于颜色计算等不需要高精度的部分应使用half半精度或fixed低精度变量。将float3 totalRayleigh改为half3 totalRayleigh。将循环内的中间变量如height,density声明为half。最终输出颜色finalColor可以保持为float3但内部计算尽量降精度。5.2 计算简化减少采样数这是最直接的手段。在Fragment Shader中将numSamples从8降到4视觉质量损失在可接受范围内性能提升显著。可以考虑根据设备性能动态设置。预计算/查表大气散射的许多计算如不同太阳高度角下的天空颜色其实是相对固定的。我们可以预计算一张查找表Look-Up Table, LUT。例如预计算一个2D纹理U坐标是视线仰角V坐标是太阳高度角纹理颜色存储对应的天空色。在Fragment Shader中只需要一次纹理采样开销极低。这是移动端动态天空盒的终极优化方案。降分辨率渲染天空盒通常占据屏幕很大一部分但细节要求并不高。可以考虑将天空盒渲染到一个较低分辨率如一半分辨率的Render Texture上然后再全屏采样这个纹理。这能大幅减少Fragment Shader的执行次数。5.3 针对移动端的简化Shader变体基于查表思想我们可以创建一个移动端专用的Shader。首先我们需要一个离线工具或一个运行时初始化脚本来生成LUT。// 这是一个示例性的C#脚本用于在Editor下生成天空颜色LUT #if UNITY_EDITOR using UnityEngine; using UnityEditor; public class SkyboxLUTGenerator : MonoBehaviour { public int lutWidth 256; public int lutHeight 64; public Material atmosphericScatteringMaterial; // 使用之前那个完整计算的材质 public Cubemap outputCubemap; // 或者输出为2D纹理 void GenerateLUT() { Texture2D lut new Texture2D(lutWidth, lutHeight, TextureFormat.RGBAHalf, false); // ... 这里需要调用 atmosphericScatteringMaterial通过设置不同的_SunDirection太阳高度角 // 和不同的视线方向仰角渲染到临时RenderTexture然后读取像素颜色写入lut ... // 这是一个复杂的过程通常需要编写一个Editor脚本或使用Compute Shader。 // 生成后将lut保存为Asset。 } } #endif生成了LUT纹理假设命名为SkyboxLUT后移动端Shader就变得非常简单Shader Custom/Skybox/MobileLUT { Properties { _LUT (Sky LUT, 2D) white {} _SunDirection (Sun Direction, Vector) (0, 1, 0, 0) _LUTParams (LUT Params (Scale, Bias), Vector) (1, 0, 0, 0) // 用于将角度映射到UV } SubShader { Tags { QueueBackground RenderTypeBackground PreviewTypeSkybox } Cull Off ZWrite Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct v2f { float4 pos : SV_POSITION; float3 viewDir : TEXCOORD0; }; v2f vert (appdata_full v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.viewDir normalize(mul((float3x3)unity_ObjectToWorld, v.vertex.xyz)); return o; } sampler2D _LUT; float4 _LUT_ST; float3 _SunDirection; float2 _LUTParams; // x: scale, y: bias fixed4 frag (v2f i) : SV_Target { float3 viewDir i.viewDir; // 计算视线仰角从地平线0到天顶1 float viewElevation (viewDir.y * 0.5 0.5); // 映射到[0,1] // 计算太阳高度角 float sunElevation (_SunDirection.y * 0.5 0.5); // 构建UVU为视线仰角V为太阳高度角 float2 uv float2(viewElevation, sunElevation); uv uv * _LUTParams.x _LUTParams.y; // 应用缩放和偏移如果需要 fixed4 skyColor tex2D(_LUT, uv); return skyColor; } ENDCG } } Fallback Off }这个移动端Shader的性能开销几乎可以忽略不计效果取决于LUT的精度和生成算法的质量。你可以为不同的天气晴天、多云、雾天生成不同的LUT并通过脚本在运行时切换来实现动态天气效果。踩坑记录在移动端使用自定义天空盒Shader时务必在Unity的Graphics Settings中将该Shader添加到“Always Included Shaders”列表里。否则在构建项目后如果该Shader没有被场景中的任何物体直接引用它可能会被Strip掉导致天空盒变成粉色。6. 与Unity渲染管线及后期效果的集成你的动态天空盒不是孤立的它需要和场景光照、雾效、后期处理如Color Grading协同工作。6.1 影响场景光照一个真实的天空盒应该为场景提供环境光Ambient Light和反射探针Reflection Probe信息。环境光在Window Rendering Lighting设置中将Environment Lighting的Source设置为Skybox。这样Unity就会根据当前天空盒的颜色和亮度动态计算场景的环境光。我们的动态天空盒材质变化时场景物体的暗部颜色也会随之自然变化。反射探针对于光滑物体天空盒是其主要的环境反射源。你需要确保场景中的反射探针尤其是全局探针的Type设置为Realtime并且Refresh Mode为Every Frame或Via Scripting。这样反射探针才会每帧捕获更新的天空盒。注意实时反射探针开销很大在移动端要慎用或者使用较低的分辨率和更新频率。6.2 与雾效协调Unity的雾效Window Rendering Lighting Environment颜色如果设置为固定值可能会与动态天空的地平线颜色冲突。一个更好的做法是在脚本中根据当前天空的颜色动态设置雾的颜色。void UpdateFogColor() { // 从我们计算出的天空色中取一个接近地平线的颜色作为雾色 Color horizonColor CalculateSkyTint(currentTimeOfDay); // 复用之前的函数 // 可以稍微混合一点灰色或让饱和度降低使雾更自然 Color fogColor Color.Lerp(horizonColor, Color.gray, 0.3f); RenderSettings.fogColor fogColor; }在UpdateSkyboxParameters函数中调用UpdateFogColor()。6.3 后期处理堆栈适配如果你使用了Unity的Post Processing v2或URP/HDRP中的Volume系统Color Grading颜色分级会改变最终屏幕颜色。我们的天空盒颜色是在所有后期处理之前渲染的因此也会被Color Grading影响。这通常是我们想要的因为它保证了视觉风格的统一。但需要注意如果你在Shader里做了非常精确的物理亮度计算强烈的Color Grading可能会破坏这种物理准确性。在这种情况下你可能需要将天空盒渲染到一个单独的图层并在后期处理之后以屏幕空间方式合成但这属于高级技巧会显著增加复杂度。6.4 在URP/HDRP中的实现差异如果你使用的是Universal RP (URP) 或 High Definition RP (HDRP)天空盒的实现方式有所不同。URP通常通过编写一个URP Skybox Shader并赋值给Volume组件中的Skybox覆盖来实现。你需要继承SkyboxRenderer并编写相应的Render函数。URP提供了Skybox渲染特性集成相对直接。HDRPHDRP拥有更完善、基于物理的天空系统例如HDRI Sky、Gradient Sky和Physically Based Sky。对于动态天空你可以使用Physically Based Sky组件并通过脚本控制其参数如Planet Radius,Air Density,Aerosol Density等这比从头写Shader要方便和精确得多但可控性可能不如自定义Shader。实操心得在Built-in管线中动态修改天空盒材质属性是全局生效的。但在SRPURP/HDRP中由于渲染数据如RenderingData的管理方式不同你需要确保你的修改在正确的渲染阶段如BeforeRenderingSkybox生效并且材质属性被正确地传递给负责渲染天空盒的Pass。建议仔细阅读对应SRP版本的天空盒渲染源码或官方文档。7. 常见问题排查与调试技巧即使按照步骤操作你也可能会遇到各种问题。这里记录了一些我实际开发中遇到的典型问题和解决方法。7.1 天空盒闪烁或撕裂原因这通常是由于每帧计算太阳方向或颜色时数值精度问题或插值不连续造成的。例如太阳方向在越过天顶时由于Mathf.Repeat或角度计算可能导致向量突然翻转。解决在计算太阳方向时使用Quaternion.Slerp进行平滑旋转而不是直接根据时间设置欧拉角。对于颜色确保你的插值函数如CalculateSkyTint在循环点如t0和t1是连续的。可以使用Mathf.PingPong或更平滑的周期函数。7.2 移动端上天空盒显示为粉色原因Shader编译失败或未被包含在构建中。解决检查Unity Console窗口是否有Shader编译错误。确保Shader的Target等级如#pragma target 3.0不超过目标移动设备的支持范围GLES2一般支持到2.0GLES3支持到3.0。对于复杂Shader可能需要使用#pragma target es3.0。如前所述将Shader添加到Project Settings Graphics Always Included Shaders列表中。7.3 性能开销过高原因Shader过于复杂每帧采样次数过多或脚本中每帧进行了昂贵的计算。解决使用Unity的Frame Debugger和ProfilerGPU模块定位瓶颈。查看Render Skybox这一项的GPU耗时。对于脚本确保将不必要每帧更新的计算移到Start或只在参数变化时更新。使用属性ID缓存。对于Shader实施第5节提到的优化策略降低精度、减少采样、使用LUT。7.4 地平线处有接缝或颜色突变原因在简化的大气散射模型中当视线接近地平线时光学深度的计算可能不准确导致颜色不连续。也可能是天空盒与地面雾效的衔接不好。解决在Shader的地平线附近viewDir.y接近0时可以增加采样数或使用一个平滑函数来混合一个额外的“地平线色”。确保雾效的起始距离和颜色与天空盒的地平线颜色相匹配。可以写一个简单的Shader在雾效计算中采样天空盒颜色作为雾色因子。7.5 太阳在夜晚依然可见或过亮原因相位函数和太阳强度计算没有考虑到太阳在地平线以下的情况。解决在Shader中当太阳方向_SunDirection.y小于某个阈值如-0.1时强制将太阳光强度_SunIntensity或对散射的贡献降为0。或者在脚本中根据太阳高度角来动态调整传递给Shader的太阳强度参数。调试Shader时一个非常实用的技巧是使用“调试输出”。例如你可以临时将Shader的最终输出改为某个中间变量如opticalDepthView并将其映射到颜色上以便可视化这个值的分布帮助你理解计算是否正确。// 调试将光学深度可视化为灰度 // return fixed4(opticalDepthView.xxx * 10.0, 1.0);实现一个从基础渐变到物理大气散射的动态天空盒是一个从“能用”到“好用”再到“惊艳”的渐进过程。核心在于理解光照与大气交互的基本原理并在性能与效果之间找到属于你项目的最佳平衡点。从简单的脚本插值开始逐步引入更复杂的Shader模型并时刻关注目标平台的性能表现这个路径对于大多数团队来说都是最稳妥和高效的。