GameTech/URP 渲染实战
CHAPTER 21第 21 章

UV 动画与粒子

让画面“动起来”的最便宜的办法,不是动顶点,而是动纹理坐标。熔岩的流淌、火焰的翻滚、传送门的旋涡、烟雾的升腾,几乎都可以归结为“按时间改变 UV”。本章从最简单的 UV 滚动讲起,一路做到粒子系统的 Shader 接口、软粒子和热扭曲。

本章你会学到
_Time 的四个分量、精度问题,以及静帧/录制时如何控制时间
UV 滚动、双层叠加、UV 扰动与渐变映射
序列帧图集的帧号 → UV 换算,帧间混合的原理和局限
Flowmap 的编码方式与双相位采样
极坐标漩涡,以及 atan2 接缝的成因与修正
粒子 Shader:顶点色、混合模式、Custom Vertex Streams、软粒子、热扭曲

21.1 Shader 里的时间

URP 在每帧开始时设置一组全局时间变量(定义在 ShaderLibrary/UnityInput.hlsl):

变量内容常见用法
_Time(t/20, t, 2t, 3t),t 为关卡加载后的秒数最常用的是 _Time.y
_SinTime / _CosTime(sin(t/8), sin(t/4), sin(t/2), sin(t)),余弦同理省一次三角函数
_TimeParameters(t, sin t, cos t)Shader Graph 的 Time 节点用的就是它
unity_DeltaTime(dt, 1/dt, 平滑 dt, 1/平滑 dt)很少在 Shader 里用

使用时间要注意三件事:

  1. 精度。_Time.y 是 float,持续增长。UV 偏移写成 uv + speed × t,运行几个小时后 t 达到 10⁴ 量级,加到 0..1 的 UV 上就会丢失小数位,纹理开始一跳一跳地移动;如果中间变量用了 half,几十秒就会出问题。对周期性的偏移,先 frac(speed × t) 再加到 UV 上——贴图的 Repeat 寻址保证结果完全相同。
  2. 编辑模式。不在 Play 模式时,Scene 视图只有勾选 Always Refresh(场景视图工具栏的效果开关里)才会持续重绘,否则动画看起来是静止的。
  3. 静帧与录制。本书配图在批处理模式下渲染,_Time 几乎为 0 且不可控。因此本章所有动画 Shader 都留了一个 _Phase(或 _Frame)参数,实际时间 = _Time.y + _Phase。这个习惯在项目里同样有用:同一材质的多个物体可以错开相位,过场动画也可以用 Timeline 直接驱动 _Phase 做精确对帧。

案例 21.1UV 滚动:流动的熔岩入门单独打开 ↗

目标:只用一张灰度噪声图,做出缓慢流动、亮暗翻滚的熔岩河。

单层滚动就是 uv + speed × t:整张图匀速平移,像传送带。它的问题是“太刚体”——所有细节以同样的速度、同样的方向移动,眼睛一下就能看出是一张图在平移。改进分三步:

  1. 双层叠加:同一张噪声以不同的平铺、不同的速度和方向再滚一层,两层相乘。两层的相对运动让图案不断变化,再也找不到“那一张图”。相乘会让平均亮度下降(0.5 × 0.5 = 0.25),所以乘以 2 补回来。
  2. UV 扰动:用第二层的值去偏移第一层的 UV。原本直线运动的图案开始扭曲、汇聚、分叉,像有了流体的速度场。
  3. 渐变映射(Gradient Mapping):把灰度当作“温度”,查一张一维的颜色渐变(公共贴图 ramp_fire):暗处是冷却凝固的黑色岩壳,亮处是黄白色的高温熔岩,并乘上 k² 让高温处成为 HDR 发光。
Assets/Cookbook/Ch21/UVScroll.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    // 时间:运行时用 _Time.y,配图时 _Time 不可控,用 _Phase 指定“第几秒”
    // frac 把偏移量限制在 0..1:贴图是 Repeat 寻址,结果不变,但避免长时间运行后浮点精度下降
    float t = _Time.y + _Phase;
    float2 off1 = frac(_Layer1.zw * t);
    float2 off2 = frac(_Layer2.zw * t);

    // 第二层:既参与叠加,也用来扰动第一层的 UV
    half n2 = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, i.uv * _Layer2.xy + off2).r;
    float2 distort = (n2 - 0.5) * _Distort * _SecondLayer;
    half n1 = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, i.uv * _Layer1.xy + off1 + distort).r;

    half v = lerp(n1, n1 * n2 * 2.0, _SecondLayer);     // 两层相乘再 ×2,保持平均亮度
    v = saturate(v);

    half3 col = v.xxx;
    if (_UseRamp > 0.5)
    {
        // 渐变映射:灰度 → 颜色。暗处是冷却的黑色岩壳,亮处是高温的黄白色
        half k = smoothstep(0.3, 1.0, v);
        col = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(k, 0.5)).rgb * (k * k * _Intensity);
    }
    col = MixFog(col, i.fog);
    return half4(col, 1);
}
vec4 frag()
{
    // 时间:运行时用 _Time.y,配图时 _Time 不可控,用 _Phase 指定“第几秒”
    // fract 把偏移量限制在 0..1:贴图是 Repeat 寻址,结果不变,但避免长时间运行后浮点精度下降
    float t = _Time.y + _Phase;
    vec2 off1 = fract(_Layer1.zw * t);
    vec2 off2 = fract(_Layer2.zw * t);

    // 第二层:既参与叠加,也用来扰动第一层的 UV
    float n2 = texture(_NoiseTex, uv * _Layer2.xy + off2).r;
    vec2 distort = vec2((n2 - 0.5) * _Distort * _SecondLayer);
    float n1 = texture(_NoiseTex, uv * _Layer1.xy + off1 + distort).r;

    float v = mix(n1, n1 * n2 * 2.0, _SecondLayer);     // 两层相乘再 ×2,保持平均亮度
    v = saturate(v);

    vec3 col = vec3(v);
    if (_UseRamp > 0.5)
    {
        // 渐变映射:灰度 → 颜色。暗处是冷却的黑色岩壳,亮处是高温的黄白色
        float k = smoothstep(0.3, 1.0, v);
        col = texture(_RampTex, vec2(k, 0.5)).rgb * (k * k * _Intensity);
    }
    col = MixFog(col, fog);
    return vec4(col, 1.0);
}
图 21-1左:单层噪声滚动;中:两层不同速度的噪声相乘,并用第二层扰动第一层的 UV;右:灰度经过火焰渐变映射,成为熔岩
调参建议两层的速度方向不要相同也不要正好相反,夹角 30°–90° 最自然;平铺比例避免整数倍(例如 1 与 1.7),否则两层图案会周期性地重合,出现“节拍”。渐变图的采样坐标先过一次 smoothstep 拉开对比,熔岩才会有清晰的“岩壳/裂缝”分界。
坑:贴图的 Wrap ModeUV 滚动依赖 Repeat 寻址。如果噪声图被设成 Clamp,滚出 0..1 之后就是一片拉长的边缘像素。渐变图则相反,必须是 Clamp——否则采样坐标为 1 时会因为双线性过滤混进另一端的颜色,最亮处突然出现一圈暗色。本书公共贴图里 ramp_* 已设为 Clamp。

