UV 动画与粒子
让画面“动起来”的最便宜的办法,不是动顶点,而是动纹理坐标。熔岩的流淌、火焰的翻滚、传送门的旋涡、烟雾的升腾,几乎都可以归结为“按时间改变 UV”。本章从最简单的 UV 滚动讲起,一路做到粒子系统的 Shader 接口、软粒子和热扭曲。
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 里用 |
使用时间要注意三件事:
- 精度。
_Time.y是 float,持续增长。UV 偏移写成uv + speed × t,运行几个小时后 t 达到 10⁴ 量级,加到 0..1 的 UV 上就会丢失小数位,纹理开始一跳一跳地移动;如果中间变量用了 half,几十秒就会出问题。对周期性的偏移,先frac(speed × t)再加到 UV 上——贴图的 Repeat 寻址保证结果完全相同。 - 编辑模式。不在 Play 模式时,Scene 视图只有勾选 (场景视图工具栏的效果开关里)才会持续重绘,否则动画看起来是静止的。
- 静帧与录制。本书配图在批处理模式下渲染,
_Time几乎为 0 且不可控。因此本章所有动画 Shader 都留了一个_Phase(或_Frame)参数,实际时间 =_Time.y + _Phase。这个习惯在项目里同样有用:同一材质的多个物体可以错开相位,过场动画也可以用 Timeline 直接驱动_Phase做精确对帧。
案例 21.1UV 滚动:流动的熔岩入门单独打开 ↗
目标:只用一张灰度噪声图,做出缓慢流动、亮暗翻滚的熔岩河。
单层滚动就是 uv + speed × t:整张图匀速平移,像传送带。它的问题是“太刚体”——所有细节以同样的速度、同样的方向移动,眼睛一下就能看出是一张图在平移。改进分三步:
- 双层叠加:同一张噪声以不同的平铺、不同的速度和方向再滚一层,两层相乘。两层的相对运动让图案不断变化,再也找不到“那一张图”。相乘会让平均亮度下降(0.5 × 0.5 = 0.25),所以乘以 2 补回来。
- UV 扰动:用第二层的值去偏移第一层的 UV。原本直线运动的图案开始扭曲、汇聚、分叉,像有了流体的速度场。
- 渐变映射(Gradient Mapping):把灰度当作“温度”,查一张一维的颜色渐变(公共贴图
ramp_fire):暗处是冷却凝固的黑色岩壳,亮处是黄白色的高温熔岩,并乘上k²让高温处成为 HDR 发光。
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); }

smoothstep 拉开对比,熔岩才会有清晰的“岩壳/裂缝”分界。ramp_* 已设为 Clamp。案例 21.2序列帧 Flipbook 与帧间混合入门单独打开 ↗
目标:播放一张 4×4 的序列帧图集,并理解“帧间混合”到底做了什么。
序列帧(Flipbook,也叫 Sprite Sheet)把动画的每一帧排在一张图集里。Shader 根据时间算出当前是第几帧,再把 0..1 的 UV 缩小到那一格。约定第 0 帧在左上角、从左到右、从上到下(与 Unity 粒子系统的 Texture Sheet Animation 一致);而纹理坐标的 V 轴是向上的,所以“第 0 行”对应 V 最大的那一行,行号要从顶部数:
// 第 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) 表示“从这一帧到下一帧走了多少”。帧间混合就是再采一次下一帧,按这个小数线性插值:
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; }

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

案例 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,此时画面完全由另一套提供;复位之后它的权重再慢慢升上来。
// 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 采到的图案完全相同,只是错开半周期,人眼仍能察觉到画面每半个周期“呼吸”一次;错开采样位置后,这种规律性被打散。
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); }

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

1 − |1 − 2p| 与错开半周期的另一条三角波相加恰好处处等于 1,满足这两个条件且最便宜。也可以用 sin²(πp) 与 cos²(πp),过渡更圆滑,但要多算三角函数。无论哪种,混合两张不相关的噪声都会让对比度在 w₀ = 0.5 附近下降(两张图平均后方差减半),画面会有轻微的“周期性变灰”,相位错开采样位置、并在混合后略微提高对比度可以缓解。_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→ 越靠里转得越多,直线条纹变成螺旋臂。
// 直角坐标 → 极坐标: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); }
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 里的几行。
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); }


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

