21 min read
Making a Survivors-like with Latios Framework Part 3

Goals:

  • Have the character play locomotion animations based on the movement direction.
  • Have a crosshair that follows the mouse position.
  • Have the character rotate towards the mouse position.
  • Have the character throw an axe in the direction of the mouse position.

Introducing : “Code Accordions”!

Character Animations

In this part, I will add to the character the ability to play movement animations. For now, since I’ve implemented the basic movement, I’ll make a FourDirectionClipStatescomponent that wil hold… Five animations: Idle, left, right, forward and backward.

It will use a similar approach as in Part 1 with the help of SmartBlobbers.

Authoring

First, I’m creating a struct with five AnimationClipProperty.

  #region Animation Authoring Structs

  [Serializable]
  public struct FourDirAnimations
  {
      public AnimationClipProperty center;
      public AnimationClipProperty down;
      public AnimationClipProperty up;
      public AnimationClipProperty left;
      public AnimationClipProperty right;

      public bool IsMissingAnimations()
      {
          return center.clip == null || down.clip == null || up.clip == null || left.clip == null || right.clip == null;
      }
  }

  #endregion

The IsMissingAnimations method is used to avoid having constant errors in the editor when the clips are not assigned.

Next, I will need a component to store the actual animations and one that will track their ClipState. On top of that, I’ll be using an enum to refer to the clips’ indices.

    public struct Clips : IComponentData
    {
        public BlobAssetReference<SkeletonClipSetBlob> ClipSet;
    }

    public struct FourDirectionClipStates : IComponentData
    {
        public ClipState Center;
        public ClipState Down;
        public ClipState Up;
        public ClipState Left;
        public ClipState Right;
    }
    
    public enum EDirections
    {
        Center,
        Down,
        Up,
        Left,
        Right
    }

Now comes a big chunk of code to bake the animations into a blob.

  • FourDirectionsAnimationsAuthoring.cs
    namespace Survivors.Play.Authoring.Animations
    {
        public class FourDirectionsAnimationsAuthoring : MonoBehaviour
        {
            public FourDirAnimations animations;
    
            [TemporaryBakingType]
            struct AnimationClipsSmartBakeItem : ISmartBakeItem<FourDirectionsAnimationsAuthoring>
            {
                SmartBlobberHandle<SkeletonClipSetBlob> m_clipSetHandle;
    
                public bool Bake(FourDirectionsAnimationsAuthoring authoring,
                    IBaker baker)
                {
                    if (authoring.animations.IsMissingAnimations()) return false;
    
                    var entity = baker.GetEntity(TransformUsageFlags.Dynamic);
                    baker.AddComponent<Clips>(entity);
    
                    var clips = new NativeArray<SkeletonClipConfig>(5, Allocator.Temp);
    
                    for (var i = 0; i < 5; i++)
                    {
                        AnimationClip clip = null;
    
                        switch (i)
                        {
                            case (int)EDirections.Center:
                                clip = authoring.animations.center.clip;
    
                                break;
                            case (int)EDirections.Down:
                                clip = authoring.animations.down.clip;
    
                                break;
                            case (int)EDirections.Up:
                                clip = authoring.animations.up.clip;
    
                                break;
                            case (int)EDirections.Left:
                                clip = authoring.animations.left.clip;
    
                                break;
                            case (int)EDirections.Right:
                                clip = authoring.animations.right.clip;
    
                                break;
                        }
    
    
                        clips[i] = new SkeletonClipConfig
                        {
                            clip     = clip,
                            settings = SkeletonClipCompressionSettings.kDefaultSettings
                        };
                    }
    
    
                    m_clipSetHandle = baker.RequestCreateBlobAsset(baker.GetComponent<Animator>(), clips);
    
                    return true;
                }
    
                public void PostProcessBlobRequests(EntityManager entityManager,
                    Entity entity)
                {
                    var clipSet = m_clipSetHandle.Resolve(entityManager);
                    entityManager.SetComponentData(entity, new Clips { ClipSet = clipSet });
                }
            }
    
    
            class ClipBaker : SmartBaker<FourDirectionsAnimationsAuthoring, AnimationClipsSmartBakeItem> { }
    
            class FourDirectionsAnimationsAuthoringBaker : Baker<FourDirectionsAnimationsAuthoring>
            {
                public override void Bake(FourDirectionsAnimationsAuthoring authoring)
                {
                    var entity = GetEntity(TransformUsageFlags.Dynamic);
    
                    var clipStates = new FourDirectionClipStates
                    {
                        Center = new ClipState { SpeedMultiplier = authoring.animations.center.speedMultiplier },
                        Down   = new ClipState { SpeedMultiplier = authoring.animations.down.speedMultiplier },
                        Up     = new ClipState { SpeedMultiplier = authoring.animations.up.speedMultiplier },
                        Left   = new ClipState { SpeedMultiplier = authoring.animations.left.speedMultiplier },
                        Right  = new ClipState { SpeedMultiplier = authoring.animations.right.speedMultiplier }
                    };
    
                    AddComponent(entity, clipStates);
                }
            }
        }
    }
    

Nothing special in the Bake method, just creating a NativeArray of SkeletonClipConfig and passing it to the SmartBlobberHandle. The PostProcessBlobRequests method is where I set the ClipSet to the Clips component.

Further down, the FourDirectionsAnimationsAuthoringBaker is where I set the FourDirectionClipStates component with the speed multipliers.

Animation System

I’ll just remove the IdleClipAuthoring and IdleClipSystem classes since they are not needed anymore. In place of IdleClipSystem’s registration, I’ll add a new system that will handle the FourDirectionClipStates component.

