☰
Unity程序化天空球Shader完整拆解:从零打造天空之城风格化场景
2026/10/19 2:30:26 网站建设 项目流程

“天空之城”这个需求看起来只是搭个背景,真正动手时我才意识到,最考验人的是头顶那片天。Unity默认的天空球其实不差,但一旦场景里出现悬浮岛、云海和纵深光线,它立刻露怯:云的层次撑不起来、太阳方向和主光对不上、地平线颜色发灰,怎么调都少了那种“天空之城”的味道。于是我从Shader到地编,从内置管线到URP迁移,完整走了一遍自建天空球加配套资源的流程。这篇文章就把这套方案完整拆给你,包括技术选型理由、关键Shader代码、浮空岛摆放逻辑、云海粒子参数,以及我踩过的接缝、颜色断层和打包翻车坑。适合正做风格化天空场景的开发者,也适合刚入门Unity想搞懂天空球原理的朋友。

1. 项目拆解:天空之城到底由哪些模块组成

1.1 天空球在场景中的定位与“天空之城”视觉要素

先聊清楚天空球是干嘛的。天空球本质是一个无限远的包围体,相机在场景里移动时,它不会产生视差,所以你看到的永远是同一片天。Unity里Camera挂上Skybox组件,或者渲染管线的Volume里指定Skybox类型,就能把天空球渲染到场景背景层,顺序上它先于所有不透明物体绘制,这也是为什么半透明云层放在场景里时,要注意和天空球的遮挡关系。

“天空之城”的视觉核心,我拆成了四块:垂直方向上的悬浮岛群、脚下和远方的云海、干净而有层次的天穹渐变、一个能和场景阴影方向对齐的太阳。难点不是单块内容,而是四者之间的匹配。你单独调岛没问题,单独调天空也没问题,合在一起就容易崩——太阳方向对不上、云海吃光严重、地平线被浮空岛切出硬边。所以我的思路是把天空球当作整个场景的光照与色调基准,先定天,再定地。

这里有个很多人忽略的点:内置渲染管线中,环境光默认会从天空球采样。也就是说,改变天空球颜色,等于同时改了场景阴影里的冷暖和漫反射基础色。我在项目中踩过一回:天空球调成了浓橙色黄昏,但场景里的平行光和阴影温度没跟上去,结果亮部是夕阳、暗部是白日光,画面非常脏。后面我会讲怎么把主光、环境光和天空球做联动。

1.2 四种天空球方案的取舍:程序化、六面贴图、全景HDR、自定义Shader

Unity里做天空球有几种常见路子,我按实际项目的可控程度排一遍。

第一种是内置程序化天空盒。参数少,调整快,适合临时预览和写实风格的地面场景。缺点很明确:做不了多层云,做不了艺术化色带,太阳圆盘也不能自由控制。如果你要的是“天空之城”这种高度风格化氛围,它基本撑不住。

第二种是六面贴图天空盒。把相机放在一个立方体中心,渲染前后左右上下六张图,再拼成天空盒。好处是美术可控性极强,可以雕刻出非常复杂的云层和光线,适合固定机位或第三人称视角。坏处是做成时间变化非常麻烦,六张图要逐一替换或做插值,资源体积也不小。而且天空盒贴图方向搞错的话,接缝问题会让人抓狂。

第三种是全景HDR贴图。用高动态范围的全景图做天空球,光影信息丰富,配合后处理很容易出效果。缺点和六面贴图类似:重量大,动画难做,网上很多免费全景图还有暗角或压缩痕迹,放大后云层边缘容易糊。

第四种就是我这篇重点讲的——自定义Shader程序化天空球。不依赖复杂贴图,用方向向量直接在GPU里算颜色、云层和太阳光晕,所有效果都是运行时参数,可以随便做昼夜循环、云层运动和风格化渐变。资源体积几乎为零,调参效率极高。

我最终选了第四种,搭配少量云噪声贴图,是因为这个方案能同时满足三个项目刚需:昼夜循环、动态云、从黄昏到夜幕的色调联动。下面详细讲实现。

2. 程序化天空球Shader:从零手写一套可调参数的天穹

2.1 Shader骨架:用方向向量画天穹而不是贴图

先给一个能跑起来的基础框架。这个Shader不贴常规UV纹理,而是把每个片元的“世界方向向量”当作输入,再根据这个方向计算颜色。

