URP 渲染实战 / 第 21 章 UV 动画与粒子 / 案例 21.2
案例 21.2序列帧 Flipbook 与帧间混合入门
目标:播放一张 4×4 的序列帧图集,并理解“帧间混合”到底做了什么。
序列帧(Flipbook,也叫 Sprite Sheet)把动画的每一帧排在一张图集里。Shader 根据时间算出当前是第几帧,再把 0..1 的 UV 缩小到那一格。约定第 0 帧在左上角、从左到右、从上到下(与 Unity 粒子系统的 Texture Sheet Animation 一致);而纹理坐标的 V 轴是向上的,所以“第 0 行”对应 V 最大的那一行,行号要从顶部数:
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; }

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

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