GameTech/URP 渲染实战
CHAPTER 20第 20 章

地形与植被

地形是游戏里面积最大的“一张材质”:它要铺几平方公里而不露出重复,要在陡崖上不拉伸,要让草、砂、岩、雪自然地互相咬合;地面上还要长出成千上万株会被踩弯的草。本章用一张程序化高度场,把这些技术一项项拆开实现。

本章你会学到
世界空间 UV 与三平面映射(Triplanar)的原理和代价
法线贴图的 Triplanar:Swizzle、UDN、Whiteout、RNM 的区别
按高度、坡度自动铺材质,以及让层与层“咬合”的高度混合
URP TerrainLit 的 Splatmap 数据布局与 Height Based Blend
用 Graphics.RenderMeshInstanced 画上万丛草,UNITY_INSTANCING_BUFFER 传每实例数据
草的交互压弯(全局数组)与距离淡出(缩短 + 抖动裁剪)

20.1 地形着色要解决的四个问题

普通物体的材质由美术在 DCC 软件里展好 UV、画好贴图,Shader 只负责“照着贴”。地形不一样:它通常由一张高度图(Heightmap)直接生成网格,UV 是均匀铺在水平面上的;它的尺寸动辄几百米到几公里,任何一张贴图拉满全图都会糊成一片;它的表面又同时包含草地、土路、岩壁、积雪等完全不同的材质。于是地形 Shader 要回答四个问题:

  1. 贴图怎么铺——用网格 UV、世界坐标,还是三个方向分别投影?陡坡上会不会被拉成竖条?
  2. 多层材质怎么分布——靠美术手刷一张权重图(Splatmap),还是按高度、坡度等规则自动计算?
  3. 层与层之间怎么过渡——简单线性插值会得到“两张图半透明叠在一起”的泥浆感,真实的过渡应该是石头缝先被土填满、雪先积在凹处。
  4. 地面上的东西怎么画——一片草地有几万株草,逐个 GameObject 渲染会把 CPU 拖垮,需要 GPU Instancing。

Unity 自带的 Terrain 组件把这些问题打包解决了一部分:它负责高度图 LOD、Splatmap 绘制工具、细节物体(Detail)与树的批量渲染,URP 为它提供了 TerrainLit 系列 Shader。但 Terrain 的很多行为藏在引擎里,不利于看清 Shader 本身。本章的配图全部用一张程序化高度场网格代替 Terrain:给一个高度函数 h(x, z),生成规则网格,用中心差分求法线。这样得到的就是一块普通 Mesh,所有着色都由我们自己的 Shader 完成;学会之后,再看 TerrainLit 的源码会非常轻松(20.6 节会对照着读)。

Assets/Cookbook/Ch20/Heightfield.cs(节选:build)
/// <param name="size">边长(米),网格中心在原点</param>
/// <param name="n">每边段数,顶点数 (n+1)²</param>
/// <param name="height">高度函数,输入世界 x/z,输出 y</param>
public static Mesh Build(float size, int n, Func<float, float, float> height)
{
    int vc = (n + 1) * (n + 1);
    var v = new Vector3[vc];
    var nr = new Vector3[vc];
    var uv = new Vector2[vc];
    float step = size / n;
    for (int z = 0; z <= n; z++)
        for (int x = 0; x <= n; x++)
        {
            float wx = -size * 0.5f + x * step, wz = -size * 0.5f + z * step;
            int k = z * (n + 1) + x;
            v[k] = new Vector3(wx, height(wx, wz), wz);
            uv[k] = new Vector2((float)x / n, (float)z / n);
            // 中心差分求法线:N = normalize(-dh/dx, 1, -dh/dz)
            float e = step * 0.5f;
            float dx = (height(wx + e, wz) - height(wx - e, wz)) / (2 * e);
            float dz = (height(wx, wz + e) - height(wx, wz - e)) / (2 * e);
            nr[k] = new Vector3(-dx, 1f, -dz).normalized;
        }
    var idx = new int[n * n * 6];
    int p = 0;
    for (int z = 0; z < n; z++)
        for (int x = 0; x < n; x++)
        {
            int a = z * (n + 1) + x, b = a + n + 1;
            idx[p++] = a; idx[p++] = b; idx[p++] = a + 1;
            idx[p++] = a + 1; idx[p++] = b; idx[p++] = b + 1;
        }
    var m = new Mesh { name = "Heightfield", indexFormat = IndexFormat.UInt32 };
    m.vertices = v; m.normals = nr; m.uv = uv; m.triangles = idx;
    m.RecalculateTangents();
    m.RecalculateBounds();
    return m;
}

法线这一步值得多说一句。高度场 y=h(x,z) 可以看成隐式曲面 F=h(x,z)−y=0,它的梯度方向就是法线方向,取反并让 y 分量为正,得到

N=normalize(−∂h∂x,1,−∂h∂z)

偏导数用中心差分 (h(x+e)−h(x−e))/2e 近似即可。它比 Mesh.RecalculateNormals() 更好:后者按三角形面积加权平均面法线,在网格边界处会因为缺少相邻三角形而偏斜,而解析求导在边界上一样准确。Unity Terrain 在 GPU 上也是用同样的差分思路从高度图生成法线图的。

案例 20.1世界空间 UV 与陡坡拉伸入门单独打开 ↗

目标:理解为什么地形不能直接用网格 UV,世界空间 XZ 投影又在哪里失效。

高度场网格的 UV 是在水平面上均匀分布的:第 i 列顶点的 u 就是 i/n。贴图因此是“从正上方投影”下来的,平地上完全没问题;但在一面 60° 的陡坡上,水平方向走 1 米,表面实际走了 2 米——同样的纹素被摊到了两倍的面积上,纹理被拉成竖条。更一般地,坡面与水平面夹角为 θ 时,沿坡方向的拉伸倍数是

拉伸倍数=1cosθ=1Ny

其中 Ny 是单位法线的 y 分量。θ 接近 90° 时它趋于无穷,垂直的崖壁上只剩下一条条竖线。

平地:竖直投影的纹素间距 = 表面间距 陡坡:同样的水平间距 对应更长的表面 → 拉伸 1/N.y 贴图从正上方投下(世界 XZ) θ
图 20-1竖直投影在陡坡上的拉伸:水平方向等间距的纹素,落到坡面上被拉开,倍数为 1/cosθ = 1/N.y

世界空间 UV(World-Space UV)就是直接用 positionWS.xz × 平铺系数 当 UV。它与网格 UV 在平地上等价,但有两个好处:一是与网格分辨率、分块方式无关,多块地形拼接时纹理天然连续;二是平铺密度用“每米几次”来描述,美术更好控制。它解决不了陡坡拉伸——那需要下一个案例的三平面映射。下面这个对照 Shader 用一个 KeywordEnum 在三种贴法之间切换:

Assets/Cookbook/Ch20/UVCompare.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float3 n = normalize(i.normalWS);
    half3 albedo;
#if defined(_UVMODE_MESHUV)
    albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv).rgb;
#elif defined(_UVMODE_WORLDXZ)
    // 世界空间 UV:从正上方往下“投影”,平地完美,陡坡被拉成竖条
    albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.positionWS.xz * _Tiling).rgb;
#else
    float3 w = TriplanarWeights(n, _Sharpness);
    #if defined(_UVMODE_WEIGHTS)
        albedo = w;     // 调试:R=X 投影 G=Y 投影 B=Z 投影
    #else
        TriplanarUV uv = GetTriplanarUV(i.positionWS, n, _Tiling);
        albedo = SampleTriplanar(TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap), uv, w).rgb;
    #endif
#endif
    return Ch20ShadePBR(i.positionWS, n, i.positionCS, albedo, 0, 0.15, 1, i.fogFactor);
}
vec4 frag()
{
    vec3 n = normalize(normalWS);
    vec3 albedo;
#if defined(_UVMODE_MESHUV)
    albedo = texture(_BaseMap, uv).rgb;
#elif defined(_UVMODE_WORLDXZ)
    // 世界空间 UV:从正上方往下“投影”,平地完美,陡坡被拉成竖条
    albedo = texture(_BaseMap, positionWS.xz * _Tiling).rgb;
#else
    vec3 w = TriplanarWeights(n, _Sharpness);
    #if defined(_UVMODE_WEIGHTS)
        albedo = w;     // 调试:R=X 投影 G=Y 投影 B=Z 投影
    #else
        TriplanarUV tuv = GetTriplanarUV(positionWS, n, _Tiling);
        albedo = SampleTriplanar(_BaseMap, tuv, w).rgb;
    #endif
#endif
    return Ch20ShadePBR(positionWS, n, gl_FragCoord, albedo, 0.0, 0.15, 1.0, fogFactor);
}
图 20-2同一张 UV 网格贴图、同一块台地。左:网格 UV;中:世界空间 XZ 投影;右:三平面映射。前两者在崖壁上都被拉成竖条,右图三个方向各自投影,崖壁上的格子也是方的

左图和中图几乎一样,这正说明高度场网格的 UV 本质上就是一次 XZ 投影。注意中图格子的大小与左图不同:世界 UV 的平铺是按米算的(_Tiling = 0.5,即每 2 米一格),与网格尺寸无关。

坑:KeywordEnum 与代码切换用 [KeywordEnum(MeshUV, WorldXZ, Triplanar, Weights)] 声明的属性,材质创建时会按默认值自动打开第一个关键字 _UVMODE_MESHUV。如果在 C# 里只 EnableKeyword("_UVMODE_TRIPLANAR"),两个关键字同时开着,Unity 会选中 #pragma 列表里靠前的那个,看上去“切换没有生效”。正确做法是先关掉同组的全部关键字再打开一个,并同步 float 属性值:
Assets/Cookbook/Ch20/Editor/Ch20Shots.cs(节选:keywordEnum)
// KeywordEnum 在材质创建时会按属性默认值打开第一个关键字(_UVMODE_MESHUV)。
// 代码里切换时要“关掉全部、再打开一个”,并同步 float 属性,否则 Inspector 与实际变体不一致。
static readonly string[] s_UVModes = { "_UVMODE_MESHUV", "_UVMODE_WORLDXZ", "_UVMODE_TRIPLANAR", "_UVMODE_WEIGHTS" };
static void SetUVMode(Material m, int mode)
{
    foreach (var k in s_UVModes) m.DisableKeyword(k);
    m.EnableKeyword(s_UVModes[mode]);
    m.SetFloat("_UVMode", mode);
}

案例 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 展开,也没有拉伸

代价与取舍

  • 采样数 ×3。每张贴图要采三次,带法线就是 6 次。地形上常见的优化是:权重小于某个阈值的投影直接跳过(动态分支),或者只对“岩石”这类出现在陡坡上的层做 Triplanar,草地、雪用一次顶视采样——20.4 节就是这样做的。
  • 世界空间 vs 物体空间。用世界坐标投影的物体一旦移动,纹理会在表面上“游泳”。地形、建筑这类静态物体用世界空间;会动的道具应改用物体空间坐标和物体空间法线做投影。
  • Mipmap 与各向异性。三次投影的 UV 导数各不相同,硬件按各自的导数选 mip,这是正确的;但过渡区两张图的模糊程度不同,远处偶尔能看到一道“清晰度接缝”,适当提高 k 就能缓解。

案例 20.3法线贴图的 Triplanar:UDN、Whiteout 与 RNM进阶单独打开 ↗

目标:给三平面映射加上法线贴图,并且光照正确。

颜色可以随便按权重混合,法线不行。普通法线贴图存的是切线空间的方向,需要 TBN 矩阵转到世界空间;可 Triplanar 根本没有网格切线——三张投影各有各的“切线空间”:X 投影的 (u, v, n) 轴对应世界的 (z, y, x),Y 投影对应 (x, z, y),Z 投影对应 (x, y, z)。问题在于这三个“切线空间”的 n 轴都是世界轴,而真实表面的法线是倾斜的。直接把切线法线 swizzle 到世界轴上(Swizzle 法),等于假装表面就是轴对齐的平面——一个球会被照成一个方块。

