Unity Shader入门实战:从零编写彩虹渐变彩球着色器

发布时间:2026/7/9 22:43:28
Unity Shader入门实战:从零编写彩虹渐变彩球着色器 1. 项目概述从“彩球”案例切入Shader实战最近在社区里看到不少朋友对Unity Shader的学习感到头疼觉得概念抽象、代码晦涩。这让我想起了自己刚入门时也是对着各种光照模型和数学公式发懵。后来我发现与其一开始就啃那些复杂的理论不如从一个具体、有趣、视觉效果立竿见影的小案例入手。今天要分享的这个“彩球”案例练习就是这样一个绝佳的起点。它不涉及复杂的光照和阴影核心目标是让你亲手操控颜色理解Shader如何逐像素地决定物体表面的最终呈现。通过这个案例你将掌握Shader编写的基本结构、属性定义、顶点/片元着色器的分工以及如何用简单的数学函数如正弦、余弦来生成动态、渐变的色彩效果。无论你是刚接触Shader编程的新手还是想巩固基础概念的开发者这个练习都能让你在动手实践中直观地感受到Shader的魅力与威力。2. 核心思路与Shader结构拆解2.1 为什么选择“彩球”作为入门案例“彩球”案例看似简单实则涵盖了Shader学习的几个核心基础。首先它剥离了复杂的光照计算如Blinn-Phong、PBR让我们可以专注于最本质的颜色输出。其次球体模型本身UV坐标分布均匀非常适合用来测试基于模型空间位置或UV坐标的颜色算法。最后实现一个动态变化的、绚丽的色彩效果能立即获得视觉上的正反馈极大地提升学习动力。我们的目标是编写一个Unlit Shader无光照着色器让一个普通的球体模型根据其表面点的位置信息实时计算并渲染出彩虹般渐变或自定义的动态色彩。2.2 标准Unlit Shader框架搭建在Unity中一个基础的Shader通常包含以下部分属性块Properties、子着色器SubShader、通道Pass以及具体的顶点/片元着色器函数。我们首先搭建一个最简框架。Shader Custom/RainbowSphere { Properties { // 属性将在这里定义例如颜色、速度等参数 } SubShader { Tags { RenderTypeOpaque } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldPos : TEXCOORD1; }; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; return o; } fixed4 frag (v2f i) : SV_Target { // 核心色彩算法将在这里实现 return fixed4(1, 1, 1, 1); // 初始返回白色 } ENDCG } } }这个框架做了几件事定义了一个名为“Custom/RainbowSphere”的Shader包含一个Pass在顶点着色器vert中我们将模型顶点从对象空间转换到裁剪空间这是渲染管线必需的一步并传递了UV坐标和世界空间位置给片元着色器片元着色器frag目前返回固定白色。注意这里我们选择将顶点转换到世界空间worldPos而非直接使用模型空间位置或UV。这是因为世界空间坐标是绝对的不受物体旋转、缩放的影响缩放会影响但用于颜色计算通常问题不大使得色彩效果相对于世界坐标系是稳定的。如果你希望色彩随着物体旋转而旋转可以使用模型空间位置或法线。3. 核心色彩算法解析与实现3.1 基于世界空间位置的彩虹渐变最简单的彩虹效果可以利用世界空间坐标的某个分量如y轴高度来映射颜色。但更经典的彩虹是环状的。我们可以利用水平方向的位置x, z计算一个角度再结合y轴高度或时间产生变化。// 在片元着色器frag函数中实现 fixed4 frag (v2f i) : SV_Target { // 计算水平方向的角度atan2返回的是弧度 float angle atan2(i.worldPos.z, i.worldPos.x); // 将弧度归一化到[0, 1]区间 float hue (angle / (2.0 * 3.14159265359)) 0.5; // 简单的HSV到RGB转换简化版用于演示 float r abs(hue * 6.0 - 3.0) - 1.0; float g 2.0 - abs(hue * 6.0 - 2.0); float b 2.0 - abs(hue * 6.0 - 4.0); float3 rainbowColor saturate(float3(r, g, b)); return fixed4(rainbowColor, 1.0); }这段代码根据片元在世界XZ平面上的角度来决定色相Hue从而在球体水平方向上形成一个彩虹环。atan2(z, x)是一个非常重要的函数它返回从原点指向点(x, z)的向量与X轴正方向的夹角完美解决了四个象限的角度计算问题。3.2 引入时间变量实现动态效果静态的彩虹很美但动态的更加吸引人。Unity提供了一个内置的时间变量_Time它是一个float4其中_Time.y是自场景加载以来的时间以秒为单位。我们可以用它来驱动色彩变化。首先在属性块中添加一个控制速度的参数Properties { _Speed (Color Change Speed, Range(0, 5)) 1.0 }在CGPROGRAM内部声明对应的变量float _Speed;然后修改色相计算加入时间影响float hue (angle / (2.0 * 3.14159265359)) 0.5 _Time.y * _Speed * 0.1; hue frac(hue); // 使用frac函数确保hue始终在[0,1)范围内循环这样彩虹色彩就会随着时间缓缓旋转流动起来。frac()函数是关键它取小数部分实现了无限循环而不溢出的效果。3.3 更复杂的多维度色彩混合单一的彩虹环可能略显单调。我们可以结合球体的法线方向、到中心的距离等因素混合出更复杂的色彩。例如创建一个从球心到表面的径向渐变再叠加上彩虹环。// 假设我们已经从appdata传递了法线v.normal到v2f结构体中命名为worldNormal // 计算球体表面的径向因子从球心[原点假设]到表面的方向 float3 radialDir normalize(i.worldPos); // 对于中心在原点的球体世界位置方向就是径向 // 将径向方向与“向上”向量0,1,0做点积得到一个从-1到1的值映射到[0,1] float verticalGradient (dot(radialDir, float3(0,1,0)) 1.0) * 0.5; // 计算彩虹色 float angle atan2(i.worldPos.z, i.worldPos.x); float hue (angle / (2.0 * 3.14159265359)) 0.5 _Time.y * _Speed * 0.1; hue frac(hue); float3 rainbowColor // ... HSV转换代码 // 混合在球体顶部使用更多彩虹色在底部混合进一些深色如蓝色 float3 bottomColor float3(0.1, 0.2, 0.6); // 深蓝色 float3 finalColor lerp(bottomColor, rainbowColor, verticalGradient); return fixed4(finalColor, 1.0);这里使用了lerp线性插值函数根据verticalGradient的值在底部颜色和彩虹色之间平滑过渡。dot点积运算计算了两个向量的余弦相似度是Shader中极其常用的操作用于模拟基于角度或方向的渐变、光照等效果。4. 属性扩展与材质面板控制4.1 丰富可调参数为了让效果在编辑器中有更强的可控性我们扩展Properties块。一个好的Shader应该将关键视觉参数暴露出来。Properties { _Speed (Color Change Speed, Range(0, 5)) 1.0 _RainbowIntensity (Rainbow Intensity, Range(0, 1)) 1.0 _BottomColor (Bottom Color, Color) (0.1, 0.2, 0.6, 1) _TopColor (Top Color (Influence), Color) (1,1,1,1) _GradientPower (Gradient Sharpness, Range(0.1, 5)) 1.0 _NoiseScale (Noise Scale, Float) 1.0 _NoiseSpeed (Noise Flow Speed, Range(0, 2)) 0.5 }_RainbowIntensity: 控制彩虹色的强度可以完全褪色到灰度。_GradientPower: 通过对verticalGradient进行幂运算pow(verticalGradient, _GradientPower)可以控制渐变过渡的锐利或平滑程度。指数大于1会使中间过渡区变窄小于1则会变宽。_NoiseScale_NoiseSpeed: 为后续添加噪声纹理扰动预留。4.2 在片元着色器中应用属性在CGPROGRAM中声明对应变量后在色彩混合时使用它们float _RainbowIntensity; float4 _BottomColor; float4 _TopColor; float _GradientPower; // ... 计算 rainbowColor 和 verticalGradient ... // 应用渐变锐度 float gradientFactor pow(verticalGradient, _GradientPower); // 混合基础色现在顶部色受_TopColor影响 float3 topBaseColor _TopColor.rgb * rainbowColor; // 将彩虹色与顶部色调混合 float3 finalColor lerp(_BottomColor.rgb, topBaseColor, gradientFactor); // 应用彩虹强度 finalColor lerp(dot(finalColor, float3(0.3,0.59,0.11)), finalColor, _RainbowIntensity); // 灰度化混合 return fixed4(finalColor, 1.0);这样艺术家或开发者就可以直接在材质面板上拖动滑块、选择颜色实时观察彩球效果的变化无需修改代码。这种工作流是Unity Shader开发的核心优势之一。5. 性能优化与进阶技巧5.1 简化计算与常用优化手段虽然我们的Shader不算复杂但养成良好的性能习惯很重要。首先尽量避免在片元着色器中进行全精度的float运算在移动平台或需要大量覆盖的情况下使用half或fixed类型可以提升效率。例如颜色计算可以多用fixed或half。// 将一些中间变量声明为half half angle atan2(i.worldPos.z, i.worldPos.x); half hue (angle / (2.0 * 6.28318530718)) 0.5 _Time.y * _Speed * 0.1; hue frac(hue);其次减少重复计算。例如2.0 * PI这个值可以预先计算好。更进阶的如果彩虹色相计算是性能瓶颈可以考虑使用一张一维的彩虹渐变纹理1x256的纹理然后根据hue值进行采样。纹理采样在GPU上通常是非常高效的尤其是结合双线性过滤后色彩过渡会比手动计算HSV转换更加平滑且可能更快。// 在Properties中声明纹理 _RampTex (Color Ramp (RGB), 2D) white {} // 在变量中声明 sampler2D _RampTex; float4 _RampTex_ST; // 注意即使1D纹理也通常用sampler2DST是缩放平移 // 在片元着色器中 float2 rampUV float2(hue, 0.5); // 用hue作为U0.5作为V取纹理中间 float3 rainbowColor tex2D(_RampTex, rampUV).rgb;这种方法将复杂的颜色计算转移到了纹理制作阶段运行时仅需一次纹理采样灵活性极高可以随意编辑渐变纹理得到任意色彩序列。5.2 添加简单的噪声扰动纯数学的渐变有时显得过于“完美”和“计算机化”。引入一些噪声扰动可以让色彩过渡更自然、更有质感。我们可以使用一个简单的程序化噪声函数或者采样一张噪声纹理。这里以使用Unity自带的tex2D采样一张噪声贴图为例假设我们有一张名为_NoiseTex的灰度噪声图// 属性 _NoiseTex (Noise Texture, 2D) gray {} _NoiseScale (Noise Scale, Float) 1.0 _NoiseSpeed (Noise Flow Speed, Range(0, 2)) 0.5 _NoiseInfluence (Noise Influence, Range(0, 0.5)) 0.1 // 变量 sampler2D _NoiseTex; float4 _NoiseTex_ST; float _NoiseScale; float _NoiseSpeed; float _NoiseInfluence; // 在片元着色器中计算噪声UV可以随时间滚动 float2 noiseUV i.worldPos.xz * _NoiseScale float2(0, _Time.y * _NoiseSpeed); // 采样噪声值在[0,1]区间 float noise tex2D(_NoiseTex, noiseUV).r; // 将噪声映射到一个小范围内用于扰动色相hue hue (noise - 0.5) * _NoiseInfluence; hue frac(hue); // 再次确保在[0,1)范围这样彩虹色的边界就会产生细微的、流动的扭曲视觉效果更加有机。_NoiseInfluence参数控制扰动的强度避免破坏整体色彩结构。6. 常见问题与调试技巧实录6.1 问题球体颜色全黑或全白没有预期效果排查思路检查Shader编译错误在Unity控制台查看是否有“Shader compilation error”。最常见的错误是变量名拼写错误、缺少分号、括号不匹配等。检查材质球赋值确保场景中的球体模型使用的材质球其Shader确实选择了你编写的“Custom/RainbowSphere”。有时我们修改了Shader文件但材质球可能还关联着旧版本或别的Shader。简化测试在片元着色器中先返回一个固定颜色比如return fixed4(1,0,0,1);红色。如果球体变红说明Shader框架和渲染管线是通的问题出在颜色计算逻辑上。如果还是不变可能是渲染队列Queue或渲染类型RenderType标签问题对于不透明物体使用Tags { RenderTypeOpaque QueueGeometry}是安全的。检查坐标空间如果你使用的是世界坐标i.worldPos但球体模型不在世界原点附近或者缩放很大计算出的角度和渐变值可能超出预期。可以在着色器中先可视化i.worldPosreturn fixed4(i.worldPos.xyz * 0.1 0.5, 1);看看是否是一个渐变的色彩这会将世界坐标的小范围映射到[0,1]颜色区间。如果出现大块纯色说明坐标值过大或过小。6.2 问题色彩有接缝或突变排查思路角度计算接缝使用atan2(z, x)计算角度时在角度从-π跳变到π即360度位置的地方会出现色相从1跳变到0导致一条明显的接缝。这是数学上的必然。解决方法通常不是消除它而是利用模型UV或法线来驱动颜色避免直接使用绝对世界坐标的角度。例如可以使用模型法线在XZ平面的投影float angle atan2(i.worldNormal.z, i.worldNormal.x);。因为法线是单位向量且平滑插值计算出的角度变化也是平滑的可以有效避免因顶点位置突变导致的接缝。纹理采样接缝如果使用了噪声纹理并且UV缩放_NoiseScale设置过小导致纹理在球面上重复次数过多可能会在UV边界处出现接缝。确保噪声纹理本身是无缝贴图tileable或者使用三维噪声基于i.worldPos直接计算如Perlin噪声函数来避免UV问题。精度问题在极少数情况下可能是浮点数精度导致。确保在关键计算中使用足够的精度如float并在最后使用saturate()函数将颜色值钳制在[0,1]范围内防止溢出导致异常颜色。6.3 问题在移动设备上运行缓慢优化建议精度降级如前所述将颜色计算相关的变量从float改为half或fixed。减少复杂数学运算sin,cos,atan2,pow等都是相对耗时的操作。评估是否必要或能否用纹理查找替代。例如用frac(_Time.y * _Speed)代替包含sin的时间函数来驱动循环。检查纹理采样确保使用的纹理尺寸合理如噪声纹理用512x512或更小并设置为合适的压缩格式如RGBA Compressed DXT5。减少Pass数量确保Shader只有一个Pass。多个Pass会导致物体被渲染多次性能成倍下降。使用Shader LOD在SubShader中设置LOD 100是一个好习惯。你可以为更简单的版本设置更低的LOD值当摄像机距离很远时Unity可能会自动切换到更简化的版本需要配合Quality Settings中的Shader LOD设置。6.4 个人实操心得调试是学习的最佳路径我强烈建议在编写Shader时养成“分步可视化”的调试习惯。当你对一段计算逻辑不确定时不要试图一次性写出完美的色彩公式。而是将中间变量直接输出为颜色。例如想看看verticalGradient的分布就写return fixed4(gradientFactor, gradientFactor, gradientFactor, 1);。想看看噪声值就写return fixed4(noise, noise, noise, 1);。这样能最直观地看到每个数据在模型表面的实际分布快速定位问题是出在数据计算阶段还是后续的颜色映射阶段。Unity的Frame Debugger和RenderDoc这类图形调试工具固然强大但对于Shader逻辑调试这种“暴力”的颜色输出法往往是最快、最直接的。