从 UV 空间的数学本质出发,理解 URP 中纹理坐标的缩放(Tiling)与偏移(Offset)控制原理, 并掌握 Shader Graph、HLSL、C# 三种维度的实践技巧。
UV 坐标系基础
在实时渲染中,UV 坐标 是将二维纹理贴图映射到三维网格表面的桥梁。 每个顶点都携带一组 (u, v) 值,顶点着色器将其传递给片段着色器,用于在纹理中查找颜色。 U 表示水平方向,V 表示垂直方向,两者共同构成一个 [0, 1] × [0, 1] 的归一化坐标空间。

Unity 的 UV 坐标系以左下角为原点 (0, 0) ,右上角为 (1, 1)。当 UV 值超出 [0, 1] 范围时, 纹理的采样行为取决于 Texture Wrap Mode 设置:
| Wrap Mode | 行为描述 | 典型用途 |
|---|---|---|
| Repeat | 超出部分重复平铺,等价于 frac(uv) | 地板、墙壁、草地、岩石 |
| Clamp | 边界像素被拉伸,不重复 | UI 元素、精灵图 |
| Mirror | 镜像翻转重复,接缝处无缝 | 对称纹理、无缝拼接 |
| Mirror Once | 仅镜像一次,之后 Clamp | 特殊边界过渡效果 |
💡
Unity 支持最多 8 套 UV 通道(TEXCOORD0~TEXCOORD7)。 Lightmap 通常占用 TEXCOORD1,自定义效果层可使用 TEXCOORD2 及更高。
纹理平铺(Tiling)原理
Tiling(平铺/缩放) 通过对 UV 坐标进行乘法缩放来实现纹理的重复。 其本质是将原本覆盖 [0,1]×[0,1] 的单一纹理"挤压",使更多重复单元出现在同一表面上。
数学定义


⚠️
Tiling 值为 0 时,所有片段都采样同一点(UV = 0),纹理退化为纯色块,通常是意外情况。 Tiling 值为负数 时,纹理会被镜像翻转,这有时是有意为之的效果。
偏移(Offset)原理
Offset(偏移) 通过对 UV 坐标进行加法平移 来滑动纹理的起始位置。 Unity 规定 Offset 在 Tiling 变换之后叠加,完整公式如下:


Offset 最常见的运行时用途是 UV 动画------每帧将偏移值随时间累加,实现水流、火焰、云朵等流动效果,无需修改网格。
✅
配合 frac() 函数,Offset 可永远保持在 [0,1] 范围内循环, 避免长时间运行后浮点精度问题导致的 UV 抖动(UV Jitter)。
URP 管线中的 UV 流动
理解 UV 数据如何在 URP 渲染管线中流动,是正确控制 Tiling/Offset 的前提。

_MainTex_ST 向量布局
Unity 材质中每个纹理属性 _MainTex 都会自动关联一个 float4 _MainTex_ST(ST = Scale-Translation):

宏 TRANSFORM_TEX(uv, tex) 展开后等价于:uv.xy * tex_ST.xy + tex_ST.zw, 其中 .xy 是 Tiling,.zw 是 Offset。
⚠️
在 URP 中,_MainTex_ST 必须声明在 CBUFFER_START(UnityPerMaterial) 块中, 否则在 SRP Batcher 下会导致材质合批失效,严重影响性能。
HLSL 手写 Shader 实现
以下是完整的 URP Unlit Shader,展示如何正确声明、传递并应用 Tiling/Offset 参数。代码逐行出现,帮助你逐步理解每个环节。
cs
Shader "Custom/URP_UV_TilingOffset"
{
Properties
{
// 声明纹理,Unity 自动为其关联 _MainTex_ST
_MainTex ("Main Texture", 2D) = "white" {}
_Color ("Tint Color", Color) = (1,1,1,1)
}
SubShader
{
Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" }
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
// ── CBUFFER:SRP Batcher 合批必需 ──
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST; // .xy=Tiling .zw=Offset
float4 _Color;
CBUFFER_END
// 纹理与采样器(URP 分离声明规范)
TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);
// ── 顶点输入 ──
struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
};
// ── 顶点→片段插值 ──
struct Varyings
{
float4 positionHCS : SV_POSITION;
float2 uv : TEXCOORD0;
};
// ── 顶点着色器 ──
Varyings vert(Attributes IN)
{
Varyings OUT;
OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
// 核心:应用 Tiling 和 Offset
OUT.uv = TRANSFORM_TEX(IN.uv, _MainTex);
return OUT;
}
// ── 片段着色器 ──
half4 frag(Varyings IN) : SV_TARGET
{
half4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv);
return col * _Color;
}
ENDHLSL
}
}
}
UV 动画:流动水面示例
在片段着色器中手动展开 TRANSFORM_TEX,可以叠加时间驱动的动态偏移:
cs
// 在 CBUFFER 中添加流速参数
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
float2 _FlowDir; // 流动方向,e.g. (1,0)=向右
float _FlowSpeed; // 流速
CBUFFER_END
// 顶点着色器中:只传递原始 UV,不做 TRANSFORM_TEX
Varyings vert(Attributes IN)
{
Varyings OUT;
OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
OUT.uv = IN.uv; // 保留原始 UV,片段中再处理
return OUT;
}
// 片段着色器中手动拆解
half4 frag(Varyings IN) : SV_TARGET
{
// 1. 手动缩放(Tiling)
float2 uv = IN.uv * _MainTex_ST.xy;
// 2. 材质面板 Offset + 时间驱动动画
float2 animOffset = _FlowDir * _FlowSpeed * _Time.y;
uv += _MainTex_ST.zw + animOffset;
// 3. frac() 防止长时间浮点漂移
uv = frac(uv);
return SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
}
Shader Graph 可视化实现
Unity Shader Graph 提供了 Tiling And Offset 节点,封装了完整的缩放与平移计算。

