diff --git a/packages/react-native/Libraries/Animated/__tests__/bezier-itest.js b/packages/react-native/Libraries/Animated/__tests__/bezier-itest.js index 38d71ce61f5c..f105e2d6cb88 100644 --- a/packages/react-native/Libraries/Animated/__tests__/bezier-itest.js +++ b/packages/react-native/Libraries/Animated/__tests__/bezier-itest.js @@ -11,7 +11,7 @@ /** * BezierEasing - use bezier curve for transition easing function * https://github.com/gre/bezier-easing - * @copyright 2014-2015 Gaetan Renaudeau. MIT License. + * @copyright 2014-2026 Gaetan Renaudeau. MIT License. */ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; @@ -110,6 +110,54 @@ describe('bezier', function () { }); }); }); + describe('precision', function () { + it('should match points of the curve', function () { + repeat(10)(function () { + const a = Math.random(), + b = 2 * Math.random() - 0.5, + c = Math.random(), + d = 2 * Math.random() - 0.5; + const easing = bezier(a, b, c, d); + for (let i = 1; i < 100; ++i) { + const t = i / 100; + const x = + 3 * a * t * (1 - t) * (1 - t) + 3 * c * t * t * (1 - t) + t * t * t; + const y = + 3 * b * t * (1 - t) * (1 - t) + 3 * d * t * t * (1 - t) + t * t * t; + assertClose(easing(x), y, 8); + } + }); + }); + it('should be precise near the extremes', function () { + // x(t) = t³, y(t) = t² (3 - 2t) + const easing = bezier(0, 0, 0, 1); + for (const t of [1e-6, 1e-3, 0.999]) { + // relative precision + assertClose(easing(t * t * t) / (t * t * (3 - 2 * t)), 1, 10); + } + expect(easing(Number.MIN_VALUE)).toBeGreaterThan(0); + }); + it('should be monotonic on steep curves', function () { + const easing = bezier(1, 0, 0, 1); + let previous = 0; + for (let i = 1; i <= 10000; ++i) { + const y = easing(0.49 + (0.02 * i) / 10000); + expect(y).toBeGreaterThanOrEqual(previous); + previous = y; + } + }); + }); + describe('outside of [0, 1]', function () { + it('should saturate to 0 / 1', function () { + const easing = bezier(0.25, 0.1, 0.25, 1); + expect(easing(-0.5)).toBe(0); + expect(easing(1.5)).toBe(1); + expect(Number.isNaN(easing(NaN))).toBe(true); + }); + it('should keep linear curves as the identity', function () { + expect(bezier(0.3, 0.3, 0.6, 0.6)(-0.5)).toBe(-0.5); + }); + }); describe('two same instances', function () { it('should be strictly equals', function () { repeat(10)(function () { diff --git a/packages/react-native/Libraries/Animated/bezier.js b/packages/react-native/Libraries/Animated/bezier.js index 063bf12449f2..f2713b4f9137 100644 --- a/packages/react-native/Libraries/Animated/bezier.js +++ b/packages/react-native/Libraries/Animated/bezier.js @@ -11,85 +11,34 @@ /** * BezierEasing - use bezier curve for transition easing function * https://github.com/gre/bezier-easing - * @copyright 2014-2015 Gaëtan Renaudeau. MIT License. + * @copyright 2014-2026 Gaëtan Renaudeau. MIT License. */ 'use strict'; -// These values are established by empiricism with tests (tradeoff: performance VS precision) -const NEWTON_ITERATIONS = 4; -const NEWTON_MIN_SLOPE = 0.001; -const SUBDIVISION_PRECISION = 0.0000001; -const SUBDIVISION_MAX_ITERATIONS = 10; - -const kSplineTableSize = 11; -const kSampleStepSize = 1.0 / (kSplineTableSize - 1.0); - -const float32ArraySupported = typeof Float32Array === 'function'; - -function A(aA1: number, aA2: number) { - return 1.0 - 3.0 * aA2 + 3.0 * aA1; -} -function B(aA1: number, aA2: number) { - return 3.0 * aA2 - 6.0 * aA1; -} -function C(aA1: number) { - return 3.0 * aA1; -} - -// Returns x(t) given t, x1, and x2, or y(t) given t, y1, and y2. -function calcBezier(aT: number, aA1: number, aA2: number) { - return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT; -} - -// Returns dx/dt given t, x1, and x2, or dy/dt given t, y1, and y2. -function getSlope(aT: number, aA1: number, aA2: number) { - return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1); -} - -function binarySubdivide( - aX: number, - _aA: number, - _aB: number, - mX1: number, - mX2: number, -) { - let currentX, - currentT, - i = 0, - aA = _aA, - aB = _aB; - do { - currentT = aA + (aB - aA) / 2.0; - currentX = calcBezier(currentT, mX1, mX2) - aX; - if (currentX > 0.0) { - aB = currentT; - } else { - aA = currentT; - } - } while ( - Math.abs(currentX) > SUBDIVISION_PRECISION && - ++i < SUBDIVISION_MAX_ITERATIONS - ); - return currentT; -} - -function newtonRaphsonIterate( - aX: number, - _aGuessT: number, - mX1: number, - mX2: number, -) { - let aGuessT = _aGuessT; - for (let i = 0; i < NEWTON_ITERATIONS; ++i) { - const currentSlope = getSlope(aGuessT, mX1, mX2); - if (currentSlope === 0.0) { - return aGuessT; - } - const currentX = calcBezier(aGuessT, mX1, mX2) - aX; - aGuessT -= currentX / currentSlope; +// Solves x(t) = ((2a * t + 3b) * t + 3c) * t = x for t, with x in (0, 1): +// u = 1/t is the largest real root of x·u³ − 3c·u² − 3b·u − 2a = 0 +function solveTForX(x: number, a: number, b: number, c: number): number { + const j = 1 / Math.max(c, Math.sqrt(x)); + const k = x * j; + const l = k * j; + const s = c * j; + const q = b * l; + const m = s * s + q; + const h = -s * (s * s + 1.5 * q) - a * k * l; + const D = h * h - m * m * m; + let v: number; + if (m === 0 || D > 1e-12 * h * h) { + // one real root (Cardano) + const U = -Math.cbrt(h < 0 ? h - Math.sqrt(D) : h + Math.sqrt(D)); + v = U + m / U || 0; + } else { + // three real roots, take the largest + const r = Math.sqrt(m); + v = + 2 * r * Math.cos(Math.acos(Math.max(-1, Math.min(1, -h / (m * r)))) / 3); } - return aGuessT; + return Math.min(1, k / (v + s)); } export default function bezier( @@ -102,63 +51,29 @@ export default function bezier( throw new Error('bezier x values must be in [0, 1] range'); } - // Precompute samples table - const sampleValues = float32ArraySupported - ? new Float32Array(kSplineTableSize) - : new Array(kSplineTableSize); - if (mX1 !== mY1 || mX2 !== mY2) { - for (let i = 0; i < kSplineTableSize; ++i) { - sampleValues[i] = calcBezier(i * kSampleStepSize, mX1, mX2); - } + if (mX1 === mY1 && mX2 === mY2) { + return function LinearEasing(x: number): number { + return x; + }; } - function getTForX(aX: number) { - let intervalStart = 0.0; - let currentSample = 1; - const lastSample = kSplineTableSize - 1; - - for ( - ; - currentSample !== lastSample && sampleValues[currentSample] <= aX; - ++currentSample - ) { - intervalStart += kSampleStepSize; - } - --currentSample; - - // Interpolate to provide an initial guess for t - const dist = - (aX - sampleValues[currentSample]) / - (sampleValues[currentSample + 1] - sampleValues[currentSample]); - const guessForT = intervalStart + dist * kSampleStepSize; - - const initialSlope = getSlope(guessForT, mX1, mX2); - if (initialSlope >= NEWTON_MIN_SLOPE) { - return newtonRaphsonIterate(aX, guessForT, mX1, mX2); - } else if (initialSlope === 0.0) { - return guessForT; - } else { - return binarySubdivide( - aX, - intervalStart, - intervalStart + kSampleStepSize, - mX1, - mX2, - ); - } - } + // x(t) = ((2a * t + 3b) * t + 3c) * t, y(t) = ((ay * t + by) * t + cy) * t + const a = (3 * mX1 - 3 * mX2 + 1) / 2; + const b = mX2 - 2 * mX1; + const c = mX1; + const ay = 3 * mY1 - 3 * mY2 + 1; + const by = 3 * (mY2 - 2 * mY1); + const cy = 3 * mY1; return function BezierEasing(x: number): number { - if (mX1 === mY1 && mX2 === mY2) { - return x; // linear - } - // Because JavaScript number are imprecise, we should guarantee the extremes are right. - if (x === 0) { + // x outside (0, 1) saturates to 0 / 1 + if (x <= 0) { return 0; } - if (x === 1) { + if (x >= 1) { return 1; } - return calcBezier(getTForX(x), mY1, mY2); + const t = solveTForX(x, a, b, c); + return ((ay * t + by) * t + cy) * t; }; }