GameTech/URP 渲染实战

案例 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(代价是远处闪烁)。