1
0

feat(actors): build articulated dog v2

This commit is contained in:
2026-08-19 03:07:40 -07:00
parent 264daaab61
commit 20deae2a0f
9 changed files with 615 additions and 104 deletions
+17 -6
View File
@@ -11,12 +11,12 @@ import {
buildCrow, buildCrow,
buildDog, buildDog,
buildHumanoid, buildHumanoid,
dogPoseStateForSpeed,
disposeCrow, disposeCrow,
disposeDog, disposeDog,
disposeHumanoid, disposeHumanoid,
poseCrow, poseCrow,
poseDogAttention, poseDog,
poseDogWalk,
poseHumanoid, poseHumanoid,
type CrowRig, type CrowRig,
type DogRig, type DogRig,
@@ -200,7 +200,7 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
let faceTexture: THREE.Texture | null = null; let faceTexture: THREE.Texture | null = null;
let disposed = false; let disposed = false;
function sync(): void { function sync(elapsedSeconds = 0, previousSpeedMps?: number): void {
const state = controller.state(); const state = controller.state();
root.position.set( root.position.set(
origin.x + state.x * scale, origin.x + state.x * scale,
@@ -217,8 +217,18 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
if (actor.kind === "humanoid") { if (actor.kind === "humanoid") {
poseHumanoid(actor.rig, { walkPhase: state.posePhase, stride: state.poseAmount * 0.68 }); poseHumanoid(actor.rig, { walkPhase: state.posePhase, stride: state.poseAmount * 0.68 });
} else if (actor.kind === "dog") { } else if (actor.kind === "dog") {
poseDogWalk(actor.rig, state.posePhase, state.poseAmount * 0.68); const dogState = dogPoseStateForSpeed(state.speedMps);
poseDogAttention(actor.rig, 0, state.posePhase * 0.7); 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") { } else if (state.mode === "flight") {
// The controller names the aerodynamic state; the rig owns how that // The controller names the aerodynamic state; the rig owns how that
// state bends shoulder, elbow, wrist, tail and feet. // state bends shoulder, elbow, wrist, tail and feet.
@@ -267,9 +277,10 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
tick(elapsedSeconds) { tick(elapsedSeconds) {
if (!disposed && enabled) { if (!disposed && enabled) {
const beforeKind = controller.state().kind; const beforeKind = controller.state().kind;
const previousSpeedMps = controller.state().speedMps;
controller.tick(elapsedSeconds, desired); controller.tick(elapsedSeconds, desired);
if (controller.state().kind !== beforeKind) replaceActor(controller.state().kind); if (controller.state().kind !== beforeKind) replaceActor(controller.state().kind);
else sync(); else sync(Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0, previousSpeedMps);
clearEdges(); clearEdges();
} }
return controller.snapshot(); return controller.snapshot();
+315 -84
View File
@@ -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 * as THREE from "three";
import { import {
actorMesh, actorMesh,
@@ -8,7 +14,27 @@ import {
type ActorRigBase, type ActorRigBase,
} from "./common.ts"; } 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 { export interface DogMaterials {
coat: THREE.Material; coat: THREE.Material;
@@ -24,12 +50,26 @@ export interface DogBuildOptions {
collarColor?: THREE.ColorRepresentation; collarColor?: THREE.ColorRepresentation;
} }
export interface DogLegJoints {
upper: THREE.Group;
lower: THREE.Group;
paw: THREE.Group;
}
export interface DogJoints { export interface DogJoints {
body: THREE.Group; body: THREE.Group;
chest: THREE.Group;
neck: THREE.Group;
head: THREE.Group; head: THREE.Group;
earLeft: THREE.Group; earLeft: THREE.Group;
earRight: THREE.Group; earRight: THREE.Group;
tail: 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; legFrontLeft: THREE.Group;
legFrontRight: THREE.Group; legFrontRight: THREE.Group;
legRearLeft: THREE.Group; legRearLeft: THREE.Group;
@@ -42,145 +82,336 @@ export interface DogRig extends ActorRigBase {
export function createDogMaterials(options: DogBuildOptions = {}): DogMaterials { export function createDogMaterials(options: DogBuildOptions = {}): DogMaterials {
return { return {
coat: new THREE.MeshStandardMaterial({ name: "dog.coat", color: options.coatColor ?? 0x9a673d, roughness: 0.92 }), coat: new THREE.MeshStandardMaterial({
markings: new THREE.MeshStandardMaterial({ name: "dog.markings", color: options.markingsColor ?? 0xe4c9a2, roughness: 0.95 }), name: "dog.coat",
nose: new THREE.MeshStandardMaterial({ name: "dog.nose", color: 0x151719, roughness: 0.72 }), color: options.coatColor ?? 0x282b2d,
collar: new THREE.MeshStandardMaterial({ name: "dog.collar", color: options.collarColor ?? 0x2d92a7, roughness: 0.55 }), 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 { 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 { return {
root, root,
ownsMaterials, ownsMaterials,
joints: { joints: {
body: requireGroup(root, "dog.body"), body: requireGroup(root, "dog.body"),
chest: requireGroup(root, "dog.chest"),
neck: requireGroup(root, "dog.neck"),
head: requireGroup(root, "dog.head"), head: requireGroup(root, "dog.head"),
earLeft: requireGroup(root, "dog.ear.left"), earLeft: requireGroup(root, "dog.ear.left"),
earRight: requireGroup(root, "dog.ear.right"), earRight: requireGroup(root, "dog.ear.right"),
tail: requireGroup(root, "dog.tail"), tail: requireGroup(root, "dog.tail"),
legFrontLeft: requireGroup(root, "dog.leg.front.left"), tailTip: requireGroup(root, "dog.tail.tip"),
legFrontRight: requireGroup(root, "dog.leg.front.right"), frontLeft,
legRearLeft: requireGroup(root, "dog.leg.rear.left"), frontRight,
legRearRight: requireGroup(root, "dog.leg.rear.right"), 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 { export function buildDog(options: DogBuildOptions = {}): DogRig {
const m = options.materials ?? createDogMaterials(options); const m = options.materials ?? createDogMaterials(options);
const root = namedGroup("dog"); const root = namedGroup("dog");
root.userData.kind = "actor"; root.userData.kind = "actor";
root.userData.actorType = "anonymous-dog"; root.userData.actorType = "anonymous-dog";
root.userData.forwardAxis = "-Z"; 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); root.add(body);
body.add( 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], 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, { actorMesh("dog.loin", new THREE.SphereGeometry(0.19, 11, 8), m.coat, {
position: [0, 0.01, -0.17], position: [0, -0.025, 0.245],
scale: [0.72, 1, 0.56], 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]); const chest = namedGroup("dog.chest", [0, 0.01, -0.225]);
body.add(head); body.add(chest);
head.add( chest.add(actorMesh("dog.chest-marking", new THREE.SphereGeometry(0.175, 11, 8), m.markings, {
actorMesh("dog.skull", new THREE.SphereGeometry(0.16, 12, 10), m.coat, { scale: [0.82, 0.92, 0.88] }), position: [0, -0.035, -0.045],
actorMesh("dog.muzzle", new THREE.SphereGeometry(0.105, 12, 8), m.markings, { scale: [0.62, 1.04, 0.52],
position: [0, -0.045, -0.13], }));
scale: [0.8, 0.62, 1],
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, { actorMesh("dog.collar", new THREE.TorusGeometry(0.128, 0.014, 6, 18), m.collar, {
position: [0, -0.035, -0.224], position: [0, 0.035, -0.005],
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],
rotation: [Math.PI / 2, 0, 0], 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) { for (const side of [-1, 1] as const) {
const word = side < 0 ? "left" : "right"; const word = side < 0 ? "left" : "right";
head.add( head.add(
actorMesh(`dog.eye.${word}`, new THREE.SphereGeometry(0.023, 8, 6), m.nose, { actorMesh(`dog.eye.${word}`, new THREE.SphereGeometry(0.022, 8, 6), m.nose, {
position: [side * 0.075, 0.025, -0.128], position: [side * 0.073, 0.025, -0.128],
scale: [0.85, 1, 0.58], scale: [0.82, 1, 0.62],
receiveShadow: false, receiveShadow: false,
}), }),
actorMesh(`dog.eye-catchlight.${word}`, new THREE.SphereGeometry(0.006, 6, 4), m.markings, { actorMesh(`dog.eye-catchlight.${word}`, new THREE.SphereGeometry(0.0055, 6, 4), m.markings, {
position: [side * 0.079, 0.031, -0.145], position: [side * 0.076, 0.031, -0.145],
receiveShadow: false, receiveShadow: false,
}), }),
); );
} const ear = namedGroup(`dog.ear.${word}`, [side * 0.105, 0.095, -0.005]);
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]);
head.add(ear); head.add(ear);
ear.add( ear.add(actorMesh(`dog.ear-flap.${word}`, new THREE.ConeGeometry(0.073, 0.19, 5), m.coat, {
actorMesh(`dog.ear-flap.${word}`, new THREE.ConeGeometry(0.075, 0.19, 5), m.coat, { position: [side * 0.012, 0.06, 0.015],
position: [side * 0.015, -0.065, 0.02], rotation: [0.08, 0, side * -0.18],
rotation: [0.18, 0, side * 0.28], scale: [0.88, 1, 0.72],
}), }));
);
} }
for (const z of [-0.2, 0.22] as const) { addLeg(body, m, "front", "left", -0.14, -0.22);
for (const side of [-1, 1] as const) { addLeg(body, m, "front", "right", 0.14, -0.22);
const fore = z < 0 ? "front" : "rear"; addLeg(body, m, "rear", "left", -0.145, 0.245);
const word = side < 0 ? "left" : "right"; addLeg(body, m, "rear", "right", 0.145, 0.245);
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],
}),
);
}
}
const tail = namedGroup("dog.tail", [0, 0.03, 0.31]); const tail = namedGroup("dog.tail", [0, 0.065, 0.37]);
body.add(tail); body.add(tail);
tail.rotation.x = 0.68; tail.add(actorMesh("dog.tail.base-mesh", new THREE.CapsuleGeometry(0.046, 0.17, 4, 8), m.coat, {
tail.add( position: [0, 0.115, 0],
actorMesh("dog.tail-mesh", new THREE.CapsuleGeometry(0.04, 0.25, 4, 8), m.coat, { scale: [1.05, 1, 1.05],
position: [0, 0.15, 0], }));
}), const tailTip = namedGroup("dog.tail.tip", [0, 0.21, 0]);
); tail.add(tailTip);
return resolveDog(root, !options.materials); 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 { export function cloneDog(source: DogRig): DogRig {
return resolveDog(source.root.clone(true), false); return resolveDog(source.root.clone(true), false);
} }
export function poseDogWalk(rig: DogRig, phase: number, amount = 0.55): void { /** A consistent state choice for local and network presentation adapters. */
const swing = Math.sin(phase) * THREE.MathUtils.clamp(amount, 0, 0.8); export function dogPoseStateForSpeed(speedMps: number): DogPoseState {
rig.joints.legFrontLeft.rotation.x = swing; const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0;
rig.joints.legRearRight.rotation.x = swing; if (speed < 0.08) return "idle";
rig.joints.legFrontRight.rotation.x = -swing; if (speed < 1.15) return "walk";
rig.joints.legRearLeft.rotation.x = -swing; if (speed < 2.65) return "trot";
rig.joints.body.position.y = 0.39 + Math.abs(Math.cos(phase)) * Math.abs(swing) * 0.018; 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 { 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.head.rotation.x = -0.08;
rig.joints.earLeft.rotation.z = 0.08; rig.joints.earLeft.rotation.z = 0.13;
rig.joints.earRight.rotation.z = -0.08; rig.joints.earRight.rotation.z = -0.105;
rig.joints.tail.rotation.z = Math.sin(tailPhase) * 0.72; 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 { export function disposeDog(rig: DogRig, options: { disposeMaterials?: boolean } = {}): void {
+5
View File
@@ -18,11 +18,16 @@ export {
cloneDog, cloneDog,
createDogMaterials, createDogMaterials,
disposeDog, disposeDog,
dogPoseStateForSpeed,
poseDog,
poseDogAttention, poseDogAttention,
poseDogWalk, poseDogWalk,
type DogBuildOptions, type DogBuildOptions,
type DogJoints, type DogJoints,
type DogLegJoints,
type DogMaterials, type DogMaterials,
type DogPose,
type DogPoseState,
type DogRig, type DogRig,
} from "./dog.ts"; } from "./dog.ts";
+36 -6
View File
@@ -11,9 +11,9 @@
import * as THREE from "three"; import * as THREE from "three";
import { import {
buildDog, buildDog,
dogPoseStateForSpeed,
disposeDog, disposeDog,
poseDogAttention, poseDog,
poseDogWalk,
type DogRig, type DogRig,
} from "../assets/actors/dog.ts"; } from "../assets/actors/dog.ts";
import { import {
@@ -118,6 +118,9 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
let enabled = options.active ?? false; let enabled = options.active ?? false;
let desired: WalkerAction = { x: 0, z: 0 }; let desired: WalkerAction = { x: 0, z: 0 };
let gait = 0; let gait = 0;
let presentationSeconds = 0;
let previousSpeed = 0;
const previousFacing = new THREE.Vector2(0, -1);
let faceTexture: THREE.Texture | null = null; let faceTexture: THREE.Texture | null = null;
let disposed = false; let disposed = false;
const root = new THREE.Group(); const root = new THREE.Group();
@@ -135,16 +138,40 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
view.position.copy(root.position); view.position.copy(root.position);
root.rotation.y = Math.atan2(-state.facing.x, -state.facing.z); 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; 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); 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") { if (actor.kind === "humanoid") {
poseHumanoid(actor.rig, { walkPhase: phase, stride: gait * 0.62 }); poseHumanoid(actor.rig, { walkPhase: phase, stride: gait * 0.62 });
} else { } else {
poseDogWalk(actor.rig, phase, gait * 0.62); const dogState = dogPoseStateForSpeed(speed * Math.max(gait, 0.2));
poseDogAttention(actor.rig, 0, state.distance * 5); 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 { function snapshot(): OfficeWalkerState {
@@ -202,6 +229,9 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
const state = controller.reset(spawn); const state = controller.reset(spawn);
desired = { x: 0, z: 0 }; desired = { x: 0, z: 0 };
gait = 0; gait = 0;
presentationSeconds = 0;
previousSpeed = 0;
previousFacing.set(state.facing.x, state.facing.z);
sync(state); sync(state);
return snapshot(); return snapshot();
}, },
+11 -8
View File
@@ -17,9 +17,9 @@ import {
disposeCrow, disposeCrow,
disposeDog, disposeDog,
disposeHumanoid, disposeHumanoid,
dogPoseStateForSpeed,
poseCrowFlight, poseCrowFlight,
poseDogAttention, poseDog,
poseDogWalk,
poseHumanoid, poseHumanoid,
type CrowRig, type CrowRig,
type DogRig, type DogRig,
@@ -208,12 +208,15 @@ function animateActor(
stride: THREE.MathUtils.clamp(speed / 3.2, 0, 0.68), stride: THREE.MathUtils.clamp(speed / 3.2, 0, 0.68),
}); });
} else if (visual.kind === "dog") { } else if (visual.kind === "dog") {
poseDogWalk( const state = dogPoseStateForSpeed(speed);
visual.rig, const turn = THREE.MathUtils.clamp(sample.velocity.yawDegPerSec / 160, -1, 1);
seconds * Math.max(1.4, speed * 7.2), poseDog(visual.rig, {
THREE.MathUtils.clamp(speed / 4, 0, 0.68), state,
); phase: seconds * Math.max(1.4, speed * 7.2),
poseDogAttention(visual.rig, 0, seconds * 5); amount: state === "idle" ? 1 : THREE.MathUtils.clamp(0.38 + speed / 4.2, 0.38, 1),
turn,
attentionYaw: -turn * 0.08,
});
} else { } else {
poseCrowFlight( poseCrowFlight(
visual.rig, visual.rig,
+159
View File
@@ -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);
});
});
+32
View File
@@ -64,6 +64,38 @@ describe("office walker actor adapter", () => {
actor.dispose(); 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", () => { it("uses resolved door gaps while retaining wall collision and reset state", () => {
const plan = makePlan([{ const plan = makePlan([{
id: "divider", id: "divider",
+25
View File
@@ -95,6 +95,31 @@ describe("playable city scene actor", () => {
actor.dispose(); 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", () => { it("keeps its stable root and identity while replacing procedural rigs", () => {
const actor = createSceneActor(options({ active: true })); const actor = createSceneActor(options({ active: true }));
const root = actor.root; const root = actor.root;
+15
View File
@@ -123,6 +123,21 @@ describe("remote scene peers", () => {
peers.dispose(); 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", () => { it("projects geographic vehicles onto caller terrain and renders a generic black EV", () => {
const peers = fixture(); const peers = fixture();
const snapshot = vehicle(1, 1_000, 34); const snapshot = vehicle(1, 1_000, 34);