Search Unity

PlayableGraph structure in AnimatorController and SimpleAnimation

Discussion in 'Animation' started by Kybernetik, Mar 21, 2019.

  1. Kybernetik

    Kybernetik

    Joined:
    Jan 3, 2013
    Posts:
    2,570
    Another question about the SimpleAnimation system for @DavidGeoffroy.

    Simple Animation talks about how the Animator component keeps all its Playables connected even when not playing so they keep writing their values to the animated object to keep its state deterministic.

    As far as I can tell, that accurately describes the outcome of how it affects the animated objects, but it doesn't actually seem to work like that under the hood when I look at it with the Playable Graph Visualiser.

    The SimpleAnimation component creates a graph that looks like this: SimpleAnimationGraph.PNG
    As the explanation says, there's one AnimationClipPlayable for every AnimationClip and they stay connected even when they aren't playing.

    However, a regular AnimatorController produces a graph like this:
    AnimatorControllerGraph.PNG
    Regardless of how many states there are, the only ones connected to the graph are the current state and the next state if a transition is active. According to the SimpleAnimation explanation, this graph structure suggests that:
    1. It should be leaving the object in a non-deterministic state when it stops playing an animation that affected something none of the remaining animations are affecting.
    2. It should be less efficient because disconnecting comes "at a large cost of performance whenever new clips start and end."
    So the SimpleAnimation explanation correctly describes the outcome, but seems to be incorrect about the structure of the graph used to create that outcome.

    Another notable observation is that having the active playable (the top green AnimationClip) selected when it becomes inactive moves the selection down to the inactive playable (the green AnimationClip near the middle) and changes it to show "Clip: none". This suggests that either the playable's clip is actually being changed which doesn't seem possible with the API we have access to.

    Can you shed any light on why AnimatorControllers use that structure and how they are able to achieve good performance and be deterministic when the SimpleAnimation explanation suggests that should not be the case?

    I'm trying to figure out the best graph structure to use, but my investigations seem to find conflicting evidence.
     
  2. Alan-Liu

    Alan-Liu

    Joined:
    Jan 23, 2014
    Posts:
    391
    I tried to optimize the memory usage by using Playable instead of AnimatorController. During doing this optimization, I found someting unexpected. I did some investigation and it seems to be related to your questions.

    First, let me describe the optimization I tried. The AnimatorControllers of characters in our game have many state macines, but in one battle, only a part of these state mahcines are needed. So I splitted one large AnimatorController into multiple small AnimatorControllers(One AnimatorController for one StateMachine in the original AnimatorController) and used CharacterControllerPlayable to play it. And I implemented some logic to manage these CharacterControllerPlayable.

    Everything seems to be working as expected until I did the profile. I found there was a CPU spike when it started a transition from one AnimatorControllerPlayable to other AnimatorControllerPlayable, which is dynamically connected to PlayableGraph when the transition started. From Unity Profiler, I saw the Director.PrepareFrame cost 8~9ms on a Redmi 9A device, when the transition started.

    Then I tried to profile on an iOS device using Instrument Time Profiler:
    Code (CSharp):
    1. 47.00 ms   54.0%    0 s                       -[UnityAppController(Rendering) repaintDisplayLink]
    2. 47.00 ms   54.0%    0 s                        -[UnityAppController(Rendering) repaint]
    3. 46.00 ms   52.8%    0 s                         UnityRepaint
    4. 46.00 ms   52.8%    0 s                          UnityPlayerLoopImpl(bool)
    5. 46.00 ms   52.8%    0 s                           PlayerLoop()
    6. 46.00 ms   52.8%    0 s                            ExecutePlayerLoop(NativePlayerLoopSystem*)
    7. 46.00 ms   52.8%    0 s                             ExecutePlayerLoop(NativePlayerLoopSystem*)
    8. 36.00 ms   41.3%    0 s                              DirectorManager::ExecuteStage(DirectorStage)
    9. 34.00 ms   39.0%    0 s                               DirectorManager::ExecutePrepareFrames(DirectorStage)
    10. 32.00 ms   36.7%    0 s                                PlayableGraph::FireConnectionHashChanged()
    11. 32.00 ms   36.7%    0 s                                 PlayableOutput::FireConnectionHashChanged()
    12. 32.00 ms   36.7%    0 s                                  Animator::OnGraphTopologyChanged(AnimationPlayableOutput*, int)
    13. 32.00 ms   36.7%    0 s                                   IsInitialized
    14. 30.00 ms   34.4%    0 s                                    Animator::SoftResetBindingsOnly()
    15. 28.00 ms   32.1%    0 s                                     Animator::CreateBindings()
    16. 28.00 ms   32.1%    0 s                                      UnityEngine::Animation::CreateAnimationSetBindings(dynamic_array<AnimationClip*, 0ul> const&, RuntimeBaseAllocator&)
    17. 28.00 ms   32.1%    0 s                                       CombineUniqueGeneric
    18.  
    It seems Unity recreate AnimationSetBindings when the topology of PlayableGraph changes. But why original AnimatorController works without this cost? Because AnimatorController created its AnimationSetBindings
    when it's initialized and Animator has a fast path to use it directly in Animator::CreateBindings. For playables, Animator just creates AnimationSetBindings every time.

    Besides, there seems to be other reason that AnimatorController can work without this cost, which I havn't confirmed. It seems that AnimatorController creates all needed playables at the initialization time. For example, it creates two AnimationMixerPlayable in a layer to handle transition and creates enough AnimationClipPlayables connected to AnimationMixerPlayable. For 'enough', I mean it seems to be equals to the max count of motions in a BlendTree in that layer. With all playables are created and connected to PlayableGraph at the initialization time, the topology of PlayableGraph may not be changed when it plays animations. So the whole cost of Animator::OnGraphTopologyChanged can be avoided.
     
    Last edited: Mar 23, 2023