California gets roads, traffic, and a car to follow
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user