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
4 changes: 2 additions & 2 deletions .claude/skills/jgengine-world/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, number>> — Per-clip duration in seconds, read from the loaded rig.
- `AnimGraphClipInfo` (type): type AnimGraphClipInfo = Readonly<Record<string, number | { duration: number; rootTrack?: { times: Float32Array; values: Float32Array } }>> — 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<Record<string, AnimParamValue>> — 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<string, number> — Static clip weights of a state at `params`, before any crossfade.
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/anim/animGraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] }],
Expand Down
58 changes: 48 additions & 10 deletions packages/core/src/anim/animGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ export type AnimParams = Readonly<Record<string, AnimParamValue>>;

/** 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. */
Expand Down Expand Up @@ -66,7 +67,7 @@ export interface AnimGraph {
}

/** Per-clip duration in seconds, read from the loaded rig. */
export type AnimGraphClipInfo = Readonly<Record<string, number>>;
export type AnimGraphClipInfo = Readonly<Record<string, number | { duration: number; rootTrack?: { times: Float32Array; values: Float32Array } }>>;

interface LayerTransitionState {
to: string;
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string, number>, 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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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 });
}
}

Expand All @@ -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;
},
};
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/meta/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const CHANGELOG: Record<string, ChangelogEntry> = {
],
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.",
Expand Down
45 changes: 39 additions & 6 deletions packages/shell/src/render/useModelAnimation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ function warnMissingClips(
interface GraphPlayback {
runtime: AnimGraphRuntime;
actions: Map<string, THREE.AnimationAction>;
durations: Record<string, number>;
durations: Record<string, { duration: number; rootTrack?: { times: Float32Array; values: Float32Array } }>;
rootBone: THREE.Bone | null;
rootBindPosition: THREE.Vector3 | null;
lastPos: [number, number, number] | null;
smoothedSpeed: number;
}
Expand All @@ -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<string, THREE.AnimationAction>();
const durations: Record<string, number> = {};
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<string>();
for (const state of Object.values(layer.states)) {
Expand Down Expand Up @@ -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,
};
}

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading