We have cleared a couple of hurdles with our game project! We should be able to get something that looks kind of like a game soon…

  1. Blending animations
    1. Animations as a state machine
  2. Thrust control sequence
  3. The new Turn Small Up
  4. The other rotations
  5. Big turns

Blending animations

We have gotten animations to start playing on optimised skeletons, hooray. Now we need to start blending between them. This isn’t covered by the Kinemation getting started tutorial, although it indicates something called a BufferPoseBlender is what we’re going to need to use.

Well, time to check the source code. There are some remarks to tell us how to drive this thing…

A BufferPoseBlender reinterprets an OptimizedBoneToRoot as temporary storage to sample and accumulate local space BoneTransforms. The first pose sampled for a given instance will overwrite the storage. Additional samples will perform additive blending instead.

To discard existing sampled poses and begin sampling new poses, simply create a new instance of BufferPoseBlender using the same OptimizedBoneToRoot buffer.

To finish sampling, call NormalizeRotations(). You can then get a view of the BoneTransforms using GetLocalTransformsView().

If you leave the buffer in this state, a new BufferPoseBlender instance can recover this view by immediately calling GetLocalTransformsView(). This allows you to separate sampling and IK into separate jobs.

When you are done performing any sampling or BoneTransform manipulation, call ApplyBoneHierarchyAndFinish().

So it looks each frame, what we’ll need to do is create a new BufferPoseBlender and then add all the poses we’d like to blend, presumably by calling SamplePose on them? Then, we call NormalizeRotations() followed by ApplyBoneHierarchyAndFinish().

What about the weights given to the different poses? Looking into the source code for animation clips, it turns out there is another overload for SamplePose which takes a weight and a BufferPoseBlender instead of a skeleton. So that’s the answer!

What we have to do is a little complex. If it takes a time \(T\) to rotate into the intended orientation, we have three phases (terminology respectively from fighting games and music synthesis):

The windup can be fairly quick since the starting pose of the transition animation is fairly close to the poses of the flight animation. The follow through might need to be a bit slower to look natural.

Since all our animations are packed into a SkeletonClipSetBlob indexed by an integer, we likely need an enum to make our code readable (so indexing animation clips will look kind of like a dictionary). Then we can store a ‘transient animation clip’ component which stores an index for the animation, and the startup and recovery times.

The enum:

public enum AnimationIndex
{ LevelFlight
, TurnSmallUp
, TurnSmallDown
, TurnSmallLeft
, TurnSmallRight
, TurnLargeUp
, TurnLargeDown
, TurnLargeLeft
, TurnLargeRight
, Thrust
}

The component:

using Unity.Entities;

partial struct TransientAnimationClip : IComponentData
{
    public AnimationIndex Index;

    public float TimeCreated;

    public float StartupEnd;
    public float RecoveryStart;
    public float AnimationEnd;
}

I added [WithNone(typeof(TransientAnimationClip))] to AnimationClipPlayerJob, the job that samples a single looping clip.

Now, here’s a job that should hopefully blend animation clips.

partial struct TransientClipBlenderJob : IJobEntity
{
    public float Time;
    public EntityCommandBuffer.ParallelWriter ECB;

    void Execute
        ( ref DynamicBuffer<OptimizedBoneToRoot> btrBuffer
        , in OptimizedSkeletonHierarchyBlobReference hierarchyRef
        , in AnimationClips animationClips
        , in TransientAnimationClip tc
        , Entity entity
        , [ChunkIndexInQuery] int chunkIndex
        )
    {
        BufferPoseBlender blender = new BufferPoseBlender(btrBuffer);

        ref var baseClip =
            ref animationClips.blob.Value.clips[0];
        ref var transientClip =
            ref animationClips.blob.Value.clips[(int) tc.Index];

        float baseClipTime = baseClip.LoopToClipTime(Time);
        float transientClipTime = Time - tc.TimeCreated;

        float transientWeight = 
            math.smoothstep(0f, tc.StartupEnd, transientClipTime)
            * ( 1 - math.smoothstep(tc.RecoveryStart, tc.AnimationEnd, transientClipTime));

        baseClip.SamplePose
            ( ref blender
            , 1 - transientWeight
            , baseClipTime
            );

        transientClip.SamplePose
            ( ref blender
            , transientWeight
            , transientClipTime
            );

        blender.NormalizeRotations();

        blender.ApplyBoneHierarchyAndFinish(hierarchyRef.blob);

        if (transientClipTime > tc.AnimationEnd)
        {
            ECB.RemoveComponent<TransientAnimationClip>(chunkIndex, entity);
        }
    }
}

