GameTech/URP 渲染实战

案例 20.4高度/坡度自动材质:草 → 砂石 → 岩石 → 雪进阶

目标:一个 Shader 自动给整块地形分配四种地表,并且层间过渡自然。

四张地表贴图都是拍摄时用 C# 程序生成的可平铺噪声(见 Ch20/Editor/TerrainTextures.cs),RGB 是颜色,A 通道存“高度”:草叶尖高、根部低;岩石的板块高、裂缝低;卵石中心高、砂粒低。高度信息就是后面高度混合的依据。

Assets/Cookbook/Ch20/AutoTerrain.shader(节选:rules)
half4 frag(Varyings i) : SV_Target
{
    half3 n = normalize(i.normalWS);
    float3 pos = i.positionWS;

    // 低频噪声:打破“等高线”一样整齐的分界
    half noise = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, pos.xz * 0.04).r - 0.5;
    half h = pos.y + noise * _NoiseAmp;
    half slope = 1.0 - n.y;                          // 0 = 平地,1 = 垂直崖壁

    // 平缓的层用顶视投影即可;岩石主要出现在陡坡,必须 Triplanar
    float2 uvTop = pos.xz * _Tiling;
    half4 grass = SAMPLE_TEXTURE2D(_GrassTex, sampler_GrassTex, uvTop);
    half4 sand  = SAMPLE_TEXTURE2D(_SandTex,  sampler_GrassTex, uvTop * 1.3);
    half4 snow  = SAMPLE_TEXTURE2D(_SnowTex,  sampler_GrassTex, uvTop * 0.5);
    float3 tw = TriplanarWeights(n, 8);
    TriplanarUV tuv = GetTriplanarUV(pos, n, _RockTiling);
    half4 rock = SampleTriplanar(TEXTURE2D_ARGS(_RockTex, sampler_GrassTex), tuv, tw);
    half3 rockN = TriplanarNormalWS(TEXTURE2D_ARGS(_RockNormal, sampler_GrassTex), tuv, tw, n, 1, 3);

    // 规则 → 遮罩(0..1)
    half sandMask = 1.0 - smoothstep(_SandHeight - _HeightBand, _SandHeight + _HeightBand, h);
    half rockMask = smoothstep(_RockSlope - _SlopeBand, _RockSlope + _SlopeBand, slope + noise * 0.25);
