Skip to content

Animation System Technical Documentation

Attila Szabo edited this page Dec 22, 2023 · 1 revision

The animations framework in Aardvark.UI.Anewmation (to be renamed in the future) lets developers create and integrate animations into their media applications. This document is an introduction to get you started with the framework.

Defining domains

An animation can be defined for any type 'Value as long as you provide a function that describes how its values are computed based on the position of the animation. This function spaceFunction: float -> 'Value is called space curve and defines the domain of the animation. The float parameter is the position of the animation, i.e. the distance traveled along the space curve. The distance is normalized so that spaceFunction 0.0 and spaceFunction 1.0 define the start and end of the space curve respectively. Despite this, a valid space function has to be defined for any real number as the animation position may exceed the interval [0, 1] in some cases.

A simple animation that linearly interpolates between two 3D vectors src : V3d and dst : V3d can be created by:

Animation.create (fun u ->
    (1.0 - u) * src + u * dst
)

returning an animation of type IAnimation<_, V3d>. The type of the animation values are encoded in the second generic type parameter, while the first denotes the type of the model. Obviously, the framework comes with a number of primitives that cover most general types of animations such as linear interpolation, splines and camera related primitives. The above linear interpolation example can also be formulated as:

Animation.Primitives.lerp src dst

Currently, simple primitives reside in the Animation.Primitives module, whereas camera related components reside in the Animation.Camera module. If you have ideas for useful primitives, let us know!

Timing animations

The space curve describing the domain is only half of what constitutes a complete animation definition. The other half is the so-called distance-time function (DTF), which simply describes how the position on the space curve changes over time. In contrast to the space curve, you do not directly specify the DTF of an animation. Instead, the distance-time function is controlled via the duration, iterations, loop mode, and easing function of the animation.

Duration

The duration of an animation is pretty straightforward. It's the time it takes to get from the start position u = 0.0 to the end position u = 1.0. You can use the following functions to set the duration:

Animation.duration     : Duration -> IAnimation -> IAnimation
Animation.nanoseconds  : ^Nanoseconds -> IAnimation -> IAnimation
Animation.microseconds : ^Microseconds -> IAnimation -> IAnimation
Animation.milliseconds : ^Milliseconds -> IAnimation -> IAnimation
Animation.seconds      : ^Seconds -> IAnimation -> IAnimation
Animation.minutes      : ^Minutes -> IAnimation -> IAnimation

So for example, we can set the duration of our lerp animation like this:

Animation.Primitives.lerp src dst
|> Animation.seconds 0.5

Iterations and loop mode

The number of iterations and loop mode determine what happens when the animation end u = 1.0 is reached after the given duration. By default the animation ends after a single such iteration. If the animation consists of more than one iteration, it is restarted depending on the set loop mode. If LoopMode.Repeat is set, the animation starts at u = 0.0 again after each iteration. This is useful for looping animations where the values at u = 0.0 and u = 1.0 are identical in some way (e.g. a rotation animation about 2π). The other mode LoopMode.Mirror reverses the direction of the animation after each iteration. So, when u = 1.0 is reached, the next animation iteration goes from u = 1.0 to u = 0.0. The following two functions control the number of iterations and loop mode of an animation:

Animation.loop  : LoopMode -> IAnimation -> IAnimation        // loop indefinitely
Animation.loopN : LoopMode -> int -> IAnimation -> IAnimation // loop N times

In our example, we want to mirror our lerp animation and end at the beginning again:

Animation.Primitives.lerp src dst
|> Animation.seconds 0.5
|> Animation.loopN LoopMode.Mirror 2

Note that the duration is specified for a single iteration, so our animation will effectively take one second to complete.

Easing function

The easing function is a function that maps from [0, 1] to [0, 1] and gives control over the motion along the space curve. Simply put, it returns a position value for a given input position. The only restriction is that it maps 0.0 to 0.0 and 1.0 to 1.0 so that the start and end of the space curve are actually reached. The default easing function is the identity function resulting in a uniform motion with a constant velocity. A quadratic easing function fun x -> x * x results in a slowly accelerating motion that reaches it peak velocity at u = 1.0. The easing function can be set explicitly:

Animation.easeCustom : compose: bool -> easing: (float -> float) -> IAnimation -> IAnimation

The compose flag determines if the easing function is overwritten or composed with the existing easing function. In the latter case the easing functions are simply chained and invoked one after another. Alternatively, you can choose from a library of predefined easing functions which is a subset of Robert Penner's Easing Functions:

type EasingFunction =
    | Quadratic
    | Cubic
    | Sine
    | Bounce of amount: float
    | Overshoot of amount: float
    | Elastic of amplitude: float * period: float
    