From the top, this looks for entities that have both a set of animation clips and a TransientAnimationClip component. It creates a new BufferPoseBlender as we’ve discussed.

The base clip is taken to loop continuously, so we use LoopToClipTime again. The transient clip is taken to play once starting from the TimeCreated. Then we compute the weight for the transient clip. We smoothstep up to 1 by the end of StartupEnd, and smoothstep back down to zero between RecoveryStart and AnimationEnd. The weight for the base clip is just one minus this.

We sample the poses, normalise the rotations, apply the bone hierarchy and done. At the end, if the clip has reached the end of its duration, I schedule a command to delete it. The next frame it should go back to being handled by AnimationClipPlayerJob, and since both jobs have been using the same looping clip time, there shouldn’t be any noticeable jump.

Now we just need to set this component and see some nice smooth transitions! Hopefully!

So, back to ThrustStartJob. I’m going to add some more fields to the job and then create a clip like so:

ECB.AddComponent
( chunkIndex
, player
, new TransientAnimationClip
    { Index = AnimationClipIndex.TurnSmallUp
    , TimeCreated = (float) Time
    , StartupEnd = TurnSmallStartupEnd
    , RecoveryStart = TurnSmallRecoveryStart
    , AnimationEnd = TurnSmallAnimationEnd
    }
);

while the job is now called with

new ThrustStartJob
    { CameraRotation = rotation
    , ECB = ecb
    , Time = SystemAPI.Time.ElapsedTime
    , ThrustForce = level.ThrustForce
    , InverseThrustCooldown = 1f/level.ThrustCooldown
    , TurnSmallStartupEnd = level.TurnSmallStartup
    , TurnSmallRecoveryStart = level.ThrustWindup - level.TurnSmallRecovery
    , TurnSmallAnimationEnd = level.ThrustWindup
    }
    .Schedule();

This is getting a bit unwieldy with all the fields and so it might be appropriate to make better use of the blackboard entity and specific structs to handle different bits of config data.

I tried this; it compiled but nothing different happened. The reason is pretty simple: I’m applying TransientClip to the entity that has the Character tag, but actually it’s the child of this entity that has the Animator and thus gets baked to have the skeleton clips and all that. Whoops. We could try to write some code to select the child, but at this point I don’t see much reason not to collapse the hierarchy and put the CharacterAuthoring and CapsuleCollider components on the same entity as the prefab root.

Having done this… and fixed an issue where I forgot to actually schedule the new job… it works! The animations blend together, the doll’s legs twitch upwards. We have blending!

Right now the animation is unreasonably fast so I adjusted parameters a bit. Looking in Blender, the leg movement lasts 15 frames out of 24, or 0.625 seconds. If we give a little bit of extra time to transition back afterwards… well, we can actually have this transient animation overlap into the next phase. So let’s try this calculation instead…

new ThrustStartJob
    { CameraRotation = rotation
    , ECB = ecb
    , Time = SystemAPI.Time.ElapsedTime
    , ThrustForce = level.ThrustForce
    , InverseThrustCooldown = 1f/level.ThrustCooldown
    , TurnSmallStartupEnd = level.TurnSmallStartup
    , TurnSmallRecoveryStart = level.ThrustWindup
    , TurnSmallAnimationEnd = level.ThrustWindup + level.TurnSmallRecovery
    }
    .Schedule();

Now the recovery can essentially be as long as we want.

With parameters TurnSmallStartup=0.1, ThrustWindup=0.625, and TurnSmallRecovery=0.3, a small turn up looks like this…

It looks… pretty bad! The major reason for this is that the blending from the end of the transition state to the new state involves a very abrupt change of momentum on the limbs—it would be better to actually make this transition part of the animation and make it feel more natural.

But going beyond that, we could consider the ‘inertial blending’ method invented for Gears of War 4 in 2018, which uses a clever method to avoid sampling two animations; instead, it transitions out of the final pose of the previous animation with a gradual, dynamic decay that respects momentum, measured using a method similar to Verlet integration by measuring the delta between frames. This is available in Unreal; as I understand Dreaming is working on implementing it in the next version of Latios Framework, so I will hold off on this idea for now.

