Connecting the DOTS: Tweening

Tweening lies at the heart of every single animation in a game. Let's look at the Wikipedia definition:

Inbetweening or tweening is a key process in all types of animation, including computer animation. It is the process of generating intermediate frames between two images, called key frames, to give the appearance that the first image evolves smoothly into the second image. Inbetweens are the drawings which create the illusion of motion.

Smoothly is the key here. We may as well define tweening as the juice of a game; what brings that cool explosion effect to the 'wow' level or makes that button animation feel as soft as cotton. When it comes to Unity, the most well-known library for tweening is probably DOTween. What about purely data-driven tweening, though? What would that look like?

My first intuition is to create a new entity per tween. You could also think about adding a tween component to the entity-to-be-tweened instead, and this will in fact be a recurring dilemma with data-oriented programming: do I create a new entity or add a new component to an existing one? It is an interesting question that deserves an article of its own. For the time being, let's move forward with the "one entity per tween" approach. This entity has a component containing the specific data for the tween which, in the case of a vector, could look like this:

using Unity.Entities;
using Unity.Mathematics;

public struct TweenFloat3 : IComponentData
{
    public Entity Target;
    public float3 Beginning;
    public float3 End;
    public float3 Change;
    public float Duration;
}

Where Target is the entity to tween, Beginning and End are the initial and final values to tween from and to respectively, Change is the difference between Beginning and End and, finally, Duration is the duration of the tween in seconds.

This is the constant data of a tween, but there is also a dynamic side to it: we need to store the current progress and the current tweened value of the tween somewhere as well. It is worth creating an independent component for this dynamic data:

using Unity.Entities;
using Unity.Mathematics;

public struct TweenProgressFloat3 : IComponentData
{
    public float Time;
    public float3 Tweened;
}

Time stores the elapsed time of the tween and Tweened represents the currently interpolated value.

Separating the constant data from the dynamic data allows marking the constant data as read-only by the appropriate systems later on, which can bring additional performance benefits to the table. You could go beyond this approach and define even more granular components for each one of the fields discussed here. How granular you want to go with your components will usually be influenced by how the systems operating on those components access their data. For the example at hand, this degree of granularity seems to be reasonable enough.

So, with the tween data out of the way, we can now write a system that performs the actual tweening calculation.

using Unity.Entities;
using Unity.Mathematics;

[UpdateInGroup(typeof(TweenEaseSystemGroup))]
public class LinearTweenSystem : SystemBase
{
    private EndSimulationEntityCommandBufferSystem ecbSystem;

    protected override void OnCreate()
    {
        ecbSystem = World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();
    }

    protected override void OnUpdate()
    {
        var ecb = ecbSystem.CreateCommandBuffer().ToConcurrent();
        var deltaTime = Time.DeltaTime;

        Entities
            .WithName("LinearTweenSystem_Update")
            .WithAll<LinearTween>()
            .WithNone<TweenFinished>()
            .ForEach((Entity entity, int entityInQueryIndex, ref TweenProgressFloat3 progress, in TweenFloat3 tween) =>
            {
                var beginning = tween.Beginning;
                var change = tween.Change;
                var duration = tween.Duration;

                var time = progress.Time;
                var x = LinearTween(time, beginning.x, change.x, duration);
                var y = LinearTween(time, beginning.y, change.y, duration);
                var z = LinearTween(time, beginning.z, change.z, duration);
                progress.Tweened = new float3(x, y, z);

                progress.Time += deltaTime;
                if (progress.Time >= duration)
                {
                    progress.Tweened = tween.End;
                    ecb.AddComponent<TweenFinished>(entityInQueryIndex, entity);
                }
            }).ScheduleParallel();

        ecbSystem.AddJobHandleForProducer(Dependency);
    }

    private static float LinearTween(float t, float beginning, float change, float duration)
    {
        return change * t/duration + beginning;
    }
}

Note the use of the tag component types LinearTween, which enables easy differentiation between different types of tweenings, and TweenFinished, which marks a tween as completed. The LinearTween method, which is marked as static so that it can be Burst-compiled, does the actual tweening calculation (linear in this example). The formula itself is based on the very well-known easing functions by Robert Penner.

Leveraging the Entities.ForEach syntax allows us writing a very succinct, yet very performant, tweening implementation that will easily scale across a large number of entities.

How to use the tweened value, though? We still need to take it and apply it to the original target entity. For example, if we want to tween the position of said entity, we can create a new system that will update its Translation component as needed:

using Unity.Entities;
using Unity.Transforms;

[UpdateInGroup(typeof(TweenUpdateSystemGroup))]
public class TweenPositionSystem : SystemBase
{
    private EndSimulationEntityCommandBufferSystem ecbSystem;

    protected override void OnCreate()
    {
        ecbSystem = World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();
    }

    protected override void OnUpdate()
    {
        var ecb = ecbSystem.CreateCommandBuffer().ToConcurrent();

        Entities
            .WithName("TweenPositionSystem_Update")
            .WithNone<TweenFinished>()
            .ForEach((Entity entity, int entityInQueryIndex, in TweenFloat3 tween, in TweenProgressFloat3 progress) =>
            {
                var target = tween.Target;
                ecb.SetComponent(entityInQueryIndex, target, new Translation
                {
                    Value = progress.Tweened
                });
            }).ScheduleParallel();

        ecbSystem.AddJobHandleForProducer(Dependency);
    }
}

Finally, we probably want to destroy all the tween entities marked as TweenFinished. Again, we can create a new system specifically devoted to that as follows:

using Unity.Entities;

[UpdateInGroup(typeof(TweenCompletionSystemGroup))]
public class TweenDestructionSystem : SystemBase
{
    private EndSimulationEntityCommandBufferSystem ecbSystem;

    protected override void OnCreate()
    {
        ecbSystem = World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();
    }

    protected override void OnUpdate()
    {
        var ecb = ecbSystem.CreateCommandBuffer().ToConcurrent();

        Entities
            .WithName("TweenDestructionSystem_Update")
            .WithAll<TweenFinished>()
            .ForEach((Entity entity, int entityInQueryIndex) =>
            {
                ecb.DestroyEntity(entityInQueryIndex, entity);
            }).ScheduleParallel();

        ecbSystem.AddJobHandleForProducer(Dependency);
    }
}

Note how all the systems are running inside a custom system group in order to have a clear ordering defined between them.

Even with such straightforward series of transformations, it is interesting to see how data-oriented design lends itself very well to having many smaller, independent systems focused on one and only one task. This is a point that will probably get stressed a lot during this series: data-oriented design is more than just performance.

All that remains is to provide a convenient utility class to make tweening accessible to client code as easy as it would be with a third-party library.

using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

public static class Tween
{
    public static void Move(Entity target, float3 end, float duration)
    {
        var world = World.DefaultGameObjectInjectionWorld;
        var entityManager = world.EntityManager;

        var translation = entityManager.GetComponentData<Translation>(target);
        var beginning = translation.Value;
        var change = end - beginning;

        var tween = entityManager.CreateEntity(
            typeof(TweenFloat3),
            typeof(TweenProgressFloat3),
            typeof(LinearTween));
        entityManager.SetComponentData(
            tween,
            new TweenFloat3
            {
                Target = target,
                Beginning = beginning,
                End = end,
                Change = change,
                Duration = duration
            });
    }
}

We could take this as the humble starting point of a small library for data-oriented tweening, by adding new systems for different types of easing and support for convenient features such as pausing and callbacks. Callbacks in particular are interesting, if you think about how they are inherently so non-data oriented. I will extend this article with some additional ideas about these topics in the future.