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 FourDirectionClipStates
component 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

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 PreviousVelocity
component 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 FourDirectionsAnimationsAuthoring
and 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.

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.main
in 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!
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.

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. SkeletonBoneMaskSetBlob
to 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:

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 ThrownWeaponComponent
to 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:
-
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); } } }
-
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); } } } }
-
-
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