案例 21.2序列帧 Flipbook 与帧间混合入门单独打开 ↗

目标:播放一张 4×4 的序列帧图集,并理解“帧间混合”到底做了什么。

序列帧(Flipbook,也叫 Sprite Sheet)把动画的每一帧排在一张图集里。Shader 根据时间算出当前是第几帧,再把 0..1 的 UV 缩小到那一格。约定第 0 帧在左上角、从左到右、从上到下(与 Unity 粒子系统的 Texture Sheet Animation 一致);而纹理坐标的 V 轴是向上的,所以“第 0 行”对应 V 最大的那一行,行号要从顶部数:

c=fmodNx,r=⌊f/Nx⌋,uv′=uv+(c,Ny−1−r)(Nx,Ny)
Assets/Cookbook/Ch21/UVAnim.hlsl(节选:flipbookUV)
// 第 frame 帧在图集中的 UV。约定:第 0 帧在左上角,从左到右、从上到下排列(与 Unity 粒子的 Texture Sheet Animation 一致)。
// 纹理坐标的 V 轴向上,所以“第 0 行”对应 V 最大的那一行。
float2 FlipbookUV(float2 uv, float frame, float2 grid)
{
    float total = grid.x * grid.y;
    frame = fmod(floor(frame), total);           // 播完从头循环
    float col = fmod(frame, grid.x);
    float row = floor(frame / grid.x);
    return (uv + float2(col, grid.y - 1.0 - row)) / grid;
}
// 第 frame 帧在图集中的 UV。约定:第 0 帧在左上角,从左到右、从上到下排列(与 Unity 粒子的 Texture Sheet Animation 一致)。
// 纹理坐标的 V 轴向上,所以“第 0 行”对应 V 最大的那一行。
vec2 FlipbookUV(vec2 uv, float frame, vec2 grid)
{
    float total = grid.x * grid.y;
    frame = Ch21Fmod(floor(frame), total);       // 播完从头循环
    float col = Ch21Fmod(frame, grid.x);
    float row = floor(frame / grid.x);
    return (uv + vec2(col, grid.y - 1.0 - row)) / grid;
}

帧号 frame = t × FPS 是一个连续的小数,floor 之后才是整数帧;它的小数部分 frac(frame) 表示“从这一帧到下一帧走了多少”。帧间混合就是再采一次下一帧,按这个小数线性插值:

Assets/Cookbook/Ch21/Flipbook.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float frame = _Time.y * _FPS + _Frame;       // 连续的“帧号”,小数部分就是两帧之间的进度

#if defined(_DEBUG_ATLAS)
    // 调试:整张图集 + 网格线;当前帧描橙框,下一帧描蓝框
    half4 atlas = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv);
    half3 bg = lerp(half3(0.12, 0.16, 0.2), atlas.rgb, atlas.a);
    float2 cell = i.uv * _Grid.xy;
    float2 f = frac(cell);
    float line_ = step(min(min(f.x, 1 - f.x), min(f.y, 1 - f.y)), 0.015);
    float idx = (_Grid.y - 1 - floor(cell.y)) * _Grid.x + floor(cell.x);
    float total = _Grid.x * _Grid.y;
    float cur = fmod(floor(frame), total), nxt = fmod(floor(frame) + 1, total);
    float border = step(min(min(f.x, 1 - f.x), min(f.y, 1 - f.y)), 0.05);
    bg = lerp(bg, half3(0.5, 0.55, 0.6), line_);
    bg = lerp(bg, half3(1.0, 0.45, 0.15), border * (idx == cur));
    bg = lerp(bg, half3(0.25, 0.6, 1.0), border * (idx == nxt));
    return half4(bg, 1);
#endif

    float2 uvA = FlipbookUV(i.uv, frame, _Grid.xy);
    half4 c = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uvA);
#if defined(_FLIPBOOK_BLEND)
    // 帧间混合:再采一张“下一帧”,按小数部分线性插值。代价是多一次采样
    float2 uvB = FlipbookUV(i.uv, frame + 1.0, _Grid.xy);
    half4 cB = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uvB);
    c = lerp(c, cB, frac(frame));
