npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

@zakkster/lite-motion-path

npm version npm bundle size npm downloads npm total downloads TypeScript Dependencies License: MIT

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-path

ESM 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 primitive

No 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 store

Still 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.5 in 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 causes mapDistanceToT to 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 sqrt per sample is unavoidable.

License

MIT. See LICENSE.txt.