GameTech/URP 渲染实战

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