#endif
    c *= _BaseColor * i.color;
    return c;
}
vec4 frag()
{
    float frame = _Time.y * _FPS + _Frame;       // 连续的“帧号”,小数部分就是两帧之间的进度

#if defined(_DEBUG_ATLAS)
    // 调试:整张图集 + 网格线;当前帧描橙框,下一帧描蓝框
    vec4 atlas = texture(_BaseMap, uv);
    vec3 bg = mix(vec3(0.12, 0.16, 0.2), atlas.rgb, atlas.a);
    vec2 cell = uv * _Grid.xy;
    vec2 f = fract(cell);
    float line_ = step(min(min(f.x, 1.0 - f.x), min(f.y, 1.0 - f.y)), 0.015);
    float idx = (_Grid.y - 1.0 - floor(cell.y)) * _Grid.x + floor(cell.x);
    float total = _Grid.x * _Grid.y;
    float cur = Ch21Fmod(floor(frame), total), nxt = Ch21Fmod(floor(frame) + 1.0, total);
    float border = step(min(min(f.x, 1.0 - f.x), min(f.y, 1.0 - f.y)), 0.05);
    bg = mix(bg, vec3(0.5, 0.55, 0.6), line_);
    bg = mix(bg, vec3(1.0, 0.45, 0.15), border * float(idx == cur));
    bg = mix(bg, vec3(0.25, 0.6, 1.0), border * float(idx == nxt));
    return vec4(bg, 1.0);
#endif

    vec2 uvA = FlipbookUV(uv, frame, _Grid.xy);
    vec4 c = texture(_BaseMap, uvA);
#if defined(_FLIPBOOK_BLEND)
    // 帧间混合:再采一张“下一帧”,按小数部分线性插值。代价是多一次采样
    vec2 uvB = FlipbookUV(uv, frame + 1.0, _Grid.xy);
    vec4 cB = texture(_BaseMap, uvB);
    c = mix(c, cB, fract(frame));
#endif
    c *= _BaseColor * vColor;
    return c;
}
图 21-2左:Flipbook 调试视图,整张 4×4 烟雾图集,橙框为当前帧(第 6 帧)、蓝框为下一帧(第 7 帧);右:frame = 6.35 时的混合结果,即 65% 第 6 帧 + 35% 第 7 帧

帧间混合能让低帧率的序列(比如 16 帧播 2 秒,每秒 8 帧)看起来不那么卡,但要清楚它的本质:它是两帧的交叉淡化(Cross-fade),不是运动插值。对烟、火、爆炸这种形状柔和、逐帧变化不大的内容,交叉淡化几乎看不出破绽;对边界清晰、位移明显的内容,就会出现两个半透明的“重影”。下面用一张程序生成的测试图集说明这一点:一个亮点沿圆周运动,每帧转 22.5°。

图 21-3从左到右 frame = 2.0、2.25、2.5、2.75、3.0。上排不混合:前四个完全相同,第五个突然跳到下一位置;下排帧间混合:亮点不是平滑地移过去,而是一个淡出、另一个淡入
深入:运动向量序列帧要让清晰的形状在帧间真正“移动”,需要额外一张运动向量图(Motion Vector / Optical Flow Map),记录每个像素从这一帧到下一帧的位移。播放时用它把当前帧往前推、下一帧往回拉,再混合——本质上就是下一节的 Flowmap 技术用在两帧之间。离线工具可以从渲染序列计算出这张图;它能让 16 帧的爆炸看起来像 60 帧。
坑:图集边缘渗色每一格的内容如果画到了格子边缘,mipmap 降采样时会混进相邻格子的颜色,远处的粒子边缘出现别的帧的碎片。制作图集时在每格四周留几个像素的透明边;或者对粒子贴图关闭 mipmap(代价是远处闪烁)。

案例 21.3Flowmap:沿任意方向流动进阶单独打开 ↗

目标:让贴图沿一张“流向图”规定的方向流动,绕过石头、在漩涡处打转,并且没有周期性的跳变。

UV 滚动只能整体沿一个方向流。Flowmap 用一张贴图存下每个位置的流速向量:R、G 两个通道分别是 x、y 方向的速度,映射到 0..1 存储,0.5 表示静止。Shader 里先解码回 −1..1,再用它代替常数速度:uv − flow × t。

问题马上出现:不同位置的速度不同,随着 t 增大,相邻像素的 UV 偏移差越来越大,图案被越拉越长,最终完全变形。解决办法是让 t 只在一个周期内增长——frac(t)——周期结束时 UV 跳回原位。拉伸被限制住了,但每个周期末尾图案会“啪”地复位。

双相位(Dual Phase)消除这个跳变:用两套相位相差半个周期的 UV 各采样一次,权重随相位呈三角波:某一套 UV 即将复位的时刻,它的权重恰好为 0,此时画面完全由另一套提供;复位之后它的权重再慢慢升上来。

时间 t10 相位 0 的权重 w₀ = 1 − |1 − 2·frac(t)|(圆点:该相位复位,权重为 0) 相位 0.5 的权重 = 1 − w₀
图 21-4双相位的权重:两条三角波此起彼伏,任一相位复位(UV 跳回原位)的瞬间,它的权重都是 0
Assets/Cookbook/Ch21/UVAnim.hlsl(节选:flowmap)
// Flowmap 双相位采样:两套相位差半个周期的 UV,各自沿流向“漂”,权重呈三角波交替。
// 某一相位重置(跳回原位)的瞬间,它的权重恰好为 0,所以看不到跳变。
struct FlowSample
{
    float2 uv0, uv1;
    float  w0;           // 第一套的权重,第二套为 1 - w0
};

FlowSample FlowUV(float2 uv, float2 flow, float time)
{
    FlowSample s;
    float p0 = frac(time);
    float p1 = frac(time + 0.5);
    s.uv0 = uv - flow * p0;
    s.uv1 = uv - flow * p1 + float2(0.37, 0.21);  // 第二套错开一点,避免两层图案重合时出现“重影节拍”
    s.w0 = 1.0 - abs(1.0 - 2.0 * p0);             // p0 = 0 或 1(重置时)权重为 0,p0 = 0.5 时为 1
    return s;
}
// Flowmap 双相位采样:两套相位差半个周期的 UV,各自沿流向“漂”,权重呈三角波交替。
// 某一相位重置(跳回原位)的瞬间,它的权重恰好为 0,所以看不到跳变。
struct FlowSample
{
    vec2 uv0, uv1;
    float w0;            // 第一套的权重,第二套为 1 - w0
};

FlowSample FlowUV(vec2 uv, vec2 flow, float time)
{
    FlowSample s;
    float p0 = fract(time);
    float p1 = fract(time + 0.5);
    s.uv0 = uv - flow * p0;
    s.uv1 = uv - flow * p1 + vec2(0.37, 0.21);    // 第二套错开一点,避免两层图案重合时出现“重影节拍”
    s.w0 = 1.0 - abs(1.0 - 2.0 * p0);             // p0 = 0 或 1(重置时)权重为 0,p0 = 0.5 时为 1
    return s;
}

第二套 UV 额外加了一个小偏移 (0.37, 0.21):如果两套 UV 采到的图案完全相同,只是错开半周期,人眼仍能察觉到画面每半个周期“呼吸”一次;错开采样位置后,这种规律性被打散。

