Hi everyone!!
Have patience with me please because I'm still quite a beginner with Spine and the Runtime.

I started studying and doing small exercises on ECS (DOTS) Architecture in Unity

Also I have a personal indie project I'm developing for mobile devices.
My game is a strategic that has some similarities to clash royale basically here you also summon units on a battlefield.
Eventually I came across the optimization problem that is having a lot of units.

If you can give me some directions on what could be for you the best approaches and if I'm missing some very useful techniques, I would very grateful.

Since each character is seen from a isometric type of camera, for each unit I have 3 different Skeleton data asset front-left, left and back-left the other mirrored direction are obtaining setting ScaleX.

As for the current stage of design I could expect to have, as an average max, 20/30 animated units "sampling" from 15/18 skeleton data asset. But since the game has the rogue-lite element and I expect to have units that could act like spawners for other units, I can expect emergent behavior from the player to bring the number of units to a certain number (I will probably limit it anyway) I can see reaching 100/200 of animated units.

I could avoid runtime Initialize() for the skeleton animation by having a list of skeleton data assets
attached to the same gameobject (one for each direction) and activate them just when needed:

- Unit G.Obj.
    - SkeletonDir1
    - SkeletonDir2
    - SkeletonDir3

it means to have x3 RAM occupied, but even if it could still be feasible, then the problem that remains is the Update of the skeleton that happens each frame for each skeleton if I understand correctly (since it's a routine inside the PlayerLoop of Unity). It can escalte to high latency very quickly with a lot of units. I know there's Update when visible, but most of the units (70/80%) will always be visible.

I know there is a way to change the update rate, do you think that If I implemented a ECS logic of establishing when a major eye-catch moment is coming for the unit ( like a enemy is in the FOV, an ability triggers or an enemy spawned next to it ), to set the update rate of the spine animation back to 60 from like 30 or 15, I could save enough latency for making it feasible?

The other safest route I'm considering is exporting every animation as sprite sheets from Spine and using the data-oriented rendering system of ECS.

  • Harald ha risposto a questo messaggio
    Related Discussions
    ...

    Pidrav I know there is a way to change the update rate, do you think that If I implemented a ECS logic of establishing when a major eye-catch moment is coming for the unit ( like a enemy is in the FOV, an ability triggers or an enemy spawned next to it ), to set the update rate of the spine animation back to 60 from like 30 or 15, I could save enough latency for making it feasible?

    Reducing update rate except for when crucial moments occur would definitely help. Just be sure to distribute updates of units across different frames evenly or according to update cost (skeleton complexity), so to not update every skeleton on frame 10 and none on frame 11, everything again on frame 12, etc.

    Note that we have an issue ticket regarding parallelization here, using normal threads with good effect. While we still have some cleanup and consolidation to do, after finishing porting and 4.3-beta tasks, it should definitely help with your performance issue. So don't wait for the feature to be finished, but be prepared to update to 4.3-beta for a performance boost when it is.

    Pidrav The other safest route I'm considering is exporting every animation as sprite sheets from Spine and using the data-oriented rendering system of ECS.

    This could be a good-enough workaround as I assume:

    • your skeleton units are small (small in regards to screen-size).
    • you don't need much runtime customization of each unit.
    • you don't need complex animation blending.

    If any of the above is wrong, please let me know.

    3 mesi dopo
    • Modificato

    Hi @Harald , how are you?
    I have started implementing more of the logic of ECS units, currently I'm trying to figure out how to connect bidirectionaly both the ECS world and the MonoBehaviour world.

    Units have behaviour tree as AI-Logic controller, these are entirely ECS based and so static.
    These trees are baked and flatten in a deep array referenced as a blobasset so that if there are 250 units of type "A" they all use the same static structure "A".
    The flatten blobasset in its array elements (the BH-Tree nodes) has a byte enum field used to reference the corresponding execute() function of the node in a table of static node execute() functions. (so also the execute logic is burst compilable)

    Basically all this means that the ISystem managing the units behavior each frame just has to call the execute() on each node reading it from the blobasset.

    The problem of ECS to MB connection comes from the fact that even if all of this is burst compilable, the behaviour tree nodes should also be the controllers of animations that are invocable from the spine runtime that are managed data.
    So I think it's inevitable to have, on the unit entity: a component containing the GameObject with everything spine related and also the request of animation that is called from the execute() of a node right?

    example of a execute() function that may be called from the system managing the behaviour logic of the units asking for an animation change:

    public static class BTFuncLibrary
    {
        [BurstCompile]
        public static BTState CheckEnemyInAttRange(BTNodeData node, BTState state, Entity entity)
        {
            
            // … some logic …
            // it then finds the enemy in the range and prepares data for it
            
    
            /* here it takes the AnimationRequestComponent from the entity and
                puts the request information for the animation to be played now that the
               attack to the enemy unit can start.
           */
            AnimationRequestComponent animationComponent = SystemAPI.
                             GetComponent<AnimationRequestComponent>(entity);
            animationComponent.changeAnimation=true;
            animationComponent.animationName= "attack"; 
            return BTState.SUCCESS
        }
        . . .
    }

    and then in a managed System that updates after the ISystem executing the static functions on the node, we can execute the change of animation:

    public partial class UnitAnimationUpdateSystem : SystemBase
    {
         //this is not burst compilable.
        protected override void OnUpdate()
        {
             /* here it gets all the AnimationRequestComponent, it filters them based on
             changeAnimation == true */
            UnityObjectReference<SpineAnimCon> spineAnimConReference 
                                  = filteredComponent.unityObjectReference;
            SpineAnimCon spineAnimCon = spineAnimConReference.Value;
            if (spineAnimCon != null)
              // let's suppose the MonoBehaviour spineAnimCon has the SkeletonAnimation as a field
              spineAnimCon.skelAnim.SetAnimation(0, filteredComponent.animationName);
    
             // then it resets the request fields..
        }
    }

    The other bridge needed is to MB to ECS, each of these execute function should look for spine events to apply specific logic.
    I will post later my idea on this too.
    As for now, do you have some insights or suggestion for the bridge ECS -> MB for changing animations?

    • Harald ha risposto a questo messaggio
      • Modificato

      Pidrav So I think it's inevitable to have, on the unit entity: a component containing the GameObject with everything spine related and also the request of animation that is called from the execute() of a node right?

      Yes, unfortunately I also see no way around it.

      Pidrav As for now, do you have some insights or suggestion for the bridge ECS -> MB for changing animations?

      I'm not sure what to suggest here, as you've already provided part of the implementation above. In general your approach of using something similar to command-buffers / requests seems very reasonable, having ECS AI logic adding animation-change-commands to a common request list, while at a MonoBehaviour you then process all accumulated animation-change-commands. If possible, script execution order should be setup so that the commands are processed before AnimationState.Update, so that you don't delay it by a frame. It's beneficial here that the animation shall be triggered by AI behaviour changes, so any latency here will not matter as much here (compared to latency from input to animation change).

      [Edit: added section]
      The only design considerations that come to my mind here are to make sure that the common animation command queue(s) are set up in a thread-friendly way as a multi-producer-single-consumer queue.
      ECS might have something like this:

      // on 
      public NativeQueue<AnimationChangeRequest> queue;
      ..
      var writer = queue.AsParallelWriter();

      A friendly LLM generated this code for me, I'm sharing it here untested, in case it helps:

      // Animation request struct that's Burst-compatible
      [BurstCompile]
      public struct AnimationChangeRequest : IComponentData
      {
          public Entity TargetEntity;
          public int AnimationId; // Use int instead of string for Burst
          public float TimeScale;
          public bool Loop;
          public int Priority; // For sorting if needed
      }
      
      // Shared queue container
      public class AnimationQueueContainer : IComponentData
      {
          public NativeQueue<AnimationChangeRequest> Queue;
          
          public AnimationQueueContainer()
          {
              Queue = new NativeQueue<AnimationChangeRequest>(Allocator.Persistent);
          }
          
          public void Dispose()
          {
              if (Queue.IsCreated)
                  Queue.Dispose();
          }
      }
      
      // ECS System that enqueues animation changes
      [BurstCompile]
      public partial struct EnqueueAnimationSystem : ISystem
      {
          private NativeQueue<AnimationChangeRequest>.ParallelWriter queueWriter;
          
          public void OnCreate(ref SystemState state)
          {
              var queue = new NativeQueue<AnimationChangeRequest>(Allocator.Persistent);
              SystemAPI.SetSingleton(new AnimationQueueContainer { Queue = queue });
          }
          
          [BurstCompile]
          public void OnUpdate(ref SystemState state)
          {
              var queue = SystemAPI.GetSingleton<AnimationQueueContainer>().Queue;
              var writer = queue.AsParallelWriter();
              
              state.Dependency = new EnqueueAnimationJob
              {
                  QueueWriter = writer
              }.ScheduleParallel(state.Dependency);
          }
          
          [BurstCompile]
          partial struct EnqueueAnimationJob : IJobEntity
          {
              public NativeQueue<AnimationChangeRequest>.ParallelWriter QueueWriter;
              
              void Execute(Entity entity, in SpineAnimationState animState)
              {
                  if (animState.HasChanged)
                  {
                      QueueWriter.Enqueue(new AnimationChangeRequest
                      {
                          TargetEntity = entity,
                          AnimationId = animState.AnimationId,
                          TimeScale = animState.TimeScale,
                          Loop = animState.Loop
                      });
                  }
              }
          }
      }
      
      // MonoBehaviour consumer
      public class SpineAnimationConsumer : MonoBehaviour
      {
          private EntityManager entityManager;
          private NativeQueue<AnimationChangeRequest> animationQueue;
          private Dictionary<Entity, SkeletonAnimation> entityToSpine;
          
          void Start()
          {
              entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
              entityToSpine = new Dictionary<Entity, SkeletonAnimation>();
          }
          
          void Update()
          {
              // Get queue reference
              var queueContainer = entityManager.CreateEntityQuery(
                  ComponentType.ReadOnly<AnimationQueueContainer>()
              ).GetSingleton<AnimationQueueContainer>();
              
              animationQueue = queueContainer.Queue;
              
              // Process all queued requests
              while (animationQueue.TryDequeue(out AnimationChangeRequest request))
              {
                  ProcessAnimationRequest(request);
              }
          }
          
          void ProcessAnimationRequest(AnimationChangeRequest request)
          {
              if (entityToSpine.TryGetValue(request.TargetEntity, out var skeleton))
              {
                  // Apply animation change
                  var animationName = GetAnimationName(request.AnimationId);
                  skeleton.AnimationState.SetAnimation(0, animationName, request.Loop);
                  skeleton.timeScale = request.TimeScale;
              }
          }
      }