type Easing =
    | None
    | In of EasingFunction
    | Out of EasingFunction
    | InOut of EasingFunction
    | OutIn of EasingFunction

Use the following functions to set a predefined easing function:

Animation.ease' : compose: bool -> easing: Easing -> IAnimation -> IAnimation
Animation.ease  : easing: Easing -> IAnimation -> IAnimation // compose = true

Finishing up our lerp example we apply a sinusoidal ease-in and a quadratic ease-out:

Animation.Primitives.lerp src dst
|> Animation.seconds 0.5
|> Animation.loopN LoopMode.Mirror 2
|> Animation.ease (Easing.In EasingFunction.Sine)
|> Animation.ease (Easing.Out EasingFunction.Quadratic)

Again, note that the easing is applied to each iteration not to the animation as a whole.

Composite animations

Multiple animations can be combined into a single composite animation in two ways: Sequentially and concurrently.

Sequential animations

Sequential animations simply play their members one after another and come in an untyped and typed flavor:

Animation.sequential : IAnimation<_> seq -> IAnimation<_, unit>
Animation.path       : IAnimation<_, 'Value> seq -> IAnimation<_, 'Value>

Animation.sequential takes a sequence of untyped animations and thus can combine animations of different types (e.g. one that produces float and another that produces V3d). The resulting animation is of type unit and thus does not produce any values itself.

In contrast, Animation.path takes a sequence of animations of the same type and produces an animation of that type. For example, this function is used internally to combine multiple spline segment animations into a continuous smooth path animation.

Concurrent animations

The other way of combining animations is concurrently. As the name implies the members of a concurrent group run at the same time. Again, concurrent animations can be untyped or typed:

