1. 项目概述为什么要在2D游戏里做日夜循环做2D游戏尤其是像素风、手绘风或者横版卷轴游戏的时候我们总想让世界“活”起来。静态的背景图看久了难免单调而一个能随时间流逝、从晨曦微露到繁星满天的动态天空无疑是提升游戏沉浸感和氛围感最直接、最有效的手段之一。这就是“2D天空日夜循环”要解决的核心问题让2D游戏的天空背景不再是死板的一张图而是一个有生命、会呼吸的有机体。你可能会问3D游戏里用天空盒Skybox做日夜循环不是更简单吗确实3D引擎处理光照和天空有天然优势。但在2D世界里我们没有三维空间和复杂的光照模型一切视觉效果都得靠“画”出来。传统做法可能是准备多张不同时间点的天空序列帧然后用动画控制器去切换。这种方法简单直接但缺点也很明显资源量大美术要画很多张图、过渡生硬帧与帧之间跳变、且难以实现平滑、无限循环的时间流逝感。所以我们转向了Shader。用Shader来实现2D天空的日夜循环本质上是用程序算法去“实时绘制”天空。我们只需要1到2张基础纹理比如一张日间天空渐变图一张星空图然后通过Shader中的时间变量动态地混合颜色、移动纹理、改变亮度就能模拟出太阳东升西落、星辰隐现、天空色彩从湛蓝到橙红再到深紫的完整过程。这种方法资源消耗极低效果平滑且高度可控还能轻松地与游戏内的时间系统比如游戏时钟挂钩实现真正的动态环境。最近在社区里关于Unity Shader和2D特效的讨论一直很热无论是想优化游戏性能还是追求更独特的视觉风格掌握Shader编程都成了进阶必备技能。这个项目就是一次绝佳的实战演练。2. 核心思路与方案设计从美术资源到Shader算法在动手写代码之前我们必须把整个效果在脑子里“演算”一遍。一个完整的2D日夜循环可以拆解成几个核心的视觉层以及驱动它们变化的时间系统。2.1 视觉层分解天空不是一层“皮”我们不能把天空想象成单一的一层颜色。一个富有层次感的天空至少包含以下几个部分地平线渐变层这是天空的底色。白天是从头顶的天蓝色渐变到地平线附近的亮白色或浅黄色傍晚则是从深蓝色渐变到火红色或紫色。这个层决定了天空的基本色调。太阳/月亮层一个发光的圆形沿着预定的轨迹通常是椭圆或正弦曲线在天空移动。它的颜色、亮度和大小会随时间变化。云层可以是静态的细节纹理也可以是动态飘动的元素。它的亮度和颜色会受太阳位置影响比如背光面暗迎光面亮傍晚被染成红色。星空层在夜晚显现。不是简单的贴图显示最好能让星星有微弱的闪烁效果以增加生动性。我们的Shader需要有能力分层处理这些元素并进行动态混合。2.2 驱动核心时间系统与参数化控制所有变化的核心驱动力是“时间”。我们需要在Shader中定义一个归一化的时间变量比如_CycleTime范围是[0, 1]代表一个完整日夜周期的进度。0代表午夜0.25代表清晨0.5代表正午0.75代表黄昏1又回到午夜。有了这个时间变量我们就可以用数学函数来驱动其他参数太阳高度角可以用sin(_CycleTime * PI)来模拟太阳从地平线升起、到顶点、再落下的平滑运动。天空颜色我们需要定义几个关键时间点的颜色如午夜蓝、黎明橙、正午蓝、黄昏红然后用时间在它们之间进行平滑插值lerp。星星透明度在白天完全透明alpha0在夜晚完全显现alpha1在黎明和黄昏有一个平滑的淡入淡出过渡。注意这里的时间_CycleTime最好由C#脚本从游戏逻辑中传递进来而不是用Shader内部的_Time.y。这样我们可以完全控制游戏内时间流逝的速度比如现实1分钟等于游戏内1小时并且可以方便地实现“时间暂停”、“时间加速”等游戏功能。2.3 方案选型片段着色器Fragment Shader为主对于2D天空我们通常使用一个覆盖全屏或背景区域的Quad四边形或Sprite。因此选择Surface Shader表面着色器有点大材小用它更适合与3D光照模型交互。而Unlit Shader无光照着色器或自定义的片段着色器是我们的最佳选择因为它完全由我们控制颜色输出计算高效且易于理解。我们将编写一个Unlit Shader在片段着色器中为屏幕上的每一个像素计算其最终颜色。计算过程就是综合上述各层的贡献先计算基础渐变再叠加太阳光晕然后混合云层最后在夜晚叠加星空。3. 核心Shader实现详解从渐变到星辰接下来我们进入最核心的部分用HLSL/ShaderLab语言一步步构建这个天空着色器。我会假设你已有基础的Shader语法知识并着重解释每个步骤的设计意图和数学原理。3.1 构建基础天空渐变这是天空的底色。我们不在纹理里画死渐变而是用算法生成。// 在片段着色器中 float2 uv i.uv; // 假设UV的v坐标0到1代表从地平线0到天顶1 float time _CycleTime; // 归一化时间 // 1. 定义关键时间点的天空顶色和地平线色 float3 dayTopColor float3(0.2, 0.5, 1.0); // 白天顶天蓝 float3 dayHorizonColor float3(0.8, 0.9, 1.0); // 白天地平线亮白 float3 sunsetTopColor float3(0.05, 0.05, 0.2); // 日落顶深蓝紫 float3 sunsetHorizonColor float3(1.0, 0.4, 0.1); // 日落地平线橙红 float3 nightTopColor float3(0.0, 0.0, 0.1); // 夜晚顶近乎黑色 float3 nightHorizonColor float3(0.1, 0.1, 0.3); // 夜晚地平线暗蓝 // 2. 根据时间混合出当前时刻的顶色和地平线色 // 简化模型将一天分为白天、黄昏、夜晚三个区间 float3 currentTopColor; float3 currentHorizonColor; if (time 0.25 || time 0.75) { // 夜晚时段 // 在午夜附近使用纯夜晚色在黎明/黄昏附近向白天/日落色过渡 float nightFactor smoothstep(0.75, 1.0, time) smoothstep(0.0, 0.25, time); currentTopColor lerp(nightTopColor, sunsetTopColor, nightFactor); currentHorizonColor lerp(nightHorizonColor, sunsetHorizonColor, nightFactor); } else if (time 0.5) { // 上午到正午 float dayFactor smoothstep(0.25, 0.5, time); currentTopColor lerp(sunsetTopColor, dayTopColor, dayFactor); currentHorizonColor lerp(sunsetHorizonColor, dayHorizonColor, dayFactor); } else { // 下午到黄昏 float sunsetFactor smoothstep(0.5, 0.75, time); currentTopColor lerp(dayTopColor, sunsetTopColor, sunsetFactor); currentHorizonColor lerp(dayHorizonColor, sunsetHorizonColor, sunsetFactor); } // 3. 根据像素的垂直位置uv.y在当前顶色和地平线色之间插值形成垂直渐变 float verticalGradient pow(uv.y, _GradientPower); // 使用pow函数控制渐变曲线让地平线附近变化更平缓 float3 skyColor lerp(currentHorizonColor, currentTopColor, verticalGradient);这里用smoothstep函数来实现颜色之间的平滑过渡避免生硬的跳变。_GradientPower是一个可调参数pow(uv.y, _GradientPower)中当_GradientPower 1时地平线附近的颜色占比会更大天空看起来更“开阔”。3.2 模拟太阳或月亮的绘制与运动太阳是一个高光点。我们完全用数学来画一个圆形并让它动起来。// 计算太阳在屏幕空间中的位置x坐标随时间正弦变化y坐标随时间余弦变化模拟弧形轨迹 float sunX 0.5 0.4 * sin(time * PI); // 在屏幕X轴中间左右摆动 float sunY 0.2 0.6 * (cos(time * PI) * 0.5 0.5); // Y轴从0.2到0.8再回到0.2形成弧形 float2 sunPos float2(sunX, sunY); // 计算当前像素到太阳中心的距离 float distToSun distance(uv, sunPos); // 绘制太阳一个平滑的圆形光晕 float sunRadius _SunRadius; // 基础半径 float sunSoftness _SunSoftness; // 边缘软化度 // 使用smoothstep创建从1中心到0边缘的平滑过渡 float sunMask 1.0 - smoothstep(sunRadius - sunSoftness, sunRadius sunSoftness, distToSun); // 太阳的颜色和亮度也随时间变化 float3 sunColor _SunColor; float sunIntensity _SunBaseIntensity; // 正午最亮清晨黄昏减弱夜晚消失 sunIntensity * (1.0 - abs(time - 0.5) * 2.0); // 一个简单的三角波模拟亮度变化 sunIntensity max(0, sunIntensity); // 确保不为负 // 将太阳光晕叠加到天空颜色上使用加法混合模拟发光效果 float3 finalColor skyColor sunColor * sunMask * sunIntensity;实操心得太阳的轨迹不一定非得是标准的正弦/余弦。你可以用动画曲线Animation Curve在C#中计算好位置再传给Shader这样能做出更符合美术需求的、非对称的日出日落轨迹比如日出快、日落慢。_SunSoftness参数非常重要一个硬边的圆看起来像贴图一个柔和的光晕才更像真实的光源。3.3 动态云层的集成与光照影响云层通常需要纹理。我们准备一张带有Alpha通道的云纹理。// 采样云纹理可能使用两个不同缩放和速度的UV进行叠加以产生更复杂、不重复的运动 float2 cloudUV1 uv * _CloudScale float2(_Time.y * _CloudSpeed1, 0); float2 cloudUV2 uv * _CloudScale * 0.7 float2(_Time.y * _CloudSpeed2, 0.1); // 第二个层稍作偏移 float4 cloudSample1 tex2D(_CloudTex, cloudUV1); float4 cloudSample2 tex2D(_CloudTex, cloudUV2); // 混合两层云使用最大值混合max可以保留较亮的云层细节避免叠加后变糊 float cloudMask max(cloudSample1.a, cloudSample2.a); // 云的颜色受太阳位置影响模拟光照 float3 cloudColor _CloudColor; // 简单的光照模拟计算云层UV点与太阳方向的点积越朝向太阳越亮 float2 toSunDir sunPos - uv; float sunLightFactor dot(normalize(toSunDir), float2(0, 1)); // 假设太阳光主要从上往下照 sunLightFactor saturate(sunLightFactor 0.3); // 加个基础值让背光面不完全黑 cloudColor * sunLightFactor; // 将云层叠加到天空上使用透明度混合Alpha混合 finalColor lerp(finalColor, cloudColor, cloudMask * _CloudOpacity);这里的关键是让云动起来通过_Time.y偏移UV以及受光影响。更复杂的模拟还可以根据太阳高度角来改变云的整体亮度和色调傍晚的云是红的。3.4 星空与星星闪烁效果星空是夜晚的主角。我们同样使用一张星空纹理但会做一些处理让它更生动。// 采样星空纹理 float4 starsSample tex2D(_StarsTex, uv * _StarsScale); float starsMask starsSample.r; // 假设星星在R通道 // 决定星星的可见度基于时间在夜晚完全显示在白天完全隐藏 float nightLerpFactor smoothstep(0.8, 1.0, time) smoothstep(0.0, 0.2, time); // 在夜晚时段接近1白天接近0 nightLerpFactor saturate(nightLerpFactor); // 星星闪烁效果利用时间和噪声纹理或者简单的正弦波改变星星的亮度 float twinkle sin(_Time.y * 5.0 uv.x * 100.0) * 0.5 0.5; // 一个简单的随机闪烁模拟 twinkle lerp(0.3, 1.2, twinkle); // 闪烁亮度范围 starsMask * twinkle; // 将星星叠加到画面上加法混合只在夜晚显示 finalColor starsSample.rgb * starsMask * nightLerpFactor * _StarsIntensity;注意事项星空纹理最好是黑底白星或透明底白星。叠加时使用加法这样星星才能“亮”起来。闪烁效果不宜过强或频率过高否则会显得虚假。这里用uv.x * 100.0来给不同位置的星星一个相位差避免所有星星同步闪烁。4. 完整Shader代码结构与参数曝光将以上所有部分组合起来并加上必要的Properties和Pass就得到了一个完整的Shader。为了让美术或策划能够方便地调整效果我们需要把关键参数暴露在材质面板上。Shader Custom/2DDayNightCycleSky { Properties { [Header(Sky Gradient)] _DayTopColor (Day Top Color, Color) (0.2, 0.5, 1.0, 1.0) _DayHorizonColor (Day Horizon Color, Color) (0.8, 0.9, 1.0, 1.0) _SunsetTopColor (Sunset Top Color, Color) (0.05, 0.05, 0.2, 1.0) _SunsetHorizonColor (Sunset Horizon Color, Color) (1.0, 0.4, 0.1, 1.0) _NightTopColor (Night Top Color, Color) (0.0, 0.0, 0.1, 1.0) _NightHorizonColor (Night Horizon Color, Color) (0.1, 0.1, 0.3, 1.0) _GradientPower (Gradient Power, Range(0.5, 3.0)) 1.5 [Header(Sun)] _SunColor (Sun Color, Color) (1.0, 0.9, 0.7, 1.0) _SunRadius (Sun Radius, Range(0.01, 0.2)) 0.05 _SunSoftness (Sun Softness, Range(0.001, 0.1)) 0.02 _SunBaseIntensity (Sun Base Intensity, Range(0, 3)) 1.0 _CycleTime (Day Cycle Time (0-1), Range(0, 1)) 0.0 // 这个通常由脚本控制 [Header(Clouds)] _CloudTex (Cloud Texture, 2D) white {} _CloudColor (Cloud Color, Color) (1,1,1,1) _CloudScale (Cloud Scale, Range(0.1, 5)) 1.0 _CloudSpeed1 (Cloud Speed 1, Float) 0.01 _CloudSpeed2 (Cloud Speed 2, Float) 0.02 _CloudOpacity (Cloud Opacity, Range(0, 1)) 0.5 [Header(Stars)] _StarsTex (Stars Texture, 2D) black {} _StarsIntensity (Stars Intensity, Range(0, 2)) 1.0 _StarsScale (Stars Scale, Range(0.5, 2)) 1.0 } SubShader { Tags { RenderTypeOpaque QueueBackground } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc // 变量声明与Properties对应... // 顶点着色器输入输出结构体... // 顶点着色器简单传递UV... // 片段着色器包含上述所有计算逻辑... // 最终输出 finalColor ENDCG } } FallBack Diffuse }在Unity中将这个Shader赋给一个材质球然后将材质球赋给一个覆盖摄像机背景的Quad你就能在场景中看到基础的天空了。通过调整材质面板上的参数你可以创造出从晴朗午后到暴风雨前夜的各种天空 mood。5. C#驱动脚本连接游戏逻辑与ShaderShader本身是“被动”的它需要一个外部力量来驱动_CycleTime的变化。这个力量就来自C#脚本。using UnityEngine; [ExecuteInEditMode] // 方便在编辑器模式下预览 public class DayNightCycleController : MonoBehaviour { public Renderer skyRenderer; // 承载天空材质的Renderer比如Quad上的 public float dayDurationInSeconds 60f; // 游戏内一整天对应的真实秒数 private float currentCycleTime 0f; // 当前周期时间 (0-1) private MaterialPropertyBlock propertyBlock; void Start() { propertyBlock new MaterialPropertyBlock(); skyRenderer.GetPropertyBlock(propertyBlock); // 使用PropertyBlock避免创建材质实例 } void Update() { // 更新时间 currentCycleTime Time.deltaTime / dayDurationInSeconds; currentCycleTime % 1.0f; // 保持在0-1循环 // 将时间传递给Shader if (propertyBlock ! null skyRenderer ! null) { skyRenderer.GetPropertyBlock(propertyBlock); propertyBlock.SetFloat(_CycleTime, currentCycleTime); skyRenderer.SetPropertyBlock(propertyBlock); } // 可选根据时间触发游戏内事件如清晨怪物刷新夜晚路灯亮起 HandleGameEvents(); } // 提供给其他脚本调用的接口用于获取时间、设置时间等 public float GetCurrentTime() currentCycleTime; public void SetTime(float normalizedTime) { currentCycleTime Mathf.Clamp01(normalizedTime); } public bool IsNightTime() currentCycleTime 0.75f || currentCycleTime 0.25f; void HandleGameEvents() { // 示例在特定时间点触发事件 if (Mathf.Abs(currentCycleTime - 0.25f) 0.01f) // 清晨 { // Debug.Log(黎明到来); } } }重要技巧这里使用了MaterialPropertyBlock来修改Shader属性而不是直接修改material.SetFloat。这是最佳实践。MaterialPropertyBlock允许你修改Renderer的材质属性而不创建新的材质实例Material Instance这对于性能优化至关重要尤其是在移动平台或需要大量重复物体的场景中。直接修改material属性会导致Unity为该Renderer创建一个独有的材质副本如果场景中有1000个使用同一天空材质的物体就会产生1000个材质实例造成巨大的内存和渲染开销。而PropertyBlock不会。6. 性能优化与进阶技巧一个看起来酷炫的效果如果让游戏卡顿就得不偿失了。下面是一些针对此Shader的优化和进阶思路。6.1 性能瓶颈分析与优化纹理采样次数我们的Shader采样了两次云纹理和一次星空纹理。对于移动平台这不算多但仍有优化空间。可以考虑将星空和云的基础图案合并到一张纹理的不同通道如R通道存星星G通道存云减少一次采样。或者在低端设备上通过Shader变体Shader Variant关闭云层或星空的动态效果。数学计算sin,cos,pow,smoothstep都是相对耗时的操作但在片段着色器中每个像素都会计算。对于太阳光晕我们可以考虑在顶点着色器中计算太阳位置和基础亮度然后通过插值传给片段着色器减少逐像素计算量。对于天空渐变如果屏幕分辨率固定甚至可以预计算一张小尺寸的1D渐变纹理Ramp Texture根据时间选择不同的行然后根据uv.y采样用一次纹理采样代替复杂的颜色插值计算。使用PropertyBlock如上节所述务必使用MaterialPropertyBlock来更新_CycleTime等动态参数这是避免运行时材质实例爆炸的关键。针对平台调整精度在移动端可以考虑将部分float运算降级为half甚至fixed在支持的情况下以提升运算速度。6.2 效果增强与扩展思路大气散射模拟进阶更真实的天空颜色变化源于瑞利散射和米氏散射。你可以引入更复杂的公式根据太阳与观察者视线之间的角度来计算天空各部分的颜色而不是简单的几个关键色插值。这会让黄昏时的天空色彩过渡更加科学和华丽。动态天气系统集成将天气参数如雨、雾、云量也作为Uniform变量传入Shader。例如增加一个_CloudCoverage参数来控制云层纹理的显示强度和范围增加一个_FogColor和_FogDensity来模拟雾效让地平线处的物体与天空颜色融合。与2D光照系统交互如果你的2D游戏使用了像Unity 2D URPLit这样的渲染管线你可以让太阳的位置影响场景中2D灯光的全局颜色和强度。在C#脚本中根据_CycleTime计算出一个环境光颜色和强度然后赋值给RenderSettings.ambientLight或2D光照系统的全局光参数。后处理叠加在后期可以加入一个全屏的色调调整Color Grading或泛光Bloom效果。让正午的画面更鲜艳明亮夜晚的画面对比度更高并带有微弱的蓝色调日落的画面泛光效果更强以突出太阳的辉煌。7. 常见问题与调试实录在实际操作中你肯定会遇到各种奇怪的问题。这里记录几个我踩过的坑和解决方法。7.1 效果在Game视图不更新或者更新闪烁问题描述在Scene视图一切正常但Game视图的天空是静止的或者变化不连贯。排查步骤首先检查C#脚本是否挂载并启用skyRenderer变量是否正确赋值。检查是否使用了MaterialPropertyBlock。一个常见的巨坑是如果你在编辑器模式下直接修改了材质球Material Asset的_CycleTime属性然后运行游戏脚本通过PropertyBlock设置的值可能会被材质球自身的默认值覆盖导致无效。确保材质球上的_CycleTime默认值为0并且脚本是唯一修改它的地方。检查Update方法中的时间计算逻辑特别是取模运算%确保currentCycleTime不会变成NaN或无限大。如果是闪烁可能是多个脚本或渲染顺序冲突。确保承载天空的Quad的渲染队列Queue是“Background”并且其Z位置在摄像机远裁剪面之内但又在所有其他场景物体之后。7.2 太阳/月亮在屏幕边缘被裁剪问题描述太阳运动到屏幕左/右或上边缘时突然消失了一半。原因与解决这是因为我们计算太阳位置 (sunPos) 时使用的是归一化的UV坐标 (0,1)。当太阳中心靠近UV边界0或1时其光晕的一部分就落在了UV范围之外比如sunPos.x - radius 0这部分不会被渲染。解决方案在片段着色器中计算太阳贡献时不要简单地丢弃UV范围外的像素。或者更简单的方法是确保太阳的运动轨迹sunX,sunY始终留出足够的光晕半径空间。例如将sunX的计算改为0.5 0.35 * sin(...)把摆动幅度从0.4降到0.35给边缘留出空间。7.3 云层移动速度太快或出现明显重复纹理问题描述云看起来像快进一样飞驰或者移动一段距离后能看到明显的纹理重复接缝。调试方法速度问题调整_CloudSpeed1和_CloudSpeed2。它们的值通常非常小如0.01因为_Time.y的单位是秒UV偏移量是每秒钟移动多少个纹理单位。值太大就会飞起来。重复纹理问题这是使用单一纹理平铺Tiling的固有缺陷。解决方法有几种使用更大的纹理减少缩放_CloudScale让单张纹理覆盖更大屏幕区域重复感自然降低。使用程序化噪声用Simplex或Perlin噪声函数代替纹理来生成云这样天生就是无重复的。但这会显著增加Shader计算复杂度。多层叠加与旋转除了用两个不同速度的UV还可以让第二层云纹理旋转一个角度如15度再叠加这样重复图案会更难被察觉。使用遮罩纹理准备一张大的、不重复的云形状遮罩图然后在其上用噪声函数生成细节这是折中的方案。7.4 在URP/HDRP中无法工作问题描述为Built-in渲染管线写的Shader直接拿到URP通用渲染管线或HDRP高清渲染管线中材质显示粉红色Missing Shader。解决方案URP/HDRP有自己的一套Shader框架和库函数。你需要将Shader重写为URP的Shader Graph可视化或者手写HLSL代码但遵循URP的库和路径。对于这个2D天空效果使用Shader Graph来实现会非常直观你可以用节点搭建出完全相同的逻辑并且URP Shader Graph天生支持Time节点和PropertyBlock的参数传递。这是将效果迁移到现代渲染管线的最佳路径。最后调试Shader时帧调试器Frame Debugger和可视化调试工具是救命稻草。你可以临时将Shader中的某个中间变量如sunMask,nightLerpFactor直接输出为最终颜色来检查它的值是否符合预期。例如在开发阶段你可以添加一个调试模式开关在材质面板上勾选后最终输出就变成float4(sunMask, sunMask, sunMask, 1)这样你就能在屏幕上直观地看到太阳光晕的形状和范围是否正确这是排查问题最快的方法。