搞了三四年Unity,天空球这东西几乎每个项目都躲不掉。小到背景氛围,大到整个场景的光照方向,全都跟这一层“天”挂着钩。尤其最近在做微信小游戏和数字孪生相关的项目,天空球的处理方式直接决定画面质感和运行帧率,里面值得讲的细节非常多。这篇就把我这些年做天空球的实践经验完整整理一遍,从原理到Shader写起,再讲到动态天空、VR场景、视频播放和性能避坑,尽量做到拿来就能用。
1. 先从原理说起:为什么天空要用“球”而不是“盒子”
1.1 无限远背景的数学表达
天空球的核心概念是“无限远”。不管摄像机怎么转,天空都应该保持在场景无穷远的位置,不会因为镜头移动而产生近大远小的视差。Unity里最简单的实现方式就是创建一个材质,把Shader设置为球面映射,然后把它赋给Skybox。
这里要理解一个关键点:为什么不用Cube呢?Unity内置的6-Sided Skybox实际上就是立方体贴图,也就是Cubemap。用Cubemap的好处是没有UV接缝,但问题也很明显——你需要准备六张贴图,而且美术要花心思去对齐六面的方向。对于大多数项目来说,一张全景等距柱状投影图就能搞定,所以“球”反而是更通用的做法。
从数学上看,天空球的做法是把一个带有特殊UV的球体固定在世界原点,把摄像机强制放在球心。物体的顶点经过MVP变换后,深度值仍然会被保留,但天空球需要的是“始终在远方”的效果。Unity在Skybox渲染时会把物体的Z值拉到远裁剪面的最远端,所以哪怕你放了一个半径1000的球,只要设置了合适的通道,它也不会被场景里的遮挡物挡住。
1.2 三种常见天空球表现方式对比
Unity里做天空球表现的方式大致分成三类,我在不同项目里都试过,各有优劣:
- 内置Skybox系统:Unity编辑器里Window -> Rendering -> Lighting,可以在Environment里直接指定天空盒材质。内置的Procedural Sky是纯程序生成的,不需要贴图,能调太阳方向、大气浓度、地面颜色等参数,项目初期占位非常合适。
- 全景贴图Skybox:用一张2:1的全景图作为天空背景。这是目前商业项目里最主流的方案,网上也有大量免费的全景HDR下载。关键是要把贴图的Wrap Mode设置为Clamp,纹理压缩选择HDR格式,避免边缘走样。
- 自定义Shader天空球:用程序化方式生成渐变天空、星星、太阳光晕等效果。这种方案最大的优势是完全可控,只需要几个颜色参数就能做出一套适配不同氛围的天色,不需要依赖外部贴图资源,在性能上也有明显优势。
我在中间那个阶段,也就是项目需要固定白天场景的时候,更推荐用全景图;但如果要做日夜循环或者风格化渲染,第三类方案会舒服得多,后面专门写。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写一套渐变天空Shader:从零到能用的完整过程
2.1 明确需求:我们要的“天空球”究竟是什么效果
在写代码之前,先想清楚需求。最常见的风格化天空需求是这样的:
- 从地平线到天顶有一个渐变过渡,地平线偏白/偏淡,天顶偏蓝/偏深。
- 太阳位置附近有一个高亮的暖色光圈。
- 背景中可以叠加云层或者光晕,但最初版本可以不做。
- 能跟随场景主光源方向旋转,这样动态改变Directional Light时天空也自动对齐。
这些需求用纯Shader就能解决,不需要C#脚本每帧传参。写一个自定义Unity Shader,主逻辑放在片元着色器中,用世界方向向量作为输入,计算出对应的颜色。
2.2 Shader的核心实现与参数含义
先给出一份可以用的Shader代码,然后逐个讲参数的设计理由。
shaderlab复制Shader "Custom/SkyGradient"
{
Properties
{
_ZenithColor ("Zenith Color", Color) = (0.2, 0.4, 0.8, 1)
_HorizonColor ("Horizon Color", Color) = (0.8, 0.85, 0.9, 1)
_GroundColor ("Ground Color", Color) = (0.4, 0.35, 0.3, 1)
_SunColor ("Sun Color", Color) = (1, 0.85, 0.6, 1)
_SunPosition ("Sun Position", Vector) = (1, 0.3, 0, 0)
_SunSpread ("Sun Spread", Range(0.001, 0.5)) = 0.02
_Exponent ("Gradient Exponent", Range(0.5, 8)) = 1.5
_HorizonHeight ("Horizon Height", Range(-0.2, 0.5)) = 0.0
}
SubShader
{
Tags { "Queue"="Background" "RenderType"="Background" "PreviewType"="Skybox" }
Cull Off
ZWrite Off
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 worldDir : TEXCOORD0;
};
float3 _ZenithColor;
float3 _HorizonColor;
float3 _GroundColor;
float3 _SunColor;
float4 _SunPosition;
float _SunSpread;
float _Exponent;
float _HorizonHeight;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldDir = mul((float3x3)unity_ObjectToWorld, v.vertex).xyz;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
float3 dir = normalize(i.worldDir);
float upDot = saturate(dir.y - _HorizonHeight);
// 上下半球的渐变
float horizonFactor = pow(1.0 - saturate(abs(dir.y - _HorizonHeight)), _Exponent);
float3 col = lerp(_GroundColor, _HorizonColor, saturate(dir.y < _HorizonHeight ? 0 : 1) * 0.5 + 0.5);
col = lerp(_HorizonColor, _ZenithColor, pow(saturate(upDot), _Exponent));
// 太阳光晕
float sunDirDot = saturate(dot(dir, normalize(_SunPosition.xyz)));
float sunDisc = smoothstep(1.0 - _SunSpread * 2.0, 1.0, sunDirDot);
col += _SunColor * sunDisc;
return fixed4(col, 1.0);
}
ENDCG
}
}
}
这份Shader用最基础的数学函数完成了天空渐变和太阳光晕。关键点在于:
_Exponent控制过渡的软硬程度,数值越大,天顶到地平线的过渡越突兀,适合高对比度风格化;数值小,过渡柔和,更加写实。_HorizonHeight允许把地平线抬高或压低,这在做海面远景或者地平面下沉时很有用。- 太阳光晕用的是点乘+平滑阶梯的方式,比直接做贴图采样性能高得多,而且颜色可以随意调整。
2.3 把天空球和雾效、平行光联动起来
很多时候天空球做好之后,发现雾效跟天空完全对不上,远景物体和天空之间有一条明显的断层。这个问题我在项目里遇到过好多次。解决办法是让雾的颜色也跟随天空的地平线颜色走。
在C#脚本里,可以这样处理:
csharp复制public class SyncSkyAndFog : MonoBehaviour
{
public Material skyMaterial;
void Update()
{
if (skyMaterial != null)
{
RenderSettings.fogColor = skyMaterial.GetColor("_HorizonColor");
}
}
}
这样当天空Shader里的地平线颜色变了,雾色也自动同步。特别是做日夜循环的时候,这个同步非常管用,否则晚上雾还是白的,整体就穿帮了。
另一个细节是平行光和方向匹配。如果你用了我上面的Shader,太阳方向完全由_SunPosition这个Vector控制。要想让平行光和它完全一致,可以在场景里定义一个方向光,然后写一个脚本把光的transform.forward传进去。注意平行光的朝向默认是“从光源向外照射”,而天空球的太阳方向是“从原点指向太阳”,通常取反即可。
csharp复制skyMaterial.SetVector("_SunPosition", -sunLight.transform.forward);
3. 动态天空球:日夜循环与程序化星星的可行路线
3.1 用时间驱动颜色变化
做日夜循环时,最忌讳的是直接瞬间切换颜色。比较好的做法是引入一个“天时间”变量,比如说timeOfDay,归一化到0到1之间,然后把它映射到天空Shader的几个关键颜色上。这样一来,清晨的颜色偏橙红,正午偏蓝,夜晚偏深蓝甚至纯黑,整个过程是平滑的。
具体来讲,我会维护一组颜色控制点:
| 时间点 | 天顶颜色 | 地平线颜色 | 太阳颜色 |
|---|---|---|---|
| 凌晨5点 | 深蓝灰 | 橙红 | 亮橙 |
| 正午12点 | 天蓝 | 淡灰白 | 暖白 |
| 傍晚19点 | 灰蓝 | 紫红橙 | 亮红 |
| 夜晚23点 | 墨蓝 | 深灰 | 无 |
然后写一个工具方法,把这些颜色控制点用AnimationCurve或者手动插值串起来。在Update里直接把计算好的颜色设给材质。这里有个性能小技巧:不要每帧都去SetColor,因为MaterialPropertyBlock和Shader.PropertyToID是更好的选择。提前用Shader.PropertyToID("_ZenithColor")缓存ID,减少字符串查找的开销。
3.2 程序化星星的采样思路
夜空里放星星,看起来简单,做起来很容易翻车。直接叠加一张星星贴图在天空球上,会因为像素密度不均出现星星大小不一、边缘锯齿严重的问题。程序化星星的实现思路是使用一种简单的网格噪声。
每颗星星本质上是球面上的一个点。在片元着色器中,把当前世界方向转换到某种网格坐标,然后对网格单元做哈希,生成一个0到1之间的随机值。根据随机值决定这个网格里是否存在星星,以及星星的亮度。这个方法的代价是每个像素要做几次哈希计算,但如果你控制好网格密度,性能还是可以接受的。
比如可以这样写一个极简的星星函数:
hlsl复制float hash(float2 p)
{
return frac(sin(dot(p, float2(127.1, 311.7))) * 43758.5453);
}
float stars(float3 dir, float density)
{
float2 uv = dir.xz / (dir.y + 0.001);
float2 id = floor(uv * density);
float h = hash(id);
return step(0.98, h) * smoothstep(0.0, 0.5, frac(h * 437.0));
}
这套公式生成的星星是密集的点阵,你还可以通过加上fmod控制闪烁。闪烁的实现也不难,用一个时间参数参与哈希,让星星的亮度随时间微微变化。不过,要注意夜间地面过暗时,星星的亮度范围很容易超出LDR设备的显示能力,最好在输出前做一次Tonemap处理。
3.3 与反射探针和实时GI的配合
很多人做日夜循环时只改了天空颜色,但忘了反射探针和全局光照。结果就是场景里的物体明明已经入夜,金属物体上却还反射着白天的天空贴图。这是典型的“天空球只服务了背景,没有服务光照”。
在Unity的URP管线里,动态天空对实时光照的影响主要靠两套机制:一套是RenderSettings.skybox,负责背景和反射;另一套是Light Probe Group,负责动态物体上的间接光照。夜晚切换天空时,需要同步更反射探针。最简单的方案是把场景中的反射探针设为实时模式,并设置一个更新间隔;如果性能吃紧,就手动在日夜切换的关键时间点调用一次RenderSettings.defaultReflectionMode = System.Reflection.Mode.Custom并重新烘焙。
更进阶的做法是用一个自定义脚本,把当前的天空颜色平均值写入Shader.SetGlobalColor("_AmbientSkyColor"),这样场景的半球环境光也跟着变。你会发现整个场景的气氛一下子就被“带进来了”。
4. 实战场景:视频天空球、VR天空球与数字孪生共存
4.1 视频天空球在微信小游戏里的播放要点
最近把一个视频天空球的需求放到了微信小游戏环境里做。刚开始我以为只要放一个VideoPlayer,把RenderTexture输出到材质就行,结果跑在Android真机上频繁黑屏,后来仔细排查才意识到问题不在天空球,而在视频播放流程。
微信小游戏环境下,VideoPlayer的资源加载方式跟原生Unity不一样。你必须提前把视频文件放到可访问的路径下,并且调用异步预加载接口,否则播放时容易出现首帧黑屏。具体操作是:在场景加载完成后,先调用一次视频播放准备,再等待isPrepared为true后再切换天空球材质的主纹理。
另一个坑是视频纹理的旋转方向。部分360度全景视频的元数据里带有Rotation信息,但VideoPlayer不会自动读取。我在Shader里通过UNITY_MATRIX_VP或者直接旋转UV来处理,但更稳妥的做法是在视频导出时就统一格式,约定好用标准的等距柱状投影,避免每次都在运行时做旋转补偿。
对了,视频天空球也必须关闭抗锯齿叠加。因为视频纹理本身已经带有自己的像素密度,如果又叠加一遍MSAA,移动端的带宽消耗会翻一倍。在微信小游戏这种讲究包体和性能的环境下,这是非常不划算的。
4.2 Pico4等VR场景下的天空球渲染要求
Pico4这类一体机设备的屏幕分辨率高,刷新率要求也高,天空球往往是最容易拉低帧率的元凶之一。很多人以为天空球只是一张全屏背景,不参与阴影,性能肯定没问题,其实除以双眼渲染之外,天空球的采样消耗会被放大。
VR里渲染天空球,有两个关键点:第一,Shader必须支持单通道实例化渲染,在URP里要正确标记UNITY_UNITY_PASS_INSTANCING。如果不支持,左右眼会各渲染一次,CPU提交的开销直接翻倍。第二,天空球上不要使用过于复杂的后处理效果,比如径向模糊、大量循环之类的操作。
此外,在VR里做的天空球应该直接禁用深度写入。原因不难理解:VR的立体渲染需要左右眼的深度一致,如果天空球把深度写入了,会导致近处物体的叠加出现闪烁。你只要设置Tags里的"Queue"="Background",并ZWrite Off,就能保证天空永远被绘制在最底层。
4.3 Cesium for Unity项目里天空球如何共存
数字孪生场景里用Cesium for Unity加载全球地形,经常遇到的问题就是默认天空球和Cesium自己的天空系统冲突。Cesium本身会生成一个参考椭球体,也会设置一个远距离相机裁剪面,如果你再挂一个Unity天空球,两者叠加会导致地物边缘泛白、地平线错位。
我采用的做法是:在Cesium场景中关闭Cesium自身的天空盒,只保留Unity的Custom Skybox作为背景;然后把Cesium的相机远裁剪面设置得足够大,同时把天空球半径固定在距离相机非常远的位置,让两者在视觉上无缝衔接。
这里有一个容易踩的坑:不要直接把天空球的GameObject放在原点并跟随相机,否则Cesium在动态加载瓦片时可能把这个球体也算进场景内,增加包围盒计算开销。正确的做法是设置天空球始终位于摄像机前一个固定的“极远距离”位置,并且关闭所有阴影投射和接收,把它当成纯粹的背景层。
5. 常见问题排查与性能避坑记录
5.1 天空颜色对不上,发灰或过亮
做天空球最常遇到的情况是,明明用的是一张漂亮的HDR全景图,渲染出来却灰蒙蒙或者死白一片。这时候先检查两个地方。
第一个是色彩空间。如果你在Project Settings里设置了Linear色彩空间,但贴图没有勾选sRGB,采样出来就会偏暗或者偏灰。全景HDR图最好把Texture Type设为Default,并勾选sRGB(Color Texture)。
第二个是曝光和后处理。URP里默认带有Tonemapping和Bloom,如果Tonemapping设为ACES,会把高光区域的饱和度压得比较低,天空颜色会被“洗白”。我一般把Tonemapping改为Neutral,或者调低Bloom的阈值,让天空的高光区域不要过曝。
5.2 天空球出现接缝或边缘闪烁
接缝最常见的原因是两个:UV映射不连续,或者材质球的Scale不为1。如果使用自定义Shader并用世界坐标计算颜色,基本上不会有接缝,但如果用全景贴图,就要注意贴图的Wrap Mode必须设为Clamp。
另外,在低端Android设备上,天空球和远景地形的交界面经常会出现轻微闪烁。这种闪烁通常是远裁剪面的深度精度不够导致的。解决方案是把远裁剪面设大,但Near值不要设得太小;或者开启Camera.depthTextureMode = DepthTextureMode.Depth并配合软粒子之类的远处淡出处理。
5.3 移动端性能:从材质到包体的几个建议
移动端的天空球性能优化,首先考虑像素填充率。天空球是全屏很像素的东西,任何在片元着色器里做浮点循环的Shader都会非常消耗性能。我建议把云层、星星这类效果放在极少数关键场景使用,或者简化成屏幕空间粒子的叠加,而不要在片元里做复杂的随机循环。
还有一个很常见的问题:全景天空球贴图太大。一张4K的全景HDR图往往有十几MB,在移动端这是很大的一笔内存开销。要压缩,但也不是让你直接压成低质量JPG。我的经验是,HDR格式且尺寸在2K左右时,视觉上已经足够应付大多数项目,再往上加分辨率,性价比很低。
另外一个小技巧:如果你的场景大多数时间处于室内,没必要在每个场景都挂一套高质量的动态天空。你可以把天空球材质换成一个纯色或者极低成本的渐变Shader,等到切户外时再动态切换回来。这样能省下不少带宽。
5.4 一些调试习惯
最后分享一个我平时调试天空球的习惯。我会在场景里放一个Debug小球体,把它的材质设为Unlit并赋上同样的天空球Shader,然后把这个小球放在原点旁边。这样我在做日夜循环或者改Shader参数时,不需要抬头看整个场景,直接从小球体上就能预览颜色变化。很多时候,调试天空球的难点在于它铺满整个视野,颜色变化看不准,有了这个小球就直观多了。这个习惯我用了好几年,效率提升非常明显。
