
1. 先搞清楚Shader在Unity渲染管线里到底站在哪个位置刚接触Unity Shader的人十有八九会卡在同一个地方代码能复制能跑但完全不知道自己写的东西在整个渲染流程里干了什么。我见过太多人收藏了一堆Unity Shader入门的文章结果换个需求还是不会改。问题不在代码本身而在于没有建立一张地图——不知道Shader站在管线的哪个环节自然就不知道该怎么动它。这篇是基础知识系列的第一集目标很明确把Unity里最简单的一个Shader从里到外讲透让你看完之后能自己从零写出来而不是抄出来。1.1 一个模型是怎么变成屏幕上那堆像素的先说清楚一件事你在场景里看到的那个立方体、那个角色本质上只是一堆顶点坐标和一堆三角形索引。显卡并不知道它长什么样它只认顶点数据。那么从一堆浮点数到屏幕上的彩色像素中间发生了一连串的运算这一连串运算的流水线就是渲染管线。管线的核心环节大致是这样走的顶点数据先进顶点着色器在这里每个顶点被变换到裁剪空间接着是图元装配把顶点组成三角形再到光栅化把三角形打散成一个个待处理的像素更准确说是片元然后进片元着色器决定每个片元最终是什么颜色最后经过深度测试、混合等步骤写进帧缓冲也就是你屏幕上看到的东西。关键点来了在可编程管线里顶点着色器和片元着色器这两段是你能自己写代码控制的其余环节基本由硬件和Unity固定处理。所以当我们说写一个Shader绝大多数情况下就是在写这两段函数。理解这一点后面所有代码的结构就顺了——你写的Shader无非就是告诉显卡顶点怎么摆像素怎么涂。1.2 Shader、Material、Renderer这三者的关系我刚开始学的时候最迷惑的就是这几个词Shader、Material材质、Renderer渲染器、Mesh网格。它们经常一起出现但职责完全不同。用做菜打个比方会清楚很多。Mesh是食材它是原始的几何数据就是那些顶点和三角形。Shader是菜谱它规定了怎么处理这些食材——顶点怎么变换颜色怎么算。但菜谱本身是做不出菜的你得有一份具体的、配好料的实例这就是Material。Material本质上就是一个Shader的实例再加上一堆参数值比如颜色、贴图、金属度。同一个Shader可以生成无数个Material就像同一份菜谱可以做出无数盘菜只是每盘放的调料不同。至于Renderer它是厨师负责把食材Mesh和菜谱Material结合起来在合适的时机交给显卡去做。你在Inspector面板上挂给物体的那个MeshRenderer组件就是它。所以一条完整的链路是Renderer拿着Mesh和MaterialMaterial引用着某个Shader并携带参数最终GPU按照Shader里的代码把Mesh渲染出来。新手最容易犯的错是直接改Shader里的颜色值来让这个物体变红。这样做的问题是所有用了这个Shader的材质都会跟着变。正确做法是把颜色做成Material上的参数这样每个材质才能独立控制。这个思路转变是理解Shader系统的第一步。1.3 为什么第一集要从最没用的Shader开始有人会问直接学带光照、带贴图的效果Shader不好吗为什么要先写一个只会输出纯色的、看起来毫无用处的Shader我的答案是纯色Shader是去掉所有干扰后的最小可运行单元。它没有光照模型、没有纹理采样、没有复杂的数学只有最核心的骨架顶点怎么变换片元返回什么颜色。把这个骨架吃透你才知道后面加光照、加贴图、加各种效果时新代码是插在哪一层的。就像学编程要先写Hello World不是因为它有用而是因为它把一个程序最基础的输入、处理、输出三要素都包含了。纯色Shader也一样它包含了渲染一个物体最基础的坐标变换和颜色输出。等你把它的每一行都看明白了后面再看复杂Shader就只是往这个骨架上不断加零件而已。2. 拆开Unity最简单的ShaderShaderLab的骨架结构抄过Shader的人应该都有印象Unity的Shader代码看起来跟普通的C#或者C完全不一样外面套着一层类似JSON的奇怪结构里面又嵌着一段像C的代码。这层外壳叫ShaderLab是Unity自己定义的一门声明式语言用来描述Shader的整体配置里面那段才是真正跑在GPU上的代码通常是HLSL。搞不清这两层的分工是很多人读Shader代码读不下去的根本原因。2.1 Shader块、Properties、SubShader、Pass的分工一个标准的Unity Shader最外层是Shader 名字 { ... }这个名字就是你在Material面板上选择Shader时看到的那一串比如Custom/BasicColor。名字里的斜杠表示分类层级纯粹为了在面板里归类不影响功能。再往里通常有Properties块用来声明暴露在Material面板上的参数比如颜色、数值、贴图。注意Properties里声明的只是界面入口真正在GPU代码里用的变量还要在后面的CG/HLSL代码块里重新声明一遍。这个声明两遍的设计经常让新手困惑其实思路是Properties只管UI暴露HLSL里的变量管实际运算两者靠名字对应。然后是SubShader这是核心。一个Shader可以有多个SubShaderUnity会从上往下找第一个当前显卡能跑通的来用这个机制是为了硬件兼容——高配用复杂的低配退回简单的。每个SubShader里可以包含一个或多个Pass每个Pass代表一次完整的绘制。一个物体如果在场景里只画一遍通常一个Pass就够了但像描边、透明叠加这类需求往往需要多个Pass叠加。2.2 Tags那些看起来像玄学的关键字Tags是ShaderLab里让人最一头雾水的部分长得像{RenderTypeOpaque}这种东西。它的作用是给这个SubShader或Pass贴标签告诉Unity的渲染系统我是什么类型该怎么对待我。常见的几个RenderType用于给Shader分类比如不透明的写Opaque透明裁剪的写TransparentCutout这些标签会被一些后处理或者替换Shader的操作比如渲染深度图用到。Queue决定渲染顺序不透明物体默认Geometry值2000透明物体要用Transparent值3000因为它必须在不透明物体之后画才能正确混合。IgnoreProjector、ForceNoShadowCasting这类则是针对特定功能的开关。我的经验是初学阶段不用把所有Tags背下来但一定要理解Queue和RenderType这两个最常被用到的。很多透明物体显示出问题物体渲染顺序不对的坑本质上都是Queue没设对。它就像一个排队号数字小的先画数字大的后画透明物体必须后画才能叠在别人身上。2.3 CGPROGRAM到ENDCGHLSL代码怎么嵌进去Pass里面最重要的就是那段被CGPROGRAM和ENDCG夹起来的代码。这里写的是真正的GPU程序用的是HLSL语法Unity早期叫Cg现在基本统一到HLSL。它的结构是固定的先用#pragma指明哪个函数是顶点着色器哪个是片元着色器然后声明用到的数据结构最后实现这两个函数。#pragma vertex vert这句话的意思是顶点着色器函数名叫vert#pragma fragment frag同理。名字随便起但要和后面定义的函数名一致。这两个pragma是必须的缺了任何一个Unity都会编译报错。在GPU代码里你会看到变量声明前面常带float4、fixed4、half4这些类型。简单解释一下float是32位浮点精度最高但最慢half是16位半精度速度快做颜色计算够用fixed是11位定点精度最低一般只用来存颜色值。它们在移动平台上的性能差异比较明显桌面平台基本无感。初学阶段用fixed4存颜色、float4存坐标是一个不错的习惯。3. 手写第一个纯色Shader从顶点到片元的完整链路前面把骨架讲完了现在来真正动手写。下面这个就是Unity里最简单的、能跑的Shader之一它做的事情只有一件把一个物体渲染成纯色。代码不长但每一行都有讲究我把完整代码贴出来再逐块拆解。Shader Custom/BasicColor { SubShader { Tags { RenderTypeOpaque QueueGeometry } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); return o; } fixed4 frag (v2f i) : SV_Target { return fixed4(1, 0, 0, 1); } ENDCG } } }3.1 顶点着色器到底在算什么vert函数就是顶点着色器它会被每个顶点执行一次。注意这句话的分量——一个模型有多少顶点它就执行多少次而且是并行的。函数接收一个appdata结构的输入里面装的通常是顶点位置、法线、UV这些数据返回一个v2f结构vertex to fragment的缩写里面是要传给片元着色器的数据。看函数体只有一行关键代码o.pos UnityObjectToClipPos(v.vertex);。这里的UnityObjectToClipPos是Unity提供的内置函数作用是把顶点从模型空间变换到裁剪空间。为什么必须做这个变换因为你在建模软件里定义的顶点坐标是相对于模型自身原点的模型空间但显卡最终需要的是裁剪空间下的坐标才能正确投影到屏幕上。这个变换过程包含了模型矩阵、视图矩阵、投影矩阵的连乘Unity用一个函数帮你封装好了。初学阶段记住一点就够了顶点着色器的核心任务是把顶点摆到正确的位置。如果省略这一步或者传错了坐标物体要么不显示要么位置、大小完全错乱。这是后面学光照之前必须扎实掌握的基础。3.2 片元着色器一个像素的最终颜色谁说了算frag函数是片元着色器它被每个片元执行一次。片元可以粗略理解成可能成为像素的点经过后续测试后才可能真正落到屏幕上。它接收顶点着色器传过来的v2f这里虽然没用上任何数据返回一个fixed4也就是RGBA四个分量。代码里直接return fixed4(1, 0, 0, 1);翻译过来就是R1、G0、B0、A1纯红色且完全不透明。所以不管你给这个材质挂到哪个物体上它都会被涂成一片红。这就是最简单Shader的全部功能——它连光照都不管纯粹输出一个固定颜色。紧接着函数后面的: SV_Target是语义声明告诉编译器这个返回值要写进最终的渲染目标也就是屏幕颜色缓冲。没有这个语义编译器不知道你把颜色输出到哪会直接报错。类似地前一个例子里的: POSITION、: SV_POSITION也是语义分别表示这是模型空间顶点坐标和这是裁剪空间位置。3.3 语义绑定冒号后面那些大写字母的作用语义Semantics是HLSL里非常重要的概念也是初学者最容易忽略的地方。它紧跟在变量或函数返回值的冒号后面作用是告诉GPU这个数据是什么用途从哪来到哪去。顶点着色器的输入结构appdata里float4 vertex : POSITION的意思是这个变量接收模型顶点的位置语义是POSITION。片元着色器输入里的SV_POSITION表示这是已经变换好的屏幕空间位置。返回值的SV_Target表示写进颜色缓冲。带SV_前缀的是系统值语义由管线自动填充你不应该手动赋值给它们。一个常见的坑顶点着色器输出结构里必须有SV_POSITION语义的成员否则编译直接失败因为它要靠这个确定片元落在屏幕哪里。这个规则不像Properties那样可以省略它是硬性要求。我一开始就是因为漏了它卡了半小时找不到错在哪最后发现是结构体里少写了一个字段。3.4 完整代码与逐行注释把上面的代码配上注释再看一遍会清晰很多Shader Custom/BasicColor // Shader在面板上的路径名 { SubShader { Tags { RenderTypeOpaque QueueGeometry } // 声明为不透明、几何队列 Pass // 一个Pass一次绘制 { CGPROGRAM // GPU代码块开始 #pragma vertex vert // 指定顶点着色器为vert #pragma fragment frag // 指定片元着色器为frag struct appdata // 顶点输入数据 { float4 vertex : POSITION; // 从模型拿顶点坐标 }; struct v2f // 顶点传给片元的数据 { float4 pos : SV_POSITION; // 裁剪空间位置必须 }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); // 模型空间 - 裁剪空间 return o; } fixed4 frag (v2f i) : SV_Target { return fixed4(1, 0, 0, 1); // 输出纯红不透明 } ENDCG // GPU代码块结束 } } }把这段代码存成.shader文件放进项目新建一个MaterialShader选Custom/BasicColor再挂到任意物体上你应该会看到一个纯红的模型。这就是你的第一个能跑通的Shader。看着简单但它是后面所有复杂效果的起点。4. 实测中的差异与意外同一段代码为什么表现不一样代码跑通只是第一步。真正上手之后你会发现同样的代码在不同环境下表现完全不一样甚至直接报错。这些意外其实都有明确的成因搞懂了能省下大量瞎试的时间。这一节把我自己踩过的几个典型坑拎出来讲。4.1 内置管线和URP之间那道墙现在 Unity 的项目分两大类老的内置渲染管线Built-in和新的通用渲染管线URP。上面那段代码是写在内置管线下的如果你把它直接丢进一个URP项目大概率会得到一个粉红色洋红的物体或者干脆不显示。粉色是Unity的错误颜色专门用来提示这个Shader在当前管线下跑不了。原因是URP重新组织了渲染流程Shader的基础库、光照模型、以及很多内置函数都换了。要在URP里写Shader通常得用URP提供的Shader库并且按照它的结构组织代码。解决办法不是硬改内置管线的代码而是先确认你的项目用的是什么管线再用对应的写法。判断方法很简单看Package Manager里有没有装Universal RP或者看Project Settings里Graphics下有没有设置管线资源。我建议新手入门先在内置管线环境里学因为它的Shader结构最纯粹把基础打通了再迁移到URP理解成本会低很多。反过来从URP直接入门很多概念会被库函数的封装包住反而看不清底层。4.2 颜色空间Gamma还是Linear另一个经典表现差异是颜色对不上。你在Shader里写了fixed4(0.5, 0.5, 0.5, 1)满心期待得到中灰色结果在某些项目里看起来偏亮或偏暗。这多半是颜色空间在作怪。Unity的Player Settings里有个Color Space选项可以设成Gamma或Linear。Gamma空间下颜色不做额外转换Linear空间下纹理和颜色会在采样时被转换到线性空间参与计算最后再转回显示空间。这个差异直接影响你在Shader里手写的颜色值最终呈现出来的样子。桌面平台现在普遍推荐Linear移动平台则要看性能取舍。实际调试时如果发现颜色明明设对了却总是不对劲第一件事就是去确认Color Space设置而不是怀疑代码写错。我因为这个问题绕了很久最后发现只是项目设成了Linear而我按Gamma的直觉在调颜色。4.3 编译报错排查的常见套路Shader编译报错和C#报错不一样它不会给你精确到某一行的提示有时候只说一句Shader error in ...然后一堆你根本看不懂的编译信息。这时候按下面这个顺序排查命中率很高。第一检查#pragma vertex和#pragma fragment指定的函数名是否和实际函数名一致拼写、大小写都要对。第二检查结构体里该有的SV_POSITION语义有没有漏。第三检查是不是在URP项目里用了内置管线的写法。第四检查大括号是否配对ShaderLab对括号非常敏感少一个}就会牵连出一大片错误。第五如果用了Unity内置函数或宏确认有没有引入对应的库文件比如#include UnityCG.cginc。提示报错信息里如果出现undeclared identifier基本可以确定是变量没声明或者是内置库没引入如果出现unrecognized identifier多半是函数名或者沙盒宏写错了。5. 从纯色Shader继续往下走下一步该练什么纯色Shader能跑通之后很多人会陷入一个瓶颈——知道骨架了但不知道该往哪加东西。方向其实很明确就是沿着顶点给你一点数据、片元算得再复杂一点这条线往上走。下面三个方向是按难度排的练完基本就能看懂大部分入门级的Shader了。5.1 挂上一张贴图纹理采样的标准套路纯色是最无聊的输出第一个自然的升级就是采样一张贴图。做法是在appdata里增加一个UV坐标语义是TEXCOORD0在v2f里也把UV传下去然后在片元着色器里用tex2D(_MainTex, i.uv)采样贴图把采样结果乘上颜色返回。UV是什么可以理解成这张贴图上的坐标。模型表面每个顶点都对应着贴图上的一个位置U和V两个方向的坐标通过插值每个片元都能算出一个贴图坐标从而取到对应的颜色。这一步是后面所有材质效果的基础——什么法线贴图、光照贴图本质都是在采样贴图。新手要注意的是贴图变量得先声明成sampler2D类型而且要和Properties里声明的名字对应。采样函数用tex2D记住它的参数是贴图采样器 UV坐标就行。做完这一步你的Shader就能给模型贴花纹了。5.2 把参数暴露到面板Properties的正确写法下一步是让Shader可调。方法是加Properties块把颜色、贴图、数值暴露到Material面板上。比如加一个_Color(Color, Color) (1,1,1,1)就能在面板上通过色板调颜色。注意Properties里声明的类型Color、Range、2D、Float等决定了面板上显示成什么控件。但记住前面说的坑Properties里声明的变量一定要在CG代码块里用相同的名字重新声明一次类型要对得上。Color属性在代码里通常声明成fixed4或float42D贴图声明成sampler2D。漏了这一步面板上能调但Shader里读不到值效果永远不变这个坑非常隐蔽新手极容易踩。5.3 调试Shader的几个实用手段Shader调试和普通代码调试很不一样没法打日志、没断点。所以实用的调试手段得自己攒。第一个手段是用颜色输出中间结果做可视化。比如想看顶点坐标变换对不对就把v.vertex的xy直接当颜色返回屏幕上不同位置会呈现不同颜色一眼就能看出坐标范围对不对。想看UV分布就把UV当颜色输出贴图坐标的走向立刻可见。第二个手段是分段返回固定值排除问题。比如怀疑贴图采样有问题就先在片元里直接返回一个固定红色看物体能不能正常显示。能显示说明顶点阶段没问题问题出在采样不能显示说明前面就错了。第三个手段是用Debug模式看渲染状态。Unity里有一些调试手段可以查看当前的渲染情况结合Scene视图的线框显示、Gizmos能帮你快速定位是几何问题还是着色问题。这些手段看着土但比对着代码干瞪眼有效得多。我个人的习惯是每写一个新的复杂一点的效果都会先确保前一步的中间结果是对的再往下加而不是一口气写完再一起调。这样出问题时范围永远只有一小段。把这三步练熟——贴图、属性面板、调试手法——你就已经跨过了只会抄Shader的阶段进入了能改、能调、能自己加东西的状态。后面再学光照模型、法线贴图、顶点动画这些进阶内容都只是在今天这套骨架上不断加零件而已。我在实际写Shader的时候有个体会真正难的不是记住某个效果怎么实现而是每次遇到新需求能不能快速判断出该在哪一层动手。第一集讲到的这个最小骨架就是帮你建立这种判断力的地基地基打得越扎实后面加的东西越不会乱。