FEATURED · 精选文章

UV坐标与Tiling Offset:Unity Shader纹理平铺深度解析

发布时间 / 2026/9/9 23:45:50
来源 / 创域科博编辑部
栏目 / 资讯中心
UV坐标与Tiling  Offset:Unity Shader纹理平铺深度解析 1. 从UV坐标到Tiling Offset先搞懂你操作的是什么做了这么多年的Unity Shader我遇到过不少美术同学拿着材质球问为什么这个纹理重复了这么多为什么这个贴图位置老是偏的问题基本都出在UV坐标和Tiling Offset这两件事上。UV坐标其实就是模型表面上每个顶点对应纹理图片的位置信息你可以把它理解成一张地图坐标。模型给你一个平面U方向代表横向V方向代表纵向取值通常落在0到1之间。也就是说不管这张纹理实际分辨率是1024还是2048在UV空间里它都是一个标准的1x1正方形区域。采样纹理的时候GPU就拿着这个坐标去纹理图里取颜色。Tiling和Offset就是基于这套UV坐标做的两个变换。Tiling控制纹理在表面上重复几次Offset控制纹理沿U和V方向偏移多少。很多刚学Shader的人只会在材质面板里拖这两个值却不知道它们在Shader代码里到底经历了什么运算。等你需要写自定义Shader、做纹理滚动、做UV动画的时候如果不把这里的门道吃透很容易掉进各种奇怪的坑里。这篇文章我就从底层运算逻辑开始把UV坐标和Tiling Offset之间的关系彻底讲清楚。然后我会带着你手写一个Shader从代码层面复现这两个参数的作用再聊一些实际项目里常见的应用场景和坑。不管你是刚接触Shader的新手还是写过一段时间却对这块概念模糊的人这篇文章都值得你花20分钟看完。2. Tiling Offset的底层逻辑GPU采样前的那点数学2.1 _MainTex_ST 这个后缀到底是什么意思Unity内置的Shader中我们经常看到类似这样的声明fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); return col; }这个写法其实已经是Unity帮我们处理过Tiling和Offset的版本。真正完整的采样代码需要配合_MainTex_ST这个变量。_ST是UnityShader中约定俗成的后缀它代表着这个纹理的平铺Tiling和偏移Offset四元组。为什么叫_STS代表Scale也就是缩放对应TilingT代表Translation也就是平移对应Offset。这个词源如果记不住你就想想Transform里面的Scale和Translation就行。在顶点着色器里我们一般会写这样一行o.uv TRANSFORM_TEX(v.uv, _MainTex);这个TRANSFORM_TEX宏展开之后就是o.uv v.uv * _MainTex_ST.xy _MainTex_ST.zw;注意这里的.xy取了Tiling值.zw取了Offset值。也就是说Shader对UV坐标做的事情很简单先乘上Tiling再加上Offset。GPU拿到这个变换后的坐标再去采样纹理这就实现了平铺和偏移的效果。2.2 一张图看懂先缩放后平移的运算顺序假设我们有个平面模型顶点的UV坐标是(0,0)到(1,1)。如果Tiling是(2,2)Offset是(0,0)那么变换后的UV范围就变成了(0,0)到(2,2)。在这个范围下纹理被采样了两次所以你看到的效果就是纹理在U和V方向各重复了两次。如果Tiling是(1,1)Offset是(0.5,0)那么变换后的UV范围整体向右平移了0.5。采样到的纹理不再是纹理的左半部分而是从纹理中间开始的区域。实际效果像是把一张纹理从左侧推出去了一半另外半边则用纹理右侧的数据来补充。很多人容易忽略一个关键点Unity是先缩放再平移。数学上这个变换可以写作finalUV uv * Tiling Offset展开成矩阵就是| finalU | | Tiling.x 0 | | uvU | | Offset.x | | finalV | | 0 Tiling.y | * | uvV | | Offset.y |为什么顺序重要因为乘法和加法不满足交换律。如果你先平移再缩放那么Offset也会被Tiling放大最终得到的偏移量就不是面板上设置的那个值了。Unity选择先缩放再平移的原因也很直观平铺是纹理在空间里的频率属性偏移是相位属性它们本来就是独立的两个维度。你先定好重复几次再决定从哪里开始采样这样美术同学调整Offset的时候不会因为Tiling值不同而陷入为什么偏移量忽大忽小的困惑。2.3 纹理的Wrap ModeTiling大于1后会发生什么有了变换后的UV坐标GPU就要根据这个坐标去纹理里取颜色。这里有一个关键问题当UV坐标超过1.0时GPU应该怎么取值比如Tiling设置成4变换后UV到了4.0此时GPU读取哪个像素这个行为由纹理的Wrap Mode决定。在Unity的纹理导入面板里Wrap Mode有Repeat、Clamp、Mirror、Mirror Once这几个选项。最常见的是Repeat也就是重复模式。在这个模式下GPU会对UV坐标取小数部分4.5就当作0.5来处理于是纹理不断重复拼接。Clamp模式则会把超过1.0的坐标全部钳制在1.0也就是说纹理边缘的像素会被拉伸通常用于不需要重复的大面积背景或UI元素。这里有个小坑如果你是程序化生成纹理或者用了RenderTexture动态渲染纹理Wrap Mode默认可能是Clamp。当你在材质里把Tiling调大发现纹理没有重复而是被拉伸糊掉了多半就是Wrap Mode的问题。排查的时候别盯着Shader代码找半天先看一眼纹理导入设置。// 直接采样时实际上会经过纹理的Wrap Mode处理 // 比如UV为2.3Repeat模式下相当于采样0.3的位置 float2 uvRepeat frac(i.uv); // 手动实现Repeat效果frac函数就能手动模拟Repeat模式。这个概念后文涉及自定义UV动画时还会用到。3. 手写一个Shader把Tiling Offset从面板到代码彻底复现3.1 从零搭一个带Tiling Offset的纹理采样Shader我先来写一个最基础的、完整包含Tiling Offset处理的Shader。建议你新建一个Shader文件跟着敲一遍。Shader Custom/TilingOffsetDemo { Properties { _MainTex (Main Texture, 2D) white {} _TilingX (Tiling X, Range(0.1, 10)) 1 _TilingY (Tiling Y, Range(0.1, 10)) 1 _OffsetX (Offset X, Range(-2, 2)) 0 _OffsetY (Offset Y, Range(-2, 2)) 0 } SubShader { Tags { RenderTypeOpaque } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; float _TilingX; float _TilingY; float _OffsetX; float _OffsetY; 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); // 核心手动实现 Tiling Offset float2 tiling float2(_TilingX, _TilingY); float2 offset float2(_OffsetX, _OffsetY); o.uv v.uv * tiling offset; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); return col; } ENDCG } } Fallback Off }把这个Shader赋给一个带标准纹理的材质球再把材质球拖到场景里一个平面上你就能在Inspector里通过拖动Tiling X/Y和Offset X/Y来实时观察变化。3.2 Transform Tex 宏与手动实现到底有没有区别有同学会问你已经声明了_MainTex_ST为什么不直接用TRANSFORM_TEX用是肯定能用的。我上面这个写法相当于把TRANSFORM_TEX拆开手动写了一遍目的是让你看清它内部到底做了什么。当你理解了变换逻辑再回过去用内置宏就会明白那些看似黑魔法的代码其实非常简单。用宏的版本是这样o.uv TRANSFORM_TEX(v.uv, _MainTex);需要注意一点TRANSFORM_TEX宏读取的是纹理属性名后面加_ST的变量。也就是说如果你的纹理属性叫_NoiseTex那么对应的ST变量必须声明为float4 _NoiseTex_ST。很多新手容易在这里栽跟头——声明了纹理却不声明_ST变量编译直接报错或者说采样结果一直不对。这是UnityShader里一个约定优于配置的设计没有什么神奇的机制纯粹是命名规范。我在实际项目中如果只是写一个简单的纹理采样Shader通常还是会用TRANSFORM_TEX。但如果要做UV动画、顶点偏移、程序化UV重映射这类复杂操作我会选择手动拆分ST变量因为这样你能在中间插入任意计算步骤灵活性高得多。3.3 顶点着色器里做还是片元着色器里做UV变换到底应该放在顶点着色器还是片元着色器这是一个很值得说的工程选择。放在顶点着色器vert里每个顶点只需要计算一次UV变换性能开销极小而且对于大多数模型来说顶点数远少于屏幕像素数。放在片元着色器frag里则每个像素都要重新计算一遍UV虽然现代GPU的ALU算力很强这点开销通常可以忽略但在移动端做大规模绘制时能省的尽量省。那么有没有必须放在片元着色器的场景有比如你的UV变换依赖像素位置、程序化噪声、或者需要逐像素做扭曲变形时顶点着色器算出来的UV经过插值后可能会显得不够光滑。但Tiling Offset本身是线性变换放在顶点着色器里经过GPU的插值之后每个像素拿到的UV依然是精确的。所以做Tiling Offset这类全局线性变换时放顶点着色器就好别浪费片元着色器的算力。我自己在移动端项目里做过一个基准测试同样的场景100个材质球UV变换从顶点着色器挪到片元着色器后安卓中端机的GPU耗时大约增加了0.3毫秒左右。看起来不多但如果你的项目里同时有几十个纹理采样Shader这个差距就会被放大。做性能优化时这种不起眼的细节才是拉开差距的地方。4. 实际项目里Tiling Offset的高级玩法4.1 地表纹理混合用Tiling解决放大就糊的难题做过大地形的人都知道地表纹理最头疼的问题就是近看糊远看也糊。近看糊是因为纹素密度不够远看糊是因为纹理重复次数太多产生摩尔纹。单纯把纹理分辨率调高只能缓解近看的问题解决不了远看的重复感。这时候Tiling就派上用场了对同一张贴图做两套不同的Tiling值一套小Tiling负责远景的大尺度覆盖一套大Tiling负责近景细节然后在中间做高度混合或噪声混合。具体做法是在片元着色器里对纹理采样两次再用噪声或高度图计算混合权重。// 近景大Tiling保留细节 float2 uvNear i.uv * _TilingNear; // 远景小Tiling避免远看密密麻麻的重复 float2 uvFar i.uv * _TilingFar; fixed4 colNear tex2D(_MainTex, uvNear); fixed4 colFar tex2D(_MainTex, uvFar); // 用噪声或距离信息混合 half blend saturate(distance(i.worldPos.xz, _WorldSpaceCameraPos.xz) / _BlendDistance); fixed4 finalColor lerp(colNear, colFar, blend);这个方案的关键在于_TilingNear和_TilingFar的配比。我一般建议近景Tiling在4到8之间远景Tiling在1到2之间具体数值还要参照地形尺度。太小的话混合过渡会显得很突兀太大的话近景的纹理重复感又会露馅。4.2 传送带与流动水面Offset动画的工程实践Tiling Offset里最容易做出效果的就是Offset动画了。把一个方向上的Offset按照时间递增纹理就会产生朝某个方向流动的视觉效果。这个技巧可以用于传送带、河流、能量管道、飞行动画等多种场景。float2 uv i.uv; // uv.x 方向随时间流动 uv.x _Time.y * _FlowSpeed; fixed4 col tex2D(_MainTex, uv);这里有一个坑_Time.y是Unity内置的时间变量单位是秒。如果你的_FlowSpeed数值很大容易让纹理看起来像在跳帧实际上这是因为纹理重复的周期太短导致视觉上出现了不连续。我建议把_FlowSpeed控制在0.1到2.0之间必要的时候再叠加一个正弦波动来模拟水流的不规则性。更高级一点的用法是让U和V方向的流速不同。比如水面流淌的时候U方向流速很慢V方向慢得更厉害这样整体看起来就非常自然。我自己在做河流效果的时候习惯把流速拆成两个方向float2 uv i.uv; uv.x _Time.y * _FlowSpeedU; uv.y _Time.y * _FlowSpeedV * 0.3; // V 方向衰减避免漂移感不要小看这个细节。很多新手做出来的流动效果看起来假就是因为所有方向流速一致像是一张贴图被人硬拖着走。真实水面是有内部剪切力的各方向流速天然不同。4.3 UV重映射与平铺组合把一张图变成多张图还有一种玩法是把同一张纹理切成多个区域通过修改UV来采样不同区域。这类技术在纹理图集Texture Atlas和Sprite动画里非常常见。核心思路就是利用Tiling和Offset把UV坐标从0~1范围映射到纹理的任意小格子中。假设一张纹理图集是4x4的格子每格是一个动画帧。要采样第r行第c列的格子可以这样计算float2 frameUV i.uv * _TileSize _TileOffset; // _TileSize 1/4 0.25, 缩放UV到单个格子大小 // _TileOffset (c * 0.25, r * 0.25), 便宜到目标格子这里本质上还是在做Tiling和Offset的乘法加法只不过数值被精确计算成了格子尺寸。如果你希望后续做帧动画只需要在C#脚本里根据帧号更新_TileOffset就能实现序列帧播放。很多初学Shader的人会把这种效果想得很复杂其实背后的数学就是本文开头讲的那个四元组变换。4.4 三平面映射让地形和墙体告别UV拉伸还有一个场景和Tiling的关系非常密切——三平面映射Triplanar Mapping。当你把一个纹理贴到地形或复杂形状的模型上时传统UV经常会在曲面或陡坡处产生拉伸。三平面映射的思想是分别从X、Y、Z三个轴方向对世界坐标做投影采样然后按法线方向混合三个采样结果。// 用世界坐标的各个分量作为UV fixed4 colX tex2D(_MainTex, i.worldPos.zy * _Tiling); fixed4 colY tex2D(_MainTex, i.worldPos.xz * _Tiling); fixed4 colZ tex2D(_MainTex, i.worldPos.xy * _Tiling); // 按法线绝对值做混合权重 half3 blend pow(abs(i.worldNormal), _BlendPower); blend / (blend.x blend.y blend.z); fixed4 finalColor colX * blend.x colY * blend.y colZ * blend.z;这里_Tiling控制了纹理在真实世界尺度中的重复频率。比如你把_Tiling设为0.1那么每10个世界单位纹理重复一次具体的值取决于你的纹理覆盖的面积。三平面映射的缺点是要采样三次纹理GPU开销大约是普通采样的三倍但换来的是完全不需要UV坐标也能获得无拉伸贴图在悬崖、洞穴、程序化地形上非常值。5. 纹理平铺中那些防不胜防的坑与排查思路5.1 纹理拉伸模糊不是Tiling的问题是Wrap Mode的问题我遇到过不止一次这样的情况美术给一张草地的纹理设置Tiling为4结果地面没出现重复的草而是整张贴图被拉得老长边缘糊成一团。排查了一圈最后发现纹理的Wrap Mode被设成了Clamp而不是Repeat。记住这一步排查顺序在Project窗口选中纹理。查看Inspector面板里的Wrap Mode。如果是Clamp改成Repeat点击Apply。同一张贴图可能因为被多个材质引用而希望用不同的Wrap Mode这时就不适合在纹理导入设置里改全局模式了。你可以在Shader里手动用frac对UV取小数部分这样即便Wrap Mode是Clamp也能模拟Repeat采样。或者使用tex2D之后手动做边缘融合当然这种变通方式精度和性能都不如直接改纹理导入设置来得干净。5.2 Tiling过小时的坐标精度问题Tiling值小于1时你可能觉得既然Tiling代表重复次数小于1不就是纹理被拉伸吗确实如此但你还要考虑坐标精度的问题。在Shader中UV坐标通常以float2存储在顶点着色器到片元着色器的插值过程中精度通常是float在移动端的某些GPU上可能被降为half。如果Tiling值极小比如0.01UV坐标经过缩放后会变得非常大此时低精度插值带来的误差就会在屏幕上表现为纹理抖动或闪烁。这个问题的解决办法是改用更高精度的变量类型比如float2代替half2或者避免让UV坐标跨越非常大范围的Tiling变换。在写移动端Shader时我习惯把UV相关的插值变量声明为float2虽然理论上half2可以减少带宽消耗但由精度引发闪烁的调试成本往往比省下的那点带宽可高多了。如果你的目标平台是PC这个问题基本可以忽略。5.3 纹理压缩格式影响Tiling后的采样质量如果你在移动端调Tiling发现纹理在远处重复的时候出现明显的块状感除了Wrap Mode之外还要检查纹理的压缩格式。移动端常用的ASTC或ETC2压缩格式在某些情况下会降低纹理的高频细节尤其是重复多次之后原本不太明显的压缩伪影会随着Tiling增加而累积。更关键的是如果纹理启用了mipmap在Tiling值较大的时候远处的纹理采样会落到mipmap的更低层级上。mipmap层级包含的颜色是原始纹理的均值重复越多远景细节丢失就越严重。对于地表这种需要大范围重复的纹理我建议关闭mipmap或者把纹理的Mipmap Bias调低一点。否则你可能会发现Tiling调大之后远处不再是纹理细节而是糊成一片的色块。5.4 URP与内置管线的Tiling Offset差异Unity从URP通用渲染管线普及之后很多内置管线的Shader写法需要调整。但Tiling Offset本身的数学在URP中完全一致变化的主要是宏和引入的包不同。比如UnityCG.cginc在URP里变成了Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlslTRANSFORM_TEX宏的定义位置不一样但用法和结果相同。如果你从内置管线迁移到URP遇到Tiling Offset不生效的情况多半是因为_MainTex_ST变量没有被正确声明或者Pass里少了#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl。我自己在迁移项目时最常犯的错误就是复制了内置管线的Shader后忘记更新include和Tags导致编译的时候提示找不到符号。检查一下这两处问题基本都能解决。5.5 时间动画与Tiling叠加时的精度陷阱最后分享一个我自己踩过很多次的坑把时间动画叠加到高Tiling的纹理上。比如UV经过Tiling放大后坐标已经变成(100, 100)量级了再叠加一个_Time.y乘速度的偏移量float的精度会迅速吃紧在大型场景中就会出现纹理跳跃的视觉瑕疵。有一个简单的解决思路在叠加时间偏移之前先对Tiling后的坐标做frac取小数部分让坐标回到0到1的区间然后再叠加时间分量。这样数值幅度大幅缩小精度问题就控制住了。代价是每帧时间偏移量也要单独累加不能直接等于_Time.y否则小数部分的平移会不连续。float2 tiledUV i.uv * _Tiling; float2 animUV frac(tiledUV float2(_Time.y * _SpeedU, _Time.y * _SpeedV)); fixed4 col tex2D(_MainTex, animUV);这种写法在长距离传送带、无缝滚动的背景里特别管用。实测下来场景越大、Tiling值越大、时间越久这个优化带来的稳定性提升就越明显。6. 一条通用的调试路径与经验总结我在项目里调试UV相关的问题时基本遵循这样一条路径先确认纹理本身没问题再看UV坐标输出最后看变换逻辑。很多人一上来就怀疑Shader代码其实一半以上的问题都在纹理导入设置或者模型UV分配上。有一个非常实用的调试技巧在片元着色器里把UV坐标直接当成颜色输出。比如return float4(i.uv.x, i.uv.y, 0, 1);如果模型表面呈现从黑色到红色U方向和从黑色到绿色V方向的渐变说明UV坐标传递正常。如果出现纯色块或渐变很突兀说明UV本身就有问题这时候Tiling做得再漂亮也白搭。fixed4 frag (v2f i) : SV_Target { // 调试UV观察U和V方向渐变是否平滑 return float4(i.uv.x, i.uv.y, 0.0, 1.0); }这个调试方法在新写Shader的时候几乎是必做的。你可能会惊讶地发现有很多看起来高级的效果最后排查下来都是因为某个模型的UV早就坏了或者忘了在顶点着色器里传递UV导致片元着色器接收到全零的坐标。7. 关于Tiling Offset的最后的几点体会做Shader这东西入门靠的是热情做得久靠的是对这些基础概念的透彻理解。Tiling Offset看起来只是两个参数但在我的日常开发里它们几乎像一个万能工具既能在小细节上调整纹理表现又能在宏观层面上决定地形、水体、建筑外立面的质感呈现。我建议你花点时间做一个小的测试项目一个平面、一张棋盘格纹理、一个带Tiling Offset参数的材质球。然后手动拖一个C#脚本把Tiling从0.1变化到10Offset从0变化到1盯着屏幕看它的变化规律。等你大概知道了什么参数对应什么视觉效果再看任何复杂的Shader效果都会比盲目抄代码更通透。纹理平铺不是玄学它就是UV坐标和纹理采样之间的那一层简单数学。把这个模型刻在脑子里以后写Shader会顺手得多。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