Skip to content

[animation] start values read from presentation() are for Core Animation's time, not the clock's time the animations begin at #96

Description

@honghaoz

Observation

Split from item 3 of #95. When retarget(keyPath:to:) can't fold the in-flight animations, it replaces them with an animation that starts from presentation(), and the replacement begins at AnimationClock's time for the turn. The two times differ.

In a probe where 30 ms passed between the turn's first clock read and a non-animated DropShadowLayer.update, the shadowOpacity replacement began at the clock's time but started 30.3 ms further along the interrupted animation: 0.3698 instead of 0.3092, for a fade from 0 to 1 over 0.5 s. In a normal turn the gap was 0.2 ms. The shadow radius, which the retarget folds at the clock's time, matched to six decimals either way.

AnimationClock documents that "the in-flight animations evaluated in a turn are evaluated at the time the new ones begin". The fold follows that. A start value read from presentation() doesn't.

Analysis

Core Animation evaluates presentation layers at one time per transaction: the time of the transaction's first presentation read, of any layer. Later reads return that time until the transaction commits, and model changes don't refresh them. Probed on macOS 26: two reads 30 ms apart returned the same object, another layer read 30 ms later showed the first read's time, and a model change or adding and removing an animation didn't refresh it. Only a commit, from CATransaction.flush() or a run loop turn, did. iOS wasn't probed.

So a start value read from presentation() is for whenever the transaction first read a presentation layer, while the animation it starts begins at whenever the turn first read the clock. In a long turn, such as a render pass over many items, the two can be milliseconds apart in either order, and the new animation starts that far along or behind the motion it interrupts.

The framework starts animations from presentation() in 12 places:

  • retarget(keyPath:to:), for the animations it can't fold: of opacity and shadowOpacity, which the render server clamps, non-additive ones, and ones of values other than numbers, sizes, and points, such as colors.
  • animate(key:keyPath:timing:from:to:model:updateAnimation:), for the unresolved from value of a delayed non-additive animation.
  • DropShadowLayer and InnerShadowLayer: shadowColor and shadowOpacity.
  • ModifierNode: backgroundColor, borderColor, shadowColor, shadowOpacity, and shadowPath.
  • ColorNode: backgroundColor.

Performance

Measured in a release build on macOS (M4 Max), with 200 hosted layers, each with a non-additive shadowOpacity animation and a frame animation in flight, median of 40 rounds:

  • A layer's first presentation read in a transaction costs about 2.7 µs, 3.0 µs through KVC, and keeps 14 allocations, 1,076 bytes, alive until the commit. Later reads of the same layer in the transaction return the same object.
  • Computing the same opacity from the layer's animations at the clock's time costs about 1.1 µs including fetching the animations, and 0.33 µs with them already at hand, as retarget has them. It matches the presentation value to within 7e-6.
  • The saving only happens where a layer's presentation reads in the turn go away entirely. A layer whose color animates in the same update still builds its presentation copy for the color, and computing its opacity would then add to that.

These paths run when an update starts or retargets animations, not every frame, so the saving counts when one turn updates many layers, such as a theme change over a long list.

Options

  1. Compute numbers at the clock's time. Evaluate opacities from the in-flight animations at the clock's time, as interruptedOpacityState() already does for opacity, and keep presentation() for colors and paths. This fixes numbers and saves the presentation copy where only numbers change, but a color and an opacity retargeted together would then start at different times.
  2. Also compute colors and paths. Interpolate them the way Core Animation does. This fixes all 12 places and removes the presentation reads from these paths, but it needs interpolation that matches Core Animation's, including the color space colors interpolate in, or the start would differ from what's on screen.
  3. Align the two clocks. Have the clock's first read in a turn also fix Core Animation's presentation time, since one presentation read fixes it for the whole transaction, so every presentation() read in the turn is for the clock's time. This fixes all 12 places with a small change, without the performance gain. Unverified: whether a read of any layer fixes it, as an unhosted layer's presentation() is nil. A CATransaction.flush() mid-turn commits and lets the next read fix a new time while the clock keeps its own. A presentation read before the clock's first read in a turn fixes the time before the clock can.

To do

  1. Add a test that exposes the offset: a hosted layer with an animation in flight, the clock read, a stall, then a replacement that should start from the value at the clock's time.
  2. Choose an option, or combine 1 and 3.
  3. Measure a render pass that updates the colors and opacities of many layers before and after, since the costs above are per-layer micro-benchmarks.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions