dphfox / Fusion

A modern reactive UI library, built specifically for Roblox and Luau.
https://elttob.uk/Fusion/
MIT License
605 stars 102 forks source link

A unified theory for animation objects #87

Open dphfox opened 2 years ago

dphfox commented 2 years ago

Currently, Spring and Tween are two completely separate objects in Fusion, and don't share any code between them. However, both are conceptually special cases of a more general 'follower object' - an object which tracks the value of another object over time. In other words, follower objects have a 'position' which they always try to move towards the 'goal'.

We're starting to get feature requests that pertain to follower objects - for example, knowing when it finishes moving, adding Bezier curve support, or adding more kinds of physical animation - which could result in potentially duplicate code. Furthermore, there's some difficulty with changing the kind of a follower object on the fly, for example changing a Spring to a Tween. On top of this, we're also now starting to see where our original assumptions are breaking down - we currently assume all animatable values live in a linear space, but this may not be strictly true for things like rotations or CFrames, which have notions of wrapping.

It's worth noting that Flipper already breaks out follower objects into two halves - one for state and one for animation (referred to in Flipper lingo as motors and goals). This is an interesting idea, but Flipper's implementation is rooted in imperative animation style, so I'm not interested in emulating this exactly.

The following are some assorted notes and thoughts on this subject:

We can define the component-wise behaviours of a spring and a tween as pure functions of state. By talking in terms of displacement from a goal, we can generalise these movement behaviours to work in wrapped spaces as well as linear spaces:

What's immediately clear here is that, outside of the displacement inputs and outputs necessarily common to all followers, these two functions operate on entirely different inputs and outputs. This is likely because tweens are more akin to playing back an animation than they are to simulating the movement of a value. This may be a fundamental differentiation between different classes of transitional animation - timeline animations (tween) versus context-free animations (spring). It may be worth thinking about whether transitional timeline animations could relate to user-defined keyframe timelines (#11).

Thinking about the requirements of these two general classes of movement behaviour:

We should also consider the spaces which components operate in:

The easy way out with the problem of linear vs wrapped spaces is to operate entirely in terms of displacements, and force the end user to take up the responsibility of calculating displacements manually. I don't think this is the right approach though - and probably would result in a terrible DX anyway given Fusion's API design.

From these thoughts, I think it's likely a revised follower object system might have these components in some form:

If possible, I think the base Follower object should never store animation configuration. Movers should be constructed from animation parameters.

These are all really vague thoughts right now, worth exploring more at a later date perhaps.

dphfox commented 2 years ago

By the way, I'd like to mention an interesting idea that I originally heard about in Material Design's original spec which I thought interesting - displacement-based timeline duration. In simpler terms, varying the length of a tween based on the distance the animation needs to cover. If Fusion were to ever implement something like this, we'd need to consider how this plays with non-linear spaces. I suspect it should just be as simple as averaging all the initial displacements for all components, then using that to scale the timeline duration.

dphfox commented 2 years ago

