Skip to content

[web-animations-1] Additive transform animations easily invoke undesirable matrix interpolation #2204

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
alancutter opened this issue Jan 19, 2018 · 15 comments

Comments

@alancutter
Copy link
Contributor

alancutter commented Jan 19, 2018

Using neutral value keyframes or {transform: 'none', composite: 'add'} keyframes in additive transform animations can easily cause undesired matrix interpolation to occur.

Example:
https://www.youtube.com/watch?v=6Jigrf5xKTg
http://jsbin.com/gazakifuma/1/edit?js,output

One way of avoiding this is to change The effect value of a keyframe effect steps 12 and 18 to perform the keyframe interpolation before applying their composite operation.

This would change interpolate(add(rotate(45deg), none), add(rotate(45deg), rotate(180deg))) (which uses matrix interpolation due to the rotate -> rotate rotate shape mismatch) into add(rotate(45deg), interpolate(none, rotate(45deg))).

@alancutter
Copy link
Contributor Author

+@birtles
+@flackr
+@shans

@birtles
Copy link
Contributor

birtles commented Jan 19, 2018

@alancutter welcome back!

I seem to remember there being pretty good reasons for using the order we have although I don't recall what they are just now.

Is it not possible to fix this by defining more intuitive interpolation for transform: none?

@alancutter
Copy link
Contributor Author

