写Unity Shader这些年,要说哪个变量最不起眼却最容易出事,我一定投Vertex TexCoord一票。它不像世界坐标那么醒目,也不像法线那样拧一下马上看得出来,但贴图歪了、法线颠倒、水面流动方向反了,折腾半天查到最后往往都绕回同一个问题:纹理坐标。这篇文章就把Vertex TexCoord彻底捋一遍,从它到底是什么、怎么从模型一路传到片元着色器,到拿它能做哪些实用效果,再到最常见的坑和排查方法,争取让大家少走我当年走过的那段弯路。
如果你正准备写第一个自定义Shader,或者已经写了几个但始终对UV这套逻辑处于“半懂”状态,这篇内容应该对你有用。我尽量用大白话讲原理,实操部分的代码也尽量写得可以直接抄。
1. Vertex TexCoord是什么:先搞清楚纹理坐标从哪来
1.1 一句话理解UV
Vertex TexCoord在Unity Shader里最常见的形态是一个float2,一般命名为uv或texcoord,语义绑定是TEXCOORD0。第一个分量叫U,对应纹理的水平方向;第二个分量叫V,对应纹理的垂直方向。常规取值落在[0,1]区间内,(0,0)在纹理左下角,(1,1)在纹理右上角。GPU把顶点送到屏幕之后,片元着色器就是靠这个坐标到纹理里,把对应位置的颜色捞出来的。
我常用一个比喻解释UV:把贴图想象成包装纸,3D模型是一块被包装纸包住的糖。UV就是包装纸上印好的折痕和定位点,每个顶点都通过这一组坐标被告知“该贴在包装纸的哪个位置”。顶点本身只有三维位置信息,图案信息是UV帮它和模型建立起关联的。没了这层关联,模型就是一具灰模,纹理长得再漂亮也无处安放。
1.2 UV是建模软件里烙下的印记
很多刚接触Shader的朋友会以为UV是Unity自动生成的,其实不是。UV在建模阶段就已经定下来了。你在Blender、3ds Max或者Maya里给模型展UV,本质上就是在一张二维平面上把模型表面摊平,记录每个顶点摊平后的坐标。这个数据会作为网格的一部分写进FBX等格式,导入Unity之后挂在Mesh资产上。
所以遇到“贴图怎么换都歪”的情况,大概率是模型本身的UV有问题:展UV的时候产生了非均匀拉伸、重叠,或者共享顶点没有对齐。Shader层面对这类问题的处理能力有限,我们能做的是接收UV数据、正确利用它。想从根本上解决,得回到建模软件里重新展UV,或者让美术重新导出。这不是Shader的锅,但你在Shader里第一个背锅的往往就是它。
1.3 为什么代码里见到的是TEXCOORD0而不是uv
Unity Shader用语义(Semantic)来标识数据用途。位置用POSITION,法线用NORMAL,UV则用TEXCOORDn。TEXCOORD后面的数字表示第几套UV:TEXCOORD0对应Mesh的第一套UV,也就是Unity脚本里的mesh.uv;TEXCOORD1对应第二套,也就是mesh.uv2,以此类推。
这个命名只是图形API历史习惯的延续。UV本身是Texture Coordinate的缩写,而Vertex TexCoord是更严谨的全称叫法。写顶点着色器输入结构的时候,你声明float2 uv : TEXCOORD0,GPU就会自动把模型第一套UV数据填进来。这里有一个容易忽视的点:TEXCOORDn只是语义标签,变量名叫什么完全无所谓,叫texcoord、叫uv、叫foo都可以,真正的通道选择由冒号后面的语义决定。
2. TexCoord在Shader里的完整传递路径:从顶点到像素
2.1 顶点着色器里拿到的UV
先看一段最基础的Vertex/Fragment Shader骨架:
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert(appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag(v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); return col; }注意appdata里那个float2 uv : TEXCOORD0就是网格给顶点着色器的原始UV。在顶点阶段,它只存在于顶点上。一个三角面有三个顶点,于是这个三角形携带三份UV。顶点着色器要做的事很直白:把这份UV放进v2f结构体,作为从顶点阶段流向片元阶段的中间数据传出去。如果结构体里写的是float2 uv : TEXCOORD1,取到的就是第二套UV。很多需要光照贴图或额外顶点数据的自定义Shader,就是靠切换这个语义来完成数据源切换的。
提示:这里最常见的问题是v2f里的语义和appdata里的语义互相混用。appdata决定“从网格取哪套UV”,v2f决定“往片元阶段传哪套UV”,两者可以不同。比如你可以把模型的第一套UV取进来,做了一些 UV 运算后,再用 TEXCOORD1语义传出去,完全合法。但很多初学者把语义看反了,结果片元里拿到了一串莫名其妙的数据,排查半天才发现是语义写错了。
2.2 光栅化的UV插值:透视校正那件事
顶点着色器输出UV之后,三角形进入光栅化阶段。GPU会对三角形内部所有被覆盖到的像素做UV插值。这意味着顶点上只有三个离散的UV值,但到了片元阶段,每个像素都拿着一个根据像素在三角形内位置比例计算出来的、平滑过渡的UV值。
早期3D硬件用一种相对简单的仿射插值,没有考虑顶点深度的影响,所以比较大的平面稍微侧一点看,纹理就会在屏幕上扭曲抖动。玩过初代PlayStation的朋友,应该对那种地板上扭来扭去的贴图有印象。现代图形API对带TEXCOORD语义的插值默认做透视校正插值,插值过程中会考虑w分量,于是屏幕空间里的纹理能保持稳定的透视效果。
这个机制对我们写Shader来说是透明的,但理解它有一个实际意义:片元阶段的UV是“插值后的UV”,不是某个顶点的原始精确值。你可以把它当作平滑连续的二维坐标去用,比如做渐变、做边缘、做径向遮罩,这些操作依赖的就是插值后的连续性。反过来,如果哪天你想在片元阶段拿到某个顶点的精确原始UV,光靠普通的插值数据是做不到的,那就得另想方案了。
2.3 Surface Shader里那套uv_前缀约定
Surface Shader把大量繁琐细节藏起来了。你需要拿纹理UV时,只要在Input结构体里写一个带uv_前缀的成员就行:
struct Input { float2 uv_MainTex; }; void surf(Input IN, inout SurfaceOutputStandard o) { o.Albedo = tex2D(_MainTex, IN.uv_MainTex).rgb; }约定是成员名为“uv_”加纹理变量去掉前导下划线后的名字。比如纹理叫_DetailTex,对应成员就是float2 uv_DetailTex。第二套UV写float2 uv2_XXX,道理一样。这套命名由Surface Shader编译器自动识别,写错拼写不会报错,但那个变量会一直是0。排查时第一件事就是核对命名,我因为这个吃过不止一次亏。
Surface Shader和手写Vertex/Fragment Shader在这里有个重要区别:编译器会在生成的顶点程序里自动把_MainTex_ST的Tiling和Offset应用到Input的uv_MainTex上。所以你直接用IN.uv_MainTex去tex2D,材质面板上的Tiling、Offset是生效的。而手写的Vertex/Fragment Shader不会帮你做这一步,必须手动调用TRANSFORM_TEX,这个细节我放到下一节详细说。
3. Vertex TexCoord的典型应用场景
3.1 通用纹理采样与TRANSFORM_TEX宏
如果只是原样采样,UV不需要任何处理,tex2D(_MainTex, i.uv)直接完事。但绝大多数材质都会用到Tiling和Offset。这两个数值在Shader侧对应一个float4参数,命名规则是纹理名加_ST后缀。主纹理_MainTex对应_MainTex_ST,xy分量是Tiling的x和y,zw分量是Offset的x和y。
Unity在UnityCG.cginc里提供了TRANSFORM_TEX宏,展开之后等价于这样一行:
o.uv = i.uv * _MainTex_ST.xy + _MainTex_ST.zw;实际用法是:
o.uv = TRANSFORM_TEX(v.uv, _MainTex);这个宏做的事情是先缩放UV,再加偏移。顺序很重要,一定是先乘Tiling后加Offset。如果你手写时把顺序写反,效果就是贴图整体跟着Tiling乱跑,非常难看。另一个常见错误是采样多张纹理时全部用了_MainTex_ST,导致副纹理的Tiling永远和主纹理绑在一起,怎么调都无效。每张要支持Tiling/Offset的纹理都要声明自己的_ST,逐一转换各自的UV。
3.2 法线贴图:UV和切线空间是绑定的
法线贴图的采样同样依赖UV,但它不是简单采样一个颜色就完事。法线贴图里存储的是切线空间下的法线偏移,采样出来后还要从切线空间变换到世界空间,才能参与光照计算。这个变换需要用到顶点的切线(Tangent)、副切线(Bitangent)和法线(Normal),而切线和副切线的计算方向跟UV的走向是强相关的。
这就是为什么UV方向错误会导致法线贴图看起来左右颠倒或者光照诡异。比如一个角色的贴图在建模软件里展UV时,不小心把某一侧UV水平翻转了,那么在Shader里使用标准的UnpackNormal和内置的WorldNormalVector都救不回来,因为切线空间本身已经歪了。遇到这类现象,优先检查网格的UV方向,而不是反复调Shader代码。很多情况下,你把normal map采样代码删掉、换成纯色,光照立刻正常,那就基本可以锁定是UV/切线问题。
3.3 第二套UV:Lightmap与自定义数据通道
光照贴图(Lightmap)是烘焙全局光照最重要的数据载体之一。烘焙时Unity会把模型表面的UV展开成第二套UV,专门用于对齐光照贴图。在自定义Shader里如果想采样光照贴图,需要在顶点输入里声明TEXCOORD1语义,并使用Unity提供的一系列宏(比如UNITY_LIGHTMAP_COORDS和UNITY_TRANSFER_LIGHTMAP)来处理不同平台的差异。
第二套UV也不只用于光照贴图。很多游戏的地形、草、植被Shader会把第二套UV塞各种自定义数据:密度分布、随机种子、细节贴图混合权重等等。美术侧在DCC软件里多展一套UV,程序员在Shader里就多了一个自由度极高的数据通道。这也是为什么理解TEXCOORD0和TEXCOORD1的区别如此重要——它直接决定了你能不能拿到这些额外数据,拿错了通道,数据就全乱了。
3.4 多纹理的_ST别串号
一个材质只要用超过一张带Tiling/Offset的纹理,就容易出现_ST串号问题。我接过一个项目,主纹理正常,细节纹理的Tiling死活不生效,最后发现Shader里声明了_DetailTex_ST,但顶点函数里写的是TRANSFORM_TEX(v.uv, _MainTex)。代码和意图不一致,编译器没法报错,只能人眼一行行盯。
实操心得:我写多纹理Shader时有个习惯,变量名里带用途后缀,比如_MainTex_ST、_DetailTex_ST、_NoiseTex_ST。复制粘贴改一处漏一处的概率能低不少。另外,不打算支持Tiling/Offset的纹理可以不声明_ST,采样时直接用原始UV,减少出错面。
4. 实操:几个基于Vertex TexCoord的常用效果
4.1 水面流动:UV随时间偏移
最直接好玩的应用就是UV动画。水面、岩浆、能量流这类效果,本质上都是让UV随时间平移,再配合噪声贴图扰动。核心代码就几行:
o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.uv.x += _Time.y * _SpeedX; o.uv.y += _Time.y * _SpeedY;然后片元阶段正常采样即可。这里有件事必须注意:UV超出[0,1]之后会发生什么,完全取决于纹理的Wrap Mode。Repeat模式会把UV折回[0,1]再采样,配合可平铺纹理做无限流动没问题;Clamp模式会采样边缘像素,流动效果会变成贴图边缘被无限拉伸。做UV动画前,先确认纹理导入设置里Wrap Mode是Repeat。
如果想更自然一点,可以采样两张不同速度、不同方向的噪声图再叠加,或者用一张噪声图去偏移主纹理的UV:
float2 uv = i.uv; uv += (tex2D(_NoiseTex, i.uv * 1.5 + _Time.y * 0.1).rg - 0.5) * _Distortion; fixed4 col = tex2D(_MainTex, uv);这种做法在水面、热浪、能量护盾上很常见,核心思想就是“用噪声去扰动UV,再拿扰动后的UV去采样目标纹理”。理解这一点,比记住某段特效代码有用得多。
4.2 技能范围指示器:纯UV数学画圆形
很多游戏在施法前会在地上显示一个技能范围圈,比如一个圆形区域。如果不打算用美术画好的贴图,完全可以用UV数学来画。把一片Quad或者地面网格的UV当成一个以(0.5, 0.5)为中心的二维坐标系,距离中心越近越亮,就能得到一个圆:
float2 centered = i.uv - 0.5; float dist = length(centered); float circle = 1.0 - smoothstep(_Radius - _Softness, _Radius, dist); o.Albedo = _Color * circle;这里面有个细节很多人第一次会踩:Quad的UV范围虽然是[0,1],但如果Quad的长宽比不是1:1,在UV空间里画出来的圆,在屏幕上会变成椭圆。因此实际使用时要根据地面网格的长宽比去修正:
float2 centered = (i.uv - 0.5) * float2(_Aspect, 1.0);_Aspect由网格在世界空间的宽高比决定,简单做法是直接用Plane的Scale信息,或者传一个手动参数。这类“用UV做遮罩”的思路可以延伸出扇形范围、环形范围、扫描线等一大堆技能指示器效果,完全不用额外贴图,改起来还快。
4.3 用UV驱动顶点位移
UV还能作为顶点着色器的输入数据驱动模型变形。比如做一块随波动的草地或旗帜,顶点着色器里可以把UV的某一分量当作相位来源:
float wave = sin(v.uv.x * _Frequency + _Time.y) * v.uv.y; v.vertex.y += wave * _Amplitude;这段代码让每个顶点根据自己水平方向的UV位置做出正弦波动,同时用v.uv.y做幅度衰减,越靠近根部摆动越小。相比用世界坐标做驱动,UV驱动的好处是变形会跟着模型的表面展开方向走,模型哪怕被缩放、旋转过,效果依然相对稳定。但请记住前置条件:网格必须有足够密的顶点。三角形内部再怎么插值,顶点数量不够什么效果都出不来。一个只有4个顶点的Plane,无论如何也做不了波浪。
4.4 双面材质的背面镜像问题
做双面渲染时,很多人会把Cull Off打开,让背面也能看到。这个时候你会发现背面看到的贴图是左右镜像的,圆形技能圈的渐变方向也是反的。原因是双面渲染时,背面光栅化后用的是同一套UV。解决办法是在片元阶段判断面的朝向,然后把U轴翻转。Built-in管线里可以这样写:
fixed4 frag(v2f i, fixed facing : VFACE) : SV_Target { float2 uv = i.uv; uv.x = lerp(uv.x, 1.0 - uv.x, facing < 0); // 后续统一用 uv 采样 }Surface Shader里则在Input结构体里声明float facing : VFACE,surf函数里根据facing的正负做同样处理。这个技巧在做双面植被、透明遮罩、双面UI时非常常用,属于那种“不知道的人折腾半天,知道的人一行代码”的典型场景。
5. 常见问题与排查实录
5.1 贴图拉伸、模糊或者定位不对
先分清是“UV数据错了”还是“采样代码错了”。最快的定位方法是把UV直接输出成颜色看:把片元返回值设成fixed4(i.uv.x, i.uv.y, 0, 1)然后运行,模型表面应该出现从左下角黑色到右上角红绿混合的平滑渐变。如果渐变分布奇怪,某片区域颜色骤变或者有硬边,基本可以断定是模型UV本身的问题;如果渐变平滑但贴图仍然错乱,问题多半出在TRANSFORM_TEX或者纹理导入设置。
这个“输出UV当颜色”的技巧可以算得上Shader调试图里最便宜的一招。不需要插桩,不需要调试器,几秒钟就能看到UV的分布情况。很多片元Shader的疑难杂症,一看UV渐变图就有思路了。我强烈建议把它刻进肌肉记忆里。
5.2 UV超出[0,1]之后出现的“鬼影”
UV动画做得正开心,结果纹理边缘冒出一堆重复的条纹,或者颜色在边缘处突然变得很奇怪。这种情况几乎都是Wrap Mode没设对。Repeat会平铺,Clamp会拉伸边缘。另外,如果你采样了带mipmap的纹理,且屏幕上不同像素的UV分布差异很大,远处会出现明显的纹理闪烁。此时要检查纹理导入设置里的mipmap选项,必要时调一下Aniso Level。很多时候“远处闪得厉害”并不是Shader写错了,而是导入设置没有针对场景调优。
5.3 TEXCOORD1数据永远是空的
自定义Shader声明了float2 uv : TEXCOORD1,但采样出来的数据一直是0,或者跟TEXCOORD0一模一样。原因大概率是模型压根没有第二套UV。Unity的Mesh资产默认只有uv这一套,除非美术在DCC软件里展开过第二套,或者你用脚本调用mesh.uv2手动赋值。这个坑特别容易出现在纯程序生成的网格上,比如动态生成的地形、广告牌、粒子网格。
排查方法很简单:在Project窗口选中模型,看Mesh的导入属性,或者写一段Editor脚本打印mesh.uv2.Length是否等于顶点数。如果长度等于0,那就别指望Shader能变出第二套UV来。
5.4 不同渲染平台的V方向差异
Unity Shader内部默认使用OpenGL风格的纹理坐标约定,(0,0)在左下角。但某些平台,典型的是DirectX风格,在渲染到纹理或后处理时,V方向可能是反的。Unity内部通过UNITY_UV_STARTS_AT_TOP宏来区分这种情况。平时写普通模型Shader不太会遇到,但凡是做RenderTexture采样、图像后处理特效,或者做离线渲染相关工具,就要警惕画面上下颠倒的问题。
处理方式一般是在片元里判断宏:
#if UNITY_UV_STARTS_AT_TOP uv.y = 1.0 - uv.y; #endif这种平台差异不熟的话很容易瞎猜。我的建议是:先在编辑器里验证(通常是OpenGL风格),再打包到目标平台验证一次画面朝向。凡是涉及RenderTexture的Shader,都值得在真机上跑一遍再做最终交付。
5.5 一张速查表
| 症状 | 优先怀疑对象 | 快速验证方法 |
|---|---|---|
| 贴图错乱或拉伸 | 模型UV或Wrap Mode | 输出UV为颜色观察渐变 |
| 法线贴图左右颠倒 | 模型切线空间/UV翻转 | 换一个已知正确的模型测试 |
| Tiling不生效 | _ST声明或TRANSFORM_TEX顺序 | 检查Shader源码逐行比对 |
| 第二套UV采样为0 | 模型没有uv2 | 打印mesh.uv2.Length |
| UV动画出现条纹 | Wrap Mode不是Repeat | 检查纹理导入设置 |
| 后处理画面上下颠倒 | 平台V方向差异 | 用UNITY_UV_STARTS_AT_TOP处理 |
| 双面渲染图案镜像 | 背面用了同一套UV | VFACE翻转U轴 |
6. 调试习惯与几条扩展思路
6.1 我常用的几条UV调试习惯
第一,所有用到UV的Shader,提交前先输出一次UV可视化截图留档。哪怕当时没问题,后面调参的时候也有个参照。第二,多通道UV的命名一定带上通道号,TEXCOORD0、TEXCOORD1、TEXCOORD2在代码里写清楚注释。不然三个月后回来看,连自己都分不清哪套是光照、哪套是细节。第三,能不用额外纹理做的东西尽量用UV数学生成,比如圆环、渐变、扫描线,省贴图内存不说,调节还快。第四,遇到UV相关的问题,先在脑子里过一遍“数据从哪来、经过什么变换、最终到哪用”,八成问题出在数据源和变换之间的不匹配,而不是采样那行代码本身。
6.2 再往深走可以研究什么
如果你觉得这篇文章里这些已经不够用了,下一步可以看这几个方向:用脚本给程序化生成的Mesh写UV,比如地形LOD、河流路径;用多个UV通道做GPU驱动的自定义数据,比如顶点密度、随机种子;在URP的Shader Graph里对照观察UV节点的数据流;还有Compute Shader配合纹理做大规模UV扰动。这些都建立在对Vertex TexCoord的扎实理解之上,把这套基础打稳了,后面的路会顺很多。
Vertex TexCoord就是这么个东西,看着不起眼,却是纹理和模型之间唯一的桥梁。把它彻底搞明白,Unity Shader这条路上很多弯道都能直着过。