URP 渲染实战 / 第 23 章 阴影进阶 / 案例 23.3
案例 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。

注意Blob 阴影贴在斜坡上时,用射线命中点的法线对齐即可;但在台阶边缘、小坑上它会悬空或穿插。可以把 Quad 换成第 22 章的贴花投影(Decal Projector 或自写屏幕空间贴花),让圆斑贴合任意地形。