Worth noting that, with the most recent spring changes (#51), the way that Spring applies its coefficients has also changed. Updating the post to reflect this.

dphfox commented 2 years ago

While thinking about the docs for tweens and springs, I drew this graph of how a spring moves over time:

image

This should be a generalisable representation of a follower move - for some configuration of a movement style, there exists a function converting the time since the move started to the desired position of the follower, with some cutoff time where the follower may go to sleep as movement has ended.

Since we have a fully analytical/stateless solution for damped spring motion, we can evaluate the position and velocity of a spring at any time into the future based on the initial position and velocity.

This furthermore means we can represent all movements as pure functions of time elapsed, using constructor functions to provide the initial conditions required. Note that it's important for the spring movement function to also return velocity, since this needs to be preserved when setting up further moves:

local function newMove_spring(
    startDisplacement: number,
    startVelocity: number,
    damping: number,
    speed: number
)
    return function(timeElapsed: number): (number, number)
        local posPos, posVel, velPos, velVel = springCoefficients(timeElapsed, damping, speed)
        local currentDisplacement = startDisplacement * posPos + startVelocity * posVel
        local currentVelocity = startVelocity * velPos + startVelocity * velVel
        return currentDisplacement, currentVelocity
    end
end

local function newMove_tween(
    startDisplacement: number,
    tweenInfo: TweenInfo
)
    return function(timeElapsed: number): (number)
        local ratio = getTweenRatio(tweenInfo, timeElapsed)
        local currentDisplacement = startDisplacement * (1 - ratio)
        return currentDisplacement
    end
end

This already has a number of benefits - besides the obvious potential for manipulating the timeline itself, and basing the passage of time on things other than the system clock (similar to #11), there's also great potential here for unifying both APIs. Notice that both constructors take in, as their first parameters, the same values they output from their position functions. We can isolate this behaviour with another layer of functions:

local function springMover(damping: number, speed: number)
    return function(startDisplacement: number, startVelocity: number)
        return function(timeElapsed: number): (number, number)
            local posPos, posVel, velPos, velVel = springCoefficients(timeElapsed, damping, speed)
            local currentDisplacement = startDisplacement * posPos + startVelocity * posVel
            local currentVelocity = startVelocity * velPos + startVelocity * velVel
            return currentDisplacement, currentVelocity
        end
    end
end

local function tweenMover(tweenInfo: TweenInfo)
    return function(startDisplacement: number)
        return function(timeElapsed: number): (number)
            local ratio = getTweenRatio(tweenInfo, timeElapsed)
            local currentDisplacement = startDisplacement * (1 - ratio)
            return currentDisplacement
        end
    end
end

Here's how to interpret the above:

The only thing missing from this picture is awake/asleep behaviour, though this can simply be prepended to the return list of the position function:

local function springMover(damping: number, speed: number)
    return function(startDisplacement: number, startVelocity: number)
        return function(timeElapsed: number): (number, number)
            local posPos, posVel, velPos, velVel = springCoefficients(timeElapsed, damping, speed)
            local currentDisplacement = startDisplacement * posPos + startVelocity * posVel
            local currentVelocity = startVelocity * velPos + startVelocity * velVel
            local shouldSleep = math.abs(currentDisplacement) < 0.0001 and math.abs(currentVelocity) < 0.0001
            return shouldSleep, currentDisplacement, currentVelocity
        end
    end
end

local function tweenMover(tweenInfo: TweenInfo)
    -- note to self: we should make this a function, similar to getTweenRatio()
    local tweenDuration = tweenInfo.DelayTime + tweenInfo.Time
    if tweenInfo.Reverses then
        tweenDuration += tweenInfo.Time
    end
    tweenDuration *= tweenInfo.RepeatCount + 1

    return function(startDisplacement: number)
        return function(timeElapsed: number): (number)
            local ratio = getTweenRatio(tweenInfo, timeElapsed)
            local currentDisplacement = startDisplacement * (1 - ratio)
            local shouldSleep = timeElapsed > tweenDuration
            return shouldSleep, currentDisplacement
        end
    end
end

This gives us a fantastic framework for dealing with arbitrary movement types in terms of 'moves'. Now that we have this theoretical framework, we can start to consider what a great API surface for this would be.

My initial thought is to consolidate the functionality of Tween and Spring into a single Follower or Mover state object, and accept those 'move constructors' as an argument, allowing the user to use the aforementioned tweenMover and springMover functions to easily obtain prefab movement types, or pass in their own custom movement types:

local goal = Value(10)

local spring = Follower(goal, springMover(0.7, 50))
local tween = Follower(goal, tweenMover(TweenInfo.new(0.5)))

This means we can now have one Follower implementation which works for all movement types and deduplicates all behaviour, such as scheduling, while also providing an easy-to-use and easy-to-extend framework for third party libraries to leverage.

We can even enable completely novel use cases, such as switching between entirely different kinds of motion curve rather than being stuck only configuring properties of a tween or spring:

local curve = Value(springMover(0.7, 50))

-- our follower starts out animating like a spring...
local follower = Follower(goal, curve)

-- ...but later on can be swapped for a tween!
curve:set(tweenMover(TweenInfo.new(0.5)))

This should be fantastic for a lot of accessibility use cases, specifically to do with motion sickness or reduced motion. With this extreme generality, it should become possible to provide alternate subtle animations, or turn them off entirely, without having to worry about 'types of movement' so much.

We could possibly convert Spring and Tween from full state objects to simple wrappers around any Follower object we choose to implement, if any - though I'm still unsure if that's actually a good idea of if it's better to have just the one way. My instinct tells me that it's a better idea to drop Spring and Tween if we go through with this, since we don't have anything else like that in the Fusion library (the most similar being New and Hydrate wrapping applyInstanceProps, though this is for good reason as it would otherwise expose our semi-weak ref implementation). Shorthands like Spring and Tween could always be maintained as a third party library for those most desperate to keep them.

dphfox commented 2 years ago

Something interesting here is that this general Follower implementation could absolutely build off the work done by #12 too, meaning we could consolidate our timekeeping logic in theory.

dphfox commented 2 years ago

It's worth taking a look at how asymmetrical movement may work within this model of follower objects. Someone may want to apply one mover in one direction, but a different mover in another direction.

If both movers have compatible condition formats, we can do this at a per-component level using a sign check;

local function makeAsymmetric(upMover, downMover)
    return function(initialDisplacement, ...)
        if initialDisplacement > 0 then
            return downMover(initialDisplacement, ...)
        else
            return upMover(initialDisplacement, ...)
        end
    end
end

local upMover = makeTween(TweenInfo.new(0.5))
local downMover = makeTween(TweenInfo.new(1.5))
local mover = makeAsymmetric(upMover, downMover)

However, this is incredibly specific and niche, and highlights a potential problem with performing animations component-wise by default. For example, suppose we have a vector which we want to spring-animate left and right, but linearly animate up and down; this is impossible to do automatically right now.

It's worth considering whether this is important to support, given that non-Computed (or some simple Computed) asymmetric animations could be done imperatively:

local goal = Value(2)
local follower = Follower(goal)

local function moveTo(newGoal: number)
    follower:setMover(if newGoal > goal:get() then upMover else downMover)
    goal:set(newGoal)
end

We could perhaps introduce the notion of a 'mover callback' which calculates a mover to use based on the old and new goal values, though a question arises about how to disambiguate between this and a regular mover;

local function moverCallback(oldGoal, newGoal)
    if oldGoal <= newGoal then
        return upMover
    else
        return downMover
    end
end

local follower = Follower(goal, moverCallback)

Perhaps we should make movers non-componentwise and make that a Fusion-provided utility that can be tapped into? In that case, what would such a utility look like?

I think it's worth at least getting a sense of where we're going to take this for asymmetric animations before locking ourselves into a concrete API surface.

dphfox commented 2 years ago

One interesting side effect of making movers non-componentwise would be that animatability becomes a property extrinsic to follower objects, and that any data type may be animatable. Only tweens, springs, beziers, acceltweens or any other componentwise mover would have that animatability restriction, and as such they would be responsible for handling it.

dphfox commented 2 years ago

Here are some notes on what a more complete componentwise API would look like for transitional animations. This doesn't yet extend to non-componentwise movers:

--[[
    `Transition` is a general-use animation object, which animates towards a
    goal state just like `Spring` or `Tween`.

    The difference is that, instead of using a fixed animation curve, it accepts
    'movers', which can generate the animation curves.
]]

local transition = Transition(value, mover)

--[[
    Movers can be passed in as a state object, which allows for different
    animation types to be swapped in.
]]

local currentMover = Computed(function()
    return if condition:get() then mover1 else mover2
end)
local transition = Transition(value, currentMover)

--[[
    Movers are pure functions which take in displacement, velocity, acceleration
    etc. and return an animation curve as a function of time.

    This animation curve provides evolved values for displacement, velocity,
    acceleration etc. for the given point in time. It is implied that animation
    stops when all values are within epsilon of 0, indicating the value has
    reached zero displacement from the goal value and does not have any
    components to provoke further motion.
]]

local moveDuration = 2 -- move over the course of two seconds

-- by convention, any nil arguments should be assumed to be 0
local function myMover(displacement, velocity, acceleration, jerk, ...)
    displacement = displacement or 0
    velocity = velocity or 0
    acceleration = acceleration or 0
    jerk = jerk or 0

    -- figure out how many units to move per second
    local moveVelocity = -displacement / moveDuration

    -- this animation curve moves from `displacement` to 0 over `moveDuration` seconds
    local function animationCurve(time)
        assert(time >= 0, "Negative time not allowed")
        -- we stop moving after `moveDuration` because we reach 0 displacement
        if time >= moveDuration then
            return 0, 0
        end

        local newDisplacement = displacement + moveVelocity*time
        -- we return the new position and velocity
        -- acceleration, jerk etc. is implicitly 0 because we don't return them
        return newDisplacement, moveVelocity
    end

    return animationCurve
end

local transition = Transition(value, myMover)

--[[
    When the transition object receives an updated goal value, it takes the
    most recent displacement, velocity, acceleration etc. and generates a new
    animation curve by passing those arguments to the mover. The mover returns
    the animation curve which the transition object will follow going forward.

    From there, the transition object emits updates every frame until the
    animation curve has 0 displacement, velocity, acceleration etc. Then it goes
    to sleep to conserve CPU resources.

    This generalised 'animation curve' representation allows users to define
    their own motion paths. However, for convenience Fusion provides mover
        'generators' for generating movers for very common animation types:
]]

-- equivalent to `Spring(value, 50, 0.8)`
local transition = Transition(value, SpringMover(50, 0.8))
-- equivalent to `Tween(value, TweenInfo.new(0.5, "Back", "In", 4, true, 1))`
local transition = Transition(value, TweenMover(0.5, "Back", "In", 4, true, 1))
-- new mover: move according to a cubic bezier, configurable control points
local transition = Transition(value, BezierMover(0.25, 0, 0.75, 0.5))
-- new mover: instant snap to value, with optional built-in delay
local transition = Transition(value, InstantMover(0.5))
-- new mover: move linearly to new value over fixed time interval
local transition = Transition(value, LinearMover(2))
-- new mover: move to new value without exceeding a max velocity
local transition = Transition(value, VelocityMover(0.5))
-- new mover: move with arbitary velocity without exceeding a max acceleration
local transition = Transition(value, AccelMover(0.5))
dphfox commented 2 years ago

With an API design of this style, it remains to be seen whether we should allow for mover generators to accept state objects during construction.

I'm currently leaning towards 'no', because the most natural way to implement mover generators with state arguments is to return a Computed which evaluates to a mover, which would then cause issues if someone attempted to use mover generators inside of their own Computeds, expecting the mover generator to return a mover directly. This would create a difference in code behaviour between constructing using constants and constructing using state objects, so for consistency either mover generators should always return Computeds (unintuitive) or mover generators should NOT accept state objects as arguments (makes more sense theoretically and keeps movers pure, at the expense of being a little less convenient)

dphfox commented 2 years ago

It also remains to be seen whether we should convert Spring and Tween to helper functions or remove them entirely. Either way, we're 100% for sure going to be breaking backwards compatibility here, so the only real argument is about ease of use.

In light of the above limitation with mover constructors not accepting state objects, we could perhaps wrap around this:

local function Spring(value, speed, damping)
    return Transition(value, Computed(function(use)
        return SpringMover(use(speed), use(damping))
    end))
end
dphfox commented 2 years ago

I've been thinking on how this theoretical framework relates to other kinds of animation for a while. Specifically, I've been digging into how these transitional animation curves (e.g. the evolution of a spring towards a goal) could relate to more general animation curves which are deterministic for a set of initial conditions (e.g. kinetic scrolling/flinging or particle physics systems). I think that it would be valuable for Fusion to support these kinds of animations that 'evolve' from initial conditions, which I'll call 'evolving animations' from here on out.

As per the current theory, we start off with an animation curve - some pure function that returns position, velocity, acceleration etc. for a given time into the future. This is ultimately what an evolving animation is, in its rawest form.

As an example, this curve here could represent the motion of a ball launched out of a cannon over time; we can plug in different values to figure out where the ball will be at any point in time:

image

We can also derive velocity and acceleration curves which give us the speed of the ball at any time, and the gravitational acceleration it experiences at any time. Here the velocity is shown in green:

image

With just these curves, we're able to animate anything that evolves over time - just start a timer, and every frame you sample the curve at the current time and move your object there/squish or stretch it based on velocity/rotate it towards where it's accelerating/whatever.

However, it's often desirable to 'stitch together' different animation curves - we can do this by matching up the 'final' position/velocity/acceleration/etc of one curve with the initial position/velocity/acceleration/etc of the next. This is done when the rules of the evolution change, for example if the strength of gravity changed - you can 'glue on' a new animation curve with the new gravity onto the end of the old animation curve with the old gravity, then continue as normal.

Here, I've only matched position, but no derivatives - notice the 'jerk' as the animations switch over. If any derivative doesn't line up (whether velocity, acceleration, jerk, snap, or further) then the user could notice the animation 'lurches' or 'jumps' in some way; this most often happens with tweens, which behave in this manner:

image

Matching up the derivatives smooths everything out, which is highly desirable:

image

A concrete example of this in action is Fusion's springs. This is what allows springs in Fusion to continue smoothly even when their animation is interrupted by the goal value changing - they can 'pass on' velocity to the next animation curve by specifying that the curve starts with a certain velocity. Even though the rules of evolution have changed (we're now pulled towards a different goal), the motion remains perfectly continuous at all levels.

A neat aside: since the position/velocity can be calculated for any point in time, we don't even need to save the current position/velocity of the spring to do that. Instead, we can calculate the exact position and velocity the spring should have at exactly the point of the crossover, even if that point lies between frames, leading to the most accurate stitch possible.

To help construct these seamless stitches, we can define a function which constructs some kind of animation curve fitting as many derivatives as we'd like; for example, here's a mover which simply fits some starting position and starting velocity:

-- by convention, any nil arguments should be assumed to be 0
local function myMover(position, velocity)
    position = position or 0
    velocity = velocity or 0

    -- this animation curve moves away from the starting `position` with a velocity of `velocity`
    local function animationCurve(time)
        local newPosition = position + velocity*time
        -- we return the new position and velocity
        -- acceleration, jerk etc. is implicitly 0 because we don't return them
        return newPosition, velocity
    end

    return animationCurve
end

On Fusion's side, we can provide an object which manages the timekeeping and stitching for us - we just pass it a mover and sample it over time. For example, here's one such API that could fit that bill (this is just one probably bad example):

local thing = Animator {
    -- we will generate an animation curve from this mover function
    mover = myMover,
    -- it will start from position = 10 and velocity = -2
    initialConditions = {10, -2}
}

-- the object automatically moves along the animation curve for us
print(thing:getPosition(), thing:getVelocity()) -- 10, -2
task.wait(2)
print(thing:getPosition(), thing:getVelocity()) -- 6, -2

-- we can also switch over to a new mover function, which will get
-- the current position/velocity as its initial conditions and return the next curve
-- the user doesn't have to worry about lining those up
thing:setMover(myOtherMover)
print(thing:getPosition(), thing:getVelocity()) -- 6, -2

That's basically the minimum amount of API to handle animating any evolving system. The specific complexities of different kinds of animation are encoded into the mover functions, with the managerial stuff neatly extracted into that manager object.

If we want to improve efficiency, and we accept the convention that derivatives == 0 means equilibrium, you could stop recalculating new values of position and velocity if the animation reaches equilibrium - this is the general implementation of 'going to sleep'. Notice how all of this lines up nearly 1:1 with the existing theory of follower objects.

Let's now try and use this system to build a pseudocode kinetic scrolling system. It should precisely track the user's finger as they drag across the UI surface, then switch to a physics-based fling once they release their finger.

Starting with the physics side of things, we can define a velocity curve (in red) that starts at some velocity (a) and decays over time by some factor (d), simulating the effect of friction. Using calculus, we can derive a position curve (in purple) thats starts with that velocity (a), starts from some position (b), and slows down by factor (d):

image

tl;dr - the red curve shows the velocity decaying away, and the purple curve shows the velocity accumulating on top of some starting location

Using those formulae, any animation curve we generate will end up looking like this:

function(time)
    local position = initialPosition + (1 - decayFactor ^ -time) * initialVelocity / math.log(decayFactor)
    local velocity = initialVelocity * decayFactor ^ -time
    return position, velocity
end

We'll want to wrap that inside a 'mover' function, so we can get our initial conditions from the manager object:

function(initialPosition, initialVelocity)
    return function(time)
        local position = initialPosition + (1 - decayFactor ^ -time) * initialVelocity / math.log(decayFactor)
        local velocity = initialVelocity * decayFactor ^ -time
        return position, velocity
    end
end

Finally, to provide the 'rules of evolution' (aka the decay factor), we wrap in a final 'constructor' function to allow users to easily generate mover functions to give to those manager objects:

local function FrictionMover(decayFactor)
    return function(initialPosition, initialVelocity)
        return function(time)
            local position = initialPosition + (1 - decayFactor ^ -time) * initialVelocity / math.log(decayFactor)
            local velocity = initialVelocity * decayFactor ^ -time
            return position, velocity
        end
    end
end

Now we can pass in a FrictionMover to any manager object, and any animation will smoothly decay to a stop at whatever rate we want. As long as we respect the initial conditions we're given, and return sensible values for position, velocity, acceleration etc, we're guaranteed 100% compatibility immediately with all other animation types.

Moving on to finger tracking, for this we want to disable animation and manually set our own position and velocity to match our finger's position and velocity. We can just generate animation curves that don't change from their initial conditions:

local function DisableMover(...)
    return function(time)
        return ...
    end
end

Now, we can pass in a DisableMover to any manager object to pause the animation. Because we're passing through the initial conditions, any mover object we change to afterwards will pick up where the previous mover left off before pausing. It'll also pass through any position or velocity overrides we do manually, too.

(performance note: it also breaks any sleeping scheme dependent on detecting when the derivatives are 0, because this implicitly breaks our assumption that zero derivatives indicates zero movement, but we can ignore this for now - pausing could always be implemented at the API level with zero fuss anyway, especially if animations are controlled with dedicated Timer objects)

Now we can assemble our kinetic scrolling system with some relatively simple pseudocode:

local SCROLL_MOVER = FrictionMover(0.5)

local isBeingScrolled = false
local scrollAnimator = Animator {
    mover = SCROLL_MOVER
}

TouchBegan:Connect(function(touch)
    if isBeingScrolled then
        return -- only allow one finger to scroll at a time
    end
    isBeingScrolled = true

    -- prevent the scroll animator from animating itself while we're dragging it
    scrollAnimator:setMover(DisableMover)

    -- capture where the touch started and where we were scrolled to at that time
    local startTouchY = touch.Position.Y
    local startScrollPosition = scrollAnimator:getPosition()

    -- while we're dragging, update the position and velocity manually
    task.spawn(function()
        while touch.IsActive do
            local scrollDistance = touch.Position.Y - startTouchY
            scrollAnimator:setPosition(startScrollPosition + scrollDistance)
            scrollAnimator:setVelocity(touch.Velocity.Y)
            RunService.RenderStepped:Wait()
        end
    end)

    -- return to physics scrolling when we're done dragging
    touch.Ended:Connect(function()
        scrollAnimator:setMover(SCROLL_MOVER)
        isBeingScrolled = false
    end)
end)

That's all there is to it - now the user has fully-functional kinetic scrolling, and can fling the UI contents around to their heart's content! I would expect that the movers built as part of this process would be pre-made as part of Fusion in some form or another, so you wouldn't have to deal with deriving the formulae for friction movement yourself; you would only need to write the last code snippet, which is surprisingly simple given how accurate and robust it would be in practice.

Now that we've seen the utility of this abstraction for regular evolving animations, we should explore how it can be tied into transitional animations.

Evolving animations are actually a superset of the transitional animations we have now. It can be said that a transitional animation 'evolves' from some initial state towards a universal equilibrium state. For example, a spring transition will start at some displacement from the goal, possibly with some velocity from a previous spring move, and will evolve towards zero displacement, zero velocity and zero acceleration. Evolving animations more generally 'evolve' from an initial state, but their equilibrium state may either depend on their input state (e.g. scrolling down a web page) or no equilibrium state may be reached (e.g. a spring oscillating forever, or impulsing an object with no friction). For that reason, it should be unsurprising that we can represent transitional animations entirely as evolving animations, just with some semantic tweaks here and there.

Let's start with the old spring mover code from the transitional animation system, and start adapting it to work with our evolving animations framework:

local function SpringMover(damping, speed)
    return function(startDisplacement, startVelocity)
        return function(time)
            local posPos, posVel, velPos, velVel = springCoefficients(time, damping, speed)
            local currentDisplacement = startDisplacement * posPos + startVelocity * posVel
            local currentVelocity = startDisplacement * velPos + startVelocity * velVel
            return currentDisplacement, currentVelocity
        end
    end
end

As with any other transitional mover from the old system, this mover deals with displacement from a goal value, not an absolute position. This displacement trends towards zero over time. We want to replace this with a system which moves from a starting position to an ending position over time.

To start with, we need to get an ending position from somewhere. Remember that this is technically part of the 'rules of evolution', because it's not an initial condition but still affects the shape of the trajectory. We'll wrap our mover function in a function dedicated to providing the ending position, so we can create multiple mover functions for different end positions without having to restate the damping and speed parameters every time:

local function SpringMover(damping, speed)
    return function(endPosition)
        return function(startPosition, startVelocity)
            return function(time)
                local posPos, posVel, velPos, velVel = springCoefficients(time, damping, speed)
                local currentDisplacement = startDisplacement * posPos + startVelocity * posVel
                local currentVelocity = startDisplacement * velPos + startVelocity * velVel
                return currentDisplacement, currentVelocity
            end
        end
    end
end

We can now convert from start/end positions to displacements, and back again:

local function SpringMover(damping, speed)
    return function(endPosition)
        return function(startPosition, startVelocity)
            local startDisplacement = endPosition - startPosition
            return function(time)
                local posPos, posVel, velPos, velVel = springCoefficients(time, damping, speed)
                local currentDisplacement = startDisplacement * posPos + startVelocity * posVel
                local currentVelocity = startDisplacement * velPos + startVelocity * velVel
                local currentPosition = currentDisplacement + endPosition
                return currentPosition, currentVelocity
            end
        end
    end
end

With that, we've now successfully converted from transitional animation to evolving animation. We can slot this into a manager object to animate towards any goal value, and we can even mix and match with our other movers, including the FrictionMover we defined earlier:

-- Set the spring speed to 2 and the spring damping to 0.4
local mySpringMover = SpringMover(2, 0.4)

local animator = Animator {
    -- Animate towards an end value of 5
    mover = mySpringMover(5),
    -- Animate from a starting position of -2, with a starting velocity of -10
    initialConditions = {-2, -10}
}

task.wait(2)
-- Animate towards an end value of -26 instead
animator:setMover(mySpringMover(-26))

task.wait(2)
-- Gradually decay velocity until we come to a stop
animator:setMover(FrictionMover(2))

Of course, I'm not suggesting that this should be the API we expose to users for transitional animation - we can provide syntax sugar on top of this to get back to our current ergonomic syntax easily - but the point is that we can also now give users a system vastly more powerful when they need to do more involved animation work.

krypt102 commented 1 year ago

That... was a mountain of information 😆 I'd definitely like for the Spring and Tween objects to be rewritten, like you said it could make a for a reduced motion or even just a smoother animation.

I love the idea of an Animator class to control animation for an object however maybe we could introduce a new method where the movers can contain value objects (for example a position which changes when a menu is open or closed)

Hexcede commented 12 months ago

I have some things to suggest considering here:

dphfox commented 12 months ago
  • Tweens should support things like rotation, or finite number fields (e.g. with rotations, 0-360, and 270 can be considered to be 90 degrees distance to 0 degrees because 0 and 360 are equal). I had to fork Fusion 0.2 for springs to work consistently with CFrames because I needed rotation, ended up botching in XVector/YVector and reconstructing with CFrame.fromMatrix and :Orthonormalize().

Yes, this is something I'd like to address and support.

  • If your goal is to fully generalize the idea of states which track values over time it would be interesting to consider whether or not the architecture you produce can describe a PID controller. PID controllers are exactly what you're describing but they're very general, and that would be a good proof that whatever architecture you come up with is generalized enough to be powerful.

I've never implemented a PID controller, but a cursory glance at the mechanics tells me that it may very well be possible to model with these abstractions (you'd calculate the animation curve based on the properties you read out of the animator, making it cyclic) but the need for PID controllers to constantly recalculate is unpalatable as far as API goes. These APIs are designed to upfront calculations as much as possible, to reduce the per-frame calculation work to resolving an analytical curve. This is done for efficiency and numerical stability reasons.

I probably wouldn't endeavour to make this natively support PID controllers at this stage, but it's something I can definitely keep in mind.

dphfox commented 6 months ago

Here's the list of inbuilt displacement curves I'm thinking of supporting (relatively final):

Optionally could also include some displacement curves that do not converge to 0:

Let me know if there's others you'd like to see.