Here, I’m splitting the code in two parts.

  • FourDirectionsAnimationSystem.cs
    namespace Survivors.Play.Systems.Animations
    {
        [RequireMatchingQueriesForUpdate]
        public partial struct FourDirectionsAnimationSystem : ISystem
        {
            EntityQuery m_query;
    
            [BurstCompile]
            public void OnCreate(ref SystemState state)
            {
                m_query = state.Fluent()
                    .WithAspect<OptimizedSkeletonAspect>()
                    .With<Clips>()
                    .With<FourDirectionClipStates>()
                    .With<RigidBody>()
                    .Without<DeadTag>()
                    .Build();
            }
    
            [BurstCompile]
            public void OnUpdate(ref SystemState state)
            {
                state.Dependency = new AnimationJob
                {
                    DeltaTime = SystemAPI.Time.DeltaTime
                }.ScheduleParallel(m_query, state.Dependency);
            }
        
         // IJobEntity definition goes here
        }
    }

A system with a BIG query

  • FourDirectionsAnimationSystem.cs
    [WithNone(typeof(DeadTag))]
    [BurstCompile]
    internal partial struct AnimationJob : IJobEntity
    {
        [ReadOnly] public float DeltaTime;
    
        public void Execute(
            OptimizedSkeletonAspect skeleton,
            in WorldTransform worldTransform,
            in RigidBody rigidBody,
            in Clips clips,
            in PreviousVelocity previousVelocity,
            ref FourDirectionClipStates clipStates,
            ref InertialBlendState inertialBlendState
        )
        {
    
            // Get Local Velocity
            var velocity =  math.mul(math.inverse(worldTransform.rotation), rigidBody.velocity.linear);
            var magnitude = math.length(velocity);
            var rotatedVelocity = math.normalizesafe(velocity);
    
            // Calculate blend weights
            var centerWeight = math.max(0, 1f - magnitude * 2f); // Idle when not moving
            centerWeight = math.clamp(centerWeight, 0f, 1f);
    
            var upWeight = math.smoothstep(0.0f, 0.5f, rotatedVelocity.z);
            var downWeight = math.smoothstep(0.0f, 0.5f, -rotatedVelocity.z);
            var rightWeight = math.smoothstep(0.0f, 0.5f, rotatedVelocity.x);
            var leftWeight = math.smoothstep(0.0f, 0.5f, -rotatedVelocity.x);
    
            // Normalize directional weights (excluding center)
            var directionalSum = upWeight + downWeight + leftWeight + rightWeight;
    
            if (directionalSum > math.EPSILON)
            {
                var normalizer = (1f - centerWeight) / directionalSum;
                upWeight    *= normalizer;
                downWeight  *= normalizer;
                leftWeight  *= normalizer;
                rightWeight *= normalizer;
            }
    
    
            // Update and sample animations
            UpdateClipState(ref clipStates.Center, ref clips.ClipSet.Value.clips[(int)EDirections.Center], DeltaTime, centerWeight);
            UpdateClipState(ref clipStates.Up, ref clips.ClipSet.Value.clips[(int)EDirections.Up], DeltaTime, upWeight);
            UpdateClipState(ref clipStates.Down, ref clips.ClipSet.Value.clips[(int)EDirections.Down], DeltaTime, downWeight);
            UpdateClipState(ref clipStates.Left, ref clips.ClipSet.Value.clips[(int)EDirections.Left], DeltaTime, leftWeight);
            UpdateClipState(ref clipStates.Right, ref clips.ClipSet.Value.clips[(int)EDirections.Right], DeltaTime, rightWeight);
    
            // Sample animations
            SampleAnimation(ref skeleton, ref clips.ClipSet.Value.clips[(int)EDirections.Center], clipStates.Center, centerWeight);
            SampleAnimation(ref skeleton, ref clips.ClipSet.Value.clips[(int)EDirections.Up], clipStates.Up, upWeight);
            SampleAnimation(ref skeleton, ref clips.ClipSet.Value.clips[(int)EDirections.Down], clipStates.Down, downWeight);
            SampleAnimation(ref skeleton, ref clips.ClipSet.Value.clips[(int)EDirections.Left], clipStates.Left, leftWeight);
            SampleAnimation(ref skeleton, ref clips.ClipSet.Value.clips[(int)EDirections.Right], clipStates.Right, rightWeight);
    
            // Finish sampling
            skeleton.EndSamplingAndSync();
        }
    
        static void UpdateClipState(ref ClipState state,
            ref SkeletonClip clip,
            float deltaTime,
            float weight)
        {
            if (weight > math.EPSILON)
            {
                state.Update(deltaTime * state.SpeedMultiplier);
                state.Time = clip.LoopToClipTime(state.Time);
            }
        }
    
        static void SampleAnimation(ref OptimizedSkeletonAspect skeleton,
            ref SkeletonClip clip,
            ClipState state,
            float weight)
        {
            if (weight > math.EPSILON) clip.SamplePose(ref skeleton, state.Time, weight);
        }
    }

A Big Job

Big Job

Another Big Job

Result

I don’t like the way the character transition to the idle animation. Thankfully, Kinemation has an implementation of “Inertial Blending”.

Inertial Blending is pretty neat for smoothly transitioning between animations. Especially when the two animations have dramatic differences. Like when transitioning from running to idle.

Here is a link to a GDC talk by Gears of War 4 developers presenting it: Inertialization: High-Performance Animation Transitions in Gears of War.

Inertial Blending

To make it work, Kinemation’s OptimizedSkeletonAspect has two “methods” :

  • StartNewInertialBlend(float previousDeltaTime, float maxBlendDurationStartingFromTimeOfPrevious) : Starts a new inertial blend.
  • InertialBlend(float timeSinceStartOfBlend) : Updates the blend with the time since it started.

I’ll need a component that stores the blend duration and a velocity change threshold to start the blend. I’ll use the same component to track the elapsed time in inertialization and stores the previous delta time before the call to StartNewInertialBlend.

public struct InertialBlendState : IComponentData
{
    public readonly float Duration;
    public readonly float VelocityChangeThreshold;
    public float TimeInCurrentState;
    public float PreviousDeltaTime;

    public InertialBlendState(float duration,
        float velocityChangeThreshold)
    {
        Duration                = duration;
        VelocityChangeThreshold = velocityChangeThreshold;
        TimeInCurrentState      = 0f;
        PreviousDeltaTime        = 0f;
    }
}

