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"> |
|||
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; |
|||
} |
|||
} |
|||
import AvatarStateDemo from './AvatarStateDemo.svelte'; |
|||
import EyeSystemDemo from './EyeSystemDemo.svelte'; |
|||
</script> |
|||
|
|||
function toggleListening() { |
|||
listening = !listening; |
|||
stateStore?.setListening(listening); |
|||
} |
|||
<h1>Humanoid animation playground</h1> |
|||
<p>A testing/debugging surface for the <code>humanoid</code> package's animation libraries.</p> |
|||
|
|||
function disconnect() { |
|||
client?.send({ type: 'session.end' }); |
|||
client?.close(); |
|||
client = null; |
|||
stopStateSub?.(); |
|||
stopStateSub = null; |
|||
stateStore?.destroy(); |
|||
stateStore = null; |
|||
listening = false; |
|||
status = 'closed'; |
|||
} |
|||
</script> |
|||
<section> |
|||
<EyeSystemDemo /> |
|||
</section> |
|||
|
|||
<h1>Humanoid avatar-state demo</h1> |
|||
<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> |
|||
<hr /> |
|||
|
|||
{#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} |
|||
<section> |
|||
<AvatarStateDemo /> |
|||
</section> |
|||
|
|||
@ -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