KybernetikGames / animancer

Documentation for the Animancer Unity Plugin.
67 stars 8 forks source link

Mixers don't stop synchronizing their children while they aren't playing #242

Closed JohnManna closed 1 year ago

JohnManna commented 1 year ago

Environment

Description

It seems inactive LinearMixerState nodes are still processed causing a large performance loss with scenes with a high number of active Animancer components. This has been observed by wrapping UpdateAll(_PreUpdatables, info.deltaTime * info.effectiveParentSpeed); with a profile sample and by observing the LinearMixerState nodes within private void UpdateAll(Key.KeyedList<IUpdatable> updatables, float deltaTime). Shouldn't these nodes be removed from _PreUpdatables? Its strange because the playableGraph does remove these nodes when they are inactive.

Reproduction

Spawning 128 characters with this code requires 0.60ms to process the inactive LinearMixerState even though the idle state is playing. Replacing this node with a AnimationClip improves performance bringing the cost down to 0.07ms.

With the mixer image

Without image

Graph of time of profiling image

Code snippets:

  // helpers
  public static T CreateState<T>(this AnimancerComponent animancer, int layer = 0) where T : AnimancerState, new() {
      var state = new T();
      animancer.Layers[layer].AddChild(state);
      return state;
  }

  public static void PlayState(this AnimancerComponent animancer, object state, float fadeTime = 0.2f) {
      animancer.Play(animancer.States[state], fadeTime);
  }

  // setup
  public AnimationClip idle;
  public MixerTransition2D walk;
  public MixerTransition2D run;

  animancer.States.GetOrCreate(0, idle);

  LinearMixerState movementMixer = animancer.CreateState<LinearMixerState>();
  movementMixer.Key = 1;
  movementMixer.Initialize(2);
  movementMixer.SetDebugName("movement");

  float walkAnimationClipMovementSpeed = ((Motion)walk.Animations[0]).averageSpeed.magnitude;
  float runAnimationClipMovementSpeed = ((Motion)run.Animations[0]).averageSpeed.magnitude;

  MixerState<Vector2> walkState = walk.CreateState();
  walkState.SetDebugName($"Walk");
  movementMixer.SetChild(0, walkState, threshold: walkAnimationClipMovementSpeed);

  MixerState<Vector2> runState = run.CreateState();
  runState.SetDebugName($"Run");
  movementMixer.SetChild(1, runState, threshold: runAnimationClipMovementSpeed);

  // play animations

  int animations.runningState = 0;
  int animations.runningState = 1;
  ...
  float movementMagnitude = math.length(characterBody.RelativeVelocity);
  bool moving = movementMagnitude > 0.01f;
  animancer.PlayState(moving ? animations.runningState : animations.idleState, 0.2f);

  if (moving) {
      float3 localVelocity = localTransform.InverseTransformDirection(characterBody.RelativeVelocity);
      float2 movement2d = new float2(localVelocity.x, localVelocity.z);

      var MovementMixer = animancer.States[animations.runningState] as LinearMixerState;
      var WalkState = MovementMixer.ChildStates[0] as MixerState<Vector2>;
      var RunState = MovementMixer.ChildStates[1] as MixerState<Vector2>;

      MovementMixer.Parameter = MathExtensions.MoveTowards(MovementMixer.Parameter, movementMagnitude, 5f * deltaTime);
      WalkState.Parameter = MathExtensions.MoveTowards((float2)WalkState.Parameter, movement2d, 10f * deltaTime);
      RunState.Parameter = MathExtensions.MoveTowards((float2)RunState.Parameter, movement2d, 10f * deltaTime);
  }
KybernetikGames commented 1 year ago

Disconnecting weightless playables from their parent usually improves performance (see the Connected vs. Disconnected table here). But for Mixer Synchronization to work it needs to keep all the mixer's children connected and continually modifies their speed to sync their time (because setting the time has other side effects like skipping events).

Having the whole mixer inactive while playing something else shouldn't have any performance cost though (except memory usage obviously). I'll need to look into that.

Also, you might want to check out Animancer v7.4 which has significant improvements to the way you can initialize mixers in code.

KybernetikGames commented 1 year ago

Are you sure you aren't modifying the mixer.Parameter while the state is inactive? I haven't looked at the code yet, but that could be causing it to recalculate all the child weights which is particularly expensive for 2D mixers (though I'm hoping I can improve that using the jobs system for Animancer v7.4).

JohnManna commented 1 year ago

Thanks for the info, I'll check it out.

I've looked through the code and it appears ApplySynchronizeChildren is what is costing so much. I believe this is what you are referring to above.

Are you sure you aren't modifying the mixer.Parameter while the state is inactive? I haven't looked at the code yet, but that could be causing it to recalculate all the child weights which is particularly expensive for 2D mixers (though I'm hoping I can improve that using the jobs system for Animancer v7.4).

Yes, if (moving) protects against that, I just added a breakpoint to verify it.

JohnManna commented 1 year ago

Disconnecting weightless playables from their parent usually improves performance (see the Connected vs. Disconnected table here). But for Mixer Synchronization to work it needs to keep all the mixer's children connected and continually modifies their speed to sync their time (because setting the time has other side effects like skipping events).

Yeah, I have seen a big improvement with performance with nodes being disconnected. What I don't understand is why is the parent mixer node still running updates when it has been removed from the graph? I understand that its children will still be connected to it but if the parent node is not part of the playable graph, shouldn't updates be skipped?

JohnManna commented 1 year ago

Adding a simple condition of if (!IsPlaying && !needsMoreUpdates) return; before ApplySynchronizeChildren(ref needsMoreUpdates); in MixerState.Update(out bool needsMoreUpdates) has greatly improved performance. Maybe this has some bad side effects like you mentioned, but for my use case seems ok. In an extreme use case of 128 animators with 10 nested mixers per each, the prepare frame step is now only 0.07ms. Without this change it was 5.50ms.

KybernetikGames commented 1 year ago

Change the if at the start of ApplySynchronizeChildren to:

if (Weight == 0 ||
    !IsPlaying ||
    _SynchronizedChildren == null ||
    _SynchronizedChildren.Count <= 1)
    return;

I only did a quick test but it seems to work fine. The side effects I mentioned were only for having some of the mixer's children disconnected, but if the whole mixer isn't playing then there obviously isn't any point in having the mixer update the speed of its children every frame and the mixer will get added back to the update list when it gets played again.

JohnManna commented 1 year ago

Fantastic, basically same change I tried. Works great.

KybernetikGames commented 1 year ago

Animancer v7.4 is now available with this fix.