@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
473 lines (411 loc) • 20.8 kB
text/typescript
import { AnimationClip, BooleanKeyframeTrack, Color, ColorKeyframeTrack, Euler,
InterpolateDiscrete, InterpolateLinear, InterpolateSmooth, KeyframeTrack,
NumberKeyframeTrack, Object3D, Quaternion, QuaternionKeyframeTrack,
Vector3, VectorKeyframeTrack } from "three";
import type { Camera, InterpolationModes, Light, Material, PerspectiveCamera } from "three";
import { isDevEnvironment } from "../engine/debug/index.js";
// ============================================================
// Value types (R3F-style array support)
// ============================================================
/** A Vector3 value, either as a Three.js Vector3 or as a `[x, y, z]` tuple */
export type Vec3Value = Vector3 | [number, number, number];
/** A Quaternion value, either as a Three.js Quaternion or as a `[x, y, z, w]` tuple */
export type QuatValue = Quaternion | [number, number, number, number];
/** A Color value, either as a Three.js Color or as an `[r, g, b]` tuple (0–1) */
export type ColorValue = Color | [number, number, number];
/** An Euler value, either as a Three.js Euler or as a `[x, y, z]` tuple (radians) */
export type EulerValue = Euler | [number, number, number];
// ============================================================
// Interpolation
// ============================================================
/** User-friendly interpolation mode names */
export type AnimationInterpolation = "linear" | "smooth" | "step";
// ============================================================
// Keyframe & Tween
// ============================================================
/** A single keyframe: a time and a value */
export type AnimationKeyframe<V> = {
/** Time in seconds */
time: number;
/** The value at this time */
value: V;
/** Interpolation mode for this track (default: `"linear"`). Note: Three.js applies one mode per track; the first keyframe's mode is used. */
interpolation?: AnimationInterpolation;
};
/** Shorthand for a simple two-keyframe animation (start → end) */
export type Tween<V> = {
/** Start value (at time 0) */
from: V;
/** End value (at time = duration) */
to: V;
/** Duration in seconds (default: 1) */
duration?: number;
/** Interpolation mode (default: `"linear"`) */
interpolation?: AnimationInterpolation;
};
/** Keyframe array or tween shorthand */
type KF<V> = AnimationKeyframe<V>[] | Tween<V>;
// ============================================================
// TrackDescriptor
// ============================================================
/**
* An opaque descriptor for a single animation track.
* Created by {@link track} and resolved into a Three.js KeyframeTrack
* when passed to {@link createAnimation}, or inline to
* {@link AnimatorControllerBuilder.state} / {@link TimelineBuilder.clip}.
*
* @category Animation and Sequencing
*/
export type TrackDescriptor = {
readonly __isTrackDescriptor: true;
/** @internal */ readonly _target: object;
/** @internal */ readonly _property: string;
/** @internal */ readonly _keyframes: Array<{ time: number; value: any; interpolation?: AnimationInterpolation }>;
/** @internal */ readonly _root?: Object3D;
};
// ============================================================
// TrackOptions / CreateAnimationOptions
// ============================================================
/** Options for a single track */
export type TrackOptions = {
/**
* Root object for resolving the track path.
* - If `root === target` → self-targeting (`.property`)
* - If `root !== target` → named targeting (`"targetName.property"` using `target.name`)
* - If omitted → self-targeting by default
*/
root?: Object3D;
};
/** Options for {@link createAnimation} */
export type CreateAnimationOptions = {
/** Default root for all tracks that don't specify their own */
root?: Object3D;
/** Clip name (auto-generated if omitted) */
name?: string;
};
// ============================================================
// track() — type-safe overloads
// ============================================================
// --- Object3D ---
/** Create an animation track for an Object3D's position or scale */
export function track(target: Object3D, property: "position" | "scale", keyframes: KF<Vec3Value>, options?: TrackOptions): TrackDescriptor;
/** Create an animation track for an Object3D's quaternion */
export function track(target: Object3D, property: "quaternion", keyframes: KF<QuatValue>, options?: TrackOptions): TrackDescriptor;
/** Create an animation track for an Object3D's rotation (Euler angles, converted to quaternion internally) */
export function track(target: Object3D, property: "rotation", keyframes: KF<EulerValue>, options?: TrackOptions): TrackDescriptor;
/** Create an animation track for an Object3D's visibility */
export function track(target: Object3D, property: "visible", keyframes: KF<boolean>, options?: TrackOptions): TrackDescriptor;
// --- Material ---
/** Create an animation track for a material's numeric property */
export function track(target: Material, property: "opacity" | "roughness" | "metalness" | "alphaTest" | "emissiveIntensity" | "envMapIntensity" | "bumpScale" | "displacementScale" | "displacementBias", keyframes: KF<number>, options?: TrackOptions): TrackDescriptor;
/** Create an animation track for a material's color property */
export function track(target: Material, property: "color" | "emissive", keyframes: KF<ColorValue>, options?: TrackOptions): TrackDescriptor;
// --- Light ---
/** Create an animation track for a light's numeric property */
export function track(target: Light, property: "intensity" | "distance" | "angle" | "penumbra" | "decay", keyframes: KF<number>, options?: TrackOptions): TrackDescriptor;
/** Create an animation track for a light's color */
export function track(target: Light, property: "color", keyframes: KF<ColorValue>, options?: TrackOptions): TrackDescriptor;
// --- Camera ---
/** Create an animation track for a camera's numeric property */
export function track(target: PerspectiveCamera, property: "fov" | "near" | "far" | "zoom", keyframes: KF<number>, options?: TrackOptions): TrackDescriptor;
// --- Implementation ---
/**
* Creates an animation track descriptor targeting a property on the given object.
*
* The `target` is used for **TypeScript type inference** — it determines which property names
* are offered and what value types the keyframes accept. By default, the resulting track
* targets "self" (the mixer root). Pass `{ root }` to target a named child instead.
*
* @param target - The object whose type determines valid properties and value types
* @param property - The property to animate (e.g. `"position"`, `"opacity"`, `"intensity"`)
* @param keyframes - An array of {@link AnimationKeyframe} objects, or a {@link Tween} shorthand
* @param options - Optional {@link TrackOptions} with a `root` for named targeting
* @returns A {@link TrackDescriptor} that can be passed to {@link createAnimation}, or inline
* to `AnimatorControllerBuilder.state()` or `TimelineBuilder.clip()`
*
* @example Keyframe array
* ```ts
* track(door, "position", [
* { time: 0, value: [0, 0, 0] },
* { time: 1, value: [2, 0, 0] },
* ])
* ```
*
* @example Tween shorthand
* ```ts
* track(door, "position", { from: [0, 0, 0], to: [2, 0, 0], duration: 1 })
* ```
*
* @example Named targeting (track targets a child of root)
* ```ts
* track(door, "position", keyframes, { root: room })
* ```
*
* @category Animation and Sequencing
* @group Utilities
*/
export function track(target: object, property: string, keyframes: KF<any>, options?: TrackOptions): TrackDescriptor {
const kf = isTween(keyframes) ? tweenToKeyframes(keyframes) : keyframes;
return {
__isTrackDescriptor: true as const,
_target: target,
_property: property,
_keyframes: kf.map(k => ({ ...k, value: snapshotValue(k.value) })),
_root: options?.root,
};
}
/** @internal alias so the AnimationBuilder class method can call the standalone function */
const trackFn = track;
// ============================================================
// AnimationBuilder
// ============================================================
/**
* A fluent builder for creating `AnimationClip` instances from code.
*
* Use {@link AnimationBuilder.create} to start a new builder, chain `.track()` calls
* to add animation tracks, and call `.build()` to produce the clip.
*
* @example Single track
* ```ts
* const clip = AnimationBuilder.create()
* .track(door, "position", { from: [0,0,0], to: [2,0,0], duration: 1 })
* .build();
* ```
*
* @example Multiple tracks
* ```ts
* const clip = AnimationBuilder.create("DoorOpen")
* .track(door, "position", { from: [0,0,0], to: [2,0,0], duration: 1 })
* .track(light, "intensity", { from: 0, to: 5, duration: 1 })
* .build(room);
* ```
*
* @category Animation and Sequencing
* @group Utilities
*/
export class AnimationBuilder {
private _name?: string;
private _tracks: TrackDescriptor[] = [];
/** Creates a new AnimationBuilder instance */
static create(name?: string): AnimationBuilder {
return new AnimationBuilder(name);
}
constructor(name?: string) {
this._name = name;
}
// --- Object3D ---
/** Adds an animation track for an Object3D's position or scale */
track(target: Object3D, property: "position" | "scale", keyframes: KF<Vec3Value>, options?: TrackOptions): this;
/** Adds an animation track for an Object3D's quaternion */
track(target: Object3D, property: "quaternion", keyframes: KF<QuatValue>, options?: TrackOptions): this;
/** Adds an animation track for an Object3D's rotation (Euler, converted to quaternion) */
track(target: Object3D, property: "rotation", keyframes: KF<EulerValue>, options?: TrackOptions): this;
/** Adds an animation track for an Object3D's visibility */
track(target: Object3D, property: "visible", keyframes: KF<boolean>, options?: TrackOptions): this;
// --- Material ---
/** Adds an animation track for a material's numeric property */
track(target: Material, property: "opacity" | "roughness" | "metalness" | "alphaTest" | "emissiveIntensity" | "envMapIntensity" | "bumpScale" | "displacementScale" | "displacementBias", keyframes: KF<number>, options?: TrackOptions): this;
/** Adds an animation track for a material's color property */
track(target: Material, property: "color" | "emissive", keyframes: KF<ColorValue>, options?: TrackOptions): this;
// --- Light ---
/** Adds an animation track for a light's numeric property */
track(target: Light, property: "intensity" | "distance" | "angle" | "penumbra" | "decay", keyframes: KF<number>, options?: TrackOptions): this;
/** Adds an animation track for a light's color */
track(target: Light, property: "color", keyframes: KF<ColorValue>, options?: TrackOptions): this;
// --- Camera ---
/** Adds an animation track for a camera's numeric property */
track(target: PerspectiveCamera, property: "fov" | "near" | "far" | "zoom", keyframes: KF<number>, options?: TrackOptions): this;
track(target: object, property: string, keyframes: KF<any>, options?: TrackOptions): this {
this._tracks.push(trackFn(target as Object3D, property as "position", keyframes, options));
return this;
}
/**
* Builds and returns the `AnimationClip`.
* @param root - Optional root Object3D for resolving track paths.
* When provided, tracks targeting a different object use `target.name` for named resolution.
*/
build(root?: Object3D): AnimationClip {
return resolveToClip(this._tracks, root, this._name);
}
}
// Keep createAnimation as internal alias for backwards compatibility
/** @internal @deprecated Use {@link AnimationBuilder.create} instead */
export function createAnimation(options: CreateAnimationOptions, ...tracks: TrackDescriptor[]): AnimationClip;
/** @internal @deprecated Use {@link AnimationBuilder.create} instead */
export function createAnimation(...tracks: TrackDescriptor[]): AnimationClip;
export function createAnimation(...args: (CreateAnimationOptions | TrackDescriptor)[]): AnimationClip {
let options: CreateAnimationOptions | undefined;
let descriptors: TrackDescriptor[];
if (args.length > 0 && !isTrackDescriptor(args[0])) {
options = args[0] as CreateAnimationOptions;
descriptors = args.slice(1) as TrackDescriptor[];
}
else {
descriptors = args as TrackDescriptor[];
}
return resolveToClip(descriptors, options?.root, options?.name);
}
// ============================================================
// Resolution helpers (exported for use by AnimatorControllerBuilder / TimelineBuilder)
// ============================================================
/**
* Resolves a clip source (AnimationClip, TrackDescriptor, or TrackDescriptor[]) into an AnimationClip.
* Used internally by AnimatorControllerBuilder and TimelineBuilder.
* @internal
*/
export function resolveClipSource(clip: AnimationClip | TrackDescriptor | TrackDescriptor[], root?: Object3D): AnimationClip {
if (clip instanceof AnimationClip) return clip;
if (Array.isArray(clip)) return resolveToClip(clip, root);
if (isTrackDescriptor(clip)) return resolveToClip([clip], root);
return clip as AnimationClip; // should not reach
}
/** Type guard for {@link TrackDescriptor} */
export function isTrackDescriptor(obj: unknown): obj is TrackDescriptor {
return obj != null && typeof obj === "object" && (obj as any).__isTrackDescriptor === true;
}
/** Resolves an array of TrackDescriptors into an AnimationClip. @internal */
export function resolveToClip(
descriptors: TrackDescriptor[],
buildRoot?: Object3D,
name?: string,
): AnimationClip {
const keyframeTracks: KeyframeTrack[] = [];
for (const desc of descriptors) {
keyframeTracks.push(buildKeyframeTrack(desc, buildRoot));
}
let duration = 0;
for (const t of keyframeTracks) {
const last = t.times[t.times.length - 1];
if (last !== undefined && last > duration) duration = last;
}
return new AnimationClip(name ?? `clip_${_clipCounter++}`, duration, keyframeTracks);
}
// ============================================================
// Internal helpers
// ============================================================
let _clipCounter = 0;
function buildKeyframeTrack(desc: TrackDescriptor, buildRoot?: Object3D): KeyframeTrack {
const property = resolvePropertyName(desc._property);
const trackName = resolveTrackName(desc, buildRoot, property);
const times: number[] = [];
const values: number[] = [];
for (const kf of desc._keyframes) {
times.push(kf.time);
const flat = flattenValue(kf.value, desc._property);
for (let i = 0; i < flat.length; i++) values.push(flat[i]);
}
const interpolation = resolveInterpolation(desc._keyframes[0]?.interpolation);
const TrackClass = resolveTrackClass(desc._property, desc._keyframes[0]?.value);
return new TrackClass(trackName, times, values, interpolation);
}
function resolveTrackName(desc: TrackDescriptor, buildRoot?: Object3D, resolvedProperty?: string): string {
const root = desc._root ?? buildRoot;
const property = resolvedProperty ?? resolvePropertyName(desc._property);
// Material target → always self-targeting with .material. prefix
if (isMaterial(desc._target)) {
return `.material.${property}`;
}
// No root → self-targeting
if (!root) return `.${property}`;
// Root === target → self-targeting
if (root === desc._target) return `.${property}`;
// Root !== target → named targeting
const target = desc._target as Object3D;
const nodeName = target.name;
if (!nodeName) {
if (isDevEnvironment()) {
console.warn(`AnimationBuilder: target has no name, falling back to self-targeting. Set target.name for named targeting.`);
}
return `.${property}`;
}
// Dev mode: validate that target is actually a descendant of root
if (isDevEnvironment() && root instanceof Object3D && target instanceof Object3D) {
let found = false;
root.traverse(child => {
if (child === target) found = true;
});
if (!found) {
console.warn(`AnimationBuilder: target "${nodeName}" is not a descendant of the provided root "${root.name}". The track may not resolve at play time.`);
}
}
return `${nodeName}.${property}`;
}
function resolvePropertyName(property: string): string {
if (property === "rotation") return "quaternion";
return property;
}
function resolveTrackClass(property: string, sampleValue: any): new (name: string, times: ArrayLike<number>, values: ArrayLike<number>, interpolation?: InterpolationModes) => KeyframeTrack {
// Check property name first (most reliable)
if (property === "quaternion" || property === "rotation") return QuaternionKeyframeTrack;
if (property === "visible") return BooleanKeyframeTrack;
if (property === "position" || property === "scale") return VectorKeyframeTrack;
if (property === "color" || property === "emissive") return ColorKeyframeTrack;
// Check value type
if (sampleValue instanceof Vector3) return VectorKeyframeTrack;
if (sampleValue instanceof Quaternion) return QuaternionKeyframeTrack;
if (sampleValue instanceof Color) return ColorKeyframeTrack;
if (sampleValue instanceof Euler) return QuaternionKeyframeTrack;
if (typeof sampleValue === "boolean") return BooleanKeyframeTrack;
if (typeof sampleValue === "number") return NumberKeyframeTrack;
// Array → infer from length + property context
if (Array.isArray(sampleValue)) {
if (sampleValue.length === 4) return QuaternionKeyframeTrack;
if (sampleValue.length === 3) return VectorKeyframeTrack;
if (sampleValue.length === 2) return VectorKeyframeTrack;
return NumberKeyframeTrack;
}
return NumberKeyframeTrack;
}
function flattenValue(value: any, property: string): number[] {
// Tuple arrays — already flat
if (Array.isArray(value)) {
// Special case: Euler array [x,y,z] for "rotation" → convert to quaternion
if (property === "rotation" && value.length === 3) {
const q = new Quaternion().setFromEuler(new Euler(value[0], value[1], value[2]));
return [q.x, q.y, q.z, q.w];
}
return value;
}
if (typeof value === "number") return [value];
if (typeof value === "boolean") return [value ? 1 : 0];
if (value instanceof Vector3) return [value.x, value.y, value.z];
if (value instanceof Quaternion) return [value.x, value.y, value.z, value.w];
if (value instanceof Color) return [value.r, value.g, value.b];
if (value instanceof Euler) {
const q = new Quaternion().setFromEuler(value);
return [q.x, q.y, q.z, q.w];
}
// duck-type Vector2/Vector3-like
if (typeof value === "object" && value !== null && "x" in value && "y" in value) {
if ("w" in value) return [value.x, value.y, value.z, value.w];
if ("z" in value) return [value.x, value.y, value.z];
return [value.x, value.y];
}
return [Number(value)];
}
function resolveInterpolation(mode?: AnimationInterpolation): InterpolationModes {
switch (mode) {
case "smooth": return InterpolateSmooth;
case "step": return InterpolateDiscrete;
default: return InterpolateLinear;
}
}
function isTween<V>(kf: KF<V>): kf is Tween<V> {
return kf != null && !Array.isArray(kf) && typeof kf === "object" && "from" in kf && "to" in kf;
}
function tweenToKeyframes<V>(tween: Tween<V>): AnimationKeyframe<V>[] {
return [
{ time: 0, value: tween.from, interpolation: tween.interpolation },
{ time: tween.duration ?? 1, value: tween.to, interpolation: tween.interpolation },
];
}
/** Snapshot a keyframe value so live references (e.g. `obj.position`) are captured at definition time */
function snapshotValue(value: any): any {
if (value == null || typeof value !== "object") return value; // primitives are fine
if (typeof value.clone === "function") return value.clone(); // Vector3, Quaternion, Color, Euler, etc.
if (Array.isArray(value)) return value.slice(); // tuple arrays
return value;
}
function isMaterial(obj: any): obj is Material {
return obj != null && typeof obj === "object" && obj.isMaterial === true;
}