GameTech/URP 渲染实战

案例 20.2三平面映射 Triplanar入门

目标:让贴图在任意朝向的表面上都不拉伸,并理解它的混合权重和代价。

三平面映射(Triplanar Mapping)的思路很朴素:既然单方向投影只在“正对投影方向”的表面上好看,那就沿世界的 X、Y、Z 三个轴各投影一次,再按表面法线决定每个像素主要用哪一张。X 方向的投影用 (z, y) 当 UV,Y 方向用 (x, z),Z 方向用 (x, y)。法线越接近某个轴,那个轴的投影就越“正”,权重也就越大:

wi=|Ni|k|Nx|k+|Ny|k+|Nz|k,i∈{x,y,z}

归一化保证三个权重之和为 1,亮度不会在过渡区忽明忽暗;指数 k(代码里的 _Sharpness)控制过渡带的宽窄。k = 1 时,45° 斜面上两张投影各占一半,两套图案叠在一起很容易看出“重影”;k 越大,过渡越窄,大部分区域只用一张投影,代价是过渡处更硬。URP 的 core 库里也有一个 ComputeTriplanarWeights(CommonMaterial.hlsl),它先减去 0.2 再取三次方,效果相当于一个固定的较硬设置。

Y 投影:用 (x, z) X 投影:用 (z, y) Z 投影:用 (x, y) 表面倾角 0° → 90°Y 投影权重 k = 1 k = 4 k = 16
图 20-3左:三个投影方向及各自使用的坐标分量;右:Y 投影权重随表面倾角变化的示意,指数 k 越大过渡带越窄
Assets/Cookbook/Ch20/TerrainCommon.hlsl(节选:triplanar)
// 三个投影方向的混合权重:|N| 的 sharpness 次方再归一化。
// sharpness 越大,过渡带越窄;1 时过渡最宽,45° 斜面上能看到两张图叠在一起。
float3 TriplanarWeights(float3 normalWS, float sharpness)
{
    float3 w = pow(abs(normalWS), sharpness);
    return w / max(dot(w, 1.0), 1e-5);
}

struct TriplanarUV
{
    float2 x;   // 沿 X 轴投影:用 (z, y)
    float2 y;   // 沿 Y 轴投影:用 (x, z)
    float2 z;   // 沿 Z 轴投影:用 (x, y)
};

TriplanarUV GetTriplanarUV(float3 positionWS, float3 normalWS, float scale)
{
    TriplanarUV t;
    float3 p = positionWS * scale;
    t.x = p.zy;
    t.y = p.xz;
    t.z = p.xy;
    // 背面方向的投影会左右镜像(文字、箭头会反),按法线符号把 U 翻回来
    float3 s = normalWS < 0 ? -1.0 : 1.0;
    t.x.x *= s.x;
    t.y.x *= s.y;
    t.z.x *= -s.z;
    return t;
}

half4 SampleTriplanar(TEXTURE2D_PARAM(tex, samp), TriplanarUV uv, float3 w)
{
    half4 cx = SAMPLE_TEXTURE2D(tex, samp, uv.x);
    half4 cy = SAMPLE_TEXTURE2D(tex, samp, uv.y);
    half4 cz = SAMPLE_TEXTURE2D(tex, samp, uv.z);
    return cx * w.x + cy * w.y + cz * w.z;
}
// 三个投影方向的混合权重:|N| 的 sharpness 次方再归一化。
// sharpness 越大,过渡带越窄;1 时过渡最宽,45° 斜面上能看到两张图叠在一起。
vec3 TriplanarWeights(vec3 normalWS, float sharpness)
{
    vec3 w = pow(abs(normalWS), vec3(sharpness));
    return w / max(dot(w, vec3(1.0)), 1e-5);
}

struct TriplanarUV
{
    vec2 x;   // 沿 X 轴投影:用 (z, y)
    vec2 y;   // 沿 Y 轴投影:用 (x, z)
    vec2 z;   // 沿 Z 轴投影:用 (x, y)
};

// HLSL 的 normalWS < 0 ? -1.0 : 1.0(逐分量)
vec3 Ch20AxisSign(vec3 n) { return mix(vec3(1.0), vec3(-1.0), vec3(lessThan(n, vec3(0.0)))); }

TriplanarUV GetTriplanarUV(vec3 positionWS, vec3 normalWS, float scale)
{
    TriplanarUV t;
    vec3 p = positionWS * scale;
    t.x = p.zy;
    t.y = p.xz;
    t.z = p.xy;
    // 背面方向的投影会左右镜像(文字、箭头会反),按法线符号把 U 翻回来
    vec3 s = Ch20AxisSign(normalWS);
    t.x.x *= s.x;
    t.y.x *= s.y;
    t.z.x *= -s.z;
    return t;
}

// GLSL 的采样器可以直接当函数参数(对应 HLSL 的 TEXTURE2D_PARAM(tex, samp))
vec4 SampleTriplanar(sampler2D tex, TriplanarUV uv, vec3 w)
{
    vec4 cx = texture(tex, uv.x);
    vec4 cy = texture(tex, uv.y);
    vec4 cz = texture(tex, uv.z);
    return cx * w.x + cy * w.y + cz * w.z;
}

代码里有一处容易忽略的细节:背面投影的镜像。沿 +X 看过去和沿 −X 看过去,同一组 (z, y) 坐标在屏幕上的左右方向是相反的;草、石头这类无方向纹理看不出来,但文字、箭头、砖缝的错位就会暴露。GetTriplanarUV 按法线符号把对应投影的 U 翻转回来,Z 方向则是在正面翻转(因为相机从 −Z 看向 +Z 面时,世界 +X 在画面左侧)。

图 20-4把三个权重直接输出成颜色(R = X 投影,G = Y 投影,B = Z 投影)。左到右 k = 1、4、16:k 小时大片区域是混合色,k 大时颜色块分明,只在交界处窄窄地过渡
图 20-5同一个 Triplanar 材质(程序生成的岩石贴图 + 法线)贴在两块不规则石头和一个立方体上,没有任何 UV 展开,也没有拉伸

代价与取舍