vec4 frag()
{
    vec3 n = normalize(normalWS);
    vec3 pos = positionWS;

    // 低频噪声:打破“等高线”一样整齐的分界
    float noise = texture(_NoiseTex, pos.xz * 0.04).r - 0.5;
    float h = pos.y + noise * _NoiseAmp;
    float slope = 1.0 - n.y;                          // 0 = 平地,1 = 垂直崖壁

    // 平缓的层用顶视投影即可;岩石主要出现在陡坡,必须 Triplanar
    vec2 uvTop = pos.xz * _Tiling;
    vec4 grass = texture(_GrassTex, uvTop);
    vec4 sand  = texture(_SandTex,  uvTop * 1.3);
    vec4 snow  = texture(_SnowTex,  uvTop * 0.5);
    vec3 tw = TriplanarWeights(n, 8.0);
    TriplanarUV tuv = GetTriplanarUV(pos, n, _RockTiling);
    vec4 rock = SampleTriplanar(_RockTex, tuv, tw);
    vec3 rockN = TriplanarNormalWS(_RockNormal, tuv, tw, n, 1.0, 3);

    // 规则 → 遮罩(0..1)
    float sandMask = 1.0 - smoothstep(_SandHeight - _HeightBand, _SandHeight + _HeightBand, h);
    float rockMask = smoothstep(_RockSlope - _SlopeBand, _RockSlope + _SlopeBand, slope + noise * 0.25);

这里有几个细节:

遮罩算好之后,按“从下往上铺”的顺序逐层叠加:

Assets/Cookbook/Ch20/AutoTerrain.shader(节选:stack)
// 按“从下往上铺”的顺序逐层叠:每层都和“已经铺好的结果”做一次高度混合
half4 c = grass;
half3 nrm = n;
half4 layerW = half4(1, 0, 0, 0);               // 记录各层最终占比:草/砂/岩/雪(调试用)

half t = HeightBlendWeight(c.a, sand.a, sandMask, _BlendDepth);
c = lerp(c, sand, t);               layerW = half4(layerW.xyz * (1 - t), 0); layerW.y += t;

t = HeightBlendWeight(c.a, rock.a, rockMask, _BlendDepth);
c = lerp(c, rock, t);               nrm = normalize(lerp(nrm, rockN, t));
layerW.xyz *= (1 - t);              layerW.z += t;

// 雪:高于雪线,并且“细节法线”朝上的地方才积得住——岩石的凹坑与台面先白
half snowSlope = 1.0 - nrm.y;
half snowMask = smoothstep(_SnowHeight - _HeightBand, _SnowHeight + _HeightBand, h)
              * (1.0 - smoothstep(0.35, 0.65, snowSlope));
t = HeightBlendWeight(c.a, snow.a, snowMask, _BlendDepth);
c = lerp(c, snow, t);               nrm = normalize(lerp(nrm, n, t * 0.6));
layerW *= (1 - t);                  layerW.w += t;
// 按“从下往上铺”的顺序逐层叠:每层都和“已经铺好的结果”做一次高度混合
vec4 c = grass;
vec3 nrm = n;
vec4 layerW = vec4(1, 0, 0, 0);               // 记录各层最终占比:草/砂/岩/雪(调试用)

float t = HeightBlendWeight(c.a, sand.a, sandMask, _BlendDepth);
c = mix(c, sand, t);                layerW = vec4(layerW.xyz * (1.0 - t), 0.0); layerW.y += t;

t = HeightBlendWeight(c.a, rock.a, rockMask, _BlendDepth);
c = mix(c, rock, t);                nrm = normalize(mix(nrm, rockN, t));
layerW.xyz *= (1.0 - t);            layerW.z += t;

// 雪:高于雪线,并且“细节法线”朝上的地方才积得住——岩石的凹坑与台面先白
float snowSlope = 1.0 - nrm.y;
float snowMask = smoothstep(_SnowHeight - _HeightBand, _SnowHeight + _HeightBand, h)
               * (1.0 - smoothstep(0.35, 0.65, snowSlope));
t = HeightBlendWeight(c.a, snow.a, snowMask, _BlendDepth);
c = mix(c, snow, t);                nrm = normalize(mix(nrm, n, t * 0.6));
layerW *= (1.0 - t);                layerW.w += t;

每叠一层,都拿“已经铺好的结果”(包括它的高度 c.a)和新层做一次高度混合;同时把各层最终占比记录在 layerW 里,既用来混合光滑度,也用于调试显示。雪的规则特意用了叠加岩石法线之后的坡度:岩石凹坑和台面朝上的细节先积雪,竖直的岩面保持裸露,这比用几何法线判断要自然得多。

图 20-7同一块台地的三步改进。左:只用高度/坡度阈值 + 线性混合,雪线和岩石边界整齐得像等高线;中:判断量加噪声,边界变得不规则,但过渡带是两张图半透明叠加的“泥浆”;右:再换成高度混合,雪先填进岩石裂缝和台面,草从石缝边缘长出来
图 20-8整块示范地形。左:调试视图,绿 = 草、黄 = 砂石、红 = 岩石、白 = 雪;右:最终效果。所有分布都由高度和坡度自动算出,没有手绘任何权重
调参顺序先打开调试视图,只看色块分布:调 _RockSlope 决定多陡算崖壁、_SnowHeight 决定雪线、_NoiseAmp 决定边界的破碎程度;分布满意后再切回正常视图调 _BlendDepth。分布和过渡是两件事,混在一起调很容易顾此失彼。