Thanks. (:

I don't think the interpolation behaviour can be tweaked to deal with none because the none disappears after composition. add(rotate(45deg), none) == rotate(45deg)

Perhaps the reasons for doing composition before interpolation is to deal with neutral keyframes and add+replace keyframes. I think a new operation will need to be added to handle that case, one that takes an interpolable CSS value and produces a "noop" equivalent.
Examples:
length: 5px -> 0
transform: rotate(45deg) translateX(10%) -> rotate(0deg) translateX(0)
shadow: 10px 20px 30px 40px blue inset, 50px 60px 70px 80px green -> 0 0 0 0 transparent inset, 0 0 0 0 transparent

Let's say we have the animation [{transform: 'scale(5)'}] at 50% progress ontop of an underlying value of rotate(90deg).
To find the final animated value:

  1. Compute the neutral keyframe value by getting the noop equivalent of the second keyframe: scale(1)
  2. Interpolate scale(1) -> scale(5) by 50%: scale(3)
  3. Find the noop equivalent of the underlying value: rotate(0deg)
  4. Interpolate the underlying value to its noop equivalent by 50%: rotate(45deg)
  5. Composite the interpolated underlying value and keyframe values together: rotate(45deg) scale(3)

Pseudocode:

U = underlying value
A = keyframe A
B = keyframe B
P = interpolation progress
final value = add(
    interpolate(
        isAdditive(A) ? U : noop(U),
        isAdditive(B) ? U : noop(U),
        P),
    interpolate(
        isNeutral(A) ? noop(B) : A,
        isNeutral(B) ? noop(A) : B,
        P))

@birtles
Copy link
Contributor

birtles commented Jan 22, 2018

Let's say we have the animation [{transform: 'scale(5)'}] at 50% progress ontop of an underlying value of rotate(90deg).

Sorry, does this mean that the element's un-animated computed style is transform: rotate(90deg)? In that case, shouldn't the result be transform: rotate(90deg) scale(3)?

In any case, I'm pretty sure you're right. And I think that's why initially the neutral value for composition was something that other specs were going to have to define for each type.

I'm starting to wonder if the reason we went with this particular order was related to handling iterationComposite correctly. The closest mention I could find of that was the meeting minutes from 12 Mar 2014, item 4 but I'm not sure. Perhaps it was to avoid having to define the “neutral value for composition” in other specs?

@alancutter
Copy link
Contributor Author

Sorry, does this mean that the element's un-animated computed style is transform: rotate(90deg)? In that case, shouldn't the result be transform: rotate(90deg) scale(3)?

The second keyframe is a replace keyframe so the underlying rotate(90deg) is being animated away.

@flackr
Copy link
Contributor

flackr commented Apr 10, 2018

I'm not sure I understand why we need the neutral value. I think as long as each animation is interpolated before being composited we should get the expected behavior - similar to if you put the animations on different elements, e.g. https://jsfiddle.net/flackr/1qj34byw/54/.

I suppose for neutral keyframes this may prevent you from matching the shape of the underlying style but I'm not sure how often this is useful rather than just unexpected.

@birtles
Copy link
Contributor

birtles commented Apr 10, 2018

I'm not sure I understand why we need the neutral value. I think as long as each animation is interpolated before being composited we should get the expected behavior - similar to if you put the animations on different elements, e.g. https://jsfiddle.net/flackr/1qj34byw/54/.

I thought it was the other way around? If you interpolate first you do need the neutral value, right? Since, if you're missing the from/to keyframe, you need to interpolate with something. When you composite that something with the underlying value it should produce the underlying value.

If we composite first I don't think we need the neutral value concept since the missing from/to keyframe value just becomes the underlying value and then you interpolate.

@birtles
Copy link
Contributor

birtles commented Apr 10, 2018

Oh, wait, I think the reason we composite first is because you can have different composite modes for each endpoint of the interval.

I think Alan's proposal works for this by basically interpolating twice but I'm not sure that it handles mixing accumulate and add. Perhaps that's not important.

@flackr
Copy link
Contributor

flackr commented Apr 10, 2018

Ah, of course! I was thinking this was trivial with transform: none but that doesn't extend to other properties. We could try to add an "opacity" to each property which is used at compositing time but that's almost like defining generic neutral values.

@birtles
Copy link
Contributor

birtles commented Apr 10, 2018

A further thought, I think we may need a neutral value concept in the API anyway.

For example, consider the following CSS animation:

div {
  animation: move-right 2s steps(5);
}

@keyframes move-right {
  to { margin-left: 100px; }
}

When you call div.getAnimations()[0].effect.getKeyframes() what do you get? You might expect just [ { marginLeft: '100px', offset: 1 } ] but how do you represent the timing function?

Bear in mind that CSS animations don't allow effect-level easing, only keyframe-level easing.

Furthermore, each keyframe can have different easing. i.e. you could also have:

@keyframes move-right {
  from { margin-top: 0px; animation-timing-function: ease }
  to { margin-left: 100px; margin-top: 100px; }
}

(i.e. the margin-top animation uses ease while the margin-left animation uses steps(5))

So I wonder if we want getKeyframes() to return something like:

[
  { marginLeft: null, easing: 'steps(5)', offset: 0 },
  { marginTop: '0px', easing: 'ease', offset: 0 },
  { marginLeft: '100px', marginTop: '100px', offset: 1 },
]

i.e. return a null value? Or use marginLeft: 0px with composite: add?

This is a pretty complex issue once you introduce CSS variables and from memory I think I found there might be a need for expressing both a value that represents the underlying value (previous in the stack) and a value that represents the base value (bottom of the stack).

For my own records I wrote a few comments about this on Mozilla bug 1268858.

@flackr
Copy link
Contributor

flackr commented Apr 11, 2018

After thinking about this, I'm concerned switching the composite / interpolate order isn't worth the additional complexity it adds to dealing with other composite operations, also it could require multiple matrix interpolations within a single animation. I'm in favor with just trying to use neutral values which implicitly match transform list shapes in order to avoid falling back to matrix interpolation.

I'm in favor of using a null value as a neutral value. If you had a neutral keyframe in the middle of an animation a null value could let us implicitly interpolate to/from matching transform lists on either side, e.g.

[
  { transform: 'translateX(100px) rotateZ(720deg)' },
  { transform: null },
  { transform: 'scale(2) rotateZ(360deg)' },
]

This might allow us to do something smart like interpolate to translateX(0) rotateZ(0) and then from scale(1) rotateZ(0).

@birtles
Copy link
Contributor

birtles commented Aug 13, 2018

Interestingly the test case for this issue, https://jsfiddle.net/flackr/1qj34byw/54/, works correctly for me in Firefox now. As does the original test case: http://jsbin.com/gazakifuma/1/edit?js,output .

I believe that is due to implementing the initial spec change for issue #927 in Firefox 62. We have yet to implement the updated resolution to that issue, however.

I'm curious about this issue because I'm working on implementing coalescing of forwards-filling animations in Firefox and suddenly the "neutral value" idea is interesting again. That's because in order to collapse a series of forwards filling animations that fill part-way through an interpolation interval, we'd really like to be able to combine different values with a neutral value representing the underlying value, and then simply add that result to the underlying value when we come to composite.

@flackr
Copy link
Contributor

flackr commented Apr 1, 2019

I'd like to revisit this as switching the order of interpolation and composition also greatly simplifies issue #3689 and #3210, by interpolating first we can always collapse any number of additive transforms into a single matrix and have the equivalent effective transform.

One of the concerns we had above was mixing composite modes but I think we can resolve this as well with the neutral value by having a partial replace be the equivalent of interpolating with neutral.

I.e. suppose you have:

let animationA = element.animate([
  {'transform': 'none'},
  {'transform': 'translateX(100px)'}],
  1000);
let animationB = element.animate({'transform': 'translateY(200px)'}, 1000);

At time t = 0.5, you would produce a value by

  • Animation A interpolates to transform: translateX(50px)
  • Animation B interpolates to transform: translateY(100px)
  • Composite B on top of A, replacing at 50% by interpolating A towards neutral at 50%, i.e. translateX(25px), and add B's transform giving
    transform: translateX(25px) translateY(100px)

Once an animation has finished we could then replace it with the computed transform matrix and since that doesn't affect the interpolation of additional animations it will be visually identical.

@birtles
Copy link
Contributor

birtles commented Apr 2, 2019

Yeah, I looked at doing this too but I'm not sure that would really solve the finished animation problem since we still need to preserve percentages, variables, context-sensitive lengths etc.

@flackr
Copy link
Contributor

flackr commented Aug 7, 2019

You're absolutely right that this doesn't actually solve the general problem due to multiplication of percentages and other context-sensitive values. It might still simplify a lot of common cases though, i.e. multiple subsequent translations / scales could be internally coalesced into a single one as the shape is no longer important.

I also would find this composition behavior closer to what I would expect as a developer, not having the underlying value affect the new interpolation.

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

No branches or pull requests

3 participants