Animation.concurrent : IAnimation<_> seq -> IAnimation<_, unit>
Animation.map2       : mapping: ('T1 -> 'T2 -> 'U) -> x: IAnimation<_, 'T1> -> y: IAnimation<_, 'T2> -> IAnimation<_, 'U>

Especially, the typed mapping functions can be used to create complex animations consisting of multiple concurrent subanimations. For example, camera animations can be defined by two concurrent animations, one for the location with type V3d and one for the orientation with type Rot3d:

/// Creates an animation that interpolates between the camera views src and dst.
let interpolate (src : CameraView) (dst : CameraView) : IAnimation<'Model, CameraView> =
    let animPos = Animation.Primitives.lerp src.Location dst.Location
    let animOri = Animation.Primitives.slerp src.Orientation dst.Orientation

    (animPos, animOri)
    ||> Animation.map2 (fun pos ori -> CameraView.orient pos ori dst.Sky)

Timing of composite animations

You can use composite animations like any simple animation (e.g. change duration and easing), including using them as members in other composite animations. Hence, a composite animation effectively has its own distance-time function that is separate of its members DTFs.

Duration

The duration of a composite animation is implicitly defined by the total duration of its members. In this context, total duration refers to the duration times the number of iterations. Consequently, the total duration may be infinite if there is an unlimited number of iterations. The duration of a sequential animation is the sum of the total duration of its members. For concurrent animations, their duration is equal to the maximum total duration of their members.

Changing the duration of a composite animation (e.g. with Animation.seconds) also scales the member animations accordingly.

let a = someAnim |> Animation.seconds 1
let b = someAnim |> Animation.seconds 2 | Animation.loopN LoopMode.Mirror 2

let composite =
    Animation.sequential [a; b]
    |> Animation.seconds 10

In this example, the members initially have total durations of 1s and 4s respectively, resulting in a duration of 5s for the sequential animation. Now, setting the duration to 10s doubles the duration, so that the members will have total durations of 2s and 8s respectively. These scaling semantics only apply as long as the composite does not have any members that loop indefinitely (i.e. have an infinite number of iterations). In that case, changing the duration of the composite animation will have no effect.

Looping and easing

Looping and easing of composite animations works pretty much the same way as for simple animations. Yet, here the same restriction as for setting the duration applies. If a member of a composite animation has an infinite total duration, you will not be able to set an easing function or a loop mode. The latter is more obvious if you consider that the duration of the composite will be infinite, meaning that the first iteration will never end.

Models and callbacks

So far we have only covered how to define animations but left out how to integrate them into our Media application. To this end, we have to perist the values generated by the animations in the application model. This is achieved with callbacks registered on animations. There are multiple event types for which callbacks can be registered:

type EventType =
    | Start        // Animation is started or restarted
    | Resume       // Animation is resumed after being paused
    | Progress     // Animation produced a new value
    | Pause        // Animation is paused
    | Stop         // Animation is stopped
    | Finalize     // Animation is finished

The following functions let you register callbacks:

Animation.onEvent : event: EventType -> callback: (Symbol -> 'Value -> 'Model -> 'Model) -> IAnimation<'Model, 'Value> -> IAnimation<'Model, 'Value>
Animation.onStart : callback: (Symbol -> 'Value -> 'Model -> 'Model) -> IAnimation<'Model, 'Value> -> IAnimation<'Model, 'Value>
Animation.onResume : callback: (Symbol -> 'Value -> 'Model -> 'Model) -> IAnimation<'Model, 'Value> -> IAnimation<'Model, 'Value>
...

A callback is a function that takes the name of the animation slot (covered later in more detail), the current value of the animation and the current model, returning a new model. In most cases, you simply want to change a member of your model whenever the animation produces a new value (i.e. on the progress event):

type Model =
    { 
        color       : C4d
        position    : V3d
        orientation : Rot3d
    }

let animation =
    Animation.Primitives.lerp src dst
    |> ...
    |> Animation.onProgress (fun name value model ->
        { model with position = value }
    )

Alternatively, you can use a lens (which are auto-generated for model types in Media applications) to link an animation to your model, which is merely syntactic sugar for the above callback:

let animation =
    Animation.Primitives.lerp src dst
    |> ...
    |> Animation.link Model.position_

Integration with the Animator type

Now that we know how to define animations, all that is left is to integrate the animations into the model and message loop of our Media application. To this end we use the Animator<'Model> type, which handles all of the update logic and more for us.

Adapting the model and message loop

First, we add the animator to our application model and message type:

type Model =
    { 
        color       : C4d
        position    : V3d
        orientation : Rot3d
        [<NonAdaptive>]
        animator    : Animator<Model>
    }
    
type Message =
    ...
    | Animation of AnimatorMessage<Model>

Initializing the animator is done via Animator.initial which takes a lens to the animator member in the model type as argument.

let initial =
    {
        color       = C4d.Black
        position    = V3d.Zero
        orientation = Rot3d.Identity
        animator    = Animator.initial Model.animator_
    }

The message loop has to be altered to pass animation messages into the Animator.update function:

let update (model : Model) (msg : Message) =
    match msg with
    ...
    
    | Animation msg ->
        model |> Animator.update msg

Generating animation ticks

Animations have to be updated by passing tick messages to the animator. For most applications, the best way to do so is to generate RealTimeTick messages in the onRendered event of the render control, by adding the following attribute:

onEvent "onRendered" [] (fun _ -> Animation AnimatorMessage.RealTimeTick)

This makes sure that animations are updated whenever the scene is rendered, resulting in smooth animations. Additionally, we have to ensure that animations are updated even when the scene is not changing (e.g. if the scene is static and a new animation is started). To this end, we generate ticks in a separate thread on demand:

{
    unpersist = Unpersist.instance
    threads = Animator.threads model.animator |> ThreadPool.map Animation
    initial = initial
    update = update
    view = view
}

Alternatively, you can generate ticks manually using AnimatorMessage.Tick, which takes a timestamp of type MicroTime as argument.

Creating and managing animation instances

The Animator module contains a range of functions to create, manage, and query animation instances. Probably the most important function is

Animator.createAndStart : name: ^Name -> animation: IAnimation<'Model> -> model: 'Model -> 'Model

which creates and starts an instance of the given animation. An animation instance is added to a slot identified by its name (can be either a string or Symbol in most cases). The name of the slot can be used to manage or query the associated animation instance:

Animator.stop : ^Name -> 'Model -> 'Model
Animator.pause : ^Name -> 'Model -> 'Model
Animator.resume : ^Name -> 'Model -> 'Model
Animator.isRunning ^Name -> 'Model -> bool
...

Note that an animation instance is not removed from its slot automatically even if it is finished. You can use these functions inside the callbacks of animations as well. For example, a possible application is to loop animations until some condition is met:

Animation.Primitives.lerp src dst
|> Animation.onFinalize (fun name value model ->
    if model.shouldWeLoop then
        model |> Animator.start name
    else
        model
)

Animation queues

Each animation slot contains a single active animation instance (does not have to be running necessarily) and a queue of pending animations. Whenever the active instance of a slot is finished, the next pending animation is dequeued and becomes active (if there is any). You can enqueue animations with the *Delayed functions:

Animator.createAndStartDelayed -> name: ^Name -> animation: ('Model -> #IAnimation<'Model>) -> model: 'Model -> 'Model

Using this function will not replace any running animation instances in the slot but enqueue the animation instead. Note that the animation is actually defined by a creator function taking the model as argument. When an enqueued animation is to become active, its creator function is called using the current model, and an instance is created and started. This mechanism is useful in some dynamic scenarios, if the specific domain of the animation is not known completely when the animation is added to the animator slot.

Example

Take a look at 26 - Animation for an example game that uses some more advanced animations that are coupled with the application logic.