@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
195 lines (156 loc) • 7.14 kB
JavaScript
import { assert } from "../../../core/assert.js";
import { spline3_hermite } from "../../../core/math/spline/spline3_hermite.js";
import { number_compare_ascending } from "../../../core/primitives/numbers/number_compare_ascending.js";
import { AnimationCurve } from "./AnimationCurve.js";
import { Keyframe } from "./Keyframe.js";
/**
* Calculates a Catmull-Rom style tangent (slope) for a point in the dataset.
* @param {number[]} points
* @param {number} points_offset
* @param {number} points_count
* @param {number} index
*/
function calculateTangent(
points,
points_offset,
points_count,
index
) {
const current = points_offset + index * 2;
// Handle boundaries: Linear slope to neighbor
if (index === 0) {
const next = points_offset + 2;
const dt = points[next] - points[current];
if (dt === 0) {
return 0;
} else {
return (points[next + 1] - points[current + 1]) / dt;
}
}
if (index === points_count - 1) {
const prev = points_offset + (points_count - 2) * 2;
const dt = points[current] - points[prev];
if (dt === 0) {
return 0;
} else {
return (points[current + 1] - points[prev + 1]) / dt;
}
}
// Handle internal points: Average slope between prev and next (Finite Difference)
const next = points_offset + (index + 1) * 2;
const prev = points_offset + (index - 1) * 2;
// A simpler approach is the slope between prev and next directly:
// return (next.value - prev.value) / (next.time - prev.time);
// However, weighted average often looks better for non-uniform time steps:
const dt0 = points[current] - points[prev];
const slope0 = dt0 === 0 ? 0 : (points[current + 1] - points[prev + 1]) / dt0;
const dt1 = points[next] - points[current];
const slope1 = dt1 === 0 ? 0 : (points[next + 1] - points[current + 1]) / dt1;
// Average the slopes
return (slope0 + slope1) * 0.5;
}
/**
* Fits a smooth {@link AnimationCurve} to a set of discrete 2D points using adaptive Cubic Hermite Spline fitting.
*
* This utility performs data reduction (lossy compression) on dense time-series data.
* It recursively subdivides the dataset, inserting {@link Keyframe}s only where the interpolated curve deviates from the original points by more than `maxError`.
*
* Tangents are automatically estimated based on the slope of the input data (Catmull-Rom style), ensuring smooth transitions between keyframes.
*
* @param {number[]} points flat array of coordinates [x0, y0, x1, y1, ... xn, yn]
* @param {number} [input_offset] flat offset into the input array where to start reading data
* @param {number} [input_count] number of points to fit, counted in points i.e., pairs of (x,y) values
* @param {number} [maxError] Maximum allowed deviation. Higher values produce fewer keys (more compression), lower values preserve more detail.
* @returns {AnimationCurve}
*/
export function animation_curve_fit(
points,
input_offset = 0,
input_count = ((points.length - input_offset) / 2),
maxError = 0.01
) {
assert.defined(points, 'points');
assert.isNonNegativeInteger(input_offset, 'input_offset');
assert.isNonNegativeInteger(input_count, 'input_count');
assert.notNaN(maxError, 'maxError');
assert.greaterThanOrEqual(maxError, 0, 'maxError must be non-negative');
if (input_count === 0) {
return new AnimationCurve();
}
if (input_count === 1) {
return AnimationCurve.from([Keyframe.from(points[input_offset], points[input_offset + 1])]);
}
// 1. Identify which indices from the source array we need to keep as Keyframes
const keyIndices = new Set();
// Always keep first and last
keyIndices.add(0);
keyIndices.add(input_count - 1);
/**
* Recursive function to find split points
* @param {number} firstIndex
* @param {number} lastIndex
*/
function fitSegment(firstIndex, lastIndex) {
if (lastIndex - firstIndex <= 1) {
// done
return;
}
// A. Estimate tangents for the start and end of this segment
// We calculate tangents based on the *original* dense data to ensure accurate slope
const m0 = calculateTangent(points, input_offset, input_count, firstIndex);
const m1 = calculateTangent(points, input_offset, input_count, lastIndex);
const address_0 = input_offset + firstIndex * 2;
const address_1 = input_offset + lastIndex * 2;
const tStart = points[address_0];
const tEnd = points[address_1];
const vStart = points[address_0 + 1];
const vEnd = points[address_1 + 1];
const duration = tEnd - tStart;
if (duration < 1e-12) {
// duration is very low, we're in division-by-zero territory
return;
}
// B. Find the point in this range with the greatest error vs the hypothetical curve
let maxSegmentError = 0;
let splitIndex = -1;
for (let i = firstIndex + 1; i < lastIndex; i++) {
const address = input_offset + i * 2;
// Normalize time t to [0, 1] for the Hermite formula
const t = (points[address] - tStart) / duration;
// Evaluate Cubic Hermite Spline
const evaluatedValue = spline3_hermite(t, vStart, vEnd, m0 * duration, m1 * duration);
const error = Math.abs(points[address + 1] - evaluatedValue);
if (error > maxSegmentError) {
maxSegmentError = error;
splitIndex = i;
}
}
// C. If error is too high, mark the split point as a Keyframe and recurse
if (maxSegmentError > maxError) {
keyIndices.add(splitIndex);
fitSegment(firstIndex, splitIndex);
fitSegment(splitIndex, lastIndex);
}
}
// Start the recursion
fitSegment(0, input_count - 1);
// 2. Build the final curve from the selected indices
const sortedIndices = Array.from(keyIndices).sort(number_compare_ascending);
/**
*
* @type {Keyframe[]}
*/
const resultKeys = [];
const fitted_key_count = sortedIndices.length;
for (let i = 0; i < fitted_key_count; i++) {
const idx = sortedIndices[i];
const address = input_offset + idx * 2;
// Calculate the smooth tangent for this key
// Note: For sharp transitions, you might want to break continuity,
// but for approximation, continuous slope is usually desired.
const tangent = calculateTangent(points, input_offset, input_count, idx);
// Create a keyframe. Assuming inTangent == outTangent for smooth fitting.
resultKeys.push(Keyframe.from(points[address], points[address + 1], tangent, tangent));
}
return AnimationCurve.from(resultKeys);
}