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.

395 lines (357 loc) 12.8 kB
import type { Quaternion, Vector2, Vector3, Vector4 } from "three"; import type { Vec3 } from "./engine_types.js"; declare type Vector = Vector3 | Vector4 | Vector2 | Quaternion; /** * Math utility class providing common mathematical operations. * Access via the exported `Mathf` constant. * * @example * ```ts * import { Mathf } from "@needle-tools/engine"; * * // Random number between 0 and 10 * const rand = Mathf.random(0, 10); * * // Clamp a value * const clamped = Mathf.clamp(value, 0, 100); * * // Smooth interpolation * const smoothed = Mathf.lerp(start, end, t); * ``` */ class MathHelper { /** * Returns a random number or element. * @param arr Array to pick a random element from * @returns Random element from array, or null if array is empty * @example `Mathf.random([1, 2, 3])` - returns random element */ random<T>(arr: Array<T>): T | null; /** * Returns a random number between min and max (inclusive). * @param min Minimum value (inclusive) * @param max Maximum value (inclusive) * @returns Random number in range, or 0-1 if no args provided * @example `Mathf.random(0, 10)` - returns 0 to 10 */ random(min?: number, max?: number): number; random<T>(arrayOrMin?: number | Array<T>, max?: number): number | T | null { if (Array.isArray(arrayOrMin)) { if(arrayOrMin.length <= 0) return null; return arrayOrMin[Math.floor(Math.random() * arrayOrMin.length)]; } else { if (arrayOrMin !== undefined && max !== undefined) { return Math.random() * (max - arrayOrMin) + arrayOrMin; } } return Math.random(); } /** * Fills a Vector3 with random values. * @param target Vector3 to fill with random values * @param min Minimum value for each component * @param max Maximum value for each component */ randomVector3(target: Vector3, min: number = 0, max: number = 1) { target.x = this.random(min, max); target.y = this.random(min, max); target.z = this.random(min, max); } /** * Clamps a value between min and max. * @param value Value to clamp * @param min Minimum bound * @param max Maximum bound * @returns Clamped value */ clamp(value: number, min: number, max: number) { if (value < min) { return min; } else if (value > max) { return max; } return value; } /** * Clamps a value between 0 and 1. * @param value Value to clamp * @returns Value clamped to [0, 1] */ clamp01(value: number) { return this.clamp(value, 0, 1); } /** * Linearly interpolates between two values. * @param value1 Start value (returned when t=0) * @param value2 End value (returned when t=1) * @param t Interpolation factor, clamped to [0, 1] * @returns Interpolated value */ lerp(value1: number, value2: number, t: number) { t = t < 0 ? 0 : t; t = t > 1 ? 1 : t; return value1 + (value2 - value1) * t; } /** * Calculates the linear interpolation parameter that produces the given value. * Inverse of lerp: if `lerp(a, b, t) = v`, then `inverseLerp(a, b, v) = t` * @param value1 Start value * @param value2 End value * @param t The value to find the parameter for * @returns The interpolation parameter (may be outside [0,1] if t is outside [value1, value2]) */ inverseLerp(value1: number, value2: number, t: number) { return (t - value1) / (value2 - value1); } /** * Remaps a value from one range to another. * @param value The value to remap. * @param min1 The minimum value of the current range. * @param max1 The maximum value of the current range. * @param min2 The minimum value of the target range. * @param max2 The maximum value of the target range. */ remap(value: number, min1: number, max1: number, min2: number, max2: number) { return min2 + (max2 - min2) * (value - min1) / (max1 - min1); } /** * Moves a value towards a target by a maximum step amount. * Useful for smooth following or gradual value changes. * @param value1 Current value * @param value2 Target value * @param amount Maximum step to move (positive moves toward target) * @returns New value moved toward target, never overshooting */ moveTowards(value1: number, value2: number, amount: number) { value1 += amount; if (amount < 0 && value1 < value2) value1 = value2; else if (amount > 0 && value1 > value2) value1 = value2; return value1; } readonly Rad2Deg = 180 / Math.PI; readonly Deg2Rad = Math.PI / 180; readonly Epsilon = 0.00001; /** * Converts radians to degrees */ toDegrees(radians: number) { return radians * 180 / Math.PI; } /** * Converts degrees to radians */ toRadians(degrees: number) { return degrees * Math.PI / 180; } tan(radians: number) { return Math.tan(radians); } gammaToLinear(gamma: number) { return Math.pow(gamma, 2.2); } linearToGamma(linear: number) { return Math.pow(linear, 1 / 2.2); } /** * Checks if two vectors are approximately equal within epsilon tolerance. * Works with Vector2, Vector3, Vector4, and Quaternion. * @param v1 First vector * @param v2 Second vector * @param epsilon Tolerance for comparison (default: Number.EPSILON) * @returns True if all components are within epsilon of each other */ approximately(v1: Vector, v2: Vector, epsilon = Number.EPSILON) { for (const key of vectorKeys) { const a = v1[key]; const b = v2[key]; if (a === undefined || b === undefined) break; const diff = Math.abs(a - b); if (diff > epsilon) { return false; } } return true; } /** * Easing function: slow start, fast middle, slow end (cubic). * @param x Input value from 0 to 1 * @returns Eased value from 0 to 1 */ easeInOutCubic(x: number) { return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; } }; const vectorKeys = ["x", "y", "z", "w"] export const Mathf = new MathHelper(); class LowPassFilter { y: number | null; s: number | null; alpha = 0; constructor(alpha: number) { this.setAlpha(alpha); this.y = null; this.s = null; } setAlpha(alpha: number) { if (alpha <= 0 || alpha > 1.0) { throw new Error(); } this.alpha = alpha; } filter(value: number, alpha: number) { if (alpha) { this.setAlpha(alpha); } let s: number; if (!this.y) { s = value; } else { s = this.alpha * value + (1.0 - this.alpha) * this.s!; } this.y = value; this.s = s; return s; } lastValue() { return this.y; } reset(value: number) { this.y = value; this.s = value; } } /** * [OneEuroFilter](https://engine.needle.tools/docs/api/OneEuroFilter) is a low-pass filter designed to reduce jitter in noisy signals while maintaining low latency. * It's particularly useful for smoothing tracking data from XR controllers, hand tracking, or other input devices where the signal contains noise but responsiveness is important. * * The filter automatically adapts its smoothing strength based on the signal's velocity: * - When the signal moves slowly, it applies strong smoothing to reduce jitter * - When the signal moves quickly, it reduces smoothing to maintain responsiveness * * Based on the research paper: [1€ Filter: A Simple Speed-based Low-pass Filter for Noisy Input](http://cristal.univ-lille.fr/~casiez/1euro/) * * @example Basic usage with timestamp * ```ts * const filter = new OneEuroFilter(120, 1.0, 0.0); * * // In your update loop: * const smoothedValue = filter.filter(noisyValue, this.context.time.time); * ``` * * @example Without timestamps (using frequency estimate) * ```ts * // Assuming 60 FPS update rate * const filter = new OneEuroFilter(60, 1.0, 0.5); * * // Call without timestamp - uses the frequency estimate * const smoothedValue = filter.filter(noisyValue); * ``` * * @example Smoothing 3D positions * ```ts * const posFilter = new OneEuroFilterXYZ(90, 0.5, 0.0); * * posFilter.filter(trackedPosition, smoothedPosition, this.context.time.time); * ``` * * @see {@link OneEuroFilterXYZ} for filtering 3D vectors */ export class OneEuroFilter { /** * An estimate of the frequency in Hz of the signal (> 0), if timestamps are not available. */ freq: number; /** * Min cutoff frequency in Hz (> 0). Lower values allow to remove more jitter. */ minCutOff: number; /** * Parameter to reduce latency (> 0). Higher values make the filter react faster to changes. */ beta: number; /** * Used to filter the derivates. 1 Hz by default. Change this parameter if you know what you are doing. */ dCutOff: number; /** * The low-pass filter for the signal. */ x: LowPassFilter; /** * The low-pass filter for the derivates. */ dx: LowPassFilter; /** * The last time the filter was called. */ lasttime: number | null; /** Create a new OneEuroFilter * @param freq - An estimate of the frequency in Hz of the signal (> 0), if timestamps are not available. * @param minCutOff - Min cutoff frequency in Hz (> 0). Lower values allow to remove more jitter. * @param beta - Parameter to reduce latency (> 0). Higher values make the filter react faster to changes. * @param dCutOff - Used to filter the derivates. 1 Hz by default. Change this parameter if you know what you are doing. */ constructor(freq: number, minCutOff = 1.0, beta = 0.0, dCutOff = 1.0) { if (freq <= 0 || minCutOff <= 0 || dCutOff <= 0) { throw new Error(); } this.freq = freq; this.minCutOff = minCutOff; this.beta = beta; this.dCutOff = dCutOff; this.x = new LowPassFilter(this.alpha(this.minCutOff)); this.dx = new LowPassFilter(this.alpha(this.dCutOff)); this.lasttime = null; } alpha(cutOff: number) { const te = 1.0 / this.freq; const tau = 1.0 / (2 * Math.PI * cutOff); return 1.0 / (1.0 + tau / te); } /** Filter your value: call with your value and the current timestamp (e.g. from this.context.time.time) */ filter(x: number, time: number | null = null) { if (this.lasttime && time) { this.freq = 1.0 / (time - this.lasttime); } this.lasttime = time; const prevX = this.x.lastValue(); const dx = !prevX ? 0.0 : (x - prevX) * this.freq; const edx = this.dx.filter(dx, this.alpha(this.dCutOff)); const cutOff = this.minCutOff + this.beta * Math.abs(edx); return this.x.filter(x, this.alpha(cutOff)); } reset(x?: number) { if (x != undefined) this.x.reset(x); this.x.alpha = this.alpha(this.minCutOff); this.dx.alpha = this.alpha(this.dCutOff); this.lasttime = null; } } export class OneEuroFilterXYZ { readonly x: OneEuroFilter; readonly y: OneEuroFilter; readonly z: OneEuroFilter; /** Create a new OneEuroFilter * @param freq - An estimate of the frequency in Hz of the signal (> 0), if timestamps are not available. * @param minCutOff - Min cutoff frequency in Hz (> 0). Lower values allow to remove more jitter. * @param beta - Parameter to reduce latency (> 0). Higher values make the filter react faster to changes. * @param dCutOff - Used to filter the derivates. 1 Hz by default. Change this parameter if you know what you are doing. */ constructor(freq: number, minCutOff = 1.0, beta = 0.0, dCutOff = 1.0) { this.x = new OneEuroFilter(freq, minCutOff, beta, dCutOff); this.y = new OneEuroFilter(freq, minCutOff, beta, dCutOff); this.z = new OneEuroFilter(freq, minCutOff, beta, dCutOff); } filter(value: Vec3, target: Vec3, time: number | null = null) { target.x = this.x.filter(value.x, time); target.y = this.y.filter(value.y, time); target.z = this.z.filter(value.z, time); } reset(value?: Vec3) { this.x.reset(value?.x); this.y.reset(value?.y); this.z.reset(value?.z); } }