Without inertial blending, we’ll need to take the transitions into account in our animations.

Animations as a state machine

Presently our animation transitions back to the ‘level flight’ idle animation, but it would be better to communicate the act of going really fast with its own animation.

Rather than write three different animation indices into our TransientAnimationClip, and hardcoding that we should always return to clip 0, I think it would be better if we get into a sort of mindset of state machines and transitions. We’ll store the currently playing clip, and then have a component that’s an AnimationTransition with a source clip and a target clip. At the end of this transition, this component will delete itself and update the ‘currently playing’ index. So a bit of refactoring is needed.

First, we store the animation clip.

using Unity.Entities;

partial struct CurrentAnimationClip : IComponentData
{
    public AnimationClipIndex Index;

    public float Start;

    public bool Looping;
}

Secondly, a component for transitions, which is the same except for a duration.

using Unity.Entities;

partial struct AnimationTransition : IComponentData
{
    public AnimationClipIndex NextIndex;

    public float Start;

    public bool Looping;

    public float Duration;
}

The job we’re going to write this time is going to be pretty similar to the TransientAnimationClip, but we have to take into account time offsets. I’ll also modify the AnimationClipPlayerJob to play the current clip.

[WithNone(typeof(TransientAnimationClip), typeof(AnimationTransition))]
partial struct AnimationClipPlayerJob : IJobEntity
{
    public float Time;

    void Execute
        ( ref DynamicBuffer<OptimizedBoneToRoot> btrBuffer
        , in OptimizedSkeletonHierarchyBlobReference hierarchyRef
        , in AnimationClips animationClips
        , in CurrentAnimationClip currentAnimation
        )
    {
        ref var clip = ref animationClips.blob.Value.clips[(int) current.Index];
        float clipTime =
            currentAnimation.Looping
                ? clip.LoopToClipTime(Time - currentAnimation.Start)
                : Time - currentAnimation.Start;

        clip.SamplePose
            ( btrBuffer
            , hierarchyRef.blob
            , clipTime
            );
    }
}

partial struct AnimationTransitionJob : IJobEntity
{
    public float Time;
    public EntityCommandBuffer.ParallelWriter ECB;

    void Execute
        ( ref DynamicBuffer<OptimizedBoneToRoot> btrBuffer
        , in OptimizedSkeletonHierarchyBlobReference hierarchyRef
        , in AnimationClips animationClips
        , ref CurrentAnimationClip current
        , in AnimationTransition transition
        , Entity entity
        , [ChunkIndexInQuery] int chunkIndex
        )
    {
        BufferPoseBlender blender = new BufferPoseBlender(btrBuffer);

        ref var currentClip =
            ref animationClips.blob.Value.clips[(int) current.Index];
        ref var nextClip =
            ref animationClips.blob.Value.clips[(int) transition.NextIndex];

        float currentClipTime =
            current.Looping
                ? currentClip.LoopToClipTime(Time - current.Start)
                : Time - current.Start;
        float nextClipTime = Time - transition.Start;

        float nextWeight = math.smoothstep(0f, transition.Duration, nextClipTime);

        currentClip.SamplePose
            ( ref blender
            , 1 - nextWeight
            , currentClipTime
            );

        nextClip.SamplePose
            ( ref blender
            , 1 - nextWeight // <- oops
            , nextClipTime
            );

        blender.NormalizeRotations();

        blender.ApplyBoneHierarchyAndFinish(hierarchyRef.blob);

        if (nextClipTime > transition.Duration)
        {
            current.Index = transition.NextIndex;
            current.Start = transition.Start;
            current.Looping = transition.Looping;

            ECB.RemoveComponent<AnimationTransition>(chunkIndex, entity);
        }
    }
}

Not all the clips we’ll make are going to loop. For those that do loop, we may need to offset the time. The simplest way to accomplish this is to store a start time on the CurrentAnimationClip and subtract it from the current time. (We may have to worry about floating point precision issues, but I suspect this will only be an issue if the user plays for a truly extreme amount of time. If so we can probably find a way to reset the Unity clock anyway. Or we can store the start time as a double and do some floating point casts.)