To check for velocity changes, I’m adding a PreviousVelocitycomponent that will be updated in the PlayerMovementSystem, assigning it the RigidBody’s linear velocity just before I change it.

I’m adding two fields in FourDirectionsAnimationsAuthoringand assign them in the baker.

  • FourDirectionsAnimationsAuthoring.cs
    namespace Survivors.Play.Authoring.Animations
    {
        public class FourDirectionsAnimationsAuthoring : MonoBehaviour
        {
    
            public FourDirAnimations animations;
            [SerializeField] float intertialBlendDuration = 0.15f;      
            [SerializeField] float velocityChangeThreshold = 0.1f;      
    
            /*
            * ISmartBakeItem implementation
            * [...]
            */
    
            class FourDirectionsAnimationsAuthoringBaker : Baker<FourDirectionsAnimationsAuthoring>
            {
                public override void Bake(FourDirectionsAnimationsAuthoring authoring)
                {
                    var entity = GetEntity(TransformUsageFlags.Dynamic);
                    var clipStates = new FourDirectionClipStates
                    {
                        Center = new ClipState { SpeedMultiplier = authoring.animations.center.speedMultiplier },
                        Down   = new ClipState { SpeedMultiplier = authoring.animations.down.speedMultiplier },
                        Up     = new ClipState { SpeedMultiplier = authoring.animations.up.speedMultiplier },
                        Left   = new ClipState { SpeedMultiplier = authoring.animations.left.speedMultiplier },
                        Right  = new ClipState { SpeedMultiplier = authoring.animations.right.speedMultiplier }
                    };
    
                    AddComponent(entity, clipStates);
       
                    AddComponent(entity,                            
                        new InertialBlendState(                     
                            authoring.intertialBlendDuration,       
                            authoring.velocityChangeThreshold));    
                }
            }
        }
    }

At the end of the AnimationJob, just before skeleton.EndSamplingAndSync() and after sampling the animations:

// Detect significant direction/movement change (for starting new blend)
var significantChange = math.abs( 
    math.length(velocity)  - math.length( previousVelocity.Value.xz)) > inertialBlendState.VelocityChangeThreshold;

// Start new inertial blend when movement changes significantly
if (significantChange)
{
    skeleton.StartNewInertialBlend(inertialBlendState.PreviousDeltaTime, inertialBlendState.Duration);
    inertialBlendState.TimeInCurrentState = 0f;
}


// Apply inertial blend with current time since blend started
if (inertialBlendState.TimeInCurrentState <= inertialBlendState.Duration)
{
    inertialBlendState.TimeInCurrentState += DeltaTime;
    skeleton.InertialBlend(inertialBlendState.TimeInCurrentState);
}


// Finish sampling
skeleton.EndSamplingAndSync();

inertialBlendState.PreviousDeltaTime = DeltaTime;

Subtle but nice!

More movements

Crosshair

I’m adding a new Canvas into the “PlayScene” without any Graphics Raycaster and dropping a crosshair sprite from Kenney’s “crosshair pack”.

Next, I’m adding a new serialized field in the PlayLifetimeScope and register the crosshair image for injection.

namespace Survivors.Play.Scope
{
    public struct MousePositionCommand : ICommand
    {
        public float2 MousePosition;
    }
}

This command will be used to notify the PlayStateRouter from the PlayerInputSystem

  • PlayerInputSystem.cs
    namespace Survivors.Play.Systems.Input
    {
        [RequireMatchingQueriesForUpdate]
        public partial class PlayerInputSystem : SubSystem
        {
            InputSystem_Actions m_inputActions;
            EntityQuery m_Query;
            UnityEngine.Camera m_mainCamera;
    
            ICommandPublisher m_commandPublisher;                       
    
            [Inject]                                                    
            public void Construct(ICommandPublisher commandPublisher)   
            {                                                           
                m_commandPublisher = commandPublisher;                  
            }                                                           
    
            /*
            * Other methods
            */
    
            protected override void OnUpdate()
            {
                var move = m_inputActions.Player.Move.ReadValue<Vector2>();
                if (math.length(move) > 0.01f) move = math.normalize(move);
    
                float2 mousePosition = m_inputActions.Player.MousePosition.ReadValue<Vector2>(); 
                m_commandPublisher.PublishAsync(new MousePositionCommand
                {   
                    MousePosition = mousePosition
                }); 
    
    
                sceneBlackboardEntity.SetComponentData(new PlayerInputState
                {
                    Direction = move
                });
            }
        }
    }

Updated PlayerInputSystem

I’m adding a similar command for the Router -> Controller communication.

    public struct MousePositionChangedCommand : ICommand
    {
        public float2 Position;
    }

… And a new Route to the Router …

    [Route]
    async UniTask On(MousePositionCommand cmd)
    {
        await commandPublisher.PublishAsync(new MousePositionChangedCommand
        {
            Position = cmd.MousePosition,
        });
    }

… A new subscription to the PlayStateController

    [Inject] Image m_crosshair;

    // -----------------

    // In Start()
    m_commandSubscribable.Subscribe<MousePositionChangedCommand>(OnMousePositionChanged)
        .AddTo(ref m_disposable);

    // -----------------

    void OnMousePositionChanged(MousePositionChangedCommand cmd,
        PublishContext ctx)
    {
        m_crosshair.rectTransform.position = new Vector3(cmd.Position.x, cmd.Position.y, 0);
    }

We now have a crosshair that follows the mouse position.

Crosshair

Character Rotation

First, I’ll had a property ro the PlayerInputState to store the mouse position in world space.

public struct PlayerInputState : IComponentData
{
    public float2 Direction;
    public float3 MousePosition;
}

Next, I’m updating the PlayerInputSystem: it grabs a reference to Camera.mainin OnCreate, then, before setting the component to the scene blackboard entity, I’ll cast a ray to a plane and get back the hit point in world space.


// PlayerInputSystem.cs

