GameTech/URP 渲染实战

案例 20.6Splatmap 四层混合:读懂 TerrainLit 的数据布局进阶

目标:实现手绘权重图驱动的四层混合,并对照 URP TerrainLit 理解 Unity Terrain 是怎么做的。

Splatmap(也叫 Control Map)是一张覆盖整块地形的 RGBA 贴图,四个通道分别是四个层的权重。美术用 Terrain 的绘制工具刷的就是它。超过四层时,Unity 会再生成第二张、第三张 Control 图,每四层一组。URP 的 TerrainLit 由几部分组成:

Shader / 文件作用
TerrainLit.shader第一个 Pass 画前 4 层(_Control + _Splat0..3,可选 _Normal0..3、_Mask0..3)
TerrainLitAdd.shader每多 4 层就叠一个 Add Pass(宏 TERRAIN_SPLAT_ADDPASS),用加法混合累加
TerrainLitBase / BasemapGen超过 Base Map Distance 的远处地形不再逐层采样,改用预先烘焙的一张合成图
TerrainLitPasses.hlslSplatmapMix、HeightBasedSplatModify 等核心函数

摘自 URP 14 Shaders/Terrain/TerrainLitPasses.hlsl,高度混合部分只有十几行:

#ifdef _TERRAIN_BLEND_HEIGHT
void HeightBasedSplatModify(inout half4 splatControl, in half4 masks[4])
{
    // heights are in mask blue channel, we multiply by the splat Control weights to get combined height
    half4 splatHeight = half4(masks[0].b, masks[1].b, masks[2].b, masks[3].b) * splatControl.rgba;
    half maxHeight = max(splatHeight.r, max(splatHeight.g, max(splatHeight.b, splatHeight.a)));
    half transition = max(_HeightTransition, 1e-5);
    half4 weightedHeights = splatHeight + transition - maxHeight.xxxx;
    weightedHeights = max(0, weightedHeights);
    weightedHeights = (weightedHeights + 1e-6) * splatControl;
    half sumHeight = max(dot(weightedHeights, half4(1, 1, 1, 1)), 1e-6);
    splatControl = weightedHeights / sumHeight.xxxx;
}
#endif

与 20.5 的两层公式相比,它用“高度 × 权重”作为每层的有效高度,同样只保留与最高者相差不到 _HeightTransition 的层,再乘回原权重并归一化。几个值得记住的约定:层高度存在 Mask Map 的 B 通道(Mask Map 布局为 R 金属度、G AO、B 高度、A 光滑度);源码注释写明层数超过 4 时会关闭高度混合,因为多 Pass 累加破坏了归一化;不开高度混合时,若颜色贴图带 Alpha 且开启了“Opacity as Density”,SplatmapMix 会用颜色 Alpha 做另一种密度混合。

我们的 Splatmap.shader 沿用同样的数据布局,只做了一处扩展:第 2 层(岩石)使用 Triplanar——TerrainLit 本身对所有层都用 Splat UV,因此 Unity 地形的崖壁仍会拉伸,这是很多项目自己改写地形 Shader 的主要原因之一。

Assets/Cookbook/Ch20/Splatmap.shader(节选:heightSplat)
// 与 TerrainLit 的 HeightBasedSplatModify 同一思路:
// 高度 × 权重,取最高者,比它低 transition 以上的层直接归零,剩下的重新归一化
half4 HeightBasedSplat(half4 control, half4 heights, half transition)
{
    half4 h = heights * control;
    half maxH = max(max(h.r, h.g), max(h.b, h.a));
    half4 w = max(h + max(transition, 1e-5) - maxH, 0);
    w = (w + 1e-6) * control;                  // 权重为 0 的层永远不出现
    return w / max(dot(w, 1.0), 1e-6);
}
// 与 TerrainLit 的 HeightBasedSplatModify 同一思路:
// 高度 × 权重,取最高者,比它低 transition 以上的层直接归零,剩下的重新归一化
vec4 HeightBasedSplat(vec4 control, vec4 heights, float transition)
{
    vec4 h = heights * control;
    float maxH = max(max(h.r, h.g), max(h.b, h.a));
    vec4 w = max(h + max(transition, 1e-5) - maxH, 0.0);
    w = (w + 1e-6) * control;                  // 权重为 0 的层永远不出现
    return w / max(dot(w, vec4(1.0)), 1e-6);
}
Assets/Cookbook/Ch20/Splatmap.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    half3 n = normalize(i.normalWS);
    half4 control = SAMPLE_TEXTURE2D(_Control, sampler_Control, i.uv);
    control /= max(dot(control, 1.0), 1e-4);   // 画笔刷出来的权重不一定和为 1

