GameTech/URP 渲染实战

案例 21.7Custom Vertex Streams:粒子系统里的帧间混合进阶

目标:让真实的粒子系统驱动我们的 Shader,播放序列帧并做帧间混合。

案例 21.2 的帧间混合是 Shader 自己算帧号;在粒子系统里,帧号由 Texture Sheet Animation 模块按每个粒子的寿命计算,Shader 无从得知。粒子系统的解决方案是 Custom Vertex Streams:在 Renderer 模块里勾选要附加到顶点上的数据流,它们会按勾选顺序依次打包进 TEXCOORD 通道。帧间混合需要两项:UV2(下一帧的 UV)和 AnimBlend(两帧之间的进度)。按“Position、Color、UV、UV2、AnimBlend”的顺序,UV 与 UV2 合占 TEXCOORD0 的 xyzw,AnimBlend 落在 TEXCOORD1.x——与案例 21.5 中 Attributes 的声明一一对应,也和 URP 自带粒子 Shader 的约定相同。Renderer 模块的 Inspector 会把每个数据流实际落在哪个通道列出来,Shader 必须与之吻合。

Assets/Cookbook/Ch21/Editor/Ch21Shots.cs(节选:particleSystem)
/// 建一个用本章 Shader 的粒子系统:Texture Sheet Animation 4×4 + Custom Vertex Streams(UV2 + AnimBlend)
static ParticleSystem MakeSystem(Shot s, Material m, Vector3 pos, float rate, Vector2 life, Vector2 speed, Vector2 size,
                                 Gradient colorOverLife, AnimationCurve sizeOverLife, float coneAngle, float radius, int seed)
{
    var go = new GameObject("Particles");
    go.transform.position = pos;
    go.transform.rotation = Quaternion.Euler(-90, 0, 0);      // Cone 默认沿 +Z 发射,转到朝上
    var ps = go.AddComponent<ParticleSystem>();
    ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
    ps.randomSeed = (uint)seed; ps.useAutoRandomSeed = false;

    var main = ps.main;
    main.duration = 5; main.loop = true;
    main.startLifetime = new ParticleSystem.MinMaxCurve(life.x, life.y);
    main.startSpeed = new ParticleSystem.MinMaxCurve(speed.x, speed.y);
    main.startSize = new ParticleSystem.MinMaxCurve(size.x, size.y);
    main.startRotation = new ParticleSystem.MinMaxCurve(0, Mathf.PI * 2);
    main.simulationSpace = ParticleSystemSimulationSpace.World;
    main.maxParticles = 500;

    var em = ps.emission; em.rateOverTime = rate;
    var sh = ps.shape; sh.shapeType = ParticleSystemShapeType.Cone; sh.angle = coneAngle; sh.radius = radius;
    var col = ps.colorOverLifetime; col.enabled = true; col.color = colorOverLife;
    var sz = ps.sizeOverLifetime; sz.enabled = true; sz.size = new ParticleSystem.MinMaxCurve(1, sizeOverLife);

    var tsa = ps.textureSheetAnimation;
    tsa.enabled = true; tsa.numTilesX = 4; tsa.numTilesY = 4;
    tsa.frameOverTime = new ParticleSystem.MinMaxCurve(1, AnimationCurve.Linear(0, 0, 1, 1));

    var r = go.GetComponent<ParticleSystemRenderer>();
    r.sharedMaterial = m;
    r.sortMode = ParticleSystemSortMode.Distance;
    // 与 Shader 里 Attributes 的顺序一一对应
    r.SetActiveVertexStreams(new List<ParticleSystemVertexStream>
    {
        ParticleSystemVertexStream.Position,
        ParticleSystemVertexStream.Color,
        ParticleSystemVertexStream.UV,
        ParticleSystemVertexStream.UV2,
        ParticleSystemVertexStream.AnimBlend,
    });
    return ps;
}

拍摄时遇到一个实际问题:批处理模式下没有 Play 循环,粒子系统不会自己模拟。实测可行的办法是调用 ParticleSystem.Simulate(4f, true, true)——在编辑模式里一次性把系统推进 4 秒(包含子系统、从头开始),之后多次 Camera.Render() 都能渲染出这一刻的粒子。同样的方法也适合做编辑器里的特效预览工具、或者生成特效缩略图。

图 21-12两个真实的粒子系统:火焰使用烟雾序列帧 + 火焰渐变映射 + 叠加混合 + 软粒子;烟使用同一图集 + Alpha 混合 + 软粒子 + 近相机淡出;两者都开启帧间混合(UV2 + AnimBlend 数据流)

火焰没有专门的火焰序列帧,而是复用烟雾图集:Shader 里用“贴图浓度 × 顶点色 alpha(随寿命淡出)”作为温度去查 ramp_fire 渐变——新生的、浓的部分是黄白色,边缘和老化的部分退成暗红。一张灰度序列帧配不同的渐变图,就能得到火焰、毒烟、魔法烟等多种变体,这是控制特效贴图数量的常用手法。

坑:数据流顺序与 GPU Instancing粒子系统的 Mesh 渲染模式可以开启 GPU Instancing,此时自定义数据流的传递方式完全不同(通过实例数据而不是顶点),URP 粒子 Shader 用 UNITY_PARTICLE_INSTANCING_ENABLED 分支处理。本章的 Shader 只支持 Billboard 模式;如果要用 Mesh 粒子 + 实例化,需要参照 ParticlesInstancing.hlsl 增加实例化路径。