That’s the easy part done. Now we need to set transitions at the appropriate points.

Thrust control sequence

Currently we’re going through the various phases of the thrust with a series of StatusChainJob jobs to set components corresponding to the different phases. The problem with this method is that the number of components we need to apply, and slices of time to apply them for, is multiplying. Now in addition to ThrustCooldown, ThrustWindup, and ThrustActive we have to worry about creating suitable AnimationTransitions at the appropriate times.

In the end, my attempt to manage everything through generic jobs proved… really stupid! Rather than make things easier to extend, all that work has actually made it harder, because as soon as I want behaviour more complex than the generic statuses can provide, I have to tear it down and rebuild it.

I might still have a use for StatusEndJob and StatusChainJob, but at least as far as the thrust sequence is concerned, it’s time I cut my losses.

Now, what does this system have to do? We can still use the windup/cooldown components to track state, but we’ll have more to do a little at the transition points. Here’s what I ended up creating…

using Unity.Entities;
using Unity.Burst;
using Latios;

[BurstCompile]
partial struct ThrustSequenceSystem : ISystem
{
    LatiosWorldUnmanaged _latiosWorld;

    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        _latiosWorld = state.GetLatiosWorldUnmanaged();
    }

    [BurstCompile]
    public void OnDestroy(ref SystemState state)
    {

    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        Level level =
            _latiosWorld
                .sceneBlackboardEntity
                .GetComponentData<Level>();

        float turnSmallToActiveTransition =
            level.TurnSmallDuration - level.ThrustWindup;

        var ecbSystem =
            SystemAPI
                .GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();

        new ThrustWindupToActiveJob
            { Time = SystemAPI.Time.ElapsedTime
            , WindupDuration = level.ThrustWindup
            , TransitionDuration = turnSmallToActiveTransition
            , ECB =
                ecbSystem
                    .CreateCommandBuffer(state.WorldUnmanaged)
                    .AsParallelWriter()
            }
            .Schedule();

        new ThrustToFlightJob
            { Time = SystemAPI.Time.ElapsedTime
            , ActiveThrustDuration = level.ThrustDuration
            , TransitionDuration = level.AfterThrustTransition
            , ECB =
                ecbSystem
                    .CreateCommandBuffer(state.WorldUnmanaged)
                    .AsParallelWriter()
            }
            .Schedule();
    }
}

partial struct ThrustWindupToActiveJob : IJobEntity
{
    public double Time;
    public float WindupDuration;
    public float TransitionDuration;

    public EntityCommandBuffer.ParallelWriter ECB;

    void Execute
        ( in ThrustWindup windup
        , Entity entity
        , [ChunkIndexInQuery] int chunkIndex
        )
    {
        if ((float) (Time - windup.TimeCreated) > WindupDuration)
        {
            ECB.RemoveComponent<ThrustWindup>(chunkIndex, entity);
            ECB.AddComponent<ThrustActive>(chunkIndex, entity); //<- mistake!
            ECB.AddComponent
                ( chunkIndex
                , entity
                , new AnimationTransition
                    { NextIndex = AnimationClipIndex.Thrust
                    , Start = (float) Time
                    , Duration = TransitionDuration
                    , Looping = true
                    }
                );
        }
    }
}

partial struct ThrustToFlightJob : IJobEntity
{
    public double Time;
    public double ActiveThrustDuration;
    public float TransitionDuration;

    public EntityCommandBuffer.ParallelWriter ECB;

    void Execute
        ( in ThrustActive active
        , Entity entity
        , [ChunkIndexInQuery] int chunkIndex
        )
    {
        if ((float) (Time - active.TimeCreated) > ActiveThrustDuration)
        {
            ECB.RemoveComponent<ThrustActive>(chunkIndex, entity);
            ECB.AddComponent
                ( chunkIndex
                , entity
                , new AnimationTransition
                    { NextIndex = AnimationClipIndex.LevelFlight
                    , Start = (float) Time
                    , Duration = TransitionDuration
                    , Looping = true
                    }
                );
        }
    }
}

There’s nothing really complicated here. When the time expires, we chop off one component and add a couple of others.

The new Turn Small Up

We need to populate this with some animations. I created a new version of the ‘turn small up’ animation which transitions into a thrust animation with a bit of anticipation, shown here with a sort of mockup of what the root motion and camera would be like in-game…