#if defined(_DEBUG_CONTROL)
    half3 dbg = control.r * half3(0.2, 0.7, 0.15) + control.g * half3(0.85, 0.6, 0.25)
              + control.b * half3(0.6, 0.6, 0.65) + control.a * half3(1, 1, 1);
    return half4(dbg * (0.55 + 0.45 * saturate(dot(n, half3(0.3, 0.9, -0.3)))), 1);
#endif

    float2 xz = i.positionWS.xz;
    half4 s0 = SAMPLE_TEXTURE2D(_Splat0, sampler_Splat0, xz * _SplatTiling.x);
    half4 s1 = SAMPLE_TEXTURE2D(_Splat1, sampler_Splat0, xz * _SplatTiling.y);
    half4 s3 = SAMPLE_TEXTURE2D(_Splat3, sampler_Splat0, xz * _SplatTiling.w);
    // 第 2 层是岩石,崖壁上用 Triplanar(TerrainLit 本身没有这一步)
    float3 tw = TriplanarWeights(n, 8);
    TriplanarUV tuv = GetTriplanarUV(i.positionWS, n, _SplatTiling.z);
    half4 s2 = SampleTriplanar(TEXTURE2D_ARGS(_Splat2, sampler_Splat0), tuv, tw);
    half3 rockN = TriplanarNormalWS(TEXTURE2D_ARGS(_Normal2, sampler_Splat0), tuv, tw, n, 1, 3);

    if (_HeightBased > 0.5)
        control = HeightBasedSplat(control, half4(s0.a, s1.a, s2.a, s3.a), _HeightTransition);

    half3 albedo = s0.rgb * control.r + s1.rgb * control.g + s2.rgb * control.b + s3.rgb * control.a;
    half3 normalWS = normalize(lerp(n, rockN, control.b));
    half smoothness = dot(control, half4(0.1, 0.15, 0.25, 0.5));
    return Ch20ShadePBR(i.positionWS, normalWS, i.positionCS, albedo, 0, smoothness, 1, i.fogFactor);
}
vec4 frag()
{
    vec3 n = normalize(normalWS);
    vec4 control = texture(_Control, uv);
    control /= max(dot(control, vec4(1.0)), 1e-4);   // 画笔刷出来的权重不一定和为 1

#if defined(_DEBUG_CONTROL)
    vec3 dbg = control.r * vec3(0.2, 0.7, 0.15) + control.g * vec3(0.85, 0.6, 0.25)
             + control.b * vec3(0.6, 0.6, 0.65) + control.a * vec3(1, 1, 1);
    return vec4(dbg * (0.55 + 0.45 * saturate(dot(n, vec3(0.3, 0.9, -0.3)))), 1.0);
#endif

    vec2 xz = positionWS.xz;
    vec4 s0 = texture(_Splat0, xz * _SplatTiling.x);
    vec4 s1 = texture(_Splat1, xz * _SplatTiling.y);
    vec4 s3 = texture(_Splat3, xz * _SplatTiling.w);
    // 第 2 层是岩石,崖壁上用 Triplanar(TerrainLit 本身没有这一步)
    vec3 tw = TriplanarWeights(n, 8.0);
    TriplanarUV tuv = GetTriplanarUV(positionWS, n, _SplatTiling.z);
    vec4 s2 = SampleTriplanar(_Splat2, tuv, tw);
    vec3 rockN = TriplanarNormalWS(_Normal2, tuv, tw, n, 1.0, 3);

    if (_HeightBased > 0.5)
        control = HeightBasedSplat(control, vec4(s0.a, s1.a, s2.a, s3.a), _HeightTransition);

    vec3 albedo = s0.rgb * control.r + s1.rgb * control.g + s2.rgb * control.b + s3.rgb * control.a;
    vec3 nWS = normalize(mix(n, rockN, control.b));
    float smoothness = dot(control, vec4(0.1, 0.15, 0.25, 0.5));
    return Ch20ShadePBR(positionWS, nWS, gl_FragCoord, albedo, 0.0, smoothness, 1.0, fogFactor);
}

