GameTech/URP 渲染实战

案例 23.3跟随物体的 Blob 阴影入门

目标:一个 Shader 画出中间实、边缘软的圆斑;一个 C# 组件每帧把它贴到物体正下方的地面上,并根据离地高度放大、变淡。

Assets/Cookbook/Ch23/BlobShadow.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    // d:0 在圆心,1 在 Quad 内切圆边缘
    half d = length(i.uv - 0.5) * 2.0;
    // 1 - smoothstep 做出中心实、边缘软的衰减;Softness 越大,实心区越小
    half mask = 1.0 - smoothstep(1.0 - _Softness, 1.0, d);
    mask *= mask;                               // 平方让过渡更接近真实半影
    half3 c = lerp(half3(1, 1, 1), _ShadowColor.rgb, mask * _Strength);
    return half4(c, 1);
}
vec4 frag()
{
    // d:0 在圆心,1 在 Quad 内切圆边缘
    float d = length(uv - 0.5) * 2.0;
    // 1 - smoothstep 做出中心实、边缘软的衰减;Softness 越大,实心区越小
    float mask = 1.0 - smoothstep(1.0 - _Softness, 1.0, d);
    mask *= mask;                               // 平方让过渡更接近真实半影
    vec3 c = mix(vec3(1, 1, 1), _ShadowColor.rgb, mask * _Strength);
    return vec4(c, 1);
}

混合模式用的是 Blend DstColor Zero,即“结果 = 地面颜色 × 片元颜色”。片元颜色在圆外是 1(不改变地面),在圆心是阴影色。相乘比 Alpha 混合更符合阴影的物理意义:阴影是“少了光”,地面原本亮的地方变暗多,原本暗的地方变暗少,不会像 Alpha 混合那样把所有颜色往同一个灰色拉。队列放在 Transparent-100,保证在不透明物体(地面)之后、普通透明物体之前绘制。

Assets/Cookbook/Ch23/BlobShadow.cs(节选:apply)
public void Apply()
{
    if (target == null) return;
    if (m_Renderer == null) m_Renderer = GetComponent<Renderer>();

    Vector3 origin = target.position;
    Vector3 groundPos = new Vector3(origin.x, fallbackGroundY, origin.z);
    Vector3 groundNormal = Vector3.up;
    if (Physics.Raycast(origin, Vector3.down, out var hit, maxHeight * 4f, groundMask, QueryTriggerInteraction.Ignore))
    {
        groundPos = hit.point;
        groundNormal = hit.normal;
    }

    float height = Mathf.Max(0f, origin.y - groundPos.y);
    float t = Mathf.Clamp01(height / maxHeight);

    // Quad 默认朝 -Z,旋转到“正面朝上”并贴合地面法线
    transform.SetPositionAndRotation(groundPos + groundNormal * 0.005f,
        Quaternion.FromToRotation(Vector3.back, groundNormal));
    transform.localScale = Vector3.one * baseSize * (1f + growth * t);

    // 用 MaterialPropertyBlock 改每个阴影自己的浓度,不必为每个单位复制材质。
    // 注意:带 MPB 的 Renderer 不再走 SRP Batcher;单位很多时改用 GPU Instancing 或顶点色。
    m_Block ??= new MaterialPropertyBlock();
    m_Renderer.GetPropertyBlock(m_Block);
    m_Block.SetFloat(s_Strength, strength * (1f - t));
    m_Renderer.SetPropertyBlock(m_Block);
}
// 案例 23.4:让一个 Blob 阴影 Quad 跟随目标物体(网页版,对应 Ch23/BlobShadow.cs)。
// 每帧从目标向下“找地面”,把 Quad 贴到地面上、对齐地面法线,
// 并根据离地高度放大、变淡(物体跳得越高,影子越大越虚)。
// 网页里没有物理系统,射线检测简化为:地面就是 y = fallbackGroundY 的水平面(书里的场景正是如此)。
export default class BlobShadow {
  constructor(engine, json, obj, scene) {
    this.engine = engine; this.obj = obj; this.scene = scene;
    const d = json.data || {};
    this.d = {
      baseSize: d.baseSize ?? 1, growth: d.growth ?? 0.8, maxHeight: d.maxHeight ?? 3,
      strength: d.strength ?? 0.8, fallbackGroundY: d.fallbackGroundY ?? 0,
    };
    // 导出数据里引用的 Transform 以 {component: 物体序号} 表示
    const t = d.target && d.target.component;
    this.target = t !== undefined && t !== null ? scene.objects[t] : null;
  }

  update() { this.apply(); }

  apply() {
    const target = this.target, d = this.d;
    if (!target) return;
    const m = target.matrix;
    const origin = [m[12], m[13], m[14]];
    const groundPos = [origin[0], d.fallbackGroundY, origin[2]];
    const height = Math.max(0, origin[1] - groundPos[1]);
    const t = Math.min(1, Math.max(0, height / d.maxHeight));

    // Quad 默认朝 -Z,旋转到“正面朝上”(绕 X 轴 +90°,与 Quaternion.FromToRotation(back, up) 相同)
    const s = d.baseSize * (1 + d.growth * t);
    const p = [groundPos[0], groundPos[1] + 0.005, groundPos[2]];
    this.scene.setMatrix(this.obj, new Float32Array([s, 0, 0, 0, 0, 0, s, 0, 0, -s, 0, 0, p[0], p[1], p[2], 1]));
    if (this.obj.renderer) {
      const c = this.obj.renderer.bounds[0];
      c[0] = p[0]; c[1] = p[1]; c[2] = p[2];
    }

    // C# 用 MaterialPropertyBlock 改每个阴影自己的浓度;网页版对应“逐物体 uniform”
    this.obj.uniforms = this.obj.uniforms || {};
    this.obj.uniforms._Strength = d.strength * (1 - t);
  }
}

组件用一次向下的射线找地面,命中则贴合地面法线,打不到碰撞体时退回到一个固定高度。浓度用 MaterialPropertyBlock 按实例设置,避免为每个单位复制材质。但要清楚它的代价:带 MaterialPropertyBlock 的 Renderer 不再走 SRP Batcher。单位数量上百时,更好的做法是开启 GPU Instancing(Shader 里已经声明了 multi_compile_instancing,再把 _Strength 放进 UNITY_INSTANCING_BUFFER),或者干脆用一个粒子系统、一个合并的动态网格一次画完所有 Blob。

图 23-13左:没有阴影,球的高度完全无法判断;中:Blob 阴影,离地越高的球阴影越大越淡(最高的黄球已经几乎淡没);右:作为参照的实时阴影贴图。Blob 不随光照方向偏移,适合顶光或对方向不敏感的场合
注意Blob 阴影贴在斜坡上时,用射线命中点的法线对齐即可;但在台阶边缘、小坑上它会悬空或穿插。可以把 Quad 换成第 22 章的贴花投影(Decal Projector 或自写屏幕空间贴花),让圆斑贴合任意地形。