正确的做法是把采样得到的细节法线“叠加”到几何法线上,这正是第 7 章细节法线混合要解决的同一个问题,只是这次“底层法线”是几何法线在该投影平面内的表示:对 X 投影,几何法线在其切线空间里的坐标就是 (N.z, N.y, N.x)。于是可以套用三种经典的法线混合公式:

方法投影平面内的公式(n = 细节法线,g = 几何法线在该平面的表示)特点
UDN(n.xy + g.xy, g.z)最便宜;细节在斜面上会被“压扁”,凹凸感偏弱
Whiteout(n.xy + g.xy, n.z × g.z)多一次乘法,细节保留得更好,最常用的性价比方案
RNM把 n 从 (0,0,1) “旋转”到 g 的方向数学上最正确(Reoriented Normal Mapping),开销略高

RNM 在 core 库里有现成实现 BlendNormalRNM(n1, n2)(CommonMaterial.hlsl),它假设 n1 的 z 为正;所以先用 |N| 构造基底,混合后再把 z 的符号乘回去。

Assets/Cookbook/Ch20/TerrainCommon.hlsl(节选:triplanarNormal)
// 法线的 Triplanar:三张切线空间法线各自“转”到世界空间再按权重混合。
// mode 0 = 直接 swizzle(忽略几何法线,球会被照成方块)
// mode 1 = UDN(xy 相加,z 用几何法线)
// mode 2 = Whiteout(xy 相加,z 相乘)
// mode 3 = RNM(Reoriented Normal Mapping,把细节法线“旋转”到几何法线上)
half3 TriplanarNormalWS(TEXTURE2D_PARAM(bump, samp), TriplanarUV uv, float3 w, half3 normalWS, half scale, int mode)
{
    half3 tx = UnpackNormalScale(SAMPLE_TEXTURE2D(bump, samp, uv.x), scale);
    half3 ty = UnpackNormalScale(SAMPLE_TEXTURE2D(bump, samp, uv.y), scale);
    half3 tz = UnpackNormalScale(SAMPLE_TEXTURE2D(bump, samp, uv.z), scale);

    half3 axisSign = normalWS < 0 ? -1.0 : 1.0;
    // 与 GetTriplanarUV 中翻转 U 对应:切线 x 分量也要跟着翻
    tx.x *= axisSign.x;
    ty.x *= axisSign.y;
    tz.x *= -axisSign.z;

    half3 n = normalWS;
    half3 an = abs(normalWS);
    if (mode == 0)          // Swizzle:假设表面就是轴对齐的平面
    {
        tx.z *= axisSign.x; ty.z *= axisSign.y; tz.z *= axisSign.z;
    }
    else if (mode == 1)     // UDN
    {
        tx = half3(tx.xy + n.zy, n.x);
        ty = half3(ty.xy + n.xz, n.y);
        tz = half3(tz.xy + n.xy, n.z);
    }
    else if (mode == 2)     // Whiteout
    {
        tx = half3(tx.xy + n.zy, abs(tx.z) * n.x);
        ty = half3(ty.xy + n.xz, abs(ty.z) * n.y);
        tz = half3(tz.xy + n.xy, abs(tz.z) * n.z);
    }
    else                    // RNM:基底法线取 (各平面内的几何法线, |N|),用 core 库的 BlendNormalRNM
    {
        tx = BlendNormalRNM(half3(n.zy, an.x), tx);
        ty = BlendNormalRNM(half3(n.xz, an.y), ty);
        tz = BlendNormalRNM(half3(n.xy, an.z), tz);
        tx.z *= axisSign.x; ty.z *= axisSign.y; tz.z *= axisSign.z;
    }
    // swizzle 回世界轴:X 平面的 (u,v,n) = (z,y,x),Y 平面 = (x,z,y),Z 平面 = (x,y,z)
    return normalize(tx.zyx * w.x + ty.xzy * w.y + tz.xyz * w.z);
}
// 法线的 Triplanar:三张切线空间法线各自“转”到世界空间再按权重混合。
// mode 0 = 直接 swizzle(忽略几何法线,球会被照成方块)
// mode 1 = UDN(xy 相加,z 用几何法线)
// mode 2 = Whiteout(xy 相加,z 相乘)
// mode 3 = RNM(Reoriented Normal Mapping,把细节法线“旋转”到几何法线上)
vec3 TriplanarNormalWS(sampler2D bump, TriplanarUV uv, vec3 w, vec3 normalWS, float scale, int mode)
{
    vec3 tx = UnpackNormalmapRGorAG(texture(bump, uv.x), scale);
    vec3 ty = UnpackNormalmapRGorAG(texture(bump, uv.y), scale);
    vec3 tz = UnpackNormalmapRGorAG(texture(bump, uv.z), scale);

    vec3 axisSign = Ch20AxisSign(normalWS);
    // 与 GetTriplanarUV 中翻转 U 对应:切线 x 分量也要跟着翻
    tx.x *= axisSign.x;
    ty.x *= axisSign.y;
    tz.x *= -axisSign.z;

    vec3 n = normalWS;
    vec3 an = abs(normalWS);
    if (mode == 0)          // Swizzle:假设表面就是轴对齐的平面
    {
        tx.z *= axisSign.x; ty.z *= axisSign.y; tz.z *= axisSign.z;
    }
    else if (mode == 1)     // UDN
    {
        tx = vec3(tx.xy + n.zy, n.x);
        ty = vec3(ty.xy + n.xz, n.y);
        tz = vec3(tz.xy + n.xy, n.z);
    }
    else if (mode == 2)     // Whiteout
    {
        tx = vec3(tx.xy + n.zy, abs(tx.z) * n.x);
        ty = vec3(ty.xy + n.xz, abs(ty.z) * n.y);
        tz = vec3(tz.xy + n.xy, abs(tz.z) * n.z);
    }
    else                    // RNM:基底法线取 (各平面内的几何法线, |N|),用 core 库的 BlendNormalRNM
    {
        tx = BlendNormalRNM(vec3(n.zy, an.x), tx);
        ty = BlendNormalRNM(vec3(n.xz, an.y), ty);
        tz = BlendNormalRNM(vec3(n.xy, an.z), tz);
        tx.z *= axisSign.x; ty.z *= axisSign.y; tz.z *= axisSign.z;
    }
    // swizzle 回世界轴:X 平面的 (u,v,n) = (z,y,x),Y 平面 = (x,z,y),Z 平面 = (x,y,z)
    return normalize(tx.zyx * w.x + ty.xzy * w.y + tz.xyz * w.z);
}

注意前面翻转 U 时,切线空间的 x 轴也随之反向,所以采样出来的 tx.x 等也要乘上同样的符号,否则背面的凹凸方向是反的。最后一行把三个平面的结果 swizzle 回世界轴、按权重相加再归一化。

图 20-6纯灰色球 + 砖块法线,侧光照明。左到右:Swizzle、UDN、Whiteout、RNM。Swizzle 忽略了几何法线,球的明暗变成了三块“平面”,像一个立方体;后三种都保留了球体的整体明暗。UDN 在斜面上的砖缝稍浅,Whiteout 与 RNM 非常接近
选型建议地形与大型静态物体用 Whiteout 足够;需要严格正确(例如法线贴图强度很大、或者要和烘焙结果对照)时用 RNM。移动端如果连 6 次采样都嫌贵,可以只对颜色做 Triplanar,法线只用 Y 投影一张,再按 |N.y| 把它淡出到几何法线。
深入:为什么 Swizzle 不是完全错如果表面本身就是轴对齐的(建筑墙面、地板),几何法线 g 恰好等于投影轴,三种混合都退化成 Swizzle。所以在“盒子形”场景里 Swizzle 也能用,它的问题只出现在曲面和斜面上——而地形恰恰全是斜面。

20.2 按规则铺材质:高度与坡度

手刷权重图(下一节的 Splatmap)精确但费人力,大地图上更常见的是“规则驱动”:海拔低处是砂石,平缓处长草,坡度超过某个角度露出岩石,雪线以上积雪。这些规则只需要两个量——世界高度 positionWS.y 和坡度 1 − N.y(平地为 0,垂直崖壁为 1)。用 smoothstep 把它们映射成 0..1 的遮罩,再按顺序把各层叠上去。

直接这样做的结果会很“假”:高度遮罩是一条条等高线,坡度遮罩沿着网格的折线走,整个地形像一张地形图。解决办法有两个:一是给判断量加噪声,打破等高线;二是层与层之间不用线性插值,而用下一节的高度混合,让过渡带呈现出“石头缝先被填满”的细节。

案例 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);

这里有几个细节:

  • 噪声同时扰动高度和坡度。同一份低频噪声 noise 加到高度判断上(乘 _NoiseAmp 米),也加到坡度判断上(乘 0.25),雪线和岩石边界就都不再是光滑曲线。
  • 只有岩石做 Triplanar。草、砂、雪只出现在平缓处,用顶视投影一次采样足够;岩石专门出现在陡坡,必须三平面。这样整个 Shader 的采样数从“每层 3 次”降到“1+1+3+3(法线)+1+1(噪声)”。
  • 共用采样器。五张图都用 sampler_GrassTex 采样。在部分移动平台和图形 API 上,一个 Shader 能用的采样器(Sampler State)数量有限(常见上限是 16),地形 Shader 层数一多很容易超,URP 的 TerrainLit 也是所有层共用 sampler_Splat0。

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

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。分布和过渡是两件事,混在一起调很容易顾此失彼。

案例 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——它是数据,不是颜色。

案例 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 混合之后再做一次高度混合即可。

20.3 植被:为什么需要 GPU Instancing

一片 30 m × 30 m、间距 25 cm 的草地有一万多丛草。如果每丛是一个 GameObject,CPU 每帧要为一万个渲染器做剔除、排序、设置常量缓冲并提交 Draw Call;即便有 SRP Batcher 减少状态切换,物体数量本身的开销也足以占满主线程。GPU 实例化(GPU Instancing)把“同一个网格、同一个材质、画很多次”合并成一次调用:CPU 只上传一个矩阵数组,GPU 用 SV_InstanceID 区分每个实例,在顶点着色器里读出各自的矩阵。

Unity 提供了几种入口:

API实例数据来自说明
MeshRenderer + 材质勾选 Enable GPU Instancing引擎自动收集在 URP 中,Shader 兼容 SRP Batcher 时优先走 SRP Batcher,不会实例化
Graphics.RenderMeshInstancedC# 传入矩阵数组2021.2 起的新接口,参数打包在 RenderParams 里;本章使用它
Graphics.DrawMeshInstancedC# 传入矩阵数组旧接口,同样可用
RenderMeshIndirect / DrawMeshInstancedIndirectGPU 缓冲区(ComputeBuffer)实例数据完全在 GPU 上,可配合 Compute Shader 做剔除;Shader 需自行读取缓冲区

在 Shader 一侧,实例化需要三样东西:#pragma multi_compile_instancing 生成带 INSTANCING_ON 的变体;顶点输入里的 UNITY_VERTEX_INPUT_INSTANCE_ID 和顶点函数开头的 UNITY_SETUP_INSTANCE_ID(v)——后者会把 unity_ObjectToWorld 等矩阵重定向到当前实例的那一个,所以 TransformObjectToWorld 等函数完全不用改;如果还需要每个实例不同的材质参数,用 UNITY_INSTANCING_BUFFER_START/END 声明,UNITY_ACCESS_INSTANCED_PROP 读取。

CPU(每帧) 矩阵数组 ×1023 _Variation[] ×1023 → 一次 RenderMeshInstanced GPU:同一网格画 N 次 SV_InstanceID = 0 … N−1 UNITY_SETUP_INSTANCE_ID → unity_ObjectToWorld = 矩阵[ID] UNITY_ACCESS_INSTANCED_PROP → _Variation[ID] 顶点着色器:风、交互、淡出照常计算
图 20-13GPU Instancing 的数据流:CPU 一次提交一批实例数据,GPU 用实例 ID 取出各自的矩阵与属性

案例 20.7RenderMeshInstanced 画一片草地进阶单独打开 ↗

目标:不创建任何 GameObject,用 GPU Instancing 画出上万丛草,每丛颜色略有不同。

草丛网格