And here’s a version with the root motion removed, i.e. the part that will be applied using the animation system…

I also created a an animation to play during the actual thrust.

After importing this into Unity and setting up the relevant components, I gave it a shot. The result was, uh. Interesting?

At a glance… I have no idea what’s going on here.

I can make a few observations though. The doll gains hardly any velocity, so it seems that ThrustToFlightJob is triggering way too early. And something crazy is happening with the animation blending.

The first issue turned out to be a silly oversight: when I create the ThrustActive component, I need to set the TimeCreated field to the current time! Whoops.

The second issue turned out to be… I had accidentally used the same weight for both the current and next clip so my weights are not normalised and in fact gradually go to zero. OK, yeah, I can see why that might fling everything into space lmao.

Once I fixed these issues… it worked. And it actually looks… fantastic! Suddenly we have the prized ‘illusion of life’…

I was starting to doubt that this game was ever going to feel good but suddenly, suddenly, it seems to be working!

The other rotations

Of course, this animation doesn’t really work if you turn down, or to the sides, or make a large turn but the idea comes across. So now it’s time to animate the rest of the ‘turn small’ family!

Now we need to pick which one we’re going to play based on which way we’re turning. We can determine which quadrant we’re in by taking the dot product of the acceleration vector with unit vectors in the doll’s local x and y directions. It looks like this… (yes, there’s a simpler way to do this, hold your horses!)

float3 localX =
    math.mul
        ( rotation.Value
        , new float3 (1f, 0f, 0f)
        );

float3 localY =
    math.mul
        ( rotation.Value
        , new float3 (0f, 1f, 0f)
        );

float aX =
    math.dot
        ( acceleration
        , localX
        );
float aY =
    math.dot
        ( acceleration
        , localY
        );

AnimationClipIndex clipToPlay =
    math.abs(aX) > math.abs(aY)
        ? aX > 0
            ? AnimationClipIndex.TurnSmallRight
            : AnimationClipIndex.TurnSmallLeft
        : aY > 0
            ? AnimationClipIndex.TurnSmallUp
            : AnimationClipIndex.TurnSmallDown;

This works! I had a small issue where even though I had disabled the root motion by disabling the ‘Object Transforms’ channels on the Actions in Blender, the disabled keyframes were still affecting the duration of the clip. All that really meant in the end was that I had to shave off 31-32 frames from the beginning of the clip in Unity’s import settings.

Here’s what it looks like inside Unity.

This is feeling pretty good, at least on an animation level. We’ll see how this half-second windup feels in gameplay with obstacles to avoid etc. later.

Big turns

The ‘turn small’ family will work pretty well in the forwards pointing cone—the exact limit to be determined. But for more extreme rotations, it will look unnatural.

For a direct turn backwards, the turn will be something similar to an Immelmann turn in aerobatics, or a tumble turn in swimming. Essentially we do a half-somersault to turn our pitch 180 degrees, and then do a roll to revert the ‘up’ direction. Here’s a Blender version without any actual skeleton animation, just the root motion…

So, within the backwards-pointing cone, we want to override the current slerp rotation and always turn using this method. This can be computed by finding the intended quaternion for the 180 rotation, slerping from zero to that quaternion, and then premultiplying it with the initial orientation. Then we can further slerp whatever results from this with the target orientation.

Hehe, slerp.

So that might look something like…

[WithAll(typeof(Thrust))]
partial struct ThrustAlignmentImmelmannJob : IJobEntity
{
    public double Time;
    public float Duration;

    void Execute(in ThrustWindup windup, ref Rotation rotation)
    {
        float amount = 
            ThrustDoll
                .Util
                .BezierComponent
                    ( 0f
                    , 1.1f
                    , (float)(Time - windup.TimeCreated) / Duration
                    );

        quaternion flipRotation =
            math.mul
                ( windup
                    .InitialRotation
                , math.slerp
                    ( quaternion.identity
                    , quaternion.RotateX(math.PI)
                    , amount
                    )
                );

        rotation.Value =
            math.slerp
                ( flipRotation
                , windup.TargetRotation
                , amount
                );
    }
}

But wait. That last slerp is going to blend the roll into the flip, resulting in a weird diagonal turn. We want the roll to happen after the flip, during the thrust animation itself. So somehow we want to align the vector with the camera but not apply the roll.

