GameTech/URP 渲染实战

案例 21.5通用粒子 Shader:顶点色与混合模式入门

目标:写一个可以替代 Particles/Unlit 大部分用途的粒子 Shader,理解几种混合模式的差别。

Assets/Cookbook/Ch21/Particle.shader(节选:streams)
// 与粒子系统 Renderer › Custom Vertex Streams 的顺序一一对应:
//   Position(POSITION) · Color(COLOR) · UV(TEXCOORD0.xy) · UV2(TEXCOORD0.zw) · AnimBlend(TEXCOORD1.x)
struct Attributes
{
    float4 positionOS : POSITION;
    half4  color      : COLOR;
#if defined(_FLIPBOOKBLENDING_ON)
    float4 texcoords  : TEXCOORD0;   // xy = 当前帧 UV,zw = 下一帧 UV
    float  animBlend  : TEXCOORD1;   // 两帧之间的进度 0..1
#else
    float2 texcoords  : TEXCOORD0;
#endif
    UNITY_VERTEX_INPUT_INSTANCE_ID
};
// 与粒子系统 Renderer › Custom Vertex Streams 的顺序一一对应:
//   Position(POSITION) · Color(COLOR) · UV(TEXCOORD0.xy) · UV2(TEXCOORD0.zw) · AnimBlend(TEXCOORD1.x)
// GLSL 版的顶点输入名字固定:positionOS、color、texcoord0(xy = 当前帧 UV,zw = 下一帧 UV)、texcoord1(x = 两帧之间的进度 0..1)
Assets/Cookbook/Ch21/Particle.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv.xy);
#if defined(_FLIPBOOKBLENDING_ON)
    half4 tex2 = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv.zw);
    tex = lerp(tex, tex2, i.extra.x);
#endif

    // 顶点色 = 粒子系统的 Start Color × Color over Lifetime:整个粒子系统的颜色变化都从这里进来
    half4 c = tex * i.color * _BaseColor;

#if defined(_RAMP_ON)
    // 渐变映射:用“贴图浓度 × 粒子剩余寿命(顶点色 alpha)”去查火焰渐变,越老越暗红
    half k = saturate(tex.a * i.color.a * _RampScale);
    c.rgb = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(k, 0.5)).rgb * _BaseColor.rgb;
    c.a = saturate(k);
#endif

    float thisZ = i.screenData.z;
#if defined(_SOFTPARTICLES_ON)
    // 软粒子:比较场景深度与粒子自身深度,越接近(快要插进物体)越透明
    float2 suv = i.screenData.xy / i.screenData.w;
    float sceneZ = LinearEyeDepth(SampleSceneDepth(suv), _ZBufferParams);
    c.a *= saturate((sceneZ - thisZ) / _SoftDistance);
#endif
#if defined(_CAMERAFADE_ON)
    // 近相机淡出:粒子贴脸时会糊满屏幕、产生巨大 Overdraw,提前淡掉
    c.a *= saturate((thisZ - _CameraFadeNear) / max(_CameraFadeFar - _CameraFadeNear, 1e-3));
#endif

    c.rgb *= lerp(1.0, c.a, _Premultiply);   // 预乘:Blend One OneMinusSrcAlpha 时需要
    // 正片叠底(Blend DstColor Zero)没有 alpha 参与:把 alpha 折算进颜色,透明处趋向白色(=不改变背景)
    c.rgb = lerp(c.rgb, lerp(1.0, c.rgb, c.a), _Modulate);
    // 雾:Alpha 混合直接 MixFog;叠加模式应往黑色雾化 MixFogColor(c.rgb, 0, fog),否则雾里越叠越亮
    c.rgb = MixFog(c.rgb, i.extra.y);
    return c;
}
vec4 frag()
{
    vec4 tex = texture(_BaseMap, uv.xy);
#if defined(_FLIPBOOKBLENDING_ON)
    vec4 tex2 = texture(_BaseMap, uv.zw);
    tex = mix(tex, tex2, extra.x);
#endif

    // 顶点色 = 粒子系统的 Start Color × Color over Lifetime:整个粒子系统的颜色变化都从这里进来
    vec4 c = tex * vColor * _BaseColor;

#if defined(_RAMP_ON)
    // 渐变映射:用“贴图浓度 × 粒子剩余寿命(顶点色 alpha)”去查火焰渐变,越老越暗红
    float k = saturate(tex.a * vColor.a * _RampScale);
    c.rgb = texture(_RampTex, vec2(k, 0.5)).rgb * _BaseColor.rgb;
    c.a = saturate(k);
#endif

    float thisZ = screenData.z;
#if defined(_SOFTPARTICLES_ON)
    // 软粒子:比较场景深度与粒子自身深度,越接近(快要插进物体)越透明
    vec2 suv = screenData.xy / screenData.w;
    float sceneZ = LinearEyeDepth(SampleSceneDepth(suv), _ZBufferParams);
    c.a *= saturate((sceneZ - thisZ) / _SoftDistance);
#endif
#if defined(_CAMERAFADE_ON)
    // 近相机淡出:粒子贴脸时会糊满屏幕、产生巨大 Overdraw,提前淡掉
    c.a *= saturate((thisZ - _CameraFadeNear) / max(_CameraFadeFar - _CameraFadeNear, 1e-3));
#endif

    c.rgb *= mix(1.0, c.a, _Premultiply);   // 预乘:Blend One OneMinusSrcAlpha 时需要
    // 正片叠底(Blend DstColor Zero)没有 alpha 参与:把 alpha 折算进颜色,透明处趋向白色(=不改变背景)
    c.rgb = mix(c.rgb, mix(vec3(1.0), c.rgb, c.a), _Modulate);
    // 雾:Alpha 混合直接 MixFog;叠加模式应往黑色雾化 MixFogColor(c.rgb, 0, fog),否则雾里越叠越亮
    c.rgb = MixFog(c.rgb, extra.y);
    return c;
}