var ray = m_mainCamera.ScreenPointToRay(new float3(mousePosition.x, mousePosition.y, 0f));
var plane = new Plane(Vector3.up, Vector3.zero);

if (plane.Raycast(ray, out var enter))
{
    float3 hitPoint = ray.GetPoint(enter);
    inputState.MousePosition = new float3(hitPoint.x, 0f, hitPoint.z);
}

Before adding player rotation, here is a useful extension method for Unity.Mathematics.quaternion reimplemented from UnityEngine.Quaternion:

namespace Survivors.Utilities
{
    public static class QuaternionExtensions
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public static quaternion RotateTowards(this quaternion from,
            quaternion to,
            float maxDegreesDelta)
        {
            return math.slerp(from, to, math.radians(maxDegreesDelta));
        }
    }
}

Last step: updating PlayerMovementSystem to handle the rotation!

var lookDir = playerInputState.MousePosition - transformAspect.worldPosition;
var lookRotation = quaternion.LookRotationSafe(lookDir, math.up());
transformAspect.worldRotation = transformAspect.worldRotation.RotateTowards(lookRotation, movementSettings.ValueRO.maxAngleDelta * deltaTime);

Aaaaannnd… Done.

Rotation around the clock

Throwing stuff

Let’s throw some axes!

Some gif

Input

I’d like to have the character throw a BIG axe in the direction of the mouse position.

As a first step, let’s listen for the left click.

I need to modify the PlayerInputState

    public struct PlayerInputState : IComponentData
    {
        public float2 Direction;
        public float3 MousePosition;
        public bool AttackTriggered;    
    }

Then listen to the left click in the PlayerInputSystem:

  • PlayerInputSystem.cs
            protected override void OnCreate()
            {
                m_inputActions = new InputSystem_Actions();
                m_inputActions.Enable();
    
                m_Query = Fluent
                    .With<PlayerInputState>()
                    .Build();
    
                m_mainCamera = UnityEngine.Camera.main;
                
                m_inputActions.Player.Attack.performed += AttackPerformed; 
                
            }
    
            protected override void OnDestroy()
            {
                m_inputActions.Player.Attack.performed -= AttackPerformed; 
                m_inputActions.Disable();
            }
            
            void AttackPerformed(InputAction.CallbackContext _) 
            {   
                _attackTriggered = true; 
            } 
    
            protected override void OnUpdate()
            {
                var inputState = new PlayerInputState();
    
                var move = m_inputActions.Player.Move.ReadValue<Vector2>();
                if (math.length(move) > 0.01f) move = math.normalize(move);
    
                inputState.Direction = move;
    
                float2 mousePosition = m_inputActions.Player.MousePosition.ReadValue<Vector2>();
    
                m_commandPublisher.PublishAsync(new MousePositionCommand
                {
                    MousePosition = mousePosition
                });
    
    
                var ray = m_mainCamera.ScreenPointToRay(new float3(mousePosition.x, mousePosition.y, 0f));
                var plane = new Plane(Vector3.up, Vector3.zero);
    
                if (plane.Raycast(ray, out var enter))
                {
                    float3 hitPoint = ray.GetPoint(enter);
                    inputState.MousePosition = new float3(hitPoint.x, 0f, hitPoint.z);
                }
                
                inputState.AttackTriggered = _attackTriggered; 
    
                sceneBlackboardEntity.SetComponentData(inputState);
                
                _attackTriggered = false; 
            }

One shot animation

For the actual animation handling, I’ll create two new components:

    public struct ActionClipComponent : IComponentData
    {
        public BlobAssetReference<SkeletonClipSetBlob> ClipSet;
    }

    public struct ActionClipState : IComponentData
    {
        public ClipState ClipState;
    }

This will need some refactoring. Later…

I know I’ll need an “Aniamtion Event” to trigger the axe throw. Luckily, Kinemation can handle animation events.

Animation Event

Right here!

And here we go again with a new big blobber :

  • ActionAuthoring.cs
    namespace Survivors.Play.Authoring.Animations
    {
        public class ActionAuthoring : MonoBehaviour
        {
            [SerializeField] AnimationClipProperty actionClipProperty;
    
            [TemporaryBakingType]
            struct ActionClipSmartBakeItem : ISmartBakeItem<ActionAuthoring>
            {
                SmartBlobberHandle<SkeletonClipSetBlob> m_clipSetHandle;
    
                public bool Bake(ActionAuthoring authoring,
                    IBaker baker)
                {
                    if (!authoring.actionClipProperty.clip) return false;
    
                    var entity = baker.GetEntity(TransformUsageFlags.Dynamic);
                    baker.AddComponent<ActionClipComponent>(entity);
    
                    var clips = new NativeArray<SkeletonClipConfig>(1, Allocator.Temp);
                    var clip = authoring.actionClipProperty.clip;
    
                    clips[0] = new SkeletonClipConfig
                    {
                        clip     = clip,
                        settings = SkeletonClipCompressionSettings.kDefaultSettings,
                        events   = clip.ExtractKinemationClipEvents(Allocator.Temp)
                    };
    
                    m_clipSetHandle = baker.RequestCreateBlobAsset(baker.GetComponent<Animator>(), clips);
    
                    return true;
                }
    
                public void PostProcessBlobRequests(EntityManager entityManager,
                    Entity entity)
                {
                    var clipSet = m_clipSetHandle.Resolve(entityManager);
                    entityManager.SetComponentData(entity, new ActionClipComponent
                    {
                        ClipSet = clipSet
                    });
                }
            }
    
    
            class ActionClipBaker : SmartBaker<ActionAuthoring, ActionClipSmartBakeItem> { }
    
            class ActionAuthoringBaker : Baker<ActionAuthoring>
            {
                public override void Bake(ActionAuthoring authoring)
                {
                    var entity = GetEntity(TransformUsageFlags.Dynamic);
                    var clipEvents = authoring.actionClipProperty.clip.ExtractKinemationClipEvents(Allocator.Temp);
                    var clipState = new ActionClipState
                    {
                        ClipState = new ClipState
                        {
                            SpeedMultiplier = authoring.actionClipProperty.speedMultiplier,
                            EventHash = clipEvents.Length > 0
                                ? clipEvents[0].name.GetHashCode()
                                : -1,
                        }
                    };
    
                    AddComponent(entity, clipState);
                }
            }
        }
    }