Assets/Cookbook/Ch21/Flowmap.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    half4 flowTex = SAMPLE_TEXTURE2D(_FlowMap, sampler_FlowMap, i.uv);
    if (_Mode < 0.5) return half4(flowTex.rg, 0, 1);          // 直接显示流向图

    // RG 存的是 0..1,映射回 -1..1 的方向向量(0.5 灰 = 静止)
    float2 flow = (flowTex.rg * 2.0 - 1.0) * _FlowStrength;
    float t = (_Time.y + _Phase) * _Speed;
    float2 uv = TRANSFORM_TEX(i.uv, _BaseMap);

    half4 c;
    if (_Mode < 1.5)
    {
        // 单相位:UV 沿流向越漂越远,到 frac 归零时整张图“啪”地跳回原位
        c = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv - flow * frac(t));
    }
    else
    {
        FlowSample fs = FlowUV(uv, flow, t);
        half4 c0 = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, fs.uv0);
        half4 c1 = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, fs.uv1);
        c = c0 * fs.w0 + c1 * (1.0 - fs.w0);
    }

    if (_UseRamp > 0.5)
    {
        half k = smoothstep(0.2, 0.9, c.r);
        c.rgb = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(k, 0.5)).rgb * (k * k * _Intensity);
    }
    return half4(c.rgb, 1);
}
vec4 frag()
{
    vec4 flowTex = texture(_FlowMap, uv);
    if (_Mode < 0.5) return vec4(flowTex.rg, 0.0, 1.0);          // 直接显示流向图

    // RG 存的是 0..1,映射回 -1..1 的方向向量(0.5 灰 = 静止)
    vec2 flow = (flowTex.rg * 2.0 - 1.0) * _FlowStrength;
    float t = (_Time.y + _Phase) * _Speed;
    vec2 uvB = TransformTex(uv, _BaseMap_ST);

    vec4 c;
    if (_Mode < 1.5)
    {
        // 单相位:UV 沿流向越漂越远,到 fract 归零时整张图“啪”地跳回原位
        c = texture(_BaseMap, uvB - flow * fract(t));
    }
    else
    {
        FlowSample fs = FlowUV(uvB, flow, t);
        vec4 c0 = texture(_BaseMap, fs.uv0);
        vec4 c1 = texture(_BaseMap, fs.uv1);
        c = c0 * fs.w0 + c1 * (1.0 - fs.w0);
    }

    if (_UseRamp > 0.5)
    {
        float k = smoothstep(0.2, 0.9, c.r);
        c.rgb = texture(_RampTex, vec2(k, 0.5)).rgb * (k * k * _Intensity);
    }
    return vec4(c.rgb, 1.0);
}
图 21-5左:公共贴图 flow_swirl(RG 两通道的漩涡流场);中:单相位,在一个周期接近结束时(phase = 0.92)网格被严重拉扯,下一刻将整张跳回;右:双相位,同一时刻两套 UV 加权,拉伸被抵消,代价是能看到轻微的双层叠影

双相位的叠影在网格这种规则图案上很明显,在水面、熔岩这种随机纹理上则几乎看不见。所以 Flowmap 主要用于噪声类、法线类贴图:

图 21-6同一张漩涡流向图驱动噪声,再经过火焰渐变映射,得到一池缓慢旋转的熔岩
深入:为什么是三角波权重两套采样的权重之和必须恒为 1,否则整体亮度会随相位起伏;同时每套的权重必须在它复位的瞬间为 0。三角波 1 − |1 − 2p| 与错开半周期的另一条三角波相加恰好处处等于 1,满足这两个条件且最便宜。也可以用 sin²(πp) 与 cos²(πp),过渡更圆滑,但要多算三角函数。无论哪种,混合两张不相关的噪声都会让对比度在 w₀ = 0.5 附近下降(两张图平均后方差减半),画面会有轻微的“周期性变灰”,相位错开采样位置、并在混合后略微提高对比度可以缓解。
坑:Flowmap 不能压缩成普通格式流向图是数据,必须关闭 sRGB;更重要的是不要用 DXT1/ETC 这类按 4×4 块压缩的格式——平滑的方向场会被压成块状台阶,流动时能看到一格一格的“抖动”。用 BC5(双通道)、BC7,或者干脆不压缩。本章配图为此在拍摄时直接读取 PNG 生成了一张不压缩的贴图。
参数与制作_FlowStrength 决定一个周期内最多漂多远,_Speed 决定周期多长;两者的乘积才是视觉上的流速。强度太大会让每个周期内的拉伸重新变得明显,一般控制在贴图平铺的 0.1–0.4 之间。流向图本身可以用专门的流向绘制工具画,也可以从流体模拟或河道样条线烘焙出来;在 Houdini、Substance Designer 一类工具里都能生成。水面法线的 Flowmap 用法见第 19 章。

案例 21.4极坐标漩涡传送门进阶单独打开 ↗

目标:用一个 Quad 做出旋转、吸入的传送门漩涡,并修掉 atan2 带来的接缝。

把 Quad 的 UV 从直角坐标换成极坐标:角度 θ = atan2(y, x) 归一化到 0..1,半径 r = 中心距离 × 2(四边中点为 1)。之后所有“UV 动画”都在极坐标里做:

  • 沿角度方向滚动 → 旋转;
  • 沿半径方向滚动 → 向中心吸入或向外扩散;
  • 角度加上 r × twist → 越靠里转得越多,直线条纹变成螺旋臂。
Assets/Cookbook/Ch21/UVAnim.hlsl(节选:polar)
// 直角坐标 → 极坐标:x = 角度(0..1,一圈),y = 半径(中心 0,四边中点 1)
float2 ToPolar(float2 uv, float2 center)
{
    float2 d = uv - center;
    float angle = atan2(d.y, d.x) * (0.5 * INV_PI) + 0.5;
    float radius = length(d) * 2.0;
    return float2(angle, radius);
}
// 直角坐标 → 极坐标:x = 角度(0..1,一圈),y = 半径(中心 0,四边中点 1)
vec2 ToPolar(vec2 uv, vec2 center)
{
    vec2 d = uv - center;
    float angle = atan(d.y, d.x) * (0.5 * INV_PI) + 0.5;
    float radius = length(d) * 2.0;
    return vec2(angle, radius);
}
Assets/Cookbook/Ch21/Portal.shader(节选:swirl)
float2 SwirlUV(float2 polar, float t, float spin)
{
    // x:角度 × 臂数(必须是整数才能首尾相接)+ 半径 × 扭转(越往里转得越多)+ 时间旋转
    // y:半径 × 平铺 − 时间(图案不断向中心流)
    return float2(polar.x * _Arms + polar.y * _Twist + t * spin,
                  polar.y * _RadialTiling - t * _Inward);
}

