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
+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 {
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 {
+5
View File
@@ -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";