1
0

California gets roads, traffic, and a car to follow

This commit is contained in:
2026-08-11 18:24:53 -07:00
parent 9c9e78f6f9
commit fe58290728
43 changed files with 5593 additions and 68 deletions
+70
View File
@@ -0,0 +1,70 @@
import * as THREE from "three";
export interface ActorRigBase {
/** Floor-centred root. Every actor faces -Z at yaw zero. */
root: THREE.Group;
/** True only when the builder created the material set. */
readonly ownsMaterials: boolean;
}
export function actorMesh(
name: string,
geometry: THREE.BufferGeometry,
material: THREE.Material,
options: {
position?: readonly [number, number, number];
rotation?: readonly [number, number, number];
scale?: readonly [number, number, number];
receiveShadow?: boolean;
} = {},
): THREE.Mesh {
const mesh = new THREE.Mesh(geometry, material);
mesh.name = name;
if (options.position) mesh.position.set(...options.position);
if (options.rotation) mesh.rotation.set(...options.rotation);
if (options.scale) mesh.scale.set(...options.scale);
mesh.castShadow = true;
mesh.receiveShadow = options.receiveShadow ?? true;
return mesh;
}
export function namedGroup(name: string, position?: readonly [number, number, number]): THREE.Group {
const group = new THREE.Group();
group.name = name;
if (position) group.position.set(...position);
return group;
}
export function requireGroup(root: THREE.Object3D, name: string): THREE.Group {
const object = root.getObjectByName(name);
if (!(object instanceof THREE.Group)) throw new Error(`actor: missing joint "${name}"`);
return object;
}
/** Release only resources owned by a prototype. Clones share those resources. */
export function disposeActor(
rig: ActorRigBase,
options: { disposeMaterials?: boolean } = {},
): void {
const geometries = new Set<THREE.BufferGeometry>();
const materials = new Set<THREE.Material>();
rig.root.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
geometries.add(object.geometry);
if (Array.isArray(object.material)) {
for (const material of object.material) materials.add(material);
} else materials.add(object.material);
});
for (const geometry of geometries) geometry.dispose();
if (options.disposeMaterials ?? rig.ownsMaterials) {
for (const material of materials) material.dispose();
}
}
export function actorMeshes(root: THREE.Object3D): THREE.Mesh[] {
const meshes: THREE.Mesh[] = [];
root.traverse((object) => {
if (object instanceof THREE.Mesh) meshes.push(object);
});
return meshes;
}
+161
View File
@@ -0,0 +1,161 @@
/** A low-cost anonymous Tera crow, ready to perch or flap in flight. */
import * as THREE from "three";
import {
actorMesh,
disposeActor,
namedGroup,
requireGroup,
type ActorRigBase,
} from "./common.ts";
export const CROW_METRICS = { bodyLength: 0.42, perchedHeight: 0.34, wingspan: 0.84 } as const;
export interface CrowMaterials {
feather: THREE.Material;
sheen: THREE.Material;
beak: THREE.Material;
eye: THREE.Material;
foot: THREE.Material;
}
export interface CrowBuildOptions {
materials?: CrowMaterials;
featherColor?: THREE.ColorRepresentation;
sheenColor?: THREE.ColorRepresentation;
}
export interface CrowJoints {
body: THREE.Group;
head: THREE.Group;
wingLeft: THREE.Group;
wingRight: THREE.Group;
tail: THREE.Group;
}
export interface CrowRig extends ActorRigBase {
joints: CrowJoints;
}
export function createCrowMaterials(options: CrowBuildOptions = {}): CrowMaterials {
return {
feather: new THREE.MeshStandardMaterial({ name: "crow.feather", color: options.featherColor ?? 0x111519, roughness: 0.62, metalness: 0.12 }),
sheen: new THREE.MeshStandardMaterial({ name: "crow.sheen", color: options.sheenColor ?? 0x1f2c36, roughness: 0.4, metalness: 0.28 }),
beak: new THREE.MeshStandardMaterial({ name: "crow.beak", color: 0x202327, roughness: 0.78 }),
eye: new THREE.MeshBasicMaterial({ name: "crow.eye", color: 0xd4b168, toneMapped: false }),
foot: new THREE.MeshStandardMaterial({ name: "crow.foot", color: 0x24272a, roughness: 0.9 }),
};
}
function wingGeometry(side: -1 | 1): THREE.BufferGeometry {
const s = side;
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute([
0, 0, 0.04,
s * 0.32, -0.015, 0.1,
s * 0.4, -0.03, 0.2,
s * 0.18, -0.015, -0.14,
0, 0, -0.16,
], 3),
);
geometry.setIndex([0, 1, 3, 1, 2, 3, 0, 3, 4]);
geometry.computeVertexNormals();
geometry.name = `crow.wing.${side < 0 ? "left" : "right"}`;
return geometry;
}
function resolveCrow(root: THREE.Group, ownsMaterials: boolean): CrowRig {
return {
root,
ownsMaterials,
joints: {
body: requireGroup(root, "crow.body"),
head: requireGroup(root, "crow.head"),
wingLeft: requireGroup(root, "crow.wing.left"),
wingRight: requireGroup(root, "crow.wing.right"),
tail: requireGroup(root, "crow.tail"),
},
};
}
export function buildCrow(options: CrowBuildOptions = {}): CrowRig {
const m = options.materials ?? createCrowMaterials(options);
const root = namedGroup("crow");
root.userData.kind = "actor";
root.userData.actorType = "anonymous-crow";
root.userData.forwardAxis = "-Z";
const body = namedGroup("crow.body", [0, 0.2, 0]);
root.add(body);
body.add(
actorMesh("crow.torso", new THREE.SphereGeometry(0.13, 12, 9), m.feather, {
rotation: [-0.16, 0, 0],
scale: [0.82, 1.08, 1.34],
}),
actorMesh("crow.breast", new THREE.SphereGeometry(0.105, 10, 8), m.sheen, {
position: [0, 0.012, -0.105],
scale: [0.72, 1, 0.58],
}),
);
const head = namedGroup("crow.head", [0, 0.145, -0.105]);
body.add(head);
head.add(
actorMesh("crow.skull", new THREE.SphereGeometry(0.093, 12, 9), m.feather, { scale: [0.92, 1, 0.95] }),
actorMesh("crow.beak", new THREE.ConeGeometry(0.055, 0.18, 6), m.beak, {
position: [0, -0.018, -0.145],
rotation: [-Math.PI / 2, 0, 0],
scale: [0.72, 1, 0.68],
}),
);
for (const side of [-1, 1] as const) {
const word = side < 0 ? "left" : "right";
head.add(
actorMesh(`crow.eye.${word}`, new THREE.SphereGeometry(0.011, 7, 5), m.eye, {
position: [side * 0.068, 0.02, -0.06],
}),
);
const wing = namedGroup(`crow.wing.${word}`, [side * 0.075, 0.035, 0]);
body.add(wing);
wing.add(actorMesh(`crow.wing-mesh.${word}`, wingGeometry(side), m.feather, { receiveShadow: false }));
}
const tail = namedGroup("crow.tail", [0, -0.01, 0.13]);
body.add(tail);
for (const side of [-1, 0, 1] as const) {
tail.add(
actorMesh(`crow.tail-feather.${side}`, new THREE.ConeGeometry(0.045, 0.25, 4), m.feather, {
position: [side * 0.04, -0.015, 0.12],
rotation: [Math.PI / 2, 0, 0],
scale: [0.72, 1, 0.35],
}),
);
}
for (const side of [-1, 1] as const) {
body.add(
actorMesh(`crow.foot.${side < 0 ? "left" : "right"}`, new THREE.CylinderGeometry(0.012, 0.009, 0.14, 6), m.foot, {
position: [side * 0.048, -0.14, -0.012],
}),
);
}
return resolveCrow(root, !options.materials);
}
export function cloneCrow(source: CrowRig): CrowRig {
return resolveCrow(source.root.clone(true), false);
}
/** Pose a flap. `amount=0` folds the wings; `amount=1` is a broad flight stroke. */
export function poseCrowFlight(rig: CrowRig, phase: number, amount = 1): void {
const strength = THREE.MathUtils.clamp(amount, 0, 1);
const stroke = Math.sin(phase) * 0.72 * strength;
const spread = 0.2 + strength * 0.74;
rig.joints.wingLeft.rotation.z = spread + stroke;
rig.joints.wingRight.rotation.z = -spread - stroke;
rig.joints.wingLeft.rotation.x = -0.12 * strength;
rig.joints.wingRight.rotation.x = -0.12 * strength;
rig.joints.body.rotation.x = 0.08 * Math.cos(phase) * strength;
rig.joints.tail.rotation.x = -0.12 * Math.cos(phase) * strength;
}
export function disposeCrow(rig: CrowRig, options: { disposeMaterials?: boolean } = {}): void {
disposeActor(rig, options);
}
+171
View File
@@ -0,0 +1,171 @@
/** A compact, friendly anonymous office dog with a poseable head, legs and tail. */
import * as THREE from "three";
import {
actorMesh,
disposeActor,
namedGroup,
requireGroup,
type ActorRigBase,
} from "./common.ts";
export const DOG_METRICS = { length: 0.76, shoulderHeight: 0.46, height: 0.74 } as const;
export interface DogMaterials {
coat: THREE.Material;
markings: THREE.Material;
nose: THREE.Material;
collar: THREE.Material;
}
export interface DogBuildOptions {
materials?: DogMaterials;
coatColor?: THREE.ColorRepresentation;
markingsColor?: THREE.ColorRepresentation;
collarColor?: THREE.ColorRepresentation;
}
export interface DogJoints {
body: THREE.Group;
head: THREE.Group;
earLeft: THREE.Group;
earRight: THREE.Group;
tail: THREE.Group;
legFrontLeft: THREE.Group;
legFrontRight: THREE.Group;
legRearLeft: THREE.Group;
legRearRight: THREE.Group;
}
export interface DogRig extends ActorRigBase {
joints: DogJoints;
}
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 }),
};
}
function resolveDog(root: THREE.Group, ownsMaterials: boolean): DogRig {
return {
root,
ownsMaterials,
joints: {
body: requireGroup(root, "dog.body"),
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"),
},
};
}
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.add(body);
body.add(
actorMesh("dog.torso", new THREE.CapsuleGeometry(0.18, 0.34, 5, 10), m.coat, {
rotation: [Math.PI / 2, 0, 0],
scale: [0.82, 1, 0.92],
}),
actorMesh("dog.chest", new THREE.SphereGeometry(0.185, 12, 9), m.markings, {
position: [0, 0.01, -0.17],
scale: [0.72, 1, 0.56],
}),
);
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],
}),
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],
rotation: [Math.PI / 2, 0, 0],
scale: [1, 0.82, 1],
}),
);
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);
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],
}),
);
}
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],
}),
);
}
}
const tail = namedGroup("dog.tail", [0, 0.03, 0.31]);
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);
}
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;
}
export function poseDogAttention(rig: DogRig, lookYaw: number, tailPhase: number): void {
rig.joints.head.rotation.y = THREE.MathUtils.clamp(lookYaw, -0.9, 0.9);
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;
}
export function disposeDog(rig: DogRig, options: { disposeMaterials?: boolean } = {}): void {
disposeActor(rig, options);
}
+261
View File
@@ -0,0 +1,261 @@
/**
* A friendly, original humanoid avatar built entirely from Three.js primitives.
*
* Dimensions are metres, the origin is between the soles, and -Z is forward.
* The face is a slightly curved dark display with an optional caller-owned
* texture. A deployment can build one prototype per appearance/webcam stream,
* then cheaply clone that prototype for repeated views: clones share immutable
* geometry and materials while all animation joints remain independent.
*/
import * as THREE from "three";
import {
actorMesh,
disposeActor,
namedGroup,
requireGroup,
type ActorRigBase,
} from "./common.ts";
export const HUMANOID_METRICS = {
height: 1.76,
shoulderWidth: 0.46,
hipY: 0.94,
eyeY: 1.65,
} as const;
export interface HumanoidMaterials {
skin: THREE.Material;
outfit: THREE.Material;
accent: THREE.Material;
sole: THREE.Material;
hair: THREE.Material;
face: THREE.Material;
}
export interface HumanoidBuildOptions {
materials?: HumanoidMaterials;
skinTone?: THREE.ColorRepresentation;
outfitColor?: THREE.ColorRepresentation;
accentColor?: THREE.ColorRepresentation;
hairColor?: THREE.ColorRepresentation;
/** Caller-owned and never disposed by this asset. */
faceTexture?: THREE.Texture;
bodyShape?: "slim" | "average" | "broad";
}
export interface HumanoidJoints {
pelvis: THREE.Group;
torso: THREE.Group;
head: THREE.Group;
shoulderLeft: THREE.Group;
shoulderRight: THREE.Group;
elbowLeft: THREE.Group;
elbowRight: THREE.Group;
hipLeft: THREE.Group;
hipRight: THREE.Group;
kneeLeft: THREE.Group;
kneeRight: THREE.Group;
}
export interface HumanoidRig extends ActorRigBase {
joints: HumanoidJoints;
/** Front-facing surface for a webcam or generated profile face. */
face: THREE.Mesh;
}
export interface HumanoidPose {
walkPhase?: number;
stride?: number;
armSwing?: number;
headYaw?: number;
headPitch?: number;
}
export function createHumanoidMaterials(options: HumanoidBuildOptions = {}): HumanoidMaterials {
return {
skin: new THREE.MeshStandardMaterial({
name: "humanoid.skin",
color: options.skinTone ?? 0x9b6246,
roughness: 0.68,
}),
outfit: new THREE.MeshStandardMaterial({
name: "humanoid.outfit",
color: options.outfitColor ?? 0x26364b,
roughness: 0.72,
}),
accent: new THREE.MeshStandardMaterial({
name: "humanoid.accent",
color: options.accentColor ?? 0x4fa9c8,
roughness: 0.55,
}),
sole: new THREE.MeshStandardMaterial({
name: "humanoid.sole",
color: 0x181b1e,
roughness: 0.9,
}),
hair: new THREE.MeshStandardMaterial({
name: "humanoid.hair",
color: options.hairColor ?? 0x241b18,
roughness: 0.86,
}),
face: new THREE.MeshBasicMaterial({
name: "humanoid.face",
color: options.faceTexture ? 0xffffff : 0x18242b,
map: options.faceTexture ?? null,
toneMapped: false,
}),
};
}
function limb(
joint: THREE.Group,
name: string,
material: THREE.Material,
radius: number,
length: number,
): void {
joint.add(
actorMesh(name, new THREE.CapsuleGeometry(radius, Math.max(0.01, length - radius * 2), 4, 8), material, {
position: [0, -length / 2, 0],
}),
);
}
function resolveHumanoid(root: THREE.Group, ownsMaterials: boolean): HumanoidRig {
const face = root.getObjectByName("humanoid.face");
if (!(face instanceof THREE.Mesh)) throw new Error("humanoid: missing face surface");
return {
root,
ownsMaterials,
face,
joints: {
pelvis: requireGroup(root, "humanoid.pelvis"),
torso: requireGroup(root, "humanoid.torso"),
head: requireGroup(root, "humanoid.head"),
shoulderLeft: requireGroup(root, "humanoid.shoulder.left"),
shoulderRight: requireGroup(root, "humanoid.shoulder.right"),
elbowLeft: requireGroup(root, "humanoid.elbow.left"),
elbowRight: requireGroup(root, "humanoid.elbow.right"),
hipLeft: requireGroup(root, "humanoid.hip.left"),
hipRight: requireGroup(root, "humanoid.hip.right"),
kneeLeft: requireGroup(root, "humanoid.knee.left"),
kneeRight: requireGroup(root, "humanoid.knee.right"),
},
};
}
export function buildHumanoid(options: HumanoidBuildOptions = {}): HumanoidRig {
const materials = options.materials ?? createHumanoidMaterials(options);
const shape = options.bodyShape ?? "average";
const widthScale = shape === "slim" ? 0.86 : shape === "broad" ? 1.14 : 1;
const root = namedGroup("humanoid");
root.userData.kind = "actor";
root.userData.actorType = "humanoid";
root.userData.forwardAxis = "-Z";
const pelvis = namedGroup("humanoid.pelvis", [0, HUMANOID_METRICS.hipY, 0]);
root.add(pelvis);
pelvis.add(
actorMesh("humanoid.pelvis.shell", new THREE.CapsuleGeometry(0.12, 0.12, 4, 8), materials.outfit, {
scale: [1.25 * widthScale, 0.75, 0.9],
position: [0, 0.02, 0],
}),
);
const torso = namedGroup("humanoid.torso", [0, 0.11, 0]);
pelvis.add(torso);
torso.add(
actorMesh("humanoid.chest", new THREE.CapsuleGeometry(0.18, 0.25, 5, 10), materials.outfit, {
position: [0, 0.24, 0],
scale: [1.08 * widthScale, 1, 0.72],
}),
actorMesh("humanoid.chest.accent", new THREE.BoxGeometry(0.2 * widthScale, 0.065, 0.018), materials.accent, {
position: [0, 0.32, -0.134],
}),
);
const head = namedGroup("humanoid.head", [0, 0.58, 0]);
torso.add(head);
head.add(
actorMesh("humanoid.head.shell", new THREE.SphereGeometry(0.12, 16, 12), materials.skin, {
position: [0, 0.1, 0],
scale: [0.86, 1.05, 0.86],
}),
actorMesh("humanoid.hair", new THREE.SphereGeometry(0.122, 14, 8, 0, Math.PI * 2, 0, Math.PI * 0.47), materials.hair, {
position: [0, 0.115, 0.004],
scale: [0.88, 1.06, 0.88],
}),
);
const face = actorMesh("humanoid.face", new THREE.PlaneGeometry(0.125, 0.105, 2, 2), materials.face, {
position: [0, 0.09, -0.105],
rotation: [0, Math.PI, 0],
receiveShadow: false,
});
head.add(face);
const shoulderY = 0.47;
for (const side of [-1, 1] as const) {
const word = side < 0 ? "left" : "right";
const shoulder = namedGroup(`humanoid.shoulder.${word}`, [side * 0.23 * widthScale, shoulderY, 0]);
torso.add(shoulder);
limb(shoulder, `humanoid.upper-arm.${word}`, materials.outfit, 0.066, 0.3);
const elbow = namedGroup(`humanoid.elbow.${word}`, [0, -0.3, 0]);
shoulder.add(elbow);
limb(elbow, `humanoid.forearm.${word}`, materials.skin, 0.055, 0.27);
elbow.add(
actorMesh(`humanoid.hand.${word}`, new THREE.SphereGeometry(0.065, 10, 8), materials.skin, {
position: [0, -0.295, -0.012],
scale: [0.72, 1.08, 0.55],
}),
);
}
for (const side of [-1, 1] as const) {
const word = side < 0 ? "left" : "right";
const hip = namedGroup(`humanoid.hip.${word}`, [side * 0.105 * widthScale, -0.02, 0]);
pelvis.add(hip);
limb(hip, `humanoid.thigh.${word}`, materials.outfit, 0.085, 0.43);
const knee = namedGroup(`humanoid.knee.${word}`, [0, -0.43, 0]);
hip.add(knee);
limb(knee, `humanoid.shin.${word}`, materials.outfit, 0.072, 0.4);
knee.add(
actorMesh(`humanoid.shoe.${word}`, new THREE.BoxGeometry(0.15, 0.09, 0.25), materials.sole, {
position: [0, -0.405, -0.055],
}),
);
}
return resolveHumanoid(root, !options.materials);
}
/** Clone the hierarchy while sharing geometry and materials. */
export function cloneHumanoid(source: HumanoidRig): HumanoidRig {
return resolveHumanoid(source.root.clone(true), false);
}
export function poseHumanoid(rig: HumanoidRig, pose: HumanoidPose = {}): void {
const phase = pose.walkPhase ?? 0;
const stride = THREE.MathUtils.clamp(pose.stride ?? 0, 0, 0.75);
const arm = THREE.MathUtils.clamp(pose.armSwing ?? stride * 0.85, 0, 0.7);
const swing = Math.sin(phase);
const bendLeft = Math.max(0, -swing) * stride * 0.7;
const bendRight = Math.max(0, swing) * stride * 0.7;
rig.joints.hipLeft.rotation.x = swing * stride;
rig.joints.hipRight.rotation.x = -swing * stride;
rig.joints.kneeLeft.rotation.x = -bendLeft;
rig.joints.kneeRight.rotation.x = -bendRight;
rig.joints.shoulderLeft.rotation.x = -swing * arm;
rig.joints.shoulderRight.rotation.x = swing * arm;
rig.joints.elbowLeft.rotation.x = 0.1 + Math.max(0, swing) * 0.18;
rig.joints.elbowRight.rotation.x = 0.1 + Math.max(0, -swing) * 0.18;
rig.joints.pelvis.position.y = HUMANOID_METRICS.hipY + Math.abs(Math.cos(phase)) * stride * 0.018;
rig.joints.head.rotation.y = THREE.MathUtils.clamp(pose.headYaw ?? 0, -0.9, 0.9);
rig.joints.head.rotation.x = THREE.MathUtils.clamp(pose.headPitch ?? 0, -0.45, 0.45);
}
export function disposeHumanoid(
rig: HumanoidRig,
options: { disposeMaterials?: boolean } = {},
): void {
disposeActor(rig, options);
}
+40
View File
@@ -0,0 +1,40 @@
export {
HUMANOID_METRICS,
buildHumanoid,
cloneHumanoid,
createHumanoidMaterials,
disposeHumanoid,
poseHumanoid,
type HumanoidBuildOptions,
type HumanoidJoints,
type HumanoidMaterials,
type HumanoidPose,
type HumanoidRig,
} from "./humanoid.ts";
export {
DOG_METRICS,
buildDog,
cloneDog,
createDogMaterials,
disposeDog,
poseDogAttention,
poseDogWalk,
type DogBuildOptions,
type DogJoints,
type DogMaterials,
type DogRig,
} from "./dog.ts";
export {
CROW_METRICS,
buildCrow,
cloneCrow,
createCrowMaterials,
disposeCrow,
poseCrowFlight,
type CrowBuildOptions,
type CrowJoints,
type CrowMaterials,
type CrowRig,
} from "./crow.ts";
+18
View File
@@ -0,0 +1,18 @@
export {
MODEL_X_METRICS,
advanceModelXWheels,
buildModelX,
cloneModelX,
createModelXMaterials,
disposeModelX,
modelXInstanceParts,
setModelXSteering,
setModelXWheelRotation,
type ModelXBuildOptions,
type ModelXDetail,
type ModelXInstancePart,
type ModelXMaterials,
type ModelXRig,
type ModelXWheel,
type ModelXWheels,
} from "./modelX.ts";
+589
View File
@@ -0,0 +1,589 @@
/**
* A procedural, unbadged black Model X-style electric crossover.
*
* The asset is authored in metres with its origin on the road at the centre of
* the wheelbase. It faces -Z at yaw zero, matching the rest of Tera; +X is the
* vehicle's right. The broad, low nose, rising panoramic glass, cab-forward
* roof and tapered tail carry the silhouette. Fine detail is deliberately
* sparse because this car is normally read from a corridor or chase camera.
*
* Build one rig and clone it. Clones share geometry and materials while their
* wheel and steering joints remain independent. Dispose the original only
* after every clone has left the scene.
*/
import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
export const MODEL_X_METRICS = {
length: 5.04,
width: 2.08,
height: 1.68,
wheelbase: 2.965,
track: 1.78,
wheelRadius: 0.405,
tireWidth: 0.285,
maxSteeringAngle: 0.62,
} as const;
export type ModelXDetail = "corridor" | "follow";
export interface ModelXMaterials {
paint: THREE.Material;
glass: THREE.Material;
trim: THREE.Material;
tire: THREE.Material;
wheel: THREE.Material;
brake: THREE.Material;
headlight: THREE.Material;
tailLight: THREE.Material;
}
export interface ModelXBuildOptions {
detail?: ModelXDetail;
/** Used only when `materials` is omitted. */
paint?: THREE.ColorRepresentation;
/** A complete externally owned skin, useful for a world-level material pool. */
materials?: ModelXMaterials;
}
export interface ModelXWheel {
/** Yaw this group to steer. Rear steering groups stay at zero. */
steering: THREE.Group;
/** Rotate this group around local X to roll the wheel. */
spin: THREE.Group;
tire: THREE.Mesh;
rim: THREE.Mesh;
}
export interface ModelXWheels {
frontLeft: ModelXWheel;
frontRight: ModelXWheel;
rearLeft: ModelXWheel;
rearRight: ModelXWheel;
}
export interface ModelXRig {
root: THREE.Group;
/** Static bodywork; useful when a traffic renderer instances body meshes. */
body: THREE.Group;
wheels: ModelXWheels;
/** True only on a prototype that created its own default material set. */
readonly ownsMaterials: boolean;
}
export interface ModelXInstancePart {
name: string;
geometry: THREE.BufferGeometry;
material: THREE.Material | THREE.Material[];
/** Transform from the vehicle root to this drawable in the neutral pose. */
matrix: THREE.Matrix4;
castShadow: boolean;
receiveShadow: boolean;
}
const UNIT_BOX = new THREE.BoxGeometry(1, 1, 1);
const UNIT_PLANE = new THREE.PlaneGeometry(1, 1);
/** Default materials are intentionally untextured: no binary art and no UV dependency. */
export function createModelXMaterials(
paint: THREE.ColorRepresentation = 0x050607,
): ModelXMaterials {
const body = new THREE.MeshPhysicalMaterial({
name: "model-x.paint",
color: paint,
metalness: 0.72,
roughness: 0.22,
clearcoat: 1,
clearcoatRoughness: 0.12,
});
return {
paint: body,
glass: new THREE.MeshPhysicalMaterial({
name: "model-x.glass",
color: 0x101b22,
metalness: 0.12,
roughness: 0.12,
transparent: true,
opacity: 0.86,
side: THREE.DoubleSide,
}),
trim: new THREE.MeshStandardMaterial({
name: "model-x.trim",
color: 0x101214,
metalness: 0.68,
roughness: 0.3,
}),
tire: new THREE.MeshStandardMaterial({
name: "model-x.tire",
color: 0x111213,
metalness: 0,
roughness: 0.92,
}),
wheel: new THREE.MeshStandardMaterial({
name: "model-x.wheel",
color: 0x2d3135,
metalness: 0.88,
roughness: 0.25,
}),
brake: new THREE.MeshStandardMaterial({
name: "model-x.brake",
color: 0x72777a,
metalness: 0.92,
roughness: 0.32,
}),
headlight: new THREE.MeshStandardMaterial({
name: "model-x.headlight",
color: 0xd9f4ff,
emissive: 0xb8eaff,
emissiveIntensity: 2.4,
roughness: 0.16,
}),
tailLight: new THREE.MeshStandardMaterial({
name: "model-x.tail-light",
color: 0xff2433,
emissive: 0xd70918,
emissiveIntensity: 1.9,
roughness: 0.2,
}),
};
}
interface Placement {
position?: readonly [number, number, number];
scale?: readonly [number, number, number];
rotation?: readonly [number, number, number];
}
function matrix(place: Placement): THREE.Matrix4 {
const position = new THREE.Vector3(...(place.position ?? [0, 0, 0]));
const quaternion = new THREE.Quaternion().setFromEuler(
new THREE.Euler(...(place.rotation ?? [0, 0, 0]), "YXZ"),
);
const scale = new THREE.Vector3(...(place.scale ?? [1, 1, 1]));
return new THREE.Matrix4().compose(position, quaternion, scale);
}
class StaticBatch {
private readonly groups = new Map<THREE.Material, THREE.BufferGeometry[]>();
add(geometry: THREE.BufferGeometry, material: THREE.Material, place: Placement = {}): void {
const transformed = geometry.clone().applyMatrix4(matrix(place));
const found = this.groups.get(material);
if (found) found.push(transformed);
else this.groups.set(material, [transformed]);
}
/** Add a one-off generated shape, then release its untransformed source. */
addOwned(geometry: THREE.BufferGeometry, material: THREE.Material, place: Placement = {}): void {
this.add(geometry, material, place);
geometry.dispose();
}
box(material: THREE.Material, place: Placement): void {
this.add(UNIT_BOX, material, place);
}
build(name: string): THREE.Group {
const group = new THREE.Group();
group.name = name;
for (const [material, geometries] of this.groups) {
const geometry =
geometries.length === 1 ? geometries[0] : mergeGeometries(geometries, false);
if (geometries.length > 1) for (const item of geometries) item.dispose();
if (!geometry) continue;
const mesh = new THREE.Mesh(geometry, material);
mesh.name = `${name}:${material.name || "surface"}`;
mesh.castShadow = true;
mesh.receiveShadow = true;
group.add(mesh);
}
this.groups.clear();
return group;
}
}
interface BodySection {
z: number;
halfWidth: number;
bottom: number;
shoulder: number;
belt: number;
roof: number;
}
const BODY_SECTIONS: readonly BodySection[] = [
{ z: -2.52, halfWidth: 0.58, bottom: 0.55, shoulder: 0.68, belt: 0.79, roof: 0.84 },
{ z: -2.34, halfWidth: 0.88, bottom: 0.47, shoulder: 0.69, belt: 0.88, roof: 0.93 },
{ z: -1.58, halfWidth: 1.0, bottom: 0.4, shoulder: 0.72, belt: 0.98, roof: 1.08 },
{ z: -0.78, halfWidth: 0.99, bottom: 0.39, shoulder: 0.76, belt: 1.06, roof: 1.32 },
{ z: -0.12, halfWidth: 0.97, bottom: 0.39, shoulder: 0.78, belt: 1.12, roof: 1.58 },
{ z: 0.72, halfWidth: 0.96, bottom: 0.4, shoulder: 0.78, belt: 1.1, roof: 1.68 },
{ z: 1.48, halfWidth: 0.97, bottom: 0.41, shoulder: 0.77, belt: 1.06, roof: 1.52 },
{ z: 2.28, halfWidth: 0.89, bottom: 0.48, shoulder: 0.72, belt: 0.98, roof: 1.18 },
{ z: 2.52, halfWidth: 0.63, bottom: 0.57, shoulder: 0.69, belt: 0.82, roof: 0.9 },
] as const;
/** A low-poly longitudinal loft with deliberately strong shoulder highlights. */
function bodyLoft(): THREE.BufferGeometry {
const positions: number[] = [];
const indices: number[] = [];
const ring = 10;
for (const s of BODY_SECTIONS) {
const points: readonly [number, number][] = [
[-s.halfWidth * 0.7, s.bottom],
[-s.halfWidth, s.shoulder],
[-s.halfWidth * 0.98, s.belt],
[-s.halfWidth * 0.83, s.roof - 0.08],
[-s.halfWidth * 0.58, s.roof],
[s.halfWidth * 0.58, s.roof],
[s.halfWidth * 0.83, s.roof - 0.08],
[s.halfWidth * 0.98, s.belt],
[s.halfWidth, s.shoulder],
[s.halfWidth * 0.7, s.bottom],
];
for (const [x, y] of points) positions.push(x, y, s.z);
}
for (let z = 0; z < BODY_SECTIONS.length - 1; z++) {
for (let i = 0; i < ring; i++) {
const next = (i + 1) % ring;
const a = z * ring + i;
const b = z * ring + next;
const c = (z + 1) * ring + next;
const d = (z + 1) * ring + i;
indices.push(a, b, d, b, c, d);
}
}
const frontCenter = positions.length / 3;
positions.push(0, 0.72, BODY_SECTIONS[0]!.z);
const rearCenter = positions.length / 3;
positions.push(0, 0.74, BODY_SECTIONS[BODY_SECTIONS.length - 1]!.z);
for (let i = 0; i < ring; i++) {
const next = (i + 1) % ring;
indices.push(frontCenter, next, i);
const base = (BODY_SECTIONS.length - 1) * ring;
indices.push(rearCenter, base + i, base + next);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
geometry.name = "model-x.body-loft";
return geometry;
}
function quad(points: readonly [number, number, number][]): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(points.flat(), 3));
geometry.setIndex([0, 1, 2, 0, 2, 3]);
geometry.computeVertexNormals();
return geometry;
}
function buildBody(materials: ModelXMaterials, detail: ModelXDetail): THREE.Group {
const batch = new StaticBatch();
batch.addOwned(bodyLoft(), materials.paint);
// The windows are fitted panels rather than holes. Against black paint their
// blue-grey reflectance is what separates the greenhouse from the body.
batch.addOwned(
quad([
[-0.77, 1.04, -0.79],
[0.77, 1.04, -0.79],
[0.63, 1.56, -0.14],
[-0.63, 1.56, -0.14],
]),
materials.glass,
);
batch.addOwned(
quad([
[-0.61, 1.58, -0.1],
[0.61, 1.58, -0.1],
[0.58, 1.64, 1.18],
[-0.58, 1.64, 1.18],
]),
materials.glass,
);
batch.addOwned(
quad([
[-0.65, 1.5, 1.45],
[0.65, 1.5, 1.45],
[0.72, 1.04, 2.12],
[-0.72, 1.04, 2.12],
]),
materials.glass,
);
for (const side of [-1, 1]) {
const x = side * 0.956;
batch.addOwned(
quad([
[x, 1.06, -0.71],
[x, 1.55, -0.08],
[x, 1.59, 0.3],
[x, 1.06, 0.3],
]),
materials.glass,
);
batch.addOwned(
quad([
[x, 1.06, 0.34],
[x, 1.59, 0.34],
[side * 0.94, 1.49, 1.34],
[side * 0.96, 1.05, 1.46],
]),
materials.glass,
);
// Slim pillars and the falcon-door roof seam survive a chase camera while
// keeping the side glass readable as two doors rather than one dark strip.
batch.box(materials.trim, {
position: [x, 1.31, 0.32],
scale: [0.028, 0.56, 0.045],
});
batch.box(materials.trim, {
position: [side * 0.76, 1.625, 0.73],
scale: [0.022, 0.025, 0.86],
rotation: [0, 0, side * -0.08],
});
// Flush black handles: relief and a highlight, never a badge.
if (detail === "follow") {
for (const z of [-0.24, 0.82]) {
batch.box(materials.trim, {
position: [side * 0.987, 1.025, z],
scale: [0.018, 0.035, 0.22],
});
}
}
// Headlights sweep back into the fender; taillights wrap the rear corner.
batch.box(materials.headlight, {
position: [side * 0.65, 0.88, -2.39],
scale: [0.56, 0.085, 0.035],
rotation: [0, side * 0.13, side * 0.04],
});
batch.box(materials.tailLight, {
position: [side * 0.65, 0.97, 2.35],
scale: [0.58, 0.075, 0.04],
rotation: [0, side * -0.12, side * -0.03],
});
}
// Lower aero surfaces stop the black shell dissolving into the road.
batch.box(materials.trim, {
position: [0, 0.39, -1.92],
scale: [1.82, 0.13, 1.02],
});
batch.box(materials.trim, {
position: [0, 0.42, 2.32],
scale: [1.5, 0.15, 0.3],
});
batch.add(UNIT_PLANE, materials.trim, {
position: [0, 1.685, 0.68],
scale: [1.18, 0.72, 1],
rotation: [-Math.PI / 2, 0, 0],
});
const body = batch.build("model-x.body");
body.userData.kind = "vehicle-body";
return body;
}
interface WheelGeometry {
tire: THREE.BufferGeometry;
rim: THREE.BufferGeometry;
brake: THREE.BufferGeometry;
}
function createWheelGeometry(detail: ModelXDetail): WheelGeometry {
const radialSegments = detail === "follow" ? 24 : 16;
const tubularSegments = detail === "follow" ? 12 : 8;
const tire = new THREE.TorusGeometry(0.315, 0.09, tubularSegments, radialSegments);
tire.rotateY(Math.PI / 2);
tire.name = "model-x.wheel.tire";
const rimParts: THREE.BufferGeometry[] = [];
const lip = new THREE.TorusGeometry(0.218, 0.024, 6, radialSegments);
lip.rotateY(Math.PI / 2);
rimParts.push(lip);
const hub = new THREE.CylinderGeometry(0.067, 0.067, 0.1, 12, 1);
hub.rotateZ(Math.PI / 2);
rimParts.push(hub);
for (let i = 0; i < 5; i++) {
const angle = (i / 5) * Math.PI * 2;
const spoke = UNIT_BOX.clone().applyMatrix4(
matrix({
position: [0, Math.cos(angle) * 0.13, Math.sin(angle) * 0.13],
scale: [0.055, 0.255, 0.035],
rotation: [angle, 0, 0],
}),
);
rimParts.push(spoke);
}
const rim = mergeGeometries(rimParts, false);
for (const part of rimParts) part.dispose();
if (!rim) throw new Error("model-x: could not build wheel rim");
rim.name = "model-x.wheel.rim";
const brake = new THREE.CylinderGeometry(0.17, 0.17, MODEL_X_METRICS.tireWidth + 0.018, 20, 1);
brake.rotateZ(Math.PI / 2);
brake.name = "model-x.wheel.brake";
return { tire, rim, brake };
}
function buildWheel(
name: string,
x: number,
z: number,
geometries: WheelGeometry,
materials: ModelXMaterials,
): ModelXWheel {
const steering = new THREE.Group();
steering.name = `${name}.steering`;
steering.position.set(x, MODEL_X_METRICS.wheelRadius, z);
const spin = new THREE.Group();
spin.name = `${name}.spin`;
steering.add(spin);
const brake = new THREE.Mesh(geometries.brake, materials.brake);
brake.name = `${name}.brake`;
brake.castShadow = true;
spin.add(brake);
const rim = new THREE.Mesh(geometries.rim, materials.wheel);
rim.name = `${name}.rim`;
rim.castShadow = true;
spin.add(rim);
const tire = new THREE.Mesh(geometries.tire, materials.tire);
tire.name = `${name}.tire`;
tire.castShadow = true;
tire.receiveShadow = true;
spin.add(tire);
return { steering, spin, tire, rim };
}
const WHEEL_KEYS = ["frontLeft", "frontRight", "rearLeft", "rearRight"] as const;
function resolveRig(root: THREE.Group, ownsMaterials: boolean): ModelXRig {
const body = root.getObjectByName("model-x.body");
if (!(body instanceof THREE.Group)) throw new Error("model-x: body group is missing");
const wheels = {} as Record<(typeof WHEEL_KEYS)[number], ModelXWheel>;
for (const key of WHEEL_KEYS) {
const steering = root.getObjectByName(`${key}.steering`);
const spin = root.getObjectByName(`${key}.spin`);
const tire = root.getObjectByName(`${key}.tire`);
const rim = root.getObjectByName(`${key}.rim`);
if (!(steering instanceof THREE.Group) || !(spin instanceof THREE.Group)) {
throw new Error(`model-x: wheel rig "${key}" is incomplete`);
}
if (!(tire instanceof THREE.Mesh) || !(rim instanceof THREE.Mesh)) {
throw new Error(`model-x: wheel meshes for "${key}" are missing`);
}
wheels[key] = { steering, spin, tire, rim };
}
return { root, body, wheels, ownsMaterials };
}
export function buildModelX(options: ModelXBuildOptions = {}): ModelXRig {
const detail = options.detail ?? "follow";
const materials = options.materials ?? createModelXMaterials(options.paint);
const root = new THREE.Group();
root.name = "model-x";
root.userData.kind = "vehicle";
root.userData.vehicleModel = "model-x";
root.userData.forwardAxis = "-Z";
root.add(buildBody(materials, detail));
const geometry = createWheelGeometry(detail);
const halfTrack = MODEL_X_METRICS.track / 2;
const halfWheelbase = MODEL_X_METRICS.wheelbase / 2;
const wheels: ModelXWheels = {
frontLeft: buildWheel("frontLeft", -halfTrack, -halfWheelbase, geometry, materials),
frontRight: buildWheel("frontRight", halfTrack, -halfWheelbase, geometry, materials),
rearLeft: buildWheel("rearLeft", -halfTrack, halfWheelbase, geometry, materials),
rearRight: buildWheel("rearRight", halfTrack, halfWheelbase, geometry, materials),
};
for (const key of WHEEL_KEYS) root.add(wheels[key].steering);
return { root, body: root.getObjectByName("model-x.body") as THREE.Group, wheels, ownsMaterials: !options.materials };
}
/** Clone the hierarchy while sharing all immutable geometry and material resources. */
export function cloneModelX(source: ModelXRig): ModelXRig {
return resolveRig(source.root.clone(true), false);
}
export function setModelXWheelRotation(rig: ModelXRig, radians: number): void {
for (const key of WHEEL_KEYS) rig.wheels[key].spin.rotation.x = radians;
}
export function advanceModelXWheels(rig: ModelXRig, distanceMetres: number): void {
const delta = -distanceMetres / MODEL_X_METRICS.wheelRadius;
for (const key of WHEEL_KEYS) rig.wheels[key].spin.rotation.x += delta;
}
/** Set both front wheels, clamped to the physical steering envelope. */
export function setModelXSteering(rig: ModelXRig, radians: number): void {
const angle = THREE.MathUtils.clamp(
radians,
-MODEL_X_METRICS.maxSteeringAngle,
MODEL_X_METRICS.maxSteeringAngle,
);
rig.wheels.frontLeft.steering.rotation.y = angle;
rig.wheels.frontRight.steering.rotation.y = angle;
}
/**
* Describe neutral-pose drawables for a traffic renderer that groups matching
* pieces into InstancedMesh batches. Matrices are cloned and safe to retain.
*/
export function modelXInstanceParts(rig: ModelXRig): ModelXInstancePart[] {
rig.root.updateMatrixWorld(true);
const inverseRoot = rig.root.matrixWorld.clone().invert();
const result: ModelXInstancePart[] = [];
rig.root.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
result.push({
name: object.name,
geometry: object.geometry,
material: object.material,
matrix: inverseRoot.clone().multiply(object.matrixWorld),
castShadow: object.castShadow,
receiveShadow: object.receiveShadow,
});
});
return result;
}
/**
* Dispose the prototype's unique geometry, and its default materials if owned.
* Never dispose a clone: it references the same GPU resources as the prototype.
*/
export function disposeModelX(
rig: ModelXRig,
options: { disposeMaterials?: boolean } = {},
): void {
const geometries = new Set<THREE.BufferGeometry>();
const materials = new Set<THREE.Material>();
rig.root.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
geometries.add(object.geometry);
if (Array.isArray(object.material)) {
for (const material of object.material) materials.add(material);
} else materials.add(object.material);
});
for (const geometry of geometries) geometry.dispose();
if (options.disposeMaterials ?? rig.ownsMaterials) {
for (const material of materials) material.dispose();
}
}