实例化的单位是“一丛”而不是“一片叶子”:一个网格里放 9 片草叶,随机分布在 22 cm 半径的圆盘里,每片有随机的朝向、高度、宽度和前倾量。这样一万个实例就是九万片叶子,而实例数(也就是 CPU 要准备的矩阵数)只有一万。每片草叶由 4 段梯形加一个尖顶组成,uv.y 记录沿叶片的高度(0 根部、1 叶尖),Shader 用它做弯曲权重;顶点色 R 存每片叶子的随机值。

Assets/Cookbook/Ch20/GrassRenderer.cs(节选:clumpMesh)
/// 一丛草:blades 片草叶随机分布在半径 radius 的圆盘里。
/// uv.y = 沿草叶的高度(0 根部,1 叶尖),Shader 用它当弯曲权重;color.r = 每片叶子的随机值
public static Mesh BuildClump(int blades = 9, float radius = 0.22f, int seed = 1)
{
    const int seg = 4;
    var rnd = new System.Random(seed);
    float R() => (float)rnd.NextDouble();
    var v = new List<Vector3>(); var n = new List<Vector3>(); var uv = new List<Vector2>();
    var col = new List<Color>(); var idx = new List<int>();
    for (int b = 0; b < blades; b++)
    {
        float a = R() * Mathf.PI * 2, r = Mathf.Sqrt(R()) * radius;
        var root = new Vector3(Mathf.Cos(a) * r, 0, Mathf.Sin(a) * r);
        float yaw = R() * Mathf.PI * 2;
        var side = new Vector3(Mathf.Cos(yaw), 0, Mathf.Sin(yaw));        // 叶片宽度方向
        var face = Vector3.Cross(side, Vector3.up);                         // 叶片朝向
        float h = Mathf.Lerp(0.35f, 0.7f, R()), w = Mathf.Lerp(0.03f, 0.05f, R());
        float lean = Mathf.Lerp(0.05f, 0.3f, R());                          // 叶尖前倾量
        float rand = R();
        int baseIdx = v.Count;
        for (int s = 0; s <= seg; s++)
        {
            float t = (float)s / seg;
            var center = root + Vector3.up * (h * t) + face * (lean * t * t);
            float ww = w * (1 - t * 0.85f);
            var bend = (Vector3.up - face * (2 * lean * t / h)).normalized;  // 沿叶片的切向
            var nrm = Vector3.Cross(bend, side).normalized;
            if (s < seg)
            {
                v.Add(center - side * ww); v.Add(center + side * ww);
                n.Add(nrm); n.Add(nrm);
                uv.Add(new Vector2(0, t)); uv.Add(new Vector2(1, t));
                col.Add(new Color(rand, 0, 0, 1)); col.Add(new Color(rand, 0, 0, 1));
            }
            else
            {
                v.Add(center); n.Add(nrm); uv.Add(new Vector2(0.5f, 1)); col.Add(new Color(rand, 0, 0, 1));
            }
        }
        for (int s = 0; s < seg - 1; s++)
        {
            int i0 = baseIdx + s * 2;
            idx.Add(i0); idx.Add(i0 + 2); idx.Add(i0 + 1);
            idx.Add(i0 + 1); idx.Add(i0 + 2); idx.Add(i0 + 3);
        }
        int last = baseIdx + (seg - 1) * 2;
        idx.Add(last); idx.Add(last + 2); idx.Add(last + 1);
    }
    var m = new Mesh { name = "GrassClump" };
    m.SetVertices(v); m.SetNormals(n); m.SetUVs(0, uv); m.SetColors(col); m.SetTriangles(idx, 0);
    m.RecalculateBounds();
    return m;
}
// 案例 20.7–20.9:批量绘制草丛 + 上传交互体(网页版,对应 Ch20/GrassRenderer.cs)。
// C# 用 Graphics.RenderMeshInstanced 每 1023 个实例一批提交;WebGL2 没有这个 API,
// 这里往场景里加一个“自定义绘制物体”:同一个草丛网格 + instanceCount,引擎用 drawElementsInstanced 一次画完,
// 每实例矩阵和 _Variation 打包进一张 RGBA32F 贴图(Grass.glsl 里按 gl_InstanceID 读取),
// 主画面、阴影 Pass 都走引擎的普通流程(和 RenderParams 里的 shadowCastingMode / receiveShadows 一样)。
//
// 实例数据在 C# 里由拍摄脚本 Ch20Shots.Grass() 调 SetInstances() 填入,不会被序列化导出,
// 所以网页版按同样的参数(区域、间距、随机种子、筛选规则)重新生成:
//   - System.Random 按 .NET 源码逐位复刻,位置、旋转、缩放、颜色随机值与 Unity 一致;
//   - 地面高度直接从场景里的地形网格插值(等价于 C# 里的高度函数 h(x, z));
//   - 缩放用到的 Mathf.PerlinNoise 是近似实现(只影响 ±20% 的大小起伏)。
import { mat4 } from '../engine/math.js';

const DATA_WIDTH = 1024;   // 与 Grass.glsl 的 GRASS_DATA_WIDTH 一致
const MAX_INTERACTORS = 8;

// 各配图调用 Ch20Shots.Grass(s, h, area, spacing, accept, seed) 的参数
const SHOTS = {
  'ch20-grass-field': { area: [-14, -8, 28, 34], spacing: 0.24 },
  'ch20-grass-interact': { area: [-6, -4, 12, 12], spacing: 0.16 },
  'ch20-grass-fade': { area: [-16, -6, 32, 40], spacing: 0.3 },
  // 只在平缓、低于雪线的地方长草
  'ch20-final': {
    area: [-16, -14, 30, 30], spacing: 0.26,
    accept: (x, z, h) => {
      const e = 0.3, y = h(x, z);
      const dx = (h(x + e, z) - h(x - e, z)) / (2 * e), dz = (h(x, z + e) - h(x, z - e)) / (2 * e);
      return dx * dx + dz * dz < 0.12 && y < 4 && y > -0.3;
    },
  },
};

export default class GrassRenderer {
  constructor(engine, json, obj, scene) {
    this.engine = engine; this.obj = obj; this.scene = scene;
    const d = json.data || {};
    this.d = d;
    this.castShadows = d.castShadows !== false;
    this.interactorRadius = d.interactorRadius ?? 1;
    // 导出数据里引用的 Transform 以 {component: 物体序号} 表示
    this.interactors = (d.interactors || []).map((t) => (t && t.component !== undefined && t.component !== null ? scene.objects[t.component] : null));
  }

  async init() {
    const E = this.engine, S = this.scene, d = this.d;
    const material = d.material && S.materialById(d.material.material);
    if (!material || !d.mesh || !d.mesh.mesh) return;
    const mesh = await E.assets.mesh(d.mesh.mesh);
    const { matrices, variation } = this.buildInstances();
    this.setInstances(matrices, variation, mesh, material);
  }

  /// 设置所有实例:矩阵(每个 12 个数 = 前三行)+ 每实例一个 0..1 的随机值(Shader 里用来做颜色变化)
  setInstances(matrices, variation, mesh, material) {
    const n = variation.length;
    if (!n) return;
    const texels = n * 4, rows = Math.ceil(texels / DATA_WIDTH);
    const data = new Float32Array(DATA_WIDTH * rows * 4);
    const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
    for (let i = 0; i < n; i++) {
      data.set(matrices.subarray(i * 12, i * 12 + 12), i * 16);
      data[i * 16 + 12] = variation[i];
      const p = [matrices[i * 12 + 3], matrices[i * 12 + 7], matrices[i * 12 + 11]];
      for (let k = 0; k < 3; k++) { min[k] = Math.min(min[k], p[k]); max[k] = Math.max(max[k], p[k]); }
    }
    const tex = this.engine.glc.texture2D(DATA_WIDTH, rows, 'RGBA32F', data, { mips: false, filter: 'Point', wrap: 'Clamp' });
    // 外扩 1 米,留给草高和弯曲
    const center = [0, 1, 2].map((k) => (min[k] + max[k]) * 0.5);
    const extents = [0, 1, 2].map((k) => (max[k] - min[k]) * 0.5 + 1);
    const o = this.obj;
    this.drawObj = {
      index: -1, name: o.name + ' (instanced)', active: true, layer: o.layer, json: o.json,
      matrix: mat4.identity(), invMatrix: mat4.identity(), flip: false,
      mesh, instanceCount: n,
      renderer: {
        enabled: true, materials: [material], castShadows: this.castShadows, shadowsOnly: false,
        receiveShadows: true, bounds: [center, extents], sortingOrder: 0,
      },
      uniforms: { _GrassInstanceData: tex },
    };
    this.scene.renderers.push(this.drawObj);
    this.count = n;
  }

  // 每帧(C# 是每个相机开始渲染时)上传交互体:全局数组,所有用到它的材质都能读到
  update() {
    if (this.drawObj) this.drawObj.active = this.obj.active !== false;
    const arr = new Float32Array(MAX_INTERACTORS * 4);
    let n = 0;
    for (const t of this.interactors) {
      if (!t || n >= MAX_INTERACTORS) continue;
      const m = t.matrix, s = (t.json.lossyScale || [1])[0];
      arr.set([m[12], m[13], m[14], this.interactorRadius * s], n * 4);
      n++;
    }
    this.engine.setGlobal('_Ch20Interactors', arr);
    this.engine.setGlobal('_Ch20InteractorCount', n);
  }

  // ---------------- 复刻 Ch20Shots.Grass() 的实例生成 ----------------
  buildInstances() {
    const cfg = SHOTS[this.scene.doc.id] || null;
    const h = this.terrainHeight();
    let area, spacing, accept = null;
    if (cfg) { area = cfg.area; spacing = cfg.spacing; accept = cfg.accept || null; }
    else {
      // 不认识的场景:铺满地形
      const b = this.terrain ? this.terrain.renderer.bounds : [[0, 0, 0], [5, 0, 5]];
      area = [b[0][0] - b[1][0], b[0][2] - b[1][2], b[1][0] * 2, b[1][2] * 2]; spacing = 0.3;
    }
    const f = Math.fround;
    const rnd = new DotNetRandom(5);
    const R = () => f(rnd.nextDouble());
    const mats = [], vars = [];
    const [ax, ay, aw, ah] = area.map(f);
    const sp = f(spacing);
    // C# 用 float 累加循环变量,次数要与之一致,所以全程用 Math.fround 模拟单精度
    for (let z = ay; z < f(ay + ah); z = f(z + sp)) {
      for (let x = ax; x < f(ax + aw); x = f(x + sp)) {
        const px = f(x + f(f(R() - 0.5) * sp));
        const pz = f(z + f(f(R() - 0.5) * sp));
        if (accept && !accept(px, pz, h)) continue;
        const py = h(px, pz);
        const sc = lerp(0.75, 1.35, R()) * lerp(0.8, 1.2, fbm(px * 0.3, pz * 0.3, 2));
        const yaw = (R() * 360 * Math.PI) / 180;
        // Matrix4x4.TRS(p, Euler(0, yaw, 0), sc) 的前三行(Unity 左手系绕 Y 旋转)
        const c = Math.cos(yaw) * sc, s = Math.sin(yaw) * sc;
        mats.push(c, 0, s, px, 0, sc, 0, py, -s, 0, c, pz);
        vars.push(R());
      }
    }
    return { matrices: new Float32Array(mats), variation: new Float32Array(vars) };
  }

