How much of an animation can you throw away?
About 97% of it. One second of a 30-bone walk cycle at 60 fps is 72,005 bytes of raw float32 and 2,229 bytes after constant-channel removal, cubic keyframe reduction and quantisation — 32x, with 7.2 mm of worst-case joint error on a 1.8 m character. Measured on a walk cycle, a snappy jump and a jitt
About 97% of it: one second of a 30-bone walk cycle at 60 fps costs 72,005 bytes as raw float32 and 2,229 bytes after constant-channel removal, cubic keyframe reduction and quantisation — 32.3x smaller, with a worst-case joint error of 7.24 mm on a 1.8 m character. Of the 37,800 keyframes in a 3-second clip, 1,116 were worth keeping. The single biggest win costs nothing and is lossless: 89% of the channels in a normal humanoid rig never change value at all.
Hardware: Apple M3, 16 GB, macOS 26.4.1 (build 25E253). Node v23.5.0, built-ins only — node:zlib and Buffer, no engine, no packages, no network. Byte counts and error figures are exact and reproduce from the generator; the nanosecond timings are medians of nine runs on one laptop under normal desktop load and move about 10% between runs.
The short answer
- Delete the channels that never move first. On the walk clip, 177 of 180 position/scale channels and 10 of 30 rotation tracks were constant — 187 of 210 tracks, 89.0%. Storing one value instead of 180 took the clip from 216,016 to 69,764 bytes, 3.10x, with zero error.
- Then drop 96–97% of the remaining keyframes. At a 1 mm tolerance, linear reduction kept 1,501 of 37,800 keys and cubic kept 1,116 — 24,068 and 17,486 bytes.
- A better interpolator is worth about a third — on smooth motion only. On the walk, cubic Hermite keys were 1.38x fewer bytes than linear at the same tolerance and lower actual error. On the snappy jump the same swap bought 1.06x, and at a 0.1 mm tolerance cubic was worse than linear.
- The snappy jump did not compress much worse than the smooth walk, which is not what we expected. Per animated track per second the walk kept 19.0 keys of 60 and the jump 18.1. It costs more per second of clip (3,053 B/s against 2,229) mostly because cubic curves cannot help it.
- gzip still helps after all of that: 1.83x. That contradicts our own earlier result on packed network packets, and the reason is size, not format.
What exactly was generated?
A 30-bone humanoid rig — hips, spine, chest, neck, head, two arm chains, two leg chains, four finger bones, a prop, a jaw, two eyes and a cape — with real bone lengths in metres for a 1.8 m character. Each bone stores local position (3 floats), a rotation quaternion (4) and scale (3): 10 floats per bone per frame, 300 per frame, 72,005 bytes per second at 60 fps including a 16-byte header. That is what an exporter produces before anyone optimises it.
Three clips, all deterministic:
- walk — 3 s, 180 frames. A 1 Hz gait: root travelling forward at 1.4 m/s with a vertical bob at twice the step frequency, limb rotations as phase-offset sinusoids, and a knee angle built with
max(0, sin(…))so the curve has a genuine corner at knee lock. - jump — 2 s, 120 frames. Piecewise by design: 0.30 s idle, a smoothstep crouch, a 0.06 s linear launch, a parabolic 0.56 s airborne arc, a 0.08 s linear impact, an eased recovery, then idle. The derivative is discontinuous at launch and at impact, and the root carries a volume-preserving squash and stretch so the scale channels are not idle.
- mocap — the walk plus deterministic per-frame jitter (±0.12° on rotations, ±0.4 mm on the root) standing in for captured data.
Both matter because they fail differently. Smooth motion is what curve fitting is for; sharp motion is where an over-eager fitter rounds off the frames the animator cared most about.
How many channels are constant?
Almost all of them, and this is the part most exporters get wrong.
| Clip | Constant scalar channels | Constant rotation tracks | Raw bytes | After removal |
|---|---|---|---|---|
| walk | 177 / 180 | 10 / 30 | 216,016 | 69,764 |
| jump | 175 / 180 | 10 / 30 | 144,016 | 48,516 |
| mocap | 177 / 180 | 10 / 30 | 216,016 | 69,764 |
Bone positions are fixed by the skeleton — only the root translates — and scale is 1 unless someone animates it. Of 180 position and scale channels, exactly three move on the walk and five on the jump. Ten rotation tracks — shoulders, fingers, prop, jaw, eyes — are never touched.
3.10x, exactly zero error, from a min/max comparison per channel. If your build step does nothing else, do this one. It is also why the numbers below are so large — everything that follows works on the 11% of the file that contains motion.
How many keyframes are actually redundant?
A key is redundant if reconstructing it from its neighbours lands within a tolerance. We fit by refinement — start with the first and last frame only, reconstruct the whole channel, insert the frame with the worst error, repeat until every frame is inside the tolerance — using the same procedure for linear and cubic so the comparison is fair.
Tolerance is a distance in millimetres; rotation tracks get an angular tolerance derived from it by assuming a 0.35 m lever arm, so 1 mm becomes 0.164°. Error is reported where it matters: maximum world-space joint displacement over every joint and every frame, after forward kinematics.
Walk clip, 3 seconds, constant-channel removal always on:
| Interp | Tolerance | Keys kept | Bytes | Bytes/s | gzip | Max joint error |
|---|---|---|---|---|---|---|
| linear | 0.1 mm | 3,365 | 55,544 | 18,515 | 15,077 | 0.32 mm |
| linear | 0.5 mm | 2,068 | 33,878 | 11,293 | 10,446 | 1.85 mm |
| linear | 1 mm | 1,501 | 24,068 | 8,023 | 8,132 | 4.69 mm |
| linear | 2 mm | 1,126 | 17,762 | 5,921 | 6,447 | 9.14 mm |
| linear | 5 mm | 791 | 11,984 | 3,995 | 4,600 | 17.74 mm |
| linear | 20 mm | 460 | 6,350 | 2,117 | 2,589 | 69.45 mm |
| cubic | 0.1 mm | 2,675 | 44,336 | 14,779 | 12,979 | 0.34 mm |
| cubic | 0.5 mm | 1,410 | 22,682 | 7,561 | 8,298 | 1.74 mm |
| cubic | 1 mm | 1,116 | 17,486 | 5,829 | 6,736 | 3.78 mm |
| cubic | 2 mm | 860 | 13,214 | 4,405 | 5,241 | 8.83 mm |
| cubic | 5 mm | 663 | 10,004 | 3,335 | 3,952 | 13.96 mm |
| cubic | 20 mm | 411 | 5,468 | 1,823 | 2,270 | 70.80 mm |
Two things. First, the world-space error is roughly 4–5x the per-channel tolerance — 1 mm produced 4.69 mm of joint displacement, because a fingertip inherits every approximation made at the hips, spine, chest, shoulder and elbow. Set a tolerance in your exporter and assume that is what players see, and you are out by a factor of five. Second, the curve is steep at the top and flat at the bottom: 0.1 mm to 1 mm cost 4.4 mm of error and saved 57% of the bytes, while 5 mm to 20 mm cost another 52 mm and saved 47% of what was left.
What error is actually visible?
This is a judgement, and we did not run a perceptual study. Here is the threshold we used and the arithmetic behind it, so you can move it.
A 1.8 m character drawn N pixels tall gives 1800/N millimetres per pixel:
| Character height on screen | 1 pixel is |
|---|---|
| 48 px | 37.50 mm |
| 64 px | 28.13 mm |
| 128 px | 14.06 mm |
| 256 px | 7.03 mm |
| 512 px | 3.52 mm |
| 1080 px | 1.67 mm |
We chose "worst-case joint error under one screen pixel" as the threshold. Sub-pixel joint displacement cannot move a rendered silhouette by a whole pixel, and a moving character hides more than a static comparison does. Half a pixel would be safer; two pixels would probably still pass on a fast action; a slow close-up of a face is a different problem entirely.
At 256 px tall, one pixel is 7.03 mm, so a 1 mm tolerance (4.69 mm linear, 3.78 mm cubic) sits inside it and a 2 mm tolerance (9.14 mm) does not. At 64 px — a typical 2D sprite character — one pixel is 28.13 mm and a 5 mm tolerance fits with room to spare, which is 3,335 bytes per second instead of 5,829. The right tolerance is a function of how big the character is on screen, and nobody sets it that way. A background NPC and a cutscene hero should not be exported with the same number.
Does a better interpolator let you drop more keys?
On smooth motion, clearly yes. On sharp motion, barely.
| Clip | Linear @1 mm | Cubic @1 mm | Bytes ratio | Linear error | Cubic error |
|---|---|---|---|---|---|
| walk | 24,068 B | 17,486 B | 1.38x | 4.69 mm | 3.78 mm |
| jump | 16,194 B | 15,336 B | 1.06x | 3.80 mm | 3.30 mm |
| mocap | 25,274 B | 24,134 B | 1.05x | 5.03 mm | 4.58 mm |
Cubic Hermite with Catmull-Rom tangents wins on the walk twice over — 1.38x fewer bytes and lower measured error — because a sinusoid is very nearly a cubic over a short span and a straight line is not.
On the jump it is close to a wash, and at the tightest tolerance it is a loss: at 0.1 mm the jump needed 1,574 linear keys but 1,677 cubic keys. A cubic segment approaching a corner overshoots, so the fitter spends extra keys on both sides of the corner holding the overshoot down. Curve fitting is a discount on smoothness, and the frames an animator snapped deliberately are exactly the ones it cannot discount. The mocap clip fails the same way: jitter is a corner every frame.
Does a snappy jump compress worse than a smooth walk?
Less than we expected, and the reason is not the one we assumed.
Per animated track per second, at a 1 mm linear tolerance, the walk kept 19.0 keys of 60 (31.7%) and the jump kept 18.1 of 60 (30.1%). Those are the same number. The jump's advantage is that it spends a third of its running time doing nothing:
| Jump phase | Frames | Keys | Keys per frame |
|---|---|---|---|
| 0.00–0.30 idle | 18 | 25 | 1.4 |
| 0.30–0.48 crouch | 11 | 160 | 14.5 |
| 0.48–0.54 launch | 3 | 25 | 8.3 |
| 0.54–1.10 airborne | 34 | 395 | 11.6 |
| 1.10–1.18 impact | 5 | 49 | 9.8 |
| 1.18–1.45 recover | 16 | 205 | 12.8 |
| 1.45–2.00 settle | 33 | 44 | 1.3 |
The transitions cost 8–15 keys per frame against 1.4 in the idle stretches, and 51 of the 120 frames are nearly free. Averaged over the clip that lands in the same place as a walk cycle that is uniformly busy. Where the jump actually loses is in the interpolator: the walk gets a 1.38x discount from cubic curves and the jump gets 1.06x, so the final per-second cost is 2,229 B/s for the walk and 3,053 B/s for the jump, a 1.37x gap. Budget by transition density, not by how snappy the animation feels: a clip with three sharp events and long holds is cheap, and a continuously busy idle-fidget loop is not.
What do quantisation and gzip add on top?
Everything measured together, walk clip, 1 mm tolerance:
| Technique | Bytes | Bytes/s | gzip | Max joint error |
|---|---|---|---|---|
| 1. Raw float32, every channel every frame | 216,016 | 72,005 | 31,818 | 0 |
| 2. + constant-channel removal (lossless) | 69,764 | 23,255 | 18,712 | 0 |
| 3. Keyframe reduction only, linear | 25,684 | 8,561 | 8,247 | 4.69 mm |
| 4. Constant removal + linear keys | 24,068 | 8,023 | 8,132 | 4.69 mm |
| 5. Constant removal + cubic keys | 17,486 | 5,829 | 6,736 | 3.78 mm |
| 6. 5 + 16-bit pos/scale + smallest-three quat | 6,688 | 2,229 | 3,650 | 7.24 mm |
| 7. Constant removal + quantisation, no key reduction | 25,034 | 8,345 | 13,314 | 7.26 mm |
Quantisation is 16-bit fixed point over each channel's own min/max range for position and scale, plus the smallest-three trick for rotations: drop the largest quaternion component, store its index in 2 bits and the other three in 10 bits each — 4 bytes per rotation instead of 16. Row 6 is 2.6x smaller than row 5 and moves the error from 3.78 mm to 7.24 mm, still inside one pixel at 256 px.
Rows 4 and 7 are the interesting pair: at almost identical size (24,068 versus 25,034 bytes), keyframe reduction gave 4.69 mm of error and quantisation alone gave 7.26 mm. If you can only implement one, implement the keyframe pass.
gzip still helps, and it is not supposed to. In syncing game state we found gzip inflated every hand-packed binary packet. Here the fully quantised 6,688-byte stream gzips to 3,650, 1.83x. Splitting by record type explains it: constant records compress 3.21x (187 near-identical 8-byte records) and the animated records still compress 1.69x. gzip's 18–23 bytes of header are irrelevant against 6.7 KB, and quantised values in adjacent keys still share high bytes. The earlier result was about packet size, not about packing. Ship your clips gzipped.
Where it stops working is noise: the mocap clip gzipped to 5,217 bytes against the clean walk's 3,650 — 1.43x worse for motion a viewer would call identical. Filter capture data before you compress it.
What does the smaller file cost to decode?
More than you would like. This is the number nobody puts next to the compression ratio.
| Sampling one full 30-bone pose (300 values) | Time |
|---|---|
| Raw float32, lerp between two whole frames | 243 ns |
| Linear keyframes, binary search + lerp | 1,900 ns |
| Cubic keyframes, binary search + Hermite | 2,029 ns |
| Quantised cubic keys, dequantise + Hermite | 2,019 ns |
The compressed formats cost 7.8–8.4x more CPU per pose than reading two raw frames, because a raw frame is one contiguous typed-array read and a keyframed pose is 210 independent binary searches into 210 short arrays. Quantisation is free on top. Whether that matters is arithmetic: 2,029 ns × 100 animated characters is 203 µs, or 1.2% of a 16.67 ms frame at 60 fps. Take the 32x.
One optimisation we expected to matter did not. Playback is sequential, so a cached per-track cursor should beat a binary search; over the 180 scalar channels it measured 374 ns with binary search and 359 ns with a cursor, 4% and inside the run-to-run noise. The tracks are short — a binary search over 13 keys is four comparisons, and there is nothing left to save.
What this means for a build
For a game with 200 clips averaging 2 seconds, the raw float32 export is a calculated 28.8 MB; the same clips through this pipeline are 0.89 MB, or 0.49 MB gzipped. Same shape of result as packing a sprite atlas and storing a tilemap: the boring lossless transformation does most of the work, and the clever lossy one is a knob to set per asset rather than per project. Our generative animation studio at animator.cstsolution.com exports clips at full rate for that reason — the tolerance belongs to your build, not to the tool that authored the motion.
Check it yourself
One file, no packages, no assets, nothing written to disk. Node 18 or newer. It takes about three seconds and reproduces every number above.
#!/usr/bin/env node
'use strict';
// Animation keyframe compression, measured. Node 23, built-ins only.
// Save as anim-compress.js and run: node anim-compress.js
const zlib = require('node:zlib');
// ---------------------------------------------------------------- rig
// 30-bone humanoid. rest = local offset from parent, in metres.
const RIG = [
['hips', -1, [ 0, 0.95, 0 ]],
['spine', 0, [ 0, 0.12, 0 ]],
['chest', 1, [ 0, 0.16, 0 ]],
['neck', 2, [ 0, 0.20, 0 ]],
['head', 3, [ 0, 0.09, 0 ]],
['shoulder.L', 2, [ 0.05, 0.16, 0 ]],
['upperarm.L', 5, [ 0.13, 0, 0 ]],
['forearm.L', 6, [ 0.28, 0, 0 ]],
['hand.L', 7, [ 0.26, 0, 0 ]],
['shoulder.R', 2, [-0.05, 0.16, 0 ]],
['upperarm.R', 9, [-0.13, 0, 0 ]],
['forearm.R', 10, [-0.28, 0, 0 ]],
['hand.R', 11, [-0.26, 0, 0 ]],
['thigh.L', 0, [ 0.09,-0.06, 0 ]],
['shin.L', 13, [ 0, -0.42, 0 ]],
['foot.L', 14, [ 0, -0.40, 0 ]],
['toe.L', 15, [ 0, -0.06, 0.12]],
['thigh.R', 0, [-0.09,-0.06, 0 ]],
['shin.R', 17, [ 0, -0.42, 0 ]],
['foot.R', 18, [ 0, -0.40, 0 ]],
['toe.R', 19, [ 0, -0.06, 0.12]],
['finger1.L', 8, [ 0.06, 0, 0.02]],
['finger2.L', 8, [ 0.06, 0, -0.02]],
['finger1.R', 12, [-0.06, 0, 0.02]],
['finger2.R', 12, [-0.06, 0, -0.02]],
['prop_sword', 12, [-0.04, 0, 0 ]],
['jaw', 4, [ 0, -0.03, 0.04]],
['eye.L', 4, [ 0.03, 0.04, 0.06]],
['eye.R', 4, [-0.03, 0.04, 0.06]],
['cape', 2, [ 0, 0.05, -0.08]],
];
const NB = RIG.length;
const FPS = 60;
// -------------------------------------------------------------- maths
const D2R = Math.PI / 180, R2D = 180 / Math.PI;
function euler(x, y, z) { // degrees, XYZ intrinsic -> quaternion [x,y,z,w]
const cx = Math.cos(x * D2R / 2), sx = Math.sin(x * D2R / 2);
const cy = Math.cos(y * D2R / 2), sy = Math.sin(y * D2R / 2);
const cz = Math.cos(z * D2R / 2), sz = Math.sin(z * D2R / 2);
return [sx*cy*cz + cx*sy*sz, cx*sy*cz - sx*cy*sz,
cx*cy*sz + sx*sy*cz, cx*cy*cz - sx*sy*sz];
}
const qmul = (a, b) => [
a[3]*b[0] + a[0]*b[3] + a[1]*b[2] - a[2]*b[1],
a[3]*b[1] - a[0]*b[2] + a[1]*b[3] + a[2]*b[0],
a[3]*b[2] + a[0]*b[1] - a[1]*b[0] + a[2]*b[3],
a[3]*b[3] - a[0]*b[0] - a[1]*b[1] - a[2]*b[2]];
function qrot(q, v) { // rotate vec3 by quaternion
const [x, y, z, w] = q, [a, b, c] = v;
const ix = w*a + y*c - z*b, iy = w*b + z*a - x*c;
const iz = w*c + x*b - y*a, iw = -x*a - y*b - z*c;
return [ix*w + iw*-x + iy*-z - iz*-y,
iy*w + iw*-y + iz*-x - ix*-z,
iz*w + iw*-z + ix*-y - iy*-x];
}
const qnorm = q => { const l = Math.hypot(q[0],q[1],q[2],q[3]) || 1;
return [q[0]/l, q[1]/l, q[2]/l, q[3]/l]; };
const qdot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3];
const qangle = (a, b) => 2 * Math.acos(Math.min(1, Math.abs(qdot(a, b)))) * R2D;
const clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v;
const smooth = u => { u = clamp(u, 0, 1); return u*u*(3 - 2*u); };
// deterministic LCG, for the mocap-jitter clip only
function lcg(seed) { let s = seed >>> 0;
return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296 * 2 - 1; }; }
// ----------------------------------------------------------- clip gen
// A clip is Float64 arrays: pos[b][3][f], rot[b][f][4], scl[b][3][f]
function blankClip(frames) {
return {
frames,
pos: RIG.map(r => [new Float64Array(frames).fill(r[2][0]),
new Float64Array(frames).fill(r[2][1]),
new Float64Array(frames).fill(r[2][2])]),
scl: RIG.map(() => [new Float64Array(frames).fill(1),
new Float64Array(frames).fill(1),
new Float64Array(frames).fill(1)]),
rot: RIG.map(() => new Array(frames).fill(null).map(() => [0, 0, 0, 1])),
};
}
const IDX = Object.fromEntries(RIG.map((r, i) => [r[0], i]));
const setRot = (c, name, f, x, y, z) => { c.rot[IDX[name]][f] = euler(x, y, z); };
function walkClip(seconds, jitter) {
const frames = Math.round(seconds * FPS), c = blankClip(frames);
const rnd = lcg(20260905);
for (let f = 0; f < frames; f++) {
const t = f / FPS, p = 2 * Math.PI * 1.0 * t; // 1 Hz cycle = 2 steps/s
const j = jitter ? () => rnd() : () => 0;
// root: constant forward travel + vertical bob + lateral sway
c.pos[0][0][f] = 0.020 * Math.sin(p) + j() * 0.0004;
c.pos[0][1][f] = 0.95 + 0.025 * Math.sin(2*p) + j() * 0.0004;
c.pos[0][2][f] = 1.40 * t + j() * 0.0004;
setRot(c, 'hips', f, -4 + 1.5*Math.sin(2*p) + j()*0.12, 3*Math.sin(p) + j()*0.12, 2*Math.sin(p));
setRot(c, 'spine', f, 2*Math.sin(2*p) + j()*0.12, -2*Math.sin(p), 0);
setRot(c, 'chest', f, 1.5*Math.sin(2*p), 4*Math.sin(p + Math.PI) + j()*0.12, 0);
setRot(c, 'neck', f, 0, -2*Math.sin(p) + j()*0.12, 0);
setRot(c, 'head', f, 1.5*Math.sin(2*p) + j()*0.12, 2*Math.sin(p), 0);
setRot(c, 'upperarm.L',f, -8 + 25*Math.sin(p) + j()*0.12, 0, 8);
setRot(c, 'upperarm.R',f, -8 + 25*Math.sin(p + Math.PI) + j()*0.12, 0, -8);
setRot(c, 'forearm.L', f, -(20 + 15*Math.sin(p + 0.6)) + j()*0.12, 0, 0);
setRot(c, 'forearm.R', f, -(20 + 15*Math.sin(p + 0.6 + Math.PI)) + j()*0.12, 0, 0);
setRot(c, 'hand.L', f, 5*Math.sin(p) + j()*0.12, 0, 0);
setRot(c, 'hand.R', f, 5*Math.sin(p + Math.PI) + j()*0.12, 0, 0);
setRot(c, 'thigh.L', f, 28*Math.sin(p) + j()*0.12, 0, 0);
setRot(c, 'thigh.R', f, 28*Math.sin(p + Math.PI) + j()*0.12, 0, 0);
// knee lock: max() puts a genuine corner in the curve, as in real gait
setRot(c, 'shin.L', f, -(4 + 46*Math.max(0, Math.sin(p + 1.2))) + j()*0.12, 0, 0);
setRot(c, 'shin.R', f, -(4 + 46*Math.max(0, Math.sin(p + 1.2 + Math.PI))) + j()*0.12, 0, 0);
setRot(c, 'foot.L', f, 12*Math.sin(p + 2.0) + j()*0.12, 0, 0);
setRot(c, 'foot.R', f, 12*Math.sin(p + 2.0 + Math.PI) + j()*0.12, 0, 0);
setRot(c, 'toe.L', f, 30*Math.max(0, Math.sin(p + 2.6)), 0, 0);
setRot(c, 'toe.R', f, 30*Math.max(0, Math.sin(p + 2.6 + Math.PI)), 0, 0);
setRot(c, 'cape', f, 6*Math.sin(p - 0.8) + j()*0.12, 0, 3*Math.sin(2*p));
// fingers, prop, jaw, eyes, shoulders: never animated (identity, constant)
}
return c;
}
// vertical offset of the root during the jump, in metres. Deliberately
// piecewise: the corners at launch and impact are the point of this clip.
function jumpY(t) {
if (t < 0.30) return 0;
if (t < 0.48) return -0.18 * smooth((t - 0.30) / 0.18); // crouch
if (t < 0.54) return -0.18 + 0.18 * ((t - 0.48) / 0.06); // launch (linear, fast)
if (t < 1.10) { const u = (t - 0.54) / 0.56; // airborne parabola
return 4 * 0.55 * u * (1 - u); }
if (t < 1.18) return -0.22 * ((t - 1.10) / 0.08); // impact
if (t < 1.45) return -0.22 * (1 - smooth((t - 1.18) / 0.27)); // recover
return 0;
}
function jumpClip(seconds) {
const frames = Math.round(seconds * FPS), c = blankClip(frames);
for (let f = 0; f < frames; f++) {
const t = f / FPS, h = jumpY(t), hc = jumpY(Math.max(0, t - 0.08));
const cr = clamp(-h / 0.22, 0, 1); // crouch amount 0..1
const air = clamp(h / 0.55, 0, 1); // airborne amount 0..1
c.pos[0][0][f] = 0;
c.pos[0][1][f] = 0.95 + h;
c.pos[0][2][f] = 0.9 * clamp((t - 0.48) / 0.62, 0, 1) * 1.0; // forward hop
// squash and stretch on the root, volume preserving
const sy = 1 - 0.10 * cr + 0.07 * air;
c.scl[0][1][f] = sy;
c.scl[0][0][f] = c.scl[0][2][f] = 1 / Math.sqrt(sy);
setRot(c, 'hips', f, -4 - 22*cr + 6*air, 0, 0);
setRot(c, 'spine', f, 25*cr - 8*air, 0, 0);
setRot(c, 'chest', f, 10*cr - 5*air, 0, 0);
setRot(c, 'neck', f, -6*cr, 0, 0);
setRot(c, 'head', f, -8*cr + 4*air, 0, 0);
setRot(c, 'upperarm.L', f, -70*cr + 100*air, 0, 8);
setRot(c, 'upperarm.R', f, -70*cr + 100*air, 0, -8);
setRot(c, 'forearm.L', f, -(15 + 45*air), 0, 0);
setRot(c, 'forearm.R', f, -(15 + 45*air), 0, 0);
setRot(c, 'hand.L', f, -10*cr + 15*air, 0, 0);
setRot(c, 'hand.R', f, -10*cr + 15*air, 0, 0);
setRot(c, 'thigh.L', f, 55*cr + 35*air, 0, 0);
setRot(c, 'thigh.R', f, 55*cr + 35*air, 0, 0);
setRot(c, 'shin.L', f, -(70*cr + 60*air), 0, 0);
setRot(c, 'shin.R', f, -(70*cr + 60*air), 0, 0);
setRot(c, 'foot.L', f, 20*cr - 30*air, 0, 0);
setRot(c, 'foot.R', f, 20*cr - 30*air, 0, 0);
setRot(c, 'toe.L', f, 25*cr, 0, 0);
setRot(c, 'toe.R', f, 25*cr, 0, 0);
setRot(c, 'cape', f, 20 * clamp(-jumpY(0) + (hc - h) * 40, -1, 1), 0, 0);
}
return c;
}
// ------------------------------------------------------------ channels
// 30 bones x (3 pos + 3 scale) = 180 scalar channels
// 30 bones x 1 rotation track (quaternion, kept whole) = 30 tracks
// Baseline float32 layout counts 10 floats per bone per frame.
function channels(clip) {
const sc = [];
for (let b = 0; b < NB; b++) {
for (let a = 0; a < 3; a++) sc.push({ id: b*6 + a, kind: 'pos', bone: b, axis: a, v: clip.pos[b][a] });
for (let a = 0; a < 3; a++) sc.push({ id: b*6 + 3 + a, kind: 'scl', bone: b, axis: a, v: clip.scl[b][a] });
}
// hemisphere-align rotation tracks so component curves are continuous
const rt = [];
for (let b = 0; b < NB; b++) {
const q = clip.rot[b].map(x => x.slice());
for (let f = 1; f < q.length; f++) if (qdot(q[f-1], q[f]) < 0) q[f] = q[f].map(x => -x);
rt.push({ bone: b, q });
}
return { sc, rt };
}
const isConstScalar = v => { let lo = v[0], hi = v[0];
for (const x of v) { if (x < lo) lo = x; if (x > hi) hi = x; }
return hi - lo < 1e-7; };
const isConstQuat = q => q.every(x => qangle(x, q[0]) < 1e-4);
// ------------------------------------------------- reconstruct + fit
function reconScalar(vals, keys, cubic) {
const n = vals.length, out = new Float64Array(n);
const slope = i => { // non-uniform Catmull-Rom tangent
if (i === 0) return (vals[keys[1]] - vals[keys[0]]) / (keys[1] - keys[0]);
if (i === keys.length - 1) return (vals[keys[i]] - vals[keys[i-1]]) / (keys[i] - keys[i-1]);
return (vals[keys[i+1]] - vals[keys[i-1]]) / (keys[i+1] - keys[i-1]);
};
for (let k = 0; k < keys.length - 1; k++) {
const t0 = keys[k], t1 = keys[k+1], h = t1 - t0;
const p0 = vals[t0], p1 = vals[t1];
const m0 = cubic ? slope(k) * h : 0, m1 = cubic ? slope(k+1) * h : 0;
for (let f = t0; f <= t1; f++) {
const u = (f - t0) / h;
out[f] = cubic
? (2*u*u*u - 3*u*u + 1)*p0 + (u*u*u - 2*u*u + u)*m0 +
(-2*u*u*u + 3*u*u)*p1 + (u*u*u - u*u)*m1
: p0 + (p1 - p0) * u;
}
}
return out;
}
function reconQuat(q, keys, cubic) {
const n = q.length, out = new Array(n);
const comp = c => reconScalar(Float64Array.from(q, x => x[c]), keys, cubic);
const [cx, cy, cz, cw] = [comp(0), comp(1), comp(2), comp(3)];
for (let f = 0; f < n; f++) out[f] = qnorm([cx[f], cy[f], cz[f], cw[f]]);
return out;
}
// fit-and-refine: start with {first,last}, insert the worst frame until under eps
function fit(n, err, eps) {
if (n <= 2) return [...Array(n).keys()];
let keys = [0, n - 1];
for (;;) {
const e = err(keys);
let worst = -1, wi = -1;
for (let f = 0; f < n; f++) if (e[f] > worst) { worst = e[f]; wi = f; }
if (worst <= eps || keys.length >= n) return keys;
let lo = 0; while (keys[lo] < wi) lo++;
keys.splice(lo, 0, wi);
}
}
const fitScalar = (vals, eps, cubic) => fit(vals.length, keys => {
const r = reconScalar(vals, keys, cubic), e = new Float64Array(vals.length);
for (let f = 0; f < vals.length; f++) e[f] = Math.abs(r[f] - vals[f]);
return e;
}, eps);
const fitQuat = (q, epsDeg, cubic) => fit(q.length, keys => {
const r = reconQuat(q, keys, cubic), e = new Float64Array(q.length);
for (let f = 0; f < q.length; f++) e[f] = qangle(r[f], q[f]);
return e;
}, epsDeg);
// ------------------------------------------------------ forward kinem.
function fkFrame(pos, rot, scl, f) { // -> array of world positions
const wp = new Array(NB), wq = new Array(NB);
for (let b = 0; b < NB; b++) {
const p = RIG[b][1];
const lp = [pos[b][0][f], pos[b][1][f], pos[b][2][f]];
const lq = rot[b][f];
if (p < 0) { wp[b] = lp; wq[b] = lq; }
else {
const s = [scl[p][0][f], scl[p][1][f], scl[p][2][f]];
const off = qrot(wq[p], [lp[0]*s[0], lp[1]*s[1], lp[2]*s[2]]);
wp[b] = [wp[p][0] + off[0], wp[p][1] + off[1], wp[p][2] + off[2]];
wq[b] = qmul(wq[p], lq);
}
}
return { wp, wq };
}
function fkError(clip, dec) { // max/mean joint error, metres
let mx = 0, sum = 0, cnt = 0, mxDeg = 0;
for (let f = 0; f < clip.frames; f++) {
const A = fkFrame(clip.pos, clip.rot, clip.scl, f);
const B = fkFrame(dec.pos, dec.rot, dec.scl, f);
for (let b = 0; b < NB; b++) {
const d = Math.hypot(A.wp[b][0]-B.wp[b][0], A.wp[b][1]-B.wp[b][1], A.wp[b][2]-B.wp[b][2]);
if (d > mx) mx = d; sum += d; cnt++;
const a = qangle(A.wq[b], B.wq[b]); if (a > mxDeg) mxDeg = a;
}
}
return { max: mx, mean: sum / cnt, maxDeg: mxDeg };
}
// ------------------------------------------------------- quantisation
function quantScalar(vals, keys) { // 16-bit fixed point over the used range
let lo = Infinity, hi = -Infinity;
for (const k of keys) { if (vals[k] < lo) lo = vals[k]; if (vals[k] > hi) hi = vals[k]; }
const range = hi - lo;
const enc = keys.map(k => range === 0 ? 0 : Math.round((vals[k] - lo) / range * 65535));
const dec = enc.map(q => lo + (range === 0 ? 0 : q / 65535 * range));
return { lo, range, enc, dec };
}
function quantQuat(q) { // smallest three: 2 + 10 + 10 + 10 = 32 bits
const n = qnorm(q);
let li = 0; for (let i = 1; i < 4; i++) if (Math.abs(n[i]) > Math.abs(n[li])) li = i;
const sign = n[li] < 0 ? -1 : 1;
const S = Math.SQRT1_2, out = [];
for (let i = 0; i < 4; i++) if (i !== li)
out.push(Math.round(clamp((n[i]*sign + S) / (2*S), 0, 1) * 1023));
return { li, e: out };
}
function dequantQuat(p) {
const S = Math.SQRT1_2, v = p.e.map(x => x / 1023 * 2*S - S);
const r = []; let k = 0; let ss = 0;
for (let i = 0; i < 4; i++) { if (i === p.li) r.push(null); else { r.push(v[k]); ss += v[k]*v[k]; k++; } }
r[p.li] = Math.sqrt(Math.max(0, 1 - ss));
return r;
}
// ----------------------------------------------------------- encoders
const HDR = 16;
function encodeRaw(clip) { // float32 per channel per frame
const buf = Buffer.alloc(HDR + clip.frames * NB * 10 * 4);
let o = HDR;
for (let f = 0; f < clip.frames; f++) for (let b = 0; b < NB; b++) {
for (let a = 0; a < 3; a++) { buf.writeFloatLE(clip.pos[b][a][f], o); o += 4; }
for (let a = 0; a < 4; a++) { buf.writeFloatLE(clip.rot[b][f][a], o); o += 4; }
for (let a = 0; a < 3; a++) { buf.writeFloatLE(clip.scl[b][a][f], o); o += 4; }
}
return buf;
}
// Track container. Scalar record: u16 id, u16 keyCount, [u16 frame + value]*.
// Constant record: u16 id, u16 count=1, one value, no frame index.
// Quat record: u16 bone, u16 keyCount, [u16 frame + quat]*.
function encodeTracks(plan, quant) {
const parts = [Buffer.alloc(HDR)];
for (const t of plan.sc) {
const n = t.keys.length, konst = t.const;
const vb = quant ? 2 : 4, extra = quant && !konst ? 8 : 0;
const b = Buffer.alloc(4 + extra + (konst ? vb : n * (2 + vb)));
b.writeUInt16LE(t.id, 0); b.writeUInt16LE(konst ? 1 : n, 2);
let o = 4;
if (quant && !konst) { b.writeFloatLE(t.q.lo, o); b.writeFloatLE(t.q.range, o+4); o += 8; }
if (konst) { quant ? b.writeUInt16LE(0, o) : b.writeFloatLE(t.v[0], o); }
else for (let i = 0; i < n; i++) {
b.writeUInt16LE(t.keys[i], o); o += 2;
if (quant) { b.writeUInt16LE(t.q.enc[i], o); o += 2; }
else { b.writeFloatLE(t.v[t.keys[i]], o); o += 4; }
}
parts.push(b);
}
for (const t of plan.rt) {
const n = t.keys.length, konst = t.const;
const vb = quant ? 4 : 16;
const b = Buffer.alloc(4 + (konst ? vb : n * (2 + vb)));
b.writeUInt16LE(t.bone, 0); b.writeUInt16LE(konst ? 1 : n, 2);
let o = 4;
const wq = (qq) => { if (quant) { const p = quantQuat(qq);
b.writeUInt32LE(((p.li << 30) | (p.e[0] << 20) | (p.e[1] << 10) | p.e[2]) >>> 0, o); o += 4; }
else { for (let i = 0; i < 4; i++) { b.writeFloatLE(qq[i], o); o += 4; } } };
if (konst) wq(t.q[0]);
else for (const k of t.keys) { b.writeUInt16LE(k, o); o += 2; wq(t.q[k]); }
parts.push(b);
}
return Buffer.concat(parts);
}
// build a compression plan at a given tolerance, and its decoded clip
function plan(clip, epsM, epsDeg, cubic, quant, dropConst) {
const ch = channels(clip), n = clip.frames;
const sc = [], rt = [];
const dec = blankClip(n);
for (const c of ch.sc) {
const konst = dropConst && isConstScalar(c.v);
let keys = konst ? [0] : (epsM === null ? [...Array(n).keys()]
: fitScalar(c.v, epsM, cubic));
const rec = { ...c, keys, const: konst };
if (quant) rec.q = quantScalar(c.v, konst ? [0] : keys);
sc.push(rec);
// decode
const arr = dec[c.kind === 'pos' ? 'pos' : 'scl'][c.bone][c.axis];
if (konst) arr.fill(quant ? rec.q.dec[0] : c.v[0]);
else {
const src = quant ? (() => { const t = Float64Array.from(c.v);
keys.forEach((k, i) => { t[k] = rec.q.dec[i]; }); return t; })()
: c.v;
const r = reconScalar(src, keys, cubic);
for (let f = 0; f < n; f++) arr[f] = r[f];
}
}
for (const t of ch.rt) {
const konst = dropConst && isConstQuat(t.q);
let keys = konst ? [0] : (epsDeg === null ? [...Array(n).keys()]
: fitQuat(t.q, epsDeg, cubic));
rt.push({ ...t, keys, const: konst });
const src = quant ? t.q.map(x => dequantQuat(quantQuat(x))) : t.q;
if (konst) { const q = qnorm(src[0]); for (let f = 0; f < n; f++) dec.rot[t.bone][f] = q; }
else {
const r = reconQuat(src, keys, cubic);
for (let f = 0; f < n; f++) dec.rot[t.bone][f] = r[f];
}
}
return { sc, rt, dec, keysScalar: sc.reduce((a, r) => a + (r.const ? 1 : r.keys.length), 0),
keysQuat: rt.reduce((a, r) => a + (r.const ? 1 : r.keys.length), 0),
constScalar: sc.filter(r => r.const).length, constQuat: rt.filter(r => r.const).length };
}
const gz = b => zlib.gzipSync(b, { level: 6 }).length;
const f2 = (x, d = 2) => x.toFixed(d);
// ============================================================ the report
const LEVER = 0.35; // metres, upper arm + forearm
const mmToDeg = mm => (mm / 1000 / LEVER) * 180 / Math.PI;
const CLIPS = { walk: walkClip(3, false), jump: jumpClip(2), mocap: walkClip(3, true) };
for (const [name, clip] of Object.entries(CLIPS)) {
const ch = channels(clip);
console.log(`${name.padEnd(6)} ${clip.frames} frames @${FPS}fps ` +
`${ch.sc.filter(c => isConstScalar(c.v)).length}/180 scalar channels constant, ` +
`${ch.rt.filter(t => isConstQuat(t.q)).length}/30 rotation tracks constant`);
}
const EPS_MM = 1;
for (const [name, clip] of Object.entries(CLIPS)) {
const secs = clip.frames / FPS, e = EPS_MM/1000, d = mmToDeg(EPS_MM);
console.log(`\n### ${name} — ${secs.toFixed(2)} s, tolerance ${EPS_MM} mm / ${d.toFixed(3)} deg`);
console.log('technique'.padEnd(42) + ' bytes B/s gzip gzB/s maxErr_mm maxDeg');
const show = (label, buf, err) => console.log(`${label.padEnd(42)} ${String(buf.length).padStart(6)} ` +
`${(buf.length/secs).toFixed(0).padStart(7)} ${String(gz(buf)).padStart(7)} ` +
`${(gz(buf)/secs).toFixed(0).padStart(7)} ${(err ? (err.max*1000).toFixed(2) : '0').padStart(9)} ` +
`${(err ? err.maxDeg.toFixed(3) : '0').padStart(7)}`);
show('1 raw float32, every channel every frame', encodeRaw(clip), null);
const P = {
2: [null, null, false, false, true], 3: [e, d, false, false, false],
4: [e, d, false, false, true], 5: [e, d, true, false, true],
6: [e, d, true, true, true], 7: [null, null, false, true, true],
};
const L = { 2: '2 + constant-channel removal (lossless)', 3: '3 keyframe reduction only, linear',
4: '4 constant removal + linear keyframes', 5: '5 constant removal + cubic keyframes',
6: '6 5 + 16-bit pos/scale + smallest-3 quat', 7: '7 constant removal + quantisation only' };
const plans = {};
for (const k of Object.keys(P)) {
const p = plan(clip, ...P[k]); plans[k] = p;
show(L[k], encodeTracks(p, P[k][3]), fkError(clip, p.dec));
}
const at = (180 - plans[4].constScalar) + (30 - plans[4].constQuat);
const kd = p => (p.keysScalar + p.keysQuat - p.constScalar - p.constQuat) / at / secs;
console.log(` ${at} animated tracks; keys kept per animated track per second: ` +
`linear ${kd(plans[4]).toFixed(1)}/60 (${(kd(plans[4])/60*100).toFixed(1)}%), ` +
`cubic ${kd(plans[5]).toFixed(1)}/60 (${(kd(plans[5])/60*100).toFixed(1)}%)`);
}
console.log('\n### walk — tolerance sweep (constant removal always on)');
console.log('interp eps_mm eps_deg keys bytes B/s gzip maxErr_mm maxDeg');
for (const cubic of [false, true]) for (const mm of [0.1, 0.25, 0.5, 1, 2, 5, 10, 20, 50]) {
const clip = CLIPS.walk, p = plan(clip, mm/1000, mmToDeg(mm), cubic, false, true);
const b = encodeTracks(p, false), err = fkError(clip, p.dec);
console.log(`${cubic?'cubic':'lin '} ${String(mm).padStart(7)} ${mmToDeg(mm).toFixed(3).padStart(8)} ` +
`${String(p.keysScalar+p.keysQuat).padStart(7)} ${String(b.length).padStart(7)} ` +
`${(b.length/3).toFixed(0).padStart(6)} ${String(gz(b)).padStart(7)} ` +
`${(err.max*1000).toFixed(2).padStart(10)} ${err.maxDeg.toFixed(3).padStart(7)}`);
}
console.log('\n### jump — does a cubic curve help at a tight tolerance?');
for (const mm of [0.1, 1]) for (const cubic of [false, true]) {
const p = plan(CLIPS.jump, mm/1000, mmToDeg(mm), cubic, false, true);
const b = encodeTracks(p, false), err = fkError(CLIPS.jump, p.dec);
console.log(`${cubic?'cubic':'lin '} ${String(mm).padStart(5)} mm keys ` +
`${String(p.keysScalar+p.keysQuat).padStart(5)} bytes ${String(b.length).padStart(6)} ` +
`maxErr ${(err.max*1000).toFixed(2)} mm`);
}
// ------------------------------------------------------------ decode cost
const clip = CLIPS.walk, N = clip.frames;
const eM = EPS_MM/1000, eD = mmToDeg(EPS_MM);
function build(p, quant) {
const sc = p.sc.map(t => ({ const: t.const,
f: Uint16Array.from(t.const ? [0] : t.keys),
v: quant ? Uint16Array.from(t.const ? [0] : t.q.enc)
: Float32Array.from(t.const ? [t.v[0]] : t.keys.map(k => t.v[k])),
lo: quant ? t.q.lo : 0, rg: quant ? t.q.range : 0,
c0: t.const ? (quant ? t.q.dec[0] : t.v[0]) : 0 }));
const rt = p.rt.map(t => { const keys = t.const ? [0] : t.keys;
const q = new Float32Array(keys.length*4);
keys.forEach((k,i) => q.set(quant ? dequantQuat(quantQuat(t.q[k])) : t.q[k], i*4));
return { const: t.const, f: Uint16Array.from(keys), q }; });
return { sc, rt };
}
function sampleTracks(S, t, cubic, quant, out) {
for (let i = 0; i < S.sc.length; i++) {
const tr = S.sc[i];
if (tr.const) { out[i] = quant ? tr.c0 : tr.v[0]; continue; }
const f = tr.f; let lo = 0, hi = f.length - 1;
while (hi - lo > 1) { const m = (lo+hi) >> 1; if (f[m] <= t) lo = m; else hi = m; }
const g = quant ? (j => tr.lo + tr.v[j]/65535*tr.rg) : (j => tr.v[j]);
const t0 = f[lo], t1 = f[hi], u = (t-t0)/(t1-t0), p0 = g(lo), p1 = g(hi);
if (!cubic) { out[i] = p0 + (p1-p0)*u; continue; }
const h = t1-t0;
const m0 = (lo===0 ? (g(1)-g(0))/(f[1]-f[0]) : (g(lo+1)-g(lo-1))/(f[lo+1]-f[lo-1]))*h;
const m1 = (hi===f.length-1 ? (g(hi)-g(hi-1))/(f[hi]-f[hi-1]) : (g(hi+1)-g(hi-1))/(f[hi+1]-f[hi-1]))*h;
const u2 = u*u, u3 = u2*u;
out[i] = (2*u3-3*u2+1)*p0 + (u3-2*u2+u)*m0 + (-2*u3+3*u2)*p1 + (u3-u2)*m1;
}
let o = S.sc.length;
for (let i = 0; i < S.rt.length; i++) {
const tr = S.rt[i], q = tr.q;
if (tr.const) { out[o]=q[0]; out[o+1]=q[1]; out[o+2]=q[2]; out[o+3]=q[3]; o+=4; continue; }
const f = tr.f; let lo = 0, hi = f.length-1;
while (hi-lo > 1) { const m = (lo+hi)>>1; if (f[m] <= t) lo = m; else hi = m; }
const u = (t-f[lo])/(f[hi]-f[lo]);
const d = q[lo*4]*q[hi*4]+q[lo*4+1]*q[hi*4+1]+q[lo*4+2]*q[hi*4+2]+q[lo*4+3]*q[hi*4+3];
const s = d < 0 ? -1 : 1;
const x=q[lo*4]+(s*q[hi*4]-q[lo*4])*u, y=q[lo*4+1]+(s*q[hi*4+1]-q[lo*4+1])*u;
const z=q[lo*4+2]+(s*q[hi*4+2]-q[lo*4+2])*u, w=q[lo*4+3]+(s*q[hi*4+3]-q[lo*4+3])*u;
const l = 1/Math.hypot(x,y,z,w);
out[o]=x*l; out[o+1]=y*l; out[o+2]=z*l; out[o+3]=w*l; o+=4;
}
}
const rawBuf = encodeRaw(clip), raw = new Float32Array(N*300);
for (let i = 0; i < N*300; i++) raw[i] = rawBuf.readFloatLE(16+i*4);
const sampleRaw = (t, out) => { const f0 = Math.floor(t), f1 = Math.min(N-1,f0+1), u = t-f0;
const a = f0*300, b = f1*300;
for (let i = 0; i < 300; i++) out[i] = raw[a+i] + (raw[b+i]-raw[a+i])*u; };
const times = new Float64Array(4096);
for (let i = 0; i < times.length; i++) times[i] = (i*0.7) % (N-1.001);
const out = new Float64Array(300);
function bench(fn, label) {
for (let w = 0; w < 30; w++) for (let i = 0; i < times.length; i++) fn(times[i], out);
const r = [];
for (let k = 0; k < 9; k++) { const t0 = process.hrtime.bigint();
for (let i = 0; i < times.length; i++) fn(times[i], out);
r.push(Number(process.hrtime.bigint()-t0)/times.length); }
r.sort((a,b)=>a-b);
console.log(`${label.padEnd(46)} ${r[4].toFixed(0).padStart(5)} ns per skeleton sample`);
}
const Slin = build(plan(clip, eM, eD, false, false, true), false);
const Scub = build(plan(clip, eM, eD, true, false, true), false);
const Sq = build(plan(clip, eM, eD, true, true, true), true);
console.log('\n### decode cost, one full 30-bone pose (300 values)');
bench(sampleRaw, 'raw float32, lerp between two frames');
bench((t,o)=>sampleTracks(Slin,t,false,false,o), 'linear keyframes, binary search + lerp');
bench((t,o)=>sampleTracks(Scub,t,true, false,o), 'cubic keyframes, binary search + Hermite');
bench((t,o)=>sampleTracks(Sq, t,true, true, o), 'quantised cubic keys, dequantise + Hermite');
// -------------------------------------------------- gzip, split by record
console.log('\n### gzip on the packed stream, by record type (walk, cubic keys)');
for (const [lab, q] of [['float32 keys', false], ['quantised keys', true]]) {
const p = plan(clip, eM, eD, true, q, true), C = [], An = [];
for (const t of p.sc) (t.const?C:An).push(encodeTracks({sc:[t],rt:[]}, q).subarray(16));
for (const t of p.rt) (t.const?C:An).push(encodeTracks({sc:[],rt:[t]}, q).subarray(16));
const c = Buffer.concat(C), a = Buffer.concat(An), b = Buffer.concat([c,a]);
console.log(`${lab}: constant ${c.length} B -> ${gz(c)} B (${(c.length/gz(c)).toFixed(2)}x), ` +
`animated ${a.length} B -> ${gz(a)} B (${(a.length/gz(a)).toFixed(2)}x), ` +
`whole ${b.length} B -> ${gz(b)} B (${(b.length/gz(b)).toFixed(2)}x)`);
}
console.log('\n### 1.8 m character: millimetres per screen pixel');
console.log([48,64,128,256,512,1080].map(px => `${px}px=${(1800/px).toFixed(2)}mm`).join(' '));
// ------------------------------- is a cached cursor faster than a search?
const scal = plan(clip, eM, eD, true, false, true).sc.map(t => ({ const: t.const,
f: Uint16Array.from(t.const ? [0] : t.keys),
v: Float32Array.from(t.const ? [t.v[0]] : t.keys.map(k => t.v[k])) }));
const cur = new Uint16Array(scal.length), o2 = new Float64Array(scal.length);
function seqBin(t) { for (let i = 0; i < scal.length; i++) { const tr = scal[i];
if (tr.const) { o2[i] = tr.v[0]; continue; }
const f = tr.f; let lo = 0, hi = f.length-1;
while (hi-lo > 1) { const m = (lo+hi)>>1; if (f[m] <= t) lo = m; else hi = m; }
o2[i] = tr.v[lo] + (tr.v[hi]-tr.v[lo]) * ((t-f[lo])/(f[hi]-f[lo])); } }
function seqCur(t) { for (let i = 0; i < scal.length; i++) { const tr = scal[i];
if (tr.const) { o2[i] = tr.v[0]; continue; }
const f = tr.f; let c = cur[i]; if (f[c] > t) c = 0;
while (c+2 < f.length && f[c+1] <= t) c++;
cur[i] = c; o2[i] = tr.v[c] + (tr.v[c+1]-tr.v[c]) * ((t-f[c])/(f[c+1]-f[c])); } }
console.log('\n### sequential playback, 180 scalar channels only');
bench(t => seqBin(t), 'binary search per track');
bench(t => seqCur(t), 'cached cursor, advance forward');
// ------------------------------------------ where do the jump's keys land?
const pj = plan(CLIPS.jump, eM, eD, false, false, true);
const hist = new Float64Array(CLIPS.jump.frames);
for (const t of [...pj.sc, ...pj.rt]) if (!t.const) for (const k of t.keys) hist[k]++;
let tot = 0; for (const h of hist) tot += h;
console.log('\n### jump: where the keyframes land');
for (const [lab,a,b] of [['0.00-0.30 idle',0,18],['0.30-0.48 crouch',18,29],
['0.48-0.54 launch',29,32],['0.54-1.10 airborne',32,66],['1.10-1.18 impact',66,71],
['1.18-1.45 recover',71,87],['1.45-2.00 settle',87,120]]) {
let s = 0; for (let f = a; f < b; f++) s += hist[f];
console.log(`${lab.padEnd(19)} ${String(b-a).padStart(3)} frames ${String(s).padStart(4)} keys ` +
`(${(s/tot*100).toFixed(1).padStart(5)}%, ${(s/(b-a)).toFixed(1).padStart(5)} keys/frame)`); }
console.log(`total animated keys ${tot} over ${CLIPS.jump.frames} frames`);
On the M3 laptop above that prints:
walk 180 frames @60fps 177/180 scalar channels constant, 10/30 rotation tracks constant
jump 120 frames @60fps 175/180 scalar channels constant, 10/30 rotation tracks constant
mocap 180 frames @60fps 177/180 scalar channels constant, 10/30 rotation tracks constant
### walk — 3.00 s, tolerance 1 mm / 0.164 deg
technique bytes B/s gzip gzB/s maxErr_mm maxDeg
1 raw float32, every channel every frame 216016 72005 31818 10606 0 0
2 + constant-channel removal (lossless) 69764 23255 18712 6237 0.00 0.000
3 keyframe reduction only, linear 25684 8561 8247 2749 4.69 0.508
4 constant removal + linear keyframes 24068 8023 8132 2711 4.69 0.508
5 constant removal + cubic keyframes 17486 5829 6736 2245 3.78 0.427
6 5 + 16-bit pos/scale + smallest-3 quat 6688 2229 3650 1217 7.24 0.857
7 constant removal + quantisation only 25034 8345 13314 4438 7.26 0.864
23 animated tracks; keys kept per animated track per second: linear 19.0/60 (31.7%), cubic 13.5/60 (22.4%)
### jump — 2.00 s, tolerance 1 mm / 0.164 deg
technique bytes B/s gzip gzB/s maxErr_mm maxDeg
1 raw float32, every channel every frame 144016 72008 11716 5858 0 0
2 + constant-channel removal (lossless) 48516 24258 11168 5584 0.00 0.000
3 keyframe reduction only, linear 17794 8897 5978 2989 3.80 0.304
4 constant removal + linear keyframes 16194 8097 5860 2930 3.80 0.304
5 constant removal + cubic keyframes 15336 7668 4955 2478 3.30 0.325
6 5 + 16-bit pos/scale + smallest-3 quat 6106 3053 2505 1253 8.40 1.016
7 constant removal + quantisation only 18086 9043 5786 2893 8.13 0.941
25 animated tracks; keys kept per animated track per second: linear 18.1/60 (30.1%), cubic 17.0/60 (28.3%)
### mocap — 3.00 s, tolerance 1 mm / 0.164 deg
technique bytes B/s gzip gzB/s maxErr_mm maxDeg
1 raw float32, every channel every frame 216016 72005 45137 15046 0 0
2 + constant-channel removal (lossless) 69764 23255 43491 14497 0.00 0.000
3 keyframe reduction only, linear 26890 8963 16408 5469 5.03 0.426
4 constant removal + linear keyframes 25274 8425 16279 5426 5.03 0.426
5 constant removal + cubic keyframes 24134 8045 15443 5148 4.58 0.486
6 5 + 16-bit pos/scale + smallest-3 quat 8894 2965 5217 1739 7.08 0.807
7 constant removal + quantisation only 25034 8345 14427 4809 5.88 0.764
23 animated tracks; keys kept per animated track per second: linear 20.0/60 (33.3%), cubic 18.8/60 (31.3%)
### walk — tolerance sweep (constant removal always on)
interp eps_mm eps_deg keys bytes B/s gzip maxErr_mm maxDeg
lin 0.1 0.016 3365 55544 18515 15077 0.32 0.044
lin 0.25 0.041 2706 44498 14833 12835 0.89 0.118
lin 0.5 0.082 2068 33878 11293 10446 1.85 0.285
lin 1 0.164 1501 24068 8023 8132 4.69 0.508
lin 2 0.327 1126 17762 5921 6447 9.14 0.941
lin 5 0.819 791 11984 3995 4600 17.74 2.385
lin 10 1.637 587 8636 2879 3457 37.46 4.434
lin 20 3.274 460 6350 2117 2589 69.45 9.093
lin 50 8.185 329 4208 1403 1621 136.83 16.318
cubic 0.1 0.016 2675 44336 14779 12979 0.34 0.046
cubic 0.25 0.041 1864 30482 10161 10073 0.85 0.101
cubic 0.5 0.082 1410 22682 7561 8298 1.74 0.241
cubic 1 0.164 1116 17486 5829 6736 3.78 0.427
cubic 2 0.327 860 13214 4405 5241 8.83 1.102
cubic 5 0.819 663 10004 3335 3952 13.96 1.950
cubic 10 1.637 547 7916 2639 3252 37.35 3.943
cubic 20 3.274 411 5468 1823 2270 70.80 9.061
cubic 50 8.185 322 4082 1361 1637 129.58 12.126
### jump — does a cubic curve help at a tight tolerance?
lin 0.1 mm keys 1574 bytes 23418 maxErr 0.11 mm
cubic 0.1 mm keys 1677 bytes 25416 maxErr 0.21 mm
lin 1 mm keys 1088 bytes 16194 maxErr 3.80 mm
cubic 1 mm keys 1035 bytes 15336 maxErr 3.30 mm
### decode cost, one full 30-bone pose (300 values)
raw float32, lerp between two frames 243 ns per skeleton sample
linear keyframes, binary search + lerp 1900 ns per skeleton sample
cubic keyframes, binary search + Hermite 2029 ns per skeleton sample
quantised cubic keys, dequantise + Hermite 2019 ns per skeleton sample
### gzip on the packed stream, by record type (walk, cubic keys)
float32 keys: constant 1616 B -> 544 B (2.97x), animated 15854 B -> 6138 B (2.58x), whole 17470 B -> 6733 B (2.59x)
quantised keys: constant 1142 B -> 356 B (3.21x), animated 5530 B -> 3276 B (1.69x), whole 6672 B -> 3637 B (1.83x)
### 1.8 m character: millimetres per screen pixel
48px=37.50mm 64px=28.13mm 128px=14.06mm 256px=7.03mm 512px=3.52mm 1080px=1.67mm
### sequential playback, 180 scalar channels only
binary search per track 374 ns per skeleton sample
cached cursor, advance forward 359 ns per skeleton sample
### jump: where the keyframes land
0.00-0.30 idle 18 frames 25 keys ( 2.8%, 1.4 keys/frame)
0.30-0.48 crouch 11 frames 160 keys ( 17.7%, 14.5 keys/frame)
0.48-0.54 launch 3 frames 25 keys ( 2.8%, 8.3 keys/frame)
0.54-1.10 airborne 34 frames 395 keys ( 43.7%, 11.6 keys/frame)
1.10-1.18 impact 5 frames 49 keys ( 5.4%, 9.8 keys/frame)
1.18-1.45 recover 16 frames 205 keys ( 22.7%, 12.8 keys/frame)
1.45-2.00 settle 33 frames 44 keys ( 4.9%, 1.3 keys/frame)
total animated keys 903 over 120 frames
The sizes and error figures are exact on every run, because the clips come from a seeded generator; only the four decode timings and the two playback timings move.
If you rig characters with bones and want the animation drawn out as sprites rather than stored as curves, that is what Game Asset Generator & Builder does: describe the motion, let it draw every frame, then clean, slice and export.