Search Unity

  1. Welcome to the Unity Forums! Please take the time to read our Code of Conduct to familiarize yourself with the forum rules and how to post constructively.
  2. Dismiss Notice

Question Architecture question for state machine (Character controller)

Discussion in 'Entity Component System' started by HBG-Mathieu, Jul 25, 2023.

  1. HBG-Mathieu

    HBG-Mathieu

    Joined:
    Feb 16, 2023
    Posts:
    59
    Hello guys !
    I'm starting to work on my character controller using the experimental package for this (RIP Rival).
    I started from the platformer sample and... changed what I wanted to !
    But I have one big last question... the state machine to decide what the character can do and when. And that's almost 2 years since I dived in the ECS package itself so I would love some guidance.

    In the sample, there is a lot of switch to do the branching and I'm not in love with it.

    Edit : Looks like I just reduced myself to the propositions already in the character controller thread. So I probably should delete the post.

    You obviously need some context here : I would probably never have more than 10 characters at the same time, but let's say 100. And they won't change states a lot (especially by frame), the more complexe one is controlled by player inputs, and the others will be walking or just standing most of the time. That's why I won't speak about having jobs all other the place.


    And there is a reduced sample of the code :
    The interface for any state :
    Code (CSharp):
    1. public enum CharacterState
    2. {
    3.     Uninitialized,
    4.  
    5.     GroundMove,
    6.     AirMove
    7. }
    8.  
    9. public interface IPlatformerCharacterState
    10. {
    11.     void OnStateEnter(CharacterState previousState, ref PlatformerCharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext, in PlatformerCharacterAspect aspect);
    12.     void OnStateExit(CharacterState nextState, ref PlatformerCharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext, in PlatformerCharacterAspect aspect);
    13.     void OnStatePhysicsUpdate(ref PlatformerCharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext, in PlatformerCharacterAspect aspect);
    14.     ....
    15. }
    One implementation :
    Code (CSharp):
    1. public struct GroundMoveState : IPlatformerCharacterState
    2. {
    3.     public void OnStateEnter(CharacterState previousState, ref PlatformerCharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext, in PlatformerCharacterAspect aspect)
    4.     {
    5.        ....
    6.     }
    7.  
    8.     public void OnStateExit(CharacterState nextState, ref PlatformerCharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext, in PlatformerCharacterAspect aspect)
    9.     {
    10.         ....
    11.     }
    12.  
    13.     public void OnStatePhysicsUpdate(ref PlatformerCharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext, in PlatformerCharacterAspect aspect)
    14.     {
    15.         ....
    16.     }
    17.  
    18.     ....
    19. }
    How to acces the right function :
    Code (CSharp):
    1. [Serializable]
    2. public struct PlatformerCharacterStateMachine : IComponentData
    3. {
    4.     public CharacterState CurrentState;
    5.     public CharacterState PreviousState;
    6.  
    7.     public GroundMoveState GroundMoveState;
    8.     public AirMoveState AirMoveState;
    9.  
    10.     public void TransitionToState(CharacterState newState, ref PlatformerCharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext, in PlatformerCharacterAspect aspect)
    11.     {
    12.         PreviousState = CurrentState;
    13.         CurrentState = newState;
    14.  
    15.         OnStateExit(PreviousState, CurrentState, ref context, ref baseContext, in aspect);
    16.         OnStateEnter(CurrentState, PreviousState, ref context, ref baseContext, in aspect);
    17.     }
    18.  
    19.     public void OnStateEnter(CharacterState state, CharacterState previousState, ref PlatformerCharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext, in PlatformerCharacterAspect aspect)
    20.     {
    21.         switch (state)
    22.         {
    23.             case CharacterState.GroundMove:
    24.                 GroundMoveState.OnStateEnter(previousState, ref context, ref baseContext, in aspect);
    25.                 break;
    26.             case CharacterState.AirMove:
    27.                 AirMoveState.OnStateEnter(previousState, ref context, ref baseContext, in aspect);
    28.                 break;
    29.         }
    30.     }
    31.  
    32.     public void OnStateExit(CharacterState state, CharacterState newState, ref PlatformerCharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext, in PlatformerCharacterAspect aspect)
    33.     {
    34.         switch (state)
    35.         {
    36.             case CharacterState.GroundMove:
    37.                 GroundMoveState.OnStateExit(newState, ref context, ref baseContext, in aspect);
    38.                 break;
    39.             case CharacterState.AirMove:
    40.                 AirMoveState.OnStateExit(newState, ref context, ref baseContext, in aspect);
    41.                 break;
    42.         }
    43.     }
    44.  
    45.     public void OnStatePhysicsUpdate(CharacterState state, ref PlatformerCharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext, in PlatformerCharacterAspect aspect)
    46.     {
    47.         switch (state)
    48.         {
    49.             case CharacterState.GroundMove:
    50.                 GroundMoveState.OnStatePhysicsUpdate(ref context, ref baseContext, in aspect);
    51.                 break;
    52.             case CharacterState.AirMove:
    53.                 AirMoveState.OnStatePhysicsUpdate(ref context, ref baseContext, in aspect);
    54.                 break;
    55.         }
    56.     }
    57. ....
    58. }

    - Switch approach : my biggest issue here is the copy paste code (and it got worse when you think about substates) which I hate. From a performance point of view, it's definitely fine, there is as much branching depth as there is depth in the substates, and it can Burst + Jobs. But as an old C++ programmer I would have always prefered to use an array of function pointer, which, in my head, means even less branching and far more readibility.

    - Switch approach (template) : I never tried the template system from microsoft but it could reduce the copy paste code. Not a big fan, but why not.

    - FunctionPointer<T> : That would have been my first idea, if it wasn't for the warning in the documentation. It looks like you should use FunctionPointer<T> only if you have no other choice. They even said that if you use it, in fact use it like a job : on a batch. So using it to compose the logic to execute seems like a big No from Unity.

    - delegate* : If I did understand correctly, that's a C# 9 feature but it's still managed (even if it required to be unsafe) and so you have to use FunctionPointer<T> for Burst + Jobs.
    Really doesn't burst compile and is restricted to static function.

    - Action<T> : Like I said, they won't be a lot of entities, so maybe Jobs are just overkill (even if someone from DOTS team said to me once that Unity can chose if a job is too small to simply run it). So maybe I could have my state machine setup as a system variable using array of Action<T> or whatever C# callback is possible. But I guess it would prevent any Burst possible to the code inside ? Not having Jobs seems like not really a concern, but not having Burst, hurts.
    Can't Burst too.

    - IEnableableComponent : I wasn't expecting new component types so I was quite happy to see this one ! But I can't find exactly how it's working, just that it's "low" cost but should still be used if the enable/disable happen at high frequency, which is not my use case. But I though it could have been cool to have states with there settings in separated components, which means one job to manage their status (so Write access) and the others simply reading. Would have been quite messy to do substates though.

    - NativeArray<IState> : Just one little though I had writing this (I don't know if it's possible, I haven't tested a lot yet). What if instead of Action<T> I used struct with the set of functions I want to call from an interface, and I do an array of this interface. Would I be able to call that in a burst code? Maybe even in a job? Sounds fishy to me, like it probably can't be that easy.
    Ok that's what I though, I can't use the interface type to fill up a NativeArray.


    That's all for now, I'm gonna sleep and think again !
    Kind regards,
    Mathieu
     
    Last edited: Jul 26, 2023
  2. DreamingImLatios

    DreamingImLatios

    Joined:
    Jun 3, 2017
    Posts:
    3,983
    If this is the issue, then you are doing something wrong.

    Usually you would only represent this in authoring and flatten it at bake time.

    Some other options include source generators and recursive generics. I can't really provide better suggestions because I don't really know anything about your problem other than "state machines".
     
  3. HBG-Mathieu

    HBG-Mathieu

    Joined:
    Feb 16, 2023
    Posts:
    59
    Well it's not a pure copy/paste code if that's what you though, I'm just speaking about having switch each time you have to decide which function to call. But I agree it's bad, that's why I want to get ride of it !

    Recursive generics won't help here, C# doesn't provide any real metaprogramming stuff, which would be really handy there. Let's keep source generator for the last ressort !

    Anyway I added a reduced sample of the platformer sample ^^
     
  4. DreamingImLatios

    DreamingImLatios

    Joined:
    Jun 3, 2017
    Posts:
    3,983
    The actual issue here is that you have multiple entrypoints into your states, so you are repeating your switch case for each entrypoint. Additionally, you are seemingly allowing these transitions to happen from anywhere, which further limits your options. If you want something different, than you need to restructure things such that you can process all entities of a particular state at once. Otherwise, your best option for reducing code typed is going to be along the lines of this: https://github.com/PhilSA/Trove/tree/main/com.trove.polymorphicstructs
     
    xVergilx and HBG-Mathieu like this.
  5. HBG-Mathieu

    HBG-Mathieu

    Joined:
    Feb 16, 2023
    Posts:
    59
    Ohh nice one ! I though about making something like this but the only way I know how to do it was not really perfromance friendly, so I though it was even worst than Function Pointer.
    You're right I'm still thinking about how I could change the context to think about the whole thing differently, but so far nothing better.
    I guess I should ask for @philsa-unity opinion then :)
    Thanks !

    Edit : Looks really like a code generator to automaticaly put the switch :0 I wonder if it's in a state where it could be used in a project. I never saw C# generator so hard to tell !
     
    Last edited: Jul 26, 2023
  6. xVergilx

    xVergilx

    Joined:
    Dec 22, 2014
    Posts:
    3,292
    This.
    +
    Polymorphic structs are okay when they work. When they don't - switch or generic methods with struct interfaces. Its a better alternative to trim down code duplication.

    E.g. this burst compiles and runs in a job just fine:
    Code (CSharp):
    1. public interface IDoSomething {
    2.     public void SomeMethod();
    3.     public bool SomeProperty { get; set; }
    4. }
    5.  
    6. public struct SomeComponent: IComponentData, IDoSomething { }
    7.  
    8. public static void ProcessSomething<T>(T data) where T : struct, IDoSomething {
    9.     data.SomeMethod();
    10. }
    As long as you're specifying proper constraints - codegen should figure out concrete types and avoid boxing.

    The quirk [con] of this - underlying properties cannot be serialized properly (if you're storing it somewhere), so properties would require manual backing field declarations.

    And it may look weird non-DOD at first, since data is being mixed a bit with logic. But, if state is treated as data, then it kinda makes sense.

    Another trick is to utilize states as a buffer, and reinterpret state directly into concrete type.
    But, its a bit hacky / unsafe.
     
    Last edited: Jul 26, 2023
    HBG-Mathieu likes this.
  7. HBG-Mathieu

    HBG-Mathieu

    Joined:
    Feb 16, 2023
    Posts:
    59
    Oh ! This can work ? Nice !
    But yes DOD is still respected, storing function and executing them is quite different. But anyway, it's not as if I was an extermist (outside for having a simple and efficient code to maintain).

    I'm not sure I follow when you say states as a buffer, but maybe DynamicBuffer support interface typing unlike NativeArray ?
     
  8. philsa-unity

    philsa-unity

    Unity Technologies

    Joined:
    Aug 23, 2022
    Posts:
    113
    Yeah I don't have much to add on this topic other than what's been said in this thread and here

    You kinda have to figure out what's the least worst thing in your use case between structural changes, jobs overhead, or various types of imperfect memory access patterns. And without either codegen or main thread code, I can't think of too many ways to reduce the boilerplate

    Or you can try to think of the problem in another way than a state machine, but if I had to rethink the way the Platformer character is implemented nowadays, I'd probably still stick with the same thing. I find that the neat organization benefit of a state machine is worth making some sacrifices for in this particular case, because otherwise things can get really hard to manage
     
    Last edited: Jul 27, 2023
    xVergilx and HBG-Mathieu like this.
  9. HBG-Mathieu

    HBG-Mathieu

    Joined:
    Feb 16, 2023
    Posts:
    59
    Thanks for the feedback !
    I don't even consider the state machine to be a sacrifice, especially whith my number of entities.

    I'll go with xVergilx proposition for now ! Edit : I'll start with polimorphic struct, that's too tempting
    I'll keep an eye on trove though.

    Thanks everyone and have a nice day !
     
    Last edited: Jul 27, 2023