UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

195 lines (156 loc) • 7.14 kB
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); }