  // 地形高度:在场景里名为 Terrain 的规则网格(Heightfield.Build 生成)上按三角形插值
  terrainHeight() {
    const t = this.scene.objects.find((o) => o.name === 'Terrain' && o.mesh && o.mesh.cpu && o.mesh.cpu.position);
    this.terrain = t;
    if (!t) return () => 0;
    const pos = t.mesh.cpu.position, vc = t.mesh.header.vertexCount;
    const N = Math.round(Math.sqrt(vc)) - 1;   // 每边段数
    const x0 = pos[0], z0 = pos[2], step = (pos[(N) * 3] - x0) / N;
    const m = t.matrix;   // 地形物体在原点,这里仍按平移处理
    const Y = (ix, iz) => pos[(iz * (N + 1) + ix) * 3 + 1];
    return (wx, wz) => {
      const gx = Math.min(Math.max((wx - m[12] - x0) / step, 0), N - 1e-4), gz = Math.min(Math.max((wz - m[14] - z0) / step, 0), N - 1e-4);
      const ix = Math.floor(gx), iz = Math.floor(gz), fx = gx - ix, fz = gz - iz;
      const h00 = Y(ix, iz), h10 = Y(ix + 1, iz), h01 = Y(ix, iz + 1), h11 = Y(ix + 1, iz + 1);
      // 与 Heightfield.Build 的三角形划分一致:(a, b, a+1) 与 (a+1, b, b+1)
      const y = fx + fz <= 1 ? h00 + fx * (h10 - h00) + fz * (h01 - h00) : h11 + (1 - fx) * (h01 - h11) + (1 - fz) * (h10 - h11);
      return y + m[13];
    };
  }
}

function lerp(a, b, t) { t = Math.min(Math.max(t, 0), 1); return a + (b - a) * t; }

// .NET(Mono)System.Random(seed) 的 Knuth 减法生成器,按参考源码逐行移植
class DotNetRandom {
  constructor(seed) {
    const MBIG = 2147483647, MSEED = 161803398;
    const a = new Int32Array(56);
    const sub = seed === -2147483648 ? 2147483647 : Math.abs(seed);
    let mj = MSEED - sub;
    a[55] = mj;
    let mk = 1;
    for (let i = 1; i < 55; i++) {
      const ii = (21 * i) % 55;
      a[ii] = mk;
      mk = mj - mk;
      if (mk < 0) mk += MBIG;
      mj = a[ii];
    }
    for (let k = 1; k < 5; k++)
      for (let i = 1; i < 56; i++) {
        a[i] -= a[1 + ((i + 30) % 55)];
        if (a[i] < 0) a[i] += MBIG;
      }
    this.a = a; this.inext = 0; this.inextp = 21; this.MBIG = MBIG;
  }
  sample() {
    let n = this.inext, np = this.inextp;
    if (++n >= 56) n = 1;
    if (++np >= 56) np = 1;
    let r = this.a[n] - this.a[np];
    if (r === this.MBIG) r--;
    if (r < 0) r += this.MBIG;
    this.a[n] = r;
    this.inext = n; this.inextp = np;
    return r;
  }
  nextDouble() { return this.sample() * (1 / this.MBIG); }
}

// Mathf.PerlinNoise 的近似(经典改进 Perlin 噪声,输出约 0..1)与 Heightfield.Fbm
const PERM = [151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142, 8, 99, 37, 240, 21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177, 33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122, 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25, 63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186, 3, 64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59, 227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9, 129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218, 246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241, 81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157, 184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156, 180];
const P = new Int32Array(512);
for (let i = 0; i < 512; i++) P[i] = PERM[i & 255];
function grad(h, x, y) { h &= 15; const u = h < 8 ? x : y, v = h < 4 ? y : h === 12 || h === 14 ? x : 0; return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v); }
function perlin(x, y) {
  const X = Math.floor(x) & 255, Y = Math.floor(y) & 255;
  x -= Math.floor(x); y -= Math.floor(y);
  const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10);
  const u = fade(x), v = fade(y);
  const A = P[X] + Y, B = P[X + 1] + Y;
  const l = (t, a, b) => a + t * (b - a);
  const n = l(v, l(u, grad(P[P[A]], x, y), grad(P[P[B]], x - 1, y)), l(u, grad(P[P[A + 1]], x, y - 1), grad(P[P[B + 1]], x - 1, y - 1)));
  return Math.min(Math.max((n + 1) * 0.5, 0), 1);
}
function fbm(x, z, octaves = 5) {
  let sum = 0, amp = 0.5, freq = 1, norm = 0;
  for (let i = 0; i < octaves; i++) {
    sum += perlin(x * freq + i * 17.13, z * freq - i * 9.71) * amp;
    norm += amp; amp *= 0.5; freq *= 2.03;
  }
  return sum / norm;
}

提交实例

Assets/Cookbook/Ch20/GrassRenderer.cs(节选:setInstances)
/// 设置所有实例:矩阵 + 每实例一个 0..1 的随机值(Shader 里用来做颜色变化)
public void SetInstances(IList<Matrix4x4> matrices, IList<float> variation)
{
    m_Batches.Clear();
    m_Props.Clear();
    var min = Vector3.one * float.MaxValue;
    var max = Vector3.one * float.MinValue;
    for (int start = 0; start < matrices.Count; start += BatchSize)
    {
        int count = Mathf.Min(BatchSize, matrices.Count - start);
        var mats = new Matrix4x4[count];
        var vars = new float[count];
        for (int i = 0; i < count; i++)
        {
            mats[i] = matrices[start + i];
            vars[i] = variation[start + i];
            Vector3 p = mats[i].GetColumn(3);
            min = Vector3.Min(min, p); max = Vector3.Max(max, p);
        }
        // 每批一个 MaterialPropertyBlock,数组属性会按实例分发到 UNITY_INSTANCING_BUFFER
        var mpb = new MaterialPropertyBlock();
        mpb.SetFloatArray(s_Variation, vars);
        m_Batches.Add(mats);
        m_Props.Add(mpb);
    }
    m_Bounds = new Bounds((min + max) * 0.5f, max - min + Vector3.one * 2f);  // 外扩 1 米,留给草高和弯曲
}
/// 设置所有实例:矩阵(每个 12 个数 = 前三行)+ 每实例一个 0..1 的随机值(Shader 里用来做颜色变化)
setInstances(matrices, variation, mesh, material) {
  const n = variation.length;
  if (!n) return;
  const texels = n * 4, rows = Math.ceil(texels / DATA_WIDTH);
  const data = new Float32Array(DATA_WIDTH * rows * 4);
  const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
  for (let i = 0; i < n; i++) {
    data.set(matrices.subarray(i * 12, i * 12 + 12), i * 16);
    data[i * 16 + 12] = variation[i];
    const p = [matrices[i * 12 + 3], matrices[i * 12 + 7], matrices[i * 12 + 11]];
    for (let k = 0; k < 3; k++) { min[k] = Math.min(min[k], p[k]); max[k] = Math.max(max[k], p[k]); }
  }
  const tex = this.engine.glc.texture2D(DATA_WIDTH, rows, 'RGBA32F', data, { mips: false, filter: 'Point', wrap: 'Clamp' });
  // 外扩 1 米,留给草高和弯曲
  const center = [0, 1, 2].map((k) => (min[k] + max[k]) * 0.5);
  const extents = [0, 1, 2].map((k) => (max[k] - min[k]) * 0.5 + 1);
  const o = this.obj;
  this.drawObj = {
    index: -1, name: o.name + ' (instanced)', active: true, layer: o.layer, json: o.json,
    matrix: mat4.identity(), invMatrix: mat4.identity(), flip: false,
    mesh, instanceCount: n,
    renderer: {
      enabled: true, materials: [material], castShadows: this.castShadows, shadowsOnly: false,
      receiveShadows: true, bounds: [center, extents], sortingOrder: 0,
    },
    uniforms: { _GrassInstanceData: tex },
  };
  this.scene.renderers.push(this.drawObj);
  this.count = n;
}

实例按每批 1023 个切分:传统的实例化路径把矩阵数组放在常量缓冲区里,大小受限,DrawMeshInstanced 的上限就是 1023;RenderMeshInstanced 的文档对单次数量的描述各版本略有出入,按 1023 分批在所有版本上都安全。每批配一个 MaterialPropertyBlock,用 SetFloatArray 传入数组——数组下标与实例下标一一对应,Unity 会把它分发到 Shader 的 UNITY_INSTANCING_BUFFER 里。worldBounds 是整片草地的包围盒,用于视锥剔除:整批要么全画、要么全不画,所以大片草地应按区块(例如 16 m × 16 m)分组,各自给包围盒,否则看不见的草也会全部提交。

Assets/Cookbook/Ch20/GrassRenderer.cs(节选:draw)
void OnEnable() { RenderPipelineManager.beginCameraRendering += OnBeginCamera; }
void OnDisable() { RenderPipelineManager.beginCameraRendering -= OnBeginCamera; }

// 在每个相机开始渲染时提交:编辑模式、Scene 视图、手动 Camera.Render() 都能画出来。
// 只在 Play 模式下用的话,放进 Update() 里调用、不指定 camera 也可以。
void OnBeginCamera(ScriptableRenderContext context, Camera cam)
{
    if (mesh == null || material == null || m_Batches.Count == 0) return;
    UploadInteractors();
    var rp = new RenderParams(material)
    {
        camera = cam,                          // 只画进当前这个相机
        worldBounds = m_Bounds,                // 整片草地的包围盒,用于视锥剔除
        shadowCastingMode = castShadows ? ShadowCastingMode.On : ShadowCastingMode.Off,
        receiveShadows = true,
        layer = gameObject.layer,
    };
    for (int i = 0; i < m_Batches.Count; i++)
    {
        rp.matProps = m_Props[i];
        Graphics.RenderMeshInstanced(rp, mesh, 0, m_Batches[i]);
    }
}
// 案例 20.7–20.9:批量绘制草丛 + 上传交互体(网页版,对应 Ch20/GrassRenderer.cs)。
// C# 用 Graphics.RenderMeshInstanced 每 1023 个实例一批提交;WebGL2 没有这个 API,
// 这里往场景里加一个“自定义绘制物体”:同一个草丛网格 + instanceCount,引擎用 drawElementsInstanced 一次画完,
// 每实例矩阵和 _Variation 打包进一张 RGBA32F 贴图(Grass.glsl 里按 gl_InstanceID 读取),
// 主画面、阴影 Pass 都走引擎的普通流程(和 RenderParams 里的 shadowCastingMode / receiveShadows 一样)。
//
// 实例数据在 C# 里由拍摄脚本 Ch20Shots.Grass() 调 SetInstances() 填入,不会被序列化导出,
// 所以网页版按同样的参数(区域、间距、随机种子、筛选规则)重新生成:
//   - System.Random 按 .NET 源码逐位复刻,位置、旋转、缩放、颜色随机值与 Unity 一致;
//   - 地面高度直接从场景里的地形网格插值(等价于 C# 里的高度函数 h(x, z));
//   - 缩放用到的 Mathf.PerlinNoise 是近似实现(只影响 ±20% 的大小起伏)。
import { mat4 } from '../engine/math.js';

const DATA_WIDTH = 1024;   // 与 Grass.glsl 的 GRASS_DATA_WIDTH 一致
const MAX_INTERACTORS = 8;

// 各配图调用 Ch20Shots.Grass(s, h, area, spacing, accept, seed) 的参数
const SHOTS = {
  'ch20-grass-field': { area: [-14, -8, 28, 34], spacing: 0.24 },
  'ch20-grass-interact': { area: [-6, -4, 12, 12], spacing: 0.16 },
  'ch20-grass-fade': { area: [-16, -6, 32, 40], spacing: 0.3 },
  // 只在平缓、低于雪线的地方长草
  'ch20-final': {
    area: [-16, -14, 30, 30], spacing: 0.26,
    accept: (x, z, h) => {
      const e = 0.3, y = h(x, z);
      const dx = (h(x + e, z) - h(x - e, z)) / (2 * e), dz = (h(x, z + e) - h(x, z - e)) / (2 * e);
      return dx * dx + dz * dz < 0.12 && y < 4 && y > -0.3;
    },
  },
};

export default class GrassRenderer {
  constructor(engine, json, obj, scene) {
    this.engine = engine; this.obj = obj; this.scene = scene;
    const d = json.data || {};
    this.d = d;
    this.castShadows = d.castShadows !== false;
    this.interactorRadius = d.interactorRadius ?? 1;
    // 导出数据里引用的 Transform 以 {component: 物体序号} 表示
    this.interactors = (d.interactors || []).map((t) => (t && t.component !== undefined && t.component !== null ? scene.objects[t.component] : null));
  }

