12.2 动画帧率与状态规范

动画规范要把「几帧、多少毫秒、混合多久」说死:本节把《星屑旅店》三个角色在统一 30fps 下算成整数帧账,给出帧数、对齐毫秒与残差,用状态完备性 lint 抓出缺失的死亡动画,并说明同帧率下两段动画的时间轴如何靠公共帧(帧数的最大公约数)对齐,附 S 层脚本与 P 层 TS 内核的逐位一致复算。

12.1 管的是「一次播放有多长、占屏多少」,本节管的是「一整套状态有多齐、每段多长、怎么接」。动画规范最常犯的错不是画得不好,而是说不清:idle 到底 1.0 秒还是 1.2 秒、受伤到死亡之间混合多久、换了个角色为什么少一个死亡动画——这些全是可以用整数说死的事。分工照旧:骨骼蒙皮的数学(骨骼矩阵、调色板、蒙皮公式)在 骨骼动画原理 ,本节不重推数学,只做美术侧的状态清单与帧账。

12.2.1 帧率不是越高越好

先说结论:角色动画用统一帧率,比用高帧率更重要。

  • 如果 host 的 idle 是 24fps、pet 的 walk 是 30fps,两段动画拼在同一条时间轴上就会错拍:同一时刻一个在帧 8、一个在帧 10,混合时视觉会抖。
  • 统一到 30fps 之后,所有角色的帧边界落在同一张网格上,混合、切换、事件回调都能对齐到整数帧。

所以规范的第一条是:designFps 全项目唯一(本套取 30)。第二条是:每段动画的时长必须是整数毫秒,并且换算出的帧数必须是整数——换算不整除的部分要么吸收进时长,要么记进残差,不能糊过去。

12.2.2 四个整数口径

口径公式说明
framesdurationMs × fps // 1000该状态的整数帧数(地板除)
alignedMsframes × 1000 // fps帧数折算回毫秒(对齐到帧边界)
residualMsdurationMs − alignedMs与整数帧网格的残差,正负都可能
blendFramesblendMs × fps // 1000过渡混合占的整数帧数

残差是这套账里最有用的量:350ms @30fps 换算成 10 帧(10.5 地板除),对齐回来是 333ms,残差 17ms——也就是说美术写「350ms」,引擎实际播 10 帧(约 333ms)。规范应该直接写帧数,让「毫秒」只是给美术看的近似值。

12.2.3 状态表(D 层配置)

下面这段是 D 层配置:统一帧率 30、混合时长 120ms、受击闪白引用的颜色 token、四个必需状态,以及三个角色的状态时长表。guest 故意缺了 die,用来证明状态完备性 lint 真的会拦:

{
  "project": "stardust-inn",
  "schemaVersion": 1,
  "specRef": "config/visual_spec.json",
  "note": "动画帧率与状态规范:designFps 是角色动画统一帧率(整数)。每个角色给出各状态的整数毫秒时长;frames = durationMs * fps // 1000(整数地板除),alignedMs = frames * 1000 // fps。requiredStates 是状态完备性 lint 的下限集合,缺任一状态即 fail。hitFlashToken 是受击闪白的颜色 token 引用,必须存在于 visual_spec.json。",
  "designFps": 30,
  "blendMs": 120,
  "hitFlashToken": "danger",
  "requiredStates": ["idle", "walk", "hurt", "die"],
  "characters": [
    { "id": "host", "cn": "店主", "states": { "idle": 1200, "walk": 800, "hurt": 400, "die": 1000 } },
    { "id": "guest", "cn": "客人", "states": { "idle": 1000, "walk": 900, "hurt": 350 } },
    { "id": "pet", "cn": "宠物", "states": { "idle": 800, "walk": 600, "hurt": 300, "die": 900, "run": 700 } }
  ]
}

12.2.4 S 层:帧账与状态完备性

下面这段是 S 层脚本,对每个角色的每个状态算整数帧账,并对 requiredStates 做完备性 lint:

#!/usr/bin/env python3
# file: sim/sp12_anim.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp12_anim.py
"""12.2 动画帧率与状态规范的 S 层账。

读 config/visual_spec.json(token 真源,只读)与 config/sp12_anim.json。
对每个角色的每个状态,在统一 designFps 下算整数帧账:

  frames     = durationMs * fps // 1000        ← 整数地板除
  alignedMs  = frames * 1000 // fps            ← 对齐到帧边界的整数毫秒
  residualMs = durationMs - alignedMs          ← 与整数帧网格的残差(毫秒)

状态完备性 lint:requiredStates 中缺任一即记入 missing。
过渡混合:blendFrames = blendMs * fps // 1000;blendAlignedMs = blendFrames * 1000 // fps。
时间轴对齐:同帧率下两段动画的公共帧 = gcd(framesA, framesB),公共帧即两者都能对齐的
最短时间轴刻度。hitFlashToken 必须在 visual_spec.json 的 tokens 里。

产出 out/sp12_anim.json,供 P 层跨语言对照。全部整数类,逐位一致。
"""

from __future__ import annotations

import json
import math
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SPEC = os.path.join(ROOT, "config/visual_spec.json")
CFG = os.path.join(ROOT, "config/sp12_anim.json")
OUT = os.path.join(ROOT, "out")


def load(p):
    with open(p, encoding="utf-8") as f:
        return json.load(f)


def frames_for(duration_ms: int, fps: int) -> int:
    return (duration_ms * fps) // 1000


def aligned_ms(frames: int, fps: int) -> int:
    return (frames * 1000) // fps