Blend One One:结果只会让背景变亮,多个发光效果重叠时与绘制顺序无关,不需要排序,也不写深度。代价是它无法让背景变暗——要做“黑洞”式的暗色漩涡,就得改用 Alpha 混合或正片叠底,并处理排序问题(见第 16 章)。21.2 选哪一种 UV 动画
到这里已经有了四种“动纹理”的手段,它们解决的问题不同,组合起来几乎能覆盖所有平面类特效。选择时可以按“运动是否有方向、方向是否处处相同、形状是否需要逐帧设计”来判断:
| 手段 | 运动特征 | 额外资源 | 采样次数 | 典型用途 |
|---|---|---|---|---|
| UV 滚动 | 全局同一方向、匀速 | 无(一张可平铺贴图) | 1–2 | 传送带、瀑布、能量流、云层 |
| 序列帧 | 任意,由美术逐帧设计 | 图集(占用显存多) | 1(混合时 2) | 爆炸、烟团、火焰、闪电 |
| Flowmap | 方向随位置变化 | 流向图(低分辨率即可) | 3(流向 + 双相位) | 河流绕石、漩涡、熔岩、魔法阵 |
| 极坐标 | 旋转、径向吸入/扩散 | 无 | 1–2 | 传送门、冲击波、雷达扫描、法阵 |
它们可以互相嵌套:在极坐标里再叠一层 UV 扰动,旋涡就有了湍流感;用 Flowmap 驱动一张序列帧,烟团在流场里一边翻滚一边漂移;把 UV 滚动的噪声当作溶解(第 18 章)的阈值,能量护盾就会沿着流动的纹路消散。设计特效 Shader 时,一个实用的习惯是先用灰度把“运动”调对,最后再做颜色(渐变映射、HDR 强度)——颜色会掩盖运动上的问题,而运动不对时颜色再好看也不自然。
21.3 粒子系统与 Shader 的接口
Unity 粒子系统(Shuriken)在 CPU 上模拟每个粒子的位置、大小、旋转、颜色和寿命,每帧把它们展开成面向相机的四边形(Billboard),打包成一个动态网格交给材质绘制。理解粒子 Shader 的关键,是搞清楚这个网格的每个顶点里装了什么:
| 顶点数据 | 来自粒子系统的哪里 |
|---|---|
| POSITION | 已经展开好的世界空间顶点(World 模拟空间时,渲染矩阵是单位矩阵) |
| COLOR | Start Color × Color over Lifetime × Color by Speed……所有颜色模块相乘的结果 |
| TEXCOORD0.xy | UV;开启 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,理解几种混合模式的差别。
// 与粒子系统 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 };
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 设置 | 结果 | 典型用途 |
|---|---|---|---|
| Alpha | SrcAlpha OneMinusSrcAlpha | 按 alpha 覆盖背景,能遮挡 | 烟、灰尘、碎片 |
| Additive | SrcAlpha One(或 One One) | 只加亮,不遮挡,与顺序无关 | 火花、魔法光效、光晕 |
| Premultiply | One OneMinusSrcAlpha | RGB 已预乘 alpha;alpha 小而 RGB 大时表现为发光 | 火焰核心(一张图里既有遮挡又有发光) |
| Multiply | DstColor Zero | 只会变暗 | 焦痕、阴影斑、暗色烟 |
正片叠底没有 alpha 参与运算,透明的地方必须输出白色才不改变背景;代码里的 _Modulate 就是把 alpha 折算进颜色:lerp(1, rgb, a),与 URP 的 AlphaModulate 函数做法相同。
由于批处理模式下粒子系统的拍摄需要额外处理(见案例 21.7),这一组对比图用一组朝向相机的 Quad 手工摆出“一团粒子”,每个 Quad 的网格写入不同的顶点色,模拟粒子的 Start Color——Shader 看到的数据与真实粒子并无二致:
/// 用一组朝向相机的 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); } }

SrcAlpha One 用 alpha 控制亮度,贴图带 alpha 通道时更直观;One One 直接把 RGB 加上去,要求贴图本身在透明处是黑色。粒子贴图如果是在黑底上画的(很多火焰素材是这样),用后者即可,还省一次乘法。案例 21.6软粒子:与场景的柔和相交进阶单独打开 ↗
目标:消除粒子面片插进地面、墙壁时那条生硬的直线。
粒子是平面,场景几何体是实体;面片与地面相交,深度测试会把地面以下的部分整齐切掉,留下一条直线,立刻暴露出“这是一张纸片”。软粒子(Soft Particles)的思路是:在片元着色器里读取场景深度,比较“场景表面的距离”与“粒子自身的距离”,两者越接近(粒子越要插进物体),alpha 越小:
两个深度都必须是线性的观察空间深度(单位:米)。场景深度来自 _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; }

_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 必须与之吻合。
/// 建一个用本章 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() 都能渲染出这一刻的粒子。同样的方法也适合做编辑器里的特效预览工具、或者生成特效缩略图。

火焰没有专门的火焰序列帧,而是复用烟雾图集:Shader 里用“贴图浓度 × 顶点色 alpha(随寿命淡出)”作为温度去查 ramp_fire 渐变——新生的、浓的部分是黄白色,边缘和老化的部分退成暗红。一张灰度序列帧配不同的渐变图,就能得到火焰、毒烟、魔法烟等多种变体,这是控制特效贴图数量的常用手法。
UNITY_PARTICLE_INSTANCING_ENABLED 分支处理。本章的 Shader 只支持 Billboard 模式;如果要用 Mesh 粒子 + 实例化,需要参照 ParticlesInstancing.hlsl 增加实例化路径。案例 21.8热扭曲:采样 Opaque Texture进阶单独打开 ↗
目标:火焰上方的空气抖动,透过它看到的背景在晃动。
热扭曲(Heat Distortion)不是画出一种颜色,而是把“背后的画面”挪一挪再画回来。URP 在不透明物体画完后可以把颜色缓冲拷贝一份,作为 _CameraOpaqueTexture 供透明物体采样(URP Asset 勾选 )。扭曲 Shader 用滚动的噪声生成一个二维偏移,加到屏幕 UV 上去采样这张图:
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.9综合:夜晚的篝火进阶单独打开 ↗
目标:把本章的技术组合成一个完整的小场景。
篝火由三层组成:火焰粒子(序列帧 + 渐变映射 + 叠加 + 软粒子)、烟雾粒子(Alpha 混合 + 软粒子 + 近相机淡出)、火焰上方一块热扭曲面片;一盏带阴影的橙色点光照亮周围的石头和木柴。远处一个小传送门作为点缀,它和熔岩、篝火一样只是一个 Quad 加一个 UV 动画 Shader。后处理开启 Bloom 和 ACES 色调映射,让 HDR 的火焰颜色自然溢出。

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