  async init() {
    const E = this.engine, S = this.scene, d = this.d;
    const material = d.material && S.materialById(d.material.material);
    if (!material || !d.mesh || !d.mesh.mesh) return;
    const mesh = await E.assets.mesh(d.mesh.mesh);
    const { matrices, variation } = this.buildInstances();
    this.setInstances(matrices, variation, mesh, material);
  }

  /// 设置所有实例:矩阵(每个 12 个数 = 前三行)+ 每实例一个 0..1 的随机值(Shader 里用来做颜色变化)
  setInstances(matrices, variation, mesh, material) {
    const n = variation.length;
    if (!n) return;
    const texels = n * 4, rows = Math.ceil(texels / DATA_WIDTH);
    const data = new Float32Array(DATA_WIDTH * rows * 4);
    const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
    for (let i = 0; i < n; i++) {
      data.set(matrices.subarray(i * 12, i * 12 + 12), i * 16);
      data[i * 16 + 12] = variation[i];
      const p = [matrices[i * 12 + 3], matrices[i * 12 + 7], matrices[i * 12 + 11]];
      for (let k = 0; k < 3; k++) { min[k] = Math.min(min[k], p[k]); max[k] = Math.max(max[k], p[k]); }
    }
    const tex = this.engine.glc.texture2D(DATA_WIDTH, rows, 'RGBA32F', data, { mips: false, filter: 'Point', wrap: 'Clamp' });
    // 外扩 1 米,留给草高和弯曲
    const center = [0, 1, 2].map((k) => (min[k] + max[k]) * 0.5);
    const extents = [0, 1, 2].map((k) => (max[k] - min[k]) * 0.5 + 1);
    const o = this.obj;
    this.drawObj = {
      index: -1, name: o.name + ' (instanced)', active: true, layer: o.layer, json: o.json,
      matrix: mat4.identity(), invMatrix: mat4.identity(), flip: false,
      mesh, instanceCount: n,
      renderer: {
        enabled: true, materials: [material], castShadows: this.castShadows, shadowsOnly: false,
        receiveShadows: true, bounds: [center, extents], sortingOrder: 0,
      },
      uniforms: { _GrassInstanceData: tex },
    };
    this.scene.renderers.push(this.drawObj);
    this.count = n;
  }

  // 每帧(C# 是每个相机开始渲染时)上传交互体:全局数组,所有用到它的材质都能读到
  update() {
    if (this.drawObj) this.drawObj.active = this.obj.active !== false;
    const arr = new Float32Array(MAX_INTERACTORS * 4);
    let n = 0;
    for (const t of this.interactors) {
      if (!t || n >= MAX_INTERACTORS) continue;
      const m = t.matrix, s = (t.json.lossyScale || [1])[0];
      arr.set([m[12], m[13], m[14], this.interactorRadius * s], n * 4);
      n++;
    }
    this.engine.setGlobal('_Ch20Interactors', arr);
    this.engine.setGlobal('_Ch20InteractorCount', n);
  }

  // ---------------- 复刻 Ch20Shots.Grass() 的实例生成 ----------------
  buildInstances() {
    const cfg = SHOTS[this.scene.doc.id] || null;
    const h = this.terrainHeight();
    let area, spacing, accept = null;
    if (cfg) { area = cfg.area; spacing = cfg.spacing; accept = cfg.accept || null; }
    else {
      // 不认识的场景:铺满地形
      const b = this.terrain ? this.terrain.renderer.bounds : [[0, 0, 0], [5, 0, 5]];
      area = [b[0][0] - b[1][0], b[0][2] - b[1][2], b[1][0] * 2, b[1][2] * 2]; spacing = 0.3;
    }
    const f = Math.fround;
    const rnd = new DotNetRandom(5);
    const R = () => f(rnd.nextDouble());
    const mats = [], vars = [];
    const [ax, ay, aw, ah] = area.map(f);
    const sp = f(spacing);
    // C# 用 float 累加循环变量,次数要与之一致,所以全程用 Math.fround 模拟单精度
    for (let z = ay; z < f(ay + ah); z = f(z + sp)) {
      for (let x = ax; x < f(ax + aw); x = f(x + sp)) {
        const px = f(x + f(f(R() - 0.5) * sp));
        const pz = f(z + f(f(R() - 0.5) * sp));
        if (accept && !accept(px, pz, h)) continue;
        const py = h(px, pz);
        const sc = lerp(0.75, 1.35, R()) * lerp(0.8, 1.2, fbm(px * 0.3, pz * 0.3, 2));
        const yaw = (R() * 360 * Math.PI) / 180;
        // Matrix4x4.TRS(p, Euler(0, yaw, 0), sc) 的前三行(Unity 左手系绕 Y 旋转)
        const c = Math.cos(yaw) * sc, s = Math.sin(yaw) * sc;
        mats.push(c, 0, s, px, 0, sc, 0, py, -s, 0, c, pz);
        vars.push(R());
      }
    }
    return { matrices: new Float32Array(mats), variation: new Float32Array(vars) };
  }

  // 地形高度:在场景里名为 Terrain 的规则网格(Heightfield.Build 生成)上按三角形插值
  terrainHeight() {
    const t = this.scene.objects.find((o) => o.name === 'Terrain' && o.mesh && o.mesh.cpu && o.mesh.cpu.position);
    this.terrain = t;
    if (!t) return () => 0;
    const pos = t.mesh.cpu.position, vc = t.mesh.header.vertexCount;
    const N = Math.round(Math.sqrt(vc)) - 1;   // 每边段数
    const x0 = pos[0], z0 = pos[2], step = (pos[(N) * 3] - x0) / N;
    const m = t.matrix;   // 地形物体在原点,这里仍按平移处理
    const Y = (ix, iz) => pos[(iz * (N + 1) + ix) * 3 + 1];
    return (wx, wz) => {
      const gx = Math.min(Math.max((wx - m[12] - x0) / step, 0), N - 1e-4), gz = Math.min(Math.max((wz - m[14] - z0) / step, 0), N - 1e-4);
      const ix = Math.floor(gx), iz = Math.floor(gz), fx = gx - ix, fz = gz - iz;
      const h00 = Y(ix, iz), h10 = Y(ix + 1, iz), h01 = Y(ix, iz + 1), h11 = Y(ix + 1, iz + 1);
      // 与 Heightfield.Build 的三角形划分一致:(a, b, a+1) 与 (a+1, b, b+1)
      const y = fx + fz <= 1 ? h00 + fx * (h10 - h00) + fz * (h01 - h00) : h11 + (1 - fx) * (h01 - h11) + (1 - fz) * (h10 - h11);
      return y + m[13];
    };
  }
}

function lerp(a, b, t) { t = Math.min(Math.max(t, 0), 1); return a + (b - a) * t; }

// .NET(Mono)System.Random(seed) 的 Knuth 减法生成器,按参考源码逐行移植
class DotNetRandom {
  constructor(seed) {
    const MBIG = 2147483647, MSEED = 161803398;
    const a = new Int32Array(56);
    const sub = seed === -2147483648 ? 2147483647 : Math.abs(seed);
    let mj = MSEED - sub;
    a[55] = mj;
    let mk = 1;
    for (let i = 1; i < 55; i++) {
      const ii = (21 * i) % 55;
      a[ii] = mk;
      mk = mj - mk;
      if (mk < 0) mk += MBIG;
      mj = a[ii];
    }
    for (let k = 1; k < 5; k++)
      for (let i = 1; i < 56; i++) {
        a[i] -= a[1 + ((i + 30) % 55)];
        if (a[i] < 0) a[i] += MBIG;
      }
    this.a = a; this.inext = 0; this.inextp = 21; this.MBIG = MBIG;
  }
  sample() {
    let n = this.inext, np = this.inextp;
    if (++n >= 56) n = 1;
    if (++np >= 56) np = 1;
    let r = this.a[n] - this.a[np];
    if (r === this.MBIG) r--;
    if (r < 0) r += this.MBIG;
    this.a[n] = r;
    this.inext = n; this.inextp = np;
    return r;
  }
  nextDouble() { return this.sample() * (1 / this.MBIG); }
}

// Mathf.PerlinNoise 的近似(经典改进 Perlin 噪声,输出约 0..1)与 Heightfield.Fbm
const PERM = [151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142, 8, 99, 37, 240, 21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177, 33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122, 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25, 63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186, 3, 64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59, 227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9, 129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218, 246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241, 81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157, 184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156, 180];
const P = new Int32Array(512);
for (let i = 0; i < 512; i++) P[i] = PERM[i & 255];
function grad(h, x, y) { h &= 15; const u = h < 8 ? x : y, v = h < 4 ? y : h === 12 || h === 14 ? x : 0; return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v); }
function perlin(x, y) {
  const X = Math.floor(x) & 255, Y = Math.floor(y) & 255;
  x -= Math.floor(x); y -= Math.floor(y);
  const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10);
  const u = fade(x), v = fade(y);
  const A = P[X] + Y, B = P[X + 1] + Y;
  const l = (t, a, b) => a + t * (b - a);
  const n = l(v, l(u, grad(P[P[A]], x, y), grad(P[P[B]], x - 1, y)), l(u, grad(P[P[A + 1]], x, y - 1), grad(P[P[B + 1]], x - 1, y - 1)));
  return Math.min(Math.max((n + 1) * 0.5, 0), 1);
}
function fbm(x, z, octaves = 5) {
  let sum = 0, amp = 0.5, freq = 1, norm = 0;
  for (let i = 0; i < octaves; i++) {
    sum += perlin(x * freq + i * 17.13, z * freq - i * 9.71) * amp;
    norm += amp; amp *= 0.5; freq *= 2.03;
  }
  return sum / norm;
}

提交时机值得专门说明。最常见的写法是在 Update() 里调用 Graphics.RenderMeshInstanced,它会把这批绘制加入“本帧”的渲染列表,Play 模式下完全没问题。但在编辑模式、或者像本书配图这样在批处理模式里手动调用 Camera.Render() 时,Update 不会按每次渲染被调用,加入的绘制只对紧随其后的一次渲染有效。我们实测的结论是:挂一个 [ExecuteAlways] 组件,在 RenderPipelineManager.beginCameraRendering 回调里、并且通过 RenderParams.camera 指定当前相机提交,编辑器 Scene 视图、Game 视图和手动 Camera.Render() 都能稳定画出来(URP 在这个回调之后才做剔除,所以阴影 Pass 也能看到这些实例)。

Shader:每实例属性

Assets/Cookbook/Ch20/Grass.shader(节选:instanceProps)
// 每实例属性:C# 用 MaterialPropertyBlock.SetFloatArray("_Variation", ...) 按批传入
UNITY_INSTANCING_BUFFER_START(GrassProps)
    UNITY_DEFINE_INSTANCED_PROP(float, _Variation)
UNITY_INSTANCING_BUFFER_END(GrassProps)
// 每实例属性:C# 用 MaterialPropertyBlock.SetFloatArray("_Variation", ...) 按批传入 UNITY_INSTANCING_BUFFER;
// WebGL 没有这套宏,每实例占贴图里连续 4 个像素:矩阵的前三行 + (_Variation, 0, 0, 0)
uniform highp sampler2D _GrassInstanceData;
#define GRASS_DATA_WIDTH 1024

没有开启实例化的变体里,这个宏会退化成一个普通的 float _Variation;开启后变成按 unity_InstanceID 索引的数组。读取时一律写 UNITY_ACCESS_INSTANCED_PROP(GrassProps, _Variation),两种变体都能编译。片元着色器如果也要读实例属性,Varyings 里必须带上 UNITY_VERTEX_INPUT_INSTANCE_ID,并在顶点里 UNITY_TRANSFER_INSTANCE_ID、片元里 UNITY_SETUP_INSTANCE_ID。

