@zakkster/lite-ease-lut
v1.0.1
Published
High-performance, zero-GC lookup table generator for easing functions. Eliminates Math.sin and Math.pow overhead in hot paths.
Maintainers
Readme
@zakkster/lite-ease-lut
📐 What is lite-ease-lut?
@zakkster/lite-ease-lut bakes any easing function (t: number) => number into a pre-computed lookup table backed by a Float32Array. The result is a drop-in replacement closure that returns the same shape of curve, but with deterministic, allocation-free cost on the hot path.
It gives you:
- 📐 Drop-in replacement:
bakeLUT(easeOutElastic)returns(t) => number - ⚡ Deterministic ~15 ns/call on V8, regardless of curve complexity
- 🧊 Zero allocations on hot path — no GC pressure during animation
- 🏗️
bakeAll()packs an entire animation system into one contiguous buffer for cache locality - 🔬 Smart segment-count heuristics by family (Bounce: 128, Elastic: 256, Sine/Circ/Expo/Back: 64)
- 🛡️ Minifier-safe via the
hintparameter — survives aggressive name mangling - ♻️
dispose()zeros owned memory for clean teardown in long-running apps - 🪶 < 1 KB minified, zero dependencies
Part of the @zakkster/lite-* ecosystem — micro-libraries built for deterministic, cache-friendly game development.
🤔 Why a LUT?
Not all easings are created equal. easeInQuad is one multiplication. easeInElastic is Math.pow + Math.sin. In a frame budget, that variance creates jank when you switch curves.
A baked LUT replaces the curve evaluation with a Float32Array index + a linear interpolation. The cost is:
- ✅ The same ~15 ns regardless of which family you pick. No more "elastic is the slow one."
- ✅ No
Math.sin/Math.pow/ branching in animation hot paths — important for older mobile WebViews and constrained runtimes (Twitch Extensions, embedded<canvas>). - ✅ Cache-friendly when batched. With
bakeAll(), every active easing lives in the same Float32Array, so all your curve evaluations hit the same hot cache lines.
This isn't a universal speedup. If your only easing is easeInQuad, raw math wins (it's already 4 ns). LUTs win when you have a mix of cheap and expensive curves and want the cost to be predictable.
🚀 Install
npm i @zakkster/lite-ease-lutPairs naturally with @zakkster/lite-ease:
npm i @zakkster/lite-ease @zakkster/lite-ease-lut🕹️ Quick Start
Single curve
import { bakeLUT } from '@zakkster/lite-ease-lut';
import { easeOutElastic } from '@zakkster/lite-ease';
// Bake once, anywhere outside the hot path
const ease = bakeLUT(easeOutElastic);
// Drop-in replacement: same signature, faster + deterministic cost
function animate(t) {
return ease(t); // (t: number) => number, just like easeOutElastic
}Batched (recommended for production)
import { bakeAll } from '@zakkster/lite-ease-lut';
import * as eases from '@zakkster/lite-ease';
// One contiguous Float32Array for all your curves
const { easings, shared, dispose } = bakeAll({
inOutSine: eases.easeInOutSine,
outBounce: eases.easeOutBounce,
inOutElastic: eases.easeInOutElastic,
});
// Hot path: zero allocations, predictable cost, cache-friendly
const y = lerp(start, end, easings.outBounce(t));
// On teardown (long-running apps, page unload, etc.)
dispose();Minified bundle (production)
When your bundler renames easeOutBounce to e or o2, segment heuristics need a hint:
import { bakeAll } from '@zakkster/lite-ease-lut';
import * as e from '@zakkster/lite-ease'; // renamed import
const { easings } = bakeAll(
{ primary: e.easeOutBounce, secondary: e.easeInElastic },
{ hints: { primary: 'bounce', secondary: 'elastic' } }
);🏗️ The bakeAll pattern
bakeAll is the difference between "I have ten LUTs scattered in heap" and "I have one cache line with everything I'll ever touch."
Without bakeAll: With bakeAll:
┌────────┐ ┌─────────────────────────┐
│ LUT A │ ← heap allocation │ shared Float32Array │
└────────┘ │ ┌──┐┌──────┐┌────┐┌───┐ │
┌──────────────────┐ │ │ A││ B ││ C ││ D │ │
│ LUT B │ ← another │ └──┘└──────┘└────┘└───┘ │
└──────────────────┘ └─────────────────────────┘
┌──────┐ ↑ contiguous, cache-friendly
│LUT C │ ← another
└──────┘You can also bring your own buffer (e.g. a slice of a WebAssembly.Memory or a pool you manage yourself):
import { bakeAll } from '@zakkster/lite-ease-lut';
const myPool = new Float32Array(8192);
const { easings } = bakeAll(easingsMap, {
shared: myPool,
offset: 1024, // Write starting at index 1024
segments: 64, // Force every curve to 64 segments
});⚙️ API
bakeLUT(easeFn, segments?, hint?)
Primary single-curve API. Returns a closure that's a drop-in replacement for easeFn.
| Param | Type | Description |
|---|---|---|
| easeFn | (t: number) => number | Pure easing function |
| segments | number? | Override the recommended count |
| hint | string \| { family: string }? | Minifier-safe family hint |
Returns a BakedEasing — a callable (t) => number with these introspection properties:
| Property | Type | Description |
|---|---|---|
| lut | Float32Array \| null | The underlying LUT (null when linear is short-circuited) |
| segments | number | Segment count |
| offset | number | Start index in the LUT (always 0 for bakeLUT) |
Linear short-circuit: When the function name or hint contains
'linear'andsegments === 2, no LUT is allocated and the closure becomes the identity function.
bakeAll(easingsMap, options?)
Batch processor. Bakes a dictionary of easings into one contiguous Float32Array.
| Option | Type | Description |
|---|---|---|
| segments | number? | Force every curve to this segment count |
| shared | Float32Array? | Bring your own buffer |
| offset | number? | Starting index when using a custom buffer (default 0) |
| hints | Record<string, string \| { family: string }>? | Per-key family hints for minifier safety |
Returns:
| Property | Type | Description |
|---|---|---|
| easings | Record<string, BakedEasing> | Drop-in closures, keyed by your map keys |
| shared | Float32Array | The contiguous LUT data |
| offsets | Int32Array | Start index of each curve in shared |
| segments | Int32Array | Segment count of each curve |
| dispose | () => void | Zero this batch's slice + null out closure .lut references |
recommendedSegments(ease, hint?)
Returns the heuristic segment count for a function or family name.
| Family | Default | Why |
|---|---|---|
| linear | 2 | Identity — only endpoints needed |
| sine / circ / expo / back | 64 | Smooth curves — moderate sampling |
| bounce | 128 | Multi-cusp piecewise — needs density |
| elastic | 256 | High-frequency oscillation — needs the most |
| (unrecognized) | 32 | Safe default |
Throws when the function name appears minified (name.length <= 2) and no hint is provided. This is intentional — it prevents you from accidentally shipping a bundle where everything falls back to 32 segments.
evalLUT(lut, offset, segments, t)
The raw evaluator used internally by baked closures. Exposed for advanced use:
- Building your own pooling layer
- Sharing a LUT between multiple call sites
- Embedding LUT data in a binary asset
import { fillLUT, evalLUT } from '@zakkster/lite-ease-lut';
const buffer = new Float32Array(33);
fillLUT(easeOutBounce, buffer, 0, 32);
// In your hot path — completely allocation-free:
const y = evalLUT(buffer, 0, 32, t);Handles t <= 0, t >= 1, and NaN without branching to allocation.
fillLUT(easeFn, outFloat32, offset, segments)
Caller-owned memory variant of the bake step. Writes segments + 1 values into outFloat32 starting at offset. Returns the count written. Throws if the buffer is too small or segments < 1.
The last vertex is always easeFn(1) exactly — no float drift from i * (1/segments) accumulating to slightly-less-than-1.
🧪 Benchmark
5,000,000 calls each on Node 22, V8 13.x, x64 Linux, t cycling through 1000 values:
| Easing | Raw math | LUT | Verdict |
|---|---:|---:|---|
| easeOutBounce (128 seg) | 3.75 ns/call | 13.83 ns/call | Raw wins — bounce is just multiplications |
| easeInOutSine (64 seg) | 18.75 ns/call | 15.14 ns/call | LUT modest win — Math.cos is the cost |
| easeInElastic (256 seg) | 72.16 ns/call | 14.84 ns/call | LUT 5× faster — Math.pow + Math.sin are expensive |
Read this honestly: if your animations only use polynomial easings, you don't need this library. If you use Elastic, Sine, Expo, Circ, or any custom curve with trigonometry, every active easing now costs the same predictable ~15 ns — and that's the win.
Numbers will vary by engine. Mobile WebViews and older JavaScriptCore versions tend to show larger LUT advantages because
Math.sin/Math.poware slower there.
🛡️ Minifier safety
If you minify with Terser/esbuild/SWC, function names get mangled. Without help, recommendedSegments(myMinifiedFn) would throw, and named imports like _a.easeOutBounce could become _a.b — losing the family signal.
Two escape hatches:
// Per-call hint on bakeLUT
bakeLUT(easeFn, undefined, 'elastic');
bakeLUT(easeFn, undefined, { family: 'bounce' });
// Per-key hints on bakeAll
bakeAll(map, { hints: { foo: 'sine', bar: 'expo' } });
// Or just override segments entirely (skips heuristics)
bakeAll(map, { segments: 64 });Hints are matched as case-insensitive substrings against the family name (e.g. 'CustomBounceV2' matches 'bounce').
📦 TypeScript
Full declarations included in EaseLut.d.ts:
import type { BakedEasing, BakeAllResult, EasingHint } from '@zakkster/lite-ease-lut';
const ease: BakedEasing = bakeLUT(easeOutBounce);
ease.lut; // Float32Array | null
ease.segments; // number
ease.offset; // number
ease(0.5); // number — still callable🤝 Pairs well with
@zakkster/lite-ease— 30 Penner easings as tree-shakeable ESM@zakkster/lite-lerp— Composable interpolation primitives@zakkster/lite-ecs— Zero-GC entity-component-system
License
MIT