// 带接缝修正的采样:atan2 在 ±π 处从 1 跳回 0,硬件按 2×2 像素块求导会得到巨大的 ddx,
// 于是选中最低一级 mip,在接缝上画出一条线。修正:另算一份“接缝挪到对面”的角度,取较小的导数
half SampleSwirl(float2 polar, float t, float spin)
{
    float2 uv = SwirlUV(polar, t, spin);
    float2 dx = ddx(uv), dy = ddy(uv);
    if (_FixSeam > 0.5)
    {
        float2 uvAlt = SwirlUV(float2(frac(polar.x + 0.5), polar.y), t, spin);
        float2 dxAlt = ddx(uvAlt), dyAlt = ddy(uvAlt);
        dx.x = abs(dx.x) < abs(dxAlt.x) ? dx.x : dxAlt.x;
        dy.x = abs(dy.x) < abs(dyAlt.x) ? dy.x : dyAlt.x;
    }
    return SAMPLE_TEXTURE2D_GRAD(_NoiseTex, sampler_NoiseTex, uv, dx, dy).r;
}
vec2 SwirlUV(vec2 polar, float t, float spin)
{
    // x:角度 × 臂数(必须是整数才能首尾相接)+ 半径 × 扭转(越往里转得越多)+ 时间旋转
    // y:半径 × 平铺 − 时间(图案不断向中心流)
    return vec2(polar.x * _Arms + polar.y * _Twist + t * spin,
                polar.y * _RadialTiling - t * _Inward);
}

// 带接缝修正的采样:atan2 在 ±π 处从 1 跳回 0,硬件按 2×2 像素块求导会得到巨大的 ddx,
// 于是选中最低一级 mip,在接缝上画出一条线。修正:另算一份“接缝挪到对面”的角度,取较小的导数
float SampleSwirl(vec2 polar, float t, float spin)
{
    vec2 suv = SwirlUV(polar, t, spin);
    vec2 dx = dFdx(suv), dy = dFdy(suv);
    if (_FixSeam > 0.5)
    {
        vec2 uvAlt = SwirlUV(vec2(fract(polar.x + 0.5), polar.y), t, spin);
        vec2 dxAlt = dFdx(uvAlt), dyAlt = dFdy(uvAlt);
        dx.x = abs(dx.x) < abs(dxAlt.x) ? dx.x : dxAlt.x;
        dy.x = abs(dy.x) < abs(dyAlt.x) ? dy.x : dyAlt.x;
    }
    return textureGrad(_NoiseTex, suv, dx, dy).r;
}

角度乘以 _Arms 后作为 U:贴图是 Repeat 寻址,U 从 0 走到整数 n,正好把贴图横向平铺 n 次首尾相接,于是得到 n 条旋臂。_Arms 如果不是整数,在 θ 回到起点的地方图案对不上,会出现一条明显的断缝。

atan2 的接缝

即使 _Arms 是整数、颜色本身连续,仍可能在 θ = ±π 处(Quad 左侧那条水平半径上)看到一条细线。原因在 mipmap:GPU 以 2×2 像素块为单位并行着色,用块内相邻像素的 UV 差(ddx/ddy)估计“一个像素覆盖多少纹素”,据此选择 mip 级别。接缝两侧的 θ 一个接近 1、一个接近 0,差值巨大,硬件以为这个像素覆盖了整张贴图,于是选中了最小的一级 mip——一个接近平均灰的值。

修正方法是自己提供导数:另算一份“把接缝挪到对面”的角度 frac(θ + 0.5),它在原接缝处是连续的;两份导数里取绝对值较小的那个,用 SAMPLE_TEXTURE2D_GRAD 采样。核心代码就是上面 SampleSwirl 里的几行。