I’m not proud of this…

Now, let’s try to create a system that will handle the action animation. I’ll leave the event handling for later.

  • PlayerActionSystem.cs
    namespace Survivors.Play.Systems.Player
    {
        public partial struct PlayerActionSystem : ISystem
        {
            LatiosWorldUnmanaged m_worldUnmanaged;
    
            [BurstCompile]
            public void OnCreate(ref SystemState state)
            {
                m_worldUnmanaged = state.GetLatiosWorldUnmanaged();
                state.RequireForUpdate<PlayerTag>();
            }
    
            [BurstCompile]
            public void OnUpdate(ref SystemState state)
            {
                var inpuState = m_worldUnmanaged.sceneBlackboardEntity.GetComponentData<PlayerInputState>();
    
                state.Dependency = new AnimationJob
                {
                    DeltaTime  = SystemAPI.Time.DeltaTime,
                    InputState = inpuState
                }.ScheduleParallel(state.Dependency);
            }
    
            [BurstCompile]
            public void OnDestroy(ref SystemState state) { }
    
    
            [BurstCompile]
            partial struct AnimationJob : IJobEntity
            {
                [ReadOnly] public float DeltaTime;
                [ReadOnly] public PlayerInputState InputState;
    
                void Execute(
                    OptimizedSkeletonAspect skeleton,
                    in ActionClipComponent actionClipComponent,
                    ref ActionClipState actionClipState)
                {
                    ref var axeThrowClip = ref actionClipComponent.ClipSet.Value.clips[0];
                    ref var axeThrowState = ref actionClipState.ClipState;
    
    
                    if (InputState.AttackTriggered || axeThrowState.PreviousTime < axeThrowState.Time)
                    {
                        axeThrowState.Update(DeltaTime * axeThrowState.SpeedMultiplier);
                        axeThrowState.Time = axeThrowClip.LoopToClipTime(axeThrowState.Time);
                        axeThrowClip.SamplePose(ref skeleton, axeThrowState.Time, 1f);
                        skeleton.EndSamplingAndSync();
                    }
                }
            }
        }
    }

Not throwing yet.

Avatar Masks

Wait. Wait, wait, wait. Is he sliding on the ground when attacking? Well, obviously. In a Game Object workflow you’d use an AvatarMask.

Luckily, again, Kinemation can handle it!

public struct AvatarMasks : IComponentData
{
    public BlobAssetReference<SkeletonBoneMaskSetBlob> Blob;
}

Yep. SkeletonBoneMaskSetBlobto the rescue!

Time for another big blobber!

  • AvatarMasksAuthoring.cs
    namespace Survivors.Play.Authoring.Animations
    {
        public class AvatarMasksAuthoring : MonoBehaviour
        {
            [SerializeField] List<AvatarMask> avatarMasks;
    
    
            [TemporaryBakingType]
            struct AvatarMasksSmartBakeItem : ISmartBakeItem<AvatarMasksAuthoring>
            {
                SmartBlobberHandle<SkeletonBoneMaskSetBlob> m_blobberHandle;
    
                public bool Bake(AvatarMasksAuthoring authoring,
                    IBaker baker)
                {
                    if (authoring.avatarMasks.Count == 0)
                        return false;
    
                    var entity = baker.GetEntity(TransformUsageFlags.Dynamic);
                    baker.AddComponent<AvatarMasks>(entity);
    
                    var masks = new NativeArray<UnityObjectRef<AvatarMask>>(authoring.avatarMasks.Count, Allocator.Temp);
                    for (var i = 0; i < authoring.avatarMasks.Count; i++) masks[i] = authoring.avatarMasks[i];
    
                    m_blobberHandle = baker.RequestCreateBlobAsset(baker.GetComponent<Animator>(), masks);
                    masks.Dispose();
    
                    return true;
                }
    
                public void PostProcessBlobRequests(EntityManager entityManager,
                    Entity entity)
                {
                    entityManager.SetComponentData(entity, new AvatarMasks
                    {
                        Blob = m_blobberHandle.Resolve(entityManager)
                    });
                }
            }
    
            class AvatarMasksBaker : SmartBaker<AvatarMasksAuthoring, AvatarMasksSmartBakeItem> { }
        }
    }

Not so big.

And a small modification to the AnimationJob to sample the pose with the mask:

        [BurstCompile]
        partial struct AnimationJob : IJobEntity
        {
            [ReadOnly] public float DeltaTime;
            [ReadOnly] public PlayerInputState InputState;

            void Execute(
                OptimizedSkeletonAspect skeleton,
                in ActionClipComponent actionClipComponent,
                ref ActionClipState actionClipState,
                ref AvatarMasks masks) 
            {
                ref var axeThrowClip = ref actionClipComponent.ClipSet.Value.clips[0];
                ref var axeThrowState = ref actionClipState.ClipState;
                
                if (InputState.AttackTriggered || axeThrowState.PreviousTime < axeThrowState.Time)
                {
                    axeThrowState.Update(DeltaTime * axeThrowState.SpeedMultiplier);
                    axeThrowState.Time = axeThrowClip.LoopToClipTime(axeThrowState.Time);
                    axeThrowClip.SamplePose(ref skeleton, axeThrowState.Time, 1f); 
                    axeThrowClip.SamplePose(ref skeleton, masks.Blob.Value.masks[(int)EAction.Throw].AsSpan(), axeThrowState.Time, 1f); 
                    skeleton.EndSamplingAndSync();
                }
            }
        }

Me Hacking! No sliding!

Event

