@zakkster/lite-motion-path
v1.0.0
Published
Zero-GC 3D spline evaluation and arc-length parameterization. Bakes multi-segment Catmull-Rom paths into Float32Array LUTs for constant-velocity hot-path sampling.
Downloads
60
Maintainers
Readme
@zakkster/lite-motion-path
Zero-GC 3D Catmull-Rom splines with arc-length parameterization. Constant-speed motion along a curve, with a hot path that writes directly to your ECS translation buffer.
┌──────────────────────────────────┐ ┌──────────────────────────────┐
│ AUTHORING (init-time) │ │ RUNTIME (per frame) │
│ │ │ │
│ 3D control points │ bake │ processChainedMotionPath() │
│ ↓ │ ──────▶ │ ↓ │
│ Sample evenly across t │ once │ Binary-search LUT │
│ ↓ │ │ ↓ │
│ Cumulative arc length │ │ Re-evaluate Catmull-Rom │
│ ↓ │ │ ↓ │
│ Normalize → [0, 1] LUT │ │ Write XYZ to ECS buffer │
└──────────────────────────────────┘ └──────────────────────────────┘Why this exists
If you parameterize a curve naturally, equal increments of t produce unequal increments of distance. A Catmull-Rom segment evaluated at t = 0.0, 0.1, 0.2, ... visits points that are bunched in tight curves and spread out in straight stretches. Move an entity along that parameterization and it lurches: fast through the straight bits, slow through the bends. Visually broken.
The fix is arc-length parameterization: sample the curve, compute cumulative distance, normalize to [0, 1], and store the result as a lookup table. At runtime, you map "distance along the curve" to "where to evaluate" via binary search through the LUT. The entity moves at constant speed regardless of curve shape.
This package does that, with zero allocations on the hot path and a chain variant that handles arbitrary-length splines while still writing directly to your ECS translation buffer.
Install
npm install @zakkster/lite-motion-pathESM only. Zero runtime dependencies.
Quick start: single segment
For a single C¹-continuous segment between two points (with two phantom controls for the tangents):
import {
bakeCatmullRomLUT,
mapDistanceToT,
evalCatmullRom,
} from '@zakkster/lite-motion-path';
// Phantom — through — through — phantom
const lut = bakeCatmullRomLUT(
-1, 0, 0, // p0 (tangent control)
0, 0, 0, // p1 (start, t=0)
1, 1, 0, // p2 (end, t=1)
2, 0, 0, // p3 (tangent control)
256
);
// Per frame
function tick(progress01) {
const t = mapDistanceToT(lut, progress01); // distance → constant-speed t
const x = evalCatmullRom(-1, 0, 1, 2, t);
const y = evalCatmullRom( 0, 0, 1, 0, t);
const z = evalCatmullRom( 0, 0, 0, 0, t);
// ... use x, y, z ...
}Quick start: chained spline (the production case)
Most real motion paths have many control points. Chain mode handles N points (N ≥ 4) and produces N-3 segments, with the curve passing through points p1 through p_(N-2). The hot path writes directly to your ECS translation buffer:
import {
bakeChainLUT,
processChainedMotionPath,
} from '@zakkster/lite-motion-path';
// 6 control points = 3 segments. Curve runs from p1 to p4.
const points = new Float32Array([
-2, 0, 0, // p0 (phantom tangent)
0, 0, 0, // p1 — start of curve
2, 3, 0, // p2
5, 1, 1, // p3
7, 4, 0, // p4 — end of curve
9, 0, 0, // p5 (phantom tangent)
]);
const pointCount = points.length / 3; // 6
const segmentCount = pointCount - 3; // 3
const lut = bakeChainLUT(points, pointCount, 1024);
// Your ECS translation buffer (XYZ, stride 3)
const translations = new Float32Array(maxEntities * 3);
function tick() {
for (let entityId = 0; entityId < activeEntities; entityId++) {
const u = progressFor(entityId); // your code, returns [0, 1]
processChainedMotionPath(
points, segmentCount, lut,
translations, entityId * 3, // write offset
u
);
}
// translations is now updated. Hand it to your renderer.
}The hot path performs zero allocations. All output goes to the buffer you provided.
API
evalCatmullRom(p0, p1, p2, p3, t) → number
Catmull-Rom basis (uniform, tension 0.5). Evaluates one component (x, y, or z) at parametric t ∈ [0, 1]. The curve passes through p1 at t=0 and through p2 at t=1; p0 and p3 control the tangents.
bakeCatmullRomLUT(p0x...p3z, resolution = 256) → Float32Array
Bakes a single Catmull-Rom segment into a normalized arc-length LUT. Returns a Float32Array where lut[0] = 0 and lut[end] ≈ 1. Returns a zero-filled LUT for collapsed segments (p1 === p2).
bakeChainLUT(pointsBuffer, pointCount, resolution = 1024) → Float32Array
Bakes a multi-segment spline. pointsBuffer is [x, y, z, x, y, z, ...] with stride 3. For N points, produces N-3 segments. Throws if pointCount < 4.
mapDistanceToT(lut, u) → number
Single-segment hot path. Maps distance u ∈ [0, 1] to constant-speed-corrected t ∈ [0, 1] via binary search. Out-of-range u is clamped. Use the result with evalCatmullRom to re-evaluate the curve.
processChainedMotionPath(pointsBuffer, segmentCount, lut, translationBuffer, entityOffset, u) → void
Chain hot path. Maps u ∈ [0, 1] to a 3D position on the chained spline and writes the result into translationBuffer[entityOffset..entityOffset+2]. Zero allocations per call. Out-of-range u is clamped.
Performance characteristics
Bake phase is O(resolution) with a single Float32Array allocation. At resolution 1024 you're doing 1024 Catmull-Rom evaluations and ~1024 sqrts — sub-millisecond on any device made this decade.
Single-segment hot path (mapDistanceToT):
mapDistanceToT(lut, u):
cmp + branch ; clamp guards
binary search ; ~log₂(resolution) iterations, branch-free comparisons
fmul + fadd ; segment-local interpolation
return primitiveNo allocations. Returns a primitive number. ~log₂(256) = 8 iterations on a default LUT.
Chain hot path (processChainedMotionPath):
The same binary search, plus:
| 0 ; segment index extraction
3 × evalCatmullRom ; ~12 multiplies, ~6 adds per component
3 × buffer write ; direct typed-array storeStill zero allocations. The function returns nothing — all output is in translationBuffer.
Memory cost: 4 bytes per LUT entry. Default chain resolution (1024) = 4 KB per path. The pointsBuffer is caller-owned and shared between bake and runtime — no duplication.
Choosing a resolution
The LUT resolution controls how accurately constant-speed motion is approximated. Higher resolution = closer to true constant-speed at the cost of cache footprint and bake time. The defaults (256 single, 1024 chain) are tuned for typical use:
| Resolution | Use case | |------------|-------------------------------------------------------------| | 64–128 | Eyeballed motion paths where small speed wobbles are fine. | | 256 | Default for single segments. Imperceptible to the eye. | | 1024 | Default for chains. Required when traversing many segments. | | 4096+ | Forensic-quality animation, scientific visualization. |
If in doubt, leave the defaults alone. The LUT is built once.
Notes & limitations
- Uniform Catmull-Rom only (tension = 0.5). The centripetal variant (
α = 0.5in the original Catmull-Rom paper) reduces self-intersection on sharp turns; not currently exposed. If you need it, open an issue with a real test case. - Chain endpoints are p₁ and p_(N-2). The first and last points are phantom tangent controls and are not traversed. If you want the curve to start exactly at your authored first point, prepend an extra phantom point (typically by reflecting:
p_phantom = 2 × first - second). - Collapsed segments (
p1 === p2) in the single-segment baker return a zero LUT. This causesmapDistanceToTto always return 0, which is the correct degenerate behavior (no motion). - No per-segment closed-form arc length. Catmull-Rom segments don't have closed-form arc length, so the baker numerically integrates with linear segments. Higher resolution = better integration; the
sqrtper sample is unavoidable.
License
MIT. See LICENSE.txt.
