☰
Unity Shader入门:渲染管线与纯色Shader实战
2026/9/29 4:37:50 网站建设 项目流程

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和Material,Material引用着某个Shader并携带参数,最终GPU按照Shader里的代码把Mesh渲染出来。

新手最容易犯的错,是直接改Shader里的颜色值来"让这个物体变红"。这样做的问题是,所有用了这个Shader的材质都会跟着变。正确做法是把颜色做成Material上的参数,这样每个材质才能独立控制。这个思路转变,是理解Shader系统的第一步。

1.3 为什么第一集要从最"没用"的Shader开始

有人会问:直接学带光照、带贴图的效果Shader不好吗,为什么要先写一个只会输出纯色的、看起来毫无用处的Shader?我的答案是,纯色Shader是去掉所有干扰后的最小可运行单元。它没有光照模型、没有纹理采样、没有复杂的数学,只有最核心的骨架:顶点怎么变换,片元返回什么颜色。

把这个骨架吃透,你才知道后面加光照、加贴图、加各种效果时,新代码是"插在哪一层"的。就像学编程要先写Hello World,不是因为它有用,而是因为它把一个程序最基础的输入、处理、输出三要素都包含了。纯色Shader也一样,它包含了渲染一个物体最基础的坐标变换和颜色输出。等你把它的每一行都看明白了,后面再看复杂Shader,就只是往这个骨架上不断加零件而已。

2. 拆开Unity最简单的Shader:ShaderLab的骨架结构

抄过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可以有多个SubShader,Unity会从上往下找第一个当前显卡能跑通的来用,这个机制是为了硬件兼容——高配用复杂的,低配退回简单的。每个SubShader里可以包含一个或多个Pass,每个Pass代表一次完整的绘制。一个物体如果在场景里只画一遍,通常一个Pass就够了;但像描边、透明叠加这类需求,往往需要多个Pass叠加。

2.2 Tags那些看起来像玄学的关键字

Tags是ShaderLab里让人最一头雾水的部分,长得像{"RenderType"="Opaque"}这种东西。它的作用是给这个SubShader或Pass贴标签,告诉Unity的渲染系统"我是什么类型,该怎么对待我"。

常见的几个:RenderType用于给Shader分类,比如不透明的写Opaque,透明裁剪的写TransparentCutout,这些标签会被一些后处理或者替换Shader的操作(比如渲染深度图)用到。Queue决定渲染顺序,不透明物体默认Geometry(值2000),透明物体要用Transparent(值3000),因为它必须在不透明物体之后画,才能正确混合。IgnoreProjector、ForceNoShadowCasting这类则是针对特定功能的开关。

我的经验是,初学阶段不用把所有Tags背下来,但一定要理解Queue和RenderType这两个最常被用到的。很多"透明物体显示出问题""物体渲染顺序不对"的坑,本质上都是Queue没设对。它就像一个排队号,数字小的先画,数字大的后画,透明物体必须后画才能叠在别人身上。

2.3 CGPROGRAM到ENDCG:HLSL代码怎么嵌进去

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 { "RenderType"="Opaque" "Queue"="Geometry" } 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);,翻译过来就是R=1、G=0、B=0、A=1,纯红色且完全不透明。所以不管你给这个材质挂到哪个物体上,它都会被涂成一片红。这就是最简单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 { "RenderType"="Opaque" "Queue"="Geometry" } // 声明为不透明、几何队列 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文件放进项目,新建一个Material,Shader选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或float4,2D贴图声明成sampler2D。漏了这一步,面板上能调,但Shader里读不到值,效果永远不变,这个坑非常隐蔽,新手极容易踩。

5.3 调试Shader的几个实用手段

Shader调试和普通代码调试很不一样,没法打日志、没断点。所以实用的调试手段得自己攒。

第一个手段是用颜色输出中间结果做可视化。比如想看顶点坐标变换对不对,就把v.vertex的xy直接当颜色返回,屏幕上不同位置会呈现不同颜色,一眼就能看出坐标范围对不对。想看UV分布,就把UV当颜色输出,贴图坐标的走向立刻可见。

第二个手段是分段返回固定值排除问题。比如怀疑贴图采样有问题,就先在片元里直接返回一个固定红色,看物体能不能正常显示。能显示说明顶点阶段没问题,问题出在采样;不能显示说明前面就错了。

第三个手段是用Debug模式看渲染状态。Unity里有一些调试手段可以查看当前的渲染情况,结合Scene视图的线框显示、Gizmos,能帮你快速定位是几何问题还是着色问题。

这些手段看着土,但比对着代码干瞪眼有效得多。我个人的习惯是,每写一个新的复杂一点的效果,都会先确保前一步的中间结果是对的,再往下加,而不是一口气写完再一起调。这样出问题时,范围永远只有一小段。

把这三步练熟——贴图、属性面板、调试手法——你就已经跨过了"只会抄Shader"的阶段,进入了"能改、能调、能自己加东西"的状态。后面再学光照模型、法线贴图、顶点动画这些进阶内容,都只是在今天这套骨架上不断加零件而已。我在实际写Shader的时候有个体会:真正难的不是记住某个效果怎么实现,而是每次遇到新需求,能不能快速判断出"该在哪一层动手"。第一集讲到的这个最小骨架,就是帮你建立这种判断力的地基,地基打得越扎实,后面加的东西越不会乱。

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

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

立即咨询