Shader "Custom/SkyCitySky" { Properties { _ZenithColor("天顶颜色", Color) = (0.2, 0.45, 0.9, 1) _HorizonColor("地平线颜色", Color) = (0.95, 0.9, 0.8, 1) _GroundColor("地面下方颜色", Color) = (0.55, 0.5, 0.4, 1) _SunDir("太阳方向", Vector) = (0.4, 0.7, -0.3, 0) _SunColor("太阳颜色", Color) = (1, 0.95, 0.8, 1) _SunRadius("太阳半径", Range(0.001, 0.1)) = 0.008 _SunSoftness("太阳边缘柔化", Range(0.0001, 0.5)) = 0.004 _CloudTex("云噪声贴图", 2D) = "white" {} _CloudColor("云颜色", Color) = (1, 1, 1, 1) _CloudScale("云层缩放", Range(0.5, 10)) = 2.5 _CloudCoverage("云量", Range(0, 1)) = 0.4 } SubShader { Tags { "Queue"="Background" "RenderType"="Background" "PreviewType"="Skybox" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; float3 worldDir : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.worldDir = mul((float3x3)unity_ObjectToWorld, v.vertex.xyz); return o; } half4 frag (v2f i) : SV_Target { half3 dir = normalize(i.worldDir); half h = dir.y; // 垂直方向渐变:地平线到天顶 half3 grad = lerp(_GroundColor, _HorizonColor, smoothstep(-0.05, 0.0, h)); grad = lerp(grad, _ZenithColor, pow(saturate(h), 0.6)); // 太阳圆盘 half3 sunDir = normalize(_SunDir.xyz); half sunDot = dot(dir, sunDir); half sun = smoothstep(_SunRadius, _SunRadius + _SunSoftness, sunDot); half3 col = grad + sun * _SunColor; // 云层:将方向向量映射到采样坐标 half2 cloudUV = dir.xz * 0.5 + 0.5; half clouds = tex2D(_CloudTex, cloudUV * _CloudScale).r; col = lerp(col, _CloudColor * grad, clouds * _CloudCoverage); return half4(col, 1.0); } ENDCG } } }

这段Shader的核心逻辑只有三步:用dir.y做地平线渐变,用点乘做太阳圆盘,用方向向量的xz分量采样云噪声。把这样的Shader挂在材质上,再把这个材质拖到Camera的Skybox,或者URP的Volume里Skybox覆盖层,场景立刻变成一个可调参数的天穹。

有些初学者会在这里犯迷糊:为什么顶点着色器里不用常规的MVP模型变换?因为天空球不能跟着相机平移走,否则你往前走走,天也跟着动,完全穿帮。做法是让天空球始终围绕相机,只旋转不位移。使用ObjectToWorld矩阵只是为了把立方体顶点的方向转到世界空间,相机永远在中心,所以不需要做平移。

2.2 云层、太阳光晕、昼夜循环的联动逻辑

基础Shader能看见天穹了,但要撑起“天空之城”的氛围,还得加三块内容。

第一块是云层运动。上面代码里我直接取dir.xz做UV,这会导致云在天顶附近拉伸变形。想要更好效果,可以加一层球面旋转再做采样:

float2 rotateUV(float2 uv, float2 pivot, float angle) { float2 delta = uv - pivot; float c = cos(angle); float s = sin(angle); return float2(delta.x * c - delta.y * s, delta.x * s + delta.y * c) + pivot; } float2 cloudUV = rotateUV(dir.xz * 0.5 + 0.5, 0.5, _CloudAngle); half clouds = tex2D(_CloudTex, cloudUV * _CloudScale).r;

_CloudAngle由C#脚本按时间累加,云就会整体转起来。这个方案比用_Time.y直接加在采样坐标上更自然,能避免云在边缘被撕扯。

第二块是太阳光晕。太阳圆盘做完之后,我习惯再叠一层非常宽的高光:

half glow = pow(saturate(sunDot), _GlowPower); col += _SunGlowColor * glow * _GlowIntensity;

_GlowPower在3到8之间会比较柔和,做黄昏时把_GlowIntensity拉高,可以模拟大范围晚霞。这个方法在移动端也能跑,因为只是几次幂运算。

第三块是昼夜循环。我写了一个C#脚本,按时间插值天空球材质参数和主光参数:

public class SkyTODController : MonoBehaviour { public Material skyboxMaterial; public Light mainLight; public Gradient zenithGradient; public Gradient horizonGradient; public Gradient lightGradient; [Range(0f, 1f)] public float dayTime = 0.5f; private void Update() { skyboxMaterial.SetColor("_ZenithColor", zenithGradient.Evaluate(dayTime)); skyboxMaterial.SetColor("_HorizonColor", horizonGradient.Evaluate(dayTime)); skyboxMaterial.SetVector("_SunDir", mainLight.transform.forward); mainLight.color = lightGradient.Evaluate(dayTime); RenderSettings.ambientLight = horizonGradient.Evaluate(dayTime) * 0.3f; } }

注意我把天空球太阳方向设成和主光Transform.forward一致。这一步非常关键:如果天空球上的太阳位置和场景平行光方向不一致,你会发现物体阴影指向一个方向,太阳高光却从另一个方向亮过来,观感极差。现在这样做,无论你白天转到夜晚,阳光角度和阴影方向永远同步。

3. 天空之城地编实操:浮空岛、云海与光照匹配

3.1 悬浮岛建模与快速摆放工作流

天空球的底层逻辑搭完后,进入地编环节。悬浮岛我试过三种做法:Unity内置的Terrain导出切块、ProBuilder手动拉、Blender雕刻。最终项目里用的是Blender雕刻加导入,原因是悬浮岛需要很强的“上平下尖”轮廓,Terrain切块容易保留底部切片边缘,ProBuilder手动拉又太慢。

条件有限但也想快速出效果的话,可以用ProBuilder做分段圆锥:顶部面拉成不规则圆形,加几个环切,再随意挪动顶点做出起伏,底部拉出一个尖锥。关键是底部要比顶部窄很多,视觉上才像“被拔起来的一整块地皮”。顶部给一点草绿或石板色,底部用深灰岩石色,色阶拉开后空气感马上就出来了。

摆放规则我总结三条。第一,竖向上“大岛在下、小岛在上”。大块头放底部能压住构图,小块头悬浮在上方增加层次,不会显得头重脚轻。第二,横向错开纵深,让摄像机在不移动只旋转的情况下,能看到前后多层岛屿穿插,云海从缝隙里露出来。第三,远离主视觉点的小岛可以用极低模替代,不加碰撞体,不投阴影,只用来填充远景密度。

每个岛我是以Prefab组织的,Prefab根节点挂一个空的“IslandAnchor”,下面挂Mesh和Collider。需要做漂浮动画时,让IslandAnchor沿Y轴做正弦运动就好,网格和碰撞体一起动,不会出现角色踩在岛边、岛却原地不动的异步问题。

3.2 多层云海方案对比与粒子调优

云海是“天空之城”最容易出氛围的地方,也是最容易翻车的地方。我试过三种方案,直接给结论。

方案A是平面贴图云层。把一张大云贴图铺在场景底部的几个半透明面上,随相机缓慢移动位置。优点是开销极低,缺点是视角一旦压低,平坦的贴图云海立刻穿帮,只能做远景。

方案B是粒子云海。新建ParticleSystem,Render Mode设为Billboard,材质使用带Alpha的云噪声纹理,Emission在每秒30到80之间。给粒子加上一点Size Over Lifetime,让云从出生到消散逐渐膨胀、变透明,整体看就像云海在流动。粒子云最大好处是能靠在场景中近景和远景之间,相机移动时依然有立体感。

方案C是体积云,用光线步进逐像素采样三维噪声。效果确实顶级,但开销巨大,手机项目慎用。我自己只在PC端Demo里试过,美术完成后帧数直接掉了近一半,最后还是换回了粒子加面片混合。

最终我采用的是“天空球底部渐变 + 外围面片云海 + 中近景粒子”的三层组合:天空球GroundColor承担最远处的雾霭基础色,面片云海负责中远景的成片云层,粒子云在玩家身边制造漂浮感。这样摄像机不管往哪个方向看,云海都有连续性和体量感。

粒子调优有几个容易忽略的参数:Start Lifetime建议在6到12秒,太短云会在眼前瞬间消失;Size Over Lifetime曲线要“小到大再到小”,中间平台期稍长,这样云的寿命大部分时间处在可见状态;材质的Alpha通道要柔和,硬边云贴图会在一远一近之间出现明显的“纸片感”。

3.3 光照、环境色与后处理调色一揽子方案

这节是很多项目效果“土”和“透”的分水岭。天空球做完后,需要回头把所有光照参数对齐。

方向光是场景的主光,我在脚本里已经把它的forward同步到天空球太阳方向。在此基础上,阴影强度建议在0.8到1之间,太软会损失悬浮岛的体积感,太硬又和云海柔光矛盾。阴影距离按场景纵深设置,常见问题是想让远景岛也投影,结果阴影距离拉得很大,内存和Draw Call一起涨。

环境光注意一下:内置管线里Legacy 3D项目的Ambient Mode默认是Skybox,天空球程序化颜色会持续驱动环境漫反射;URP里通常要手动设置Environment Lighting的Source,把Skybox作为环境光来源或直接Specified Gradient。冷暖要跟着天色走——白天天顶偏蓝、地平线偏暖;黄昏则整体向橙色偏移,同时把主光Color也调暖,场景里的反射和阴影才会统一。

后处理我用的方案是Bloom加Color Grading加Tonemapping,Bloom Threshold放到1.2左右,这样只有太阳、云层亮部和岛缘高光会泛光,不会把整片天空糊成一片;Color Grading的饱和度和对比度各加15到25,让云海和岛屿轮廓更清晰;Tonemapping选中性或ACES对应风格看具体需求,如果是梦幻天空之城风格,ACES曲线偏微妙的暖感会更有味道。

4. 踩坑实录:天空球项目中常见的六类问题

4.1 接缝、摩尔纹和颜色断层为何会出现

程序化天空球只要Shader逻辑对,本身没有贴图接缝问题。但如果你在云层采样时用了常规2D纹理,问题就来了:方向向量的xz或球面UV在接缝处会跳变,云层出现一条突变的竖线。云噪声贴图的Wrap Mode一定要选Repeat,然后做一层模糊处理,云的低频部分跨缝隙过渡才会自然。如果仍然裂开,把采样从dir.xz换成完整的球面UV公式:

float2 sphericalUV(float3 dir) { float u = atan2(dir.z, dir.x) / 6.28318 + 0.5; float v = asin(clamp(dir.y, -1.0, 1.0)) / 3.14159 + 0.5; return float2(u, v); }

不过atan2和asin在Fragment里性能开销稍高,移动端尽量少用,可以只在PC端或高端机开启。

摩尔纹通常来自云层贴图分辨率过高或者细节太锐利。云本身就是模糊大块的形体,不需要高清贴图。我把云噪声图控制在256乘256,开启MipMap和Bilinear过滤,反而比2048贴图更不容易闪烁。记住原则:云层贴图要“低频要足够低”,让每一处像素变化都缓慢平滑。

颜色断层就不离谱了:天顶到地平线如果是一个平滑但单调的渐变,在8位色深的移动屏幕上会看到明显的色带。解决办法是在天空球Shader输出前叠加一点Dithering抖动:

float dither = (frac(sin(dot(i.pos.xy + _Time.y, float2(12.9898, 78.233))) * 43758.5453) * 2.0 - 1.0) / 255.0; col += dither;

这样渐变带被打散,肉眼看过去是自然的颗粒感,而不是一条条色阶。这个技巧我在PC和手机上都用过,效果稳定。

4.2 URP管线迁移与Shader改写方向

如果你的项目用的是URP,上面那段CGPROGRAM代码不能直接跑。URP的Shader库和内置管线不同,主要改动有三处。

第一处是把CGPROGRAM换成HLSLPROGRAM,把include从UnityCG.cginc换成Universal RP的Core.hlsl;第二处是顶点变换函数从UnityObjectToClipPos换成TransformObjectToHClip;第三处是在SubShader的Tag里声明UniversalPipeline,让URP正确识别这个Pass。

SubShader { Tags { "RenderPipeline"="UniversalPipeline" "Queue"="Background" "RenderType"="Background" } Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct Attributes { float4 positionOS : POSITION; }; struct Varyings { float4 positionHCS : SV_POSITION; float3 worldDir : TEXCOORD0; }; Varyings vert(Attributes input) { Varyings output; output.positionHCS = TransformObjectToHClip(input.positionOS.xyz); output.worldDir = TransformObjectToWorld(input.positionOS.xyz); return output; } ... } }

URP里挂自定义天空球的方式也有点不一样:不是直接把材质拖到Camera的Skybox组件,而是在场景Volume里添加Skybox Override,然后把材质指定给它。很多人刚迁过来时找不到在哪设置,卡了半天,这里一并说清。

另外注意:如果在URP下你同时用了Linear颜色空间,天空球程序化渐变会比内置管线更亮一些,这是正常的,因为Shader计算在Linear空间里进行,颜色值不再被二次校正。后面调色时别被这个差异带偏,按“最终输出看起来OK”而不是“数值和原来一致”为准。

4.3 资源导入规范与Build打包避坑

天空之城资源不算重,但打包规范不做好,Build阶段会出各种莫名其妙的问题。

云噪声贴图要避免用PNG存高动态范围数据,PNG只支持8位固定范围。太阳和云层这种需要高亮区域的贴图,用TGA或EXR存16位。贴图格式上按平台选择:Android用ASTC,iOS用ASTC也行,PC用BC7。导入设置里把MipMap开启,除了云层这种大面积模糊低频内容,低频图开MipMap有助于减少远处摩尔纹。

Shader Variant是另一个坑。如果你的天空球Shader没有任何材质在场景中引用,或者引用了但Shader从未进入构建时编译列表,Build出来天空球会以洋红错误显示。保险做法是把这个Shader加进Project Settings的Graphics Settings里的Always Included Shaders列表,无论它有没有被引用,构建一定带上它。

还有一个和资源无关但十有八九会发生的Build坑:场景Far Clip Plane太小。天空之城场景纵深大,相机默认能见距离只有1000米左右,远处的悬浮岛会被裁掉,玩家视角边缘出现“天空一半、岛屿截断”的断层。Camera的Far Clip在飞行类或开放场景里最好给到2000到3000,阴影距离不必跟着拉满,否则阴影贴图精度会被摊薄。我一般在主相机上单独放一个组件,运行时强制设置Far和Fixed Shadow Distance。

5. 把天空球方案复用到更多风格与玩法

5.1 参数化改造:从黄昏到赛博朋克的四种天空

程序化天空球最大的优势是复用性极强。一套Shader,改几组参数就是完全不同的世界氛围。

做黄昏古堡时,我把天顶颜色改成深紫红,地平线色改成橙金色,太阳半径调大,光晕强度提高到2.5,云层覆盖率降到0.2。出来的效果像是太阳正在坠入云海,古堡轮廓在逆光里变成剪影,氛围一下就成立了。

做清晨群岛时,天顶是淡蓝青色,地平线是浅玫瑰色,主光稍微冷一些,太阳半径缩小、边缘柔化调大,云层覆盖率调到0.5。整个场景会让玩家觉得空气中带着湿度,太阳刚升起还没完全刺眼。

做赛博朋克风格时,我把天空球跑到了另一个极端:地平线用亮青色,天顶用深蓝黑,太阳方向其实已经没有自然意义了,我把它当成一个“发光体”用,配合大量粒子光点和Bloom,造出人造天际线的感觉。反正它的本质就是一个始终跟随相机的着色特效,你完全可以把“太阳”当成“月球”、“巨型广告牌”、“能量潮汐”等任意发光对象。

做暴风雨前兆时,云量拉到0.9,云颜色往灰蓝色靠,太阳方向压得很低,光晕强度降下来,同时把GroundColor调成暗棕色。再加上后处理压暗高光,场景就有一种山雨欲来的压迫感。

5.2 天空球与玩法机制的联动示例

最后分享一个我实际做过的小联动。在一个飞行玩法原型里,玩家控制一艘小船在空中移动,我用世界方向向量给云层加了一个偏移值:玩家向前飞时,云层UV整体向身后偏移,模拟穿云的视觉阻挡;玩家绕圈时,云层角度也跟着改变。实现上只是每帧向Shader传一个由输入方向计算的偏移量,却给飞行带来了很强的速度感和“世界在动”的沉浸感。

另一个玩法联动是昼夜对战斗的影响。夜晚时把天空球参数切到低亮度,同时降低场景中的可视距离,怪物的警戒范围扩大;天亮后天空球回到明亮状态,AI警戒范围缩小。这套逻辑通过一个全局时间变量驱动,天空球、主光、AI状态都从这里读值,不会出现某一系统不同步的问题。天空球在这里不只是背景,而是整个游戏循环的节奏开关。

我个人做类似项目时的体会有两点:一是别迷信复杂贴图和成品资源,程序化天空球虽然一开始写起来有点门槛,但它让你对画面的每个像素都有控制权,调参调得多了,一眼就知道当前问题出在天空还是地编;二是把参数集中在一个脚本里管理,不要在Shade、Material、Light组件各处撒,否则项目稍微改版,参数对不齐的情况一定会再次出现。这套方案在我手里的多个风格化场景里反复用过,改动成本很低,效果一直很稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询