Assets/Cookbook/Ch21/Portal.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float2 polar = ToPolar(i.uv, float2(0.5, 0.5));
    float t = _Time.y + _Phase;

    if (_Debug > 0.5 && _Debug < 1.5) return half4(polar.x, polar.y, 0, 1);
    if (_Debug > 1.5 && _Debug < 2.5) return half4(frac(SwirlUV(polar, t, _Spin)), 0, 1);
    // 只看一层漩涡噪声并二值化:接缝处取到最低级 mip(≈ 平均灰),在黑白图案里显出一道灰线
    if (_Debug > 3.5) { float2 u = SwirlUV(polar, t, _Spin); float d = max(length(ddx(u)), length(ddy(u))); return half4(saturate(log2(d * 512.0) / 10.0).xxx, 1); }
    if (_Debug > 2.5) return half4(smoothstep(0.5, 0.7, SampleSwirl(polar, t, _Spin)).xxx, 1);

    // 两层速度不同的漩涡相乘,图案更“活”
    half n1 = SampleSwirl(polar, t, _Spin);
    half n2 = SampleSwirl(polar * float2(1, 0.7), t * 1.3, -_Spin * 0.6);
    half swirl = pow(saturate(n1 * n2 * 2.0), _Contrast);

    half r = polar.y;
    half disc = smoothstep(1.0, 0.8, r);                     // 外缘淡出
    half rim  = exp(-pow((r - 0.86) * 9.0, 2.0));            // 外圈一道亮环
    half core = exp(-r * r * 18.0);                          // 中心光核
    half3 col = lerp(_ColorInner.rgb, _ColorOuter.rgb, saturate(r * 1.2));
    half3 result = col * (swirl * disc + rim * 0.8) + _ColorInner.rgb * core;
    return half4(result, 1);
}
vec4 frag()
{
    vec2 polar = ToPolar(uv, vec2(0.5, 0.5));
    float t = _Time.y + _Phase;

    if (_Debug > 0.5 && _Debug < 1.5) return vec4(polar.x, polar.y, 0.0, 1.0);
    if (_Debug > 1.5 && _Debug < 2.5) return vec4(fract(SwirlUV(polar, t, _Spin)), 0.0, 1.0);
    // 只看一层漩涡噪声并二值化:接缝处取到最低级 mip(≈ 平均灰),在黑白图案里显出一道灰线
    if (_Debug > 3.5) { vec2 u = SwirlUV(polar, t, _Spin); float d = max(length(dFdx(u)), length(dFdy(u))); return vec4(vec3(saturate(log2(d * 512.0) / 10.0)), 1.0); }
    if (_Debug > 2.5) return vec4(vec3(smoothstep(0.5, 0.7, SampleSwirl(polar, t, _Spin))), 1.0);

    // 两层速度不同的漩涡相乘,图案更“活”
    float n1 = SampleSwirl(polar, t, _Spin);
    float n2 = SampleSwirl(polar * vec2(1, 0.7), t * 1.3, -_Spin * 0.6);
    float swirl = pow(saturate(n1 * n2 * 2.0), _Contrast);

    float r = polar.y;
    float disc = smoothstep(1.0, 0.8, r);                     // 外缘淡出
    float rim  = exp(-pow((r - 0.86) * 9.0, 2.0));            // 外圈一道亮环
    float core = exp(-r * r * 18.0);                          // 中心光核
    vec3 col = mix(_ColorInner.rgb, _ColorOuter.rgb, saturate(r * 1.2));
    vec3 result = col * (swirl * disc + rim * 0.8) + _ColorInner.rgb * core;
    return vec4(result, 1.0);
}
图 21-7左一:极坐标 UV(R = 角度、G = 半径),左侧水平线就是 θ 从 1 跳回 0 的位置;左二:加上扭转与时间偏移后的漩涡 UV;右二:UV 导数的大小(越亮越大),接缝处出现一条亮线,中心处导数也趋于无穷;右一:最终的漩涡
图 21-8接缝特写(只显示一层漩涡噪声并二值化)。左:直接采样,左侧水平方向上有一道灰线;右:用 SAMPLE_TEXTURE2D_GRAD 传入修正后的导数,接缝消失

还有一个有趣的现象:这条接缝在屏幕上时隐时现。如果接缝恰好落在两个 2×2 像素块的分界线上,块内的像素都在接缝同一侧,导数正常,就看不到线。拍图 21-7 时我们特意把 Quad 向上移了 1 个像素才让它显形——这也是为什么这类 bug 经常“在我机器上没有”。中心点是另一个奇点:所有角度在那里汇聚,导数同样趋于无穷,实际制作中用一个中心光核盖住即可。

图 21-9传送门成品:极坐标漩涡(叠加混合,HDR 颜色)+ 外圈亮环 + 中心光核,开启 Bloom 与 ACES 色调映射。石环用的是普通 Lit 材质,紫色点光模拟传送门照亮周围
叠加混合的好处传送门 Pass 使用 Blend One One:结果只会让背景变亮,多个发光效果重叠时与绘制顺序无关,不需要排序,也不写深度。代价是它无法让背景变暗——要做“黑洞”式的暗色漩涡,就得改用 Alpha 混合或正片叠底,并处理排序问题(见第 16 章)。

21.2 选哪一种 UV 动画

到这里已经有了四种“动纹理”的手段,它们解决的问题不同,组合起来几乎能覆盖所有平面类特效。选择时可以按“运动是否有方向、方向是否处处相同、形状是否需要逐帧设计”来判断:

手段运动特征额外资源采样次数典型用途
UV 滚动全局同一方向、匀速无(一张可平铺贴图)1–2传送带、瀑布、能量流、云层
序列帧任意,由美术逐帧设计图集(占用显存多)1(混合时 2)爆炸、烟团、火焰、闪电
Flowmap方向随位置变化流向图(低分辨率即可)3(流向 + 双相位)河流绕石、漩涡、熔岩、魔法阵
极坐标旋转、径向吸入/扩散无1–2传送门、冲击波、雷达扫描、法阵

它们可以互相嵌套:在极坐标里再叠一层 UV 扰动,旋涡就有了湍流感;用 Flowmap 驱动一张序列帧,烟团在流场里一边翻滚一边漂移;把 UV 滚动的噪声当作溶解(第 18 章)的阈值,能量护盾就会沿着流动的纹路消散。设计特效 Shader 时,一个实用的习惯是先用灰度把“运动”调对,最后再做颜色(渐变映射、HDR 强度)——颜色会掩盖运动上的问题,而运动不对时颜色再好看也不自然。

移动端的贴图预算序列帧最吃显存:一张 2048² 的 8×8 图集每帧只有 256² 的分辨率,却要占用整张贴图的显存。能用程序化噪声 + UV 动画表达的效果(流动、旋转、扭曲),尽量不要做成序列帧;必须用序列帧时,降低单帧分辨率、减少帧数并打开帧间混合,往往比提高分辨率更划算。另外,透明特效的瓶颈多在片元填充率而不是采样,Flowmap 的三次采样在中低端设备上也要算进预算里。
把参数交给顶点色或 Custom Data同一个材质的不同实例要有不同的速度、相位、强度时,不要复制材质(会打断合批),而是把这些参数放进顶点色或粒子的 Custom Data 数据流里:粒子系统的 Custom Data 模块可以按曲线随寿命变化,Shader 从额外的 TEXCOORD 读取。网格物体则可以用 MaterialPropertyBlock,或者在 SRP Batcher 下直接使用不同材质(SRP Batcher 对多材质的代价很小)。

21.3 粒子系统与 Shader 的接口

Unity 粒子系统(Shuriken)在 CPU 上模拟每个粒子的位置、大小、旋转、颜色和寿命,每帧把它们展开成面向相机的四边形(Billboard),打包成一个动态网格交给材质绘制。理解粒子 Shader 的关键,是搞清楚这个网格的每个顶点里装了什么:

顶点数据来自粒子系统的哪里
POSITION已经展开好的世界空间顶点(World 模拟空间时,渲染矩阵是单位矩阵)
COLORStart Color × Color over Lifetime × Color by Speed……所有颜色模块相乘的结果
TEXCOORD0.xyUV;开启 Texture Sheet Animation 时已经换算成当前帧所在的格子
Custom Vertex Streams可选的额外数据:UV2(下一帧的 UV)、AnimBlend(帧间进度)、Center、Velocity、Age%、Random、Custom1/2……

