GameTech/URP 渲染实战

案例 20.5高度混合 Height Blend进阶

目标:让两层材质的过渡由各自的高度决定,而不是简单的半透明叠加。

设想砖地上铺了一层土:土首先会填满砖缝,砖面最后才被盖住。线性插值 lerp(A, B, t) 做不到这一点——它在每个像素上都是“A 的 (1−t) 加 B 的 t”,结果是砖和土同时半透明。高度混合的想法是:把遮罩 t 理解成“B 层的厚度”,加到 B 的高度上;同样把 1−t 加到 A 的高度上;哪一层“更高”就显示哪一层:

a1=h1+(1−t),a2=h2+t,bi=max(ai−(max(a1,a2)−d),0),w2=b2b1+b2

只有比“最高者”低不超过 d(混合深度,_BlendDepth)的层才有权重。d 很小时几乎是“谁高谁赢”的硬切换;d 很大时所有层都有权重,退化成接近线性的混合。

砖层高度 + (1−t)草层高度 + t(t 从左到右增大) 绿色阴影:草层“高过”砖层的区域——先出现在砖缝里
图 20-9高度混合示意:遮罩 t 从左到右增大,草层的“有效高度”逐渐抬升,最先超过砖层的是砖缝(低处),最后才是砖面
Assets/Cookbook/Ch20/TerrainCommon.hlsl(节选:heightBlend)
// 高度混合:t 是“第二层的遮罩权重”,h1/h2 是两层的高度(存在贴图 A 通道)。
// 谁“又高、权重又大”谁就露出来;depth 控制过渡软硬(0.01 很硬,0.3 接近线性)。
half HeightBlendWeight(half h1, half h2, half t, half depth)
{
    half a1 = h1 + (1.0 - t);
    half a2 = h2 + t;
    half top = max(a1, a2) - depth;
    half b1 = max(a1 - top, 0.0);
    half b2 = max(a2 - top, 0.0);
    return b2 / max(b1 + b2, 1e-4);
}
// 高度混合:t 是“第二层的遮罩权重”,h1/h2 是两层的高度(存在贴图 A 通道)。
// 谁“又高、权重又大”谁就露出来;depth 控制过渡软硬(0.01 很硬,0.3 接近线性)。
float HeightBlendWeight(float h1, float h2, float t, float depth)
{
    float a1 = h1 + (1.0 - t);
    float a2 = h2 + t;
    float top = max(a1, a2) - depth;
    float b1 = max(a1 - top, 0.0);
    float b2 = max(a2 - top, 0.0);
    return b2 / max(b1 + b2, 1e-4);
}

演示 Shader 把两层的数据组织方式也做成了对照:A 层(砖)用三张独立的图——颜色、高度(R 通道)、法线;B 层(草)把高度打包进颜色贴图的 A 通道。后者省一次采样,是地形层贴图的常见约定(URP TerrainLit 则把高度放在 Mask Map 的 B 通道,见下一节)。

Assets/Cookbook/Ch20/HeightBlend.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float2 uvA = TRANSFORM_TEX(i.uv, _MapA);
    float2 uvB = i.uv * _TilingB;
    half3 albedoA = SAMPLE_TEXTURE2D(_MapA, sampler_MapA, uvA).rgb;
    half  heightA = SAMPLE_TEXTURE2D(_HeightA, sampler_MapA, uvA).r;
    half3 normalA = UnpackNormal(SAMPLE_TEXTURE2D(_NormalA, sampler_MapA, uvA));
    half4 layerB  = SAMPLE_TEXTURE2D(_MapB, sampler_MapA, uvB);

    // 遮罩:从左到右的渐变 + 噪声,模拟“刷”出来的过渡区
    half noise = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, i.uv * 1.5).r - 0.5;
    half mask = saturate((i.uv.x - 0.5 + _MaskOffset + noise * 0.35) * _MaskContrast + 0.5);

    // 线性混合 vs 高度混合
    half tLinear = mask;
    half tHeight = HeightBlendWeight(heightA, layerB.a, mask, _BlendDepth);
    half t = lerp(tLinear, tHeight, _UseHeight);

    half3 albedo = lerp(albedoA, layerB.rgb, t);
    half3 normalTS = normalize(lerp(normalA, half3(0, 0, 1), t));
    float sgn = i.tangentWS.w;
    float3 bitangent = sgn * cross(i.normalWS, i.tangentWS.xyz);
    half3 normalWS = TransformTangentToWorld(normalTS, half3x3(i.tangentWS.xyz, bitangent, i.normalWS));
    half occlusion = lerp(lerp(0.6, 1.0, heightA), 1.0, t);   // 砖缝顺手压暗一点
    return Ch20ShadePBR(i.positionWS, normalWS, i.positionCS, albedo, 0, lerp(0.3, 0.12, t), occlusion, i.fogFactor);
}
vec4 frag()
{
    vec2 uvA = TransformTex(uv, _MapA_ST);
    vec2 uvB = uv * _TilingB;
    vec3 albedoA = texture(_MapA, uvA).rgb;
    float heightA = texture(_HeightA, uvA).r;
    vec3 normalA = UnpackNormalmapRGorAG(texture(_NormalA, uvA), 1.0);   // = URP 桌面平台的 UnpackNormal
    vec4 layerB  = texture(_MapB, uvB);

    // 遮罩:从左到右的渐变 + 噪声,模拟“刷”出来的过渡区
    float noise = texture(_NoiseTex, uv * 1.5).r - 0.5;
    float mask = saturate((uv.x - 0.5 + _MaskOffset + noise * 0.35) * _MaskContrast + 0.5);

    // 线性混合 vs 高度混合
    float tLinear = mask;
    float tHeight = HeightBlendWeight(heightA, layerB.a, mask, _BlendDepth);
    float t = mix(tLinear, tHeight, _UseHeight);

    vec3 albedo = mix(albedoA, layerB.rgb, t);
    vec3 normalTS = normalize(mix(normalA, vec3(0, 0, 1), t));
    float sgn = tangentWS.w;
    vec3 bitangent = sgn * cross(normalWS, tangentWS.xyz);
    // HLSL 的 half3x3(T, B, N) 按行构造;GLSL 的 mat3(T, B, N) 列就是 T/B/N,TransformTangentToWorld 已按此处理
    vec3 nWS = TransformTangentToWorld(normalTS, mat3(tangentWS.xyz, bitangent, normalWS));
    float occlusion = mix(mix(0.6, 1.0, heightA), 1.0, t);   // 砖缝顺手压暗一点
    return Ch20ShadePBR(positionWS, nWS, gl_FragCoord, albedo, 0.0, mix(0.3, 0.12, t), occlusion, fogFactor);
}
图 20-10砖地向草地过渡,遮罩完全相同。左:线性混合,过渡区砖和草半透明叠在一起;中:高度混合,d = 0.25;右:高度混合,d = 0.05,草严格地先长满砖缝,再一块一块吞掉砖面
坑:高度要在同一个尺度上高度混合比较的是两层的绝对高度。如果一张图的高度集中在 0.8 以上、另一张集中在 0.2 以下,前者会永远赢,遮罩基本失效。制作时让各层高度都大致铺满 0..1,或者给每层加一个高度偏移/缩放参数(TerrainLit 的层设置里 Channel Remapping 就是干这个的)。另外高度图不能开 sRGB——它是数据,不是颜色。