混合模式用 Blend [_SrcBlend] [_DstBlend] 做成材质参数,同一个 Shader 不同材质就能是不同的模式。粒子常用的几种:

模式Blend 设置结果典型用途
AlphaSrcAlpha OneMinusSrcAlpha按 alpha 覆盖背景,能遮挡烟、灰尘、碎片
AdditiveSrcAlpha One(或 One One)只加亮,不遮挡,与顺序无关火花、魔法光效、光晕
PremultiplyOne OneMinusSrcAlphaRGB 已预乘 alpha;alpha 小而 RGB 大时表现为发光火焰核心(一张图里既有遮挡又有发光)
MultiplyDstColor Zero只会变暗焦痕、阴影斑、暗色烟

正片叠底没有 alpha 参与运算,透明的地方必须输出白色才不改变背景;代码里的 _Modulate 就是把 alpha 折算进颜色:lerp(1, rgb, a),与 URP 的 AlphaModulate 函数做法相同。

由于批处理模式下粒子系统的拍摄需要额外处理(见案例 21.7),这一组对比图用一组朝向相机的 Quad 手工摆出“一团粒子”,每个 Quad 的网格写入不同的顶点色,模拟粒子的 Start Color——Shader 看到的数据与真实粒子并无二致:

Assets/Cookbook/Ch21/Editor/Ch21Shots.cs(节选:quadParticles)
/// 用一组朝向相机的 Quad 摆出“一团粒子”,每个 Quad 带不同的顶点色(模拟粒子的 Start Color)
static void FakeParticles(Shot s, Material m, Vector3 center, int count, Vector3 spread, float size, Color c0, Color c1, int seed)
{
    var rnd = new System.Random(seed);
    for (int i = 0; i < count; i++)
    {
        var p = center + Vector3.Scale(new Vector3((float)rnd.NextDouble() - 0.5f, (float)rnd.NextDouble() - 0.5f, (float)rnd.NextDouble() - 0.5f), spread);
        var q = s.Quad(m, p, size * Mathf.Lerp(0.7f, 1.2f, (float)rnd.NextDouble()));
        q.transform.rotation = Quaternion.LookRotation(p - s.Cam.transform.position) * Quaternion.Euler(0, 0, (float)rnd.NextDouble() * 360);
        var mesh = Object.Instantiate(q.GetComponent<MeshFilter>().sharedMesh);
        var col = Color.Lerp(c0, c1, (float)rnd.NextDouble());
        mesh.colors = new[] { col, col, col, col };
        q.GetComponent<MeshFilter>().sharedMesh = mesh;
        s.Temp.Add(mesh);
    }
}
图 21-10同一团粒子(橙色与灰色顶点色)在灰墙(左半)与暗砖墙(右半)前。左:Alpha 混合,两种背景上都能看出颜色和遮挡;中:Additive,在暗背景上明亮发光,在亮背景上几乎消失;右:Multiply,只会让背景变暗,在暗砖墙上像一片焦痕
Additive 的两种写法SrcAlpha One 用 alpha 控制亮度,贴图带 alpha 通道时更直观;One One 直接把 RGB 加上去,要求贴图本身在透明处是黑色。粒子贴图如果是在黑底上画的(很多火焰素材是这样),用后者即可,还省一次乘法。