GameTech/URP 渲染实战

案例 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 知识。