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.

688 lines (644 loc) • 25.5 kB
import { BufferGeometry, CatmullRomCurve3, CubicBezierCurve3, Curve, Line, LineBasicMaterial,LineCurve3, Object3D, Quaternion, Vector3 } from "three"; import { Mathf } from "../../engine/engine_math.js"; import { serializeable } from "../../engine/engine_serialization.js"; import { getParam } from "../../engine/engine_utils.js"; import { Behaviour } from "../Component.js"; import type { SplineWalker } from "./index.js"; const debug = getParam("debugsplines"); /** * Represents a single knot (control point) in a spline curve. * * Each knot defines a point along the spline with its position, rotation, and tangent handles * that control the curve's shape entering and leaving the knot. * * **Properties:** * - **position**: The 3D position of this knot in local space * - **rotation**: The orientation at this knot (useful for rotating objects along the spline) * - **tangentIn**: The incoming tangent handle controlling the curve shape before this knot * - **tangentOut**: The outgoing tangent handle controlling the curve shape after this knot * * @see {@link SplineContainer} for the container that holds and manages multiple knots */ export class SplineData { /** * The 3D position of this knot in local space relative to the SplineContainer. */ @serializeable(Vector3) position: Vector3 = new Vector3(); /** * The orientation at this knot. Can be used to rotate objects following the spline. */ @serializeable(Quaternion) rotation: Quaternion = new Quaternion(); /** * The incoming tangent handle controlling the curve shape as it approaches this knot. * The magnitude and direction affect the smoothness and curvature of the spline. */ @serializeable(Vector3) tangentIn: Vector3 = new Vector3(); /** * The outgoing tangent handle controlling the curve shape as it leaves this knot. * The magnitude and direction affect the smoothness and curvature of the spline. */ @serializeable(Vector3) tangentOut: Vector3 = new Vector3(); } // enum SplineTypeEnum { // CatmullRom = 0, // Bezier = 1, // Linear = 2 // } // type SplineType = "CatmullRom" | "Bezier" | "Linear"; //@dont-generate-component /** * [SplineContainer](https://engine.needle.tools/docs/api/SplineContainer) manages spline curves defined by a series of knots (control points). * This component stores spline data and generates smooth curves that can be used for animation paths, camera paths, racing tracks, or any curved path in 3D space. * * ![](https://cloud.needle.tools/-/media/XIHaiNFsA1IbMZVJepp1aQ.gif) * * **How It Works:** * The spline is defined by an array of {@link SplineData} knots. Each knot contains: * - **Position**: The location of the control point * - **Rotation**: Orientation at that point (useful for banking/tilting objects along the path) * - **Tangents**: Handles that control the curve's smoothness and shape * * The component uses Catmull-Rom interpolation to create smooth curves between knots. The curve is automatically * rebuilt when knots are added, removed, or marked dirty, and all sampling methods return positions in world space. * * **Key Features:** * - Smooth Catmull-Rom curve interpolation * - Support for open and closed curves * - Dynamic knot management (add/remove at runtime) * - World-space sampling with {@link getPointAt} and {@link getTangentAt} * - Automatic curve regeneration when modified * - Built-in debug visualization * - Integrates seamlessly with {@link SplineWalker} * * **Common Use Cases:** * - Camera paths and cinematics * - Object movement along curved paths * - Racing game tracks and racing lines * - Character patrol routes * - Procedural road/path generation * - Animation curves for complex motion * - Cable/rope visualization * * @example Basic spline setup with knots * ```ts * const splineObj = new Object3D(); * const spline = splineObj.addComponent(SplineContainer); * * // Add knots to define the path * spline.addKnot({ position: new Vector3(0, 0, 0) }); * spline.addKnot({ position: new Vector3(2, 1, 0) }); * spline.addKnot({ position: new Vector3(4, 0, 2) }); * spline.addKnot({ position: new Vector3(6, -1, 1) }); * * // Sample a point halfway along the spline * const midpoint = spline.getPointAt(0.5); * console.log("Midpoint:", midpoint); * ``` * * @example Creating a closed loop spline * ```ts * const loopSpline = gameObject.addComponent(SplineContainer); * loopSpline.closed = true; // Makes the spline loop back to the start * * // Add circular path knots * for (let i = 0; i < 8; i++) { * const angle = (i / 8) * Math.PI * 2; * const pos = new Vector3(Math.cos(angle) * 5, 0, Math.sin(angle) * 5); * loopSpline.addKnot({ position: pos }); * } * ``` * * @example Sampling points along a spline * ```ts * const spline = gameObject.getComponent(SplineContainer); * * // Sample 10 points along the spline * const points: Vector3[] = []; * for (let i = 0; i <= 10; i++) { * const t = i / 10; // 0 to 1 * const point = spline.getPointAt(t); * points.push(point); * } * * // Get tangent (direction) at 75% along the spline * const tangent = spline.getTangentAt(0.75); * console.log("Direction at 75%:", tangent); * ``` * * @example Dynamic knot manipulation * ```ts * const spline = gameObject.getComponent(SplineContainer); * * // Add a new knot dynamically * const newKnot = new SplineData(); * newKnot.position.set(10, 5, 0); * spline.addKnot(newKnot); * * // Remove the first knot * spline.removeKnot(0); * * // Modify existing knot * spline.spline[1].position.y += 2; * spline.markDirty(); // Tell the spline to rebuild * ``` * * @example Using with SplineWalker for animation * ```ts * // Set up spline path * const spline = pathObject.addComponent(SplineContainer); * spline.addKnot({ position: new Vector3(0, 0, 0) }); * spline.addKnot({ position: new Vector3(5, 2, 5) }); * spline.addKnot({ position: new Vector3(10, 0, 0) }); * * // Make object follow the spline * const walker = movingObject.addComponent(SplineWalker); * walker.spline = spline; * walker.speed = 2; // Units per second * walker.loop = true; * ``` * * **Debug Visualization:** * Add `?debugsplines` to your URL to enable debug visualization, which draws the spline curve as a purple line. * You can also enable it programmatically: * ```ts * spline.debug = true; // Show debug visualization * ``` * * @see {@link SplineWalker} - Component for moving objects along a spline path * @see {@link SplineData} - The knot data structure used to define spline points * @see {@link getPointAt} - Sample positions along the spline * @see {@link getTangentAt} - Get direction vectors along the spline * @see {@link addKnot} - Add control points to the spline * @see {@link removeKnot} - Remove control points from the spline * * @summary Manages smooth spline curves defined by control point knots * @category Splines * @group Components * @component */ // #region SplineContainer export class SplineContainer extends Behaviour { /** * Adds a knot (control point) to the end of the spline. * * You can pass either a full {@link SplineData} object or a simple object with just a position. * When passing a simple object, default values are used for rotation and tangents. * * The spline curve is automatically marked dirty and will be rebuilt on the next update. * * @param knot - Either a SplineData object or an object with at least a `position` property * @returns This SplineContainer for method chaining * * @example Add knots with positions only * ```ts * spline.addKnot({ position: new Vector3(0, 0, 0) }) * .addKnot({ position: new Vector3(5, 0, 0) }) * .addKnot({ position: new Vector3(5, 0, 5) }); * ``` * * @example Add a full SplineData knot * ```ts * const knot = new SplineData(); * knot.position.set(10, 2, 5); * knot.rotation.setFromEuler(new Euler(0, Math.PI / 4, 0)); * spline.addKnot(knot); * ``` */ addKnot(knot: SplineData | { position: Vector3 }): SplineContainer { if (knot instanceof SplineData) { this.spline.push(knot); this._isDirty = true; } else { const k = new SplineData(); k.position.copy(knot.position); this.spline.push(k); this._isDirty = true; } return this; } /** * Removes a knot (control point) from the spline. * * You can remove a knot either by its numeric index in the spline array or by passing * a reference to the SplineData object itself. * * The spline curve is automatically marked dirty and will be rebuilt on the next update. * * @param index - Either the numeric index of the knot to remove, or the SplineData object reference * @returns This SplineContainer for method chaining * * @example Remove knot by index * ```ts * spline.removeKnot(0); // Remove first knot * spline.removeKnot(spline.spline.length - 1); // Remove last knot * ``` * * @example Remove knot by reference * ```ts * const knotToRemove = spline.spline[2]; * spline.removeKnot(knotToRemove); * ``` */ removeKnot(index: number | SplineData): SplineContainer { if (typeof index === "number") { this.spline.splice(index, 1); this._isDirty = true; } else { const i = this.spline.indexOf(index); if (i !== -1) { this.spline.splice(i, 1); this._isDirty = true; } } return this; } /** * Samples a point on the spline at a given parametric position (in world space). * * The parameter `t` ranges from 0 to 1, where: * - `0` = start of the spline * - `0.5` = middle of the spline * - `1` = end of the spline * * The returned position is in world space, accounting for the SplineContainer's transform. * Values outside 0-1 are clamped to the valid range. * * @param to01 - Parametric position along the spline (0 to 1) * @param target - Optional Vector3 to store the result (avoids allocation) * @returns The world-space position at parameter `t` * * @example Sample multiple points along the spline * ```ts * // Sample 20 evenly-spaced points * const points: Vector3[] = []; * for (let i = 0; i <= 20; i++) { * const t = i / 20; * points.push(spline.getPointAt(t)); * } * ``` * * @example Using a target vector for efficiency * ```ts * const reusableVector = new Vector3(); * for (let i = 0; i < 100; i++) { * const point = spline.getPointAt(i / 100, reusableVector); * // Use point... * } * ``` * * @see {@link getTangentAt} to get the direction at a point */ getPointAt(to01: number, target?: Vector3): Vector3 { if (!this.curve) return new Vector3(); const pos = this.curve.getPointAt(Mathf.clamp01(to01), target); const worldMatrix = this.gameObject.matrixWorld ?? undefined; if (worldMatrix) { pos.applyMatrix4(worldMatrix); } return pos; } /** * Marks the spline as dirty, causing it to be rebuilt on the next update frame. * * Call this method whenever you manually modify the spline data (knot positions, rotations, or tangents) * to ensure the curve is regenerated. This is done automatically when using {@link addKnot} or {@link removeKnot}. * * @example Modifying knots and marking dirty * ```ts * // Modify existing knot positions * spline.spline[0].position.y += 2; * spline.spline[1].position.x -= 1; * * // Tell the spline to rebuild * spline.markDirty(); * ``` * * @example Animating knot positions * ```ts * update() { * const time = this.context.time.time; * // Animate knot positions * for (let i = 0; i < spline.spline.length; i++) { * spline.spline[i].position.y = Math.sin(time + i) * 2; * } * spline.markDirty(); // Rebuild curve each frame * } * ``` */ markDirty() { this._isDirty = true; } /** * Samples the tangent (direction) vector on the spline at a given parametric position (in world space). * * The tangent represents the forward direction of the curve at point `t`. This is useful for: * - Orienting objects along the spline (facing the direction of travel) * - Calculating banking/tilting for vehicles on the path * - Understanding the curve's direction at any point * * The parameter `t` ranges from 0 to 1 (same as {@link getPointAt}). * The returned vector is normalized and in world space, accounting for the SplineContainer's rotation. * * @param t - Parametric position along the spline (0 to 1) * @param target - Optional Vector3 to store the result (avoids allocation) * @returns The normalized tangent vector in world space at parameter `t` * * @example Orient an object along the spline * ```ts * const position = spline.getPointAt(0.5); * const tangent = spline.getTangentAt(0.5); * * object.position.copy(position); * object.lookAt(position.clone().add(tangent)); // Face along the spline * ``` * * @example Calculate velocity direction for a moving object * ```ts * let t = 0; * update() { * t += this.context.time.deltaTime * 0.2; // Speed * if (t > 1) t = 0; // Loop * * const pos = spline.getPointAt(t); * const direction = spline.getTangentAt(t); * * movingObject.position.copy(pos); * movingObject.quaternion.setFromUnitVectors( * new Vector3(0, 0, 1), * direction * ); * } * ``` * * @see {@link getPointAt} to get the position at a point */ getTangentAt(t: number, target?: Vector3): Vector3 { if (!this.curve) return target ?? new Vector3(); const wr = this.gameObject.worldQuaternion; return this.curve.getTangentAt(Mathf.clamp01(t), target).applyQuaternion(wr); } /** * Whether the spline forms a closed loop. * * **When `true`:** * - The spline connects the last knot back to the first knot, forming a continuous loop * - Perfect for racing tracks, patrol routes, or any circular path * - Parameter `t=1` will smoothly connect back to `t=0` * * **When `false` (default):** * - The spline is open, with distinct start and end points * - Suitable for one-way paths, camera movements, or linear progressions * * Changing this property marks the spline as dirty and triggers a rebuild. * * @example Create a circular patrol route * ```ts * const patrol = gameObject.addComponent(SplineContainer); * patrol.closed = true; // Loop back to start * * // Add points in a circle * for (let i = 0; i < 8; i++) { * const angle = (i / 8) * Math.PI * 2; * patrol.addKnot({ * position: new Vector3(Math.cos(angle) * 10, 0, Math.sin(angle) * 10) * }); * } * ``` * * @default false */ @serializeable() set closed(value: boolean) { this._closed = value; this._isDirty = true; } get closed() { return this._closed; } private _closed: boolean = false; /** * Array of knots (control points) that define the spline curve. * * Each element is a {@link SplineData} object containing position, rotation, and tangent information. * You can directly access and modify this array, but remember to call {@link markDirty} afterwards * to trigger a curve rebuild. * * **Best practices:** * - Use {@link addKnot} and {@link removeKnot} methods for automatic dirty marking * - If modifying knots directly, always call {@link markDirty} afterwards * - The order of knots determines the path direction * * @example Direct array access * ```ts * console.log(`Spline has ${spline.spline.length} knots`); * * // Access first knot * const firstKnot = spline.spline[0]; * console.log("Start position:", firstKnot.position); * * // Modify and mark dirty * spline.spline[2].position.y += 5; * spline.markDirty(); * ``` * * @see {@link SplineData} for the knot data structure * @see {@link addKnot} for adding knots (auto marks dirty) * @see {@link removeKnot} for removing knots (auto marks dirty) * @see {@link markDirty} to trigger rebuild after manual modifications */ @serializeable(SplineData) spline: SplineData[] = []; /** * Enables visual debug rendering of the spline curve. * * When enabled, the spline is rendered as a purple line in the scene, making it easy to * visualize the path during development. The debug line automatically updates when the spline is modified. * * **Debug visualization:** * - Purple line showing the complete curve path * - Automatically rebuilds when spline changes * - Line resolution based on number of knots (10 segments per knot) * * **Tip:** You can also enable debug visualization globally for all splines by adding `?debugsplines` * to your URL. * * @example Enable debug visualization * ```ts * const spline = gameObject.addComponent(SplineContainer); * spline.debug = true; // Show purple debug line * * // Add some knots to see the visualization * spline.addKnot({ position: new Vector3(0, 0, 0) }); * spline.addKnot({ position: new Vector3(5, 2, 0) }); * spline.addKnot({ position: new Vector3(10, 0, 5) }); * ``` */ set debug(debug: boolean) { if (debug && !this._builtCurve) this.buildCurve(); if (!this._debugLine) return; this._debugLine.visible = debug; } /** * The Three.js Curve object generated from the spline knots. * * This is the underlying curve implementation (typically a CatmullRomCurve3) that's used for * all position and tangent sampling. The curve is automatically regenerated when the spline * is marked dirty. * * **Note:** This curve is in local space relative to the SplineContainer. Use {@link getPointAt} * and {@link getTangentAt} methods to get world-space results. * * @returns The generated Three.js Curve, or null if not yet built */ get curve(): Curve<Vector3> | null { return this._curve; } /** * Whether the spline needs to be rebuilt due to modifications. * * The spline is marked dirty when: * - Knots are added via {@link addKnot} * - Knots are removed via {@link removeKnot} * - {@link markDirty} is called manually * - The {@link closed} property is changed * * The curve is automatically rebuilt on the next update frame when dirty. * * @returns `true` if the spline needs rebuilding, `false` otherwise */ get isDirty() { return this._isDirty; } private _isDirty: boolean = false; private _curve: Curve<Vector3> | null = null; private _builtCurve: boolean = false; private _debugLine: Object3D | null = null; /** @internal */ awake() { if (debug) { console.log(`[Spline] ${this.name}`, this); this.buildCurve(); } } /** @internal */ update() { if (this._isDirty) { this.buildCurve(true); } if (this._debugLine && this._debugLine.parent !== this.gameObject) this.gameObject.add(this._debugLine); } private buildCurve(force: boolean = false) { if (this._builtCurve && !force) return; this._builtCurve = true; if (!this.spline) { console.error("[Spline] Can not build curve, no spline data", this.name); return; } this._isDirty = false; this._curve = createCatmullRomCurve(this.spline, this.closed); this.buildDebugCurve(); // TODO: Unity supports spline interpolation type per knot which we don't support right now. Additionally EditType is deprecated. For simplicity we're just supporting CatmullRom for now. // switch (this.editType) { // case SplineType.CatmullRom: // this.createCatmullRomCurve(); // break; // case SplineType.Bezier: // console.warn("Bezier spline not implemented yet", this.name); // this.createCatmullRomCurve(); // // this.createBezierCurve(); // break; // case SplineType.Linear: // this.createLinearCurve(); // break; // } } private buildDebugCurve() { if (debug && this.spline && this._curve) { this._debugLine?.removeFromParent(); this._debugLine = null; const material = new LineBasicMaterial({ color: 0x6600ff, }); const res = this.spline.length * 10; const splinePoints = this._curve.getPoints(res); const geometry = new BufferGeometry().setFromPoints(splinePoints); this._debugLine = new Line(geometry, material); this.gameObject?.add(this._debugLine); } } } function createCatmullRomCurve(data: SplineData[], closed: boolean): CatmullRomCurve3 { const points = data.map(knot => new Vector3(-knot.position.x, knot.position.y, knot.position.z)); if (points.length === 1) points.push(points[0]); const averageTension = data.reduce((acc, knot) => acc + Math.abs(knot.tangentOut.x) + Math.abs(knot.tangentOut.y) + Math.abs(knot.tangentOut.z), 0) / data.length; const tension = Mathf.clamp(Mathf.remap(averageTension, 0, 0.3, 0, .5), 0, 1); return new CatmullRomCurve3(points, closed, "catmullrom", tension); } function createLinearCurve(data: SplineData[], closed: boolean): LineCurve3 | null { if (!data || data.length < 2) return null; const points = data.map(knot => new Vector3(-knot.position.x, knot.position.y, knot.position.z)); if (closed) points.push(points[0]); return new LineCurve3(points.at(0), points.at(1)); } // function createBezierCurve(data: SplineData[], closed: boolean): CubicBezierCurve3 | null { // if (!data || data.length < 2) return null; // for (let k = 0; k < data.length; k++) { // const k0 = data[k]; // let nextIndex = k + 1; // if (nextIndex >= data.length) { // if (!closed) break; // nextIndex = 0; // } // const k1 = data[nextIndex]; // // points // const p0 = new Vector3(-k0.position.x, k0.position.y, k0.position.z); // const p1 = new Vector3(-k1.position.x, k1.position.y, k1.position.z); // // tangents // const t0 = new Vector3(-k0.tangentOut.x, k0.tangentOut.y, k0.tangentOut.z); // const t1 = new Vector3(-k1.tangentIn.x, k1.tangentIn.y, k1.tangentIn.z); // // rotations // // const q0 = k0.rotation;// new Quaternion(k0.rotation.value.x, k0.rotation.value.y, k0.rotation.value.z, k0.rotation.value.w); // // const q1 = k1.rotation;// new Quaternion(k1.rotation.value.x, k1.rotation.value.y, k1.rotation.value.z, k1.rotation.value.w); // // const a = new Vector3(0,1,0); // // const angle = Math.PI*.5; // // t0.sub(p0).applyQuaternion(q0).add(p0); // // t1.sub(p1).applyQuaternion(q1).add(p1); // t0.add(p0); // // t0.applyQuaternion(q0); // t1.add(p1); // const curve = new CubicBezierCurve3(p0, t0, t1, p1); // return curve; // } // return null; // } // class SplineCurve { // private spline: Spline; // constructor(spline: Spline) { // this.spline = spline; // } // getPoints(num: number): Vector3[] { // const points: Vector3[] = []; // const samplePerKnot = num / this.spline.length; // for (let k = 1; k < this.spline.length; k++) { // const cur = this.spline[k]; // const prev = this.spline[k - 1]; // for (let i = 0; i < samplePerKnot; i++) { // const t = i / (samplePerKnot - 1); // console.log(CurveUtils); // const x = this.interpolate(-prev.Position.x, -cur.Position.x, -prev.tangentOut.x, -cur.TangentIn.x, t); // const y = this.interpolate(prev.Position.y, cur.Position.y, prev.tangentOut.y, cur.TangentIn.y, t); // const z = this.interpolate(prev.Position.z, cur.Position.z, prev.tangentOut.z, cur.TangentIn.z, t); // points.push(new Vector3(x, y, z)); // } // } // return points; // } // interpolate(p0, p1, p2, p3, t) { // var v0 = (p2 - p0) * 0.5; // var v1 = (p3 - p1) * 0.5; // var t2 = t * t; // var t3 = t * t2; // return (2 * p1 - 2 * p2 + v0 + v1) * t3 + (- 3 * p1 + 3 * p2 - 2 * v0 - v1) * t2 + v0 * t + p1; // } // }