12.1 管的是「一次播放有多长、占屏多少」,本节管的是「一整套状态有多齐、每段多长、怎么接」。动画规范最常犯的错不是画得不好,而是说不清:idle 到底 1.0 秒还是 1.2 秒、受伤到死亡之间混合多久、换了个角色为什么少一个死亡动画——这些全是可以用整数说死的事。分工照旧:骨骼蒙皮的数学(骨骼矩阵、调色板、蒙皮公式)在 骨骼动画原理 ,本节不重推数学,只做美术侧的状态清单与帧账。
12.2.1 帧率不是越高越好
先说结论:角色动画用统一帧率,比用高帧率更重要。
- 如果 host 的 idle 是 24fps、pet 的 walk 是 30fps,两段动画拼在同一条时间轴上就会错拍:同一时刻一个在帧 8、一个在帧 10,混合时视觉会抖。
- 统一到 30fps 之后,所有角色的帧边界落在同一张网格上,混合、切换、事件回调都能对齐到整数帧。
所以规范的第一条是:designFps 全项目唯一(本套取 30)。第二条是:每段动画的时长必须是整数毫秒,并且换算出的帧数必须是整数——换算不整除的部分要么吸收进时长,要么记进残差,不能糊过去。
12.2.2 四个整数口径
| 口径 | 公式 | 说明 |
|---|---|---|
frames | durationMs × fps // 1000 | 该状态的整数帧数(地板除) |
alignedMs | frames × 1000 // fps | 帧数折算回毫秒(对齐到帧边界) |
residualMs | durationMs − alignedMs | 与整数帧网格的残差,正负都可能 |
blendFrames | blendMs × 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 反馈层级的视觉表达 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。