svgdotjs / svg.js

The lightweight library for manipulating and animating SVG
https://svgjs.dev
Other
11.07k stars 1.08k forks source link

Declarative Animations #769

Closed saivan closed 6 years ago

saivan commented 6 years ago

It would be awesome to have declarative animations in SVG.js, the current behaviour of svg.js when asking one animation to occur after another is this:

https://codepen.io/saivan/pen/OzbWNW

But for example, if we always wanted an animation to finish in say 500ms even if there is a chain of animations that are supposed to run for the next 20s, we would just discard all of the other animations in the chain and force this animation to occur and be finished within 500ms.

This is especially useful for interaction. For example, if I have an animation playing where a ball is supposed to do 20 tumbles over 10 seconds; and then I want to allow the user to click on the ball to move it to another place, I don't want to have to wait for the ball to finish its animation chain (or even its current animation), I just want it to move immediately to the new position, tweening smoothly from whatever state it is in when the user clicks on the ball.

This declarative style of animation is already what css uses, although css actually has a pretty serious bug in chrome when dealing with matrices as you can see here:

https://bugs.chromium.org/p/chromium/issues/detail?id=797472

I have put together another pen that shows how this animation style could be useful don't view it in chrome because of this bug (although it might be fun to watch for the lols):

https://codepen.io/saivan/pen/baBgpR

Possible API

The syntax change could be small, something like:

ball.animate(duration, ease, delay, declarative)

If the declarative flag is passed, all animations in the chain will be removed, and the object will tween smoothly from its current state to a new state, discarding all animations that are enqueued.

Possible Implementation

Since an animation may already be running, we would need to decay its effect on the next animation, so we could perhaps say:

// define a start animation, a mid animation and an end animation
// assume we were running an animation where t = [0, 1]
// and we interrupted it with mid @ t=0.6, so mid is the point at which the new animation starts
tInterrupt = 0.6 // The time on the old animation where we interrupted it and reset t to zero
decayRate = 20
decay = Math.exp(- decayRate * t)
oldContribution = t + tInterrupt < 1
   : decay * ease(t + tInterrupt) * (end - mid)
   : 0
value = mid + oldContribution + ease(tNew) * (end - mid)

The decay value will make the contribution of the old animation go to zero, and if our time exceeds the old interrupt point, it is simply no longer included in the calculation for the next term.

I'm not sure how much complexity this will add, but it could be really nice and it will avoid the jank that seems to happen with the css approach I believe.

Any thoughts or comments?

Fuzzyma commented 6 years ago

Did you try el.stop(false, true).animate().move(100,100)? This stops the animation where it is and clears the whole queue. It then animates the object to position 100,100. Seems like that is what you want

saivan commented 6 years ago

Well almost, but not quite - I have been using this solution in practice; but it doesn't tick all of the boxes here.

The main problem is that there will be no smooth transition from the previously running animation to the new animation; theres a very clear "jump" from the old animation to a new one. Wouldn't it be better if we smoothly blended into a new animation?

Fuzzyma commented 6 years ago

Ofc it would. But this is not as easy as it sounds. Feel free to open a pr for this. As for me I have no free resources to work at this atm. But I am open to any solutions. It would be a quite advanced feature

saivan commented 6 years ago

Do you recommend branching off of 3.0?

Fuzzyma commented 6 years ago