Instead of copying the camera rotation quaternion, we can use LookRotationSafe to generate a quaternion aligned with a target direction with an arbitrary ‘up’ vector. So in ThrustStartSystem, I can generate LookRotationSafe as the target rotation…

We need some way to track what type of rotation we’re going to perform. This is related to the animation clip enum, but there are fewer cases to consider. We could either have different components for different types of rotation and schedule different jobs for different types, or store an enum for the rotation type and include a branch in the logic that computes the rotation.

Since the logic has some extra elements, I figure it would make sense to make it a new component. Now, let’s look at the code that picks which rotation type we’re going to use. We’ve been transforming the unit vectors \(\hat{\mathbf{x}}\), \(\hat{\mathbf{y}}\) into the local space, and then taking the dot product with the acceleration to find its components in the doll’s frame. But that just amounts to rotating the acceleration vector by the inverse of the doll’s multiplication matrix… but we don’t even need to find the matrix, the quaternion will do just fine.

quaternion toLocalSpace = math.inverse(rotation.Value);

float3 accelLocal =
    math.mul(toLocalSpace, math.normalize(acceleration));

AnimationClipIndex clipToPlay =
    math.abs(accelLocal.x) > math.abs(accelLocal.y)
        ? accelLocal.x > 0
            ? AnimationClipIndex.TurnSmallRight
            : AnimationClipIndex.TurnSmallLeft
        : accelLocal.y > 0
            ? AnimationClipIndex.TurnSmallUp
            : AnimationClipIndex.TurnSmallDown;

Suppose we want the forwards \(45°\) cone to use the ‘turn small’ animation set, the backwards \(45°\) cone to use the ‘turn reverse’ animation, and some other animation for the ring inbetween. We can do this by using the z component of accelLocal. If it’s greater than \(\cos 45°=\frac{1}{\sqrt{2}}\) then it’s turn-small territory, if it’s less than \(-\frac{1}{\sqrt{2}}\) then it’s turn-reverse territory.

if ( accelLocal.z > 1f/math.SQRT2) {    
    AnimationClipIndex clipToPlay =
        math.abs(accelLocal.x) > math.abs(accelLocal.y)
            ? accelLocal.x > 0
                ? AnimationClipIndex.TurnSmallRight
                : AnimationClipIndex.TurnSmallLeft
            : accelLocal.y > 0
                ? AnimationClipIndex.TurnSmallUp
                : AnimationClipIndex.TurnSmallDown;

    ECB.AddComponent
        ( chunkIndex
        , player
        , new ThrustWindupSmall
            { TimeCreated = Time
            , InitialRotation = rotation.Value
            , TargetRotation = CameraRotation
            }
        );

    ECB.AddComponent
        ( chunkIndex
        , player
        , new AnimationTransition
            { NextIndex = clipToPlay
            , Start = (float) Time
            , Duration = TurnSmallTransitionIn
            , Looping = false
            }
        );
} else {
    ECB.AddComponent
        ( chunkIndex
        , player
        , new ThrustWindupReverse
            { TimeCreated = Time
            , InitialRotation = rotation.Value
            , BackRotation =
                quaternion
                    .LookRotationSafe
                        ( acceleration
                        , -accelLocal.y //<- this is wrong...
                        )
            , TargetRotation = CameraRotation
            }
        );
}

Now we need to go take a look at ThrustSequenceSystem to make it handle the new rotation type. We want the thrust to start when the doll is aligned with the BackRotation, but after that we need to keep rotating in order to roll to the TargetRotation. This can be accomplished by assigning a new ThrustWindupSmall at the same time as ThrustActive, so we can reuse the existing system and job instead of creating an essentially identical one.

The only wrinkle is that we don’t want ThrustWindupSmallToActiveJob to overwrite the existing ThrustActive when it removes the ThrustWindupSmall. A slightly hacky way to solve might be to add a bool on ThrustWindup to say whether to add a ThrustActive after.

But… maybe I’m overcomplicating this with all these jobs? Maybe there should be only one job in ThrustSequenceSystem, and the only time that matters is the time that Thrust was set? This might make the logic of the sequence a lot clearer and we can store less data on components, make fewer queries, etc.

Spoiler alert: not a great idea

