UNPKG

@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
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; }