Unfortunately 3.0 is subject of heavy changes atm (even if there weren't any changes the last months). But the fx module won't change much in 3.0 so just make a from master

saivan commented 6 years ago

Sure! I'll see what I can do :)

saivan commented 6 years ago

After giving this some more thought, I think it might not be so good to use the existing animate methods defined in the fx module, because after reading through the fx module; this would add a lot of code complexity, we would need to keep track of a queue of "unfinished" animations, and fade out their effects over time.

Perhaps instead; we should have a separate plugin or module that allows for declarative transitions based on a finite differences controller. So for example, the user can call .declarative on any SVG object, which will then put the object into an "error correcting" mode.

For example, this code:

let follower = SVG("theThing").declarative(myController)

Will cause the object to now listen for any future changes to its desired state. The majority of the work needs to be undertaken by myController, which is a simple function, whose only role is to drive the error of the system to zero.

As a result, if the user changed the desired state, the controller will just smoothly try to adjust the error until it reaches zero.

User facing API

As a simple example imagine we had the following code:

function myController (error) {
    let delta = 0.03 * error
    return delta
}

let follower = SVG("theThing").translate(200, 300).declarative(myController)
follower.translate(100, 500)

.
. [some time later]
.

follower.translate(500, 100) // And it will just move on its on nicely!

When starting this, on the first call of myController (pretending we are operating on translate and not its matrix representation), the error would be (-100) in the x and (+200) in the y. So as a result, the controller will return 3% of 100 and -200 to add on to the old values in the first frame, so the new position after one animationFrame would be:

// Where C is the controller function
newX = 200 + C (-100) = 200 + 0.03 * (-100) = 197
newY = 300 + C (200) = 300 + 0.03 * (200) = 306

This would lead to a motion that exponentially decays towards the target point. Of course, if the target point changes, the error given to the controller in the next frame will change, so it will smoothly transition to the new value automatically without any user intervention.

Using Velocity and Acceleration

Whilst this can already be used for a lot of our animations, the controller can also pass the derivative and integral of the function in as a parameter, and it can return a new position, velocity and acceleration which can be useful to simulate a number of different physical systems, eg:

function physicalController (oldPos, oldSpeed, oldAcceleration, posIntegral) {

    let newPos = ...
    let newSpeed = ...
    let newAcceleration = ...

    return [newPos, newSpeed, newAcceleration]
}

This will then be forward propagated automatically for the user. Of course, using a physical controller means that we can instead, imagine pressing only the accelerator of a car, for example a spring could just be;

function spring(p, v, a) {

    let K = 0.03, D = 0.02
    return [p, v, - K * p - D * v]
}

After this is returned by the user, we will forward propagate by one step. This will lead to some bouncing around our new target if D is too small, and it will lead to a really slow movement if D is large as it will result in a very low terminal velocity.

Pros and Cons

Pros:

Cons:

Despite the cons though, I think this would be a really useful module to add to SVG.js. Sorry about the huge post. Thoughts?

Fuzzyma commented 6 years ago

That sounds like a bigger project :D. I like the idea. Its similar for what I had in mind when I reworked the fx module. My idea was to allow multiple animations on one element with different timeframe which would also require some sort of "merging animations". However your solution looks more general without that much pitfalls.

The idea to use a plugin is most appreciated. That way the core stays small.

One problem I see is the performance of your animation. Note that all computation must occur in the requestAnimationFrame-callback so you habe maybe 16ms or so to do all the work. But beside this your proposal looks promising!

saivan commented 6 years ago

Should I just go ahead and make my own repository and link you to it? I think I should be able to get this done in a (hopefully) reasonable amount of time, but I'd really appreciate input as I'm chugging along if possible.

Fuzzyma commented 6 years ago

Yes Ofc. You can open a repository for your plugin and than add it to the plugin list

saivan commented 6 years ago

Are you guys planning to move to es6 soon?

Fuzzyma commented 6 years ago

First thing will be to finish 3.0. After that the move to Es6 is planned. Unfortunately we are a bit behind with that one. But we already discussed it

saivan commented 6 years ago

I'm not completely versed in the api, but as a first commit; I'm going to try to get this working with transforms only.

Fuzzyma commented 6 years ago

transformations are tought. Animating these is more complex that any other animations. The problem is, that you need to know in which form of animation you are currently in. Ofc you can always take the current matrix and morph it to a new destination but that doesnt work with rotation (morphing rotation matrix does not result in a rotation but some other weired animation with the same outcome). When you want to go from one transformation type to another one that gets really hard.

Next thing you need to take into account is, that you shall not change the speed of teh "visual appearance" of the animation. It cannot just start moving faster when the animation is changed mitterm. But I think thats taken care of when introducing the controller as you mentioned

saivan commented 6 years ago

Well yes, I was first planning to just animate the matrices directly. But as we were discussing in the issue I closed not so long ago there are an infinite number of possible transforms you can take. I think the largest difficulty for me here is just covering all of the functionality you've exposed in the api :) the controller code I've written many times before.

I was just planning to animate the parameters that are given to each attached function by using the arguments keyword and just keeping an object for each parameter that has:

{
    position: ...
    error: ...
    velocity: ...
    acceleration: ...
    integral: ...
}

I figure that should be relatively performant because I'd just be calling the native SVG.js functions.

Fuzzyma commented 6 years ago

But as we were discussing in the issue I closed not so long ago there are an infinite number of possible transforms you can take

Thats why the fx module saves every transformation not as matrix but as a transformation. At every step the new values are applied to get the matrix for exactly that step and after that all matrices are multiplied. Thats a very costly way to do it but I didnt see another way.

I was just planning to animate the parameters that are given to each attached function by using the arguments keyword

Some methods have arguments which are not animatable but fix. For example take the rotation center which should stay the same the whole animation. But its a good point to start

saivan commented 6 years ago

What would be wrong with allowing the rotation centre to animate? If the target stays the same, then there is simply going to be no movement :)