Assets/Cookbook/Ch20/Grass.shader(节选:frag)
half4 frag(Varyings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    // 距离淡出的后半段:抖动裁剪,避免草在某个距离“啪”地整片消失
    clip(i.data.z - Bayer4x4(i.positionCS.xy));

    float h = i.data.x;
    half3 albedo = lerp(_RootColor.rgb, _TipColor.rgb, h);
    albedo = lerp(albedo, _DryColor.rgb * lerp(0.6, 1.0, h), smoothstep(1.0 - _DryAmount, 1.0, i.data.y));
    albedo *= lerp(0.85, 1.15, frac(i.data.y * 7.31));

    // 双面:背面翻转法线;再往“上”方向拉,让整片草地的明暗更柔和统一
    half3 n = normalize(i.normalWS) * (IS_FRONT_VFACE(face, 1, -1));
    n = normalize(lerp(n, half3(0, 1, 0), _NormalUp));
    half occlusion = lerp(0.35, 1.0, h);    // 根部互相遮挡,压暗
    half4 color = Ch20ShadePBR(i.positionWS, n, i.positionCS, albedo, 0, 0.2, occlusion, i.data.w);

    // 逆光透射:视线与光线方向接近相反时,叶尖透出亮绿色
    Light mainLight = GetMainLight(TransformWorldToShadowCoord(i.positionWS));
    half3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    half back = pow(saturate(dot(V, -mainLight.direction)), 4.0);
    color.rgb += albedo * mainLight.color * (mainLight.shadowAttenuation * back * _Translucency * h);
    return color;
}
vec4 frag()
{
    // 距离淡出的后半段:抖动裁剪,避免草在某个距离“啪”地整片消失
    if (data.z - Bayer4x4(gl_FragCoord.xy) < 0.0) discard;

    float h = data.x;
    vec3 albedo = mix(_RootColor.rgb, _TipColor.rgb, h);
    albedo = mix(albedo, _DryColor.rgb * mix(0.6, 1.0, h), smoothstep(1.0 - _DryAmount, 1.0, data.y));
    albedo *= mix(0.85, 1.15, fract(data.y * 7.31));

    // 双面:背面翻转法线;再往“上”方向拉,让整片草地的明暗更柔和统一
    vec3 n = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0);
    n = normalize(mix(n, vec3(0, 1, 0), _NormalUp));
    float occlusion = mix(0.35, 1.0, h);    // 根部互相遮挡,压暗
    vec4 color = Ch20ShadePBR(positionWS, n, gl_FragCoord, albedo, 0.0, 0.2, occlusion, data.w);

    // 逆光透射:视线与光线方向接近相反时,叶尖透出亮绿色
    Light mainLight = GetMainLight(TransformWorldToShadowCoord(positionWS));
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    float back = pow(saturate(dot(V, -mainLight.direction)), 4.0);
    color.rgb += albedo * mainLight.color * (mainLight.shadowAttenuation * back * _Translucency * h);
    return color;
}

草的着色做了三处“作弊”:法线向世界上方偏一半(_NormalUp),让成片的草明暗统一,不至于一片片叶子各自闪烁;根部按高度压暗,模拟草丛内部互相遮挡;再加一个逆光透射项——视线与光线方向接近相反时,叶片透出亮绿色,这是草地在夕阳下最有特征的样子。

图 20-14约一万丛、九万片草叶,全部由 Graphics.RenderMeshInstanced 绘制。逆光照明下叶尖的透射清晰可见;远处的草按距离淡出
常见坑① 材质没勾 enableInstancing:RenderMeshInstanced 会直接报错,代码里要 mat.enableInstancing = true。② Shader 漏了 #pragma multi_compile_instancing:没有实例化变体,所有实例画在同一个位置。③ ShadowCaster、DepthOnly 等 Pass 也要加这条 pragma 并调用 UNITY_SETUP_INSTANCE_ID,否则阴影全叠在原点。④ 阴影的代价:草地投射阴影会让阴影 Pass 再画一遍所有实例,远处的草通常应关闭投影(分两批,近处 ShadowCastingMode.On、远处 Off)。

案例 20.8草的交互压弯进阶单独打开 ↗

目标:角色或物体经过时,周围的草被推开、压低。

最简单而有效的做法是把“交互体”的位置和半径作为全局数组传给 Shader,每个草叶顶点计算自己到每个交互体的水平距离,在半径以内的就沿远离方向推开。全局变量不放进 UnityPerMaterial,由 Shader.SetGlobalVectorArray 设置,所有用到它的材质(草、花、灌木)都能读到。

Assets/Cookbook/Ch20/Grass.shader(节选:globals)
// 交互体:全局数组,xyz = 世界坐标,w = 影响半径。不放进 UnityPerMaterial,由 Shader.SetGlobal* 设置
float4 _Ch20Interactors[8];
float  _Ch20InteractorCount;
// 交互体:全局数组,xyz = 世界坐标,w = 影响半径。不放进材质参数,由 GrassRenderer.js 按全局变量设置(同 Shader.SetGlobal*)
uniform vec4  _Ch20Interactors[8];
uniform float _Ch20InteractorCount;
Assets/Cookbook/Ch20/GrassRenderer.cs(节选:interactors)
void UploadInteractors()
{
    int n = 0;
    if (interactors != null)
        foreach (var t in interactors)
        {
            if (t == null || n >= MaxInteractors) continue;
            var p = t.position;
            m_InteractorData[n++] = new Vector4(p.x, p.y, p.z, interactorRadius * t.lossyScale.x);
        }
    // 全局数组:所有用到它的材质都能读到(草、灌木、花……)
    Shader.SetGlobalVectorArray(s_Interactors, m_InteractorData);
    Shader.SetGlobalFloat(s_InteractorCount, n);
}
// 案例 20.7–20.9:批量绘制草丛 + 上传交互体(网页版,对应 Ch20/GrassRenderer.cs)。
// C# 用 Graphics.RenderMeshInstanced 每 1023 个实例一批提交;WebGL2 没有这个 API,
// 这里往场景里加一个“自定义绘制物体”:同一个草丛网格 + instanceCount,引擎用 drawElementsInstanced 一次画完,
// 每实例矩阵和 _Variation 打包进一张 RGBA32F 贴图(Grass.glsl 里按 gl_InstanceID 读取),
// 主画面、阴影 Pass 都走引擎的普通流程(和 RenderParams 里的 shadowCastingMode / receiveShadows 一样)。
//
// 实例数据在 C# 里由拍摄脚本 Ch20Shots.Grass() 调 SetInstances() 填入,不会被序列化导出,
// 所以网页版按同样的参数(区域、间距、随机种子、筛选规则)重新生成:
//   - System.Random 按 .NET 源码逐位复刻,位置、旋转、缩放、颜色随机值与 Unity 一致;
//   - 地面高度直接从场景里的地形网格插值(等价于 C# 里的高度函数 h(x, z));
//   - 缩放用到的 Mathf.PerlinNoise 是近似实现(只影响 ±20% 的大小起伏)。
import { mat4 } from '../engine/math.js';

const DATA_WIDTH = 1024;   // 与 Grass.glsl 的 GRASS_DATA_WIDTH 一致
const MAX_INTERACTORS = 8;

// 各配图调用 Ch20Shots.Grass(s, h, area, spacing, accept, seed) 的参数
const SHOTS = {
  'ch20-grass-field': { area: [-14, -8, 28, 34], spacing: 0.24 },
  'ch20-grass-interact': { area: [-6, -4, 12, 12], spacing: 0.16 },
  'ch20-grass-fade': { area: [-16, -6, 32, 40], spacing: 0.3 },
  // 只在平缓、低于雪线的地方长草
  'ch20-final': {
    area: [-16, -14, 30, 30], spacing: 0.26,
    accept: (x, z, h) => {
      const e = 0.3, y = h(x, z);
      const dx = (h(x + e, z) - h(x - e, z)) / (2 * e), dz = (h(x, z + e) - h(x, z - e)) / (2 * e);
      return dx * dx + dz * dz < 0.12 && y < 4 && y > -0.3;
    },
  },
};

export default class GrassRenderer {
  constructor(engine, json, obj, scene) {
    this.engine = engine; this.obj = obj; this.scene = scene;
    const d = json.data || {};
    this.d = d;
    this.castShadows = d.castShadows !== false;
    this.interactorRadius = d.interactorRadius ?? 1;
    // 导出数据里引用的 Transform 以 {component: 物体序号} 表示
    this.interactors = (d.interactors || []).map((t) => (t && t.component !== undefined && t.component !== null ? scene.objects[t.component] : null));
  }

  async init() {
    const E = this.engine, S = this.scene, d = this.d;
    const material = d.material && S.materialById(d.material.material);
    if (!material || !d.mesh || !d.mesh.mesh) return;
    const mesh = await E.assets.mesh(d.mesh.mesh);
    const { matrices, variation } = this.buildInstances();
    this.setInstances(matrices, variation, mesh, material);
  }

  /// 设置所有实例:矩阵(每个 12 个数 = 前三行)+ 每实例一个 0..1 的随机值(Shader 里用来做颜色变化)
  setInstances(matrices, variation, mesh, material) {
    const n = variation.length;
    if (!n) return;
    const texels = n * 4, rows = Math.ceil(texels / DATA_WIDTH);
    const data = new Float32Array(DATA_WIDTH * rows * 4);
    const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
    for (let i = 0; i < n; i++) {
      data.set(matrices.subarray(i * 12, i * 12 + 12), i * 16);
      data[i * 16 + 12] = variation[i];
      const p = [matrices[i * 12 + 3], matrices[i * 12 + 7], matrices[i * 12 + 11]];
      for (let k = 0; k < 3; k++) { min[k] = Math.min(min[k], p[k]); max[k] = Math.max(max[k], p[k]); }
    }
    const tex = this.engine.glc.texture2D(DATA_WIDTH, rows, 'RGBA32F', data, { mips: false, filter: 'Point', wrap: 'Clamp' });
    // 外扩 1 米,留给草高和弯曲
    const center = [0, 1, 2].map((k) => (min[k] + max[k]) * 0.5);
    const extents = [0, 1, 2].map((k) => (max[k] - min[k]) * 0.5 + 1);
    const o = this.obj;
    this.drawObj = {
      index: -1, name: o.name + ' (instanced)', active: true, layer: o.layer, json: o.json,
      matrix: mat4.identity(), invMatrix: mat4.identity(), flip: false,
      mesh, instanceCount: n,
      renderer: {
        enabled: true, materials: [material], castShadows: this.castShadows, shadowsOnly: false,
        receiveShadows: true, bounds: [center, extents], sortingOrder: 0,
      },
      uniforms: { _GrassInstanceData: tex },
    };
    this.scene.renderers.push(this.drawObj);
    this.count = n;
  }

  // 每帧(C# 是每个相机开始渲染时)上传交互体:全局数组,所有用到它的材质都能读到
  update() {
    if (this.drawObj) this.drawObj.active = this.obj.active !== false;
    const arr = new Float32Array(MAX_INTERACTORS * 4);
    let n = 0;
    for (const t of this.interactors) {
      if (!t || n >= MAX_INTERACTORS) continue;
      const m = t.matrix, s = (t.json.lossyScale || [1])[0];
      arr.set([m[12], m[13], m[14], this.interactorRadius * s], n * 4);
      n++;
    }
    this.engine.setGlobal('_Ch20Interactors', arr);
    this.engine.setGlobal('_Ch20InteractorCount', n);
  }

  // ---------------- 复刻 Ch20Shots.Grass() 的实例生成 ----------------
  buildInstances() {
    const cfg = SHOTS[this.scene.doc.id] || null;
    const h = this.terrainHeight();
    let area, spacing, accept = null;
    if (cfg) { area = cfg.area; spacing = cfg.spacing; accept = cfg.accept || null; }
    else {
      // 不认识的场景:铺满地形
      const b = this.terrain ? this.terrain.renderer.bounds : [[0, 0, 0], [5, 0, 5]];
      area = [b[0][0] - b[1][0], b[0][2] - b[1][2], b[1][0] * 2, b[1][2] * 2]; spacing = 0.3;
    }
    const f = Math.fround;
    const rnd = new DotNetRandom(5);
    const R = () => f(rnd.nextDouble());
    const mats = [], vars = [];
    const [ax, ay, aw, ah] = area.map(f);
    const sp = f(spacing);
    // C# 用 float 累加循环变量,次数要与之一致,所以全程用 Math.fround 模拟单精度
    for (let z = ay; z < f(ay + ah); z = f(z + sp)) {
      for (let x = ax; x < f(ax + aw); x = f(x + sp)) {
        const px = f(x + f(f(R() - 0.5) * sp));
        const pz = f(z + f(f(R() - 0.5) * sp));
        if (accept && !accept(px, pz, h)) continue;
        const py = h(px, pz);
        const sc = lerp(0.75, 1.35, R()) * lerp(0.8, 1.2, fbm(px * 0.3, pz * 0.3, 2));
        const yaw = (R() * 360 * Math.PI) / 180;
        // Matrix4x4.TRS(p, Euler(0, yaw, 0), sc) 的前三行(Unity 左手系绕 Y 旋转)
        const c = Math.cos(yaw) * sc, s = Math.sin(yaw) * sc;
        mats.push(c, 0, s, px, 0, sc, 0, py, -s, 0, c, pz);
        vars.push(R());
      }
    }
    return { matrices: new Float32Array(mats), variation: new Float32Array(vars) };
  }

