From 64c9211eb8813c66c844ff8ab6a51ead684dbb45 Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:48:59 -0400 Subject: [PATCH] feat-add-animation-root-motion --- .claude/skills/jgengine-world/api.md | 4 +- CHANGELOG.md | 2 + packages/core/src/anim/animGraph.test.ts | 10 ++++ packages/core/src/anim/animGraph.ts | 58 +++++++++++++++---- packages/core/src/meta/changelog.ts | 1 + .../shell/src/render/useModelAnimation.ts | 45 ++++++++++++-- 6 files changed, 102 insertions(+), 18 deletions(-) diff --git a/.claude/skills/jgengine-world/api.md b/.claude/skills/jgengine-world/api.md index 714c00d8..3ba41438 100644 --- a/.claude/skills/jgengine-world/api.md +++ b/.claude/skills/jgengine-world/api.md @@ -200,14 +200,14 @@ - `AnimCondition` (interface): interface AnimCondition — A parameter comparison; every condition on a transition must hold. - `AnimEvent` (interface): interface AnimEvent — A named moment inside a clip (foot plant, hit frame, reload point). - `AnimGraph` (interface): interface AnimGraph — Serializable animation graph. Clip durations come from the rig at runtime (see {@link AnimGraphClipInfo}). -- `AnimGraphClipInfo` (type): type AnimGraphClipInfo = Readonly> — Per-clip duration in seconds, read from the loaded rig. +- `AnimGraphClipInfo` (type): type AnimGraphClipInfo = Readonly> — Per-clip duration in seconds, read from the loaded rig. - `AnimGraphOutput` (interface): interface AnimGraphOutput — What one advance asks the rig to show. - `AnimGraphRuntime` (interface): interface AnimGraphRuntime — The evaluator handle: arm triggers, advance, inspect, snapshot and restore. - `AnimGraphState` (interface): interface AnimGraphState — Serializable evaluator state. - `AnimLayer` (interface): interface AnimLayer — A blend layer with its own state machine. Masked layers apply only to bones whose track names start with a prefix. - `AnimParamValue` (type): type AnimParamValue = number | boolean — Parameter value a graph reads: floats for blends and comparisons, booleans for gates. - `AnimParams` (type): type AnimParams = Readonly> — The parameter set a graph evaluates against each advance. -- `AnimState` (type): type AnimState = | { kind: "clip"; clip: string; speed?: number; loop?: boolean } | { kind: "blend1D"; param: string; points: readonly { at: number; clip: string }[]; speed?: number; loop?: boolean } | { kind: "blend2D"; params: readonly [string, string]; points: readonly { at: readonly [number, num… — A state plays one clip, or blends clips by one or two parameters. +- `AnimState` (type): type AnimState = | { kind: "clip"; clip: string; speed?: number; loop?: boolean; rootMotion?: boolean } | { kind: "blend1D"; param: string; points: readonly { at: number; clip: string }[]; speed?: number; loop?: boolean; rootMotion?: boolean } | { kind: "blend2D"; params: readonly [string, string]; … — A state plays one clip, or blends clips by one or two parameters. - `AnimTransition` (interface): interface AnimTransition — Edge between states. `from: "*"` matches any state except `to`. - `createAnimGraphRuntime` (function): function createAnimGraphRuntime(initial: AnimGraph): AnimGraphRuntime — Headless animation state machine and blend evaluator. It owns every clip's playback time and weight, so the renderer only seeks and weights actions on a mixer, and headless hosts, replays, and tests advance the same graph without three.js. Transitions are data (parameter comparisons and consumed triggers), layers can be masked or additive, and events fire by clip time, including across loop wraps. - `stateClipWeights` (function): function stateClipWeights(state: AnimState, params: AnimParams): Record — Static clip weights of a state at `params`, before any crossfade. diff --git a/CHANGELOG.md b/CHANGELOG.md index df282497..bc4a22e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,8 @@ between (`--json` for structured output). - Rigged `ModelConfig` entries can opt into shell foot IK with named thigh/shin/foot bones; the driver raycasts the shared scene terrain, solves each chain, and fades corrections while airborne. +- Animation graph clips may carry root-bone position tracks; states with `rootMotion: true` return the sampled displacement and the shell applies it to the entity while restoring the root bone's bind translation. + - Added renderer-free two-bone, FABRIK, and look-at inverse-kinematics solvers to `@jgengine/core`. - Shell model animation now routes states and one-shots through the shared animation graph runtime. diff --git a/packages/core/src/anim/animGraph.test.ts b/packages/core/src/anim/animGraph.test.ts index 7a5f669b..24b1ba1e 100644 --- a/packages/core/src/anim/animGraph.test.ts +++ b/packages/core/src/anim/animGraph.test.ts @@ -33,6 +33,16 @@ describe("stateClipWeights", () => { }); describe("createAnimGraphRuntime", () => { + test("returns root motion sampled from a synthetic root track", () => { + const rt = createAnimGraphRuntime({ + layers: [{ id: "base", entry: "walk", states: { walk: { kind: "clip", clip: "walk", rootMotion: true } }, transitions: [] }], + }); + const out = rt.advance(0.5, {}, { + walk: { duration: 1, rootTrack: { times: new Float32Array([0, 1]), values: new Float32Array([0, 0, 0, 2, 0, 0]) } }, + }); + expect(out.rootDelta).toEqual([1, 0, 0]); + }); + test("a clip state advances time, loops, and fires events across the wrap", () => { const graph: AnimGraph = { layers: [{ id: "base", entry: "idle", states: { idle: { kind: "clip", clip: "walk" } }, transitions: [] }], diff --git a/packages/core/src/anim/animGraph.ts b/packages/core/src/anim/animGraph.ts index 101473c9..e6cb47e8 100644 --- a/packages/core/src/anim/animGraph.ts +++ b/packages/core/src/anim/animGraph.ts @@ -5,14 +5,15 @@ export type AnimParams = Readonly>; /** A state plays one clip, or blends clips by one or two parameters. */ export type AnimState = - | { kind: "clip"; clip: string; speed?: number; loop?: boolean } - | { kind: "blend1D"; param: string; points: readonly { at: number; clip: string }[]; speed?: number; loop?: boolean } + | { kind: "clip"; clip: string; speed?: number; loop?: boolean; rootMotion?: boolean } + | { kind: "blend1D"; param: string; points: readonly { at: number; clip: string }[]; speed?: number; loop?: boolean; rootMotion?: boolean } | { kind: "blend2D"; params: readonly [string, string]; points: readonly { at: readonly [number, number]; clip: string }[]; speed?: number; loop?: boolean; + rootMotion?: boolean; }; /** Comparison operators an {@link AnimCondition} supports. */ @@ -66,7 +67,7 @@ export interface AnimGraph { } /** Per-clip duration in seconds, read from the loaded rig. */ -export type AnimGraphClipInfo = Readonly>; +export type AnimGraphClipInfo = Readonly>; interface LayerTransitionState { to: string; @@ -100,6 +101,7 @@ export interface AnimClipOutput { export interface AnimGraphOutput { clips: AnimClipOutput[]; events: { name: string; clip: string }[]; + rootDelta?: [number, number, number]; } /** The evaluator handle: arm triggers, advance, inspect, snapshot and restore. */ @@ -203,10 +205,36 @@ function stateLoops(state: AnimState): boolean { /** Longest clip in a state at these weights; the state's timeline length for exit times and events. */ function stateDuration(weights: Record, clips: AnimGraphClipInfo): number { let duration = 0; - for (const clip of Object.keys(weights)) duration = Math.max(duration, clips[clip] ?? 0); + for (const clip of Object.keys(weights)) duration = Math.max(duration, clipDuration(clips[clip])); return duration; } +function clipDuration(info: AnimGraphClipInfo[string]): number { + return typeof info === "number" ? info : info?.duration ?? 0; +} + +function rootPosition(track: { times: Float32Array; values: Float32Array }, time: number, duration: number, loop: boolean): [number, number, number] { + const count = Math.min(track.times.length, Math.floor(track.values.length / 3)); + if (count === 0) return [0, 0, 0]; + const first = track.times[0] ?? 0; + const last = track.times[count - 1] ?? first; + const cycle = loop && duration > 0 ? Math.floor(time / duration) : 0; + const local = loop && duration > 0 ? ((time % duration) + duration) % duration : Math.min(Math.max(time, first), last); + let i = 0; + while (i + 1 < count && (track.times[i + 1] ?? 0) <= local) i += 1; + const next = Math.min(i + 1, count - 1); + const span = (track.times[next] ?? 0) - (track.times[i] ?? 0); + const t = next === i || span <= 0 ? 0 : (local - (track.times[i] ?? 0)) / span; + const out: [number, number, number] = [0, 0, 0]; + for (let axis = 0; axis < 3; axis += 1) { + const a = track.values[i * 3 + axis] ?? 0; + const b = track.values[next * 3 + axis] ?? a; + const cycleOffset = loop && duration > 0 ? ((track.values[(count - 1) * 3 + axis] ?? 0) - (track.values[axis] ?? 0)) * cycle : 0; + out[axis] = a + (b - a) * t + cycleOffset; + } + return out; +} + function clipTimeFor(time: number, duration: number, loop: boolean): number { if (!(duration > 0)) return 0; if (loop) return time % duration; @@ -290,6 +318,7 @@ export function createAnimGraphRuntime(initial: AnimGraph): AnimGraphRuntime { stateOf: (layerId) => layers[layerId]?.current ?? null, advance(dt, params, clips) { const output: AnimGraphOutput = { clips: [], events: [] }; + const rootDelta: [number, number, number] = [0, 0, 0]; for (const layer of graph.layers) { const state = layers[layer.id]; if (state === undefined) continue; @@ -305,15 +334,23 @@ export function createAnimGraphRuntime(initial: AnimGraph): AnimGraphRuntime { const normalized = duration > 0 ? (loop ? (state.time % duration) / duration : Math.min(1, state.time / duration)) : 1; for (const clip of Object.keys(weights)) { - const clipDuration = clips[clip] ?? 0; + const duration = clipDuration(clips[clip]); collectEvents( output.events, clip, - clipTimeFor(before, clipDuration, loop), - clipTimeFor(state.time, clipDuration, loop), - clipDuration, + clipTimeFor(before, duration, loop), + clipTimeFor(state.time, duration, loop), + duration, loop, ); + if (def.rootMotion) { + const info = clips[clip]; + if (typeof info !== "number" && info?.rootTrack !== undefined) { + const from = rootPosition(info.rootTrack, before, duration, loop); + const to = rootPosition(info.rootTrack, state.time, duration, loop); + for (let axis = 0; axis < 3; axis += 1) rootDelta[axis] += (to[axis] - from[axis]) * weights[clip]! * layerWeight; + } + } } const transition = state.transition; @@ -328,7 +365,7 @@ export function createAnimGraphRuntime(initial: AnimGraph): AnimGraphRuntime { } for (const [clip, w] of Object.entries(weights)) { merged[clip] = (merged[clip] ?? 0) + w * t; - times[clip] = clipTimeFor(state.time, clips[clip] ?? 0, loop); + times[clip] = clipTimeFor(state.time, clipDuration(clips[clip]), loop); } for (const [clip, w] of Object.entries(merged)) { if (w <= 0) continue; @@ -338,7 +375,7 @@ export function createAnimGraphRuntime(initial: AnimGraph): AnimGraphRuntime { } else { for (const [clip, w] of Object.entries(weights)) { if (w <= 0) continue; - output.clips.push({ clip, weight: w * layerWeight, time: clipTimeFor(state.time, clips[clip] ?? 0, loop), layer: layer.id }); + output.clips.push({ clip, weight: w * layerWeight, time: clipTimeFor(state.time, clipDuration(clips[clip]), loop), layer: layer.id }); } } @@ -365,6 +402,7 @@ export function createAnimGraphRuntime(initial: AnimGraph): AnimGraphRuntime { } } triggers.clear(); + if (rootDelta[0] !== 0 || rootDelta[1] !== 0 || rootDelta[2] !== 0) output.rootDelta = rootDelta; return output; }, }; diff --git a/packages/core/src/meta/changelog.ts b/packages/core/src/meta/changelog.ts index 57ef4116..a90e4a7e 100644 --- a/packages/core/src/meta/changelog.ts +++ b/packages/core/src/meta/changelog.ts @@ -27,6 +27,7 @@ export const CHANGELOG: Record = { ], changed: [ "Rigged `ModelConfig` entries can opt into shell foot IK with named thigh/shin/foot bones; the driver raycasts the shared scene terrain, solves each chain, and fades corrections while airborne.", + "Animation graph clips may carry root-bone position tracks; states with `rootMotion: true` return the sampled displacement and the shell applies it to the entity while restoring the root bone's bind translation.", "`presence.sync` accepts a call with no `pose` at all, which holds the stored position and moves only the liveness stamp. `createConvexPresenceSync` sends one itself when the pose gate has suppressed writes for 10s, so a standing player keeps their row alive without a pose write — and without the reaper collecting a live session.", "`PresencePoseRow` carries optional `sessionId` / `kind` / `label`, so a nameplate reads off the presence row instead of joining against users per frame.", "`runCommand` no longer reads the whole world on every command. `LoadSnapshotScope` landed in 0.17 but only `helpers.loadSnapshot` accepted one, so the supported way to run a command — the registered `runCommand` mutation, a one-line delegation to `helpers.runCommand` — hydrated every member profile and every chunk however narrow the command was, and a game could only get bounded hydration by reimplementing the mutation. `runCommand`, `joinServer`, and `leaveServer` now hydrate a scope; a command whose `apply` dirties a player or chunk its scope never named is refused rather than persisted, since writing an unhydrated chunk overwrites the stored one with a blank. `flushSave` / `flushServerIfDue` / `tickActiveServers` / `flushDirtyServers` still hydrate everything, which is correct for what they write.", diff --git a/packages/shell/src/render/useModelAnimation.ts b/packages/shell/src/render/useModelAnimation.ts index c1e118bd..5ee9ee6c 100644 --- a/packages/shell/src/render/useModelAnimation.ts +++ b/packages/shell/src/render/useModelAnimation.ts @@ -71,7 +71,9 @@ function warnMissingClips( interface GraphPlayback { runtime: AnimGraphRuntime; actions: Map; - durations: Record; + durations: Record; + rootBone: THREE.Bone | null; + rootBindPosition: THREE.Vector3 | null; lastPos: [number, number, number] | null; smoothedSpeed: number; } @@ -81,10 +83,21 @@ function actionKey(layer: string, clip: string): string { return `${layer}:${clip}`; } -function buildGraphPlayback(mixer: THREE.AnimationMixer, graph: AnimGraph, clips: THREE.AnimationClip[]): GraphPlayback { +function buildGraphPlayback(scene: THREE.Object3D, mixer: THREE.AnimationMixer, graph: AnimGraph, clips: THREE.AnimationClip[]): GraphPlayback { const actions = new Map(); - const durations: Record = {}; - for (const clip of clips) durations[clip.name] = clip.duration; + const durations: GraphPlayback["durations"] = {}; + let resolvedRootBone: THREE.Bone | null = null; + scene.traverse((object: THREE.Object3D) => { + if (resolvedRootBone === null && object instanceof THREE.Bone) resolvedRootBone = object; + }); + const rootBone = resolvedRootBone as THREE.Bone | null; + for (const clip of clips) { + const track = rootBone === null ? undefined : clip.tracks.find((candidate) => candidate.name === `${rootBone.name}.position`); + durations[clip.name] = { + duration: clip.duration, + ...(track === undefined ? {} : { rootTrack: { times: new Float32Array(track.times), values: new Float32Array(track.values) } }), + }; + } for (const layer of graph.layers) { const names = new Set(); for (const state of Object.values(layer.states)) { @@ -117,7 +130,15 @@ function buildGraphPlayback(mixer: THREE.AnimationMixer, graph: AnimGraph, clips actions.set(actionKey(layer.id, name), action); } } - return { runtime: createAnimGraphRuntime(graph), actions, durations, lastPos: null, smoothedSpeed: 0 }; + return { + runtime: createAnimGraphRuntime(graph), + actions, + durations, + rootBone, + rootBindPosition: rootBone?.position.clone() ?? null, + lastPos: null, + smoothedSpeed: 0, + }; } /** @@ -184,7 +205,7 @@ export function useModelAnimation( warnMissingClips(animation, clips); const mixer = new THREE.AnimationMixer(scene); if (graph !== undefined) { - graphRef.current = buildGraphPlayback(mixer, graph, clips); + graphRef.current = buildGraphPlayback(scene, mixer, graph, clips); mixer.update(0); mixerRef.current = mixer; animationPausedRef.current = false; @@ -274,7 +295,19 @@ export function useModelAnimation( action.time = entry.time; } mixerRef.current.update(0); + const currentPlayback = graphRef.current; + if (currentPlayback !== null && currentPlayback.rootBone !== null && currentPlayback.rootBindPosition !== null) { + currentPlayback.rootBone.position.copy(currentPlayback.rootBindPosition); + } if (ctx !== null && instanceId !== undefined) { + const rootDelta = out.rootDelta; + const entity = rootDelta === undefined ? null : ctx.scene.entity.get(instanceId); + if (entity !== null && rootDelta !== undefined) { + ctx.scene.entity.setPose(instanceId, { + position: [entity.position[0] + rootDelta[0], entity.position[1] + rootDelta[1], entity.position[2] + rootDelta[2]], + dt: delta, + }); + } for (const event of out.events) ctx.game.events.emit("animation.event", { instanceId, name: event.name, clip: event.clip }); } return;