feat: build articulated crow flight v2
This commit is contained in:
+412
-71
@@ -1,5 +1,13 @@
|
||||
/** A low-cost anonymous Tera crow, ready to perch or flap in flight. */
|
||||
/**
|
||||
* Procedural American-crow-scale actor with a readable rear flight silhouette.
|
||||
*
|
||||
* The rig remains code-only and cheap to clone, but the wing is no longer one
|
||||
* flat polygon. Shoulder, elbow and wrist joints carry overlapping covert,
|
||||
* secondary and primary feathers, so the renderer can describe a flap, glide,
|
||||
* bank, tuck or perch without replacing geometry.
|
||||
*/
|
||||
import * as THREE from "three";
|
||||
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
||||
import {
|
||||
actorMesh,
|
||||
disposeActor,
|
||||
@@ -8,7 +16,25 @@ import {
|
||||
type ActorRigBase,
|
||||
} from "./common.ts";
|
||||
|
||||
export const CROW_METRICS = { bodyLength: 0.42, perchedHeight: 0.34, wingspan: 0.84 } as const;
|
||||
export const CROW_METRICS = {
|
||||
bodyLength: 0.48,
|
||||
perchedHeight: 0.44,
|
||||
wingspan: 1.02,
|
||||
} as const;
|
||||
|
||||
export type CrowPoseState = "flap" | "glide" | "bank" | "tuck" | "perch";
|
||||
|
||||
export interface CrowPose {
|
||||
state: CrowPoseState;
|
||||
/** Cyclic flap phase in radians. */
|
||||
phase?: number;
|
||||
/** Pose strength in [0, 1]. */
|
||||
amount?: number;
|
||||
/** Signed bank request in [-1, 1]; positive banks right. */
|
||||
bank?: number;
|
||||
/** Tucks the feet as this reaches one. */
|
||||
flight?: number;
|
||||
}
|
||||
|
||||
export interface CrowMaterials {
|
||||
feather: THREE.Material;
|
||||
@@ -27,9 +53,18 @@ export interface CrowBuildOptions {
|
||||
export interface CrowJoints {
|
||||
body: THREE.Group;
|
||||
head: THREE.Group;
|
||||
/** Backwards-compatible aliases for the shoulder joints. */
|
||||
wingLeft: THREE.Group;
|
||||
wingRight: THREE.Group;
|
||||
shoulderLeft: THREE.Group;
|
||||
shoulderRight: THREE.Group;
|
||||
elbowLeft: THREE.Group;
|
||||
elbowRight: THREE.Group;
|
||||
wristLeft: THREE.Group;
|
||||
wristRight: THREE.Group;
|
||||
tail: THREE.Group;
|
||||
legLeft: THREE.Group;
|
||||
legRight: THREE.Group;
|
||||
}
|
||||
|
||||
export interface CrowRig extends ActorRigBase {
|
||||
@@ -38,124 +73,430 @@ export interface CrowRig extends ActorRigBase {
|
||||
|
||||
export function createCrowMaterials(options: CrowBuildOptions = {}): CrowMaterials {
|
||||
return {
|
||||
// Wing sheets are intentionally thin. Both faces must render because a
|
||||
// chase camera sees their backs while a flyover camera sees their fronts.
|
||||
feather: new THREE.MeshStandardMaterial({ name: "crow.feather", color: options.featherColor ?? 0x111519, roughness: 0.62, metalness: 0.12, side: THREE.DoubleSide }),
|
||||
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 }),
|
||||
// A blue-violet highlight keeps the bird readable against black terrain
|
||||
// without painting a naturally black animal grey.
|
||||
feather: new THREE.MeshStandardMaterial({
|
||||
name: "crow.feather",
|
||||
color: options.featherColor ?? 0x111821,
|
||||
roughness: 0.68,
|
||||
metalness: 0.08,
|
||||
emissive: 0x07111d,
|
||||
emissiveIntensity: 0.24,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
sheen: new THREE.MeshPhysicalMaterial({
|
||||
name: "crow.sheen",
|
||||
color: options.sheenColor ?? 0x253547,
|
||||
roughness: 0.34,
|
||||
metalness: 0.22,
|
||||
clearcoat: 0.28,
|
||||
clearcoatRoughness: 0.42,
|
||||
emissive: 0x0b1729,
|
||||
emissiveIntensity: 0.32,
|
||||
}),
|
||||
beak: new THREE.MeshStandardMaterial({
|
||||
name: "crow.beak",
|
||||
color: 0x252a30,
|
||||
roughness: 0.72,
|
||||
metalness: 0.08,
|
||||
}),
|
||||
eye: new THREE.MeshBasicMaterial({ name: "crow.eye", color: 0xd9b86c, toneMapped: false }),
|
||||
foot: new THREE.MeshStandardMaterial({ name: "crow.foot", color: 0x2b2f35, roughness: 0.9 }),
|
||||
};
|
||||
}
|
||||
|
||||
function wingGeometry(side: -1 | 1): THREE.BufferGeometry {
|
||||
const s = side;
|
||||
/** A small double-sided feather prism, rooted at z=0 and tapered along +Z. */
|
||||
function featherGeometry(): THREE.BufferGeometry {
|
||||
const outline: readonly [number, number][] = [
|
||||
[-0.28, 0],
|
||||
[-0.5, 0.46],
|
||||
[-0.42, 0.76],
|
||||
[-0.18, 0.95],
|
||||
[0, 1],
|
||||
[0.18, 0.95],
|
||||
[0.42, 0.76],
|
||||
[0.5, 0.46],
|
||||
[0.28, 0],
|
||||
];
|
||||
const halfThickness = 0.012;
|
||||
const positions: number[] = [];
|
||||
for (const y of [halfThickness, -halfThickness]) {
|
||||
for (const [x, z] of outline) positions.push(x, y, z);
|
||||
}
|
||||
const count = outline.length;
|
||||
const indices: number[] = [];
|
||||
// Top and bottom are triangle fans around the root-side midpoint.
|
||||
for (let index = 1; index < count - 1; index += 1) {
|
||||
indices.push(0, index, index + 1);
|
||||
indices.push(count, count + index + 1, count + index);
|
||||
}
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const next = (index + 1) % count;
|
||||
indices.push(index, count + index, next, next, count + index, count + next);
|
||||
}
|
||||
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.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||
geometry.setIndex(indices);
|
||||
geometry.computeVertexNormals();
|
||||
geometry.name = `crow.wing.${side < 0 ? "left" : "right"}`;
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
geometry.name = "crow.feather-blade";
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function beakGeometry(): THREE.BufferGeometry {
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute([
|
||||
-0.054, 0.025, 0,
|
||||
0.054, 0.025, 0,
|
||||
-0.043, -0.035, 0,
|
||||
0.043, -0.035, 0,
|
||||
0, -0.018, -0.19,
|
||||
], 3));
|
||||
geometry.setIndex([
|
||||
0, 1, 4,
|
||||
1, 3, 4,
|
||||
3, 2, 4,
|
||||
2, 0, 4,
|
||||
0, 2, 3,
|
||||
0, 3, 1,
|
||||
]);
|
||||
geometry.computeVertexNormals();
|
||||
geometry.name = "crow.beak-wedge";
|
||||
return geometry;
|
||||
}
|
||||
|
||||
interface FeatherPlacement {
|
||||
position: readonly [number, number, number];
|
||||
scale: readonly [number, number, number];
|
||||
rotation?: readonly [number, number, number];
|
||||
}
|
||||
|
||||
/** Merge one articulated feather layer into one draw call. */
|
||||
function featherBatch(
|
||||
name: string,
|
||||
material: THREE.Material,
|
||||
placements: readonly FeatherPlacement[],
|
||||
blade: THREE.BufferGeometry,
|
||||
): THREE.Mesh {
|
||||
const parts = placements.map((placement) => {
|
||||
const matrix = new THREE.Matrix4().compose(
|
||||
new THREE.Vector3(...placement.position),
|
||||
new THREE.Quaternion().setFromEuler(new THREE.Euler(...(placement.rotation ?? [0, 0, 0]), "XYZ")),
|
||||
new THREE.Vector3(...placement.scale),
|
||||
);
|
||||
return blade.clone().applyMatrix4(matrix);
|
||||
});
|
||||
const geometry = mergeGeometries(parts, false);
|
||||
for (const part of parts) part.dispose();
|
||||
if (!geometry) throw new Error(`crow: could not merge feather layer "${name}"`);
|
||||
geometry.name = `${name}.geometry`;
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
const mesh = actorMesh(name, geometry, material, { receiveShadow: false });
|
||||
mesh.userData.featherCount = placements.length;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function addWing(
|
||||
body: THREE.Group,
|
||||
side: -1 | 1,
|
||||
materials: CrowMaterials,
|
||||
featherBlade: THREE.BufferGeometry,
|
||||
): { shoulder: THREE.Group; elbow: THREE.Group; wrist: THREE.Group } {
|
||||
const word = side < 0 ? "left" : "right";
|
||||
const shoulder = namedGroup(`crow.wing.${word}`, [side * 0.09, 0.055, -0.035]);
|
||||
const elbow = namedGroup(`crow.elbow.${word}`, [side * 0.175, 0, 0.012]);
|
||||
const wrist = namedGroup(`crow.wrist.${word}`, [side * 0.165, -0.004, 0.045]);
|
||||
shoulder.add(elbow);
|
||||
elbow.add(wrist);
|
||||
body.add(shoulder);
|
||||
|
||||
// Rounded scapular coverts bridge the torso to the articulated arm.
|
||||
const coverts: FeatherPlacement[] = [];
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
coverts.push({
|
||||
position: [side * (0.025 + index * 0.035), 0.018 - index * 0.004, -0.035 + index * 0.018],
|
||||
scale: [0.09, 0.72, 0.18 + index * 0.012],
|
||||
rotation: [0.06, side * (-0.05 - index * 0.035), 0],
|
||||
});
|
||||
}
|
||||
shoulder.add(featherBatch(`crow.coverts.${word}`, materials.sheen, coverts, featherBlade));
|
||||
|
||||
// The secondaries overlap from elbow to wrist and create the broad inner
|
||||
// trailing edge visible in the accepted rear-view direction.
|
||||
const innerSecondaries: FeatherPlacement[] = [];
|
||||
const outerSecondaries: FeatherPlacement[] = [];
|
||||
for (let index = 0; index < 7; index += 1) {
|
||||
const along = index / 6;
|
||||
(index < 3 ? innerSecondaries : outerSecondaries).push({
|
||||
position: [side * (0.015 + along * 0.155), -0.006 - along * 0.005, 0.018 + along * 0.014],
|
||||
scale: [0.095 - along * 0.014, 0.72, 0.26 + along * 0.055],
|
||||
rotation: [0.02 + along * 0.05, side * (-0.1 - along * 0.16), 0],
|
||||
});
|
||||
}
|
||||
elbow.add(
|
||||
featherBatch(`crow.secondaries-inner.${word}`, materials.sheen, innerSecondaries, featherBlade),
|
||||
featherBatch(`crow.secondaries-outer.${word}`, materials.feather, outerSecondaries, featherBlade),
|
||||
);
|
||||
|
||||
// Long, individually separated primaries fan from the wrist. Their small
|
||||
// angular and length progression gives a clear fingertip silhouette.
|
||||
const primaries: FeatherPlacement[] = [];
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const along = index / 8;
|
||||
primaries.push({
|
||||
position: [side * (0.01 + along * 0.095), -0.014 - along * 0.003, 0.025 + along * 0.01],
|
||||
scale: [0.074 - along * 0.014, 0.78, 0.34 - along * 0.075],
|
||||
rotation: [0.015 + along * 0.035, side * (-0.2 - along * 0.42), side * (along - 0.5) * 0.035],
|
||||
});
|
||||
}
|
||||
wrist.add(featherBatch(`crow.primaries.${word}`, materials.feather, primaries, featherBlade));
|
||||
return { shoulder, elbow, wrist };
|
||||
}
|
||||
|
||||
function addFoot(body: THREE.Group, side: -1 | 1, material: THREE.Material): THREE.Group {
|
||||
const word = side < 0 ? "left" : "right";
|
||||
const leg = namedGroup(`crow.leg.${word}`, [side * 0.052, -0.115, 0.015]);
|
||||
leg.add(actorMesh(
|
||||
`crow.shank.${word}`,
|
||||
new THREE.CylinderGeometry(0.009, 0.007, 0.12, 7),
|
||||
material,
|
||||
{ position: [0, -0.052, 0.015], rotation: [0.2, 0, 0] },
|
||||
));
|
||||
const foot = namedGroup(`crow.foot.${word}`, [0, -0.108, -0.002]);
|
||||
leg.add(foot);
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const angle = (index - 1) * 0.42;
|
||||
foot.add(actorMesh(
|
||||
`crow.toe.${word}.${index}`,
|
||||
new THREE.CylinderGeometry(0.0045, 0.003, 0.075, 6),
|
||||
material,
|
||||
{
|
||||
position: [Math.sin(angle) * 0.025, -0.004, -0.028 - Math.cos(angle) * 0.006],
|
||||
rotation: [Math.PI / 2, 0, -angle],
|
||||
},
|
||||
));
|
||||
}
|
||||
foot.add(actorMesh(
|
||||
`crow.toe.${word}.rear`,
|
||||
new THREE.CylinderGeometry(0.004, 0.003, 0.055, 6),
|
||||
material,
|
||||
{ position: [0, -0.005, 0.022], rotation: [Math.PI / 2, 0, 0] },
|
||||
));
|
||||
body.add(leg);
|
||||
return leg;
|
||||
}
|
||||
|
||||
function resolveCrow(root: THREE.Group, ownsMaterials: boolean): CrowRig {
|
||||
const wingLeft = requireGroup(root, "crow.wing.left");
|
||||
const wingRight = requireGroup(root, "crow.wing.right");
|
||||
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"),
|
||||
wingLeft,
|
||||
wingRight,
|
||||
shoulderLeft: wingLeft,
|
||||
shoulderRight: wingRight,
|
||||
elbowLeft: requireGroup(root, "crow.elbow.left"),
|
||||
elbowRight: requireGroup(root, "crow.elbow.right"),
|
||||
wristLeft: requireGroup(root, "crow.wrist.left"),
|
||||
wristRight: requireGroup(root, "crow.wrist.right"),
|
||||
tail: requireGroup(root, "crow.tail"),
|
||||
legLeft: requireGroup(root, "crow.leg.left"),
|
||||
legRight: requireGroup(root, "crow.leg.right"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCrow(options: CrowBuildOptions = {}): CrowRig {
|
||||
const m = options.materials ?? createCrowMaterials(options);
|
||||
const materials = options.materials ?? createCrowMaterials(options);
|
||||
const featherBlade = featherGeometry();
|
||||
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.userData.rigVersion = 2;
|
||||
|
||||
const body = namedGroup("crow.body", [0, 0.225, 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.torso", new THREE.SphereGeometry(0.14, 18, 12), materials.feather, {
|
||||
rotation: [-0.12, 0, 0],
|
||||
scale: [0.82, 0.94, 1.58],
|
||||
}),
|
||||
actorMesh("crow.breast", new THREE.SphereGeometry(0.105, 10, 8), m.sheen, {
|
||||
position: [0, 0.012, -0.105],
|
||||
scale: [0.72, 1, 0.58],
|
||||
actorMesh("crow.breast", new THREE.SphereGeometry(0.112, 16, 10), materials.sheen, {
|
||||
position: [0, -0.006, -0.112],
|
||||
rotation: [-0.18, 0, 0],
|
||||
scale: [0.76, 1.03, 0.72],
|
||||
}),
|
||||
actorMesh("crow.mantle", new THREE.SphereGeometry(0.116, 16, 10), materials.sheen, {
|
||||
position: [0, 0.057, 0.028],
|
||||
scale: [0.82, 0.62, 1.12],
|
||||
}),
|
||||
actorMesh("crow.nape", new THREE.SphereGeometry(0.096, 16, 10), materials.sheen, {
|
||||
position: [0, 0.086, -0.088],
|
||||
rotation: [-0.18, 0, 0],
|
||||
scale: [0.86, 0.74, 1.2],
|
||||
}),
|
||||
);
|
||||
const head = namedGroup("crow.head", [0, 0.145, -0.105]);
|
||||
|
||||
// Small overlapping mantle feathers break up the old featureless pawn back.
|
||||
const crownMantle: FeatherPlacement[] = [];
|
||||
const lowerMantle: FeatherPlacement[] = [];
|
||||
for (let row = 0; row < 3; row += 1) {
|
||||
for (let column = -1; column <= 1; column += 1) {
|
||||
(row === 0 ? crownMantle : lowerMantle).push({
|
||||
position: [column * (0.036 - row * 0.004), 0.105 - row * 0.026, 0.02 + row * 0.045],
|
||||
scale: [0.07 - row * 0.006, 0.65, 0.1 + row * 0.014],
|
||||
rotation: [0.28, column * -0.08, 0],
|
||||
});
|
||||
}
|
||||
}
|
||||
body.add(
|
||||
featherBatch("crow.mantle-feathers.crown", materials.sheen, crownMantle, featherBlade),
|
||||
featherBatch("crow.mantle-feathers.lower", materials.feather, lowerMantle, featherBlade),
|
||||
);
|
||||
|
||||
const head = namedGroup("crow.head", [0, 0.108, -0.15]);
|
||||
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],
|
||||
actorMesh("crow.skull", new THREE.SphereGeometry(0.098, 18, 12), materials.feather, {
|
||||
scale: [0.88, 0.84, 1.14],
|
||||
}),
|
||||
actorMesh("crow.crown-sheen", new THREE.SphereGeometry(0.09, 16, 10, 0, Math.PI * 2, 0, Math.PI * 0.52), materials.sheen, {
|
||||
position: [0, 0.012, -0.003],
|
||||
scale: [0.92, 0.62, 0.96],
|
||||
}),
|
||||
actorMesh("crow.beak", beakGeometry(), materials.beak, { position: [0, -0.018, -0.073] }),
|
||||
);
|
||||
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],
|
||||
actorMesh(`crow.eye-rim.${word}`, new THREE.SphereGeometry(0.016, 8, 6), materials.beak, {
|
||||
position: [side * 0.068, 0.022, -0.058],
|
||||
scale: [0.55, 1, 0.82],
|
||||
}),
|
||||
actorMesh(`crow.eye.${word}`, new THREE.SphereGeometry(0.008, 8, 6), materials.eye, {
|
||||
position: [side * 0.076, 0.023, -0.063],
|
||||
}),
|
||||
);
|
||||
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]);
|
||||
|
||||
addWing(body, -1, materials, featherBlade);
|
||||
addWing(body, 1, materials, featherBlade);
|
||||
|
||||
const tail = namedGroup("crow.tail", [0, -0.025, 0.132]);
|
||||
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],
|
||||
}),
|
||||
);
|
||||
const centralTail: FeatherPlacement[] = [];
|
||||
const outerTail: FeatherPlacement[] = [];
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const fan = (index - 4) / 4;
|
||||
(Math.abs(fan) < 0.5 ? centralTail : outerTail).push({
|
||||
position: [fan * 0.052, -Math.abs(fan) * 0.004, 0],
|
||||
scale: [0.082, 0.8, 0.285 - Math.abs(fan) * 0.035],
|
||||
rotation: [0.04, fan * -0.44, fan * -0.045],
|
||||
});
|
||||
}
|
||||
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);
|
||||
tail.add(
|
||||
featherBatch("crow.tail-feathers.central", materials.sheen, centralTail, featherBlade),
|
||||
featherBatch("crow.tail-feathers.outer", materials.feather, outerTail, featherBlade),
|
||||
);
|
||||
|
||||
addFoot(body, -1, materials.foot);
|
||||
addFoot(body, 1, materials.foot);
|
||||
featherBlade.dispose();
|
||||
|
||||
const rig = resolveCrow(root, options.materials === undefined);
|
||||
poseCrow(rig, { state: "perch", amount: 1, flight: 0 });
|
||||
return rig;
|
||||
}
|
||||
|
||||
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. */
|
||||
function poseWing(
|
||||
shoulder: THREE.Group,
|
||||
elbow: THREE.Group,
|
||||
wrist: THREE.Group,
|
||||
side: -1 | 1,
|
||||
pose: Required<CrowPose>,
|
||||
): void {
|
||||
const stroke = Math.sin(pose.phase);
|
||||
const bankLift = pose.bank * side;
|
||||
if (pose.state === "perch") {
|
||||
elbow.position.x = side * 0.035;
|
||||
wrist.position.x = side * 0.018;
|
||||
shoulder.rotation.set(0.08, 0, side * 0.06);
|
||||
elbow.rotation.set(0.08, 0, side * -0.1);
|
||||
wrist.rotation.set(-0.06, 0, side * -0.06);
|
||||
return;
|
||||
}
|
||||
if (pose.state === "tuck") {
|
||||
elbow.position.x = side * 0.065;
|
||||
wrist.position.x = side * 0.035;
|
||||
shoulder.rotation.set(-0.05, 0, side * 0.16);
|
||||
elbow.rotation.set(0.08, 0, side * -0.3);
|
||||
wrist.rotation.set(-0.08, 0, side * -0.2);
|
||||
return;
|
||||
}
|
||||
|
||||
elbow.position.x = side * 0.175;
|
||||
wrist.position.x = side * 0.165;
|
||||
const flap = pose.state === "flap" ? stroke * 0.72 * pose.amount : 0;
|
||||
const glideDihedral = pose.state === "glide" || pose.state === "bank" ? 0.09 : 0.15;
|
||||
shoulder.rotation.set(-0.06 - Math.max(0, stroke) * 0.05, side * -0.08, side * (glideDihedral + flap + bankLift * 0.2));
|
||||
elbow.rotation.set(0.025, side * (-0.1 - Math.max(0, -stroke) * 0.1), side * (-0.05 + flap * 0.22 + bankLift * 0.12));
|
||||
wrist.rotation.set(-0.025, side * (-0.16 - pose.amount * 0.06), side * (-0.035 + flap * 0.12 + bankLift * 0.08));
|
||||
}
|
||||
|
||||
/** Apply an explicit production pose while preserving the legacy rig contract. */
|
||||
export function poseCrow(rig: CrowRig, value: CrowPose): void {
|
||||
const pose: Required<CrowPose> = {
|
||||
state: value.state,
|
||||
phase: Number.isFinite(value.phase) ? value.phase ?? 0 : 0,
|
||||
amount: THREE.MathUtils.clamp(Number.isFinite(value.amount) ? value.amount ?? 1 : 1, 0, 1),
|
||||
bank: THREE.MathUtils.clamp(Number.isFinite(value.bank) ? value.bank ?? 0 : 0, -1, 1),
|
||||
flight: THREE.MathUtils.clamp(Number.isFinite(value.flight) ? value.flight ?? 1 : 1, 0, 1),
|
||||
};
|
||||
poseWing(rig.joints.shoulderLeft, rig.joints.elbowLeft, rig.joints.wristLeft, -1, pose);
|
||||
poseWing(rig.joints.shoulderRight, rig.joints.elbowRight, rig.joints.wristRight, 1, pose);
|
||||
|
||||
const flapBob = pose.state === "flap" ? Math.cos(pose.phase) * 0.045 * pose.amount : 0;
|
||||
rig.joints.body.rotation.set(flapBob, 0, -pose.bank * 0.12);
|
||||
rig.joints.head.rotation.set(-flapBob * 0.6, pose.bank * -0.08, pose.bank * 0.08);
|
||||
rig.joints.tail.rotation.set(
|
||||
pose.state === "tuck" ? -0.24 : pose.state === "perch" ? 0.18 : -0.03 - flapBob,
|
||||
0,
|
||||
-pose.bank * 0.16,
|
||||
);
|
||||
const tailSpread = pose.state === "perch"
|
||||
? 0.78
|
||||
: pose.state === "tuck"
|
||||
? 0.62
|
||||
: pose.state === "flap"
|
||||
? 1.2
|
||||
: 1.48 + Math.abs(pose.bank) * 0.18;
|
||||
rig.joints.tail.scale.x = tailSpread;
|
||||
|
||||
const legTuck = pose.state === "perch" ? 0 : 0.22 + pose.flight * 0.62;
|
||||
rig.joints.legLeft.rotation.x = legTuck;
|
||||
rig.joints.legRight.rotation.x = legTuck;
|
||||
rig.joints.legLeft.rotation.z = pose.state === "perch" ? -0.035 : 0.08;
|
||||
rig.joints.legRight.rotation.z = pose.state === "perch" ? 0.035 : -0.08;
|
||||
rig.root.userData.pose = pose.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backwards-compatible flap helper. New adapters should prefer `poseCrow` so a
|
||||
* glide, bank, tuck and perch is semantic rather than inferred from amplitude.
|
||||
*/
|
||||
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;
|
||||
poseCrow(rig, { state: amount <= 0.25 ? "glide" : "flap", phase, amount, flight: 1 });
|
||||
}
|
||||
|
||||
export function disposeCrow(rig: CrowRig, options: { disposeMaterials?: boolean } = {}): void {
|
||||
|
||||
@@ -32,9 +32,12 @@ export {
|
||||
cloneCrow,
|
||||
createCrowMaterials,
|
||||
disposeCrow,
|
||||
poseCrow,
|
||||
poseCrowFlight,
|
||||
type CrowBuildOptions,
|
||||
type CrowJoints,
|
||||
type CrowMaterials,
|
||||
type CrowPose,
|
||||
type CrowPoseState,
|
||||
type CrowRig,
|
||||
} from "./crow.ts";
|
||||
|
||||
Reference in New Issue
Block a user