feat(actors): build articulated dog v2
This commit is contained in:
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user