URP 渲染实战 / 第 20 章 地形与植被 / 案例 20.5
案例 20.5高度混合 Height Blend进阶
目标:让两层材质的过渡由各自的高度决定,而不是简单的半透明叠加。
设想砖地上铺了一层土:土首先会填满砖缝,砖面最后才被盖住。线性插值 lerp(A, B, t) 做不到这一点——它在每个像素上都是“A 的 (1−t) 加 B 的 t”,结果是砖和土同时半透明。高度混合的想法是:把遮罩 t 理解成“B 层的厚度”,加到 B 的高度上;同样把 1−t 加到 A 的高度上;哪一层“更高”就显示哪一层:
只有比“最高者”低不超过 d(混合深度,_BlendDepth)的层才有权重。d 很小时几乎是“谁高谁赢”的硬切换;d 很大时所有层都有权重,退化成接近线性的混合。
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); }

坑:高度要在同一个尺度上高度混合比较的是两层的绝对高度。如果一张图的高度集中在 0.8 以上、另一张集中在 0.2 以下,前者会永远赢,遮罩基本失效。制作时让各层高度都大致铺满 0..1,或者给每层加一个高度偏移/缩放参数(TerrainLit 的层设置里 Channel Remapping 就是干这个的)。另外高度图不能开 sRGB——它是数据,不是颜色。
本案例出自《URP 渲染实战》第 20 章「地形与植被」。在章节中阅读上下文 →