I’ve already (in an ugly fashion) stored the event hash in the ActionClipState component. Here is how I’m handling it at the end of the AnimationJob:

    if (axeThrowClip.events.TryGetEventsRange(axeThrowState.PreviousTime, axeThrowState.Time, out var index, out var eventCount))
        for (var i = index; i < eventCount; i++)
        {
            var eventHash = axeThrowClip.events.nameHashes[i];

            if (eventHash == axeThrowState.EventHash) UnityEngine.Debug.Log("Event Triggered");
        }

It is working like I want it to. I can now figure out how to spawn the axe at the hand position and throw it in the direction of the mouse.

Storing a reference to another entity and spawning the axe prefab

To spawn an axe at the hand position, I’ll need to store a reference to the character’s (non-deforming) bone handslot.r. I’m adding an IEnableableComponent to the RightHandSlot. Enabling it will allow me to spawn the axe in a dedicated system.

namespace Survivors.Play.Authoring.Player.Actions
{
    public class RightHandSlotAuthoring : MonoBehaviour
    {
        [SerializeField] Transform rightHandSlot;

        class RightHandSlotAuthoringBaker : Baker<RightHandSlotAuthoring>
        {
            public override void Bake(RightHandSlotAuthoring authoring)
            {
                var entity = GetEntity(TransformUsageFlags.Dynamic);
                AddComponent(entity, new RightHandSlot
                {
                    RightHandSlotEntity = GetEntity(authoring.rightHandSlot, TransformUsageFlags.Dynamic)
                });
            }
        }
    }

    public struct RightHandSlot : IComponentData
    {
        public Entity RightHandSlotEntity;
    }

    public struct RightHandSlotThrowAxeTag : IComponentData, IEnableableComponent { }
}

Now, to the axe prefab.

Here is how I’m setting up the axe prefab:

Axe prefab setup

It is “rotated on the side” because I want it to be thrown like a frisbee.

Here is the authoring script. I’m leaving the ThrownWeaponComponentto its default values because it will be set inside a system. The ThrownWeaponConfigComponent is meant to be read only.

  • AxeAuthoring.cs
    namespace Survivors.Play.Authoring.Player.Weapons
    {
        public class AxeAuthoring : MonoBehaviour
        {
            [Header("Axe Config")] [SerializeField]
            float speed;
    
            [SerializeField] float rotationSpeed;
            [SerializeField] float3 rotationAxis;
    
            class AxeAuthoringBaker : Baker<AxeAuthoring>
            {
                public override void Bake(AxeAuthoring authoring)
                {
                    var entity = GetEntity(TransformUsageFlags.Dynamic);
                    AddComponent<ThrownWeaponComponent>(entity);
                    AddComponent(entity, new WeaponConfigComponent
                    (
                        authoring.speed,
                        authoring.rotationSpeed,
                        authoring.rotationAxis
                    ));
                }
            }
        }
    
        public struct ThrownWeaponComponent : IComponentData
        {
            public float Speed;
            public float RotationSpeed;
            public float3 Direction;
        }
    
        public struct ThrownWeaponConfigComponent : IComponentData
        {
            public readonly float Speed;
            public readonly float RotationSpeed;
            public readonly float3 RotationAxis;
    
            public ThrownWeaponConfigComponent(float speed,
                float rotationSpeed,
                float3 rotationAxis)
            {
                Speed         = speed;
                RotationSpeed = rotationSpeed;
                RotationAxis  = rotationAxis;
            }
        }
    }

In an excess of confidence I told myself that it would be smart to create a “Weapon Prefab Collection” and have the scene blackboard entity spawn them in a “spawn queue” in a reusable way.

In reality, it looks like premature over engineering 🥲 …

Anyway, I’ve created a BufferElement that will store a bunch of prefabs (just one, for now…) and an ICollectionComponent with a NativeQueue that will hold some “spawn data”.

    public struct PrefabBufferElement : IBufferElementData
    {
        public EntityWith<Prefab> Prefab;
    }

    public partial struct WeaponSpawnQueue : ICollectionComponent
    {
        public struct WeaponSpawnData
        {
            public EntityWith<Prefab> WeaponPrefab;
            public float3 Direction;
            public float3 Position;
        }

        public NativeQueue<WeaponSpawnData> WeaponQueue;

        public JobHandle TryDispose(JobHandle inputDeps)
        {
            if (!WeaponQueue.IsCreated)
                return inputDeps;

            return WeaponQueue.Dispose(inputDeps);
        }
    }

    // Ordered by damage power :D
    public enum EWeaponType
    {
        ThrowableAxe,
        BFG9000,
        Maroilles,
        Surströmming,
    }