  // 地形高度:在场景里名为 Terrain 的规则网格(Heightfield.Build 生成)上按三角形插值
  terrainHeight() {
    const t = this.scene.objects.find((o) => o.name === 'Terrain' && o.mesh && o.mesh.cpu && o.mesh.cpu.position);
    this.terrain = t;
    if (!t) return () => 0;
    const pos = t.mesh.cpu.position, vc = t.mesh.header.vertexCount;
    const N = Math.round(Math.sqrt(vc)) - 1;   // 每边段数
    const x0 = pos[0], z0 = pos[2], step = (pos[(N) * 3] - x0) / N;
    const m = t.matrix;   // 地形物体在原点,这里仍按平移处理
    const Y = (ix, iz) => pos[(iz * (N + 1) + ix) * 3 + 1];
    return (wx, wz) => {
      const gx = Math.min(Math.max((wx - m[12] - x0) / step, 0), N - 1e-4), gz = Math.min(Math.max((wz - m[14] - z0) / step, 0), N - 1e-4);
      const ix = Math.floor(gx), iz = Math.floor(gz), fx = gx - ix, fz = gz - iz;
      const h00 = Y(ix, iz), h10 = Y(ix + 1, iz), h01 = Y(ix, iz + 1), h11 = Y(ix + 1, iz + 1);
      // 与 Heightfield.Build 的三角形划分一致:(a, b, a+1) 与 (a+1, b, b+1)
      const y = fx + fz <= 1 ? h00 + fx * (h10 - h00) + fz * (h01 - h00) : h11 + (1 - fx) * (h01 - h11) + (1 - fz) * (h10 - h11);
      return y + m[13];
    };
  }
}

function lerp(a, b, t) { t = Math.min(Math.max(t, 0), 1); return a + (b - a) * t; }

// .NET(Mono)System.Random(seed) 的 Knuth 减法生成器,按参考源码逐行移植
class DotNetRandom {
  constructor(seed) {
    const MBIG = 2147483647, MSEED = 161803398;
    const a = new Int32Array(56);
    const sub = seed === -2147483648 ? 2147483647 : Math.abs(seed);
    let mj = MSEED - sub;
    a[55] = mj;
    let mk = 1;
    for (let i = 1; i < 55; i++) {
      const ii = (21 * i) % 55;
      a[ii] = mk;
      mk = mj - mk;
      if (mk < 0) mk += MBIG;
      mj = a[ii];
    }
    for (let k = 1; k < 5; k++)
      for (let i = 1; i < 56; i++) {
        a[i] -= a[1 + ((i + 30) % 55)];
        if (a[i] < 0) a[i] += MBIG;
      }
    this.a = a; this.inext = 0; this.inextp = 21; this.MBIG = MBIG;
  }
  sample() {
    let n = this.inext, np = this.inextp;
    if (++n >= 56) n = 1;
    if (++np >= 56) np = 1;
    let r = this.a[n] - this.a[np];
    if (r === this.MBIG) r--;
    if (r < 0) r += this.MBIG;
    this.a[n] = r;
    this.inext = n; this.inextp = np;
    return r;
  }
  nextDouble() { return this.sample() * (1 / this.MBIG); }
}

// Mathf.PerlinNoise 的近似(经典改进 Perlin 噪声,输出约 0..1)与 Heightfield.Fbm
const PERM = [151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142, 8, 99, 37, 240, 21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177, 33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122, 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25, 63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186, 3, 64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59, 227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9, 129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218, 246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241, 81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157, 184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156, 180];
const P = new Int32Array(512);
for (let i = 0; i < 512; i++) P[i] = PERM[i & 255];
function grad(h, x, y) { h &= 15; const u = h < 8 ? x : y, v = h < 4 ? y : h === 12 || h === 14 ? x : 0; return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v); }
function perlin(x, y) {
  const X = Math.floor(x) & 255, Y = Math.floor(y) & 255;
  x -= Math.floor(x); y -= Math.floor(y);
  const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10);
  const u = fade(x), v = fade(y);
  const A = P[X] + Y, B = P[X + 1] + Y;
  const l = (t, a, b) => a + t * (b - a);
  const n = l(v, l(u, grad(P[P[A]], x, y), grad(P[P[B]], x - 1, y)), l(u, grad(P[P[A + 1]], x, y - 1), grad(P[P[B + 1]], x - 1, y - 1)));
  return Math.min(Math.max((n + 1) * 0.5, 0), 1);
}
function fbm(x, z, octaves = 5) {
  let sum = 0, amp = 0.5, freq = 1, norm = 0;
  for (let i = 0; i < octaves; i++) {
    sum += perlin(x * freq + i * 17.13, z * freq - i * 9.71) * amp;
    norm += amp; amp *= 0.5; freq *= 2.03;
  }
  return sum / norm;
}

顶点变形把距离淡出、风、交互三件事放在一个函数里,前向 Pass、阴影 Pass、深度 Pass 共用——所有 Pass 必须做相同的变形,否则影子和深度不跟着草动。

Assets/Cookbook/Ch20/Grass.shader(节选:deform)
// 返回变形后的世界坐标;fade 为距离淡出系数(1 = 完全可见)
float3 GrassDeform(GrassAttributes v, out float fade)
{
    float3 pivotWS = TransformObjectToWorld(float3(0, 0, 0));     // 这一丛草的根部中心
    // 1) 距离淡出:先把整丛草“压矮”,再配合片元里的抖动裁剪
    float camDist = distance(pivotWS, GetCameraPositionWS());
    fade = saturate((_FadeEnd - camDist) / max(_FadeEnd - _FadeStart, 1e-3));
    float3 posOS = v.positionOS.xyz;
    posOS.y *= lerp(0.3, 1.0, fade);
    float3 posWS = TransformObjectToWorld(posOS);

    float h = v.uv.y;            // 0 根部 → 1 叶尖
    float bendW = h * h;         // 根部钉死、越往上越软(平方让弯曲更像弧线)

    // 2) 风:简化的行波(完整的风场做法见第 17 章)。_WindPhase 供静帧拍摄用
    float phase = _WindPhase + _Time.y * _WindSpeed;
    float wave = sin(dot(pivotWS.xz, float2(0.35, 0.22)) + phase) * 0.5 + 0.5;
    wave += sin(dot(pivotWS.xz, float2(-1.3, 0.9)) + phase * 2.3) * 0.15;
    posWS.xz += normalize(_WindDir.xz) * (wave * _WindStrength * bendW);

    // 3) 交互:每个交互体把附近的草沿水平方向推开,并往下压
    float2 push = 0;
    float press = 0;
    int count = (int)_Ch20InteractorCount;
    for (int k = 0; k < count; k++)
    {
        float4 it = _Ch20Interactors[k];
        float2 d = posWS.xz - it.xz;
        float dist = length(d);
        float f = saturate(1.0 - dist / it.w);
        f = f * f * (3.0 - 2.0 * f);                         // smoothstep 形状,边缘不突变
        f *= saturate(1.0 - (pivotWS.y - it.y) / it.w);      // 交互体离地太高就不影响
        push += d / max(dist, 1e-3) * f;
        press = max(press, f);
    }
    // 水平推开量不超过顶点离地高度;再按“到根部的距离不变”把顶点压低:y' = √(h² − |o|²)
    float hy = max(posWS.y - pivotWS.y, 0.0);
    float2 o = push * (_BendStrength * bendW);
    float ol = length(o);
    float maxO = hy * 0.95;
    o *= min(1.0, maxO / max(ol, 1e-4));
    ol = min(ol, maxO);
    posWS.xz += o;
    posWS.y = pivotWS.y + sqrt(max(hy * hy - ol * ol, 0.0)) * (1.0 - press * _PressDown);
    return posWS;
}
// 返回变形后的世界坐标;fade 为距离淡出系数(1 = 完全可见)
// 顶点输入(GrassAttributes):positionOS、normalOS、color、texcoord0
vec3 GrassDeform(out float fade)
{
    vec3 pivotWS = GrassObjectToWorld(vec3(0, 0, 0));     // 这一丛草的根部中心
    // 1) 距离淡出:先把整丛草“压矮”,再配合片元里的抖动裁剪
    float camDist = distance(pivotWS, GetCameraPositionWS());
    fade = saturate((_FadeEnd - camDist) / max(_FadeEnd - _FadeStart, 1e-3));
    vec3 posOS = positionOS.xyz;
    posOS.y *= mix(0.3, 1.0, fade);
    vec3 posWS = GrassObjectToWorld(posOS);

    float h = texcoord0.y;       // 0 根部 → 1 叶尖
    float bendW = h * h;         // 根部钉死、越往上越软(平方让弯曲更像弧线)

    // 2) 风:简化的行波(完整的风场做法见第 17 章)。_WindPhase 供静帧拍摄用
    float phase = _WindPhase + _Time.y * _WindSpeed;
    float wave = sin(dot(pivotWS.xz, vec2(0.35, 0.22)) + phase) * 0.5 + 0.5;
    wave += sin(dot(pivotWS.xz, vec2(-1.3, 0.9)) + phase * 2.3) * 0.15;
    posWS.xz += normalize(_WindDir.xz) * (wave * _WindStrength * bendW);

    // 3) 交互:每个交互体把附近的草沿水平方向推开,并往下压
    vec2 push = vec2(0.0);
    float press = 0.0;
    int count = int(_Ch20InteractorCount);
    for (int k = 0; k < count; k++)
    {
        vec4 it = _Ch20Interactors[k];
        vec2 d = posWS.xz - it.xz;
        float dist = length(d);
        float f = saturate(1.0 - dist / it.w);
        f = f * f * (3.0 - 2.0 * f);                         // smoothstep 形状,边缘不突变
        f *= saturate(1.0 - (pivotWS.y - it.y) / it.w);      // 交互体离地太高就不影响
        push += d / max(dist, 1e-3) * f;
        press = max(press, f);
    }
    // 水平推开量不超过顶点离地高度;再按“到根部的距离不变”把顶点压低:y' = √(h² − |o|²)
    float hy = max(posWS.y - pivotWS.y, 0.0);
    vec2 o = push * (_BendStrength * bendW);
    float ol = length(o);
    float maxO = hy * 0.95;
    o *= min(1.0, maxO / max(ol, 1e-4));
    ol = min(ol, maxO);
    posWS.xz += o;
    posWS.y = pivotWS.y + sqrt(max(hy * hy - ol * ol, 0.0)) * (1.0 - press * _PressDown);
    return posWS;
}

推开量直接加在 xz 上会让草叶变长(水平位移 + 原有高度),被压倒的草看起来像被拉长的面条。修正方法是保持“顶点到根部的距离”不变:顶点原来离地 h,水平推开 |o| 后,新的高度应为 h2−|o|2,并且 |o| 不能超过 h。弯曲权重用 uv.y²,根部几乎不动,越往上越软,叶片呈弧形倒下。

