GameTech/URP 渲染实战

案例 26.10受击红屏进阶

目标:角色受到伤害时,屏幕边缘泛起带有不规则“血丝”的红色,中间区域轻微压暗去色,随后在半秒左右淡出;强度可以随伤害大小变化。

原理:效果本身是一个边缘遮罩:椭圆形的距离场,用 fBm 噪声扰动边界产生不规则的“血丝”,强度越大红色往中心侵入越深。混合时不用简单的 lerp 到红色——那会把画面边缘变成一片纯色——而是“乘 + 加”:原图乘以红色保留明暗细节,再加上一点红色保证暗部也被染红:

Assets/Cookbook/Ch26/HitFlash.shader(节选:frag)
half4 Frag(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv = input.texcoord;
    half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv);
    float k = _HitParams.x;

    // 1) 椭圆形的边缘遮罩:强度越大,红色往中心侵入越深
    float2 d = (uv - 0.5) * 2.0;
    float r = length(d * float2(1.0, 0.85));

    // 2) 用 fBm 噪声扰动遮罩边界,形成不规则的“血丝”;相位让它在运行时蠕动
    float t = _HitParams.z + _Time.y * 0.6;
    float n = Fbm(uv * _HitParams.y + float2(t, -t * 0.7));
    float inner = lerp(1.25, 0.45, k);
    half edge = smoothstep(inner, inner + 0.55, r + (n - 0.5) * 0.45);

    // 3) 中心区域轻微去色、压暗,模拟“眼前一黑”
    half luma = dot(color.rgb, half3(0.2126, 0.7152, 0.0722));
    half3 dimmed = lerp(color.rgb, luma.xxx * half3(1.0, 0.85, 0.85), 0.35 * k) * (1.0 - 0.2 * k);

    // 4) 边缘:用乘 + 加的组合,暗部也能被染红,亮部不会变成纯色块
    half3 blood = dimmed * _HitColor.rgb * 0.6 + _HitColor.rgb * 0.35;
    color.rgb = lerp(dimmed, blood, edge * saturate(k * 1.5));
    return color;
}
vec4 Frag()
{
    vec2 uv = texcoord;
    vec4 color = texture(_BlitTexture, uv);
    float k = _HitParams.x;

    // 1) 椭圆形的边缘遮罩:强度越大,红色往中心侵入越深
    vec2 d = (uv - 0.5) * 2.0;
    float r = length(d * vec2(1.0, 0.85));

    // 2) 用 fBm 噪声扰动遮罩边界,形成不规则的“血丝”;相位让它在运行时蠕动
    float t = _HitParams.z + _Time.y * 0.6;
    float n = Fbm(uv * _HitParams.y + vec2(t, -t * 0.7));
    float inner = mix(1.25, 0.45, k);
    float edge = smoothstep(inner, inner + 0.55, r + (n - 0.5) * 0.45);

    // 3) 中心区域轻微去色、压暗,模拟“眼前一黑”
    float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
    vec3 dimmed = mix(color.rgb, vec3(luma) * vec3(1.0, 0.85, 0.85), 0.35 * k) * (1.0 - 0.2 * k);

    // 4) 边缘:用乘 + 加的组合,暗部也能被染红,亮部不会变成纯色块
    vec3 blood = dimmed * _HitColor.rgb * 0.6 + _HitColor.rgb * 0.35;
    color.rgb = mix(dimmed, blood, edge * saturate(k * 1.5));
    return color;
}
图 26-17intensity 分别为 0.3、0.65、1:红色逐渐从角落向中心侵入,中央画面压暗、去色的程度也随之增加

怎么驱动它:这个效果天然是“瞬时”的,最干净的做法是让 Volume 权重来做动画,而不是每帧去改 Profile 里的参数:

  1. 场景里放一个全局 Volume,Profile 中只添加 Hit Flash 覆盖项,intensity = 1。
  2. 给它挂上下面的 HitFlashTrigger,受击时调用 Hit(伤害比例)。
Assets/Cookbook/Ch26/HitFlashTrigger.cs(节选:trigger)
/// 挂在一个全局 Volume(Is Global 勾选,Profile 里只放 HitFlashVolume 且 intensity=1)上。
/// 受击时调用 Hit():Volume 权重瞬间拉到 1 再按曲线衰减到 0。权重为 0 时栈里的 intensity 回到默认值 0,
/// Feature 的 IsActive() 为假,整个 Pass 不入队,平时没有任何开销。
[RequireComponent(typeof(Volume))]
public class HitFlashTrigger : MonoBehaviour
{
    public float duration = 0.6f;
    public AnimationCurve falloff = AnimationCurve.EaseInOut(0, 1, 1, 0);

    Volume m_Volume;
    float m_Elapsed = float.MaxValue;

    void Awake()
    {
        m_Volume = GetComponent<Volume>();
        m_Volume.weight = 0f;
    }

    public void Hit(float strength = 1f)
    {
        m_Elapsed = 0f;
        m_Volume.weight = strength;
    }

    void Update()
    {
        if (m_Elapsed > duration) { m_Volume.weight = 0f; return; }
        m_Elapsed += Time.deltaTime;
        m_Volume.weight = falloff.Evaluate(Mathf.Clamp01(m_Elapsed / duration));
    }
}

这样做的好处是:Profile 资产不会在运行时被改脏(直接改 sharedProfile 里的参数,退出 Play 模式后改动会留在资产里);多个来源的受击可以用多个 Volume 叠加;权重回到 0 后,栈里的 intensity 回到默认值 0,IsActive() 为假,Pass 不入队,平时没有任何开销。这个“用 Volume 权重做后处理动画”的模式同样适用于径向模糊、低血量时的持续暗角、进入水下时的色调等。

注意:记得 26.1 节的提醒:如果相机的 Volume Update Mode 是 Via Scripting,权重变化不会自动反映到栈里。另外,Volume.weight 是对整个 Profile 生效的,所以给这类“动画用”的 Volume 单独一个 Profile,不要和常驻的调色、Bloom 放在一起。