feat: build articulated crow flight v2
This commit is contained in:
+206
-11
@@ -13,6 +13,22 @@ export type ActorKind = "humanoid" | "dog" | "crow";
|
||||
export type ActorMode = "ground" | "flight";
|
||||
export type ActorModeRequest = "none" | ActorMode;
|
||||
export type ActorKindRequest = "none" | ActorKind;
|
||||
export type CrowFlightPoseState = "flap" | "glide" | "bank" | "tuck" | "perch";
|
||||
|
||||
export interface CrowWind {
|
||||
/** Positive X blows east/right in the actor's metre-space adapter. */
|
||||
xMps: number;
|
||||
/** Positive Z blows toward the actor's rear at yaw zero. */
|
||||
zMps: number;
|
||||
}
|
||||
|
||||
export interface CrowThermal {
|
||||
x: number;
|
||||
z: number;
|
||||
radiusM: number;
|
||||
/** Maximum deterministic updraft at the core. */
|
||||
liftMps: number;
|
||||
}
|
||||
|
||||
/** JSON-safe appearance fields. URLs are references; render resources stay outside simulation. */
|
||||
export interface ActorProfile {
|
||||
@@ -102,6 +118,16 @@ export interface ActorControllerOptions {
|
||||
maximumFlightSpeedMps?: number;
|
||||
glideSpeedMps?: number;
|
||||
maximumClimbSpeedMps?: number;
|
||||
/** Constant air-mass velocity. Defaults to still air. */
|
||||
crowWind?: Partial<CrowWind>;
|
||||
/** Bounded deterministic radial updrafts in actor metre space. */
|
||||
crowThermals?: readonly CrowThermal[];
|
||||
/** Normalized reserve used by powered flapping. Defaults to one. */
|
||||
crowInitialEnergy?: number;
|
||||
/** Reserve spent per second at a representative powered cruise. */
|
||||
crowEnergyDrainPerSecond?: number;
|
||||
/** Reserve recovered per second while gliding or perched. */
|
||||
crowEnergyRecoveryPerSecond?: number;
|
||||
/** Optional rectangle for a board or office adapter without collision geometry. */
|
||||
horizontalBounds?: ActorHorizontalBounds;
|
||||
fixedStepSeconds?: number;
|
||||
@@ -122,6 +148,17 @@ export interface ActorControllerState extends ActorPosition {
|
||||
/** Renderer request for pose intensity, in [0, 1]. */
|
||||
poseAmount: number;
|
||||
gliding: boolean;
|
||||
/** Signed visual/turn bank in radians. */
|
||||
roll: number;
|
||||
/** Normalized powered-flight reserve in [0, 1]. */
|
||||
flightEnergy: number;
|
||||
/** Aerodynamic and thermal vertical contribution before pilot climb. */
|
||||
liftMps: number;
|
||||
thermalLiftMps: number;
|
||||
windXMps: number;
|
||||
windZMps: number;
|
||||
crowPose: CrowFlightPoseState;
|
||||
perched: boolean;
|
||||
altitudeBoundContact: "none" | "minimum" | "maximum";
|
||||
distanceM: number;
|
||||
elapsedSteps: number;
|
||||
@@ -161,6 +198,11 @@ interface ResolvedOptions {
|
||||
maximumFlightSpeedMps: number;
|
||||
glideSpeedMps: number;
|
||||
maximumClimbSpeedMps: number;
|
||||
crowWind: CrowWind;
|
||||
crowThermals: readonly CrowThermal[];
|
||||
crowInitialEnergy: number;
|
||||
crowEnergyDrainPerSecond: number;
|
||||
crowEnergyRecoveryPerSecond: number;
|
||||
horizontalBounds?: ActorHorizontalBounds;
|
||||
fixedStepSeconds: number;
|
||||
maxFrameDeltaSeconds: number;
|
||||
@@ -277,6 +319,33 @@ function checkedBounds(bounds: ActorHorizontalBounds | undefined): ActorHorizont
|
||||
return { ...bounds };
|
||||
}
|
||||
|
||||
function checkedCrowWind(value: Partial<CrowWind> | undefined): CrowWind {
|
||||
const xMps = finiteOr(value?.xMps, 0);
|
||||
const zMps = finiteOr(value?.zMps, 0);
|
||||
// A malformed adapter cannot inject hurricane-scale displacement into an
|
||||
// authoritative actor step. The generous cap still covers severe weather.
|
||||
return { xMps: clamp(xMps, -60, 60), zMps: clamp(zMps, -60, 60) };
|
||||
}
|
||||
|
||||
function checkedCrowThermals(value: readonly CrowThermal[] | undefined): readonly CrowThermal[] {
|
||||
if (!value) return Object.freeze([]);
|
||||
if (value.length > 32) throw new RangeError("crowThermals supports at most 32 updrafts");
|
||||
const checked = value.map((thermal) => {
|
||||
if (
|
||||
!Number.isFinite(thermal?.x) || !Number.isFinite(thermal?.z) ||
|
||||
!Number.isFinite(thermal?.radiusM) || thermal.radiusM <= 0 ||
|
||||
!Number.isFinite(thermal?.liftMps) || thermal.liftMps < 0
|
||||
) throw new RangeError("crow thermal fields must be finite with positive radius and non-negative lift");
|
||||
return Object.freeze({
|
||||
x: thermal.x,
|
||||
z: thermal.z,
|
||||
radiusM: clamp(thermal.radiusM, 0.5, 10_000),
|
||||
liftMps: clamp(thermal.liftMps, 0, 20),
|
||||
});
|
||||
});
|
||||
return Object.freeze(checked);
|
||||
}
|
||||
|
||||
/** Clamp malformed adapter input before it can poison authoritative state. */
|
||||
export function normalizeActorActions(
|
||||
actions: Partial<ActorActionSnapshot> | undefined,
|
||||
@@ -350,6 +419,19 @@ function resolveOptions(options: ActorControllerOptions): ResolvedOptions {
|
||||
maximumFlightSpeedMps,
|
||||
glideSpeedMps: clamp(positive(options.glideSpeedMps, 7.5, "glideSpeedMps"), minimumFlightSpeedMps, maximumFlightSpeedMps),
|
||||
maximumClimbSpeedMps: positive(options.maximumClimbSpeedMps, 5, "maximumClimbSpeedMps"),
|
||||
crowWind: checkedCrowWind(options.crowWind),
|
||||
crowThermals: checkedCrowThermals(options.crowThermals),
|
||||
crowInitialEnergy: clamp(finiteOr(options.crowInitialEnergy, 1), 0, 1),
|
||||
crowEnergyDrainPerSecond: clamp(
|
||||
positive(options.crowEnergyDrainPerSecond, 0.006, "crowEnergyDrainPerSecond"),
|
||||
0.0001,
|
||||
1,
|
||||
),
|
||||
crowEnergyRecoveryPerSecond: clamp(
|
||||
positive(options.crowEnergyRecoveryPerSecond, 0.012, "crowEnergyRecoveryPerSecond"),
|
||||
0.0001,
|
||||
1,
|
||||
),
|
||||
horizontalBounds: bounds,
|
||||
fixedStepSeconds: clamp(positive(options.fixedStepSeconds, 1 / 60, "fixedStepSeconds"), 1 / 240, 0.1),
|
||||
maxFrameDeltaSeconds: clamp(positive(options.maxFrameDeltaSeconds, 0.25, "maxFrameDeltaSeconds"), 0.05, 1),
|
||||
@@ -376,6 +458,14 @@ export class ActorController {
|
||||
posePhase: 0,
|
||||
poseAmount: 0,
|
||||
gliding: false,
|
||||
roll: 0,
|
||||
flightEnergy: this.options.crowInitialEnergy,
|
||||
liftMps: 0,
|
||||
thermalLiftMps: 0,
|
||||
windXMps: this.options.crowWind.xMps,
|
||||
windZMps: this.options.crowWind.zMps,
|
||||
crowPose: this.options.mode === "flight" && this.options.kind === "crow" ? "glide" : "perch",
|
||||
perched: this.options.kind === "crow" && this.options.mode === "ground",
|
||||
altitudeBoundContact: "none",
|
||||
distanceM: 0,
|
||||
elapsedSteps: 0,
|
||||
@@ -429,6 +519,11 @@ export class ActorController {
|
||||
this.current.pitch = 0;
|
||||
this.current.poseAmount = 0;
|
||||
this.current.gliding = false;
|
||||
this.current.roll = 0;
|
||||
this.current.liftMps = 0;
|
||||
this.current.thermalLiftMps = 0;
|
||||
this.current.crowPose = kind === "crow" && this.current.mode === "ground" ? "perch" : "flap";
|
||||
this.current.perched = kind === "crow" && this.current.mode === "ground";
|
||||
this.current.altitudeBoundContact = "none";
|
||||
if (kind !== "crow") this.land();
|
||||
}
|
||||
@@ -453,6 +548,14 @@ export class ActorController {
|
||||
posePhase: 0,
|
||||
poseAmount: 0,
|
||||
gliding: false,
|
||||
roll: 0,
|
||||
flightEnergy: this.options.crowInitialEnergy,
|
||||
liftMps: 0,
|
||||
thermalLiftMps: 0,
|
||||
windXMps: this.options.crowWind.xMps,
|
||||
windZMps: this.options.crowWind.zMps,
|
||||
crowPose: this.options.mode === "flight" && this.options.kind === "crow" ? "glide" : "perch",
|
||||
perched: this.options.kind === "crow" && this.options.mode === "ground",
|
||||
altitudeBoundContact: "none",
|
||||
distanceM: 0,
|
||||
elapsedSteps: 0,
|
||||
@@ -518,6 +621,16 @@ export class ActorController {
|
||||
this.current.y = this.options.groundY;
|
||||
this.current.pitch = 0;
|
||||
this.current.gliding = false;
|
||||
this.current.roll = moveToward(this.current.roll, 0, 7 * dt);
|
||||
this.current.liftMps = 0;
|
||||
this.current.thermalLiftMps = 0;
|
||||
this.current.crowPose = this.current.kind === "crow" ? "perch" : "flap";
|
||||
this.current.perched = this.current.kind === "crow";
|
||||
this.current.flightEnergy = clamp(
|
||||
this.current.flightEnergy + this.options.crowEnergyRecoveryPerSecond * 2.5 * dt,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
this.current.altitudeBoundContact = "none";
|
||||
this.current.poseAmount = input;
|
||||
const strideLength = this.current.kind === "dog" ? 0.54 : 0.78;
|
||||
@@ -526,23 +639,91 @@ export class ActorController {
|
||||
|
||||
private stepFlight(actions: ActorActionSnapshot): void {
|
||||
const dt = this.options.fixedStepSeconds;
|
||||
const idleSoaring =
|
||||
!actions.glide && Math.abs(actions.forward) < 0.02 && Math.abs(actions.turn) < 0.02 &&
|
||||
Math.abs(actions.pitch) < 0.02 && Math.abs(actions.climb) < 0.02;
|
||||
const speedFraction = clamp(
|
||||
this.current.speedMps / Math.max(this.options.glideSpeedMps, 0.001),
|
||||
0,
|
||||
2,
|
||||
);
|
||||
const targetRoll = actions.turn * 0.62;
|
||||
this.current.roll = moveToward(this.current.roll, targetRoll, 2.7 * dt);
|
||||
this.current.yaw = wrapAngle(
|
||||
this.current.yaw + actions.turn * this.options.flightTurnRateRadPerSecond * dt,
|
||||
this.current.yaw +
|
||||
(
|
||||
actions.turn * this.options.flightTurnRateRadPerSecond * (0.68 + speedFraction * 0.22) +
|
||||
Math.sin(this.current.roll) * 0.34
|
||||
) * dt,
|
||||
);
|
||||
const targetPitch = actions.pitch * this.options.maximumFlightPitchRad;
|
||||
this.current.pitch = moveToward(this.current.pitch, targetPitch, 1.9 * dt);
|
||||
|
||||
const throttle = (actions.forward + 1) / 2;
|
||||
const tucking = actions.glide && actions.forward < -0.6 && actions.pitch < -0.1;
|
||||
const recovering = actions.glide;
|
||||
if (recovering) {
|
||||
this.current.flightEnergy = clamp(
|
||||
this.current.flightEnergy + this.options.crowEnergyRecoveryPerSecond * dt,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
} else {
|
||||
const effort = 0.35 + throttle * 0.65 + Math.max(0, actions.climb) * 0.55;
|
||||
this.current.flightEnergy = clamp(
|
||||
this.current.flightEnergy - this.options.crowEnergyDrainPerSecond * effort * dt,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
}
|
||||
const energyAuthority = 0.52 + this.current.flightEnergy * 0.48;
|
||||
const poweredTarget = this.options.minimumFlightSpeedMps +
|
||||
(this.options.maximumFlightSpeedMps - this.options.minimumFlightSpeedMps) * throttle;
|
||||
const targetSpeed = actions.glide ? this.options.glideSpeedMps : poweredTarget;
|
||||
this.current.speedMps = moveToward(this.current.speedMps, targetSpeed, (actions.glide ? 2.2 : 5.5) * dt);
|
||||
(this.options.maximumFlightSpeedMps - this.options.minimumFlightSpeedMps) * throttle * energyAuthority;
|
||||
const targetSpeed = tucking
|
||||
? Math.min(this.options.maximumFlightSpeedMps, this.options.glideSpeedMps * 1.45)
|
||||
: actions.glide ? this.options.glideSpeedMps : poweredTarget;
|
||||
const response = actions.glide ? (tucking ? 3.1 : 2.2) : 5.5 * energyAuthority;
|
||||
this.current.speedMps = moveToward(this.current.speedMps, targetSpeed, response * dt);
|
||||
// Parasitic drag rises with the square of airspeed. It is small enough that
|
||||
// a healthy powered bird holds its requested target but makes a tucked dive
|
||||
// finite instead of a perpetual acceleration source.
|
||||
const dragMps2 = 0.0045 * this.current.speedMps * this.current.speedMps;
|
||||
this.current.speedMps = clamp(
|
||||
this.current.speedMps - dragMps2 * dt * (actions.glide ? 0.7 : 0.25),
|
||||
0,
|
||||
this.options.maximumFlightSpeedMps,
|
||||
);
|
||||
|
||||
let thermalLiftMps = 0;
|
||||
for (const thermal of this.options.crowThermals) {
|
||||
const normalizedDistance = Math.hypot(
|
||||
this.current.x - thermal.x,
|
||||
this.current.z - thermal.z,
|
||||
) / thermal.radiusM;
|
||||
if (normalizedDistance >= 1) continue;
|
||||
const falloff = 1 - normalizedDistance;
|
||||
thermalLiftMps += thermal.liftMps * falloff * falloff;
|
||||
}
|
||||
this.current.thermalLiftMps = clamp(thermalLiftMps, 0, this.options.maximumClimbSpeedMps * 1.5);
|
||||
const liftRatio = this.current.speedMps / Math.max(this.options.glideSpeedMps, 0.001);
|
||||
const wingLiftMps = (liftRatio * liftRatio - 0.92) * (actions.glide ? 0.72 : 0.38);
|
||||
const flapLiftMps = actions.glide ? 0 : (0.18 + throttle * 0.46) * energyAuthority;
|
||||
const tuckPenaltyMps = tucking ? 1.35 : 0;
|
||||
this.current.liftMps = wingLiftMps + flapLiftMps + this.current.thermalLiftMps - tuckPenaltyMps;
|
||||
const pitchLift = Math.sin(this.current.pitch) * this.current.speedMps;
|
||||
const glideSink = actions.glide ? 0.7 : 0.15;
|
||||
const targetVertical = pitchLift + actions.climb * this.options.maximumClimbSpeedMps - glideSink;
|
||||
const baselineSink = actions.glide ? (tucking ? 0.95 : 0.62) : 0.18;
|
||||
const targetVertical =
|
||||
pitchLift +
|
||||
actions.climb * this.options.maximumClimbSpeedMps * energyAuthority +
|
||||
this.current.liftMps -
|
||||
baselineSink;
|
||||
this.current.verticalSpeedMps = moveToward(
|
||||
this.current.verticalSpeedMps,
|
||||
clamp(targetVertical, -this.options.maximumClimbSpeedMps, this.options.maximumClimbSpeedMps),
|
||||
clamp(
|
||||
targetVertical,
|
||||
-this.options.maximumClimbSpeedMps * 1.35,
|
||||
this.options.maximumClimbSpeedMps + this.current.thermalLiftMps,
|
||||
),
|
||||
8 * dt,
|
||||
);
|
||||
|
||||
@@ -550,8 +731,8 @@ export class ActorController {
|
||||
const beforeX = this.current.x;
|
||||
const beforeY = this.current.y;
|
||||
const beforeZ = this.current.z;
|
||||
this.current.x -= Math.sin(this.current.yaw) * horizontalSpeed * dt;
|
||||
this.current.z -= Math.cos(this.current.yaw) * horizontalSpeed * dt;
|
||||
this.current.x += (-Math.sin(this.current.yaw) * horizontalSpeed + this.options.crowWind.xMps) * dt;
|
||||
this.current.z += (-Math.cos(this.current.yaw) * horizontalSpeed + this.options.crowWind.zMps) * dt;
|
||||
this.current.y += this.current.verticalSpeedMps * dt;
|
||||
this.applyHorizontalBounds();
|
||||
this.applyAltitudeBounds();
|
||||
@@ -561,9 +742,16 @@ export class ActorController {
|
||||
this.current.z - beforeZ,
|
||||
);
|
||||
this.current.gliding = actions.glide;
|
||||
this.current.poseAmount = actions.glide ? 0.22 : 0.55 + throttle * 0.45;
|
||||
this.current.perched = false;
|
||||
this.current.crowPose = tucking
|
||||
? "tuck"
|
||||
: actions.glide
|
||||
? Math.abs(this.current.roll) > 0.16 ? "bank" : "glide"
|
||||
: idleSoaring ? "glide" : "flap";
|
||||
this.current.poseAmount = actions.glide || idleSoaring ? (tucking ? 0.42 : 0.72) : 0.55 + throttle * 0.45;
|
||||
if (!actions.glide) {
|
||||
const flapRate = 3.5 + throttle * 4;
|
||||
// A tired bird loses cadence before it loses basic control authority.
|
||||
const flapRate = 3.5 + throttle * (2.2 + energyAuthority * 1.8);
|
||||
this.current.posePhase = wrapPhase(this.current.posePhase + flapRate * TWO_PI * dt);
|
||||
}
|
||||
}
|
||||
@@ -572,14 +760,21 @@ export class ActorController {
|
||||
this.current.mode = "ground";
|
||||
this.current.y = this.options.groundY;
|
||||
this.current.pitch = 0;
|
||||
this.current.roll = 0;
|
||||
this.current.verticalSpeedMps = 0;
|
||||
this.current.liftMps = 0;
|
||||
this.current.thermalLiftMps = 0;
|
||||
this.current.gliding = false;
|
||||
this.current.crowPose = this.current.kind === "crow" ? "perch" : "flap";
|
||||
this.current.perched = this.current.kind === "crow";
|
||||
this.current.altitudeBoundContact = "none";
|
||||
}
|
||||
|
||||
private takeOff(): void {
|
||||
this.current.mode = "flight";
|
||||
this.current.y = Math.max(this.current.y, this.options.groundY + this.options.minFlightAltitude);
|
||||
this.current.crowPose = "flap";
|
||||
this.current.perched = false;
|
||||
this.current.altitudeBoundContact = "none";
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ export {
|
||||
type ActorPosition,
|
||||
type ActorProfile,
|
||||
type ActorReplayResult,
|
||||
type CrowFlightPoseState,
|
||||
type CrowThermal,
|
||||
type CrowWind,
|
||||
type TimedActorInputFrame,
|
||||
} from "./controller.ts";
|
||||
|
||||
|
||||
+17
-19
@@ -14,7 +14,7 @@ import {
|
||||
disposeCrow,
|
||||
disposeDog,
|
||||
disposeHumanoid,
|
||||
poseCrowFlight,
|
||||
poseCrow,
|
||||
poseDogAttention,
|
||||
poseDogWalk,
|
||||
poseHumanoid,
|
||||
@@ -115,7 +115,6 @@ function buildActor(kind: ActorKind, identity: Readonly<ActorIdentity>): RiggedA
|
||||
kind,
|
||||
rig: buildCrow({
|
||||
...(color(appearance?.primaryColor) === undefined ? {} : { featherColor: color(appearance?.primaryColor) }),
|
||||
...(color(appearance?.accentColor) === undefined ? {} : { sheenColor: color(appearance?.accentColor) }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -209,7 +208,11 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
|
||||
origin.z + state.z * scale,
|
||||
);
|
||||
root.rotation.order = "YXZ";
|
||||
root.rotation.set(state.mode === "flight" ? state.pitch : 0, state.yaw, 0);
|
||||
root.rotation.set(
|
||||
state.mode === "flight" ? state.pitch : 0,
|
||||
state.yaw,
|
||||
state.mode === "flight" && state.kind === "crow" ? -state.roll * 0.42 : 0,
|
||||
);
|
||||
view.position.copy(root.position);
|
||||
if (actor.kind === "humanoid") {
|
||||
poseHumanoid(actor.rig, { walkPhase: state.posePhase, stride: state.poseAmount * 0.68 });
|
||||
@@ -217,22 +220,17 @@ export function createSceneActor(options: SceneActorOptions): SceneActor {
|
||||
poseDogWalk(actor.rig, state.posePhase, state.poseAmount * 0.68);
|
||||
poseDogAttention(actor.rig, 0, state.posePhase * 0.7);
|
||||
} else if (state.mode === "flight") {
|
||||
// A possessed crow is airborne even before the first key arrives. Keep a
|
||||
// readable soaring silhouette at neutral input; the controller still
|
||||
// owns phase/intensity as soon as movement begins.
|
||||
const idleSoar = enabled && state.poseAmount < 1e-4;
|
||||
poseCrowFlight(
|
||||
actor.rig,
|
||||
idleSoar ? -Math.PI / 2 : state.posePhase,
|
||||
Math.max(state.poseAmount, idleSoar ? 0.58 : 0),
|
||||
);
|
||||
if (idleSoar) {
|
||||
// The authored wing sheet already extends along local X. A neutral
|
||||
// chase view needs that broad plan silhouette, not the edge-on middle
|
||||
// of a flap cycle.
|
||||
actor.rig.joints.wingLeft.rotation.set(-0.08, 0, 0.08);
|
||||
actor.rig.joints.wingRight.rotation.set(-0.08, 0, -0.08);
|
||||
}
|
||||
// The controller names the aerodynamic state; the rig owns how that
|
||||
// state bends shoulder, elbow, wrist, tail and feet.
|
||||
poseCrow(actor.rig, {
|
||||
state: state.crowPose,
|
||||
phase: state.posePhase,
|
||||
amount: Math.max(state.poseAmount, enabled ? 0.68 : 0.5),
|
||||
bank: THREE.MathUtils.clamp(state.roll / 0.62, -1, 1),
|
||||
flight: 1,
|
||||
});
|
||||
} else {
|
||||
poseCrow(actor.rig, { state: "perch", amount: 1, flight: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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";
|
||||
|
||||
+19
-5
@@ -904,7 +904,7 @@ async function mountCity(id: string) {
|
||||
minFlightAltitude: 20,
|
||||
maxFlightAltitude: 1_500,
|
||||
...(access.subject === null
|
||||
? { camera: { distance: 1.55, height: 1.35, targetHeight: 0.18, lookAhead: 0 } }
|
||||
? { camera: { distance: 2.55, height: 1.2, targetHeight: 0.1, lookAhead: 1.3 } }
|
||||
: {}),
|
||||
},
|
||||
...(id === "california"
|
||||
@@ -1763,7 +1763,7 @@ function renderLegend() {
|
||||
: flying
|
||||
? `${cityLabel}, following your electric aircraft. Use W A S D to fly or P to resume assisted flight.`
|
||||
: exploring
|
||||
? `${cityLabel}, following your ${city.actorState()?.kind ?? "actor"}. Use W A S D to move.`
|
||||
? `${cityLabel}, following your ${city.actorState()?.kind ?? "actor"}. Use W A S D to move, Q and E for altitude, and G to glide.`
|
||||
: `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`,
|
||||
);
|
||||
}
|
||||
@@ -1774,7 +1774,17 @@ function renderLegend() {
|
||||
officePlan?.setActiveView(office?.current() ?? null);
|
||||
renderOfficeBadge();
|
||||
if (driveControls) driveControls.hidden = !routeDriveIsActive();
|
||||
if (walkControls) walkControls.hidden = !(walking || exploring || flying);
|
||||
if (walkControls) {
|
||||
walkControls.hidden = !(walking || exploring || flying);
|
||||
walkControls.setAttribute(
|
||||
"aria-label",
|
||||
flying
|
||||
? "Aircraft flight controls"
|
||||
: exploring && city.actorState()?.kind === "crow"
|
||||
? "Crow flight controls"
|
||||
: "Walking controls",
|
||||
);
|
||||
}
|
||||
for (const control of walkControls?.querySelectorAll<HTMLButtonElement>("[data-walk-key]") ?? []) {
|
||||
control.textContent = flying
|
||||
? control.dataset.aircraftLabel ?? control.textContent
|
||||
@@ -1786,6 +1796,9 @@ function renderLegend() {
|
||||
for (const control of walkControls?.querySelectorAll<HTMLElement>(".aircraft-only") ?? []) {
|
||||
control.hidden = !flying;
|
||||
}
|
||||
for (const control of walkControls?.querySelectorAll<HTMLElement>(".crow-only") ?? []) {
|
||||
control.hidden = inside || flying || city.actorState()?.kind !== "crow";
|
||||
}
|
||||
if (driveHint) driveHint.hidden = inside || cityId !== "california" || flying;
|
||||
if (walkHint) {
|
||||
walkHint.hidden = !(inside || city.actorState() || city.aircraftState());
|
||||
@@ -1793,7 +1806,7 @@ function renderLegend() {
|
||||
? "WASD fly · P assisted · R reset"
|
||||
: inside
|
||||
? "V walk · WASD move"
|
||||
: "V explore · WASD move";
|
||||
: "V explore · WASD · Q/E altitude · G glide";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3107,6 +3120,7 @@ function publishCityActorActions(): boolean {
|
||||
sprint: heldDriveKeys.has(" "),
|
||||
climb: (heldDriveKeys.has("e") || heldDriveKeys.has(" ") ? 1 : 0) -
|
||||
(heldDriveKeys.has("q") ? 1 : 0),
|
||||
glide: heldDriveKeys.has("g"),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -3275,7 +3289,7 @@ window.addEventListener("keydown", (event) => {
|
||||
const lower = event.key.toLowerCase();
|
||||
if (
|
||||
lower === "w" || lower === "a" || lower === "s" || lower === "d" ||
|
||||
lower === "q" || lower === "e" || event.key === " "
|
||||
lower === "q" || lower === "e" || lower === "g" || event.key === " "
|
||||
) {
|
||||
heldDriveKeys.add(event.key === " " ? " " : lower);
|
||||
if (publishVehicleActions()) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
disposeCrow,
|
||||
disposeDog,
|
||||
disposeHumanoid,
|
||||
poseCrow,
|
||||
poseCrowFlight,
|
||||
poseDogAttention,
|
||||
poseDogWalk,
|
||||
@@ -100,6 +101,52 @@ describe("procedural actor assets", () => {
|
||||
disposeCrow(rig);
|
||||
});
|
||||
|
||||
it("builds a layered v2 feather rig with shoulder, elbow, wrist, tail and claw anatomy", () => {
|
||||
const rig = buildCrow();
|
||||
assert.equal(rig.root.userData.rigVersion, 2);
|
||||
assert.equal(rig.joints.elbowLeft.parent, rig.joints.shoulderLeft);
|
||||
assert.equal(rig.joints.wristLeft.parent, rig.joints.elbowLeft);
|
||||
assert.equal(rig.joints.elbowRight.parent, rig.joints.shoulderRight);
|
||||
assert.equal(rig.joints.wristRight.parent, rig.joints.elbowRight);
|
||||
|
||||
const allMeshes = meshes(rig.root);
|
||||
const featherCount = (prefix: string) => allMeshes
|
||||
.filter((mesh) => mesh.name.startsWith(prefix))
|
||||
.reduce((sum, mesh) => sum + Number(mesh.userData.featherCount ?? 0), 0);
|
||||
assert.equal(featherCount("crow.primaries."), 18);
|
||||
assert.equal(featherCount("crow.secondaries-"), 14);
|
||||
assert.equal(featherCount("crow.tail-feathers."), 9);
|
||||
assert.equal(allMeshes.filter((mesh) => mesh.name.startsWith("crow.toe.")).length, 8);
|
||||
assert.ok(allMeshes.length <= 36, `${allMeshes.length} meshes keeps one crow bounded for mobile/peers`);
|
||||
disposeCrow(rig);
|
||||
});
|
||||
|
||||
it("poses a materially different glide, bank, tuck and perch without rebuilding geometry", () => {
|
||||
const rig = buildCrow();
|
||||
const width = () => new THREE.Box3().setFromObject(rig.root).getSize(new THREE.Vector3()).x;
|
||||
|
||||
poseCrow(rig, { state: "glide", amount: 0.8, flight: 1 });
|
||||
const glideWidth = width();
|
||||
assert.equal(rig.root.userData.pose, "glide");
|
||||
assert.ok(Math.abs(glideWidth - CROW_METRICS.wingspan) < 0.12, `glide width ${glideWidth}`);
|
||||
|
||||
poseCrow(rig, { state: "bank", amount: 0.8, bank: 0.75, flight: 1 });
|
||||
assert.equal(rig.root.userData.pose, "bank");
|
||||
assert.notEqual(rig.joints.shoulderLeft.rotation.z, -rig.joints.shoulderRight.rotation.z,
|
||||
"bank makes the wing dihedral asymmetric");
|
||||
assert.ok(rig.joints.tail.rotation.z < 0, "tail counters a right bank");
|
||||
|
||||
poseCrow(rig, { state: "tuck", amount: 1, flight: 1 });
|
||||
const tuckWidth = width();
|
||||
poseCrow(rig, { state: "perch", amount: 1, flight: 0 });
|
||||
const perchWidth = width();
|
||||
assert.ok(tuckWidth < glideWidth, `${tuckWidth} tuck < ${glideWidth} glide`);
|
||||
assert.ok(perchWidth < tuckWidth, `${perchWidth} perch < ${tuckWidth} tuck`);
|
||||
assert.equal(rig.joints.legLeft.rotation.x, 0);
|
||||
assert.equal(rig.joints.legRight.rotation.x, 0);
|
||||
disposeCrow(rig);
|
||||
});
|
||||
|
||||
it("never disposes caller-owned materials unless explicitly requested", () => {
|
||||
const source = buildCrow();
|
||||
const shared = meshes(source.root)[0]!.material as THREE.Material;
|
||||
|
||||
@@ -129,6 +129,76 @@ describe("crow flight", () => {
|
||||
controller.stepFixed({ modeRequest: "ground" });
|
||||
assert.equal(controller.state().mode, "ground");
|
||||
assert.equal(controller.state().y, 12);
|
||||
assert.equal(controller.state().crowPose, "perch");
|
||||
assert.equal(controller.state().perched, true);
|
||||
});
|
||||
|
||||
it("models deterministic wind, radial thermal lift, banking and finite energy", () => {
|
||||
const options = groundOptions({
|
||||
kind: "crow",
|
||||
mode: "flight",
|
||||
position: { x: 0, y: 20, z: 0 },
|
||||
minFlightAltitude: 2,
|
||||
maxFlightAltitude: 80,
|
||||
crowWind: { xMps: 2, zMps: -0.5 },
|
||||
crowThermals: [{ x: 0, z: 0, radiusM: 100, liftMps: 2.5 }],
|
||||
crowInitialEnergy: 0.6,
|
||||
crowEnergyDrainPerSecond: 0.1,
|
||||
crowEnergyRecoveryPerSecond: 0.2,
|
||||
});
|
||||
const first = new ActorController(options);
|
||||
const replay = new ActorController(options);
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const actions = { forward: 1, turn: 0.7, climb: 0.1 };
|
||||
first.stepFixed(actions);
|
||||
replay.stepFixed(actions);
|
||||
}
|
||||
assert.deepEqual(first.snapshot(), replay.snapshot());
|
||||
assert.ok(first.state().x > 0, "the configured eastward air mass advects the bird");
|
||||
assert.ok(first.state().thermalLiftMps > 2, "the bird remains near the thermal core");
|
||||
assert.ok(first.state().liftMps > first.state().thermalLiftMps);
|
||||
assert.ok(first.state().roll > 0);
|
||||
assert.equal(first.state().crowPose, "flap");
|
||||
assert.ok(first.state().flightEnergy < 0.6 && first.state().flightEnergy >= 0);
|
||||
|
||||
const spent = first.state().flightEnergy;
|
||||
for (let index = 0; index < 10; index += 1) first.stepFixed({ glide: true, turn: 0.7 });
|
||||
assert.ok(first.state().flightEnergy > spent);
|
||||
assert.equal(first.state().crowPose, "bank");
|
||||
assert.equal(first.state().gliding, true);
|
||||
|
||||
first.stepFixed({ forward: -1, glide: true, pitch: -1 });
|
||||
assert.equal(first.state().crowPose, "tuck");
|
||||
assert.ok(first.state().flightEnergy >= 0 && first.state().flightEnergy <= 1);
|
||||
});
|
||||
|
||||
it("replays wind and thermal flight bit-for-bit across powered, glide and tuck states", () => {
|
||||
const options = groundOptions({
|
||||
kind: "crow",
|
||||
mode: "flight",
|
||||
position: { x: -10, y: 15, z: 5 },
|
||||
minFlightAltitude: 2,
|
||||
maxFlightAltitude: 100,
|
||||
crowWind: { xMps: -1.25, zMps: 0.8 },
|
||||
crowThermals: [
|
||||
{ x: -10, z: 5, radiusM: 45, liftMps: 3.2 },
|
||||
{ x: 80, z: -20, radiusM: 25, liftMps: 1.4 },
|
||||
],
|
||||
crowInitialEnergy: 0.82,
|
||||
});
|
||||
const frames = [
|
||||
{ steps: 90, actions: { forward: 0.8, turn: 0.3, climb: 0.2 } },
|
||||
{ steps: 45, actions: { glide: true, turn: -0.7 } },
|
||||
{ steps: 20, actions: { forward: -1, glide: true, pitch: -0.7 } },
|
||||
{ steps: 30, actions: { forward: 0.4, pitch: 0.2 } },
|
||||
];
|
||||
const first = replayActorInputs(options, frames);
|
||||
const second = replayActorInputs(options, frames);
|
||||
assert.deepEqual(first, second);
|
||||
assert.equal(first.final.elapsedSteps, 185);
|
||||
assert.ok(first.trajectory.every((state) =>
|
||||
state.flightEnergy >= 0 && state.flightEnergy <= 1 &&
|
||||
Number.isFinite(state.liftMps) && Number.isFinite(state.roll)));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,6 +249,10 @@ describe("actor identity, reset and replay", () => {
|
||||
it("rejects invalid configuration and identity before creating state", () => {
|
||||
assert.throws(() => new ActorController(groundOptions({ minFlightAltitude: 5, maxFlightAltitude: 2 })), RangeError);
|
||||
assert.throws(() => new ActorController(groundOptions({ walkSpeedMps: 0 })), RangeError);
|
||||
assert.throws(() => new ActorController(groundOptions({ crowEnergyDrainPerSecond: 0 })), RangeError);
|
||||
assert.throws(() => new ActorController(groundOptions({
|
||||
crowThermals: [{ x: 0, z: 0, radiusM: 0, liftMps: 2 }],
|
||||
})), RangeError);
|
||||
assert.throws(() => new ActorController(groundOptions({
|
||||
horizontalBounds: { minX: 2, maxX: 1, minZ: 0, maxZ: 1 },
|
||||
})), RangeError);
|
||||
|
||||
@@ -66,6 +66,13 @@ describe("playable city scene actor", () => {
|
||||
assert.ok(actor.state().y > 4 && actor.state().y <= 6);
|
||||
assert.equal(actor.root.rotation.order, "YXZ");
|
||||
assert.equal(actor.root.rotation.x, actor.state().pitch);
|
||||
const crow = actor.root.getObjectByName("crow");
|
||||
assert.equal(crow?.userData.pose, "flap");
|
||||
actor.setActions({ glide: true, turn: 1 });
|
||||
actor.tick(0.3);
|
||||
assert.equal(actor.state().crowPose, "bank");
|
||||
assert.equal(crow?.userData.pose, "bank");
|
||||
assert.notEqual(actor.root.rotation.z, 0, "the scene root presents controller-owned bank");
|
||||
const pose = actor.followPose();
|
||||
assert.ok(pose.position.toArray().every(Number.isFinite));
|
||||
assert.ok(pose.target.toArray().every(Number.isFinite));
|
||||
|
||||
Reference in New Issue
Block a user