Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions src/systems/animationRenderHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@
* 同じ `frameIndex` に対しては何度呼ばれても結果が変わらない (= 冪等) 実装が要る
* - `timeSeconds` は `frameTimeSeconds` と一致しないことがある (= pre-post の side sample では
* `frameTimeSeconds + 0.001`)。時刻の正本は `frameIndex` であり、 `timeSeconds` は参考値として扱う
* - **animation 単位の周期情報を context に載せている** (= `animationLengthSeconds` /
* `renderSampleCount` / `loopMode` / `loopDelayFrames`)。 このうち `renderSampleCount` /
* `loopMode` / `loopDelayFrames` は **datapack meta の `dur` / `lp` / `dly` に対応する**
* (= 同じ animation 設定を指す) 値で、 hook 側が animation の内部構造を推測せずに周期を
* 判断できるようにするために渡している。 `renderSampleCount` と `loopDelayFrames` は `dur` / `dly`
* と **同じ値**だが、 `loopMode` だけは **エンコードが違う** (= context は文字列
* `'once' | 'hold' | 'loop'`、 meta の `lp` は score 用に 0 / 1 / 2 へ畳んだ byte)。
* `renderSampleCount` は render loop が実際に生成する frame 数そのもの (= `animation.length`
* から数え直した値ではない) なので、 `IRenderedAnimation.frames.length` / `duration` と必ず一致する。
* **「表示上の最終 frame がどれか」 の解釈は hook 側の責務**であり、 AJ は生の値を渡すだけ
* - **hook が加える変化は matrix に現れていればよい** (= `pos` / `rot` / `scale` に出る必要はない)。
* `hashAnimations` は node transform の `matrix.elements` 16 要素をそのまま mix するため、
* shear や right rotation だけを動かす変換も reload-skip 判定に反映される。
Expand All @@ -40,6 +50,17 @@ export interface RenderAnimationContext {
excludedNodeUuids: ReadonlySet<string>
/** 指定時刻の keyframe pose を scene へ再評価する。 呼び出し側が閉包として詰める。 */
evaluateBasePose(timeSeconds: number): void
/** `animation.length` (= 秒)。 */
readonly animationLengthSeconds: number
/** render loop が実際に生成する frame の数。 datapack meta の `dur` と一致する。 */
readonly renderSampleCount: number
/**
* `animation.loop` (= 文字列)。 datapack meta の `lp` に対応するが、 `lp` は score 用に
* 0 / 1 / 2 へ畳んだ byte なので**エンコードは違う**。
*/
readonly loopMode: _Animation['loop']
/** `Number(animation.loop_delay) || 0` (= tick)。 datapack meta の `dly` と一致する。 */
readonly loopDelayFrames: number
}

/** frame 単位のコンテキスト (= `RenderAnimationContext` に時刻情報を足したもの)。 */
Expand Down Expand Up @@ -198,9 +219,15 @@ export function areRenderHooksSuppressed() {

// --- 公開 API ---------------------------------------------------------------

/** 外部 plugin 向けの公開 API。 `version` は互換性確認用。 */
/**
* 外部 plugin 向けの公開 API。 `version` は互換性確認用。
*
* - `1` : 初版
* - `2` : `RenderAnimationContext` に周期情報 (= `animationLengthSeconds` / `renderSampleCount` /
* `loopMode` / `loopDelayFrames`) を必須で追加
*/
export const RENDER_HOOKS_API = {
version: 1,
version: 2,
register: registerRenderHooks,
unregister: unregisterRenderHooks,
}
Expand Down
45 changes: 44 additions & 1 deletion src/systems/animationRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,10 +405,44 @@ function renderAnimation(animation: _Animation, rig: IRenderedRig) {

const includedNodes = new Set<string>()

// `+Infinity` は `time <= animation.length` が永久に真になる (= 旧実装がハングしていた) ので、
// 時刻列を作る前に弾く。 `NaN` / `-Infinity` は旧実装でも比較が偽で 0 件だったため、
// **弾かずに 0 件のまま通す** (= 従来の出力を変えない)。
if (animation.length === Infinity) {
throw new Error(
`Animation '${animation.name}' has a non-finite length (${animation.length}). Cannot render.`
)
}

// frame ループが訪れる時刻の列。 **ループ本体もこの配列を回す** (= context の
// `renderSampleCount` と実際の frame 数を同じ配列から取るため)。 `animation.length` から
// 別式で数え直すと `roundToNth` の丸めと食い違って off-by-one が出る。
// **件数の上限は設けない**。 有限長では hook 導入前の for ループと 1 件も違わない。
const sampleTimes: number[] = []
for (let time = 0; time <= animation.length; ) {
sampleTimes.push(time)
const nextTime = roundToNth(time + 0.05, 20)
// double の精度限界 (= `time` が大きすぎて `+0.05` が丸めで消える) に達すると時刻が
// 進まなくなり、 旧実装は同じ frame を延々と積み続けていた。 黙って回り続けるより失敗させる。
if (!(nextTime > time)) {
throw new Error(
`Animation '${animation.name}' stopped advancing at ${time}s (length ${animation.length}). Cannot render.`
)
}
time = nextTime
}

currentRenderContext = {
animation,
rig,
excludedNodeUuids: collectExcludedNodeUuids(animation),
animationLengthSeconds: animation.length,
renderSampleCount: sampleTimes.length,
// loop 情報は `animation` から読み直さず `rendered` を経由する。 `animation.select()` は
// `select_animation` を同期 dispatch するため、 listener が loop 設定を書き換えると
// 「context = select 後の新値 / datapack meta = select 前の旧値」 に割れてしまう
loopMode: rendered.loop_mode,
loopDelayFrames: rendered.loop_delay,
evaluateBasePose(timeSeconds: number) {
const previousTime = Timeline.time
try {
Expand All @@ -432,14 +466,23 @@ function renderAnimation(animation: _Animation, rig: IRenderedRig) {
animationBegun = true

let frameIndex = 0
for (let time = 0; time <= animation.length; time = roundToNth(time + 0.05, 20)) {
for (const time of sampleTimes) {
updatePreview(animation, time, frameIndex)
updatePreview(animation, time, frameIndex) // IK doesn't work unless I call this twice for some reason...
const frame: IRenderedFrame = getFrame(animation, rig.nodes, time, frameIndex)
Object.keys(frame.node_transforms).forEach(n => includedNodes.add(n))
rendered.frames.push(frame)
frameIndex++
}
// dev guard : hook へ渡した `renderSampleCount` と実際の frame 数がずれていたら契約違反。
// 出力自体は壊さないので throw はせず warn だけ出す。
// **現在の制御フローでは発火しない** (= ループは `sampleTimes` を最後まで回し、 各周で
// 必ず 1 frame push する)。 将来ループ本体に `continue` 等が入ったときの保険として置いている。
if (rendered.frames.length !== sampleTimes.length) {
console.warn(
`Render sample count mismatch on animation '${animation.name}': context reported ${sampleTimes.length}, but ${rendered.frames.length} frames were rendered.`
)
}
} catch (error) {
bodyError.failed = true
bodyError.error = error
Expand Down
Loading
Loading