From 20deae2a0f199e3b0b152b89b4c04fad0a472f75 Mon Sep 17 00:00:00 2001 From: Kartios Date: Wed, 19 Aug 2026 03:07:40 -0700 Subject: [PATCH] feat(actors): build articulated dog v2 --- src/actors/sceneActor.ts | 23 +- src/assets/actors/dog.ts | 399 +++++++++++++++++++++++++++------- src/assets/actors/index.ts | 5 + src/interiors/officeWalker.ts | 42 +++- src/realtime/scenePeers.ts | 19 +- src/test/dogAssetV2.test.ts | 159 ++++++++++++++ src/test/officeWalker.test.ts | 32 +++ src/test/sceneActor.test.ts | 25 +++ src/test/scenePeers.test.ts | 15 ++ 9 files changed, 615 insertions(+), 104 deletions(-) create mode 100644 src/test/dogAssetV2.test.ts diff --git a/src/actors/sceneActor.ts b/src/actors/sceneActor.ts index 8acace1..191a550 100644 --- a/src/actors/sceneActor.ts +++ b/src/actors/sceneActor.ts @@ -11,12 +11,12 @@ import { buildCrow, buildDog, buildHumanoid, + dogPoseStateForSpeed, disposeCrow, disposeDog, disposeHumanoid, poseCrow, - poseDogAttention, - poseDogWalk, + poseDog, poseHumanoid, type CrowRig, type DogRig, @@ -200,7 +200,7 @@ export function createSceneActor(options: SceneActorOptions): SceneActor { let faceTexture: THREE.Texture | null = null; let disposed = false; - function sync(): void { + function sync(elapsedSeconds = 0, previousSpeedMps?: number): void { const state = controller.state(); root.position.set( origin.x + state.x * scale, @@ -217,8 +217,18 @@ export function createSceneActor(options: SceneActorOptions): SceneActor { if (actor.kind === "humanoid") { poseHumanoid(actor.rig, { walkPhase: state.posePhase, stride: state.poseAmount * 0.68 }); } else if (actor.kind === "dog") { - poseDogWalk(actor.rig, state.posePhase, state.poseAmount * 0.68); - poseDogAttention(actor.rig, 0, state.posePhase * 0.7); + const dogState = dogPoseStateForSpeed(state.speedMps); + const acceleration = elapsedSeconds > 0 && previousSpeedMps !== undefined + ? THREE.MathUtils.clamp((state.speedMps - previousSpeedMps) / elapsedSeconds / 5, -1, 1) + : 0; + poseDog(actor.rig, { + state: dogState, + phase: state.posePhase, + amount: dogState === "idle" ? 1 : THREE.MathUtils.clamp(0.38 + state.poseAmount * 0.62, 0, 1), + turn: desired.turn, + acceleration, + attentionYaw: -desired.turn * 0.08, + }); } else if (state.mode === "flight") { // The controller names the aerodynamic state; the rig owns how that // state bends shoulder, elbow, wrist, tail and feet. @@ -267,9 +277,10 @@ export function createSceneActor(options: SceneActorOptions): SceneActor { tick(elapsedSeconds) { if (!disposed && enabled) { const beforeKind = controller.state().kind; + const previousSpeedMps = controller.state().speedMps; controller.tick(elapsedSeconds, desired); if (controller.state().kind !== beforeKind) replaceActor(controller.state().kind); - else sync(); + else sync(Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0, previousSpeedMps); clearEdges(); } return controller.snapshot(); diff --git a/src/assets/actors/dog.ts b/src/assets/actors/dog.ts index 61f2095..6902ca7 100644 --- a/src/assets/actors/dog.ts +++ b/src/assets/actors/dog.ts @@ -1,4 +1,10 @@ -/** A compact, friendly anonymous office dog with a poseable head, legs and tail. */ +/** + * A metre-scale, original procedural dog built for readable third-person motion. + * + * The hierarchy intentionally separates shoulder/hip, upper leg, lower leg and + * paw. Presentation can therefore distinguish a planted walk, diagonal trot and + * gathered run without changing renderer-independent actor or replay state. + */ import * as THREE from "three"; import { actorMesh, @@ -8,7 +14,27 @@ import { type ActorRigBase, } from "./common.ts"; -export const DOG_METRICS = { length: 0.76, shoulderHeight: 0.46, height: 0.74 } as const; +export const DOG_METRICS = { + length: 1.25, + shoulderHeight: 0.54, + height: 0.94, +} as const; + +export type DogPoseState = "idle" | "attention" | "walk" | "trot" | "run"; + +/** Renderer-only values. All numbers are clamped and the pose is deterministic. */ +export interface DogPose { + state: DogPoseState; + phase?: number; + /** Overall pose intensity, from still (0) to full expression (1). */ + amount?: number; + /** Signed turn/lean request, -1 left to 1 right. */ + turn?: number; + /** Signed start/stop impulse, -1 braking to 1 accelerating. */ + acceleration?: number; + attentionYaw?: number; + attentionPitch?: number; +} export interface DogMaterials { coat: THREE.Material; @@ -24,12 +50,26 @@ export interface DogBuildOptions { collarColor?: THREE.ColorRepresentation; } +export interface DogLegJoints { + upper: THREE.Group; + lower: THREE.Group; + paw: THREE.Group; +} + export interface DogJoints { body: THREE.Group; + chest: THREE.Group; + neck: THREE.Group; head: THREE.Group; earLeft: THREE.Group; earRight: THREE.Group; tail: THREE.Group; + tailTip: THREE.Group; + frontLeft: DogLegJoints; + frontRight: DogLegJoints; + rearLeft: DogLegJoints; + rearRight: DogLegJoints; + /** Backwards-compatible aliases for the four original one-piece leg joints. */ legFrontLeft: THREE.Group; legFrontRight: THREE.Group; legRearLeft: THREE.Group; @@ -42,145 +82,336 @@ export interface DogRig extends ActorRigBase { export function createDogMaterials(options: DogBuildOptions = {}): DogMaterials { return { - coat: new THREE.MeshStandardMaterial({ name: "dog.coat", color: options.coatColor ?? 0x9a673d, roughness: 0.92 }), - markings: new THREE.MeshStandardMaterial({ name: "dog.markings", color: options.markingsColor ?? 0xe4c9a2, roughness: 0.95 }), - nose: new THREE.MeshStandardMaterial({ name: "dog.nose", color: 0x151719, roughness: 0.72 }), - collar: new THREE.MeshStandardMaterial({ name: "dog.collar", color: options.collarColor ?? 0x2d92a7, roughness: 0.55 }), + coat: new THREE.MeshStandardMaterial({ + name: "dog.coat", + color: options.coatColor ?? 0x282b2d, + roughness: 0.84, + metalness: 0.02, + }), + markings: new THREE.MeshStandardMaterial({ + name: "dog.markings", + color: options.markingsColor ?? 0xc7ad88, + roughness: 0.92, + }), + nose: new THREE.MeshStandardMaterial({ name: "dog.nose", color: 0x0b0d0e, roughness: 0.62 }), + collar: new THREE.MeshStandardMaterial({ + name: "dog.collar", + color: options.collarColor ?? 0xd69a35, + roughness: 0.48, + metalness: 0.08, + }), + }; +} + +function leg(root: THREE.Group, name: string): DogLegJoints { + return { + upper: requireGroup(root, `dog.leg.${name}.upper`), + lower: requireGroup(root, `dog.leg.${name}.lower`), + paw: requireGroup(root, `dog.leg.${name}.paw`), }; } function resolveDog(root: THREE.Group, ownsMaterials: boolean): DogRig { + const frontLeft = leg(root, "front.left"); + const frontRight = leg(root, "front.right"); + const rearLeft = leg(root, "rear.left"); + const rearRight = leg(root, "rear.right"); return { root, ownsMaterials, joints: { body: requireGroup(root, "dog.body"), + chest: requireGroup(root, "dog.chest"), + neck: requireGroup(root, "dog.neck"), head: requireGroup(root, "dog.head"), earLeft: requireGroup(root, "dog.ear.left"), earRight: requireGroup(root, "dog.ear.right"), tail: requireGroup(root, "dog.tail"), - legFrontLeft: requireGroup(root, "dog.leg.front.left"), - legFrontRight: requireGroup(root, "dog.leg.front.right"), - legRearLeft: requireGroup(root, "dog.leg.rear.left"), - legRearRight: requireGroup(root, "dog.leg.rear.right"), + tailTip: requireGroup(root, "dog.tail.tip"), + frontLeft, + frontRight, + rearLeft, + rearRight, + legFrontLeft: frontLeft.upper, + legFrontRight: frontRight.upper, + legRearLeft: rearLeft.upper, + legRearRight: rearRight.upper, }, }; } +function addLeg( + body: THREE.Group, + m: DogMaterials, + fore: "front" | "rear", + side: "left" | "right", + x: number, + z: number, +): void { + const name = `${fore}.${side}`; + const upperLength = fore === "front" ? 0.16 : 0.17; + const lowerLength = fore === "front" ? 0.18 : 0.17; + const upper = namedGroup(`dog.leg.${name}.upper`, [x, fore === "front" ? -0.055 : -0.035, z]); + body.add(upper); + upper.add(actorMesh(`dog.leg.${name}.upper-mesh`, new THREE.CapsuleGeometry(0.047, upperLength - 0.08, 3, 7), m.coat, { + position: [0, -upperLength * 0.5, 0], + scale: fore === "front" ? [0.9, 1, 0.9] : [1.15, 1.04, 1.08], + })); + + const lower = namedGroup(`dog.leg.${name}.lower`, [0, -upperLength, 0]); + upper.add(lower); + lower.add(actorMesh(`dog.leg.${name}.lower-mesh`, new THREE.CapsuleGeometry(0.039, lowerLength - 0.07, 3, 7), m.coat, { + position: [0, -lowerLength * 0.5, 0], + scale: fore === "front" ? [0.9, 1, 0.88] : [1, 1, 0.9], + })); + + const paw = namedGroup(`dog.leg.${name}.paw`, [0, -lowerLength, 0]); + lower.add(paw); + paw.add(actorMesh(`dog.paw.${name}`, new THREE.SphereGeometry(0.058, 8, 6), m.markings, { + position: [0, -0.018, -0.026], + scale: [0.88, 0.46, 1.38], + })); +} + export function buildDog(options: DogBuildOptions = {}): DogRig { const m = options.materials ?? createDogMaterials(options); const root = namedGroup("dog"); root.userData.kind = "actor"; root.userData.actorType = "anonymous-dog"; root.userData.forwardAxis = "-Z"; - const body = namedGroup("dog.body", [0, 0.39, 0.04]); + root.userData.rigVersion = 2; + root.userData.pose = "idle" satisfies DogPoseState; + + const body = namedGroup("dog.body", [0, 0.425, 0.045]); root.add(body); body.add( - actorMesh("dog.torso", new THREE.CapsuleGeometry(0.18, 0.34, 5, 10), m.coat, { + actorMesh("dog.torso", new THREE.CapsuleGeometry(0.205, 0.43, 5, 11), m.coat, { rotation: [Math.PI / 2, 0, 0], - scale: [0.82, 1, 0.92], + scale: [0.82, 1, 0.9], }), - actorMesh("dog.chest", new THREE.SphereGeometry(0.185, 12, 9), m.markings, { - position: [0, 0.01, -0.17], - scale: [0.72, 1, 0.56], + actorMesh("dog.loin", new THREE.SphereGeometry(0.19, 11, 8), m.coat, { + position: [0, -0.025, 0.245], + scale: [0.78, 0.8, 1.05], + }), + actorMesh("dog.shoulder-mass", new THREE.SphereGeometry(0.205, 11, 8), m.coat, { + position: [0, 0.015, -0.205], + scale: [0.92, 1.12, 0.78], + }), + actorMesh("dog.hip-mass", new THREE.SphereGeometry(0.195, 11, 8), m.coat, { + position: [0, 0.01, 0.255], + scale: [0.9, 1.04, 0.82], }), ); - const head = namedGroup("dog.head", [0, 0.16, -0.32]); - body.add(head); - head.add( - actorMesh("dog.skull", new THREE.SphereGeometry(0.16, 12, 10), m.coat, { scale: [0.82, 0.92, 0.88] }), - actorMesh("dog.muzzle", new THREE.SphereGeometry(0.105, 12, 8), m.markings, { - position: [0, -0.045, -0.13], - scale: [0.8, 0.62, 1], + const chest = namedGroup("dog.chest", [0, 0.01, -0.225]); + body.add(chest); + chest.add(actorMesh("dog.chest-marking", new THREE.SphereGeometry(0.175, 11, 8), m.markings, { + position: [0, -0.035, -0.045], + scale: [0.62, 1.04, 0.52], + })); + + const neck = namedGroup("dog.neck", [0, 0.105, -0.31]); + body.add(neck); + neck.rotation.x = -0.24; + neck.add( + actorMesh("dog.neck-mesh", new THREE.CapsuleGeometry(0.125, 0.13, 4, 9), m.coat, { + position: [0, 0.085, -0.018], + scale: [0.92, 1, 0.92], }), - actorMesh("dog.nose", new THREE.SphereGeometry(0.045, 10, 7), m.nose, { - position: [0, -0.035, -0.224], - scale: [1.15, 0.72, 0.7], - }), - actorMesh("dog.collar", new THREE.TorusGeometry(0.118, 0.016, 6, 18), m.collar, { - position: [0, -0.11, 0.1], + actorMesh("dog.collar", new THREE.TorusGeometry(0.128, 0.014, 6, 18), m.collar, { + position: [0, 0.035, -0.005], rotation: [Math.PI / 2, 0, 0], - scale: [1, 0.82, 1], + scale: [1, 0.9, 1], + }), + actorMesh("dog.tag", new THREE.OctahedronGeometry(0.032, 0), m.collar, { + position: [0, -0.005, -0.135], + rotation: [0.2, 0, Math.PI / 4], + }), + ); + + const head = namedGroup("dog.head", [0, 0.17, -0.045]); + neck.add(head); + head.add( + actorMesh("dog.skull", new THREE.SphereGeometry(0.155, 12, 9), m.coat, { + scale: [0.8, 0.94, 0.92], + }), + actorMesh("dog.brow", new THREE.SphereGeometry(0.12, 10, 7), m.coat, { + position: [0, 0.035, -0.1], + scale: [0.9, 0.55, 0.72], + }), + actorMesh("dog.muzzle", new THREE.SphereGeometry(0.105, 11, 8), m.markings, { + position: [0, -0.05, -0.145], + scale: [0.8, 0.62, 1.15], + }), + actorMesh("dog.nose", new THREE.SphereGeometry(0.044, 9, 6), m.nose, { + position: [0, -0.035, -0.247], + scale: [1.18, 0.72, 0.72], }), ); - // Slightly proud of the skull so the chase camera gets a readable gaze - // instead of a blank mask. The tiny warm catchlights remain visible against - // every supported coat without introducing another material or texture. for (const side of [-1, 1] as const) { const word = side < 0 ? "left" : "right"; head.add( - actorMesh(`dog.eye.${word}`, new THREE.SphereGeometry(0.023, 8, 6), m.nose, { - position: [side * 0.075, 0.025, -0.128], - scale: [0.85, 1, 0.58], + actorMesh(`dog.eye.${word}`, new THREE.SphereGeometry(0.022, 8, 6), m.nose, { + position: [side * 0.073, 0.025, -0.128], + scale: [0.82, 1, 0.62], receiveShadow: false, }), - actorMesh(`dog.eye-catchlight.${word}`, new THREE.SphereGeometry(0.006, 6, 4), m.markings, { - position: [side * 0.079, 0.031, -0.145], + actorMesh(`dog.eye-catchlight.${word}`, new THREE.SphereGeometry(0.0055, 6, 4), m.markings, { + position: [side * 0.076, 0.031, -0.145], receiveShadow: false, }), ); - } - for (const side of [-1, 1] as const) { - const word = side < 0 ? "left" : "right"; - const ear = namedGroup(`dog.ear.${word}`, [side * 0.1, 0.105, -0.015]); + const ear = namedGroup(`dog.ear.${word}`, [side * 0.105, 0.095, -0.005]); head.add(ear); - ear.add( - actorMesh(`dog.ear-flap.${word}`, new THREE.ConeGeometry(0.075, 0.19, 5), m.coat, { - position: [side * 0.015, -0.065, 0.02], - rotation: [0.18, 0, side * 0.28], - }), - ); + ear.add(actorMesh(`dog.ear-flap.${word}`, new THREE.ConeGeometry(0.073, 0.19, 5), m.coat, { + position: [side * 0.012, 0.06, 0.015], + rotation: [0.08, 0, side * -0.18], + scale: [0.88, 1, 0.72], + })); } - for (const z of [-0.2, 0.22] as const) { - for (const side of [-1, 1] as const) { - const fore = z < 0 ? "front" : "rear"; - const word = side < 0 ? "left" : "right"; - const leg = namedGroup(`dog.leg.${fore}.${word}`, [side * 0.125, -0.1, z]); - body.add(leg); - leg.add( - actorMesh(`dog.leg-mesh.${fore}.${word}`, new THREE.CapsuleGeometry(0.046, 0.19, 3, 7), m.coat, { - position: [0, -0.135, 0], - }), - actorMesh(`dog.paw.${fore}.${word}`, new THREE.SphereGeometry(0.06, 8, 6), m.markings, { - position: [0, -0.29, -0.022], - scale: [0.84, 0.48, 1.18], - }), - ); - } - } + addLeg(body, m, "front", "left", -0.14, -0.22); + addLeg(body, m, "front", "right", 0.14, -0.22); + addLeg(body, m, "rear", "left", -0.145, 0.245); + addLeg(body, m, "rear", "right", 0.145, 0.245); - const tail = namedGroup("dog.tail", [0, 0.03, 0.31]); + const tail = namedGroup("dog.tail", [0, 0.065, 0.37]); body.add(tail); - tail.rotation.x = 0.68; - tail.add( - actorMesh("dog.tail-mesh", new THREE.CapsuleGeometry(0.04, 0.25, 4, 8), m.coat, { - position: [0, 0.15, 0], - }), - ); - return resolveDog(root, !options.materials); + tail.add(actorMesh("dog.tail.base-mesh", new THREE.CapsuleGeometry(0.046, 0.17, 4, 8), m.coat, { + position: [0, 0.115, 0], + scale: [1.05, 1, 1.05], + })); + const tailTip = namedGroup("dog.tail.tip", [0, 0.21, 0]); + tail.add(tailTip); + tailTip.add(actorMesh("dog.tail.tip-mesh", new THREE.CapsuleGeometry(0.034, 0.15, 4, 8), m.coat, { + position: [0, 0.095, 0], + scale: [0.9, 1, 0.9], + })); + + const rig = resolveDog(root, !options.materials); + poseDog(rig, { state: "idle", phase: 0, amount: 1 }); + return rig; } export function cloneDog(source: DogRig): DogRig { return resolveDog(source.root.clone(true), false); } -export function poseDogWalk(rig: DogRig, phase: number, amount = 0.55): void { - const swing = Math.sin(phase) * THREE.MathUtils.clamp(amount, 0, 0.8); - rig.joints.legFrontLeft.rotation.x = swing; - rig.joints.legRearRight.rotation.x = swing; - rig.joints.legFrontRight.rotation.x = -swing; - rig.joints.legRearLeft.rotation.x = -swing; - rig.joints.body.position.y = 0.39 + Math.abs(Math.cos(phase)) * Math.abs(swing) * 0.018; +/** A consistent state choice for local and network presentation adapters. */ +export function dogPoseStateForSpeed(speedMps: number): DogPoseState { + const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0; + if (speed < 0.08) return "idle"; + if (speed < 1.15) return "walk"; + if (speed < 2.65) return "trot"; + return "run"; } +function finite(value: number | undefined, fallback = 0): number { + return value === undefined || !Number.isFinite(value) ? fallback : value; +} + +function setLeg(leg: DogLegJoints, upper: number, bend: number, toeLift: number): void { + leg.upper.rotation.set(upper, 0, 0); + leg.lower.rotation.set(bend, 0, 0); + leg.paw.rotation.set(-(upper + bend) * 0.64 - toeLift, 0, 0); +} + +/** Apply one complete pose. Repeated identical inputs produce identical transforms. */ +export function poseDog(rig: DogRig, pose: DogPose): void { + const state: DogPoseState = ["idle", "attention", "walk", "trot", "run"].includes(pose.state) + ? pose.state + : "idle"; + const phase = finite(pose.phase); + const amount = THREE.MathUtils.clamp(finite(pose.amount, 1), 0, 1); + const turn = THREE.MathUtils.clamp(finite(pose.turn), -1, 1); + const acceleration = THREE.MathUtils.clamp(finite(pose.acceleration), -1, 1); + const lookYaw = THREE.MathUtils.clamp(finite(pose.attentionYaw), -0.9, 0.9); + const lookPitch = THREE.MathUtils.clamp(finite(pose.attentionPitch), -0.45, 0.4); + const locomoting = state === "walk" || state === "trot" || state === "run"; + const breath = Math.sin(phase * 0.48); + const cycle = Math.sin(phase); + const opposite = Math.sin(phase + Math.PI); + const liftLeft = locomoting ? Math.max(0, Math.sin(phase + Math.PI * 0.18)) : 0; + const liftRight = locomoting ? Math.max(0, Math.sin(phase + Math.PI * 1.18)) : 0; + + let stride = 0; + let bend = 0; + let bob = breath * 0.003; + if (state === "walk") { + stride = 0.38 * amount; + bend = 0.3 * amount; + bob += Math.abs(Math.cos(phase)) * 0.006 * amount; + } else if (state === "trot") { + stride = 0.58 * amount; + bend = 0.46 * amount; + bob += Math.abs(Math.cos(phase)) * 0.015 * amount; + } else if (state === "run") { + stride = 0.76 * amount; + bend = 0.66 * amount; + bob += Math.sin(phase * 2) * 0.018 * amount; + } + + if (state === "run") { + const frontLeft = Math.sin(phase + 0.18); + const frontRight = Math.sin(phase - 0.18); + const rearLeft = Math.sin(phase + Math.PI + 0.2); + const rearRight = Math.sin(phase + Math.PI - 0.2); + setLeg(rig.joints.frontLeft, frontLeft * stride, -Math.max(0, -frontLeft) * bend, liftLeft * 0.16); + setLeg(rig.joints.frontRight, frontRight * stride, -Math.max(0, -frontRight) * bend, liftRight * 0.16); + setLeg(rig.joints.rearLeft, rearLeft * stride, Math.max(0, rearLeft) * bend, liftRight * 0.13); + setLeg(rig.joints.rearRight, rearRight * stride, Math.max(0, rearRight) * bend, liftLeft * 0.13); + } else { + setLeg(rig.joints.frontLeft, cycle * stride, -liftLeft * bend, liftLeft * 0.1); + setLeg(rig.joints.frontRight, opposite * stride, -liftRight * bend, liftRight * 0.1); + setLeg(rig.joints.rearLeft, opposite * stride, liftRight * bend * 0.84, liftRight * 0.08); + setLeg(rig.joints.rearRight, cycle * stride, liftLeft * bend * 0.84, liftLeft * 0.08); + } + + const locomotionAmount = locomoting ? amount : 0; + rig.joints.body.position.set(0, 0.425 + bob, 0.045); + rig.joints.body.rotation.set(-acceleration * 0.09 + (state === "run" ? -0.035 : 0), 0, -turn * 0.12 * locomotionAmount); + rig.joints.chest.scale.set(1, 1 + breath * (locomoting ? 0.012 : 0.026), 1); + rig.joints.neck.rotation.set(-0.24 + acceleration * 0.035, turn * 0.035, turn * 0.04); + rig.joints.head.rotation.set( + -0.055 + lookPitch - Math.abs(turn) * 0.035 + (state === "attention" ? -0.08 : 0), + lookYaw - turn * 0.08, + turn * 0.045, + ); + + const attention = state === "attention" ? 1 : 0; + rig.joints.earLeft.rotation.set(0.02 + attention * 0.08, 0, 0.07 + attention * 0.08); + rig.joints.earRight.rotation.set(-0.01, 0, -0.07 + attention * 0.035); + const wag = Math.sin(phase * (locomoting ? 0.85 : 1.3)) * (locomoting ? 0.16 : 0.38) * amount; + rig.joints.tail.rotation.set(0.72 - locomotionAmount * 0.12, 0, wag - turn * 0.12); + rig.joints.tailTip.rotation.set(-0.16, 0, wag * 0.7); + + rig.root.userData.pose = state; + rig.root.userData.posePhase = phase; + rig.root.userData.poseAmount = amount; + rig.root.userData.turnLean = turn; + rig.root.userData.acceleration = acceleration; +} + +/** Legacy gait API retained for existing integrations and downstream users. */ +export function poseDogWalk(rig: DogRig, phase: number, amount = 0.55): void { + const intensity = THREE.MathUtils.clamp(finite(amount), 0, 1); + poseDog(rig, { + state: intensity < 0.04 ? "idle" : intensity < 0.52 ? "walk" : "trot", + phase, + amount: intensity, + }); +} + +/** Legacy additive attention API: deliberately leaves the active gait untouched. */ export function poseDogAttention(rig: DogRig, lookYaw: number, tailPhase: number): void { - rig.joints.head.rotation.y = THREE.MathUtils.clamp(lookYaw, -0.9, 0.9); + const yaw = THREE.MathUtils.clamp(finite(lookYaw), -0.9, 0.9); + const phase = finite(tailPhase); + rig.joints.head.rotation.y = yaw; rig.joints.head.rotation.x = -0.08; - rig.joints.earLeft.rotation.z = 0.08; - rig.joints.earRight.rotation.z = -0.08; - rig.joints.tail.rotation.z = Math.sin(tailPhase) * 0.72; + rig.joints.earLeft.rotation.z = 0.13; + rig.joints.earRight.rotation.z = -0.105; + rig.joints.tail.rotation.z = Math.sin(phase) * 0.34; + rig.joints.tailTip.rotation.z = Math.sin(phase + 0.35) * 0.22; } export function disposeDog(rig: DogRig, options: { disposeMaterials?: boolean } = {}): void { diff --git a/src/assets/actors/index.ts b/src/assets/actors/index.ts index 5933736..e2ab345 100644 --- a/src/assets/actors/index.ts +++ b/src/assets/actors/index.ts @@ -18,11 +18,16 @@ export { cloneDog, createDogMaterials, disposeDog, + dogPoseStateForSpeed, + poseDog, poseDogAttention, poseDogWalk, type DogBuildOptions, type DogJoints, + type DogLegJoints, type DogMaterials, + type DogPose, + type DogPoseState, type DogRig, } from "./dog.ts"; diff --git a/src/interiors/officeWalker.ts b/src/interiors/officeWalker.ts index 540c761..eaa198a 100644 --- a/src/interiors/officeWalker.ts +++ b/src/interiors/officeWalker.ts @@ -11,9 +11,9 @@ import * as THREE from "three"; import { buildDog, + dogPoseStateForSpeed, disposeDog, - poseDogAttention, - poseDogWalk, + poseDog, type DogRig, } from "../assets/actors/dog.ts"; import { @@ -118,6 +118,9 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of let enabled = options.active ?? false; let desired: WalkerAction = { x: 0, z: 0 }; let gait = 0; + let presentationSeconds = 0; + let previousSpeed = 0; + const previousFacing = new THREE.Vector2(0, -1); let faceTexture: THREE.Texture | null = null; let disposed = false; const root = new THREE.Group(); @@ -135,16 +138,40 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of view.position.copy(root.position); root.rotation.y = Math.atan2(-state.facing.x, -state.facing.z); - const speed = elapsedSeconds > 0 ? travelled / elapsedSeconds : 0; + const speed = elapsedSeconds > 0 ? Math.max(0, travelled / elapsedSeconds) : 0; const targetGait = enabled && speed > 1e-5 ? Math.min(1, speed / (options.speed ?? 1.6)) : 0; gait += (targetGait - gait) * Math.min(1, elapsedSeconds / GAIT_EASE_SECONDS); - const phase = (state.distance / STRIDE_METRES) * Math.PI * 2; + presentationSeconds += elapsedSeconds; + const phase = speed > 1e-5 + ? (state.distance / STRIDE_METRES) * Math.PI * 2 + : presentationSeconds * 2.2; + const facingDot = THREE.MathUtils.clamp( + previousFacing.x * state.facing.x + previousFacing.y * state.facing.z, + -1, + 1, + ); + const facingCross = previousFacing.x * state.facing.z - previousFacing.y * state.facing.x; + const turn = elapsedSeconds > 0 + ? THREE.MathUtils.clamp(Math.atan2(facingCross, facingDot) / elapsedSeconds / 4, -1, 1) + : 0; + const acceleration = elapsedSeconds > 0 + ? THREE.MathUtils.clamp((speed - previousSpeed) / elapsedSeconds / 5, -1, 1) + : 0; if (actor.kind === "humanoid") { poseHumanoid(actor.rig, { walkPhase: phase, stride: gait * 0.62 }); } else { - poseDogWalk(actor.rig, phase, gait * 0.62); - poseDogAttention(actor.rig, 0, state.distance * 5); + const dogState = dogPoseStateForSpeed(speed * Math.max(gait, 0.2)); + poseDog(actor.rig, { + state: dogState, + phase, + amount: dogState === "idle" ? 1 : THREE.MathUtils.clamp(0.38 + gait * 0.62, 0, 1), + turn, + acceleration, + attentionYaw: -turn * 0.08, + }); } + previousSpeed = speed; + previousFacing.set(state.facing.x, state.facing.z); } function snapshot(): OfficeWalkerState { @@ -202,6 +229,9 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of const state = controller.reset(spawn); desired = { x: 0, z: 0 }; gait = 0; + presentationSeconds = 0; + previousSpeed = 0; + previousFacing.set(state.facing.x, state.facing.z); sync(state); return snapshot(); }, diff --git a/src/realtime/scenePeers.ts b/src/realtime/scenePeers.ts index 39d3d4b..ee539c3 100644 --- a/src/realtime/scenePeers.ts +++ b/src/realtime/scenePeers.ts @@ -17,9 +17,9 @@ import { disposeCrow, disposeDog, disposeHumanoid, + dogPoseStateForSpeed, poseCrowFlight, - poseDogAttention, - poseDogWalk, + poseDog, poseHumanoid, type CrowRig, type DogRig, @@ -208,12 +208,15 @@ function animateActor( stride: THREE.MathUtils.clamp(speed / 3.2, 0, 0.68), }); } else if (visual.kind === "dog") { - poseDogWalk( - visual.rig, - seconds * Math.max(1.4, speed * 7.2), - THREE.MathUtils.clamp(speed / 4, 0, 0.68), - ); - poseDogAttention(visual.rig, 0, seconds * 5); + const state = dogPoseStateForSpeed(speed); + const turn = THREE.MathUtils.clamp(sample.velocity.yawDegPerSec / 160, -1, 1); + poseDog(visual.rig, { + state, + phase: seconds * Math.max(1.4, speed * 7.2), + amount: state === "idle" ? 1 : THREE.MathUtils.clamp(0.38 + speed / 4.2, 0.38, 1), + turn, + attentionYaw: -turn * 0.08, + }); } else { poseCrowFlight( visual.rig, diff --git a/src/test/dogAssetV2.test.ts b/src/test/dogAssetV2.test.ts new file mode 100644 index 0000000..5fbb5c0 --- /dev/null +++ b/src/test/dogAssetV2.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import * as THREE from "three"; +import { + DOG_METRICS, + buildDog, + cloneDog, + disposeDog, + dogPoseStateForSpeed, + poseDog, + poseDogAttention, + type DogRig, +} from "../assets/actors/index.ts"; + +function meshes(root: THREE.Object3D): THREE.Mesh[] { + const result: THREE.Mesh[] = []; + root.traverse((object) => { + if (object instanceof THREE.Mesh) result.push(object); + }); + return result; +} + +function transformSnapshot(rig: DogRig): readonly number[][] { + const result: number[][] = []; + rig.root.traverse((object) => { + result.push([ + ...object.position.toArray(), + object.rotation.x, + object.rotation.y, + object.rotation.z, + ...object.scale.toArray(), + ]); + }); + return result; +} + +describe("procedural dog v2", () => { + it("builds a floor-centred articulated silhouette within the peer/mobile budget", () => { + const rig = buildDog(); + assert.equal(rig.root.userData.rigVersion, 2); + for (const leg of [rig.joints.frontLeft, rig.joints.frontRight, rig.joints.rearLeft, rig.joints.rearRight]) { + assert.equal(leg.lower.parent, leg.upper); + assert.equal(leg.paw.parent, leg.lower); + } + for (const name of [ + "dog.shoulder-mass", + "dog.hip-mass", + "dog.neck", + "dog.chest", + "dog.muzzle", + "dog.collar", + "dog.tag", + "dog.tail.tip", + ]) assert.ok(rig.root.getObjectByName(name), `missing ${name}`); + + const box = new THREE.Box3().setFromObject(rig.root); + const size = box.getSize(new THREE.Vector3()); + assert.ok(box.min.y > -0.02, `floor ${box.min.y}`); + assert.ok(Math.abs(size.y - DOG_METRICS.height) < 0.08, `height ${size.y}`); + assert.ok(Math.abs(size.z - DOG_METRICS.length) < 0.08, `length ${size.z}`); + const allMeshes = meshes(rig.root); + const triangles = allMeshes.reduce((sum, mesh) => { + const geometry = mesh.geometry; + return sum + (geometry.index?.count ?? geometry.getAttribute("position").count) / 3; + }, 0); + assert.ok(allMeshes.length <= 34, `${allMeshes.length} meshes`); + assert.ok(triangles <= 3_600, `${triangles} triangles`); + disposeDog(rig); + }); + + it("separates idle, attention, walk, trot, run, turn and start/stop poses", () => { + const rig = buildDog(); + poseDog(rig, { state: "idle", phase: Math.PI / 2, amount: 1 }); + const idleChest = rig.joints.chest.scale.y; + assert.equal(rig.root.userData.pose, "idle"); + assert.equal(rig.joints.frontLeft.upper.rotation.x, 0); + + poseDog(rig, { state: "attention", phase: Math.PI / 2, amount: 1, attentionYaw: 0.35 }); + assert.equal(rig.root.userData.pose, "attention"); + assert.equal(rig.joints.head.rotation.y, 0.35); + assert.notEqual(rig.joints.earLeft.rotation.z, -rig.joints.earRight.rotation.z); + + poseDog(rig, { state: "walk", phase: Math.PI / 2, amount: 1 }); + const walkStride = Math.abs(rig.joints.frontLeft.upper.rotation.x); + assert.ok(walkStride > 0.3); + assert.equal(rig.joints.frontLeft.upper.rotation.x, rig.joints.rearRight.upper.rotation.x); + assert.equal(rig.joints.frontRight.upper.rotation.x, rig.joints.rearLeft.upper.rotation.x); + assert.notEqual(rig.joints.chest.scale.y, idleChest); + + poseDog(rig, { state: "trot", phase: Math.PI / 2, amount: 1 }); + const trotStride = Math.abs(rig.joints.frontLeft.upper.rotation.x); + assert.ok(trotStride > walkStride); + + poseDog(rig, { state: "run", phase: 0.8, amount: 1 }); + assert.equal(rig.root.userData.pose, "run"); + assert.notEqual(rig.joints.frontLeft.upper.rotation.x, rig.joints.frontRight.upper.rotation.x, + "a gathered run offsets the front pair rather than using a rigid scissor"); + + poseDog(rig, { state: "trot", phase: 0.7, amount: 1, turn: 0.8, acceleration: 0.7 }); + assert.ok(rig.joints.body.rotation.z < -0.09, "turn banks through the chest"); + assert.ok(rig.joints.body.rotation.x < -0.05, "starting lowers the chest into motion"); + poseDog(rig, { state: "idle", phase: 0.7, amount: 1, acceleration: -0.8 }); + assert.ok(rig.joints.body.rotation.x > 0.06, "stopping braces the chest"); + disposeDog(rig); + }); + + it("selects gait thresholds and repeats an exact pose without accumulating transforms", () => { + assert.equal(dogPoseStateForSpeed(Number.NaN), "idle"); + assert.equal(dogPoseStateForSpeed(0.07), "idle"); + assert.equal(dogPoseStateForSpeed(0.5), "walk"); + assert.equal(dogPoseStateForSpeed(1.5), "trot"); + assert.equal(dogPoseStateForSpeed(3), "run"); + + const rig = buildDog(); + const pose = { + state: "trot" as const, + phase: 2.17, + amount: 0.83, + turn: -0.42, + acceleration: -0.3, + attentionYaw: 0.11, + attentionPitch: -0.08, + }; + poseDog(rig, pose); + const first = transformSnapshot(rig); + poseDog(rig, pose); + assert.deepEqual(transformSnapshot(rig), first); + poseDogAttention(rig, 0.2, 1.7); + const attention = transformSnapshot(rig); + poseDogAttention(rig, 0.2, 1.7); + assert.deepEqual(transformSnapshot(rig), attention, "legacy attention remains idempotent"); + disposeDog(rig); + }); + + it("clones shared resources into independent joints and respects material ownership", () => { + const source = buildDog(); + const clone = cloneDog(source); + const sourceMeshes = meshes(source.root); + const cloneMeshes = meshes(clone.root); + assert.equal(cloneMeshes.length, sourceMeshes.length); + for (let index = 0; index < sourceMeshes.length; index += 1) { + assert.equal(cloneMeshes[index]!.geometry, sourceMeshes[index]!.geometry); + assert.equal(cloneMeshes[index]!.material, sourceMeshes[index]!.material); + } + poseDog(clone, { state: "run", phase: 1.3, amount: 1 }); + assert.notEqual(clone.joints.frontLeft.upper.rotation.x, source.joints.frontLeft.upper.rotation.x); + assert.equal(clone.ownsMaterials, false); + + const shared = sourceMeshes[0]!.material as THREE.Material; + let disposals = 0; + shared.addEventListener("dispose", () => disposals++); + const external = { coat: shared, markings: shared, nose: shared, collar: shared }; + const externalRig = buildDog({ materials: external }); + disposeDog(externalRig); + assert.equal(disposals, 0); + disposeDog(source); + assert.equal(disposals, 1); + }); +}); diff --git a/src/test/officeWalker.test.ts b/src/test/officeWalker.test.ts index 02ef023..b572453 100644 --- a/src/test/officeWalker.test.ts +++ b/src/test/officeWalker.test.ts @@ -64,6 +64,38 @@ describe("office walker actor adapter", () => { actor.dispose(); }); + it("presents deterministic dog start, office turn, trot and stop semantics", () => { + const actor = createOfficeWalker(makePlan(), { + levelId: "ground", + position: { x: 4, z: 4 }, + actor: { kind: "anonymous-dog" }, + speed: 1.6, + fixedStep: 0.1, + active: true, + }); + const dog = actor.root.getObjectByName("dog") as THREE.Group; + assert.equal(dog.userData.pose, "idle"); + + actor.setAction({ x: 0, z: -1 }); + actor.tick(0.1); + assert.equal(dog.userData.pose, "walk", "eased first step does not pop directly into a trot"); + assert.equal(dog.userData.acceleration, 1); + actor.tick(0.1); + assert.equal(dog.userData.pose, "trot"); + + actor.setAction({ x: 1, z: 0 }); + actor.tick(0.1); + assert.ok(Math.abs(Number(dog.userData.turnLean)) > 0.9); + const body = dog.getObjectByName("dog.body") as THREE.Group; + assert.notEqual(body.rotation.z, 0); + + actor.setAction({ x: 0, z: 0 }); + actor.tick(0.1); + assert.equal(dog.userData.pose, "idle"); + assert.equal(dog.userData.acceleration, -1); + actor.dispose(); + }); + it("uses resolved door gaps while retaining wall collision and reset state", () => { const plan = makePlan([{ id: "divider", diff --git a/src/test/sceneActor.test.ts b/src/test/sceneActor.test.ts index 5b6457e..b44b233 100644 --- a/src/test/sceneActor.test.ts +++ b/src/test/sceneActor.test.ts @@ -95,6 +95,31 @@ describe("playable city scene actor", () => { actor.dispose(); }); + it("maps the unchanged dog controller snapshot to trot, run, turn and stop presentation", () => { + const actor = createSceneActor(options({ kind: "dog", active: true })); + const dog = actor.root.getObjectByName("dog") as THREE.Group; + assert.equal(dog.userData.pose, "idle"); + + actor.setActions({ forward: 1, turn: 0.6 }); + actor.tick(0.1); + assert.equal(actor.state().speedMps, 2.125); + assert.equal(dog.userData.pose, "trot"); + assert.equal(dog.userData.turnLean, 0.6); + assert.equal(dog.userData.acceleration, 1); + + actor.setActions({ forward: 1, sprint: true }); + actor.tick(0.1); + assert.equal(actor.state().speedMps, 5.625); + assert.equal(dog.userData.pose, "run"); + + actor.setActions({ forward: 0, sprint: false }); + actor.tick(0.1); + assert.equal(actor.state().speedMps, 0); + assert.equal(dog.userData.pose, "idle"); + assert.equal(dog.userData.acceleration, -1); + actor.dispose(); + }); + it("keeps its stable root and identity while replacing procedural rigs", () => { const actor = createSceneActor(options({ active: true })); const root = actor.root; diff --git a/src/test/scenePeers.test.ts b/src/test/scenePeers.test.ts index 4c96fa1..2740c6f 100644 --- a/src/test/scenePeers.test.ts +++ b/src/test/scenePeers.test.ts @@ -123,6 +123,21 @@ describe("remote scene peers", () => { peers.dispose(); }); + it("derives deterministic dog gait and turn lean from the existing peer snapshot", () => { + const peers = fixture(); + const snapshot = actor("dog", "dog", 1, 1_000, 0); + snapshot.velocity.xMps = 3.2; + snapshot.velocity.yawDegPerSec = -80; + assert.equal(peers.upsert(snapshot), true); + assert.equal(peers.tick(1_000), 1); + const dog = peers.root.children[0]?.getObjectByName("dog") as THREE.Group; + assert.equal(dog.userData.pose, "run"); + assert.equal(dog.userData.turnLean, -0.5); + const body = dog.getObjectByName("dog.body") as THREE.Group; + assert.ok(body.rotation.z > 0); + peers.dispose(); + }); + it("projects geographic vehicles onto caller terrain and renders a generic black EV", () => { const peers = fixture(); const snapshot = vehicle(1, 1_000, 34);