也就是说,粒子系统里调的颜色渐变、序列帧动画,最终都以顶点数据的形式到达 Shader;Shader 必须把顶点色乘进去,否则美术在粒子系统里调的颜色全部失效——这是自定义粒子 Shader 最常见的 bug。

URP 自带三个粒子 Shader:Particles/Unlit(不受光,最常用)、Particles/Simple Lit(Blinn-Phong)、Particles/Lit(PBR)。它们的材质面板提供:Surface Type(不透明/透明)、Blending Mode(Alpha、Premultiply、Additive、Multiply)、Color Mode(贴图颜色与粒子顶点色的组合方式:Multiply、Additive、Subtractive、Overlay、Color、Difference,实现在 Particles.hlsl 的 MixParticleColor)、Flip-Book Blending、Soft Particles、Camera Fading、Distortion。下面三个案例自己实现其中最核心的几项。

案例 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 加上去,要求贴图本身在透明处是黑色。粒子贴图如果是在黑底上画的(很多火焰素材是这样),用后者即可,还省一次乘法。

案例 21.6软粒子:与场景的柔和相交进阶单独打开 ↗

目标:消除粒子面片插进地面、墙壁时那条生硬的直线。

粒子是平面,场景几何体是实体;面片与地面相交,深度测试会把地面以下的部分整齐切掉,留下一条直线,立刻暴露出“这是一张纸片”。软粒子(Soft Particles)的思路是:在片元着色器里读取场景深度,比较“场景表面的距离”与“粒子自身的距离”,两者越接近(粒子越要插进物体),alpha 越小:

fade=saturate(zscene−zparticled)

两个深度都必须是线性的观察空间深度(单位:米)。场景深度来自 _CameraDepthTexture,用 SampleSceneDepth 读出的是非线性的设备深度,要经过 LinearEyeDepth(raw, _ZBufferParams) 转换;粒子自身的观察空间深度在顶点着色器里直接取 −positionVS.z 传下来(Unity 观察空间看向 −Z)。d 是过渡距离,代码里的 _SoftDistance。

作为对照,摘自 URP 14 ShaderLibrary/Particles.hlsl 的实现(注意它支持正交相机,还多了一个 near 偏移参数):

float SoftParticles(float near, float far, float4 projection)
{
    float fade = 1;
    if (near > 0.0 || far > 0.0)
    {
        float2 uv = UnityStereoTransformScreenSpaceTex(projection.xy / projection.w);
        uv = FoveatedRemapLinearToNonUniform(uv);
        float rawDepth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_CameraDepthTexture, uv).r;
        float sceneZ = (unity_OrthoParams.w == 0) ? LinearEyeDepth(rawDepth, _ZBufferParams) : LinearDepthToEyeDepth(rawDepth);
        float thisZ = LinearEyeDepth(projection.z / projection.w, _ZBufferParams);
        fade = saturate(far * ((sceneZ - near) - thisZ));
    }
    return fade;
}
图 21-11一团贴地的烟(大面片)与地面、箱子相交。左:普通粒子,地面上和箱子边缘都有清晰的切线;右:软粒子(过渡 0.6 米),烟雾柔和地“沉”进地面和箱子背后
软粒子需要深度图URP 只有在 URP Asset 勾选了 Depth Texture(或相机单独开启、或某个 Renderer Feature 请求了深度)时才会生成 _CameraDepthTexture;否则采到的是默认值,所有粒子要么全透明要么毫无变化。URP 的粒子材质面板会在这种情况下给出提示,自写 Shader 则不会,排查时先看 Frame Debugger 里有没有 CopyDepth 这一步。另外,在移动端生成深度图本身有带宽开销(可能需要一次额外的 Depth Prepass 或拷贝),大量使用软粒子前要评估。

同样的“深度差”还可以做近相机淡出:粒子离相机太近时会糊满整个屏幕,带来巨大的 Overdraw,按 thisZ 在近处淡出即可(代码中的 _CAMERAFADE_ON 分支)。它不需要深度图,开销几乎为零,烟雾类粒子建议默认打开。

案例 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 增加实例化路径。

案例 21.8热扭曲:采样 Opaque Texture进阶单独打开 ↗

目标:火焰上方的空气抖动,透过它看到的背景在晃动。

热扭曲(Heat Distortion)不是画出一种颜色,而是把“背后的画面”挪一挪再画回来。URP 在不透明物体画完后可以把颜色缓冲拷贝一份,作为 _CameraOpaqueTexture 供透明物体采样(URP Asset 勾选 Opaque Texture)。扭曲 Shader 用滚动的噪声生成一个二维偏移,加到屏幕 UV 上去采样这张图:

Assets/Cookbook/Ch21/HeatDistortion.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float t = _Time.y + _Phase;
    // 两次错位采样单通道噪声,拼成一个二维偏移向量;整体向上滚动,像热空气上升
    float2 nuv = i.uv * _NoiseTiling.xy + float2(0, -frac(t * _RiseSpeed));
    half nx = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, nuv).r - 0.5;
    half ny = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, nuv * 1.31 + 0.37).r - 0.5;
    half mask = SAMPLE_TEXTURE2D(_MaskTex, sampler_MaskTex, i.uv).a * i.color.a;

    // 屏幕上的偏移量除以深度:同样的“空气扰动”,离得越远在屏幕上越小
    float2 offset = float2(nx, ny) * (_Strength * mask) / max(i.screenData.z * 0.2, 1.0);

    // 调试:把偏移量放大后显示,R/G = 水平/竖直偏移,0.5 灰为不偏移
    if (_ShowOffset > 0.5) return half4(offset * (1.5 / max(_Strength, 1e-4)) + 0.5, 0.5, 1);

    float2 suv = i.screenData.xy / i.screenData.w;
    half3 scene = SampleSceneColor(suv + offset);
    return half4(scene, saturate(mask * 3.0));
}
vec4 frag()
{
    float t = _Time.y + _Phase;
    // 两次错位采样单通道噪声,拼成一个二维偏移向量;整体向上滚动,像热空气上升
    vec2 nuv = uv * _NoiseTiling.xy + vec2(0.0, -fract(t * _RiseSpeed));
    float nx = texture(_NoiseTex, nuv).r - 0.5;
    float ny = texture(_NoiseTex, nuv * 1.31 + 0.37).r - 0.5;
    float mask = texture(_MaskTex, uv).a * vColor.a;

    // 屏幕上的偏移量除以深度:同样的“空气扰动”,离得越远在屏幕上越小
    vec2 offset = vec2(nx, ny) * (_Strength * mask) / max(screenData.z * 0.2, 1.0);

    // 调试:把偏移量放大后显示,R/G = 水平/竖直偏移,0.5 灰为不偏移
    if (_ShowOffset > 0.5) return vec4(offset * (1.5 / max(_Strength, 1e-4)) + 0.5, 0.5, 1.0);

    vec2 suv = screenData.xy / screenData.w;
    vec3 scene = SampleSceneColor(suv + offset);
    return vec4(scene, saturate(mask * 3.0));
}

