GameTech/URP 渲染实战

案例 20.1世界空间 UV 与陡坡拉伸入门

目标:理解为什么地形不能直接用网格 UV,世界空间 XZ 投影又在哪里失效。

高度场网格的 UV 是在水平面上均匀分布的:第 i 列顶点的 u 就是 i/n。贴图因此是“从正上方投影”下来的,平地上完全没问题;但在一面 60° 的陡坡上,水平方向走 1 米,表面实际走了 2 米——同样的纹素被摊到了两倍的面积上,纹理被拉成竖条。更一般地,坡面与水平面夹角为 θ 时,沿坡方向的拉伸倍数是

拉伸倍数=1cosθ=1Ny

其中 Ny 是单位法线的 y 分量。θ 接近 90° 时它趋于无穷,垂直的崖壁上只剩下一条条竖线。

平地:竖直投影的纹素间距 = 表面间距 陡坡:同样的水平间距 对应更长的表面 → 拉伸 1/N.y 贴图从正上方投下(世界 XZ) θ
图 20-1竖直投影在陡坡上的拉伸:水平方向等间距的纹素,落到坡面上被拉开,倍数为 1/cosθ = 1/N.y

世界空间 UV(World-Space UV)就是直接用 positionWS.xz × 平铺系数 当 UV。它与网格 UV 在平地上等价,但有两个好处:一是与网格分辨率、分块方式无关,多块地形拼接时纹理天然连续;二是平铺密度用“每米几次”来描述,美术更好控制。它解决不了陡坡拉伸——那需要下一个案例的三平面映射。下面这个对照 Shader 用一个 KeywordEnum 在三种贴法之间切换:

Assets/Cookbook/Ch20/UVCompare.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float3 n = normalize(i.normalWS);
    half3 albedo;
#if defined(_UVMODE_MESHUV)
    albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv).rgb;
#elif defined(_UVMODE_WORLDXZ)
    // 世界空间 UV:从正上方往下“投影”,平地完美,陡坡被拉成竖条
    albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.positionWS.xz * _Tiling).rgb;
#else
    float3 w = TriplanarWeights(n, _Sharpness);
    #if defined(_UVMODE_WEIGHTS)
        albedo = w;     // 调试:R=X 投影 G=Y 投影 B=Z 投影
    #else
        TriplanarUV uv = GetTriplanarUV(i.positionWS, n, _Tiling);
        albedo = SampleTriplanar(TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap), uv, w).rgb;
    #endif
#endif
    return Ch20ShadePBR(i.positionWS, n, i.positionCS, albedo, 0, 0.15, 1, i.fogFactor);
}
vec4 frag()
{
    vec3 n = normalize(normalWS);
    vec3 albedo;
#if defined(_UVMODE_MESHUV)
    albedo = texture(_BaseMap, uv).rgb;
#elif defined(_UVMODE_WORLDXZ)
    // 世界空间 UV:从正上方往下“投影”,平地完美,陡坡被拉成竖条
    albedo = texture(_BaseMap, positionWS.xz * _Tiling).rgb;
#else
    vec3 w = TriplanarWeights(n, _Sharpness);
    #if defined(_UVMODE_WEIGHTS)
        albedo = w;     // 调试:R=X 投影 G=Y 投影 B=Z 投影
    #else
        TriplanarUV tuv = GetTriplanarUV(positionWS, n, _Tiling);
        albedo = SampleTriplanar(_BaseMap, tuv, w).rgb;
    #endif
#endif
    return Ch20ShadePBR(positionWS, n, gl_FragCoord, albedo, 0.0, 0.15, 1.0, fogFactor);
}
图 20-2同一张 UV 网格贴图、同一块台地。左:网格 UV;中:世界空间 XZ 投影;右:三平面映射。前两者在崖壁上都被拉成竖条,右图三个方向各自投影,崖壁上的格子也是方的

左图和中图几乎一样,这正说明高度场网格的 UV 本质上就是一次 XZ 投影。注意中图格子的大小与左图不同:世界 UV 的平铺是按米算的(_Tiling = 0.5,即每 2 米一格),与网格尺寸无关。

坑:KeywordEnum 与代码切换用 [KeywordEnum(MeshUV, WorldXZ, Triplanar, Weights)] 声明的属性,材质创建时会按默认值自动打开第一个关键字 _UVMODE_MESHUV。如果在 C# 里只 EnableKeyword("_UVMODE_TRIPLANAR"),两个关键字同时开着,Unity 会选中 #pragma 列表里靠前的那个,看上去“切换没有生效”。正确做法是先关掉同组的全部关键字再打开一个,并同步 float 属性值:
Assets/Cookbook/Ch20/Editor/Ch20Shots.cs(节选:keywordEnum)
// KeywordEnum 在材质创建时会按属性默认值打开第一个关键字(_UVMODE_MESHUV)。
// 代码里切换时要“关掉全部、再打开一个”,并同步 float 属性,否则 Inspector 与实际变体不一致。
static readonly string[] s_UVModes = { "_UVMODE_MESHUV", "_UVMODE_WORLDXZ", "_UVMODE_TRIPLANAR", "_UVMODE_WEIGHTS" };
static void SetUVMode(Material m, int mode)
{
    foreach (var k in s_UVModes) m.DisableKeyword(k);
    m.EnableKeyword(s_UVModes[mode]);
    m.SetFloat("_UVMode", mode);
}