Here comes the next steps:

  1. Filling the missing piece in PlayerActionSystem

    
        [BurstCompile]
        partial struct AnimationJob : IJobEntity
        {
            [ReadOnly] public float DeltaTime;
            [ReadOnly] public PlayerInputState InputState;
            public EntityCommandBuffer.ParallelWriter ECB;      
    
            void Execute(
                Entity entity,                                  
                [ChunkIndexInQuery] int chunkIndexInQuery,      
                OptimizedSkeletonAspect skeleton,
                in ActionClipComponent actionClipComponent,
                ref ActionClipState actionClipState,
                ref AvatarMasks masks)
            {
                ref var weaponThrowClip = ref actionClipComponent.ClipSet.Value.clips[0];
                ref var weaponThrowState = ref actionClipState.ClipState;
    
                if (InputState.AttackTriggered || weaponThrowState.PreviousTime < weaponThrowState.Time)
                {
                    weaponThrowState.Update(DeltaTime * weaponThrowState.SpeedMultiplier);
                    weaponThrowState.Time = weaponThrowClip.LoopToClipTime(weaponThrowState.Time);
                    weaponThrowClip.SamplePose(ref skeleton, masks.Blob.Value.masks[(int)EAction.Throw].AsSpan(), weaponThrowState.Time, 1f);
                    skeleton.EndSamplingAndSync();
                }
    
    
                if (weaponThrowClip.events.TryGetEventsRange(weaponThrowState.PreviousTime, weaponThrowState.Time, out var index, out var eventCount))
                    for (var i = index; i < eventCount; i++)
                    {
                        var eventHash = weaponThrowClip.events.nameHashes[i];
                        if (eventHash == axeThrowState.EventHash) UnityEngine.Debug.Log("Event Triggered"); 
                        if (eventHash == weaponThrowState.EventHash) ECB.SetComponentEnabled<RightHandSlotThrowTag>(chunkIndexInQuery, entity, true); 
                    }
            }
        }
  2. Add the axe to the spawn queue

    • WeaponThrowTriggerSystem
      namespace Survivors.Play.Systems.Player.Weapons.Initialization
      {
          [RequireMatchingQueriesForUpdate]
          public partial struct WeaponThrowTriggerSystem : ISystem
          {
              LatiosWorldUnmanaged m_worldUnmanaged;
              EntityQuery _rightHandQuery;
      
              [BurstCompile]
              public void OnCreate(ref SystemState state)
              {
                  m_worldUnmanaged = state.GetLatiosWorldUnmanaged();
      
                  _rightHandQuery = state.Fluent()
                      .WithEnabled<RightHandSlotThrowTag>()
                      .Build();
              }
      
              [BurstCompile]
              public void OnUpdate(ref SystemState state)
              {
                  if (_rightHandQuery.IsEmpty) return;
      
      
                  var mousePosition = m_worldUnmanaged.sceneBlackboardEntity.GetComponentData<PlayerInputState>().MousePosition;
                  var spawnQueue = m_worldUnmanaged.sceneBlackboardEntity.GetCollectionComponent<WeaponSpawnQueue>().WeaponQueue;
                  var prefab = m_worldUnmanaged.sceneBlackboardEntity.GetBuffer<PrefabBufferElement>()[(int)EWeaponType.ThrowableAxe].Prefab;
      
                  var ecb = m_worldUnmanaged.syncPoint.CreateEntityCommandBuffer();
      
                  foreach (var (transform, slot, entity) in
                          SystemAPI.Query<RefRO<WorldTransform>, RefRO<RightHandSlot>>()
                              .WithEntityAccess())
                  {
                      var rHandSlotTransform = SystemAPI.GetComponent<WorldTransform>(slot.ValueRO.RightHandSlotEntity);
      
                      var direction2d = math.normalizesafe(mousePosition.xz - rHandSlotTransform.position.xz);
                      var direction = new float3(direction2d.x, 0f, direction2d.y);
      
                      spawnQueue.Enqueue(new WeaponSpawnQueue.WeaponSpawnData
                      {
                          WeaponPrefab = prefab,
                          Position     = rHandSlotTransform.position,
                          Direction    = direction
                      });
      
      
                      ecb.SetComponentEnabled<RightHandSlotThrowTag>(entity, false);
                  }
              }
          }
      }
  3. Make a Weapon Spawn Queue System.

    namespace Survivors.Bootstrap.RootSystems
    {
        [UpdateInGroup(typeof(LatiosWorldSyncGroup))]
        public partial class SyncRootSystems : RootSuperSystem
        {
            protected override void CreateSystems()
            {
                GetOrCreateAndAddUnmanagedSystem<WeaponSpawnQueueSystem>();
            }
        }
    }

    SyncRootSystems.cs

    • WeaponSpawnQueueSystem.cs
      namespace Survivors.Play.Systems.Player.Weapons.Spawn
      {
          public partial struct WeaponSpawnQueueSystem : ISystem
          {
              LatiosWorldUnmanaged m_worldUnmanaged;
      
              [BurstCompile]
              public void OnCreate(ref SystemState state)
              {
                  m_worldUnmanaged = state.GetLatiosWorldUnmanaged();
              }
      
              [BurstCompile]
              public void OnUpdate(ref SystemState state)
              {
                  var spawnQueue = m_worldUnmanaged.sceneBlackboardEntity.GetCollectionComponent<WeaponSpawnQueue>().WeaponQueue;
                  var icb = m_worldUnmanaged.syncPoint
                      .CreateInstantiateCommandBuffer<ThrownWeaponComponent, WorldTransform>();
      
                  state.Dependency = new WeaponSpawnJob
                  {
                      SpawnQueue            = spawnQueue,
                      WeaponComponentLookup = SystemAPI.GetComponentLookup<ThrownWeaponConfigComponent>(true),
                      SpawnQueueWriter      = icb.AsParallelWriter()
                  }.Schedule(state.Dependency);
              }
      
              [BurstCompile]
              public void OnDestroy(ref SystemState state) { }
          }
      
      
          [BurstCompile]
          internal struct WeaponSpawnJob : IJob
          {
              public NativeQueue<WeaponSpawnQueue.WeaponSpawnData> SpawnQueue;
              [ReadOnly] public ComponentLookup<ThrownWeaponConfigComponent> WeaponComponentLookup;
              public InstantiateCommandBuffer<ThrownWeaponComponent, WorldTransform>.ParallelWriter SpawnQueueWriter;
      
              public void Execute()
              {
                  var sortKey = 0;
      
                  while (!SpawnQueue.IsEmpty())
                      if (SpawnQueue.TryDequeue(out var weapon))
                      {
                          var transform = new WorldTransform
                          {
                              worldTransform = TransformQvvs.identity
                          };
      
                          transform.worldTransform.position = weapon.Position;
                          transform.worldTransform.rotation = quaternion.LookRotation(weapon.Direction, math.up());
      
                          var config = WeaponComponentLookup[weapon.WeaponPrefab];
      
                          SpawnQueueWriter.Add(
                              weapon.WeaponPrefab, new ThrownWeaponComponent
                              {
                                  Direction     = weapon.Direction,
                                  Speed         = config.Speed,
                                  RotationSpeed = config.RotationSpeed,
                                  RotationAxis  = config.RotationAxis
                              }, transform,
                              ++sortKey);
                      }
              }
          }
      }

    WeaponSpawnQueueSystem.cs

    Moving the Axe !

    Now that the axe is spawned, I need to move it. I’ll create a new system that will handle the axe movement.

    I will keep it simple or this article will become a book.