To make this work in one job, I thought the easiest way may be to store a couple of enums on ThrustSequence that record the type of sequence we’re pursuing, and the current stage of the system. This could also be found out by querying the other components on the player (which is essentially what the jobs do), but that would entail creating various ComponentLookups, and updating them each frame, and this all seems like unnecessary work compared to storing an enum. Plus we can potentially use that enum in drawing interface elements.

Perhaps you can guess why this was a dead end. Yeah, it turns out I need data from the ThrustWindupReverse in order to create the subsequent transient rotation. Which means it needs to be part of the entity query. Which means the job needs it in the type signature of its Execute function. Which means it can only run if a component has a ThrustWindup or ThrustWindupSmall. Which means the job won’t run after we’ve removed that component. So there’s no way I can do all the sequencing in one job.

The big chained ‘if’ statement I was creating wasn’t looking much easier to follow than the jobs, if I’m honest.

For better clarity about what behaviour is associated with them, I decided to rename ThrustWindupSmall to ThrustRotation and ThrustWindupReverse to ThrustFlip. And I can finally arrive at these two jobs…

partial struct ThrustRotationEndJob : IJobEntity
{
    public double Time;
    public float RotationDuration;
    public float TransitionDuration;

    public EntityCommandBuffer.ParallelWriter ECB;

    void Execute
        ( in ThrustRotation rotation
        , Entity entity
        , [ChunkIndexInQuery] int chunkIndex
        )
    {
        if (((float) (Time - rotation.TimeCreated)) > RotationDuration)
        {
            ECB.RemoveComponent<ThrustRotation>(chunkIndex, entity);
            if (rotation.BeforeActive) {
                ECB.AddComponent
                    ( chunkIndex
                    , entity
                    , new ThrustActive
                        { TimeCreated = Time
                        }
                    );
            }
            ECB.AddComponent
                ( chunkIndex
                , entity
                , new AnimationTransition
                    { NextIndex = AnimationClipIndex.Thrust
                    , Start = (float) Time
                    , Duration = TransitionDuration
                    , Looping = true
                    }
                );
        }
    }
}

partial struct ThrustFlipEndJob : IJobEntity
{
    public double Time;
    public float ReverseDuration;
    public float TransitionDuration;

    public EntityCommandBuffer.ParallelWriter ECB;

    void Execute
        ( in ThrustFlip reverse
        , Entity entity
        , [ChunkIndexInQuery] int chunkIndex
        )
    {
        if (((float) (Time - reverse.TimeCreated)) > ReverseDuration)
        {
            ECB.RemoveComponent<ThrustFlip>(chunkIndex, entity);
            ECB.AddComponent<ThrustRotation>
                ( chunkIndex
                , entity
                , new ThrustRotation
                    { TimeCreated = Time
                    , InitialRotation = reverse.BackRotation
                    , TargetRotation = reverse.TargetRotation
                    , BeforeActive = false
                    }
                );
            ECB.AddComponent
                ( chunkIndex
                , entity
                , new ThrustActive
                    { TimeCreated = Time
                    }
                );
        }
    }
}

In testing, this does essentially the correct behaviour: the doll flips over, then rolls while initiating the thrust. However, the flip isn’t consistently around the axis I want.

The reason appears to be because we’re blending it with the BackRotation quaternion? Turns out LookAt isn’t working like I thought it would. Hmm.

BackRotation =
    quaternion
        .LookRotationSafe
            ( acceleration
            , -accelLocal.y
            )

Wait, is accelLocal.y really the axis I’m looking for? …that’s a scalar not a vector, I shouldn’t even be able to pass that into the LookRotationSafe function! Is it silently casting it to a vector, which would duplicate its three values? Bizarre.

What we actually want is… the ‘-y’ direction in the local space, which can be found by rotating \((0,-1,0)\) with the current rotation quaternion.

BackRotation =
    quaternion
        .LookRotationSafe
            ( acceleration
            , math.mul(rotation.Value, new float3 (0, -1, 0))
            )

With that change, this works pretty good, but there is curiously an awkward pause between the two rotations. This may be caused by the Bèzier interpolation. Adjusting the parameters helped a bit.

Now the root motion is solved for this case, the next stage is to animate a corresponding motion in Blender. We have about a second to play with in total, plus a bit of extra time where it blends into ‘thrust’.

