Skip to content

Commit e2d4f05

Browse files
committed
Use bezier-easing 3.2 closed-form solver in Animated bezier
Replace the sample table + Newton-Raphson + bisection search with the closed-form solver of bezier-easing 3.2: exact (~1e-14 instead of up to ~1e-3), no backward jump on steep curves such as (1, 0, 0, 1), faster. Inputs outside [0, 1] now saturate to 0 / 1 instead of going through the old search, which returned arbitrary values there.
1 parent 278e7f9 commit e2d4f05

2 files changed

Lines changed: 88 additions & 125 deletions

File tree

‎packages/react-native/Libraries/Animated/__tests__/bezier-itest.js‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
/**
1212
* BezierEasing - use bezier curve for transition easing function
1313
* https://github.com/gre/bezier-easing
14-
* @copyright 2014-2015 Gaetan Renaudeau. MIT License.
14+
* @copyright 2014-2026 Gaetan Renaudeau. MIT License.
1515
*/
1616

1717
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
@@ -110,6 +110,54 @@ describe('bezier', function () {
110110
});
111111
});
112112
});
113+
describe('precision', function () {
114+
it('should match points of the curve', function () {
115+
repeat(10)(function () {
116+
const a = Math.random(),
117+
b = 2 * Math.random() - 0.5,
118+
c = Math.random(),
119+
d = 2 * Math.random() - 0.5;
120+
const easing = bezier(a, b, c, d);
121+
for (let i = 1; i < 100; ++i) {
122+
const t = i / 100;
123+
const x =
124+
3 * a * t * (1 - t) * (1 - t) + 3 * c * t * t * (1 - t) + t * t * t;
125+
const y =
126+
3 * b * t * (1 - t) * (1 - t) + 3 * d * t * t * (1 - t) + t * t * t;
127+
assertClose(easing(x), y, 8);
128+
}
129+
});
130+
});
131+
it('should be precise near the extremes', function () {
132+
// x(t) = t³, y(t) = t² (3 - 2t)
133+
const easing = bezier(0, 0, 0, 1);
134+
for (const t of [1e-6, 1e-3, 0.999]) {
135+
// relative precision
136+
assertClose(easing(t * t * t) / (t * t * (3 - 2 * t)), 1, 10);
137+
}
138+
expect(easing(Number.MIN_VALUE)).toBeGreaterThan(0);
139+
});
140+
it('should be monotonic on steep curves', function () {
141+
const easing = bezier(1, 0, 0, 1);
142+
let previous = 0;
143+
for (let i = 1; i <= 10000; ++i) {
144+
const y = easing(0.49 + (0.02 * i) / 10000);
145+
expect(y).toBeGreaterThanOrEqual(previous);
146+
previous = y;
147+
}
148+
});
149+
});
150+
describe('outside of [0, 1]', function () {
151+
it('should saturate to 0 / 1', function () {
152+
const easing = bezier(0.25, 0.1, 0.25, 1);
153+
expect(easing(-0.5)).toBe(0);
154+
expect(easing(1.5)).toBe(1);
155+
expect(Number.isNaN(easing(NaN))).toBe(true);
156+
});
157+
it('should keep linear curves as the identity', function () {
158+
expect(bezier(0.3, 0.3, 0.6, 0.6)(-0.5)).toBe(-0.5);
159+
});
160+
});
113161
describe('two same instances', function () {
114162
it('should be strictly equals', function () {
115163
repeat(10)(function () {

‎packages/react-native/Libraries/Animated/bezier.js‎

Lines changed: 39 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -11,85 +11,34 @@
1111
/**
1212
* BezierEasing - use bezier curve for transition easing function
1313
* https://github.com/gre/bezier-easing
14-
* @copyright 2014-2015 Gaëtan Renaudeau. MIT License.
14+
* @copyright 2014-2026 Gaëtan Renaudeau. MIT License.
1515
*/
1616

1717
'use strict';
1818

19-
// These values are established by empiricism with tests (tradeoff: performance VS precision)
20-
const NEWTON_ITERATIONS = 4;
21-
const NEWTON_MIN_SLOPE = 0.001;
22-
const SUBDIVISION_PRECISION = 0.0000001;
23-
const SUBDIVISION_MAX_ITERATIONS = 10;
24-
25-
const kSplineTableSize = 11;
26-
const kSampleStepSize = 1.0 / (kSplineTableSize - 1.0);
27-
28-
const float32ArraySupported = typeof Float32Array === 'function';
29-
30-
function A(aA1: number, aA2: number) {
31-
return 1.0 - 3.0 * aA2 + 3.0 * aA1;
32-
}
33-
function B(aA1: number, aA2: number) {
34-
return 3.0 * aA2 - 6.0 * aA1;
35-
}
36-
function C(aA1: number) {
37-
return 3.0 * aA1;
38-
}
39-
40-
// Returns x(t) given t, x1, and x2, or y(t) given t, y1, and y2.
41-
function calcBezier(aT: number, aA1: number, aA2: number) {
42-
return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT;
43-
}
44-
45-
// Returns dx/dt given t, x1, and x2, or dy/dt given t, y1, and y2.
46-
function getSlope(aT: number, aA1: number, aA2: number) {
47-
return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1);
48-
}
49-
50-
function binarySubdivide(
51-
aX: number,
52-
_aA: number,
53-
_aB: number,
54-
mX1: number,
55-
mX2: number,
56-
) {
57-
let currentX,
58-
currentT,
59-
i = 0,
60-
aA = _aA,
61-
aB = _aB;
62-
do {
63-
currentT = aA + (aB - aA) / 2.0;
64-
currentX = calcBezier(currentT, mX1, mX2) - aX;
65-
if (currentX > 0.0) {
66-
aB = currentT;
67-
} else {
68-
aA = currentT;
69-
}
70-
} while (
71-
Math.abs(currentX) > SUBDIVISION_PRECISION &&
72-
++i < SUBDIVISION_MAX_ITERATIONS
73-
);
74-
return currentT;
75-
}
76-
77-
function newtonRaphsonIterate(
78-
aX: number,
79-
_aGuessT: number,
80-
mX1: number,
81-
mX2: number,
82-
) {
83-
let aGuessT = _aGuessT;
84-
for (let i = 0; i < NEWTON_ITERATIONS; ++i) {
85-
const currentSlope = getSlope(aGuessT, mX1, mX2);
86-
if (currentSlope === 0.0) {
87-
return aGuessT;
88-
}
89-
const currentX = calcBezier(aGuessT, mX1, mX2) - aX;
90-
aGuessT -= currentX / currentSlope;
19+
// Solves x(t) = ((2a * t + 3b) * t + 3c) * t = x for t, with x in (0, 1):
20+
// u = 1/t is the largest real root of x·u³ − 3c·u² − 3b·u − 2a = 0
21+
function solveTForX(x: number, a: number, b: number, c: number): number {
22+
const j = 1 / Math.max(c, Math.sqrt(x));
23+
const k = x * j;
24+
const l = k * j;
25+
const s = c * j;
26+
const q = b * l;
27+
const m = s * s + q;
28+
const h = -s * (s * s + 1.5 * q) - a * k * l;
29+
const D = h * h - m * m * m;
30+
let v: number;
31+
if (m === 0 || D > 1e-12 * h * h) {
32+
// one real root (Cardano)
33+
const U = -Math.cbrt(h < 0 ? h - Math.sqrt(D) : h + Math.sqrt(D));
34+
v = U + m / U || 0;
35+
} else {
36+
// three real roots, take the largest
37+
const r = Math.sqrt(m);
38+
v =
39+
2 * r * Math.cos(Math.acos(Math.max(-1, Math.min(1, -h / (m * r)))) / 3);
9140
}
92-
return aGuessT;
41+
return Math.min(1, k / (v + s));
9342
}
9443

9544
export default function bezier(
@@ -102,63 +51,29 @@ export default function bezier(
10251
throw new Error('bezier x values must be in [0, 1] range');
10352
}
10453

105-
// Precompute samples table
106-
const sampleValues = float32ArraySupported
107-
? new Float32Array(kSplineTableSize)
108-
: new Array<number>(kSplineTableSize);
109-
if (mX1 !== mY1 || mX2 !== mY2) {
110-
for (let i = 0; i < kSplineTableSize; ++i) {
111-
sampleValues[i] = calcBezier(i * kSampleStepSize, mX1, mX2);
112-
}
54+
if (mX1 === mY1 && mX2 === mY2) {
55+
return function LinearEasing(x: number): number {
56+
return x;
57+
};
11358
}
11459

115-
function getTForX(aX: number) {
116-
let intervalStart = 0.0;
117-
let currentSample = 1;
118-
const lastSample = kSplineTableSize - 1;
119-
120-
for (
121-
;
122-
currentSample !== lastSample && sampleValues[currentSample] <= aX;
123-
++currentSample
124-
) {
125-
intervalStart += kSampleStepSize;
126-
}
127-
--currentSample;
128-
129-
// Interpolate to provide an initial guess for t
130-
const dist =
131-
(aX - sampleValues[currentSample]) /
132-
(sampleValues[currentSample + 1] - sampleValues[currentSample]);
133-
const guessForT = intervalStart + dist * kSampleStepSize;
134-
135-
const initialSlope = getSlope(guessForT, mX1, mX2);
136-
if (initialSlope >= NEWTON_MIN_SLOPE) {
137-
return newtonRaphsonIterate(aX, guessForT, mX1, mX2);
138-
} else if (initialSlope === 0.0) {
139-
return guessForT;
140-
} else {
141-
return binarySubdivide(
142-
aX,
143-
intervalStart,
144-
intervalStart + kSampleStepSize,
145-
mX1,
146-
mX2,
147-
);
148-
}
149-
}
60+
// x(t) = ((2a * t + 3b) * t + 3c) * t, y(t) = ((ay * t + by) * t + cy) * t
61+
const a = (3 * mX1 - 3 * mX2 + 1) / 2;
62+
const b = mX2 - 2 * mX1;
63+
const c = mX1;
64+
const ay = 3 * mY1 - 3 * mY2 + 1;
65+
const by = 3 * (mY2 - 2 * mY1);
66+
const cy = 3 * mY1;
15067

15168
return function BezierEasing(x: number): number {
152-
if (mX1 === mY1 && mX2 === mY2) {
153-
return x; // linear
154-
}
155-
// Because JavaScript number are imprecise, we should guarantee the extremes are right.
156-
if (x === 0) {
69+
// x outside (0, 1) saturates to 0 / 1
70+
if (x <= 0) {
15771
return 0;
15872
}
159-
if (x === 1) {
73+
if (x >= 1) {
16074
return 1;
16175
}
162-
return calcBezier(getTForX(x), mY1, mY2);
76+
const t = solveTForX(x, a, b, c);
77+
return ((ay * t + by) * t + cy) * t;
16378
};
16479
}

0 commit comments

Comments
 (0)