namespace Survivors.Play.Systems.Player.Weapons.Physics
{
    public partial struct ThrownWeaponUpdateSystem : ISystem
    {
        LatiosWorldUnmanaged m_latiosWorldUnmanaged;

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

        [BurstCompile]
        public void OnUpdate(ref SystemState state)
        {
            var dcb = m_latiosWorldUnmanaged.syncPoint.CreateDestroyCommandBuffer();
            var collisionLayer = m_latiosWorldUnmanaged.sceneBlackboardEntity
                .GetCollectionComponent<EnvironmentCollisionLayer>().layer;
            state.Dependency = new ThrownWeaponUpdateJob
            {
                DeltaTime            = SystemAPI.Time.DeltaTime,
                EnvironmentLayer     = collisionLayer,
                DestroyCommandBuffer = dcb.AsParallelWriter()
            }.ScheduleParallel(state.Dependency);
        }

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

The System

[BurstCompile]
internal partial struct ThrownWeaponUpdateJob : IJobEntity
{
    public DestroyCommandBuffer.ParallelWriter DestroyCommandBuffer;
    [ReadOnly] public CollisionLayer EnvironmentLayer;
    [ReadOnly] public float DeltaTime;

    public void Execute(
        Entity entity,
        [EntityIndexInQuery] int entityIndexInQuery,
        ref WorldTransform transform,
        in ThrownWeaponComponent thrownWeapon,
        in Collider collider
    )
    {
        var transformQvs = transform.worldTransform;

        if (Latios.Psyshock.Physics.ColliderCast(
                in collider,
                in transformQvs,
                transform.position + thrownWeapon.Direction * thrownWeapon.Speed,
                in EnvironmentLayer,
                out var result,
                out _))
            switch (collider.type)
            {
                case ColliderType.Capsule:
                {
                    CapsuleCollider capsuleCollider = collider;

                    if (result.distance <= capsuleCollider.radius * 2f)
                    {
                        DestroyCommandBuffer.Add(entity, entityIndexInQuery);

                        return;
                    }


                    break;
                }
            }


        transform.worldTransform.position = transformQvs.position
                                            + thrownWeapon.Direction * thrownWeapon.Speed * DeltaTime;


        transform.worldTransform.rotation = math.mul(transformQvs.rotation, Quat.RotateAroundAxis(
            thrownWeapon.RotationAxis,
            thrownWeapon.RotationSpeed * DeltaTime));
    }
}

The Job

The ThrownWeaponUpdateJob is quite simple, it moves the axed in the direction that was given to the ThrownWeaponComponent and destroys the axe if it collides with the environment. It also rotates the axe around the RotationAxis at a speed defined in the ThrownWeaponConfigComponent.

Quat.RotateAroundAxis is a simple utility function that rotates the axe around the given axis. It is similar to quaternion.RotateX, quaternion.RotateY and quaternion.RotateZ but it takes a float3 as an axis.

public static class Quat
{
    public static quaternion RotateAroundAxis(float3 axis,
        float angle)
    {
        float sina, cosa;
        math.sincos(0.5f * angle, out sina, out cosa);

        axis = math.normalize(axis);    

        return math.quaternion(
            axis.x * sina,
            axis.y * sina,
            axis.z * sina,
            cosa);
    }
}

Small issue

With this code, the axe tends to often fo through wall without being destroyed. The issues are:

  • It is moving quite fast
  • It is rotating and the collider is a CapsuleCollider. Between two collision checks, the CapsuleCollider might have been rotated into an obstacle thus failing the ColliderCast.

So, I’m adding steps to the collision check (an arbitrary 4 steps). In each steps, I’m moving the initial transformQvvs position and rotation by a fraction (1/4) of the speed and rotation speed.

    var transformQvs = transform.worldTransform;
    var stepCount = 4;                                                      
    var steppedSpeed = thrownWeapon.Speed / stepCount;                      
    var steppedRotation = thrownWeapon.RotationSpeed / stepCount;           
    for (float i = 0; i < stepCount; i++)                                   
    {                                                                       
        if (Latios.Psyshock.Physics.ColliderCast(                           
        var collision = Latios.Psyshock.Physics.ColliderCast(               
            in collider,
            in transformQvs,
            transform.position + thrownWeapon.Direction * thrownWeapon.Speed,                   
            transformQvs.position + thrownWeapon.Direction * thrownWeapon.Speed * DeltaTime,    
            in EnvironmentLayer,
            out _,
            out _);

            switch (collider.type)                                              
            {                                                                   
                case ColliderType.Capsule:                                      
                {                                                               
                    CapsuleCollider capsuleCollider = collider;                 
                    if (result.distance <= capsuleCollider.radius * 2f)         
                    {                                                           
                        DestroyCommandBuffer.Add(entity, entityIndexInQuery);   
                        return;                                                 
                    }                                                           
                    break;                                                      
                }                                                               
            }                                                                   

        if (collision)                                                          
        {                                                                       
            DestroyCommandBuffer.Add(entity, entityIndexInQuery);               
            return;                                                             
        }                                                                       


        transformQvs.position += thrownWeapon.Direction * steppedSpeed * DeltaTime;     
        transformQvs.rotation = math.mul(transformQvs.rotation, Quat.RotateAroundAxis(  
            thrownWeapon.RotationAxis, steppedRotation * DeltaTime));                   
    }


    transform.worldTransform.position = transformQvs.position
                                    + thrownWeapon.Direction * thrownWeapon.Speed * DeltaTime; 


    transform.worldTransform.rotation = math.mul(transformQvs.rotation, Quat.RotateAroundAxis( 
        thrownWeapon.RotationAxis, 
        thrownWeapon.RotationSpeed * DeltaTime)) 

    transform.worldTransform = transformQvs;                

Conclusion

That was a big one and it does not even contain any mass simulation stuff you often see in DOTS samples / tutorials… I guess that’s the next step: adding a bunch of enemies to mow through!

As usual, you’ll find the source here : Source Part 3