Browse Source
Ports an Unreal Engine avatar project's Macro/Micro/Nano additive gaze design to ARKit blend shapes: pure, seedable state machine in src/lib/humanoid/eye/, a client wrapper owning the frame loop in src/lib/client/humanoid/eye/, and a Three.js demo/debug playground at /demo/humanoid (split into AvatarStateDemo + EyeSystemDemo so future animation libraries get their own sibling demo). Updates HUMANOID.md and ARCHITECTURE.md to describe animation libraries as sub-packages nested under humanoid/<name> rather than separate top-level packages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>main
24 changed files with 2594 additions and 106 deletions
@ -0,0 +1,223 @@ |
|||||
|
import { describe, expect, it } from 'vitest'; |
||||
|
import type { EyeGazeState } from '../../../humanoid/eye/gaze.js'; |
||||
|
import { createEyeGazeController } from './gaze-controller.js'; |
||||
|
|
||||
|
/** A `scheduleFrame` stand-in the test drives manually, with a fully controlled `nowMs`. */ |
||||
|
function createFakeScheduler() { |
||||
|
let pending: ((nowMs: number) => void) | null = null; |
||||
|
return { |
||||
|
scheduleFrame(callback: (nowMs: number) => void): () => void { |
||||
|
pending = callback; |
||||
|
return () => { |
||||
|
if (pending === callback) pending = null; |
||||
|
}; |
||||
|
}, |
||||
|
fire(nowMs: number) { |
||||
|
const callback = pending; |
||||
|
callback?.(nowMs); |
||||
|
}, |
||||
|
hasPending() { |
||||
|
return pending !== null; |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const FIXED_RNG = () => 0.4; |
||||
|
|
||||
|
describe('createEyeGazeController', () => { |
||||
|
it('subscribe() calls run immediately with the current state', () => { |
||||
|
const scheduler = createFakeScheduler(); |
||||
|
const controller = createEyeGazeController( |
||||
|
{}, |
||||
|
{ rng: FIXED_RNG, scheduleFrame: scheduler.scheduleFrame } |
||||
|
); |
||||
|
const calls: EyeGazeState[] = []; |
||||
|
controller.subscribe((s) => calls.push(s)); |
||||
|
expect(calls).toHaveLength(1); |
||||
|
expect(calls[0].macro.phase).toBe('atLookAt'); |
||||
|
controller.destroy(); |
||||
|
}); |
||||
|
|
||||
|
it('advances gaze state on a driven frame', () => { |
||||
|
const scheduler = createFakeScheduler(); |
||||
|
const controller = createEyeGazeController( |
||||
|
{}, |
||||
|
{ rng: FIXED_RNG, scheduleFrame: scheduler.scheduleFrame } |
||||
|
); |
||||
|
let latest: EyeGazeState | undefined; |
||||
|
controller.subscribe((s) => (latest = s)); |
||||
|
const initial = latest; |
||||
|
|
||||
|
scheduler.fire(0); // establishes lastFrameMs — no elapsed time yet
|
||||
|
scheduler.fire(10_000); // 10 real seconds later — long enough to guarantee some layer redraws
|
||||
|
|
||||
|
expect(latest).not.toEqual(initial); |
||||
|
controller.destroy(); |
||||
|
}); |
||||
|
|
||||
|
it("setLookAtDistanceCm affects subsequent frames' macro offset scaling", () => { |
||||
|
const makeController = (lookAtDistanceCm: number) => { |
||||
|
const scheduler = createFakeScheduler(); |
||||
|
const controller = createEyeGazeController( |
||||
|
{}, |
||||
|
{ rng: FIXED_RNG, scheduleFrame: scheduler.scheduleFrame } |
||||
|
); |
||||
|
controller.setLookAtDistanceCm(lookAtDistanceCm); |
||||
|
return { controller, scheduler }; |
||||
|
}; |
||||
|
|
||||
|
const near = makeController(10); |
||||
|
const far = makeController(610); |
||||
|
let nearState: EyeGazeState | undefined; |
||||
|
let farState: EyeGazeState | undefined; |
||||
|
near.controller.subscribe((s) => (nearState = s)); |
||||
|
far.controller.subscribe((s) => (farState = s)); |
||||
|
|
||||
|
near.controller.forceLookAway(); |
||||
|
far.controller.forceLookAway(); |
||||
|
near.scheduler.fire(0); |
||||
|
far.scheduler.fire(0); |
||||
|
// Short enough that the forced look-away's own timer (min 0.3s) can't
|
||||
|
// have expired again — `goal` stays exactly the forced value on both,
|
||||
|
// only `current` (and therefore the offset) differs by distance scale.
|
||||
|
near.scheduler.fire(100); |
||||
|
far.scheduler.fire(100); |
||||
|
|
||||
|
expect(nearState!.macro.goal).toEqual(farState!.macro.goal); |
||||
|
expect(Math.abs(farState!.macroOffsetCm.x)).toBeGreaterThan( |
||||
|
Math.abs(nearState!.macroOffsetCm.x) |
||||
|
); |
||||
|
|
||||
|
near.controller.destroy(); |
||||
|
far.controller.destroy(); |
||||
|
}); |
||||
|
|
||||
|
it('setAvatarState changes which look-at timer range a subsequent return-to-center draws from', () => { |
||||
|
// A fixed rng > secondaryProbability(0.5) always fails the secondary
|
||||
|
// roll, so a forced look-away always returns straight to center on its
|
||||
|
// very next expiry — deterministic and avoids depending on how many
|
||||
|
// secondaries a lower rng value would chain first.
|
||||
|
const rng = () => 0.9; |
||||
|
const makeController = () => { |
||||
|
const scheduler = createFakeScheduler(); |
||||
|
const controller = createEyeGazeController( |
||||
|
{}, |
||||
|
{ rng, scheduleFrame: scheduler.scheduleFrame } |
||||
|
); |
||||
|
return { controller, scheduler }; |
||||
|
}; |
||||
|
|
||||
|
const listening = makeController(); |
||||
|
const thinking = makeController(); |
||||
|
let listeningState: EyeGazeState | undefined; |
||||
|
let thinkingState: EyeGazeState | undefined; |
||||
|
listening.controller.subscribe((s) => (listeningState = s)); |
||||
|
thinking.controller.subscribe((s) => (thinkingState = s)); |
||||
|
|
||||
|
listening.controller.setAvatarState('listening'); |
||||
|
thinking.controller.setAvatarState('thinking'); |
||||
|
listening.controller.forceLookAway(); |
||||
|
thinking.controller.forceLookAway(); |
||||
|
listening.scheduler.fire(0); |
||||
|
thinking.scheduler.fire(0); |
||||
|
// The forced look-away timer draws to 1.2s with rng=0.9 — 2s is
|
||||
|
// enough for exactly one return-to-center transition, not two.
|
||||
|
listening.scheduler.fire(2000); |
||||
|
thinking.scheduler.fire(2000); |
||||
|
|
||||
|
expect(listeningState!.macro.phase).toBe('atLookAt'); |
||||
|
expect(thinkingState!.macro.phase).toBe('atLookAt'); |
||||
|
expect(listeningState!.macro.timer).not.toBeCloseTo(thinkingState!.macro.timer, 3); |
||||
|
|
||||
|
listening.controller.destroy(); |
||||
|
thinking.controller.destroy(); |
||||
|
}); |
||||
|
|
||||
|
it('setTimeScale scales the effective per-frame deltaSeconds', () => { |
||||
|
const makeController = (scale: number) => { |
||||
|
const scheduler = createFakeScheduler(); |
||||
|
const controller = createEyeGazeController( |
||||
|
{}, |
||||
|
{ rng: FIXED_RNG, scheduleFrame: scheduler.scheduleFrame } |
||||
|
); |
||||
|
controller.setTimeScale(scale); |
||||
|
return { controller, scheduler }; |
||||
|
}; |
||||
|
|
||||
|
const slow = makeController(1); |
||||
|
const fast = makeController(2); |
||||
|
let slowState: EyeGazeState | undefined; |
||||
|
let fastState: EyeGazeState | undefined; |
||||
|
slow.controller.subscribe((s) => (slowState = s)); |
||||
|
fast.controller.subscribe((s) => (fastState = s)); |
||||
|
|
||||
|
slow.controller.forceLookAway(); |
||||
|
fast.controller.forceLookAway(); |
||||
|
slow.scheduler.fire(0); |
||||
|
fast.scheduler.fire(0); |
||||
|
slow.scheduler.fire(100); |
||||
|
fast.scheduler.fire(100); |
||||
|
|
||||
|
expect(slowState!.macro.goal).toEqual(fastState!.macro.goal); |
||||
|
expect(Math.abs(fastState!.macro.current.x)).toBeGreaterThan( |
||||
|
Math.abs(slowState!.macro.current.x) |
||||
|
); |
||||
|
|
||||
|
slow.controller.destroy(); |
||||
|
fast.controller.destroy(); |
||||
|
}); |
||||
|
|
||||
|
it('setLayerEnabled(false) freezes that layer across subsequent ticks', () => { |
||||
|
const scheduler = createFakeScheduler(); |
||||
|
const controller = createEyeGazeController( |
||||
|
{}, |
||||
|
{ rng: FIXED_RNG, scheduleFrame: scheduler.scheduleFrame } |
||||
|
); |
||||
|
let latest: EyeGazeState | undefined; |
||||
|
controller.subscribe((s) => (latest = s)); |
||||
|
|
||||
|
controller.setLayerEnabled('nano', false); |
||||
|
scheduler.fire(0); |
||||
|
const frozenNano = latest!.nano; |
||||
|
scheduler.fire(5_000); |
||||
|
|
||||
|
expect(latest!.nano).toEqual(frozenNano); |
||||
|
controller.destroy(); |
||||
|
}); |
||||
|
|
||||
|
it('forceLookAway is reflected immediately, without waiting for a tick', () => { |
||||
|
const scheduler = createFakeScheduler(); |
||||
|
const controller = createEyeGazeController( |
||||
|
{}, |
||||
|
{ rng: FIXED_RNG, scheduleFrame: scheduler.scheduleFrame } |
||||
|
); |
||||
|
let latest: EyeGazeState | undefined; |
||||
|
controller.subscribe((s) => (latest = s)); |
||||
|
|
||||
|
expect(latest!.macro.phase).toBe('atLookAt'); |
||||
|
controller.forceLookAway(); |
||||
|
expect(latest!.macro.phase).toBe('lookingAway'); |
||||
|
|
||||
|
controller.destroy(); |
||||
|
}); |
||||
|
|
||||
|
it('destroy() cancels the pending frame and silences further notifications', () => { |
||||
|
const scheduler = createFakeScheduler(); |
||||
|
const controller = createEyeGazeController( |
||||
|
{}, |
||||
|
{ rng: FIXED_RNG, scheduleFrame: scheduler.scheduleFrame } |
||||
|
); |
||||
|
let callCount = 0; |
||||
|
controller.subscribe(() => { |
||||
|
callCount++; |
||||
|
}); |
||||
|
scheduler.fire(0); |
||||
|
const afterFirstFrame = callCount; |
||||
|
|
||||
|
controller.destroy(); |
||||
|
expect(scheduler.hasPending()).toBe(false); |
||||
|
|
||||
|
controller.forceLookAway(); |
||||
|
expect(callCount).toBe(afterFirstFrame); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,130 @@ |
|||||
|
import { |
||||
|
resolveEyeConfig, |
||||
|
type DeepPartial, |
||||
|
type EyeConfig |
||||
|
} from '../../../humanoid/eye/config.js'; |
||||
|
import { |
||||
|
createInitialEyeGazeState, |
||||
|
forceMacroLookAway, |
||||
|
stepEyeGaze, |
||||
|
type EyeGazeState, |
||||
|
type LayerToggles |
||||
|
} from '../../../humanoid/eye/gaze.js'; |
||||
|
import type { AvatarState } from '../../../humanoid/state.js'; |
||||
|
|
||||
|
export interface EyeGazeStore { |
||||
|
/** Svelte-store-compatible: calls `run` immediately with the current value, then on every change. */ |
||||
|
subscribe(run: (state: EyeGazeState) => void): () => void; |
||||
|
/** Feeds Macro's per-state look-at timer range (see `$lib/humanoid/eye/config.ts`). */ |
||||
|
setAvatarState(state: AvatarState): void; |
||||
|
/** Feeds Macro's distance-scale correction and the cm-offset -> gaze-angle conversion. */ |
||||
|
setLookAtDistanceCm(cm: number): void; |
||||
|
setLayerEnabled(layer: 'macro' | 'micro' | 'nano', enabled: boolean): void; |
||||
|
/** Multiplies the per-frame `deltaSeconds` fed into `stepEyeGaze` — 1 is real time. */ |
||||
|
setTimeScale(scale: number): void; |
||||
|
/** Forces an immediate macro look-away — see `forceMacroLookAway` in `$lib/humanoid/eye/gaze.ts`. */ |
||||
|
forceLookAway(): void; |
||||
|
/** Stops the frame loop and releases all subscribers. */ |
||||
|
destroy(): void; |
||||
|
} |
||||
|
|
||||
|
export interface EyeGazeControllerOptions { |
||||
|
/** Defaults to `Math.random` — the only place this whole package calls it directly. */ |
||||
|
rng?: () => number; |
||||
|
/** |
||||
|
* Defaults to a real `requestAnimationFrame` loop. Injectable so tests can |
||||
|
* drive frames manually (with a fully controlled `nowMs`) without a real |
||||
|
* browser or DOM — see `gaze-controller.spec.ts`. |
||||
|
*/ |
||||
|
scheduleFrame?: (callback: (nowMs: number) => void) => () => void; |
||||
|
} |
||||
|
|
||||
|
const DEFAULT_LOOK_AT_DISTANCE_CM = 80; |
||||
|
|
||||
|
function defaultScheduleFrame(callback: (nowMs: number) => void): () => void { |
||||
|
const id = requestAnimationFrame(callback); |
||||
|
return () => cancelAnimationFrame(id); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Owns the eye gaze system's per-frame clock and `Math.random` — the |
||||
|
* `client/humanoid/eye` counterpart to `$lib/humanoid/eye`'s pure |
||||
|
* `stepEyeGaze`, mirroring how `client/humanoid/avatar-state.ts` wraps |
||||
|
* `$lib/humanoid/state.ts`'s pure `nextAvatarState`. `overrides` resolves |
||||
|
* through `resolveEyeConfig` (a project only names the knobs it wants to |
||||
|
* change); `options` only exists for testability (`rng`/`scheduleFrame`) |
||||
|
* and isn't meant to be set by real callers. |
||||
|
*/ |
||||
|
export function createEyeGazeController( |
||||
|
overrides: DeepPartial<EyeConfig> = {}, |
||||
|
options: EyeGazeControllerOptions = {} |
||||
|
): EyeGazeStore { |
||||
|
const config = resolveEyeConfig(overrides); |
||||
|
const rng = options.rng ?? Math.random; |
||||
|
const scheduleFrame = options.scheduleFrame ?? defaultScheduleFrame; |
||||
|
|
||||
|
let avatarState: AvatarState = 'idleActive'; |
||||
|
let lookAtDistanceCm = DEFAULT_LOOK_AT_DISTANCE_CM; |
||||
|
let timeScale = 1; |
||||
|
let toggles: LayerToggles = { macro: true, micro: true, nano: true }; |
||||
|
let state: EyeGazeState = createInitialEyeGazeState(config, rng); |
||||
|
|
||||
|
const subscribers = new Set<(state: EyeGazeState) => void>(); |
||||
|
let lastFrameMs: number | null = null; |
||||
|
let destroyed = false; |
||||
|
let cancelScheduled: (() => void) | null = null; |
||||
|
|
||||
|
function notify(): void { |
||||
|
for (const run of subscribers) run(state); |
||||
|
} |
||||
|
|
||||
|
function tick(nowMs: number): void { |
||||
|
if (destroyed) return; |
||||
|
// The very first scheduled frame has no prior timestamp to diff against —
|
||||
|
// treat it as a zero-length tick rather than advancing by `nowMs` itself.
|
||||
|
const deltaSeconds = lastFrameMs === null ? 0 : Math.max(0, (nowMs - lastFrameMs) / 1000); |
||||
|
lastFrameMs = nowMs; |
||||
|
|
||||
|
state = stepEyeGaze( |
||||
|
state, |
||||
|
{ deltaSeconds: deltaSeconds * timeScale, avatarState, lookAtDistanceCm }, |
||||
|
config, |
||||
|
rng, |
||||
|
toggles |
||||
|
); |
||||
|
notify(); |
||||
|
|
||||
|
cancelScheduled = scheduleFrame(tick); |
||||
|
} |
||||
|
|
||||
|
cancelScheduled = scheduleFrame(tick); |
||||
|
|
||||
|
return { |
||||
|
subscribe(run) { |
||||
|
subscribers.add(run); |
||||
|
run(state); |
||||
|
return () => subscribers.delete(run); |
||||
|
}, |
||||
|
setAvatarState(next) { |
||||
|
avatarState = next; |
||||
|
}, |
||||
|
setLookAtDistanceCm(cm) { |
||||
|
lookAtDistanceCm = cm; |
||||
|
}, |
||||
|
setLayerEnabled(layer, enabled) { |
||||
|
toggles = { ...toggles, [layer]: enabled }; |
||||
|
}, |
||||
|
setTimeScale(scale) { |
||||
|
timeScale = scale; |
||||
|
}, |
||||
|
forceLookAway() { |
||||
|
state = forceMacroLookAway(state, config, rng); |
||||
|
notify(); |
||||
|
}, |
||||
|
destroy() { |
||||
|
destroyed = true; |
||||
|
cancelScheduled?.(); |
||||
|
subscribers.clear(); |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
# Eye gaze system — design notes |
||||
|
|
||||
|
Rationale that doesn't belong in code comments spread across four files, kept in one place for whoever next has to retune or re-derive this. See `docs/HUMANOID.md`'s "`$lib/humanoid/eye`" section for the user-facing summary this backs up. |
||||
|
|
||||
|
## Source material and its limits |
||||
|
|
||||
|
The port comes from an Unreal Engine avatar project's control-rig design doc: a "Eye-System Variablen & Logik" section giving exact variable names, ranges, defaults, and branching logic for three additive gaze layers (Macro/Micro/Nano), plus three screenshots of hand-authored curve-editor splines. The variable/logic side is complete and ported faithfully (see `gaze.ts`'s docstrings, which cite the doc's own state-machine description directly). The curves are not: a screenshot shows a spline's shape, not its keyframe positions or tangent handles, so `curves.ts`'s `smoothstepSigned`/`biasedSmoothstepSigned`/`easeOutCubic` are documented best-effort approximations chosen to match the _described and visible_ behavior, not pixel-exact reconstructions. If exact curve data ever surfaces (an exported `.curve` asset, or numeric keyframe values), these three functions are the only place that needs revisiting — everything else in `gaze.ts` is agnostic to their exact shape. |
||||
|
|
||||
|
## The spring/"SpringInterpolate" formula |
||||
|
|
||||
|
The doc names a `SpringInterpolate` operation for every layer's `current -> goal` smoothing, described only qualitatively ("initially snappy, then increasingly smooth as it nears the goal") plus a ballpark duration ("should take about 0.125 seconds"), no formula and no mention of overshoot or oscillation. That description is exactly frame-rate-independent exponential decay, not a literal damped spring (which can overshoot) — implemented in `curves.ts` as `current += (goal - current) * (1 - exp(-dt / tau))` with `tau = 0.125` (the doc's ballpark duration, reinterpreted as a decay time constant rather than a fixed completion time, since exponential decay has no finite completion time). |
||||
|
|
||||
|
## cm-offset -> ARKit blend shapes |
||||
|
|
||||
|
This entire conversion (`blend-shapes.ts`) has no source-doc precedent. The original system positions a 2D offset on a plane in front of the avatar's face and (per the doc's `ControlRig` hierarchy — `LookAt_ctrl > LookAtMacroScale_ctrl > LookAtMacro_ctrl > ...`) drives real eye bones from it. This project's avatars are ARKit-blend-shape-only, so the port instead treats the plane offset as subtending a gaze angle (`Math.atan2(offsetCm, lookAtDistanceCm)`) and normalizes that angle by a new `maxGazeAngleRad` config knob (no equivalent in the source doc) to get a `[-1, 1]` blend-weight input. |
||||
|
|
||||
|
Two simplifications worth knowing about: |
||||
|
|
||||
|
- **No per-eye vergence.** A real "aim each eye bone at the same 3D point" rig naturally gives each eye a slightly different angle for a near target (interpupillary distance). This port computes one shared angle and mirrors it onto both eyes' In/Out channels — consistent with the doc's own single-shared-2D-offset framing, but not physically exact for very close look-at targets. |
||||
|
- **`lookAtDistanceCm` is one value, reused twice.** It's the same physical distance the doc uses for Macro's `distance/300 + 0.66` scale correction _and_ the distance this port uses for the angle conversion — deliberately, since both describe "how far away is whatever the avatar is looking at," not two separate concepts. It's always a live runtime value (`EyeGazeInput.lookAtDistanceCm`), never a config default, because the (out-of-scope, not-yet-built) LookAt/body-aim system is what should ultimately own it. |
||||
|
|
||||
|
## Sign convention |
||||
|
|
||||
|
Positive `x` = the avatar's own right; positive `y` = up. See the docstring on `gazeOffsetToBlendShapes` in `blend-shapes.ts` for the left/right eye In/Out derivation — flagged there and here because it's the one place in this whole port that's easy to get backwards without a live model to check against. |
||||
@ -0,0 +1,67 @@ |
|||||
|
import { describe, expect, it } from 'vitest'; |
||||
|
import { gazeOffsetToAngleRad, gazeOffsetToBlendShapes } from './blend-shapes.js'; |
||||
|
|
||||
|
const MAX_ANGLE = (30 * Math.PI) / 180; |
||||
|
|
||||
|
describe('gazeOffsetToAngleRad', () => { |
||||
|
it('matches a hand-computed atan2 for a known offset/distance pair', () => { |
||||
|
const result = gazeOffsetToAngleRad({ x: 20, y: -10 }, 80); |
||||
|
expect(result.x).toBeCloseTo(Math.atan2(20, 80), 10); |
||||
|
expect(result.y).toBeCloseTo(Math.atan2(-10, 80), 10); |
||||
|
}); |
||||
|
|
||||
|
it('is (0, 0) for a zero offset', () => { |
||||
|
expect(gazeOffsetToAngleRad({ x: 0, y: 0 }, 80)).toEqual({ x: 0, y: 0 }); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('gazeOffsetToBlendShapes', () => { |
||||
|
it('returns an empty frame for a zero offset', () => { |
||||
|
expect(gazeOffsetToBlendShapes({ x: 0, y: 0 }, 80, MAX_ANGLE)).toEqual({}); |
||||
|
}); |
||||
|
|
||||
|
it('a positive-X-only offset drives eyeLookInLeft/eyeLookOutRight, and nothing else', () => { |
||||
|
const frame = gazeOffsetToBlendShapes({ x: 5, y: 0 }, 80, MAX_ANGLE); |
||||
|
expect(frame.eyeLookInLeft).toBeGreaterThan(0); |
||||
|
expect(frame.eyeLookOutRight).toBe(frame.eyeLookInLeft); |
||||
|
expect(frame.eyeLookOutLeft).toBeUndefined(); |
||||
|
expect(frame.eyeLookInRight).toBeUndefined(); |
||||
|
expect(frame.eyeLookUpLeft).toBeUndefined(); |
||||
|
expect(frame.eyeLookDownLeft).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('a negative-X-only offset drives eyeLookOutLeft/eyeLookInRight', () => { |
||||
|
const frame = gazeOffsetToBlendShapes({ x: -5, y: 0 }, 80, MAX_ANGLE); |
||||
|
expect(frame.eyeLookOutLeft).toBeGreaterThan(0); |
||||
|
expect(frame.eyeLookInRight).toBe(frame.eyeLookOutLeft); |
||||
|
expect(frame.eyeLookInLeft).toBeUndefined(); |
||||
|
expect(frame.eyeLookOutRight).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('a positive-Y-only offset drives both eyeLookUp channels equally', () => { |
||||
|
const frame = gazeOffsetToBlendShapes({ x: 0, y: 5 }, 80, MAX_ANGLE); |
||||
|
expect(frame.eyeLookUpLeft).toBeGreaterThan(0); |
||||
|
expect(frame.eyeLookUpRight).toBe(frame.eyeLookUpLeft); |
||||
|
expect(frame.eyeLookDownLeft).toBeUndefined(); |
||||
|
expect(frame.eyeLookDownRight).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('a negative-Y-only offset drives both eyeLookDown channels equally', () => { |
||||
|
const frame = gazeOffsetToBlendShapes({ x: 0, y: -5 }, 80, MAX_ANGLE); |
||||
|
expect(frame.eyeLookDownLeft).toBeGreaterThan(0); |
||||
|
expect(frame.eyeLookDownRight).toBe(frame.eyeLookDownLeft); |
||||
|
expect(frame.eyeLookUpLeft).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('clamps an offset beyond maxGazeAngleRad to exactly 1', () => { |
||||
|
const frame = gazeOffsetToBlendShapes({ x: 1000, y: 0 }, 80, MAX_ANGLE); |
||||
|
expect(frame.eyeLookInLeft).toBe(1); |
||||
|
expect(frame.eyeLookOutRight).toBe(1); |
||||
|
}); |
||||
|
|
||||
|
it('combines independent X and Y channels for a diagonal offset', () => { |
||||
|
const frame = gazeOffsetToBlendShapes({ x: 5, y: 5 }, 80, MAX_ANGLE); |
||||
|
expect(frame.eyeLookInLeft).toBeGreaterThan(0); |
||||
|
expect(frame.eyeLookUpLeft).toBeGreaterThan(0); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,72 @@ |
|||||
|
import type { AnimationFrame } from '../animation.js'; |
||||
|
import { clamp, type Vec2 } from './curves.js'; |
||||
|
|
||||
|
/** |
||||
|
* Converts a cm-offset on the gaze plane into a gaze angle (radians) per |
||||
|
* axis, treating `offsetCm` as a point on a plane `lookAtDistanceCm` in |
||||
|
* front of the eyes. `Math.atan2` rather than a linear divide, so the |
||||
|
* relationship is physically correct (the same cm offset subtends a smaller |
||||
|
* angle at a greater distance) rather than an approximation valid only for |
||||
|
* small angles. |
||||
|
*/ |
||||
|
export function gazeOffsetToAngleRad(offsetCm: Vec2, lookAtDistanceCm: number): Vec2 { |
||||
|
return { |
||||
|
x: Math.atan2(offsetCm.x, lookAtDistanceCm), |
||||
|
y: Math.atan2(offsetCm.y, lookAtDistanceCm) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Converts a gaze-plane cm-offset into ARKit `eyeLook*` blend-shape weights. |
||||
|
* This whole conversion is a web-port adaptation with no Unreal precedent — |
||||
|
* the original design doc drives real eye bones via a control rig, not |
||||
|
* blend shapes; this project's avatars are ARKit-blend-shape-only (see |
||||
|
* docs/HUMANOID.md), so the plane offset is instead turned into a gaze |
||||
|
* angle (`gazeOffsetToAngleRad`) and normalized by `maxGazeAngleRad`, the |
||||
|
* angle at which a channel reaches full weight. |
||||
|
* |
||||
|
* Sign convention (the one place this is easy to get backwards): positive |
||||
|
* `offsetCm.x` means the avatar looks toward its own right. That's a |
||||
|
* conjugate gaze shift — both eyes rotate the same rotational direction — |
||||
|
* so the *left* eye (sitting on the avatar's left) rotates toward the nose |
||||
|
* (`eyeLookInLeft`), while the *right* eye (already on the right) rotates |
||||
|
* further from the nose (`eyeLookOutRight`). Negative `x` mirrors this onto |
||||
|
* `eyeLookOutLeft`/`eyeLookInRight`. Positive `offsetCm.y` (up) drives |
||||
|
* `eyeLookUpLeft`/`eyeLookUpRight` equally; negative (down) drives |
||||
|
* `eyeLookDownLeft`/`eyeLookDownRight` equally — no left/right ambiguity on |
||||
|
* this axis. Per-eye vergence (a closer look-at target producing a larger |
||||
|
* angle for one eye than the other) isn't modeled, matching the source |
||||
|
* doc's single shared 2D offset — see DESIGN_NOTES.md. |
||||
|
* |
||||
|
* Output is sparse: only the channels a nonzero offset actually drives are |
||||
|
* present. |
||||
|
*/ |
||||
|
export function gazeOffsetToBlendShapes( |
||||
|
offsetCm: Vec2, |
||||
|
lookAtDistanceCm: number, |
||||
|
maxGazeAngleRad: number |
||||
|
): AnimationFrame { |
||||
|
const angle = gazeOffsetToAngleRad(offsetCm, lookAtDistanceCm); |
||||
|
const horizontal = clamp(angle.x / maxGazeAngleRad, -1, 1); |
||||
|
const vertical = clamp(angle.y / maxGazeAngleRad, -1, 1); |
||||
|
|
||||
|
const frame: AnimationFrame = {}; |
||||
|
|
||||
|
if (horizontal > 0) { |
||||
|
frame.eyeLookInLeft = horizontal; |
||||
|
frame.eyeLookOutRight = horizontal; |
||||
|
} else if (horizontal < 0) { |
||||
|
frame.eyeLookOutLeft = -horizontal; |
||||
|
frame.eyeLookInRight = -horizontal; |
||||
|
} |
||||
|
|
||||
|
if (vertical > 0) { |
||||
|
frame.eyeLookUpLeft = vertical; |
||||
|
frame.eyeLookUpRight = vertical; |
||||
|
} else if (vertical < 0) { |
||||
|
frame.eyeLookDownLeft = -vertical; |
||||
|
frame.eyeLookDownRight = -vertical; |
||||
|
} |
||||
|
|
||||
|
return frame; |
||||
|
} |
||||
@ -0,0 +1,63 @@ |
|||||
|
import { describe, expect, it } from 'vitest'; |
||||
|
import { DEFAULT_EYE_CONFIG, resolveEyeConfig } from './config.js'; |
||||
|
|
||||
|
describe('resolveEyeConfig', () => { |
||||
|
it('returns DEFAULT_EYE_CONFIG when called with no overrides', () => { |
||||
|
expect(resolveEyeConfig()).toEqual(DEFAULT_EYE_CONFIG); |
||||
|
}); |
||||
|
|
||||
|
it('merges a leaf override in macro without disturbing its siblings', () => { |
||||
|
const resolved = resolveEyeConfig({ macro: { secondaryProbability: 0.9 } }); |
||||
|
expect(resolved.macro).toEqual({ ...DEFAULT_EYE_CONFIG.macro, secondaryProbability: 0.9 }); |
||||
|
expect(resolved.micro).toEqual(DEFAULT_EYE_CONFIG.micro); |
||||
|
expect(resolved.nano).toEqual(DEFAULT_EYE_CONFIG.nano); |
||||
|
}); |
||||
|
|
||||
|
it('merges a leaf override inside macro.offsetStrengthCm without dropping the other axis', () => { |
||||
|
const resolved = resolveEyeConfig({ macro: { offsetStrengthCm: { x: 99 } } }); |
||||
|
expect(resolved.macro.offsetStrengthCm).toEqual({ |
||||
|
x: 99, |
||||
|
y: DEFAULT_EYE_CONFIG.macro.offsetStrengthCm.y |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('merges a partial lookAtLengthMinMaxByState override without dropping the other states', () => { |
||||
|
const resolved = resolveEyeConfig({ |
||||
|
macro: { lookAtLengthMinMaxByState: { talking: [1, 2] } } |
||||
|
}); |
||||
|
expect(resolved.macro.lookAtLengthMinMaxByState.talking).toEqual([1, 2]); |
||||
|
expect(resolved.macro.lookAtLengthMinMaxByState.listening).toEqual( |
||||
|
DEFAULT_EYE_CONFIG.macro.lookAtLengthMinMaxByState.listening |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('merges a leaf override in micro without disturbing macro/nano', () => { |
||||
|
const resolved = resolveEyeConfig({ micro: { offsetStrengthCm: { y: 5 } } }); |
||||
|
expect(resolved.micro.offsetStrengthCm).toEqual({ |
||||
|
x: DEFAULT_EYE_CONFIG.micro.offsetStrengthCm.x, |
||||
|
y: 5 |
||||
|
}); |
||||
|
expect(resolved.macro).toEqual(DEFAULT_EYE_CONFIG.macro); |
||||
|
}); |
||||
|
|
||||
|
it('merges a leaf override in nano', () => { |
||||
|
const resolved = resolveEyeConfig({ nano: { offsetStrengthCm: 1.5 } }); |
||||
|
expect(resolved.nano).toEqual({ ...DEFAULT_EYE_CONFIG.nano, offsetStrengthCm: 1.5 }); |
||||
|
}); |
||||
|
|
||||
|
it('merges top-level scalar overrides', () => { |
||||
|
const resolved = resolveEyeConfig({ springTimeConstantSeconds: 0.5, maxGazeAngleRad: 1 }); |
||||
|
expect(resolved.springTimeConstantSeconds).toBe(0.5); |
||||
|
expect(resolved.maxGazeAngleRad).toBe(1); |
||||
|
}); |
||||
|
|
||||
|
it('does not mutate DEFAULT_EYE_CONFIG (deep check)', () => { |
||||
|
const snapshot = JSON.parse(JSON.stringify(DEFAULT_EYE_CONFIG)); |
||||
|
resolveEyeConfig({ |
||||
|
macro: { secondaryProbability: 0.1, offsetStrengthCm: { x: 1 } }, |
||||
|
micro: { offsetStrengthCm: { x: 1 } }, |
||||
|
nano: { offsetStrengthCm: 1 } |
||||
|
}); |
||||
|
expect(DEFAULT_EYE_CONFIG).toEqual(snapshot); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,145 @@ |
|||||
|
import type { AvatarState } from '../state.js'; |
||||
|
|
||||
|
/** |
||||
|
* Recursively-partial variant of a config type, used by `resolveEyeConfig`'s |
||||
|
* overrides parameter. Arrays/tuples (e.g. a `[min, max]` range) are treated |
||||
|
* as atomic — overriding one means supplying the whole tuple, not patching |
||||
|
* one element — everything else recurses field-by-field. |
||||
|
*/ |
||||
|
export type DeepPartial<T> = T extends readonly unknown[] |
||||
|
? T |
||||
|
: T extends object |
||||
|
? { [K in keyof T]?: DeepPartial<T[K]> } |
||||
|
: T; |
||||
|
|
||||
|
export interface EyeMacroConfig { |
||||
|
/** Seconds spent at the look-at target before the next look-away, keyed by the current `AvatarState`. */ |
||||
|
lookAtLengthMinMaxByState: Record<AvatarState, [number, number]>; |
||||
|
/** Seconds spent looking away before returning or chaining a secondary. */ |
||||
|
lookAwayLengthMinMax: [number, number]; |
||||
|
/** Seconds spent looking away while chaining a secondary glance. */ |
||||
|
lookAwayLengthSecondaryMinMax: [number, number]; |
||||
|
/** Chance (0-1) a secondary glance is chained once a look-away expires. */ |
||||
|
secondaryProbability: number; |
||||
|
/** A secondary can only be chained while `|goal.x|` and `|goal.y|` are both within this. */ |
||||
|
secondaryCutoff: number; |
||||
|
/** Max magnitude of the additional offset a secondary glance adds onto the current goal. */ |
||||
|
secondaryMaxStrength: number; |
||||
|
/** Max cm offset on each axis at `goal` magnitude 1, before distance scaling. */ |
||||
|
offsetStrengthCm: { x: number; y: number }; |
||||
|
/** Reference distance (cm) in the `distance/reference + floor` scale formula. */ |
||||
|
distanceScaleReferenceCm: number; |
||||
|
/** Minimum distance scale (reached as distance approaches 0). */ |
||||
|
distanceScaleFloor: number; |
||||
|
/** Bias passed to `biasedSmoothstepSigned` when drawing the Y axis of a macro goal. */ |
||||
|
yCurveBias: number; |
||||
|
} |
||||
|
|
||||
|
export interface EyeMicroConfig { |
||||
|
/** Seconds range for the X-axis micro timer, remapped through `easeOutCubic` on redraw. */ |
||||
|
xTimerMinMax: [number, number]; |
||||
|
/** Seconds range for the Y-axis micro timer, remapped through `easeOutCubic` on redraw. */ |
||||
|
yTimerMinMax: [number, number]; |
||||
|
/** Max cm offset on each axis (goal is always -1, 0, or 1). */ |
||||
|
offsetStrengthCm: { x: number; y: number }; |
||||
|
} |
||||
|
|
||||
|
export interface EyeNanoConfig { |
||||
|
/** Seconds range for the (single, unremapped) nano timer. */ |
||||
|
timerMinMax: [number, number]; |
||||
|
/** Max cm offset magnitude (goal direction is unit-length, so this is the only strength term). */ |
||||
|
offsetStrengthCm: number; |
||||
|
} |
||||
|
|
||||
|
export interface EyeConfig { |
||||
|
macro: EyeMacroConfig; |
||||
|
micro: EyeMicroConfig; |
||||
|
nano: EyeNanoConfig; |
||||
|
/** Shared exponential-decay time constant (seconds) all three layers' `current` springs toward `goal` with. */ |
||||
|
springTimeConstantSeconds: number; |
||||
|
/** |
||||
|
* Gaze angle (radians) at which `gazeOffsetToBlendShapes` (blend-shapes.ts) |
||||
|
* saturates a channel to weight 1. No `lookAtDistanceCm` default lives |
||||
|
* here — that's always a live runtime value (see `EyeGazeInput` in |
||||
|
* gaze.ts), not a config constant, since it's also what Macro's distance |
||||
|
* scaling reads every tick. |
||||
|
*/ |
||||
|
maxGazeAngleRad: number; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Defaults taken directly from the ported design doc's "Eye-System |
||||
|
* Variablen & Logik" section, except `yCurveBias` and `maxGazeAngleRad` |
||||
|
* (both new web-port knobs — see DESIGN_NOTES.md). |
||||
|
*/ |
||||
|
export const DEFAULT_EYE_CONFIG: EyeConfig = { |
||||
|
macro: { |
||||
|
lookAtLengthMinMaxByState: { |
||||
|
listening: [7, 15], |
||||
|
thinking: [3, 10], |
||||
|
talking: [3, 10], |
||||
|
idleActive: [3, 9], |
||||
|
idlePassive: [2, 9] |
||||
|
}, |
||||
|
lookAwayLengthMinMax: [0.3, 1.3], |
||||
|
lookAwayLengthSecondaryMinMax: [0.3, 0.8], |
||||
|
secondaryProbability: 0.5, |
||||
|
secondaryCutoff: 0.7, |
||||
|
secondaryMaxStrength: 0.2, |
||||
|
offsetStrengthCm: { x: 20, y: 15 }, |
||||
|
distanceScaleReferenceCm: 300, |
||||
|
distanceScaleFloor: 0.66, |
||||
|
yCurveBias: 0.5 |
||||
|
}, |
||||
|
micro: { |
||||
|
xTimerMinMax: [0.05, 6], |
||||
|
yTimerMinMax: [0.05, 4], |
||||
|
offsetStrengthCm: { x: 3, y: 1 } |
||||
|
}, |
||||
|
nano: { |
||||
|
timerMinMax: [0.05, 2.0], |
||||
|
offsetStrengthCm: 0.5 |
||||
|
}, |
||||
|
springTimeConstantSeconds: 0.125, |
||||
|
maxGazeAngleRad: (30 * Math.PI) / 180 |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* Merges a project's partial overrides onto `DEFAULT_EYE_CONFIG`. Unlike |
||||
|
* `resolveAnimationConfig`'s flat `{...DEFAULT, ...overrides}` (every field |
||||
|
* of `AnimationConfig` is a scalar), `EyeConfig`'s fields are themselves |
||||
|
* objects, so this merges each nested section independently — a caller |
||||
|
* overriding only `macro.secondaryProbability` must not blank out the rest |
||||
|
* of `macro`, or `micro`/`nano`/the top-level scalars. |
||||
|
*/ |
||||
|
export function resolveEyeConfig(overrides: DeepPartial<EyeConfig> = {}): EyeConfig { |
||||
|
return { |
||||
|
macro: { |
||||
|
...DEFAULT_EYE_CONFIG.macro, |
||||
|
...overrides.macro, |
||||
|
lookAtLengthMinMaxByState: { |
||||
|
...DEFAULT_EYE_CONFIG.macro.lookAtLengthMinMaxByState, |
||||
|
...overrides.macro?.lookAtLengthMinMaxByState |
||||
|
}, |
||||
|
offsetStrengthCm: { |
||||
|
...DEFAULT_EYE_CONFIG.macro.offsetStrengthCm, |
||||
|
...overrides.macro?.offsetStrengthCm |
||||
|
} |
||||
|
}, |
||||
|
micro: { |
||||
|
...DEFAULT_EYE_CONFIG.micro, |
||||
|
...overrides.micro, |
||||
|
offsetStrengthCm: { |
||||
|
...DEFAULT_EYE_CONFIG.micro.offsetStrengthCm, |
||||
|
...overrides.micro?.offsetStrengthCm |
||||
|
} |
||||
|
}, |
||||
|
nano: { |
||||
|
...DEFAULT_EYE_CONFIG.nano, |
||||
|
...overrides.nano |
||||
|
}, |
||||
|
springTimeConstantSeconds: |
||||
|
overrides.springTimeConstantSeconds ?? DEFAULT_EYE_CONFIG.springTimeConstantSeconds, |
||||
|
maxGazeAngleRad: overrides.maxGazeAngleRad ?? DEFAULT_EYE_CONFIG.maxGazeAngleRad |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,143 @@ |
|||||
|
import { describe, expect, it } from 'vitest'; |
||||
|
import { |
||||
|
biasedSmoothstepSigned, |
||||
|
clamp, |
||||
|
easeOutCubic, |
||||
|
normalizeVec2, |
||||
|
smoothstepSigned, |
||||
|
springTowardScalar, |
||||
|
springTowardVec2 |
||||
|
} from './curves.js'; |
||||
|
|
||||
|
describe('clamp', () => { |
||||
|
it('passes values inside the range through unchanged', () => { |
||||
|
expect(clamp(0.5, -1, 1)).toBe(0.5); |
||||
|
}); |
||||
|
|
||||
|
it('clamps values outside the range to the nearest bound', () => { |
||||
|
expect(clamp(-2, -1, 1)).toBe(-1); |
||||
|
expect(clamp(2, -1, 1)).toBe(1); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('smoothstepSigned', () => { |
||||
|
it('passes through the three fixed points', () => { |
||||
|
expect(smoothstepSigned(-1)).toBe(-1); |
||||
|
expect(smoothstepSigned(0)).toBe(0); |
||||
|
expect(smoothstepSigned(1)).toBe(1); |
||||
|
}); |
||||
|
|
||||
|
it('is odd-symmetric', () => { |
||||
|
for (const x of [0.1, 0.3, 0.7, 0.95]) { |
||||
|
expect(smoothstepSigned(-x)).toBeCloseTo(-smoothstepSigned(x), 10); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('is monotonically increasing', () => { |
||||
|
const samples = Array.from({ length: 21 }, (_, i) => -1 + (i / 20) * 2); |
||||
|
const values = samples.map(smoothstepSigned); |
||||
|
for (let i = 1; i < values.length; i++) { |
||||
|
expect(values[i]).toBeGreaterThanOrEqual(values[i - 1]); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('biases a uniform-random input toward the extremes (steep center, flat ends)', () => { |
||||
|
// Equal-width input bands near the extremes map to a wider output band
|
||||
|
// than the same-width band at the center — the "makes values more
|
||||
|
// extreme" effect described in the doc.
|
||||
|
const edgeSpread = smoothstepSigned(-0.9) - smoothstepSigned(-1); |
||||
|
const centerSpread = smoothstepSigned(0.1) - smoothstepSigned(-0.1); |
||||
|
expect(centerSpread).toBeGreaterThan(edgeSpread); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('biasedSmoothstepSigned', () => { |
||||
|
it('passes through the three fixed points', () => { |
||||
|
expect(biasedSmoothstepSigned(-1, 0.5)).toBeCloseTo(-1, 10); |
||||
|
expect(biasedSmoothstepSigned(0, 0.5)).toBe(0); |
||||
|
expect(biasedSmoothstepSigned(1, 0.5)).toBeCloseTo(1, 10); |
||||
|
}); |
||||
|
|
||||
|
it('matches smoothstepSigned exactly at and below the origin', () => { |
||||
|
for (const x of [-1, -0.7, -0.3, 0]) { |
||||
|
expect(biasedSmoothstepSigned(x, 0.5)).toBe(smoothstepSigned(x)); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('exceeds smoothstepSigned above the origin (the documented upward shift)', () => { |
||||
|
for (const x of [0.1, 0.3, 0.5, 0.8]) { |
||||
|
expect(biasedSmoothstepSigned(x, 0.5)).toBeGreaterThan(smoothstepSigned(x)); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('reaches its plateau near 1 well before x=1', () => { |
||||
|
expect(biasedSmoothstepSigned(0.8, 0.5)).toBeGreaterThan(0.95); |
||||
|
}); |
||||
|
|
||||
|
it('guards against a non-positive bias instead of producing NaN/Infinity', () => { |
||||
|
expect(Number.isFinite(biasedSmoothstepSigned(0.5, 0))).toBe(true); |
||||
|
expect(Number.isFinite(biasedSmoothstepSigned(0.5, -1))).toBe(true); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('easeOutCubic', () => { |
||||
|
it('passes through the two fixed points', () => { |
||||
|
expect(easeOutCubic(0)).toBe(0); |
||||
|
expect(easeOutCubic(1)).toBe(1); |
||||
|
}); |
||||
|
|
||||
|
it('is monotonically increasing', () => { |
||||
|
const samples = Array.from({ length: 11 }, (_, i) => i / 10); |
||||
|
const values = samples.map(easeOutCubic); |
||||
|
for (let i = 1; i < values.length; i++) { |
||||
|
expect(values[i]).toBeGreaterThan(values[i - 1]); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('front-loads growth, biasing a uniform input toward larger outputs', () => { |
||||
|
expect(easeOutCubic(0.5)).toBeGreaterThan(0.5); |
||||
|
}); |
||||
|
|
||||
|
it('clamps input outside [0, 1]', () => { |
||||
|
expect(easeOutCubic(-1)).toBe(0); |
||||
|
expect(easeOutCubic(2)).toBe(1); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('springTowardScalar', () => { |
||||
|
it('does not move for dt=0', () => { |
||||
|
expect(springTowardScalar(0, 10, 0, 0.125)).toBe(0); |
||||
|
}); |
||||
|
|
||||
|
it('moves partway toward the goal for a finite dt', () => { |
||||
|
const result = springTowardScalar(0, 10, 0.125, 0.125); |
||||
|
expect(result).toBeGreaterThan(0); |
||||
|
expect(result).toBeLessThan(10); |
||||
|
}); |
||||
|
|
||||
|
it('converges to the goal as dt grows large relative to tau', () => { |
||||
|
const result = springTowardScalar(0, 10, 100 * 0.125, 0.125); |
||||
|
expect(result).toBeCloseTo(10, 5); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('springTowardVec2', () => { |
||||
|
it('applies springTowardScalar independently per axis', () => { |
||||
|
const result = springTowardVec2({ x: 0, y: 5 }, { x: 10, y: -5 }, 0.125, 0.125); |
||||
|
expect(result.x).toBe(springTowardScalar(0, 10, 0.125, 0.125)); |
||||
|
expect(result.y).toBe(springTowardScalar(5, -5, 0.125, 0.125)); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('normalizeVec2', () => { |
||||
|
it('returns a unit-length vector for a nonzero input', () => { |
||||
|
const result = normalizeVec2({ x: 3, y: 4 }); |
||||
|
expect(Math.hypot(result.x, result.y)).toBeCloseTo(1, 10); |
||||
|
expect(result.x).toBeCloseTo(0.6, 10); |
||||
|
expect(result.y).toBeCloseTo(0.8, 10); |
||||
|
}); |
||||
|
|
||||
|
it('passes a zero-length input through unchanged instead of dividing by zero', () => { |
||||
|
expect(normalizeVec2({ x: 0, y: 0 })).toEqual({ x: 0, y: 0 }); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,99 @@ |
|||||
|
/** |
||||
|
* Small math/easing primitives for the eye gaze system's Macro/Micro/Nano |
||||
|
* layers (see gaze.ts and DESIGN_NOTES.md). Kept separate from gaze.ts so |
||||
|
* each curve's shape can be asserted directly and retuned without touching |
||||
|
* the state machine. |
||||
|
*/ |
||||
|
|
||||
|
export interface Vec2 { |
||||
|
x: number; |
||||
|
y: number; |
||||
|
} |
||||
|
|
||||
|
export function clamp(value: number, min: number, max: number): number { |
||||
|
return Math.min(max, Math.max(min, value)); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Symmetric ease through (-1,-1) -> (0,0) -> (1,1) with zero tangent at both |
||||
|
* ends (a signed remap of the classic 3t^2-2t^3 smoothstep). Approximates |
||||
|
* the Unreal "LookAt_Macro_Strength_X" curve asset — only a screenshot of |
||||
|
* the hand-authored spline was available, not its keyframe/tangent data, so |
||||
|
* this is a best-effort match to the visible shape rather than a pixel-exact |
||||
|
* port (see DESIGN_NOTES.md). Because the derivative is near zero at the |
||||
|
* extremes and highest at the center, remapping a uniform-random input |
||||
|
* through this curve biases the *output* toward the extremes, not the |
||||
|
* center — matching the doc's documented intent ("tends to make values more |
||||
|
* extreme"). |
||||
|
*/ |
||||
|
export function smoothstepSigned(x: number): number { |
||||
|
const t = clamp((x + 1) / 2, 0, 1); |
||||
|
const s = 3 * t * t - 2 * t * t * t; |
||||
|
return 2 * s - 1; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Approximates the "LookAt_Macro_Strength_Y" curve, whose screenshot shows |
||||
|
* it tracking `smoothstepSigned` closely below the origin but plateauing at |
||||
|
* +1 well before x=1 — the documented "shifts values further upward, so the |
||||
|
* avatar looks up more than down". Modeled as `smoothstepSigned` for x<=0, |
||||
|
* and for x>0 a Perlin-style input bias (`t' = t^bias`) applied before the |
||||
|
* smoothstep evaluation so the plateau arrives early while endpoints (0->0, |
||||
|
* 1->1) and the lower half stay untouched. `bias` is clamped away from zero |
||||
|
* to avoid a `pow` degeneracy. |
||||
|
*/ |
||||
|
export function biasedSmoothstepSigned(x: number, bias: number): number { |
||||
|
if (x <= 0) return smoothstepSigned(x); |
||||
|
const safeBias = Math.max(bias, 1e-3); |
||||
|
const t = clamp(Math.pow(x, safeBias), 0, 1); |
||||
|
return 3 * t * t - 2 * t * t * t; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Ease-out cubic on [0,1]: steep at t=0, flattening to a plateau at t=1. |
||||
|
* Approximates the "LookAt_Micro_Timer" curve, whose documented effect is |
||||
|
* that a uniform-random `t` remapped through it is "somewhat less likely to |
||||
|
* land on a small value than a large one". |
||||
|
*/ |
||||
|
export function easeOutCubic(t: number): number { |
||||
|
const clamped = clamp(t, 0, 1); |
||||
|
return 1 - Math.pow(1 - clamped, 3); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Frame-rate-independent exponential-decay smoothing of `current` toward |
||||
|
* `goal` — the doc's "SpringInterpolate" ("initially snappy, then |
||||
|
* increasingly smooth as it nears the goal", no overshoot described). No |
||||
|
* exact formula is given, only a ballpark "should take about 0.125 |
||||
|
* seconds"; that value is treated here as the decay time constant `tau`, |
||||
|
* not a fixed completion duration — see DESIGN_NOTES.md. |
||||
|
*/ |
||||
|
export function springTowardScalar( |
||||
|
current: number, |
||||
|
goal: number, |
||||
|
dtSeconds: number, |
||||
|
tauSeconds: number |
||||
|
): number { |
||||
|
if (dtSeconds <= 0) return current; |
||||
|
const alpha = 1 - Math.exp(-dtSeconds / Math.max(tauSeconds, 1e-6)); |
||||
|
return current + (goal - current) * alpha; |
||||
|
} |
||||
|
|
||||
|
export function springTowardVec2( |
||||
|
current: Vec2, |
||||
|
goal: Vec2, |
||||
|
dtSeconds: number, |
||||
|
tauSeconds: number |
||||
|
): Vec2 { |
||||
|
return { |
||||
|
x: springTowardScalar(current.x, goal.x, dtSeconds, tauSeconds), |
||||
|
y: springTowardScalar(current.y, goal.y, dtSeconds, tauSeconds) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** Zero-length input passes through unchanged rather than dividing by zero. */ |
||||
|
export function normalizeVec2(v: Vec2): Vec2 { |
||||
|
const length = Math.hypot(v.x, v.y); |
||||
|
if (length === 0) return { x: 0, y: 0 }; |
||||
|
return { x: v.x / length, y: v.y / length }; |
||||
|
} |
||||
@ -0,0 +1,423 @@ |
|||||
|
import { describe, expect, it } from 'vitest'; |
||||
|
import { DEFAULT_EYE_CONFIG } from './config.js'; |
||||
|
import { |
||||
|
biasedSmoothstepSigned, |
||||
|
clamp, |
||||
|
easeOutCubic, |
||||
|
normalizeVec2, |
||||
|
smoothstepSigned, |
||||
|
springTowardVec2 |
||||
|
} from './curves.js'; |
||||
|
import { |
||||
|
createInitialEyeGazeState, |
||||
|
forceMacroLookAway, |
||||
|
stepEyeGaze, |
||||
|
type EyeGazeState |
||||
|
} from './gaze.js'; |
||||
|
|
||||
|
/** Returns a fixed sequence of values; throws if called more times than scripted (proves "no extra draw happened"). */ |
||||
|
function scriptedRng(values: number[]): () => number { |
||||
|
let i = 0; |
||||
|
return () => { |
||||
|
if (i >= values.length) throw new Error(`scriptedRng exhausted after ${i} calls`); |
||||
|
return values[i++]; |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const ZERO = { x: 0, y: 0 }; |
||||
|
|
||||
|
/** A state where nothing is about to expire (timer=100) unless a test overrides a specific layer. */ |
||||
|
function baseState(overrides: Partial<EyeGazeState> = {}): EyeGazeState { |
||||
|
return { |
||||
|
macro: { phase: 'atLookAt', timer: 100, goal: { ...ZERO }, current: { ...ZERO } }, |
||||
|
micro: { |
||||
|
x: { goal: 0, current: 0, timer: 100 }, |
||||
|
y: { goal: 0, current: 0, timer: 100 } |
||||
|
}, |
||||
|
nano: { goal: { ...ZERO }, current: { ...ZERO }, timer: 100 }, |
||||
|
macroOffsetCm: { ...ZERO }, |
||||
|
microOffsetCm: { ...ZERO }, |
||||
|
nanoOffsetCm: { ...ZERO }, |
||||
|
combinedOffsetCm: { ...ZERO }, |
||||
|
...overrides |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const config = DEFAULT_EYE_CONFIG; |
||||
|
|
||||
|
describe('createInitialEyeGazeState', () => { |
||||
|
it("starts Macro atLookAt, at rest, per the doc's startup rule", () => { |
||||
|
const state = createInitialEyeGazeState(config, scriptedRng([0.5, 0.5, 0.5, 0.5])); |
||||
|
expect(state.macro.phase).toBe('atLookAt'); |
||||
|
expect(state.macro.goal).toEqual(ZERO); |
||||
|
expect(state.macro.current).toEqual(ZERO); |
||||
|
expect(state.macro.timer).toBeGreaterThan(0); |
||||
|
}); |
||||
|
|
||||
|
it('starts Micro and Nano at rest with a randomly-drawn first timer', () => { |
||||
|
const state = createInitialEyeGazeState(config, scriptedRng([0.5, 0.5, 0.5, 0.5])); |
||||
|
expect(state.micro.x.goal).toBe(0); |
||||
|
expect(state.micro.y.goal).toBe(0); |
||||
|
expect(state.nano.goal).toEqual(ZERO); |
||||
|
expect(state.micro.x.timer).toBeGreaterThan(0); |
||||
|
expect(state.micro.y.timer).toBeGreaterThan(0); |
||||
|
expect(state.nano.timer).toBeGreaterThan(0); |
||||
|
}); |
||||
|
|
||||
|
it('starts with all offsets at zero', () => { |
||||
|
const state = createInitialEyeGazeState(config, scriptedRng([0.5, 0.5, 0.5, 0.5])); |
||||
|
expect(state.combinedOffsetCm).toEqual(ZERO); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('stepEyeGaze — macro', () => { |
||||
|
it('transitions atLookAt -> lookingAway on expiry, drawing a goal via the curve functions', () => { |
||||
|
const state = baseState({ |
||||
|
macro: { phase: 'atLookAt', timer: 0.05, goal: { ...ZERO }, current: { ...ZERO } } |
||||
|
}); |
||||
|
const rng = scriptedRng([0.75, 0.25, 0.5]); |
||||
|
const result = stepEyeGaze( |
||||
|
state, |
||||
|
{ deltaSeconds: 0.1, avatarState: 'idleActive', lookAtDistanceCm: 80 }, |
||||
|
config, |
||||
|
rng, |
||||
|
{ micro: false, nano: false } |
||||
|
); |
||||
|
|
||||
|
const expectedGoal = { |
||||
|
x: smoothstepSigned(0.75 * 2 - 1), |
||||
|
y: biasedSmoothstepSigned(0.25 * 2 - 1, config.macro.yCurveBias) |
||||
|
}; |
||||
|
const [min, max] = config.macro.lookAwayLengthMinMax; |
||||
|
|
||||
|
expect(result.macro.phase).toBe('lookingAway'); |
||||
|
expect(result.macro.goal.x).toBeCloseTo(expectedGoal.x, 10); |
||||
|
expect(result.macro.goal.y).toBeCloseTo(expectedGoal.y, 10); |
||||
|
expect(result.macro.timer).toBeCloseTo(0.05 - 0.1 + (min + 0.5 * (max - min)), 10); |
||||
|
expect(result.macro.current).toEqual( |
||||
|
springTowardVec2(ZERO, result.macro.goal, 0.1, config.springTimeConstantSeconds) |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('ignores avatarState changes while the current countdown has not expired', () => { |
||||
|
const state = baseState({ |
||||
|
macro: { phase: 'atLookAt', timer: 5, goal: { ...ZERO }, current: { ...ZERO } } |
||||
|
}); |
||||
|
const rng = scriptedRng([]); // any draw here would mean avatarState triggered a redraw it shouldn't have
|
||||
|
const result = stepEyeGaze( |
||||
|
state, |
||||
|
{ deltaSeconds: 1, avatarState: 'talking', lookAtDistanceCm: 80 }, |
||||
|
config, |
||||
|
rng, |
||||
|
{ micro: false, nano: false } |
||||
|
); |
||||
|
expect(result.macro.phase).toBe('atLookAt'); |
||||
|
expect(result.macro.timer).toBeCloseTo(4, 10); |
||||
|
}); |
||||
|
|
||||
|
it('keys the next look-at timer range by the current avatarState on return-to-center', () => { |
||||
|
const runWithState = (avatarState: 'listening' | 'thinking') => |
||||
|
stepEyeGaze( |
||||
|
baseState({ |
||||
|
macro: { |
||||
|
phase: 'lookingAway', |
||||
|
timer: -0.009, |
||||
|
goal: { x: 0.1, y: 0.1 }, |
||||
|
current: { ...ZERO } |
||||
|
} |
||||
|
}), |
||||
|
{ deltaSeconds: 0, avatarState, lookAtDistanceCm: 80 }, |
||||
|
config, |
||||
|
scriptedRng([0.9, 0.5]), // r=0.9 fails the secondary roll -> return-to-center branch
|
||||
|
{ micro: false, nano: false } |
||||
|
); |
||||
|
|
||||
|
const listening = runWithState('listening'); |
||||
|
const thinking = runWithState('thinking'); |
||||
|
const [listenMin, listenMax] = config.macro.lookAtLengthMinMaxByState.listening; |
||||
|
const [thinkMin, thinkMax] = config.macro.lookAtLengthMinMaxByState.thinking; |
||||
|
|
||||
|
expect(listening.macro.phase).toBe('atLookAt'); |
||||
|
expect(listening.macro.goal).toEqual(ZERO); |
||||
|
expect(listening.macro.timer).toBeCloseTo( |
||||
|
-0.009 + (listenMin + 0.5 * (listenMax - listenMin)), |
||||
|
10 |
||||
|
); |
||||
|
expect(thinking.macro.timer).toBeCloseTo(-0.009 + (thinkMin + 0.5 * (thinkMax - thinkMin)), 10); |
||||
|
expect(listening.macro.timer).not.toBeCloseTo(thinking.macro.timer, 3); |
||||
|
}); |
||||
|
|
||||
|
it('returns to center if the roll succeeds but the goal exceeds the secondary cutoff', () => { |
||||
|
const state = baseState({ |
||||
|
macro: { phase: 'lookingAway', timer: -0.01, goal: { x: 0.9, y: 0 }, current: { ...ZERO } } |
||||
|
}); |
||||
|
const rng = scriptedRng([0.0, 0.5]); // r=0 rolls secondary, but goal.x=0.9 exceeds the 0.7 cutoff
|
||||
|
const result = stepEyeGaze( |
||||
|
state, |
||||
|
{ deltaSeconds: 0, avatarState: 'idlePassive', lookAtDistanceCm: 80 }, |
||||
|
config, |
||||
|
rng, |
||||
|
{ micro: false, nano: false } |
||||
|
); |
||||
|
expect(result.macro.phase).toBe('atLookAt'); |
||||
|
expect(result.macro.goal).toEqual(ZERO); |
||||
|
}); |
||||
|
|
||||
|
it('chains a secondary glance when the roll succeeds and the goal is within the cutoff', () => { |
||||
|
const initialGoal = { x: 0.5, y: 0.5 }; |
||||
|
const state = baseState({ |
||||
|
macro: { phase: 'lookingAway', timer: -0.01, goal: initialGoal, current: { ...ZERO } } |
||||
|
}); |
||||
|
const rng = scriptedRng([0.1, 0.75, 0.25, 0.5]); // r=0.1 rolls secondary; within cutoff
|
||||
|
const result = stepEyeGaze( |
||||
|
state, |
||||
|
{ deltaSeconds: 0, avatarState: 'talking', lookAtDistanceCm: 80 }, |
||||
|
config, |
||||
|
rng, |
||||
|
{ micro: false, nano: false } |
||||
|
); |
||||
|
|
||||
|
const deltaX = smoothstepSigned(0.75 * 2 - 1) * config.macro.secondaryMaxStrength; |
||||
|
const deltaY = smoothstepSigned(0.25 * 2 - 1) * config.macro.secondaryMaxStrength; |
||||
|
const [secMin, secMax] = config.macro.lookAwayLengthSecondaryMinMax; |
||||
|
|
||||
|
expect(result.macro.phase).toBe('lookingAway'); |
||||
|
expect(result.macro.goal.x).toBeCloseTo(clamp(initialGoal.x + deltaX, -1, 1), 10); |
||||
|
expect(result.macro.goal.y).toBeCloseTo(clamp(initialGoal.y + deltaY, -1, 1), 10); |
||||
|
expect(result.macro.timer).toBeCloseTo(-0.01 + (secMin + 0.5 * (secMax - secMin)), 10); |
||||
|
}); |
||||
|
|
||||
|
it('scales the macro cm-offset by distance, and never below the floor', () => { |
||||
|
const state = baseState({ |
||||
|
macro: { phase: 'atLookAt', timer: 100, goal: { ...ZERO }, current: { x: 1, y: 1 } } |
||||
|
}); |
||||
|
const rng = scriptedRng([]); |
||||
|
const near = stepEyeGaze( |
||||
|
state, |
||||
|
{ deltaSeconds: 0, avatarState: 'idleActive', lookAtDistanceCm: 0 }, |
||||
|
config, |
||||
|
rng, |
||||
|
{ |
||||
|
micro: false, |
||||
|
nano: false |
||||
|
} |
||||
|
); |
||||
|
const far = stepEyeGaze( |
||||
|
state, |
||||
|
{ deltaSeconds: 0, avatarState: 'idleActive', lookAtDistanceCm: 600 }, |
||||
|
config, |
||||
|
rng, |
||||
|
{ |
||||
|
micro: false, |
||||
|
nano: false |
||||
|
} |
||||
|
); |
||||
|
expect(near.macroOffsetCm.x).toBeCloseTo( |
||||
|
1 * config.macro.offsetStrengthCm.x * config.macro.distanceScaleFloor, |
||||
|
10 |
||||
|
); |
||||
|
expect(far.macroOffsetCm.x).toBeGreaterThan(near.macroOffsetCm.x); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('stepEyeGaze — micro', () => { |
||||
|
it('advances the X and Y timers independently', () => { |
||||
|
const state = baseState({ |
||||
|
micro: { x: { goal: 0, current: 0, timer: 0.01 }, y: { goal: 0, current: 0, timer: 100 } } |
||||
|
}); |
||||
|
const rng = scriptedRng([0.5, 0.5]); |
||||
|
const result = stepEyeGaze( |
||||
|
state, |
||||
|
{ deltaSeconds: 0.02, avatarState: 'idleActive', lookAtDistanceCm: 80 }, |
||||
|
config, |
||||
|
rng, |
||||
|
{ macro: false, nano: false } |
||||
|
); |
||||
|
expect(result.micro.x.goal).not.toBe(0); |
||||
|
expect(result.micro.y.goal).toBe(0); |
||||
|
expect(result.micro.y.timer).toBeCloseTo(100 - 0.02, 10); |
||||
|
}); |
||||
|
|
||||
|
it('always redraws a goal different from the current value', () => { |
||||
|
const fromZero = baseState({ |
||||
|
micro: { x: { goal: 0, current: 0, timer: -0.01 }, y: { goal: 0, current: 0, timer: 100 } } |
||||
|
}); |
||||
|
const pickLow = stepEyeGaze( |
||||
|
fromZero, |
||||
|
{ deltaSeconds: 0, avatarState: 'idleActive', lookAtDistanceCm: 80 }, |
||||
|
config, |
||||
|
scriptedRng([0.1, 0.5]), |
||||
|
{ macro: false, nano: false } |
||||
|
); |
||||
|
const pickHigh = stepEyeGaze( |
||||
|
fromZero, |
||||
|
{ deltaSeconds: 0, avatarState: 'idleActive', lookAtDistanceCm: 80 }, |
||||
|
config, |
||||
|
scriptedRng([0.9, 0.5]), |
||||
|
{ macro: false, nano: false } |
||||
|
); |
||||
|
expect(pickLow.micro.x.goal).not.toBe(0); |
||||
|
expect(pickHigh.micro.x.goal).not.toBe(0); |
||||
|
expect(pickLow.micro.x.goal).not.toBe(pickHigh.micro.x.goal); |
||||
|
|
||||
|
const fromNegativeOne = baseState({ |
||||
|
micro: { x: { goal: -1, current: -1, timer: -0.01 }, y: { goal: 0, current: 0, timer: 100 } } |
||||
|
}); |
||||
|
const redrawn = stepEyeGaze( |
||||
|
fromNegativeOne, |
||||
|
{ deltaSeconds: 0, avatarState: 'idleActive', lookAtDistanceCm: 80 }, |
||||
|
config, |
||||
|
scriptedRng([0.1, 0.5]), |
||||
|
{ macro: false, nano: false } |
||||
|
); |
||||
|
expect(redrawn.micro.x.goal).not.toBe(-1); |
||||
|
}); |
||||
|
|
||||
|
it('remaps the redrawn timer duration through easeOutCubic, not linearly', () => { |
||||
|
const state = baseState({ |
||||
|
micro: { x: { goal: 0, current: 0, timer: -0.01 }, y: { goal: 0, current: 0, timer: 100 } } |
||||
|
}); |
||||
|
const rawT = 0.3; |
||||
|
const result = stepEyeGaze( |
||||
|
state, |
||||
|
{ deltaSeconds: 0, avatarState: 'idleActive', lookAtDistanceCm: 80 }, |
||||
|
config, |
||||
|
scriptedRng([0.9, rawT]), |
||||
|
{ macro: false, nano: false } |
||||
|
); |
||||
|
const [min, max] = config.micro.xTimerMinMax; |
||||
|
const curvedDuration = min + easeOutCubic(rawT) * (max - min); |
||||
|
const linearDuration = min + rawT * (max - min); |
||||
|
expect(result.micro.x.timer).toBeCloseTo(-0.01 + curvedDuration, 10); |
||||
|
expect(curvedDuration).not.toBeCloseTo(linearDuration, 5); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('stepEyeGaze — nano', () => { |
||||
|
it('draws a unit-length direction and a linearly (uncurved) mapped timer duration', () => { |
||||
|
const state = baseState({ nano: { goal: { ...ZERO }, current: { ...ZERO }, timer: -0.01 } }); |
||||
|
const rng = scriptedRng([0.75, 0.25, 0.4]); |
||||
|
const result = stepEyeGaze( |
||||
|
state, |
||||
|
{ deltaSeconds: 0, avatarState: 'idleActive', lookAtDistanceCm: 80 }, |
||||
|
config, |
||||
|
rng, |
||||
|
{ macro: false, micro: false } |
||||
|
); |
||||
|
|
||||
|
const expectedGoal = normalizeVec2({ x: 0.75 * 2 - 1, y: 0.25 * 2 - 1 }); |
||||
|
expect(result.nano.goal.x).toBeCloseTo(expectedGoal.x, 10); |
||||
|
expect(result.nano.goal.y).toBeCloseTo(expectedGoal.y, 10); |
||||
|
expect(Math.hypot(result.nano.goal.x, result.nano.goal.y)).toBeCloseTo(1, 10); |
||||
|
|
||||
|
const [min, max] = config.nano.timerMinMax; |
||||
|
expect(result.nano.timer).toBeCloseTo(-0.01 + (min + 0.4 * (max - min)), 10); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('stepEyeGaze — composition', () => { |
||||
|
it('combinedOffsetCm is the exact sum of the three layer offsets', () => { |
||||
|
const state = baseState({ |
||||
|
macro: { phase: 'atLookAt', timer: 100, goal: { ...ZERO }, current: { x: 0.2, y: -0.1 } }, |
||||
|
micro: { |
||||
|
x: { goal: 1, current: 0.5, timer: 100 }, |
||||
|
y: { goal: -1, current: -0.3, timer: 100 } |
||||
|
}, |
||||
|
nano: { goal: { x: 0.6, y: 0.8 }, current: { x: 0.6, y: 0.8 }, timer: 100 } |
||||
|
}); |
||||
|
const result = stepEyeGaze( |
||||
|
state, |
||||
|
{ deltaSeconds: 0, avatarState: 'idleActive', lookAtDistanceCm: 80 }, |
||||
|
config, |
||||
|
scriptedRng([]) |
||||
|
); |
||||
|
expect(result.combinedOffsetCm.x).toBeCloseTo( |
||||
|
result.macroOffsetCm.x + result.microOffsetCm.x + result.nanoOffsetCm.x, |
||||
|
10 |
||||
|
); |
||||
|
expect(result.combinedOffsetCm.y).toBeCloseTo( |
||||
|
result.macroOffsetCm.y + result.microOffsetCm.y + result.nanoOffsetCm.y, |
||||
|
10 |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('forceMacroLookAway', () => { |
||||
|
it('immediately transitions to lookingAway with a fresh goal, regardless of remaining timer', () => { |
||||
|
const state = baseState({ |
||||
|
macro: { phase: 'atLookAt', timer: 50, goal: { ...ZERO }, current: { ...ZERO } } |
||||
|
}); |
||||
|
const rng = scriptedRng([0.75, 0.25, 0.5]); |
||||
|
const result = forceMacroLookAway(state, config, rng); |
||||
|
|
||||
|
expect(result.macro.phase).toBe('lookingAway'); |
||||
|
expect(result.macro.goal.x).toBeCloseTo(smoothstepSigned(0.5), 10); |
||||
|
expect(result.macro.goal.y).toBeCloseTo( |
||||
|
biasedSmoothstepSigned(-0.5, config.macro.yCurveBias), |
||||
|
10 |
||||
|
); |
||||
|
const [min, max] = config.macro.lookAwayLengthMinMax; |
||||
|
expect(result.macro.timer).toBeCloseTo(min + 0.5 * (max - min), 10); |
||||
|
}); |
||||
|
|
||||
|
it('is a no-op while already lookingAway', () => { |
||||
|
const state = baseState({ |
||||
|
macro: { |
||||
|
phase: 'lookingAway', |
||||
|
timer: 3, |
||||
|
goal: { x: 0.4, y: 0.4 }, |
||||
|
current: { x: 0.1, y: 0.1 } |
||||
|
} |
||||
|
}); |
||||
|
const result = forceMacroLookAway(state, config, scriptedRng([])); |
||||
|
expect(result).toBe(state); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('layer toggles', () => { |
||||
|
it('freezes a disabled layer entirely and zeroes its offset contribution', () => { |
||||
|
const state = baseState({ |
||||
|
macro: { |
||||
|
phase: 'atLookAt', |
||||
|
timer: 0.01, |
||||
|
goal: { x: 0.3, y: 0.3 }, |
||||
|
current: { x: 0.2, y: 0.2 } |
||||
|
} |
||||
|
}); |
||||
|
const rng = scriptedRng([]); // the frozen layer's timer looks expired but must not draw
|
||||
|
const result = stepEyeGaze( |
||||
|
state, |
||||
|
{ deltaSeconds: 1, avatarState: 'idleActive', lookAtDistanceCm: 80 }, |
||||
|
config, |
||||
|
rng, |
||||
|
{ |
||||
|
macro: false |
||||
|
} |
||||
|
); |
||||
|
expect(result.macro).toEqual(state.macro); |
||||
|
expect(result.macroOffsetCm).toEqual(ZERO); |
||||
|
}); |
||||
|
|
||||
|
it('resumes a re-enabled layer from where it left off, not from a reset state', () => { |
||||
|
const frozen = baseState({ |
||||
|
macro: { phase: 'atLookAt', timer: 5, goal: { ...ZERO }, current: { ...ZERO } } |
||||
|
}); |
||||
|
const stillFrozen = stepEyeGaze( |
||||
|
frozen, |
||||
|
{ deltaSeconds: 1, avatarState: 'idleActive', lookAtDistanceCm: 80 }, |
||||
|
config, |
||||
|
scriptedRng([]), |
||||
|
{ macro: false } |
||||
|
); |
||||
|
expect(stillFrozen.macro.timer).toBe(5); |
||||
|
|
||||
|
const resumed = stepEyeGaze( |
||||
|
stillFrozen, |
||||
|
{ deltaSeconds: 1, avatarState: 'idleActive', lookAtDistanceCm: 80 }, |
||||
|
config, |
||||
|
scriptedRng([]), |
||||
|
{ macro: true } |
||||
|
); |
||||
|
expect(resumed.macro.timer).toBeCloseTo(4, 10); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,358 @@ |
|||||
|
import type { AvatarState } from '../state.js'; |
||||
|
import { |
||||
|
biasedSmoothstepSigned, |
||||
|
clamp, |
||||
|
easeOutCubic, |
||||
|
normalizeVec2, |
||||
|
smoothstepSigned, |
||||
|
springTowardScalar, |
||||
|
springTowardVec2, |
||||
|
type Vec2 |
||||
|
} from './curves.js'; |
||||
|
import type { EyeConfig, EyeMacroConfig, EyeMicroConfig, EyeNanoConfig } from './config.js'; |
||||
|
|
||||
|
export interface EyeGazeInput { |
||||
|
deltaSeconds: number; |
||||
|
avatarState: AvatarState; |
||||
|
/** Distance (cm) from the avatar's eyes to wherever the (out of scope) LookAt system is currently aiming. */ |
||||
|
lookAtDistanceCm: number; |
||||
|
} |
||||
|
|
||||
|
/** Each layer defaults to enabled; disabling one freezes it (see `stepEyeGaze`). */ |
||||
|
export type LayerToggles = { macro?: boolean; micro?: boolean; nano?: boolean }; |
||||
|
|
||||
|
interface MacroLayerState { |
||||
|
phase: 'atLookAt' | 'lookingAway'; |
||||
|
/** Seconds remaining in the current phase. */ |
||||
|
timer: number; |
||||
|
goal: Vec2; |
||||
|
current: Vec2; |
||||
|
} |
||||
|
|
||||
|
interface MicroAxisState { |
||||
|
goal: -1 | 0 | 1; |
||||
|
current: number; |
||||
|
timer: number; |
||||
|
} |
||||
|
|
||||
|
interface MicroLayerState { |
||||
|
x: MicroAxisState; |
||||
|
y: MicroAxisState; |
||||
|
} |
||||
|
|
||||
|
interface NanoLayerState { |
||||
|
goal: Vec2; |
||||
|
current: Vec2; |
||||
|
timer: number; |
||||
|
} |
||||
|
|
||||
|
export interface EyeGazeState { |
||||
|
macro: MacroLayerState; |
||||
|
micro: MicroLayerState; |
||||
|
nano: NanoLayerState; |
||||
|
/** Each layer's effective cm-offset this tick — exposed for debug UIs, not just internal use. */ |
||||
|
macroOffsetCm: Vec2; |
||||
|
microOffsetCm: Vec2; |
||||
|
nanoOffsetCm: Vec2; |
||||
|
combinedOffsetCm: Vec2; |
||||
|
} |
||||
|
|
||||
|
const ZERO: Vec2 = { x: 0, y: 0 }; |
||||
|
|
||||
|
function randomSigned(rng: () => number): number { |
||||
|
return rng() * 2 - 1; |
||||
|
} |
||||
|
|
||||
|
function randomRange(min: number, max: number, rng: () => number): number { |
||||
|
return min + rng() * (max - min); |
||||
|
} |
||||
|
|
||||
|
function drawMacroGoal(config: EyeMacroConfig, rng: () => number): Vec2 { |
||||
|
return { |
||||
|
x: smoothstepSigned(randomSigned(rng)), |
||||
|
y: biasedSmoothstepSigned(randomSigned(rng), config.yCurveBias) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** Micro's goal must differ from its current value on every redraw — pick uniformly among the 2 remaining options. */ |
||||
|
function drawMicroGoal(current: -1 | 0 | 1, rng: () => number): -1 | 0 | 1 { |
||||
|
const options = ([-1, 0, 1] as const).filter((value) => value !== current); |
||||
|
return options[rng() < 0.5 ? 0 : 1]; |
||||
|
} |
||||
|
|
||||
|
function drawMicroTimerDuration(timerMinMax: [number, number], rng: () => number): number { |
||||
|
const t = easeOutCubic(rng()); |
||||
|
return timerMinMax[0] + t * (timerMinMax[1] - timerMinMax[0]); |
||||
|
} |
||||
|
|
||||
|
function macroDistanceScale(config: EyeMacroConfig, lookAtDistanceCm: number): number { |
||||
|
return lookAtDistanceCm / config.distanceScaleReferenceCm + config.distanceScaleFloor; |
||||
|
} |
||||
|
|
||||
|
function macroOffsetFromCurrent( |
||||
|
current: Vec2, |
||||
|
config: EyeMacroConfig, |
||||
|
lookAtDistanceCm: number |
||||
|
): Vec2 { |
||||
|
const scale = macroDistanceScale(config, lookAtDistanceCm); |
||||
|
return { |
||||
|
x: current.x * config.offsetStrengthCm.x * scale, |
||||
|
y: current.y * config.offsetStrengthCm.y * scale |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
function microOffsetFromCurrent(current: Vec2, config: EyeMicroConfig): Vec2 { |
||||
|
return { |
||||
|
x: current.x * config.offsetStrengthCm.x, |
||||
|
y: current.y * config.offsetStrengthCm.y |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
function nanoOffsetFromCurrent(current: Vec2, config: EyeNanoConfig): Vec2 { |
||||
|
return { |
||||
|
x: current.x * config.offsetStrengthCm, |
||||
|
y: current.y * config.offsetStrengthCm |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Advances the Macro phase machine by `dtSeconds`. A `while` loop (not |
||||
|
* `if`) handles a `dtSeconds` large enough to span more than one phase |
||||
|
* transition (e.g. a demo's time-scale multiplier cranked way up) — each |
||||
|
* iteration always draws a strictly-positive next duration, so this |
||||
|
* terminates. `current` springs toward the *final* goal over the full |
||||
|
* `dtSeconds` once, rather than re-springing after every intermediate |
||||
|
* transition — an intentional simplification that only matters in that same |
||||
|
* large-dt edge case. |
||||
|
*/ |
||||
|
function stepMacro( |
||||
|
layer: MacroLayerState, |
||||
|
dtSeconds: number, |
||||
|
avatarState: AvatarState, |
||||
|
config: EyeConfig, |
||||
|
rng: () => number |
||||
|
): MacroLayerState { |
||||
|
let phase = layer.phase; |
||||
|
let timer = layer.timer - dtSeconds; |
||||
|
let goal = layer.goal; |
||||
|
|
||||
|
while (timer <= 0) { |
||||
|
if (phase === 'atLookAt') { |
||||
|
goal = drawMacroGoal(config.macro, rng); |
||||
|
timer += randomRange( |
||||
|
config.macro.lookAwayLengthMinMax[0], |
||||
|
config.macro.lookAwayLengthMinMax[1], |
||||
|
rng |
||||
|
); |
||||
|
phase = 'lookingAway'; |
||||
|
} else { |
||||
|
const withinCutoff = |
||||
|
Math.abs(goal.x) <= config.macro.secondaryCutoff && |
||||
|
Math.abs(goal.y) <= config.macro.secondaryCutoff; |
||||
|
const rollsSecondary = rng() <= config.macro.secondaryProbability; |
||||
|
|
||||
|
if (rollsSecondary && withinCutoff) { |
||||
|
const delta = { |
||||
|
x: smoothstepSigned(randomSigned(rng)) * config.macro.secondaryMaxStrength, |
||||
|
y: smoothstepSigned(randomSigned(rng)) * config.macro.secondaryMaxStrength |
||||
|
}; |
||||
|
goal = { |
||||
|
x: clamp(goal.x + delta.x, -1, 1), |
||||
|
y: clamp(goal.y + delta.y, -1, 1) |
||||
|
}; |
||||
|
timer += randomRange( |
||||
|
config.macro.lookAwayLengthSecondaryMinMax[0], |
||||
|
config.macro.lookAwayLengthSecondaryMinMax[1], |
||||
|
rng |
||||
|
); |
||||
|
// stays 'lookingAway' — another secondary check happens when this timer next expires.
|
||||
|
} else { |
||||
|
goal = { x: 0, y: 0 }; |
||||
|
const [min, max] = config.macro.lookAtLengthMinMaxByState[avatarState]; |
||||
|
timer += randomRange(min, max, rng); |
||||
|
phase = 'atLookAt'; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const current = springTowardVec2( |
||||
|
layer.current, |
||||
|
goal, |
||||
|
dtSeconds, |
||||
|
config.springTimeConstantSeconds |
||||
|
); |
||||
|
return { phase, timer, goal, current }; |
||||
|
} |
||||
|
|
||||
|
function stepMicroAxis( |
||||
|
axis: MicroAxisState, |
||||
|
dtSeconds: number, |
||||
|
timerMinMax: [number, number], |
||||
|
tauSeconds: number, |
||||
|
rng: () => number |
||||
|
): MicroAxisState { |
||||
|
let goal = axis.goal; |
||||
|
let timer = axis.timer - dtSeconds; |
||||
|
|
||||
|
while (timer <= 0) { |
||||
|
goal = drawMicroGoal(goal, rng); |
||||
|
timer += drawMicroTimerDuration(timerMinMax, rng); |
||||
|
} |
||||
|
|
||||
|
const current = springTowardScalar(axis.current, goal, dtSeconds, tauSeconds); |
||||
|
return { goal, timer, current }; |
||||
|
} |
||||
|
|
||||
|
function stepNano( |
||||
|
layer: NanoLayerState, |
||||
|
dtSeconds: number, |
||||
|
config: EyeConfig, |
||||
|
rng: () => number |
||||
|
): NanoLayerState { |
||||
|
let goal = layer.goal; |
||||
|
let timer = layer.timer - dtSeconds; |
||||
|
|
||||
|
while (timer <= 0) { |
||||
|
goal = normalizeVec2({ x: randomSigned(rng), y: randomSigned(rng) }); |
||||
|
timer += randomRange(config.nano.timerMinMax[0], config.nano.timerMinMax[1], rng); |
||||
|
} |
||||
|
|
||||
|
const current = springTowardVec2( |
||||
|
layer.current, |
||||
|
goal, |
||||
|
dtSeconds, |
||||
|
config.springTimeConstantSeconds |
||||
|
); |
||||
|
return { goal, timer, current }; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Initial state per the doc's startup rule ("LookAtMacroOffsetStartup"): |
||||
|
* Macro begins `atLookAt` with `goal`/`current` at rest rather than an |
||||
|
* immediate look-away. Every layer's first timer is still randomly drawn |
||||
|
* (from the same distributions a normal redraw would use) so the three |
||||
|
* layers don't all expire on the same tick. Macro's very first look-at |
||||
|
* timer is seeded using the `idleActive` range — the `AvatarState` store's |
||||
|
* own documented starting value (`avatar-state.ts`) — since no live |
||||
|
* `AvatarState` is available yet at construction time; this only affects |
||||
|
* the first countdown; every countdown after that reads the live state |
||||
|
* passed into `stepEyeGaze`, per that state's own range. |
||||
|
*/ |
||||
|
export function createInitialEyeGazeState(config: EyeConfig, rng: () => number): EyeGazeState { |
||||
|
const [macroMin, macroMax] = config.macro.lookAtLengthMinMaxByState.idleActive; |
||||
|
|
||||
|
return { |
||||
|
macro: { |
||||
|
phase: 'atLookAt', |
||||
|
timer: randomRange(macroMin, macroMax, rng), |
||||
|
goal: { x: 0, y: 0 }, |
||||
|
current: { x: 0, y: 0 } |
||||
|
}, |
||||
|
micro: { |
||||
|
x: { goal: 0, current: 0, timer: drawMicroTimerDuration(config.micro.xTimerMinMax, rng) }, |
||||
|
y: { goal: 0, current: 0, timer: drawMicroTimerDuration(config.micro.yTimerMinMax, rng) } |
||||
|
}, |
||||
|
nano: { |
||||
|
goal: { x: 0, y: 0 }, |
||||
|
current: { x: 0, y: 0 }, |
||||
|
timer: randomRange(config.nano.timerMinMax[0], config.nano.timerMinMax[1], rng) |
||||
|
}, |
||||
|
macroOffsetCm: ZERO, |
||||
|
microOffsetCm: ZERO, |
||||
|
nanoOffsetCm: ZERO, |
||||
|
combinedOffsetCm: ZERO |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Advances all enabled layers by `input.deltaSeconds` and recomputes their |
||||
|
* effective cm-offsets. A disabled layer (`toggles.<layer> === false`) |
||||
|
* *freezes* — its timer/goal/current stop advancing entirely, rather than |
||||
|
* continuing to tick while its offset is silently zeroed — so re-enabling |
||||
|
* it resumes exactly where it left off instead of jumping. Every random |
||||
|
* draw anywhere in this module goes through `rng`; nothing here calls |
||||
|
* `Math.random()` directly, which is what makes this whole stepper |
||||
|
* deterministically testable (`Math.random` is only ever supplied at the |
||||
|
* `client/humanoid/eye/gaze-controller.ts` boundary). |
||||
|
*/ |
||||
|
export function stepEyeGaze( |
||||
|
state: EyeGazeState, |
||||
|
input: EyeGazeInput, |
||||
|
config: EyeConfig, |
||||
|
rng: () => number, |
||||
|
toggles: LayerToggles = {} |
||||
|
): EyeGazeState { |
||||
|
const macroEnabled = toggles.macro ?? true; |
||||
|
const microEnabled = toggles.micro ?? true; |
||||
|
const nanoEnabled = toggles.nano ?? true; |
||||
|
|
||||
|
const macro = macroEnabled |
||||
|
? stepMacro(state.macro, input.deltaSeconds, input.avatarState, config, rng) |
||||
|
: state.macro; |
||||
|
const micro = microEnabled |
||||
|
? { |
||||
|
x: stepMicroAxis( |
||||
|
state.micro.x, |
||||
|
input.deltaSeconds, |
||||
|
config.micro.xTimerMinMax, |
||||
|
config.springTimeConstantSeconds, |
||||
|
rng |
||||
|
), |
||||
|
y: stepMicroAxis( |
||||
|
state.micro.y, |
||||
|
input.deltaSeconds, |
||||
|
config.micro.yTimerMinMax, |
||||
|
config.springTimeConstantSeconds, |
||||
|
rng |
||||
|
) |
||||
|
} |
||||
|
: state.micro; |
||||
|
const nano = nanoEnabled ? stepNano(state.nano, input.deltaSeconds, config, rng) : state.nano; |
||||
|
|
||||
|
const macroOffsetCm = macroEnabled |
||||
|
? macroOffsetFromCurrent(macro.current, config.macro, input.lookAtDistanceCm) |
||||
|
: ZERO; |
||||
|
const microOffsetCm = microEnabled |
||||
|
? microOffsetFromCurrent({ x: micro.x.current, y: micro.y.current }, config.micro) |
||||
|
: ZERO; |
||||
|
const nanoOffsetCm = nanoEnabled ? nanoOffsetFromCurrent(nano.current, config.nano) : ZERO; |
||||
|
|
||||
|
return { |
||||
|
macro, |
||||
|
micro, |
||||
|
nano, |
||||
|
macroOffsetCm, |
||||
|
microOffsetCm, |
||||
|
nanoOffsetCm, |
||||
|
combinedOffsetCm: { |
||||
|
x: macroOffsetCm.x + microOffsetCm.x + nanoOffsetCm.x, |
||||
|
y: macroOffsetCm.y + microOffsetCm.y + nanoOffsetCm.y |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Demo/debug helper: immediately starts a macro look-away (fresh goal, full |
||||
|
* look-away timer), regardless of how much time is left in the current |
||||
|
* `atLookAt` countdown. A no-op while already `lookingAway` — chaining a |
||||
|
* secondary on demand isn't exposed, only triggering the first look-away. |
||||
|
*/ |
||||
|
export function forceMacroLookAway( |
||||
|
state: EyeGazeState, |
||||
|
config: EyeConfig, |
||||
|
rng: () => number |
||||
|
): EyeGazeState { |
||||
|
if (state.macro.phase === 'lookingAway') return state; |
||||
|
|
||||
|
const goal = drawMacroGoal(config.macro, rng); |
||||
|
const timer = randomRange( |
||||
|
config.macro.lookAwayLengthMinMax[0], |
||||
|
config.macro.lookAwayLengthMinMax[1], |
||||
|
rng |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
...state, |
||||
|
macro: { phase: 'lookingAway', timer, goal, current: state.macro.current } |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,4 @@ |
|||||
|
export * from './config.js'; |
||||
|
export * from './curves.js'; |
||||
|
export * from './gaze.js'; |
||||
|
export * from './blend-shapes.js'; |
||||
@ -1,107 +1,17 @@ |
|||||
<script lang="ts"> |
<script lang="ts"> |
||||
import { |
import AvatarStateDemo from './AvatarStateDemo.svelte'; |
||||
ResilientRealtimeClient, |
import EyeSystemDemo from './EyeSystemDemo.svelte'; |
||||
type ConnectionState |
</script> |
||||
} from '$lib/client/core/resilient-client.js'; |
|
||||
import { deriveAvatarState, type AvatarStateStore } from '$lib/client/humanoid/avatar-state.js'; |
|
||||
import type { ServerEvent } from '$lib/core/protocol.js'; |
|
||||
import type { AvatarState } from '$lib/humanoid/state.js'; |
|
||||
|
|
||||
type Status = 'idle' | 'connecting' | 'reconnecting' | 'ready' | 'error' | 'closed'; |
|
||||
|
|
||||
let status = $state<Status>('idle'); |
|
||||
let statusMessage = $state(''); |
|
||||
let avatarState = $state<AvatarState>('idleActive'); |
|
||||
let listening = $state(false); |
|
||||
|
|
||||
let client: ResilientRealtimeClient | null = null; |
|
||||
let stateStore: AvatarStateStore | null = null; |
|
||||
let stopStateSub: (() => void) | null = null; |
|
||||
|
|
||||
async function fetchToken(): Promise<string> { |
|
||||
const res = await fetch('/api/realtime/session', { method: 'POST' }); |
|
||||
if (!res.ok) throw new Error(`Failed to mint session token (${res.status})`); |
|
||||
const { token } = await res.json(); |
|
||||
return token; |
|
||||
} |
|
||||
|
|
||||
async function connect() { |
|
||||
status = 'connecting'; |
|
||||
statusMessage = ''; |
|
||||
try { |
|
||||
const nextClient = new ResilientRealtimeClient({ getToken: fetchToken }); |
|
||||
nextClient.onConnectionState(handleConnectionState); |
|
||||
nextClient.on(handleServerEvent); |
|
||||
await nextClient.connect(); |
|
||||
client = nextClient; |
|
||||
|
|
||||
// See $lib/humanoid — this is the "humanoid" optional package's |
|
||||
// contract layer, not tied to any renderer/UI. deriveAvatarState |
|
||||
// takes the client structurally (RealtimeClient or |
|
||||
// ResilientRealtimeClient both work). |
|
||||
const store = deriveAvatarState(nextClient); |
|
||||
stateStore = store; |
|
||||
stopStateSub = store.subscribe((s) => (avatarState = s)); |
|
||||
} catch (err) { |
|
||||
status = 'error'; |
|
||||
statusMessage = err instanceof Error ? err.message : 'Failed to connect.'; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
function handleConnectionState(connState: ConnectionState) { |
|
||||
if (connState === 'connecting') { |
|
||||
status = 'connecting'; |
|
||||
} else if (connState === 'reconnecting') { |
|
||||
status = 'reconnecting'; |
|
||||
statusMessage = 'Connection lost — reconnecting…'; |
|
||||
} else if (connState === 'closed' && status !== 'error') { |
|
||||
status = 'closed'; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
function handleServerEvent(event: ServerEvent) { |
|
||||
if (event.type === 'session.ready') { |
|
||||
status = 'ready'; |
|
||||
statusMessage = ''; |
|
||||
} else if (event.type === 'error') { |
|
||||
status = 'error'; |
|
||||
statusMessage = event.message; |
|
||||
} else if (event.type === 'session.closed') { |
|
||||
statusMessage = event.reason; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
function toggleListening() { |
<h1>Humanoid animation playground</h1> |
||||
listening = !listening; |
<p>A testing/debugging surface for the <code>humanoid</code> package's animation libraries.</p> |
||||
stateStore?.setListening(listening); |
|
||||
} |
|
||||
|
|
||||
function disconnect() { |
<section> |
||||
client?.send({ type: 'session.end' }); |
<EyeSystemDemo /> |
||||
client?.close(); |
</section> |
||||
client = null; |
|
||||
stopStateSub?.(); |
|
||||
stopStateSub = null; |
|
||||
stateStore?.destroy(); |
|
||||
stateStore = null; |
|
||||
listening = false; |
|
||||
status = 'closed'; |
|
||||
} |
|
||||
</script> |
|
||||
|
|
||||
<h1>Humanoid avatar-state demo</h1> |
<hr /> |
||||
<p> |
|
||||
Exercises <code>$lib/humanoid</code>'s state derivation without a live API key: run |
|
||||
<code>REALTIME_PROVIDER=mock npm run dev</code> (dev-server restart required — see AGENTS.md's HMR |
|
||||
gotcha) and connect below to watch <code>avatarState</code> move through the mock provider's default |
|
||||
fixture. |
|
||||
</p> |
|
||||
<p>Status: {status}{statusMessage ? ` — ${statusMessage}` : ''}</p> |
|
||||
<p>Avatar state: <strong>{avatarState}</strong></p> |
|
||||
|
|
||||
{#if status === 'idle' || status === 'error' || status === 'closed'} |
<section> |
||||
<button onclick={connect}>Connect</button> |
<AvatarStateDemo /> |
||||
{:else if status === 'ready'} |
</section> |
||||
<button onclick={disconnect}>Disconnect</button> |
|
||||
<button onclick={toggleListening}>{listening ? 'Stop listening' : 'Start listening'}</button> |
|
||||
{/if} |
|
||||
|
|||||
@ -0,0 +1,107 @@ |
|||||
|
<script lang="ts"> |
||||
|
import { |
||||
|
ResilientRealtimeClient, |
||||
|
type ConnectionState |
||||
|
} from '$lib/client/core/resilient-client.js'; |
||||
|
import { deriveAvatarState, type AvatarStateStore } from '$lib/client/humanoid/avatar-state.js'; |
||||
|
import type { ServerEvent } from '$lib/core/protocol.js'; |
||||
|
import type { AvatarState } from '$lib/humanoid/state.js'; |
||||
|
|
||||
|
type Status = 'idle' | 'connecting' | 'reconnecting' | 'ready' | 'error' | 'closed'; |
||||
|
|
||||
|
let status = $state<Status>('idle'); |
||||
|
let statusMessage = $state(''); |
||||
|
let avatarState = $state<AvatarState>('idleActive'); |
||||
|
let listening = $state(false); |
||||
|
|
||||
|
let client: ResilientRealtimeClient | null = null; |
||||
|
let stateStore: AvatarStateStore | null = null; |
||||
|
let stopStateSub: (() => void) | null = null; |
||||
|
|
||||
|
async function fetchToken(): Promise<string> { |
||||
|
const res = await fetch('/api/realtime/session', { method: 'POST' }); |
||||
|
if (!res.ok) throw new Error(`Failed to mint session token (${res.status})`); |
||||
|
const { token } = await res.json(); |
||||
|
return token; |
||||
|
} |
||||
|
|
||||
|
async function connect() { |
||||
|
status = 'connecting'; |
||||
|
statusMessage = ''; |
||||
|
try { |
||||
|
const nextClient = new ResilientRealtimeClient({ getToken: fetchToken }); |
||||
|
nextClient.onConnectionState(handleConnectionState); |
||||
|
nextClient.on(handleServerEvent); |
||||
|
await nextClient.connect(); |
||||
|
client = nextClient; |
||||
|
|
||||
|
// See $lib/humanoid — this is the "humanoid" optional package's |
||||
|
// contract layer, not tied to any renderer/UI. deriveAvatarState |
||||
|
// takes the client structurally (RealtimeClient or |
||||
|
// ResilientRealtimeClient both work). |
||||
|
const store = deriveAvatarState(nextClient); |
||||
|
stateStore = store; |
||||
|
stopStateSub = store.subscribe((s) => (avatarState = s)); |
||||
|
} catch (err) { |
||||
|
status = 'error'; |
||||
|
statusMessage = err instanceof Error ? err.message : 'Failed to connect.'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function handleConnectionState(connState: ConnectionState) { |
||||
|
if (connState === 'connecting') { |
||||
|
status = 'connecting'; |
||||
|
} else if (connState === 'reconnecting') { |
||||
|
status = 'reconnecting'; |
||||
|
statusMessage = 'Connection lost — reconnecting…'; |
||||
|
} else if (connState === 'closed' && status !== 'error') { |
||||
|
status = 'closed'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function handleServerEvent(event: ServerEvent) { |
||||
|
if (event.type === 'session.ready') { |
||||
|
status = 'ready'; |
||||
|
statusMessage = ''; |
||||
|
} else if (event.type === 'error') { |
||||
|
status = 'error'; |
||||
|
statusMessage = event.message; |
||||
|
} else if (event.type === 'session.closed') { |
||||
|
statusMessage = event.reason; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function toggleListening() { |
||||
|
listening = !listening; |
||||
|
stateStore?.setListening(listening); |
||||
|
} |
||||
|
|
||||
|
function disconnect() { |
||||
|
client?.send({ type: 'session.end' }); |
||||
|
client?.close(); |
||||
|
client = null; |
||||
|
stopStateSub?.(); |
||||
|
stopStateSub = null; |
||||
|
stateStore?.destroy(); |
||||
|
stateStore = null; |
||||
|
listening = false; |
||||
|
status = 'closed'; |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<h2>Avatar-state demo</h2> |
||||
|
<p> |
||||
|
Exercises <code>$lib/humanoid</code>'s state derivation without a live API key: run |
||||
|
<code>REALTIME_PROVIDER=mock npm run dev</code> (dev-server restart required — see AGENTS.md's HMR |
||||
|
gotcha) and connect below to watch <code>avatarState</code> move through the mock provider's default |
||||
|
fixture. |
||||
|
</p> |
||||
|
<p>Status: {status}{statusMessage ? ` — ${statusMessage}` : ''}</p> |
||||
|
<p>Avatar state: <strong>{avatarState}</strong></p> |
||||
|
|
||||
|
{#if status === 'idle' || status === 'error' || status === 'closed'} |
||||
|
<button onclick={connect}>Connect</button> |
||||
|
{:else if status === 'ready'} |
||||
|
<button onclick={disconnect}>Disconnect</button> |
||||
|
<button onclick={toggleListening}>{listening ? 'Stop listening' : 'Start listening'}</button> |
||||
|
{/if} |
||||
@ -0,0 +1,121 @@ |
|||||
|
<script lang="ts"> |
||||
|
import { onMount } from 'svelte'; |
||||
|
import { |
||||
|
createEyeGazeController, |
||||
|
type EyeGazeStore |
||||
|
} from '$lib/client/humanoid/eye/gaze-controller.js'; |
||||
|
import { gazeOffsetToAngleRad, gazeOffsetToBlendShapes } from '$lib/humanoid/eye/blend-shapes.js'; |
||||
|
import { DEFAULT_EYE_CONFIG } from '$lib/humanoid/eye/config.js'; |
||||
|
import type { EyeGazeState } from '$lib/humanoid/eye/gaze.js'; |
||||
|
import type { AvatarState } from '$lib/humanoid/state.js'; |
||||
|
import ThreeAvatarViewer from './eye-demo/ThreeAvatarViewer.svelte'; |
||||
|
import GazeDebugPanel from './eye-demo/GazeDebugPanel.svelte'; |
||||
|
import GazePlaneVisualizer from './eye-demo/GazePlaneVisualizer.svelte'; |
||||
|
|
||||
|
let controller: EyeGazeStore | null = null; |
||||
|
let gazeState = $state<EyeGazeState | null>(null); |
||||
|
let avatarState = $state<AvatarState>('idleActive'); |
||||
|
let lookAtDistanceCm = $state(80); |
||||
|
let timeScale = $state(1); |
||||
|
let layersEnabled = $state({ macro: true, micro: true, nano: true }); |
||||
|
|
||||
|
onMount(() => { |
||||
|
const eyeController = createEyeGazeController(); |
||||
|
controller = eyeController; |
||||
|
const unsubscribe = eyeController.subscribe((s) => (gazeState = s)); |
||||
|
return () => { |
||||
|
unsubscribe(); |
||||
|
eyeController.destroy(); |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
const frame = $derived( |
||||
|
gazeState |
||||
|
? gazeOffsetToBlendShapes( |
||||
|
gazeState.combinedOffsetCm, |
||||
|
lookAtDistanceCm, |
||||
|
DEFAULT_EYE_CONFIG.maxGazeAngleRad |
||||
|
) |
||||
|
: {} |
||||
|
); |
||||
|
|
||||
|
const angleDeg = $derived.by(() => { |
||||
|
if (!gazeState) return { x: 0, y: 0 }; |
||||
|
const rad = gazeOffsetToAngleRad(gazeState.combinedOffsetCm, lookAtDistanceCm); |
||||
|
return { x: (rad.x * 180) / Math.PI, y: (rad.y * 180) / Math.PI }; |
||||
|
}); |
||||
|
|
||||
|
function handleAvatarStateChange(next: AvatarState) { |
||||
|
avatarState = next; |
||||
|
controller?.setAvatarState(next); |
||||
|
} |
||||
|
function handleLookAtDistanceChange(cm: number) { |
||||
|
lookAtDistanceCm = cm; |
||||
|
controller?.setLookAtDistanceCm(cm); |
||||
|
} |
||||
|
function handleTimeScaleChange(scale: number) { |
||||
|
timeScale = scale; |
||||
|
controller?.setTimeScale(scale); |
||||
|
} |
||||
|
function handleLayerToggle(layer: 'macro' | 'micro' | 'nano', enabled: boolean) { |
||||
|
layersEnabled = { ...layersEnabled, [layer]: enabled }; |
||||
|
controller?.setLayerEnabled(layer, enabled); |
||||
|
} |
||||
|
function handleForceLookAway() { |
||||
|
controller?.forceLookAway(); |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<h2>Eye system demo</h2> |
||||
|
<p> |
||||
|
Exercises <code>$lib/humanoid/eye</code>'s Macro/Micro/Nano gaze layers live, against the bundled |
||||
|
ARKit-rigged test avatar — see <code>docs/HUMANOID.md</code> and |
||||
|
<code>src/lib/humanoid/eye/DESIGN_NOTES.md</code>. |
||||
|
</p> |
||||
|
|
||||
|
{#if gazeState} |
||||
|
<div class="layout"> |
||||
|
<ThreeAvatarViewer {frame} /> |
||||
|
<div class="side"> |
||||
|
<GazeDebugPanel |
||||
|
state={gazeState} |
||||
|
{angleDeg} |
||||
|
{frame} |
||||
|
{avatarState} |
||||
|
{lookAtDistanceCm} |
||||
|
{timeScale} |
||||
|
{layersEnabled} |
||||
|
onAvatarStateChange={handleAvatarStateChange} |
||||
|
onLookAtDistanceChange={handleLookAtDistanceChange} |
||||
|
onTimeScaleChange={handleTimeScaleChange} |
||||
|
onLayerToggle={handleLayerToggle} |
||||
|
onForceLookAway={handleForceLookAway} |
||||
|
/> |
||||
|
<GazePlaneVisualizer |
||||
|
macroOffsetCm={gazeState.macroOffsetCm} |
||||
|
microOffsetCm={gazeState.microOffsetCm} |
||||
|
nanoOffsetCm={gazeState.nanoOffsetCm} |
||||
|
combinedOffsetCm={gazeState.combinedOffsetCm} |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
{/if} |
||||
|
|
||||
|
<style> |
||||
|
.layout { |
||||
|
display: flex; |
||||
|
gap: 1.5rem; |
||||
|
flex-wrap: wrap; |
||||
|
align-items: flex-start; |
||||
|
} |
||||
|
.layout :global(.viewer) { |
||||
|
flex: 1 1 480px; |
||||
|
min-width: 320px; |
||||
|
} |
||||
|
.side { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 1rem; |
||||
|
flex: 1 1 320px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,229 @@ |
|||||
|
<script lang="ts"> |
||||
|
import type { AvatarState } from '$lib/humanoid/state.js'; |
||||
|
import type { AnimationFrame } from '$lib/humanoid/animation.js'; |
||||
|
import type { EyeGazeState } from '$lib/humanoid/eye/gaze.js'; |
||||
|
|
||||
|
interface Vec2 { |
||||
|
x: number; |
||||
|
y: number; |
||||
|
} |
||||
|
|
||||
|
interface LayersEnabled { |
||||
|
macro: boolean; |
||||
|
micro: boolean; |
||||
|
nano: boolean; |
||||
|
} |
||||
|
|
||||
|
interface Props { |
||||
|
state: EyeGazeState; |
||||
|
angleDeg: Vec2; |
||||
|
/** The exact frame handed to the Three.js viewer — shown raw so the eyeLookIn/Out sign convention can be checked without guessing from pixels. */ |
||||
|
frame: AnimationFrame; |
||||
|
avatarState: AvatarState; |
||||
|
lookAtDistanceCm: number; |
||||
|
timeScale: number; |
||||
|
layersEnabled: LayersEnabled; |
||||
|
onAvatarStateChange: (state: AvatarState) => void; |
||||
|
onLookAtDistanceChange: (cm: number) => void; |
||||
|
onTimeScaleChange: (scale: number) => void; |
||||
|
onLayerToggle: (layer: keyof LayersEnabled, enabled: boolean) => void; |
||||
|
onForceLookAway: () => void; |
||||
|
} |
||||
|
|
||||
|
let { |
||||
|
state, |
||||
|
angleDeg, |
||||
|
frame, |
||||
|
avatarState, |
||||
|
lookAtDistanceCm, |
||||
|
timeScale, |
||||
|
layersEnabled, |
||||
|
onAvatarStateChange, |
||||
|
onLookAtDistanceChange, |
||||
|
onTimeScaleChange, |
||||
|
onLayerToggle, |
||||
|
onForceLookAway |
||||
|
}: Props = $props(); |
||||
|
|
||||
|
const frameEntries = $derived(Object.entries(frame) as [string, number][]); |
||||
|
|
||||
|
const AVATAR_STATES: AvatarState[] = [ |
||||
|
'idleActive', |
||||
|
'idlePassive', |
||||
|
'listening', |
||||
|
'thinking', |
||||
|
'talking' |
||||
|
]; |
||||
|
|
||||
|
function fmt(value: number): string { |
||||
|
return value.toFixed(2); |
||||
|
} |
||||
|
|
||||
|
function handleAvatarStateChange(e: Event & { currentTarget: HTMLSelectElement }) { |
||||
|
onAvatarStateChange(e.currentTarget.value as AvatarState); |
||||
|
} |
||||
|
|
||||
|
function handleDistanceInput(e: Event & { currentTarget: HTMLInputElement }) { |
||||
|
onLookAtDistanceChange(Number(e.currentTarget.value)); |
||||
|
} |
||||
|
|
||||
|
function handleTimeScaleInput(e: Event & { currentTarget: HTMLInputElement }) { |
||||
|
onTimeScaleChange(Number(e.currentTarget.value)); |
||||
|
} |
||||
|
|
||||
|
function handleLayerToggle(layer: keyof LayersEnabled) { |
||||
|
return (e: Event & { currentTarget: HTMLInputElement }) => |
||||
|
onLayerToggle(layer, e.currentTarget.checked); |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div class="panel"> |
||||
|
<div class="row"> |
||||
|
<label> |
||||
|
Avatar state |
||||
|
<select value={avatarState} onchange={handleAvatarStateChange}> |
||||
|
{#each AVATAR_STATES as s (s)} |
||||
|
<option value={s}>{s}</option> |
||||
|
{/each} |
||||
|
</select> |
||||
|
</label> |
||||
|
|
||||
|
<label> |
||||
|
Look-at distance (cm) |
||||
|
<input |
||||
|
type="number" |
||||
|
min="1" |
||||
|
max="600" |
||||
|
value={lookAtDistanceCm} |
||||
|
oninput={handleDistanceInput} |
||||
|
/> |
||||
|
</label> |
||||
|
|
||||
|
<label> |
||||
|
Time scale ({timeScale.toFixed(2)}x) |
||||
|
<input |
||||
|
type="range" |
||||
|
min="0" |
||||
|
max="10" |
||||
|
step="0.1" |
||||
|
value={timeScale} |
||||
|
oninput={handleTimeScaleInput} |
||||
|
/> |
||||
|
</label> |
||||
|
|
||||
|
<button type="button" onclick={onForceLookAway} disabled={state.macro.phase === 'lookingAway'}> |
||||
|
Force look-away |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
<div class="row"> |
||||
|
<label class="checkbox"> |
||||
|
<input type="checkbox" checked={layersEnabled.macro} onchange={handleLayerToggle('macro')} /> |
||||
|
Macro |
||||
|
</label> |
||||
|
<label class="checkbox"> |
||||
|
<input type="checkbox" checked={layersEnabled.micro} onchange={handleLayerToggle('micro')} /> |
||||
|
Micro |
||||
|
</label> |
||||
|
<label class="checkbox"> |
||||
|
<input type="checkbox" checked={layersEnabled.nano} onchange={handleLayerToggle('nano')} /> |
||||
|
Nano |
||||
|
</label> |
||||
|
</div> |
||||
|
|
||||
|
<table> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>Layer</th> |
||||
|
<th>x (cm)</th> |
||||
|
<th>y (cm)</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
<tr> |
||||
|
<td>Macro</td> |
||||
|
<td>{fmt(state.macroOffsetCm.x)}</td> |
||||
|
<td>{fmt(state.macroOffsetCm.y)}</td> |
||||
|
</tr> |
||||
|
<tr> |
||||
|
<td>Micro</td> |
||||
|
<td>{fmt(state.microOffsetCm.x)}</td> |
||||
|
<td>{fmt(state.microOffsetCm.y)}</td> |
||||
|
</tr> |
||||
|
<tr> |
||||
|
<td>Nano</td> |
||||
|
<td>{fmt(state.nanoOffsetCm.x)}</td> |
||||
|
<td>{fmt(state.nanoOffsetCm.y)}</td> |
||||
|
</tr> |
||||
|
<tr> |
||||
|
<td><strong>Combined</strong></td> |
||||
|
<td>{fmt(state.combinedOffsetCm.x)}</td> |
||||
|
<td>{fmt(state.combinedOffsetCm.y)}</td> |
||||
|
</tr> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
|
||||
|
<p class="meta"> |
||||
|
Macro phase: <strong>{state.macro.phase}</strong> · Gaze angle: {fmt(angleDeg.x)}°, {fmt( |
||||
|
angleDeg.y |
||||
|
)}° |
||||
|
</p> |
||||
|
|
||||
|
<p class="meta">Active blend shapes:</p> |
||||
|
{#if frameEntries.length === 0} |
||||
|
<p class="meta"><em>none</em></p> |
||||
|
{:else} |
||||
|
<ul class="blend-shapes"> |
||||
|
{#each frameEntries as [name, weight] (name)} |
||||
|
<li><code>{name}</code>: {weight.toFixed(3)}</li> |
||||
|
{/each} |
||||
|
</ul> |
||||
|
{/if} |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.panel { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 0.75rem; |
||||
|
font-family: sans-serif; |
||||
|
font-size: 0.85rem; |
||||
|
} |
||||
|
.row { |
||||
|
display: flex; |
||||
|
flex-wrap: wrap; |
||||
|
gap: 1rem; |
||||
|
align-items: center; |
||||
|
} |
||||
|
label { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 0.2rem; |
||||
|
} |
||||
|
label.checkbox { |
||||
|
flex-direction: row; |
||||
|
align-items: center; |
||||
|
gap: 0.35rem; |
||||
|
} |
||||
|
table { |
||||
|
border-collapse: collapse; |
||||
|
} |
||||
|
th, |
||||
|
td { |
||||
|
border: 1px solid #ddd; |
||||
|
padding: 0.25rem 0.5rem; |
||||
|
text-align: right; |
||||
|
} |
||||
|
th:first-child, |
||||
|
td:first-child { |
||||
|
text-align: left; |
||||
|
} |
||||
|
.meta { |
||||
|
margin: 0; |
||||
|
color: #555; |
||||
|
} |
||||
|
.blend-shapes { |
||||
|
margin: 0; |
||||
|
padding-left: 1.2rem; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,101 @@ |
|||||
|
<script lang="ts"> |
||||
|
interface Vec2 { |
||||
|
x: number; |
||||
|
y: number; |
||||
|
} |
||||
|
|
||||
|
interface Props { |
||||
|
macroOffsetCm: Vec2; |
||||
|
microOffsetCm: Vec2; |
||||
|
nanoOffsetCm: Vec2; |
||||
|
combinedOffsetCm: Vec2; |
||||
|
} |
||||
|
|
||||
|
let { macroOffsetCm, microOffsetCm, nanoOffsetCm, combinedOffsetCm }: Props = $props(); |
||||
|
|
||||
|
/** Half-width (cm) of the plotted plane — comfortably covers Macro's default ±20/±15cm strength. */ |
||||
|
const RANGE_CM = 30; |
||||
|
</script> |
||||
|
|
||||
|
<svg |
||||
|
viewBox="{-RANGE_CM} {-RANGE_CM} {RANGE_CM * 2} {RANGE_CM * 2}" |
||||
|
class="plane" |
||||
|
role="img" |
||||
|
aria-label="Gaze offset plane — Macro, Micro, Nano, and their combined offset" |
||||
|
> |
||||
|
<line x1={-RANGE_CM} y1="0" x2={RANGE_CM} y2="0" class="axis" /> |
||||
|
<line x1="0" y1={-RANGE_CM} x2="0" y2={RANGE_CM} class="axis" /> |
||||
|
|
||||
|
<!-- SVG y grows downward — negate so "up" (positive y) plots visually up. --> |
||||
|
<circle cx={macroOffsetCm.x} cy={-macroOffsetCm.y} r="1.2" class="macro" /> |
||||
|
<circle cx={microOffsetCm.x} cy={-microOffsetCm.y} r="1" class="micro" /> |
||||
|
<circle cx={nanoOffsetCm.x} cy={-nanoOffsetCm.y} r="0.6" class="nano" /> |
||||
|
<circle cx={combinedOffsetCm.x} cy={-combinedOffsetCm.y} r="1.6" class="combined" /> |
||||
|
</svg> |
||||
|
|
||||
|
<ul class="legend"> |
||||
|
<li class="macro">Macro</li> |
||||
|
<li class="micro">Micro</li> |
||||
|
<li class="nano">Nano</li> |
||||
|
<li class="combined">Combined</li> |
||||
|
</ul> |
||||
|
|
||||
|
<style> |
||||
|
.plane { |
||||
|
width: 220px; |
||||
|
height: 220px; |
||||
|
background: #f4f4f6; |
||||
|
border: 1px solid #ccc; |
||||
|
border-radius: 4px; |
||||
|
} |
||||
|
.axis { |
||||
|
stroke: #ccc; |
||||
|
stroke-width: 0.3; |
||||
|
} |
||||
|
circle { |
||||
|
stroke: #000; |
||||
|
stroke-width: 0.15; |
||||
|
} |
||||
|
.macro { |
||||
|
fill: #3b82f6; |
||||
|
} |
||||
|
.micro { |
||||
|
fill: #22c55e; |
||||
|
} |
||||
|
.nano { |
||||
|
fill: #eab308; |
||||
|
} |
||||
|
.combined { |
||||
|
fill: #ef4444; |
||||
|
} |
||||
|
.legend { |
||||
|
list-style: none; |
||||
|
display: flex; |
||||
|
gap: 0.75rem; |
||||
|
padding: 0; |
||||
|
margin: 0.5rem 0 0; |
||||
|
font-size: 0.8rem; |
||||
|
font-family: sans-serif; |
||||
|
} |
||||
|
.legend li::before { |
||||
|
content: ''; |
||||
|
display: inline-block; |
||||
|
width: 0.6rem; |
||||
|
height: 0.6rem; |
||||
|
border-radius: 50%; |
||||
|
margin-right: 0.3rem; |
||||
|
vertical-align: middle; |
||||
|
} |
||||
|
.legend .macro::before { |
||||
|
background: #3b82f6; |
||||
|
} |
||||
|
.legend .micro::before { |
||||
|
background: #22c55e; |
||||
|
} |
||||
|
.legend .nano::before { |
||||
|
background: #eab308; |
||||
|
} |
||||
|
.legend .combined::before { |
||||
|
background: #ef4444; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,156 @@ |
|||||
|
<script lang="ts"> |
||||
|
import * as THREE from 'three'; |
||||
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; |
||||
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; |
||||
|
import type { Attachment } from 'svelte/attachments'; |
||||
|
import type { AnimationFrame } from '$lib/humanoid/animation.js'; |
||||
|
|
||||
|
interface Props { |
||||
|
/** The composited eye-system output for the current tick — applied to the model's morph targets every time it changes. */ |
||||
|
frame: AnimationFrame; |
||||
|
} |
||||
|
|
||||
|
let { frame }: Props = $props(); |
||||
|
|
||||
|
let loading = $state(true); |
||||
|
let loadError = $state<string | null>(null); |
||||
|
|
||||
|
/** |
||||
|
* Sets every known morph target's influence from `current`, defaulting to |
||||
|
* 0 for any target the sparse frame doesn't mention — so a channel that |
||||
|
* was driven last frame and isn't this frame doesn't stay stuck on. |
||||
|
*/ |
||||
|
function applyFrame(meshes: THREE.Mesh[], current: AnimationFrame): void { |
||||
|
for (const mesh of meshes) { |
||||
|
const dictionary = mesh.morphTargetDictionary; |
||||
|
const influences = mesh.morphTargetInfluences; |
||||
|
if (!dictionary || !influences) continue; |
||||
|
for (const [name, index] of Object.entries(dictionary)) { |
||||
|
influences[index] = current[name as keyof AnimationFrame] ?? 0; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Owns the whole Three.js scene lifecycle against the attached container |
||||
|
* element — setup runs once, the returned callback tears everything down |
||||
|
* when the element is removed. A nested `$effect` re-applies `frame` |
||||
|
* independently of that one-time setup (see {@attach} docs' canvas |
||||
|
* example for this exact "outer effect only runs once, inner effect |
||||
|
* tracks reactive state" pattern). |
||||
|
*/ |
||||
|
const setupViewer: Attachment<HTMLDivElement> = (container) => { |
||||
|
let disposed = false; |
||||
|
let morphMeshes: THREE.Mesh[] = []; |
||||
|
|
||||
|
const scene = new THREE.Scene(); |
||||
|
scene.background = new THREE.Color(0x1a1a1f); |
||||
|
|
||||
|
const camera = new THREE.PerspectiveCamera(35, 1, 0.01, 100); |
||||
|
camera.position.set(0, 1.6, 0.6); |
||||
|
|
||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true }); |
||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
||||
|
container.appendChild(renderer.domElement); |
||||
|
|
||||
|
const controls = new OrbitControls(camera, renderer.domElement); |
||||
|
controls.target.set(0, 1.55, 0); |
||||
|
controls.enableDamping = true; |
||||
|
|
||||
|
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.2)); |
||||
|
const keyLight = new THREE.DirectionalLight(0xffffff, 1.5); |
||||
|
keyLight.position.set(1, 2, 2); |
||||
|
scene.add(keyLight); |
||||
|
|
||||
|
function resize(): void { |
||||
|
const width = container.clientWidth; |
||||
|
const height = container.clientHeight; |
||||
|
if (width === 0 || height === 0) return; |
||||
|
camera.aspect = width / height; |
||||
|
camera.updateProjectionMatrix(); |
||||
|
renderer.setSize(width, height); |
||||
|
} |
||||
|
resize(); |
||||
|
const resizeObserver = new ResizeObserver(resize); |
||||
|
resizeObserver.observe(container); |
||||
|
|
||||
|
new GLTFLoader().load( |
||||
|
'/models/avatar.glb', |
||||
|
(gltf) => { |
||||
|
if (disposed) return; |
||||
|
scene.add(gltf.scene); |
||||
|
const found: THREE.Mesh[] = []; |
||||
|
gltf.scene.traverse((child) => { |
||||
|
if (child instanceof THREE.Mesh && child.morphTargetDictionary) found.push(child); |
||||
|
}); |
||||
|
morphMeshes = found; |
||||
|
applyFrame(morphMeshes, frame); |
||||
|
loading = false; |
||||
|
}, |
||||
|
undefined, |
||||
|
(err) => { |
||||
|
loadError = err instanceof Error ? err.message : 'Failed to load /models/avatar.glb'; |
||||
|
loading = false; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
let frameId = requestAnimationFrame(function animate() { |
||||
|
controls.update(); |
||||
|
renderer.render(scene, camera); |
||||
|
frameId = requestAnimationFrame(animate); |
||||
|
}); |
||||
|
|
||||
|
$effect(() => { |
||||
|
applyFrame(morphMeshes, frame); |
||||
|
}); |
||||
|
|
||||
|
return () => { |
||||
|
disposed = true; |
||||
|
cancelAnimationFrame(frameId); |
||||
|
resizeObserver.disconnect(); |
||||
|
controls.dispose(); |
||||
|
renderer.dispose(); |
||||
|
renderer.domElement.remove(); |
||||
|
scene.traverse((child) => { |
||||
|
if (child instanceof THREE.Mesh) { |
||||
|
child.geometry.dispose(); |
||||
|
const materials = Array.isArray(child.material) ? child.material : [child.material]; |
||||
|
for (const material of materials) material.dispose(); |
||||
|
} |
||||
|
}); |
||||
|
}; |
||||
|
}; |
||||
|
</script> |
||||
|
|
||||
|
<div class="viewer" {@attach setupViewer}> |
||||
|
{#if loading} |
||||
|
<p class="overlay">Loading avatar…</p> |
||||
|
{:else if loadError} |
||||
|
<p class="overlay error">{loadError}</p> |
||||
|
{/if} |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.viewer { |
||||
|
position: relative; |
||||
|
width: 100%; |
||||
|
height: 480px; |
||||
|
background: #1a1a1f; |
||||
|
border-radius: 4px; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
.overlay { |
||||
|
position: absolute; |
||||
|
inset: 0; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
margin: 0; |
||||
|
color: #eee; |
||||
|
font-family: sans-serif; |
||||
|
pointer-events: none; |
||||
|
} |
||||
|
.error { |
||||
|
color: #ff8080; |
||||
|
} |
||||
|
</style> |
||||
Binary file not shown.
Loading…
Reference in new issue