Unity着色器入门:从零实现可调色Shader,掌握顶点/片元着色器核心原理

📅 2026/7/18 3:16:36
Unity着色器入门:从零实现可调色Shader,掌握顶点/片元着色器核心原理
1. 项目概述为什么从零写一个可调色着色器是必经之路如果你在Unity里做过一段时间开发尤其是涉及美术表现或者UI特效大概率已经用过不少内置的Shader或者从Asset Store下载的现成方案。它们用起来很方便拖拽材质球调整几个滑块效果就出来了。但不知道你有没有遇到过这种情况想要一个简单的颜色叠加效果却发现内置的Standard Shader参数太多太复杂或者从网上下载的某个炫酷Shader你想改一下它的颜色混合逻辑打开代码一看里面各种复杂的光照计算和顶点变换让你瞬间头大。这时候你就会想要是能自己从头写一个只做“调色”这件事的、干干净净的Shader该多好。这正是我们这次要做的从零开始手写一个可以调节颜色的基础着色器。别被“从零”吓到这恰恰是理解Shader核心最直接的方式。Unity的Shader尤其是表面着色器Surface Shader和顶点/片元着色器Vertex/Fragment Shader其本质是写给GPU看的一套“渲染说明书”。我们写的ShaderLab和HLSL/Cg代码最终会被编译成GPU指令告诉它每个像素该怎么画。而“调节颜色”可以说是所有Shader效果里最基础、也最核心的诉求之一。它直接关联着材质的_Color属性、顶点色、以及后续所有复杂效果如溶解、渐变、流光的底层色彩混合。通过亲手实现这个基础功能你能彻底搞明白几个关键问题材质面板上的颜色拾取器是怎么和Shader代码里的变量绑定的颜色值在Shader内部是如何传递和运算的一个最简单的着色器应该包含哪些必不可少的代码块理解这些就像是学会了编程里的“Hello World”之后无论你想实现多复杂的效果都有了稳固的起点。这个项目适合所有对Unity Shader感到好奇但又不知从何下手的开发者无论你是程序想深入图形学还是TA技术美术想打通工具链这都是一块绝佳的敲门砖。2. 核心思路与Shader类型选择在动手写代码之前我们得先想清楚用什么“写法”来实现。Unity支持多种编写Shader的方式选对类型能事半功倍。2.1 三种主流Shader编写方式对比Unity中主要有三种编写Shader的路径它们抽象层级和复杂度各不相同Shader Graph可视化着色器图这是Unity近年力推的节点化编辑工具通过拖拽节点连线来构建Shader无需直接编写代码。对于实现颜色混合、简单的数学运算非常直观。但对于本次“从零实现”的学习目标而言它隐藏了太多底层细节不利于我们理解Shader的本质结构和数据流。因此本次我们不采用这种方式。表面着色器Surface Shader这是Unity提供的一种对传统顶点/片元着色器的高级封装。你只需要编写一个surf函数来描述表面属性如颜色、法线、高光等Unity会自动帮你生成处理前向渲染路径Forward Rendering所需的光照、阴影等复杂代码。它非常适合需要与Unity灯光系统交互的材质。对于我们的基础调色器用它有点“杀鸡用牛刀”但作为入门理解Unity Shader的便捷方式它依然是个不错的选择因为它结构清晰能快速看到效果。顶点/片元着色器Vertex/Fragment Shader这是最基础、最直接的编写方式也是图形API如OpenGL, DirectX的原生概念。你需要手动编写vert函数处理顶点位置frag函数处理每个像素的颜色。这种方式给你最大的控制权能最清晰地看到数据从CPU材质属性到GPU再经过顶点着色器、插值最终到片元着色器的完整流程。对于学习着色器原理来说这是最“纯净”的路径。2.2 我们的选择顶点/片元着色器我决定在本项目中采用顶点/片元着色器的方案。原因有三点学习价值最大化它能强迫我们理解每一个步骤从属性声明、变量传递到最终的色彩输出没有任何“魔法”。控制力强后续如果我们想扩展功能比如基于顶点位置做颜色渐变或者加入简单的纹理采样在这个框架下添加会非常自然。性能透明生成的代码最接近最终GPU执行的指令便于我们理解性能开销。当然这并不意味着表面着色器不好。在实际项目中需要复杂光照的物体如角色、场景物件用表面着色器开发效率更高。但作为学习从底层开始构建认知以后再使用高级抽象工具时你会更清楚它在背后帮你做了什么调试时也更有底气。注意Unity的Shader最终都会被编译成对应图形API的低级着色语言如HLSL for DirectX, GLSL for OpenGL。我们编写的HLSL/Cg代码是一种“高级”着色语言由Unity的着色器编译器UnityShaderCompiler进行跨平台编译和优化。在Project视图的Library/ShaderCache文件夹下可以找到缓存的各种变体Variant的编译结果。理解这一点很重要它说明了为什么有时修改Shader后第一次运行会有短暂的编译卡顿。3. 项目创建与Shader文件结构解析思路定了我们开始动手。首先在Unity中创建一个新的Shader文件。3.1 创建Unlit Shader模板在Project视图右键 -Create - Shader - Unlit Shader。我强烈建议选择“Unlit”而不是“Standard Surface”或其他的模板。因为“Unlit Shader”生成的就是一个最基础的、不受光照影响的顶点/片元着色器框架这正好符合我们的需求——专注于颜色本身排除光照计算的干扰。创建后Unity会生成一个.shader文件用你喜欢的代码编辑器如VSCode, Rider打开它。你会看到一套完整的、但可能略显复杂的代码。别担心我们接下来会像解剖麻雀一样把它拆解清楚并替换成我们自己的精简版。3.2 ShaderLab基础结构Properties, SubShader, Pass一个Unity Shader文件的核心骨架是由ShaderLab语言定义的。你可以把它理解为Shader的“包装器”或“元数据”它定义了材质面板如何显示、以及Shader包含哪些实际的渲染通道Pass。我们先来看一个最精简的、可调色的ShaderLab框架应该包含什么Shader Custom/MyColorShader { Properties { // 此处定义在材质面板中显示的属性 _MainColor (Main Color, Color) (1,1,1,1) } SubShader { Tags { RenderTypeOpaque } // 渲染队列和类型标签 LOD 100 // 细节级别 Pass { CGPROGRAM // 此处开始是真正的CG/HLSL代码 #pragma vertex vert #pragma fragment frag // 包含必要的工具函数和宏 #include UnityCG.cginc // ... 变量声明和函数定义 ... ENDCG } } FallBack Diffuse // 备选着色器 }我们来逐部分解析Shader Custom/MyColorShader这一行定义了Shader在材质下拉菜单中的路径。Custom/是自定义分类MyColorShader是它的名字。好的命名和分类能让你的Shader在庞大的列表中易于查找。Properties块这是Shader与美术/设计人员交互的界面。在这里声明的变量会自动显示在材质球Material的Inspector面板上。我们声明了一个_MainColor属性类型是Color在面板上显示为“Main Color”默认值是白色(1,1,1,1)RGBA。这个块是可调色功能的关键所在。SubShader块一个Shader可以包含多个SubShaderUnity会从上到下选择第一个能被当前显卡支持的SubShader来运行。这常用于为不同性能等级的硬件提供不同复杂度的渲染方案。对于我们这个简单Shader一个SubShader就够了。Tags { RenderTypeOpaque }这是一个渲染标签。Opaque告诉Unity这是一个不透明物体它会影响渲染顺序不透明物体先画半透明物体后画和一些屏幕后处理效果如深度纹理生成。LOD 100Level Of Detail的缩写。当摄像机的LOD值大于这个值时这个SubShader可能被禁用。通常用于性能优化我们暂时保持默认。Pass块这是渲染的核心。一个SubShader可以包含多个Pass渲染通道每个Pass代表一次完整的绘制调用。复杂的特效如描边填充可能需要多个Pass。我们的调色器只需要一个Pass。CGPROGRAM和ENDCG这对指令标记了其中包含的是用CG/HLSL语言编写的着色器程序。#pragma vertex vert和#pragma fragment frag这是编译指令告诉Unity我们的顶点着色器函数名叫vert片元着色器函数名叫frag。函数名可以自定义但必须通过这两条指令声明。FallBack指定一个备用的Shader。如果当前显卡不支持这个SubShader中的所有特性就会回退使用这个备用的Shader。Diffuse是Unity内置的一个简单漫反射着色器是个安全的通用选择。4. 核心代码实现连接属性、顶点与片元理解了框架我们现在来填充CGPROGRAM块内的具体逻辑。这是将颜色属性应用到模型上的关键。4.1 变量声明与数据传递结构体在CGPROGRAM内部我们需要声明变量并定义数据在着色器各个阶段间传递的结构。CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc // 声明与Properties块中同名的变量建立连接 uniform float4 _MainColor; // 定义从CPU应用阶段传递给顶点着色器的数据结构 struct appdata { float4 vertex : POSITION; // 模型空间顶点位置 // 这里可以添加其他数据如法线NORMAL、纹理坐标TEXCOORD0等 }; // 定义顶点着色器输出给片元着色器的数据结构 struct v2f { float4 pos : SV_POSITION; // 齐次裁剪空间中的位置必须 // 可以在此添加需要从顶点着色器传递给片元着色器的自定义数据 };关键点解析uniform float4 _MainColor;在CG/HLSL中我们用uniform关键字声明一个从外部CPU端即材质传入的、在整个绘制调用中保持不变的变量。float4对应一个包含RGBA四个分量的颜色值。变量名_MainColor必须与Properties块中的名字完全一致包括大小写Unity的材质系统会自动将两者绑定。appdata结构体这是顶点着色器的输入。float4 vertex : POSITION;声明了一个POSITION语义的变量意味着Unity会自动将模型的顶点坐标填充进来。: POSITION这种语法称为语义Semantics它告诉GPU这个变量的用途。v2f结构体这是顶点着色器的输出也是片元着色器的输入。float4 pos : SV_POSITION;是必须的它表示顶点在齐次裁剪空间中的最终位置GPU用它来进行光栅化。SV_POSITION中的SV代表系统值System Value。4.2 顶点着色器vert函数实现顶点着色器的主要任务是将顶点从模型空间变换到齐次裁剪空间。v2f vert (appdata v) { v2f o; // 将模型空间顶点坐标变换到齐次裁剪空间 o.pos UnityObjectToClipPos(v.vertex); return o; }关键点解析UnityObjectToClipPos这是一个Unity内置的宏/函数它封装了模型矩阵Object to World、观察矩阵World to View和投影矩阵View to Projection的连续乘法。在旧版本中你可能看到mul(UNITY_MATRIX_MVP, v.vertex)UnityObjectToClipPos是其更现代、更安全的替代品。它帮我们处理了坐标系转换中最繁琐的部分。在这个简单的例子中顶点着色器只做了坐标变换没有传递其他数据比如颜色、UV。如果我们想基于顶点位置做颜色渐变可以在这里计算一个颜色值并存入v2f结构体自定义的变量中传递给片元着色器。4.3 片元着色器frag函数实现片元着色器决定了屏幕上每个像素更准确说是片元的最终颜色。fixed4 frag (v2f i) : SV_Target { // 直接返回我们在材质面板上设置的颜色 return _MainColor; }关键点解析fixed4这是一种精度限定类型。在移动平台或对性能敏感的场景使用fixed低精度范围-2到2代替float全精度来处理颜色可以提升性能。fixed4就相当于lowp vec4in GLSL。对于简单的颜色输出用fixed4是安全且高效的。: SV_Target这是片元着色器的输出语义表示返回值将写入渲染目标通常是屏幕或纹理。return _MainColor;这是最核心的一行代码。它意味着这个着色器输出的每个像素的颜色完全由材质属性_MainColor决定。无论模型是什么形状最终都会渲染成均匀的纯色。至此一个最基础的可调色着色器就完成了。将它保存在Unity中创建一个新的材质球将Shader选择为我们刚写的Custom/MyColorShader你就能在材质面板上看到一个“Main Color”的拾色器。拖动滑块或点击色块模型的颜色就会实时变化。5. 功能扩展从纯色到更多可控性实现纯色渲染只是第一步。一个实用的基础着色器应该提供更多的控制维度。下面我们来扩展几个最常用的功能。5.1 添加纹理采样与颜色叠加纯色往往不够用我们通常需要贴图。让我们加入一张主纹理并实现纹理颜色与自定义颜色的叠加通常是乘法混合这样能保持纹理的细节。首先在Properties块中添加纹理属性Properties { _MainTex (Main Texture, 2D) white {} // 2D纹理默认白色 _MainColor (Tint Color, Color) (1,1,1,1) // 改个更贴切的名字 }然后在CGPROGRAM中声明对应的变量。对于纹理需要声明两个变量纹理本身和它的平铺偏移参数。uniform sampler2D _MainTex; uniform float4 _MainTex_ST; // ST是Scale和Tiling的缩写xy是平铺zw是偏移 uniform float4 _MainColor;接着修改appdata和v2f结构体加入纹理坐标。struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; // 第一套纹理坐标 }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; // 将UV坐标传递给片元着色器 };在顶点着色器中我们需要处理纹理的平铺和偏移。v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); // 使用TRANSFORM_TEX宏处理纹理的缩放和偏移 o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; }最后在片元着色器中采样纹理并与颜色混合。fixed4 frag (v2f i) : SV_Target { // 采样纹理在i.uv坐标处的颜色 fixed4 texColor tex2D(_MainTex, i.uv); // 将纹理颜色与色调颜色相乘乘法混合是常见的着色方式 fixed4 finalColor texColor * _MainColor; return finalColor; }现在你的材质球上会多出一个纹理贴图槽。放入一张图片再调整Tint Color就能看到纹理被染上你设置的颜色。_MainColor为白色(1,1,1,1)时显示原图设置为红色(1,0,0,1)时纹理会呈现红色调。5.2 实现透明度混合Alpha Blending默认情况下我们的Shader渲染的是不透明物体。如果要实现半透明效果如玻璃、烟雾需要启用混合Blending。这需要在Pass中添加特定的渲染状态RenderState指令。在Pass块内CGPROGRAM之前添加以下代码Pass { // 开启透明混合模式 Blend SrcAlpha OneMinusSrcAlpha // 关闭深度写入防止半透明物体相互遮挡时出现排序问题简单处理复杂场景需排序 ZWrite Off CGPROGRAM // ... 原有的CG代码 ... ENDCG }Blend SrcAlpha OneMinusSrcAlpha这是最常用的混合模式。SrcAlpha表示使用当前片元源的Alpha值作为混合因子OneMinusSrcAlpha表示使用1 - 源Alpha作为背景目标的混合因子。公式为FinalColor SrcColor * SrcAlpha DstColor * (1 - SrcAlpha)。这能实现自然的半透明叠加。ZWrite Off关闭深度写入。对于半透明物体通常不写入深度缓冲区否则位于它后面的其他半透明物体可能无法被正确渲染。更完善的方案需要对半透明物体进行从后往前的排序渲染。同时确保你的_MainColor或纹理的Alpha通道包含有效的透明度信息0为完全透明1为不透明。现在调整颜色的Alpha分量模型就能呈现半透明效果了。5.3 暴露更多控制参数饱和度与亮度更进一步我们可以加入一些简单的图像处理效果比如调节饱和度和亮度。这需要在片元着色器中进行一些颜色空间运算。首先在Properties中添加两个范围Range属性Properties { _MainTex (Main Texture, 2D) white {} _MainColor (Tint Color, Color) (1,1,1,1) _Saturation (Saturation, Range(0, 2)) 1 // 饱和度1为原图 _Brightness (Brightness, Range(0, 2)) 1 // 亮度1为原图 }在CG代码中声明对应变量uniform float _Saturation; uniform float _Brightness;然后修改片元着色器在颜色混合后加入饱和度和亮度处理fixed4 frag (v2f i) : SV_Target { fixed4 texColor tex2D(_MainTex, i.uv); fixed4 col texColor * _MainColor; // 亮度调整直接乘以系数 col.rgb * _Brightness; // 饱和度调整计算亮度值并与原色进行插值 float luminance 0.2126 * col.r 0.7152 * col.g 0.0722 * col.b; // 标准灰度公式 fixed3 luminanceColor fixed3(luminance, luminance, luminance); col.rgb lerp(luminanceColor, col.rgb, _Saturation); // lerp函数当_Saturation0时返回luminanceColor(灰度)1时返回原色1时过度饱和 return col; }原理说明亮度最简单的方式是将RGB三个通道同时乘以一个系数。大于1变亮小于1变暗。饱和度稍微复杂一点。我们首先根据一个权重公式这是ITU-R BT.709标准近似人眼对不同颜色通道的敏感度计算出颜色的亮度灰度值。然后使用lerp线性插值函数在纯灰度色和原始色之间进行混合。_Saturation为0时输出纯灰度为1时输出原始色大于1时会“过度饱和”颜色更鲜艳。现在你的材质面板上多了两个滑块可以实时调整模型的饱和度和亮度配合颜色色调能创造出非常丰富的视觉变化。6. 实战调试与性能优化要点Shader写完了能跑起来但怎么知道它效率如何有没有潜在问题下面分享几个我在实际项目中积累的调试和优化心得。6.1 使用Frame Debugger与Shader InspectorUnity提供了强大的工具来审视你的Shader。Frame Debugger (Window Analysis Frame Debugger)这是分析渲染一帧具体发生了什么的神器。开启后你可以逐步查看每个Draw Call。选中使用我们自定义Shader的Draw Call可以查看其详细的渲染状态包括使用的Shader、Pass、以及所有Uniform属性的值。这能帮你确认材质属性是否正确传递到了GPU。Shader Inspector在Project视图中点击你的Shader文件Inspector面板会显示Shader的编译信息。点击Compile and show code下拉按钮可以选择针对不同图形API如Direct3D11, OpenGL ES 3.0查看Unity最终生成的底层着色器汇编代码。这对于高级性能优化至关重要。你可以看到你的HLSL代码被编译成了多少条指令。指令数越少通常性能越好。例如复杂的数学函数如pow,sin或条件分支if可能会生成较多指令。6.2 常见问题排查速查表在编写和测试Shader时你肯定会遇到各种问题。下面是一个快速排查指南问题现象可能原因排查步骤与解决方案模型显示为洋红色MissingShader编译错误或显卡不支持。1. 检查Console窗口是否有编译错误。2. 确保Shader语法正确特别是分号和括号。3. 如果使用了高级特性如计算着色器检查图形API兼容性。颜色调节无反应Properties中的变量名与CG代码中的变量名不匹配。仔细检查Properties块和uniform声明中的变量名是否完全一致包括大小写。纹理不显示或拉伸错误顶点数据中没有提供UV坐标或UV传递错误。1. 确认appdata和v2f结构体中定义了TEXCOORD0语义的UV变量。2. 确认在顶点着色器中正确计算并传递了UV使用了TRANSFORM_TEX。3. 检查模型本身是否包含UV信息。半透明效果异常排序错乱半透明物体渲染顺序问题。1. 确保在Pass中设置了ZWrite Off。2. 尝试调整物体的渲染队列Tags { QueueTransparent }。3. 对于复杂场景可能需要手动控制半透明物体的绘制顺序从远到近。在移动设备上性能差Shader计算过于复杂或精度使用不当。1. 用Frame Debugger或Shader Inspector查看指令数。2. 将不必要的float改为half或fixed。3. 减少复杂的数学运算和纹理采样次数。4. 避免在片元着色器中使用循环或分支。6.3 针对移动平台的优化技巧如果你的项目需要发布到手机以下几点优化尤为重要精度选择尽可能使用低精度数据类型。对于颜色和UV坐标fixed和half通常就足够了。只在世界坐标、法线等需要高精度的计算中使用float。减少纹理采样纹理采样tex2D是GPU上的昂贵操作。避免在片元着色器中多次采样同一张纹理可以通过将采样结果存入变量来复用。考虑使用纹理图集Atlas来合并多张小图减少Draw Call和纹理切换。简化数学运算用mad乘加指令替代独立的乘法和加法编译器有时会自动优化。对于简单的pow(x, 2)直接用x*x更快。查表法比如用一张1D纹理存储复杂的函数值有时也是优化手段。慎用分支和循环GPU是并行处理器分支if-else和循环for会导致不同线程的执行路径不同严重降低效率。如果可能用数学函数如step,lerp或纹理查找来替代。利用顶点着色器如果有些计算在顶点之间变化平滑如简单的雾效、顶点动画尽量在顶点着色器中计算然后通过v2f结构体插值传递给片元着色器。因为顶点数量远少于片元数量这样能节省大量计算。7. 从基础到进阶下一步可以做什么完成这个可调色的基础着色器你已经掌握了Unity Shader开发的核心工作流。以此为基石你可以尝试许多有趣的扩展让效果立刻丰富起来顶点动画在vert函数中修改v.vertex的位置。比如做一个简单的波浪效果v.vertex.y sin(_Time.y * _Speed v.vertex.x * _Frequency) * _Amplitude;。记得要声明对应的属性_Speed,_Frequency,_Amplitude。边缘光Rim Light在片元着色器中根据视角方向与法线方向的点积来计算边缘强度。这需要将法线NORMAL从顶点着色器传递过来并在片元着色器中与视角向量进行运算。溶解效果采样一张噪波纹理作为溶解图将它的灰度值与一个阈值可由_Cutoff属性控制比较如果低于阈值则使用clip()函数丢弃该片元形成镂空效果。屏幕后处理将整个Shader写在一个屏幕图像处理的效果中。这时顶点着色器只需渲染一个覆盖全屏的四边形片元着色器对屏幕纹理_MainTex实际上是摄像机图像进行处理实现全局的颜色校正、模糊、Bloom等效果。我个人的体会是Shader学习是一个“动手-疑惑-查证-再动手”的循环。不要害怕修改代码和看报错。多利用Unity官方文档、社区论坛如Unity官方论坛、知乎专栏以及一些优秀的开源Shader项目在GitHub上有很多。把每次遇到的问题和解决方案记录下来积累成你自己的“避坑笔记”。最终你会发现自己不仅能实现想要的效果更能理解其背后的图形学原理从而创造出独一无二的视觉风格。