拍摄用的 Control 图同样是程序“刷”出来的:坡度大的地方写 B 通道(岩石),高处写 A 通道(雪),再沿一条正弦曲线画一条土路写进 G 通道——这条路就是规则驱动做不出来、必须手绘的内容。

Assets/Cookbook/Ch20/Editor/Ch20Shots.cs(节选:controlMap)
/// 程序化“画”一张 Control 图:R 草、G 土路、B 岩石(按坡度)、A 雪(按高度)
static Texture2D BuildControl(float size, System.Func<float, float, float> h, int res = 256)
{
    var t = new Texture2D(res, res, TextureFormat.RGBA32, false, true)
    { wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Bilinear, hideFlags = HideFlags.DontSave };
    var px = new Color[res * res];
    for (int y = 0; y < res; y++)
        for (int x = 0; x < res; x++)
        {
            float wx = (x / (res - 1f) - 0.5f) * size, wz = (y / (res - 1f) - 0.5f) * size;
            float e = 0.3f;
            float dx = (h(wx + e, wz) - h(wx - e, wz)) / (2 * e), dz = (h(wx, wz + e) - h(wx, wz - e)) / (2 * e);
            float ny = 1f / Mathf.Sqrt(1 + dx * dx + dz * dz);
            float rock = Smooth(0.82f, 0.74f, ny);
            float snow = Smooth(6.5f, 8f, h(wx, wz)) * (1 - rock * 0.7f);
            // 一条蜿蜒的小路:到正弦曲线的距离
            float pathX = Mathf.Sin(wz * 0.18f) * 4f - 2f + Heightfield.Fbm(wz * 0.2f, 3f, 2) * 2f;
            float path = Smooth(1.4f, 0.6f, Mathf.Abs(wx - pathX)) * Smooth(14f, 8f, wz);
            float grass = 1;
            var c = new Color(grass, 0, 0, 0);
            c = Color.Lerp(c, new Color(0, 1, 0, 0), path);
            c = Color.Lerp(c, new Color(0, 0, 1, 0), rock);
            c = Color.Lerp(c, new Color(0, 0, 0, 1), snow);
            px[y * res + x] = c;
        }
    t.SetPixels(px); t.Apply();
    return t;
}
图 20-11Splatmap 驱动的四层地形。左:Control 图可视化(绿 = 草、橙 = 土路、灰 = 岩石、白 = 雪);右:最终效果,蜿蜒的小路来自手绘权重
图 20-12土路与草地交界的特写。左:直接用 Control 权重线性混合,边缘是一圈模糊的过渡;右:开启 Height Based Blend(transition = 0.15),卵石“顶”出草地,边缘由卵石的形状决定
规则与手绘结合实际项目往往两者都用:Splatmap 刷道路、营地、河岸这些有设计意图的区域,Shader 里再按坡度自动叠岩石、按高度叠积雪。只要把规则遮罩当作“第五层”,在 Splatmap 混合之后再做一次高度混合即可。