交互体 影响半径 r 实线:推开 + 压低后的草叶,叶长近似不变;半透明:原始草叶 半径外:不受影响 越靠近交互体,f = smoothstep(1 − d/r) 越大,推得越远
图 20-15交互压弯示意:半透明为原始草叶,实线为变形后。水平推开量随距离平滑衰减,顶点高度按 √(h² − |o|²) 降低
图 20-16左:没有交互,球像是“插”在草里;右:两个球与红球身后一串不可见的交互点(模拟它滚过的路径)把草推开、压倒
扩展:留下痕迹全局数组只能表达“此刻”的交互,物体离开后草立刻弹回。要让草地留下被踩过的痕迹并慢慢恢复,常见做法是维护一张覆盖角色周围区域、跟随角色移动的“交互 RT”:每帧把交互体画进去(例如写入推开方向和强度),再整体乘一个略小于 1 的系数让它逐渐衰减;草的 Shader 改为按世界坐标采样这张 RT。这需要第 25 章的 Renderer Feature 与 RTHandle 知识。

案例 20.9距离淡出:缩短与抖动裁剪进阶单独打开 ↗

目标:草在远处平滑地消失,而不是在某个距离“啪”地断掉。

草地必须有一个绘制距离:几十米外的草叶小于一个像素,画了也只是产生闪烁和过度绘制(Overdraw)。直接截断会在地面上留下一条清晰的边界线,相机一动边界线跟着移动,非常显眼。我们组合两种手段:

  1. 缩短:顶点着色器里按到相机的距离计算 fade,把整丛草的高度乘以 lerp(0.3, 1, fade)。远处的草先变矮,自然地“沉”进地面。
  2. 抖动裁剪(Dither Clip):片元着色器里用 4×4 Bayer 矩阵给每个像素一个阈值,clip(fade − 阈值)。fade 为 0.5 时恰好一半像素被丢弃。没有时间抗锯齿时能看到细小的网点(如图 20-17 右的远处),开启 TAA 后网点在帧间累积,看起来就像半透明;它仍然是不透明渲染,不需要排序、也能正常写深度。注意 MSAA 帮不上忙:clip 是按片元执行的,同一像素的多个采样一起被保留或丢弃。
Assets/Cookbook/Ch20/Grass.shader(节选:dither)
// 4×4 Bayer 抖动阈值(0..1):按屏幕像素位置给出不同阈值,用 clip 做“半透明”
float Bayer4x4(float2 pixel)
{
    const float m[16] = { 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5 };
    uint2 p = uint2(pixel) % 4;
    return (m[p.y * 4 + p.x] + 0.5) / 16.0;
}
// 4×4 Bayer 抖动阈值(0..1):按屏幕像素位置给出不同阈值,用 clip 做“半透明”
float Bayer4x4(vec2 pixel)
{
    const float m[16] = float[16](0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0);
    uvec2 p = uvec2(pixel) % 4u;
    return (m[p.y * 4u + p.x] + 0.5) / 16.0;
}
图 20-17左:在 14 米处硬截断,草地边缘是一条整齐的线;右:9–15 米之间缩短 + 抖动淡出,草地逐渐稀疏、变矮,融入地面的草地贴图
移动端提示clip(Alpha Test)在 TBDR 架构的移动 GPU 上会破坏隐藏面剔除(HSR/Early-Z)的效率,大面积使用时代价明显。移动端可以只保留“缩短”,或者把淡出带放在非常远、覆盖像素很少的地方;更彻底的做法是按区块做 LOD:远处区块直接用更稀疏的实例数组。地面贴图的颜色也要与草的平均颜色接近,草消失后才不会露出一块不同色的地面——这也是为什么示范地形的草地层贴图偏深绿。

案例 20.10综合:一块完整的小地形进阶单独打开 ↗

目标:把本章的技术组合起来。

地面使用案例 20.4 的自动材质(高度/坡度规则 + 岩石 Triplanar + 高度混合);草只种在“坡度小、低于雪线”的地方——种植时在 C# 里对高度函数求坡度,过滤掉陡坡和高处,这与 Shader 里的规则是同一套判断,所以草地边缘正好与地表的草地层对齐。草的距离淡出设在 20–28 米,再开启指数雾让远山退到背景里。

图 20-18本章综合效果:程序化高度场 + 自动地表材质 + GPU Instancing 草地 + 距离淡出 + 指数雾

如果要把这一套搬到 Unity Terrain 上,对应关系是:高度场网格 → Terrain 组件;AutoTerrain.shader → 改写 TerrainLit(保留它的 _Control/_Splat 接口和 Instanced 地形的高度图采样)或在 Terrain 的材质槽里放自定义 Shader;草 → Terrain 的 Detail 系统(URP 用 TerrainDetailLit / WavingGrass 系列 Shader)或者像本章这样自己用实例化绘制,后者在交互和淡出上更可控。

20.4 性能、移动端与 Built-in 差异

地形 Shader 是“屏幕覆盖率最高”的 Shader 之一,往往占据画面下半部分的全部像素,片元开销会被放大。几条经验:

  • 数清采样数。四层 Splatmap、每层颜色 + 法线 + Mask,就是 1 + 4×3 = 13 次采样;岩石层再改成 Triplanar,又多 6 次。优先砍掉远处用不到的:法线和 Mask 只在近处采样(按距离做动态分支或像 TerrainLit 那样在远处切换到 Basemap),Triplanar 只给陡坡层用。
  • 贴图打包。颜色 + 高度放一张 RGBA,金属/AO/高度/光滑度放一张 Mask,法线用两通道压缩格式(BC5 / ASTC 的法线模式)。所有层共用一个采样器。
  • 用 Texture2DArray。层数很多(8 层以上)时,把各层贴图放进一个纹理数组,Control 图里存“层索引 + 权重”而不是每层一个通道,可以避免 Add Pass 的多次绘制。这超出了本章范围,但值得知道它是大世界地形的主流方案。
  • 植被的瓶颈通常在顶点和 Overdraw,不在 Draw Call。九万片叶子 × 9 个顶点,再加上阴影 Pass 的一遍,顶点数相当可观;草叶互相重叠又带来大量 Overdraw。控制实例密度、远处减少每丛叶片数(准备 2–3 个 LOD 的草丛网格)、关闭远处阴影,效果往往比优化 Shader 指令更明显。
  • half 精度。颜色、权重、法线都可以用 half;但世界坐标(用于 Triplanar 和世界 UV)必须是 float——在距原点几百米的地方,half 的精度已经不足以表达厘米级的位置,纹理会出现明显的抖动和块状。

与 Built-in 管线相比,地形的原理没有变化,差异主要在写法:Built-in 的 Terrain Shader 通常是 Surface Shader,用 #pragma surface ... vertex:SplatmapVert finalcolor:SplatmapFinalColor 挂接引擎提供的 TerrainSplatmapCommon.cginc;URP 没有 Surface Shader,TerrainLit 是手写的顶点/片元函数,光照通过 UniversalFragmentPBR 计算——与本章的 Ch20ShadePBR 是同一个入口:

Assets/Cookbook/Ch20/TerrainCommon.hlsl(节选:shade)
// 把“世界空间位置 + 世界法线 + 表面参数”交给 URP 的 PBR 光照。
// 地形类 Shader 的表面是自己混出来的,不走 Lit 的 SurfaceInput,所以直接组装 InputData/SurfaceData。
half4 Ch20ShadePBR(float3 positionWS, half3 normalWS, float4 positionCS,
                   half3 albedo, half metallic, half smoothness, half occlusion, half fogFactor)
{
    InputData inputData = (InputData)0;
    inputData.positionWS = positionWS;
    inputData.normalWS = NormalizeNormalPerPixel(normalWS);
    inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(positionWS);
    inputData.shadowCoord = TransformWorldToShadowCoord(positionWS);   // 逐像素算级联,避免顶点插值跨级联
    inputData.fogCoord = fogFactor;
    inputData.bakedGI = SampleSH(inputData.normalWS);
    inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(positionCS);  // Forward+ 的分块光源查找要用
    inputData.shadowMask = half4(1, 1, 1, 1);

    SurfaceData surface = (SurfaceData)0;
    surface.albedo = albedo;
    surface.metallic = metallic;
    surface.smoothness = smoothness;
    surface.occlusion = occlusion;
    surface.alpha = 1;
    surface.normalTS = half3(0, 0, 1);

    half4 color = UniversalFragmentPBR(inputData, surface);
    color.rgb = MixFog(color.rgb, fogFactor);
    return color;
}
// 把“世界空间位置 + 世界法线 + 表面参数”交给 URP 的 PBR 光照。
// 地形类 Shader 的表面是自己混出来的,不走 Lit 的 SurfaceInput,所以直接组装 InputData/SurfaceData。
// positionCS 在片元里传 gl_FragCoord(对应 HLSL 的 SV_POSITION)
vec4 Ch20ShadePBR(vec3 positionWS, vec3 normalWS, vec4 positionCS,
                  vec3 albedo, float metallic, float smoothness, float occlusion, float fogFactor)
{
    InputData inputData = InitInputData();
    inputData.positionWS = positionWS;
    inputData.positionCS = positionCS;
    inputData.normalWS = NormalizeNormalPerPixel(normalWS);
    inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(positionWS);
    inputData.shadowCoord = TransformWorldToShadowCoord(positionWS);   // 逐像素算级联,避免顶点插值跨级联
    inputData.fogCoord = fogFactor;
    inputData.bakedGI = SampleSH(inputData.normalWS);
    inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(positionCS);  // Forward+ 的分块光源查找要用
    inputData.shadowMask = vec4(1, 1, 1, 1);

    SurfaceData surface = InitSurfaceData();
    surface.albedo = albedo;
    surface.metallic = metallic;
    surface.smoothness = smoothness;
    surface.occlusion = occlusion;
    surface.alpha = 1.0;
    surface.normalTS = vec3(0, 0, 1);

    vec4 color = UniversalFragmentPBR(inputData, surface);
    color.rgb = MixFog(color.rgb, fogFactor);
    return color;
}

这个小函数是本章所有“自己混出表面”的 Shader 共用的光照出口:表面参数自己算,InputData 自己组装,然后交给 URP。shadowCoord 在片元里逐像素计算而不是在顶点里插值,是因为开启级联阴影时,同一个大三角形的三个顶点可能落在不同的级联里,插值出来的阴影坐标是错的——地形三角形往往很大,这一点尤其要注意。

本章小结

  • 高度场网格的 UV 等价于世界 XZ 投影,陡坡上拉伸 1/N.y 倍;三平面映射沿三个轴各投影一次,用 |N|^k 归一化作为权重。
  • Triplanar 的法线不能直接 swizzle,要把细节法线叠加到几何法线上:UDN 最便宜,Whiteout 性价比最高,RNM 最正确;背面翻转 U 时切线 x 分量也要翻。
  • 自动地表 = 规则遮罩(高度、坡度 + 噪声)+ 逐层叠加;层间用高度混合,过渡由贴图高度决定,“缝隙先被填满”。
  • TerrainLit:_Control 的 RGBA 是四层权重,超过 4 层走 Add Pass;高度存在 Mask Map 的 B 通道,Height Based Blend 只在 ≤4 层时生效;所有层共用一个采样器。
  • 大量植被用 GPU Instancing:Graphics.RenderMeshInstanced + multi_compile_instancing + UNITY_SETUP_INSTANCE_ID,每实例数据用 UNITY_INSTANCING_BUFFER + MaterialPropertyBlock 数组。
  • 交互用全局数组,所有 Pass 做相同的顶点变形;距离淡出用“缩短 + Bayer 抖动裁剪”,移动端慎用 clip。

练习

  1. 给 Triplanar.shader 加一个“物体空间”开关:用 positionOS 和物体空间法线做投影,让一个旋转、移动的石头纹理不再游动。
  2. 在 TriplanarNormalWS 里加入动态分支:权重小于 0.01 的投影跳过采样。用 Frame Debugger 或 GPU 性能工具对比开关前后的耗时。
  3. 在 AutoTerrain.shader 中加一个“朝向”规则:只在背阴的北坡(法线 z 分量为负)上长苔藓。
  4. 把 GrassRenderer 改成按 16 m × 16 m 区块分组,每块各自计算包围盒,并让远处区块关闭阴影投射。
  5. 实现“交互 RT”:用一张跟随相机的 RenderTexture 记录推开方向,每帧衰减,让草被压过后几秒内慢慢恢复。