Repository navigation
Description
- OS: Linux (Ubuntu, x86_64)
- Browser: Chromium 153. The numbers below are headless; we first saw it headed, in a G6 app (7–8 s frames at 2,000 nodes).
- Version: @antv/g@6.3.1, @antv/g-lite@2.7.0, @antv/g-canvas@2.2.0 (latest); seen through @antv/g6@5.1.1. The animation code is unchanged on
master.
Starting many animations at once makes the next frame cost time quadratic in their number. With G6, an animated draw() over a few thousand elements produces single frames of several seconds. Related to #1731, which is about the same list growing (memory), but this one is CPU time per frame.
Steps To Reproduce
- Create N circles on a canvas.
- In one synchronous loop, call
circle.animate(...)on each and read.finished, as G6 does for every element animation. - Time the next frame.
Link to code example:
import { Canvas, Circle } from '@antv/g';
import { Renderer } from '@antv/g-canvas';
const N = 4000;
const canvas = new Canvas({ container: 'container', width: 800, height: 600, renderer: new Renderer() });
await canvas.ready;
const circles = Array.from({ length: N }, (_, i) =>
canvas.appendChild(new Circle({ style: { cx: (i % 80) * 10, cy: Math.floor(i / 80) * 10, r: 3, fill: '#1890ff' } })),
);
await new Promise(requestAnimationFrame);
const t0 = performance.now();
const finished = circles.map(
(circle) => circle.animate([{ transform: 'translate(0, 0)' }, { transform: 'translate(10, 10)' }], { duration: 300 }).finished,
);
// G's frame callback was queued first, so this one runs right after it, in the same frame.
requestAnimationFrame(() => console.log('next frame after', performance.now() - t0, 'ms'));On my machine (headless Chromium, one run): 614 ms for N = 4,000 and 142 ms for N = 2,000. With the change proposed below: 62 ms and 40 ms.
The same through G6 5.1.1: N nodes, then graph.updateNodeData(...) on all of them and graph.draw() with animation: { duration: 300 }. The longest frame at 4,000 nodes is 1.77 s.
The current behavior
The cost of that frame grows like N². Below, the fitted exponent k is from t ≈ c·N^k over N = 250…4,000 (medians, headless Chromium 153):
| N | 250 | 500 | 1,000 | 2,000 | 4,000 |
|---|---|---|---|---|---|
updatePromises() calls |
155,244 | 560,494 | 2,114,994 | 8,169,994 | 32,075,994 |
| CPU time of the next frame (frames driven by hand, no vsync) | 2.6 ms | 8.3 ms | 35 ms | 145 ms | 746 ms |
Starting the same elements' animations again makes it worse. Finished animations stay in animationsWithPromises (#1731), so the second round at N = 4,000 has a 1,163 ms frame against 439 ms for the first. G6 prunes that list itself after each draw (release() in runtime/animation.ts), so with G6 only the N² inside one draw remains.
The expected behavior
The cost of starting N animations, and of the frame after it, is linear in N.
Cause
In packages/g/src/plugins/web-animations-api/dom/:
AnimationTimeline.play→Animation.play()(Animation.ts:297) →timeline.applyDirtiedAnimation(this)(Animation.ts:307) →tick(...)(AnimationTimeline.ts:85).tickresetsthis.ticking(AnimationTimeline.ts:189). A new animation is pending, sotickends withif (this.ticking) this.requestAnimationFrame(() => {})(AnimationTimeline.ts:221). Each started animation queues one more no-op frame callback.restart()(AnimationTimeline.ts:99-106), called by thecurrentTimesetter, does the same when seeking finished animations.- The timeline's
requestAnimationFrame(AnimationTimeline.ts:176-182) wraps every callback:updateAnimationsPromises(); f(x); updateAnimationsPromises();. updateAnimationsPromises(AnimationTimeline.ts:119-125) runsupdatePromises()on every animation inanimationsWithPromises. Theready/finishedgetters add animations to that list (Animation.ts:81,:116).
So the next processRafCallbacks (AnimationTimeline.ts:153-163) runs N+1 wrappers, and each one goes over the list twice: about 2·N² updatePromises() calls.
The repeated calls do nothing:
- All callbacks of one frame run synchronously inside a single browser rAF callback, so no user code or promise reaction can run between them.
- The wrapped functions (no-ops and
webAnimationsNextTick) don't change anythingupdatePromises()reads. updatePromises()called again with no state change is a no-op.
Proposed fix
Do the bookkeeping once before and once after the frame's callbacks, instead of around each one (+5/−5 lines in AnimationTimeline.ts):
private processRafCallbacks = (t: number) => {
...
this.animations = this.tick(t, true, this.animations)[0];
+ this.updateAnimationsPromises();
processing.forEach((entry) => {
entry[1](t);
});
+ this.updateAnimationsPromises();
this.applyPendingEffects();
};
private requestAnimationFrame(f: (ts: number) => void) {
- return this.rAF((x: number) => {
- this.updateAnimationsPromises();
- f(x);
- this.updateAnimationsPromises();
- });
+ return this.rAF(f);
}- The promise updates still happen in the same frame, after
tick, over the list in the same order, soready/finishedsettle when they did before. - I checked this with tests that record, frame by frame, when
ready/finishedsettle and whenplayStatechanges: play, pause/play/reverse/updatePlaybackRate, cancel/finish()/seeking, chained animations, destroyed targets. The traces were recorded on 6.3.1, and the fixed code reproduces them exactly. - The existing unit tests pass.
- There's no slowdown for small N: 0.59 / 1.24 / 7.39 ms against 0.65 / 1.35 / 7.91 ms of CPU for N = 1 / 10 / 100.
Two alternatives I looked at:
- Not queuing a no-op callback while one is pending: fixes starting animations, but not seeking finished ones, which queue through
restart(). - A Set or flag instead of
indexOfforanimationsWithPromises: that would break G6, which replaces that array from outside (release()).
PR with the change and the tests: #2265.
Activity
Ethan-Icet commentedon Oct 30, 2026
Fixed by #2265.