节点参数说明
| Shader Graph 节点 | 输入/输出 | 说明 |
|---|---|---|
| UV | 输出 UV (Vector2) | 读取网格的 UV 通道,默认 TEXCOORD0 |
| Tiling And Offset → Tiling | 输入 Vector2 | 横纵平铺倍数,默认 (1,1) |
| Tiling And Offset → Offset | 输入 Vector2 | 横纵偏移量,默认 (0,0) |
| Tiling And Offset → Out | 输出 Vector2 | 变换后的最终 UV,送入采样节点 |
| Sample Texture 2D → UV | 输入 Vector2 | 接收变换后的 UV |
✅
在 Shader Graph 中,将 Tiling 和 Offset 的输入连接到 Vector2 Property(属性节点), 即可在材质 Inspector 面板中实时调整,或通过 C# 脚本动态控制。
C# 脚本动态控制
通过 C# 脚本在运行时修改材质的 Tiling 和 Offset,是实现 UV 动画、程序化效果的常用手段。
cs
using UnityEngine;
public class TextureTilingControl : MonoBehaviour
{
[Header("Tiling")]
public Vector2 tiling = new Vector2(2f, 2f);
[Header("Offset")]
public Vector2 offset = Vector2.zero;
private Material _mat;
void Start()
{
// GetComponent 获取渲染器,取材质实例(避免修改共享材质)
_mat = GetComponent<Renderer>().material;
// 方法一:SetTextureScale / SetTextureOffset(推荐,语义清晰)
_mat.SetTextureScale("_MainTex", tiling);
_mat.SetTextureOffset("_MainTex", offset);
}
}
cs
using UnityEngine;
public class UVAnimator : MonoBehaviour
{
public Vector2 flowDirection = new Vector2(1f, 0f);
public float flowSpeed = 0.5f;
private Material _mat;
private Vector2 _offset;
void Start()
{
_mat = GetComponent<Renderer>().material;
}
void Update()
{
// 每帧累加偏移
_offset += flowDirection * flowSpeed * Time.deltaTime;
// 使用 Repeat 将偏移限制在 [0, 1] 范围,防止浮点精度劣化
_offset.x = Mathf.Repeat(_offset.x, 1f);
_offset.y = Mathf.Repeat(_offset.y, 1f);
_mat.SetTextureOffset("_MainTex", _offset);
}
}
⚠️
使用 renderer.material 会自动创建材质实例,避免修改 sharedMaterial(会影响场景中所有使用该材质的对象)。 在频繁更新时,优先使用 MaterialPropertyBlock 以完全避免材质实例化,保持合批。
MaterialPropertyBlock(性能最优方案)
cs
using UnityEngine;
public class UVAnimatorMPB : MonoBehaviour
{
static readonly int MainTexST = Shader.PropertyToID("_MainTex_ST");
public Vector2 tiling = Vector2.one;
public float speed = 0.3f;
Renderer _renderer;
MaterialPropertyBlock _mpb;
void Awake()
{
_renderer = GetComponent<Renderer>();
_mpb = new MaterialPropertyBlock();
}
void Update()
{
float t = Time.time * speed;
// _MainTex_ST: .xy = Tiling, .zw = Offset
var st = new Vector4(tiling.x, tiling.y, t, 0f);
_mpb.SetVector(MainTexST, st);
_renderer.SetPropertyBlock(_mpb);
}
}
常见场景与最佳实践

性能与最佳实践总结
| 场景 | 推荐方案 | 注意事项 |
|---|---|---|
| 静态纹理缩放 | 材质 Inspector 面板直接设置 | 无运行时开销,推荐首选 |
| 一次性运行时设置 | mat.SetTextureScale/Offset | 会创建材质实例,注意内存 |
| 每帧更新(动画) | MaterialPropertyBlock | 不破坏 GPU 合批,性能最优 |
| Shader 内动画 | 片段着色器 _Time.y 驱动 | 无 CPU 开销,避免 UV Jitter |
| Shader Graph 项目 | Tiling And Offset 节点 + Property | 连接 Vector2 属性节点可调试 |