I mean on each new frame, we would just calculate a new matrix with a new call to rotate anyway, if the rotation centre is never changed, then that is fine.

saivan commented 6 years ago

So if the user calls something like:

item.rotate(30, 20, 20)

We can just get the current rotation when they called that, say it was 15, then we just make sequential calls to rotate as required by our controller, so:

item.rotate(15, 20, 20)
...
item.rotate(18, 20, 20)
...
...
item.rotate(30, 20, 20)

When the user first makes the object declarative, we will store its initial state and base everything off of that.

saivan commented 6 years ago

Regarding Transformations

Actually, upon further thought; I see what you mean and I agree with it! Some of the parameters should be fixed, you're right about that!

Although I am thinking about this a little carefully; and I'm wondering whether it makes sense to allow multiple chained relative transformations for two reasons:

  1. It goes against the idea of having declarative transforms - you're supposed to be able to declare what final state you want your object to be in and the controller will worry about getting you there, you're not supposed to declare multiple transformations, because we won't be animating between them anyway.
  2. Because its easy to get ourselves into a situation where allowing any number of user defined transforms can land us into trouble.

Eg:

element.rotate(50, 10, 10)

...wait some time

element.rotate(130, 10, 50, relative)

Now if the rotation around the 10, 10 point puts us on an arc that we can't reach from the rotation around 10, 50 (which is very likely), then we would be stuck.

The only way to solve this is to force the user to always use a predefined transformation order (where it is permissible to let them set the centres of each transform) eg:

If we do this, we can just animate the parameters of these transformations directly - this would be an "affine mode". Alternatively, the user can provide a matrix, and we will just morph the matrix directly. Is there any good reason to allow for multiple relative transformations?

Another Question

In your fx module, you have:

SVG.extend(SVG.FX, {
   style: function(s, v) {...
   x: function(x, relative) {...
   .
   .
   .
}

Is there any reason you used this over just adding style, x, etc... as arguments to the extend section of the SVG.invent call?

Fuzzyma commented 6 years ago

We allow the user to do what he wants. If you think about it you dont ever know how to use an affine transformation to get what you want. So you often just do it in more steps. Move your object there, rotate it now, then scale it and now move it back and finally rotate it there. Thats how user think and thats the reason you are able to chain transformations as you want. However since you are programming a plugin you can set new boundaries if you are not comfortable with the old.

To your other question: There is no difference. It was for division of concern and redability. So technically it would be ok either way

saivan commented 6 years ago

I think that makes sense, but then lets pretend you did do all of those steps, say the user had 10 transformation steps that are all representable by their own matrix, once you've completed those 10 steps, we get a matrix that has six parameters: a, b, c, d, e, f.

Now as you said before; if we just put controllers on a, b, c, d, e, f we would get a pretty bad animation in cases where we didn't want a direct morph; especially if there was no skew applied.

So what we can do is to wait till after the user has applied all of their transformations, then take the resulting matrix and decompose it into six unique parameters that we can animate like cx, cy, theta, skewX, scaleX, scaleY (I intentionally left out skewY because that would be over-defined); from there we should be able to decompose any invertible a, b, c, d, e, f into these parameters and put the controllers on those parameters.

That way, we could allow for relative transformations by just using the last target matrix.

Fuzzyma commented 6 years ago

Go for it. I am really curious about the maths you do to get to your trasnforms. Thats something I never read about or see anywhere. But I might just have missed it (which would be great). That would also simplify the fx module quite a bit

saivan commented 6 years ago

So I've made this pen to explain that part of it:

https://codepen.io/saivan/pen/opZOEx?editors=1010

Although I must admit that some part of this is not always working, I've attached my working here:

Affine Decomposition.pdf

Indeed it seems to work "alot of the time", but sometimes we get a flip along what seems to be the direction of the eigenvector, and I can't think of a very good way to fix this yet.

If you have any suggestions I'd really appreciate them :) I guess 90% of the work is moving between (a, b, c, d, e, f) -> (tx, ty, theta, sx, sy, shear) and back. There has to be something I'm not accounting for here, and there should be a way to fix this, unless of course it is possible for two different (tx, ty, theta, sx, sy, shear) combinations to arrive at the same a, b, c, d, e, f; which is possible due to the fact that the mapping between them is nonlinear.

But I think that with a little bit of modification we should be able to find a "sensible" transformation.

If you have any suggestions I'd be most open to them :) I've been scratching my head about this one. Try modifying the transform in the html to transform="matrix(1, -1, -2, 1, 150, 70)" /> to see what the problem is. Then try transform="matrix(1, -1, 2, 1, 150, 70)" /> to see a working example.

saivan commented 6 years ago

It just came to my attention that sx and sy are always positive... which is obvious -_- But of course they should not be. So I need a way to fix that. think think think :P

saivan commented 6 years ago

Okay I believe I fixed it šŸ˜„ I realised that irrespective of the amount of shear, if we rotate the i and j vector in 2d space by some angle theta, so that they are now

Then we can determine whether our matrix encountered a flip in the x direction and a flip in the y direction directly by looking at the final matrix M and its components,

if M= (a, b, c, d, e, f)

Then dot((a, b) , A) can be used to tell us if there was a flip in the X direction and also dot((c, d), B) can be used to tell us if there was a flip in the Y direction

So with this information, we should be able to uniquely reconstruct the matrix M from the affine parameters. Of course, this will make animation a breeze if everything is as continuous as I hope it is...

Heres an updated (hopefully working) pen:

https://codepen.io/saivan/pen/opZOEx?editors=1010

Maybe I forgot to fork the old one, but it should be sweet now I hope.

Fuzzyma commented 6 years ago

And what is, when the user does indeed WANT a flip and no rotation? Flip at x and y axis and rotation bei 180 degrees has the same outcome. Can you detect that, too?

saivan commented 6 years ago

Well, if you flipped x or y, that would work fine; but if you flipped both x and y as you said, thats equivalent to a rotation, so it would just rotate to the new position. But now that you mention that, this is a simple example that clearly shows that these transforms aren't unique; so I'm very interested to see how they animate.

Of course we could code for this in the transformation, but I will try this first and check how it performs :) But if you did want to allow flips to both x and y, you should probably not be using affine transitions; so I've added an affine method to the class that allows the controller to attach to either the a, b, c, d, e, f parameters or on the tx, ty, theta, sx, sy, shear parameters.

Fuzzyma commented 6 years ago

As I already mentioned: what is possible with this plugin is up to you. It's not worth to allow everything when the api gets complicated as hell. Mostly keeping it clear and simple while covering most use cases is way better (and it helps to get a first working prototype faster which is nice ^^)

saivan commented 6 years ago

Okay, we have an MVP I believe - just in time for the new year! haha https://github.com/saivan/svg.declarative.js

You can clone this and just run one of the demos. Just do an npm install and then run the mouse chaser demo. I tried to loosely follow the code style in svg.js, but its a little different to the way I usually program these things šŸ˜„

Once cloned, just run:

npm run demo mouseChaser

It works pretty fast on my machine, and the initial profiles are looking really promising :) I'm just excited to try it with the transforms, but its time to celebrate the new year for a while :P

I'll hopefully jump back on this as soon as I can.

saivan commented 6 years ago

You can also see the api by looking in the examples folder, it has a basic usage example. I tried to make it exactly mirror your animate methods.

Fuzzyma commented 6 years ago

added 652 packages in 113.531s

woooow

I cant load the mouse chase index file. bundle.js is missing

saivan commented 6 years ago

WOW indeed 0_0 Thats going to be babel and webpack! I decided to write it in es6 to avoid having to do checks for objects everywhere (I mainly used it for destructuring). I also used es6 style imports and exports.

Bundle.js is the ouptut of webpack dev server, did you run:

npm run demo mouseChaser

Webpack is a little weird because it doesn't actually output any files when running its development server. But it is very handy when developing haha.

Fuzzyma commented 6 years ago

$ npm run demo mouseChaser

svg.declarative.js@1.0.0 demo D:\Repositories\svg.declarative.js run () { ./node_modules/.bin/webpack-dev-server --env.demoName=$1; }; run "mouseChaser"

The run command was not found

saivan commented 6 years ago

Oh you're doing this on windows right?

saivan commented 6 years ago

It means the bash run command I believe :P I really should have thought that one through! I didn't test this on a windows machine. But you should be able to run

webpack-dev-server --env.demoName=mouseChaser

I just used npm run to alias that. But I should think of a way to do it that would include windows as well.

Fuzzyma commented 6 years ago

And how to I convince webpack to now use port 8080? Thats already taken by other stuff in my case^^

saivan commented 6 years ago

You should be able to use --port

Sorry about the annoying configuration here.

webpack-dev-server --env.demoName=mouseChaser --port <your choice>
Fuzzyma commented 6 years ago

Ok webpack works now. But...

TypeError: source is null

Sorry man :D

saivan commented 6 years ago

oh man! Okay maybe I should just finish it off and build it so you can use it in a pen or something Its one of those "works on my machine" problems :P

saivan commented 6 years ago

Would you be opposed to using docker for the demos, or should I leave it as is and make a github.io page for the demos.

Fuzzyma commented 6 years ago

just commit a working bundle :D

saivan commented 6 years ago

Yea good plan! :P Lol! I'll do that haha!

saivan commented 6 years ago

So I'm just trying to implement the transforms, but I'm a little bit confused about the order of transforms and the relative flag. If I have the sequence:

item.translate(m)
   .rotate(a)
   .skew(K)
   .translate(n)
   .rotate(b)

Since the default is absolute mode, will this just be the same as applying a skew K, a rotation b and a translation n? Also, is the order always just Translate - Rotate - Scale - Flip - Skew or is it something else? Does calling the methods in another order change the transformation order in absolute mode? I found the documentation a little unclear so I might also clarify it when I'm done.

Fuzzyma commented 6 years ago

Absolut transformations are a broken concept. They are never precise (because of math) and were only introduced to have a move() method for groups.

When you apply an absolute transformation to an element which already has any transformation in it it works the following:

In theory this should set the transformation value (rotation in our example) to the value specified as absolute transformation.

But since we have infinite solutions for the extract part, this never works 100 percent correct.

Well - when the element was only rotated or only translated or only scaled THEN this mostly yields to the correct behavior. But when you have mixed transformations on an element this is ridiculous

This also answer the other questions. You can chain transformations in any order and as you like. But when you want to get meaningfull results stick to relative transformations (which is default in the 3.0 branch and the normal behavior you get when multiplying 2 matrices aka applying the transformation one at a time)

saivan commented 6 years ago

Okay so I've bundled it up and prepared two demos, the first is the mouse chaser:

https://codepen.io/saivan/pen/zpdwpY

The second is the vector field (try to find the minima):

https://codepen.io/saivan/pen/vpJmQJ

There are still things to do, but I have most of the functionality I intend to implement for now. The notable changes to make include:

saivan commented 6 years ago

I should probably comment on how I've set up the transformations here too. My transformations are absolute by default, but I always make the assumption that you are always applying absolute transformations in the order:

So if you set rotate twice, that will just overwrite the rotate that was last set. If however, you opt to have a relative transformation, it will instead "bake" the current transforms (apply them) and then it will apply the relative transformations from there.

I do agree that defaulting to relative transformations is perhaps more intuitive, but I also made the assumption that all of the absolute transforms were happening around a centre, which made it very easy to move the object around for the end user (they will at least know where one point should be most of the time I think).

saivan commented 6 years ago

So about documenting this should all of the docs be on the github page? Or is it possible to document the plugin in the svg.js docs?

saivan commented 6 years ago

I've added some docs and some basic demos to the repo itself. Have you had a chance to check them out, and are there any comments or concerns?

Fuzzyma commented 6 years ago

Sorry to beeing late on this. I tried your codepens above and they dont work :(.

For the docs: Keep your stuff in the readme. Imo its earier for everyone to have anything at this place. You can create a PR for the svg docs to get added to the plugin section

saivan commented 6 years ago

They don't work? I've tried them on chrome and firefox and they are working for me, where were you trying them?