def main() -> int:
    spec, cfg = load(SPEC), load(CFG)
    fps = cfg["designFps"]
    required = cfg["requiredStates"]
    blend_ms = cfg["blendMs"]
    blend_frames = frames_for(blend_ms, fps)

    chars = []
    for ch in cfg["characters"]:
        states = []
        for name in sorted(ch["states"]):
            ms = ch["states"][name]
            fr = frames_for(ms, fps)
            states.append({"state": name, "durationMs": ms, "frames": fr,
                           "alignedMs": aligned_ms(fr, fps),
                           "residualMs": ms - aligned_ms(fr, fps)})
        missing = [s for s in required if s not in ch["states"]]
        chars.append({
            "id": ch["id"], "cn": ch["cn"], "states": states,
            "stateCount": len(states), "missing": missing,
            "totalFrames": sum(s["frames"] for s in states),
            "complete": len(missing) == 0,
        })

    # 时间轴对齐:同帧率下,取两段动画帧数的最大公约数作为公共帧刻度。
    align = []
    for i, a in enumerate(chars):
        for b in chars[i + 1:]:
            fa = next(s["frames"] for s in a["states"] if s["state"] == "walk")
            fb = next(s["frames"] for s in b["states"] if s["state"] == "walk")
            align.append({"pair": f"{a['id']}/{b['id']}", "framesA": fa, "framesB": fb,
                          "commonFrames": math.gcd(fa, fb)})

    flash = cfg["hitFlashToken"]
    summary = {
        "project": spec["project"], "specVersion": spec["specVersion"],
        "designFps": fps, "blendMs": blend_ms, "blendFrames": blend_frames,
        "blendAlignedMs": aligned_ms(blend_frames, fps),
        "requiredStates": required, "characters": chars,
        "incomplete": [c["id"] for c in chars if not c["complete"]],
        "alignment": align,
        "hitFlashToken": flash, "hitFlashTokenExists": flash in spec["tokens"],
    }

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "sp12_anim.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"designFps={fps} blendMs={blend_ms} blendFrames={blend_frames} "
          f"blendAlignedMs={summary['blendAlignedMs']}")
    for c in chars:
        seq = " ".join(f"{s['state']}={s['frames']}f/{s['alignedMs']}ms" for s in c["states"])
        print(f"  {c['id']:<6} states={c['stateCount']} totalFrames={c['totalFrames']:>3} "
              f"missing={c['missing']}")
        print(f"    {seq}")
    for a in align:
        print(f"  align {a['pair']:<12} {a['framesA']}&{a['framesB']} -> common={a['commonFrames']}f")
    print(f"incomplete={summary['incomplete']} hitFlashTokenExists={summary['hitFlashTokenExists']}")
    print("wrote out/sp12_anim.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/sp12_anim.py:

designFps=30 blendMs=120 blendFrames=3 blendAlignedMs=100
  host   states=4 totalFrames=102 missing=[]
    die=30f/1000ms hurt=12f/400ms idle=36f/1200ms walk=24f/800ms
  guest  states=3 totalFrames= 67 missing=['die']
    hurt=10f/333ms idle=30f/1000ms walk=27f/900ms
  pet    states=5 totalFrames= 99 missing=[]
    die=27f/900ms hurt=9f/300ms idle=24f/800ms run=21f/700ms walk=18f/600ms
  align host/guest   24&27 -> common=3f
  align host/pet     24&18 -> common=6f
  align guest/pet    27&18 -> common=9f
incomplete=['guest'] hitFlashTokenExists=True
wrote out/sp12_anim.json

读这张表:blendFrames=3(120ms @30fps 地板除 3.6 → 3),对齐回来是 100ms——美术说「混合 120 毫秒」,引擎实际播 3 帧、约 100ms。guest 的 hurt 写 350ms,换算 10 帧、对齐 333ms、残差 17ms。missing=['die'] 精确抓出 guest 缺死亡动画。hitFlashTokenExists=True 说明受击闪白引用的 danger 在真源里。

12.2.5 P 层:同一套帧账的 TS 内核

下面这段是 P 层 TS 内核 core/src/sp12/anim.ts,与 Python 同一套整数规则(gcd 用整数辗转相除):

// file: core/src/sp12/anim.ts
// tier: P
// verify: node --test 'core/test/sp12_*.test.ts'
// 12.2 动画帧率与状态规范的 TS 侧校验器。与 sim/sp12_anim.py 同一套整数规则:
// frames = durationMs * fps // 1000;alignedMs = frames * 1000 // fps;
// 状态完备性 lint 返回 required 中缺失的状态;gcd 用整数辗转相除。整数类逐位一致。

export function framesFor(durationMs: number, fps: number): number {
  return Math.floor((durationMs * fps) / 1000);
}

export function alignedMs(frames: number, fps: number): number {
  return Math.floor((frames * 1000) / fps);
}

export function lintStates(states: any, required: string[]): string[] {
  return required.filter((s) => !(s in states));
}

export function gcd(a: number, b: number): number {
  while (b !== 0) {
    const t = b;
    b = a % b;
    a = t;
  }
  return a;
}

配套的 P 层测试 core/test/sp12_anim.test.ts 读 Python 落盘的 out/sp12_anim.json 做跨语言对照,并用内存样本构造「缺状态必须被 lint 报出」的负例。实跑 node --test 'core/test/sp12_*.test.ts'(此处只列本节相关的 5 条;三节合计 14 条全绿):

✔ 帧数与对齐毫秒:TS 复算与 Python 产物逐位一致
✔ 时间轴对齐:同帧率下公共帧 = gcd(帧数)
✔ 负例:缺状态必须被 lint 报出
✔ 正例:状态齐全的角色 missing 为空
✔ 受击闪白 token 存在于 visual_spec.json
ℹ tests 14
ℹ pass 14
ℹ fail 0

帧数、对齐毫秒、残差、混合帧数全是整数,TS 与 Python 逐位一致;gcd 也是整数运算,同样逐位一致。

12.2.6 同帧率下的时间轴对齐

统一帧率之后,「对齐」就变成一个可算的整数问题。看三个角色的 walk 帧数:host 24、guest 27、pet 18。

  • 两两公共帧 = gcd:host&guest → gcd(24,27)=3;host&pet → gcd(24,18)=6;guest&pet → gcd(27,18)=9。
  • 公共帧的意思是:在这条统一时间轴上,两段动画都能落在整数帧边界的最短刻度。host 与 pet 每 6 帧对齐一次,host 与 guest 每 3 帧对齐一次。

为什么关心这个:当一个动作要「同步切换」两个角色(比如店主和宠物一起转身),切换点必须落在公共帧上,否则两个角色会在不同的帧切、看起来一先一后。gcd 越大,可用的对齐点越多;gcd 等于 1 意味着两段动画几乎无法优雅同步——这本身就是一条规范信号:同一组要联动的动画,帧数应尽量共享公因数。

12.2.7 边界与取舍

  • 真实骨骼动画的混合效果、插值曲线、根运动需要真实环境(引擎 / DCC),命令行不可复现,标 X;本节只做状态时长与帧数的整数账,不预测真实播放观感。
  • designFps=30 是**《星屑旅店》这一款治愈系休闲游戏的取值**;动作游戏可能用 60,定格风格可能用 12。换项目换 fps,但「统一 + 整数帧」的规则不变。
  • 残差不是错误,是信息:它告诉你「美术写的毫秒」和「引擎播的帧」差多少。规范应把残差控制在半帧(约 17ms @30fps)以内。
  • 全部量为整数类,TS 与 Python 逐位一致;hitFlashToken 存在性是布尔判据,同样逐位一致。

阅读导航:上一节:12.1 特效强度、时长与屏占比预算 · 下一节:12.3 反馈层级的视觉表达 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则