几个细节:

  • 偏移来自单通道噪声的两次错位采样。公共贴图 noise_fbm 只有一个通道,在两个不同位置采样得到 x、y 两个不相关的分量。用法线贴图的 xy 作为偏移也很常见,效果相同。
  • 除以深度。同样强度的扰动,离相机远时在屏幕上应该更小,否则远处的火堆会把整片背景扭得乱七八糟。
  • 遮罩。偏移量乘以 particle_soft 的径向遮罩和顶点色 alpha,扭曲在面片边缘归零,不会露出矩形边框;输出 alpha 也用遮罩,边缘处仍然显示原本的画面。
图 21-13左:原始场景;中:调试视图,显示放大后的屏幕 UV 偏移(R/G = 水平/竖直偏移,灰色为零);右:最终的热扭曲,背景网格线在面片范围内弯曲晃动,边缘平滑过渡
Opaque Texture 的三个限制① 它只包含不透明物体和天空盒:拷贝发生在不透明 Pass 之后,透明物体(包括其他粒子)不在里面。扭曲面片如果挡在另一团透明烟前面,那团烟会在扭曲区域里“消失”。② URP Asset 的 Opaque Downsampling 默认是 2× 双线性降采样,透过扭曲区域看到的画面会略模糊——对热浪反而是好事,对需要清晰折射的玻璃就要改成 None。③ 偏移采样会把位于扭曲面片前方的物体也“吸”进来,形成鬼影,修正方法是比较偏移位置的场景深度与面片深度,详见第 19 章水的折射。

案例 21.9综合:夜晚的篝火进阶单独打开 ↗

目标:把本章的技术组合成一个完整的小场景。

篝火由三层组成:火焰粒子(序列帧 + 渐变映射 + 叠加 + 软粒子)、烟雾粒子(Alpha 混合 + 软粒子 + 近相机淡出)、火焰上方一块热扭曲面片;一盏带阴影的橙色点光照亮周围的石头和木柴。远处一个小传送门作为点缀,它和熔岩、篝火一样只是一个 Quad 加一个 UV 动画 Shader。后处理开启 Bloom 和 ACES 色调映射,让 HDR 的火焰颜色自然溢出。

图 21-14本章综合效果:粒子系统驱动的火焰与烟、热扭曲、点光源阴影与远处的极坐标传送门

性能提示

  • 粒子的主要开销是 Overdraw。一团烟由几十个大面片叠成,屏幕上同一像素可能被画十几次。减少粒子数量、缩小单个面片、裁掉贴图的透明边缘(粒子系统支持用自定义网格代替四边形,贴合贴图形状的八边形能减少不少无效像素),比优化 Shader 指令有效得多。
  • 软粒子和热扭曲都要额外的全屏纹理(深度图、Opaque Texture),在移动端是实打实的带宽成本;如果整个项目只有少数特效需要它们,考虑只在特定相机或质量档位开启。
  • 叠加混合的粒子与绘制顺序无关,可以关闭粒子系统的排序;Alpha 混合的粒子则需要按距离排序(Renderer 模块的 Sort Mode),否则前后关系会错。
  • Shader 变体:本章的粒子 Shader 用 shader_feature_local 控制帧混合、软粒子、近相机淡出,只有材质实际用到的组合才会进包。

本章小结

  • 动画的时间来自 _Time.y;周期性偏移先 frac 再加到 UV 上避免精度问题;留一个 _Phase 参数便于静帧、错相和 Timeline 控制。
  • UV 滚动 → 双层相乘 → UV 扰动 → 渐变映射,一张灰度噪声就能做出流体感。
  • 序列帧的帧号 → 格子换算要注意 V 轴向上;帧间混合是交叉淡化,不是运动插值,清晰形状会出现重影。
  • Flowmap 用 RG 存流速,单相位会周期性跳变,双相位用两个错开半周期、三角波加权的采样消除跳变;流向图不能用块压缩格式。
  • 极坐标 = (atan2 角度, 半径);角度乘整数臂数才能首尾相接;atan2 接缝来自 2×2 像素块求导,用 SAMPLE_TEXTURE2D_GRAD 传入修正后的导数解决。
  • 粒子系统的颜色、序列帧都以顶点数据到达 Shader;Custom Vertex Streams 的顺序必须与 Shader 的输入语义一致。
  • 软粒子比较线性的场景深度与粒子深度,需要 Depth Texture;热扭曲采样 Opaque Texture,它不包含透明物体。

练习

  1. 给 UVScroll.shader 加上法线:用两层噪声的高度差算一个扰动法线,让熔岩的暗色岩壳有凹凸受光的感觉。
  2. 修改 Flipbook.shader,让每个物体根据自己的世界坐标得到一个随机的起始帧,一排相同的火把不再同步闪烁。
  3. 做一个三相位的 Flowmap(三套 UV 各错开 1/3 周期),与双相位比较叠影和开销。
  4. 在 Portal.shader 里加入一个“开启进度” _Open:0 时只有中心一个光点,1 时完全展开,半径方向的遮罩随进度扩大。
  5. 给 HeatDistortion.shader 加上前景泄漏修正:采样偏移位置的场景深度,若它比面片更近,就退回不偏移的 UV。