Here’s what I came up with with the one second constraint…

So, let’s get this into Unity… after going through the usual importing steps, I tried it, and it looks fantastic. I wasn’t sure if it would be too fast, but it actually feels just right to make this sort of mad somersault before going in another direction.

There is one missing element from the reverse thrust manoeuvre. That is to increase the drag briefly, to essentially cancel out whatever speed the player was currently going before applying thrust.

I will store the previous value of drag on ThrustFlip. When ThrustFlip gets removed, we write that cached value of drag back to the Drag component. It’s as simple as…

partial struct ThrustStartJob : IJobEntity
{
    //other fields
    public float IncreasedDrag;  //read from Level

    void Execute
        ([ChunkIndexInQuery] int chunkIndex
        , Entity player
        , in Rotation rotation
        , ref Drag drag
        )
    { //etc. etc.

    if (/* reverse case*/)
    {
        ECB.AddComponent
            ( /*...*/
            , new ThrustFlip
                { /*...*/
                , PreviousDrag = drag.Coefficient
                }
            )

        Drag.Coefficient = IncreasedDrag;
    }
    // and so on
}

and similarly

partial struct ThrustFlipEndJob : IJobEntity
{
    void Execute
        ( in ThrustFlip flip
        , ref Drag drag
        , Entity entity
        , [ChunkIndexInQuery] int chunkIndex
        )
    {
        if (/*removing the component*/)
        {
            /* various ECB stuff*/

            Drag.Coefficient = flip.PreviousDrag;
        }
    }

With some testing, I found a value around 1 worked pretty well (when the default drag is 0.005)—large enough to rapidly achieve a halt, but not so rapid that it feels like you suddenly slammed into a brick wall.

Now, then, here are all the systems implemented in this post active in Unity…

The actual thrust feels rather weak in this demo, but we can tune those parameters later.

Those cubes are instantiated using a simple system:

System that makes cubes
using Unity.Entities;
using Unity.Burst;
using Unity.Collections;
using Unity.Transforms;
using Unity.Mathematics;

[BurstCompile]
partial struct CubeGridSystem : ISystem
{
    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {

    }

    [BurstCompile]
    public void OnDestroy(ref SystemState state)
    {

    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        int numCubesX = 50;
        int numCubesZ = 50;

        var level = SystemAPI.GetSingleton<Level>();

        Entity cubePrefab = level.CubePrefab;

        var cubes = new NativeArray<Entity>(numCubesX * numCubesZ, Allocator.TempJob);

        EntityCommandBuffer ECB =
            SystemAPI
                .GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>()
                .CreateCommandBuffer(state.WorldUnmanaged);

        ECB.Instantiate(cubePrefab, cubes);

        for (int i = 0; i < numCubesX; ++i)
        {
            for (int j = 0; j < numCubesZ; ++j)
            {
                ECB.SetComponent
                    ( cubes[i*numCubesX+j]
                    , new Translation
                        { Value = new float3 (-250f+i * 10f, 0f, -250f+j * 10f)
                        }
                    );
            }
        }

        state.Enabled = false;
    }
}

which generates 2500 cubes on the first frame and then turns itself off. The performance impact of all these cubes is, as expected, negligible.

So what’s next? I have a few more animation cases to cover. At the end of that video you can see the ‘reverse turn’ animation running for a 90-degree turn. It looks weird, since the doll flips upside down needlessly, but it almost looks like it could work. It’s worth considering whether we might not need to create more animations if a change to the rotation logic would do.

The next big puzzle to solve is terrain collisons. I’ve got some ideas of accomplishing this using signed distance fields and raymarching, since I was already considering designing the organic shapes in my levels using signed distance fields. Essentially I could raymarch the signed distance field starting from the player origin—then if the nearest surface is closer than the radius of a notional sphere collider, I could detect a collision. How expensive this is would depend on the complexity of the SDF. The gradient of the field would give the normal. I could even render the SDF with raymarching, although it may be more efficient to generate a mesh instead.

To begin with, though, I’ll just use regular colliders and detect collisions with Psyshock. More on that next time!

I only have a couple of weeks left on the Mastered game development course. I definitely won’t stop development on THRUST//DOLL after that, but it is urgent to get some kind of playable proof of concept working in that time. So, I’ve got my work cut out for me…

Comments

Add a comment
[?]