Code/HumanoidRetargeter/Engine/AnimGraphParameters.cs

GameObjectSystem that sets animation graph parameters on a SkinnedModelRenderer for both AnimGraph1 and AnimGraph2. AnimGraph1 uses the renderer's own setters; AnimGraph2 collects values per renderer and writes them into the serialized animation state each frame, plus one-frame trigger bools.

Reflection
#if SANDBOX
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json.Nodes;
using Sandbox;

namespace HumanoidRetargeter.Engine;

/// <summary>
/// Sets animation graph parameters on a renderer, whichever graph system drives it (see <see cref="AnimGraphVersions"/>).
/// AnimGraph1 takes them through the renderer's own setters, exactly as before. AnimGraph2 takes them through the
/// renderer's serialized animation state: game code may not name AnimGraph2's types (engines without it couldn't
/// compile it) nor reflect on them, and that state is the one route to its parameters left. The engine replaces the
/// whole parameter set on each write, so every value set on a renderer is kept and written together.
/// </summary>
public sealed class AnimGraphParameters : GameObjectSystem<AnimGraphParameters>
{
	// Each AnimGraph2 renderer's values by kind, named as the engine serializes them, and whether they changed since
	// they were written
	private sealed class Values
	{
		public readonly Dictionary<string, float> Floats = new( StringComparer.OrdinalIgnoreCase );
		public readonly Dictionary<string, bool> Bools = new( StringComparer.OrdinalIgnoreCase );
		public readonly Dictionary<string, Vector3> Vectors = new( StringComparer.OrdinalIgnoreCase );
		public readonly Dictionary<string, string> Ids = new( StringComparer.OrdinalIgnoreCase );
		public object Graph;
		public Dictionary<string, string[]> Options;
		public bool Dirty;
	}

	private readonly Dictionary<SkinnedModelRenderer, Values> _values = new();
	private readonly List<(SkinnedModelRenderer Renderer, string Name)> _triggers = new();
	private readonly List<(SkinnedModelRenderer Renderer, string Name)> _firedTriggers = new();
	private readonly List<SkinnedModelRenderer> _gone = new();

	public AnimGraphParameters( Scene scene ) : base( scene )
	{
		// Written as bones are worked out, so everything set this frame reaches the graph together
		Listen( Stage.UpdateBones, -10000, Write, "AnimGraphParameters.Write" );
		Listen( Stage.StartUpdate, 0, EndTriggers, "AnimGraphParameters.EndTriggers" );
	}

	/// <summary>Sets float <paramref name="name"/> on <paramref name="renderer"/>.</summary>
	public static void Set( SkinnedModelRenderer renderer, string name, float value )
	{
		if ( For( renderer ) is { } values )
			Change( values, values.Floats, name, value );
		else
			Legacy( renderer, name, value );
	}

	/// <summary>Sets bool <paramref name="name"/> on <paramref name="renderer"/>; it stays set (see <see cref="Trigger"/>).</summary>
	public static void Set( SkinnedModelRenderer renderer, string name, bool value )
	{
		if ( For( renderer ) is { } values )
			Change( values, values.Bools, name, value );
		else
			Legacy( renderer, name, value );
	}

	/// <summary>
	/// Sets int <paramref name="name"/> on <paramref name="renderer"/>. AnimGraph2 takes an AnimGraph1 enum (a hold type)
	/// as the option at that index of its ID parameter, in the same order; a number otherwise.
	/// </summary>
	public static void Set( SkinnedModelRenderer renderer, string name, int value )
	{
		if ( For( renderer ) is not { } values )
		{
			Legacy( renderer, name, value );
			return;
		}

		if ( OptionsOf( values, name ) is { } options )
		{
			if ( value >= 0 && value < options.Length )
				Change( values, values.Ids, name, options[ value ] );

			return;
		}

		Change( values, values.Floats, name, (float)value );
	}

	/// <summary>Sets option <paramref name="name"/> (an enum or ID parameter) on <paramref name="renderer"/> by its name.</summary>
	public static void Set( SkinnedModelRenderer renderer, string name, string option )
	{
		if ( For( renderer ) is { } values )
			Change( values, values.Ids, name, option ?? "" );
		else
			Legacy( renderer, name, option );
	}

	/// <summary>Sets vector <paramref name="name"/> on <paramref name="renderer"/>.</summary>
	public static void Set( SkinnedModelRenderer renderer, string name, Vector3 value )
	{
		if ( For( renderer ) is { } values )
			Change( values, values.Vectors, name, value );
		else
			Legacy( renderer, name, value );
	}

	/// <summary>
	/// Points look parameter <paramref name="name"/> along <paramref name="directionWorld"/> (made local to the renderer),
	/// and sets <c>{name}_weight</c> to <paramref name="weight"/>, as AnimGraph1's own look direction does.
	/// </summary>
	public static void SetLookDirection( SkinnedModelRenderer renderer, string name, Vector3 directionWorld, float weight )
	{
		if ( For( renderer ) is not { } values )
		{
#pragma warning disable CS0618 // AnimGraph1's own setter: obsolete only on engines that have AnimGraph2
			renderer?.SetLookDirection( name, directionWorld, weight );
#pragma warning restore CS0618
			return;
		}

		Change( values, values.Vectors, name, directionWorld * renderer.WorldRotation.Inverse );
		Change( values, values.Floats, $"{name}_weight", weight );
	}

	/// <summary>
	/// Sets bool <paramref name="name"/> for one update, for an action that fires once (an attack, a reload). AnimGraph1
	/// graphs reset these themselves; AnimGraph2 has it cleared at the start of the next frame.
	/// </summary>
	public static void Trigger( SkinnedModelRenderer renderer, string name )
	{
		if ( For( renderer ) is not { } values )
		{
			Legacy( renderer, name, true );
			return;
		}

		Change( values, values.Bools, name, true );
		renderer.Scene.GetSystem<AnimGraphParameters>()?._triggers.Add( (renderer, name) );
	}

	/// <summary>Forgets what was set on <paramref name="renderer"/> for AnimGraph2, after its graph is swapped.</summary>
	public static void Forget( SkinnedModelRenderer renderer )
	{
		renderer?.Scene?.GetSystem<AnimGraphParameters>()?._values.Remove( renderer );
	}

	// This renderer's AnimGraph2 values, or null when AnimGraph1 drives it
	private static Values For( SkinnedModelRenderer renderer )
	{
		if ( AnimGraphVersions.GraphOf( renderer ) is not { } graph || renderer.Scene?.GetSystem<AnimGraphParameters>() is not { } system )
			return null;

		if ( !system._values.TryGetValue( renderer, out var values ) || values.Graph != graph )
		{
			values = new Values { Graph = graph };
			system._values[ renderer ] = values;
		}

		return values;
	}

	private static void Change<T>( Values values, Dictionary<string, T> kind, string name, T value )
	{
		if ( string.IsNullOrEmpty( name ) || kind.TryGetValue( name, out var known ) && EqualityComparer<T>.Default.Equals( known, value ) )
			return;

		kind[ name ] = value;
		values.Dirty = true;
	}

	// The options of the graph's ID parameter called name, or null when it isn't one
	private static string[] OptionsOf( Values values, string name )
	{
		values.Options ??= ReadOptions( values.Graph );
		return values.Options.TryGetValue( name, out var options ) ? options : null;
	}

	private static Dictionary<string, string[]> ReadOptions( object graph )
	{
		var options = new Dictionary<string, string[]>( StringComparer.OrdinalIgnoreCase );
		if ( TypeLibrary.GetPropertyValue( graph, "Parameters" ) is not IEnumerable parameters )
			return options;

		foreach ( var parameter in parameters )
		{
			if ( TypeLibrary.GetPropertyValue( parameter, "Name" ) is string name
				&& TypeLibrary.GetPropertyValue( parameter, "Options" ) is IEnumerable<string> list )
			{
				var array = new List<string>( list ).ToArray();
				if ( array.Length > 0 )
					options[ name ] = array;
			}
		}

		return options;
	}

	private void Write()
	{
		_gone.Clear();
		foreach ( var (renderer, values) in _values )
		{
			if ( !renderer.IsValid() )
			{
				_gone.Add( renderer );
				continue;
			}

			if ( !values.Dirty )
				continue;

			values.Dirty = false;
			renderer.DeserializeImmediately( new JsonObject { [ "Animation" ] = Overlay( renderer, values ) } );
		}

		foreach ( var renderer in _gone )
			_values.Remove( renderer );

		// Written now: they end at the start of the next frame
		_firedTriggers.AddRange( _triggers );
		_triggers.Clear();
	}

	// The renderer's animation state as it is now, with every value set here laid over it. Starting from what the
	// renderer holds keeps values others set (another library's copy of this, game code using AnimGraph2 directly), and
	// keeps its graph as it names it: null when it plays its model's, which naming would restart, losing this frame.
	// shortcut: serializes the renderer and writes it back per changed renderer per frame (well under a millisecond,
	// measured on staging); switch to AnimGraph2's own parameter setters once game code may call them on every engine.
	private static JsonObject Overlay( SkinnedModelRenderer renderer, Values values )
	{
		var animation = renderer.Serialize()?[ "Animation" ]?.DeepClone() as JsonObject ?? new JsonObject();
		if ( animation[ "Parameters" ] is not JsonObject parameters )
			animation[ "Parameters" ] = parameters = new JsonObject();

		var floats = Kind( parameters, "floats" );
		foreach ( var (name, value) in values.Floats )
			floats[ name ] = value;

		var bools = Kind( parameters, "bools" );
		foreach ( var (name, value) in values.Bools )
			bools[ name ] = value;

		var vectors = Kind( parameters, "vectors" );
		foreach ( var (name, value) in values.Vectors )
			vectors[ name ] = string.Create( CultureInfo.InvariantCulture, $"{value.x},{value.y},{value.z}" );

		var ids = Kind( parameters, "ids" );
		foreach ( var (name, value) in values.Ids )
			ids[ name ] = value;

		return animation;
	}

	// One kind of value in the serialized parameters, named as the engine names it
	private static JsonObject Kind( JsonObject parameters, string kind )
	{
		if ( parameters[ kind ] is not JsonObject values )
			parameters[ kind ] = values = new JsonObject();

		return values;
	}

	// Triggers written last frame go back to false now, one update after
	private void EndTriggers()
	{
		foreach ( var (renderer, name) in _firedTriggers )
		{
			if ( For( renderer ) is { } values )
				Change( values, values.Bools, name, false );
		}

		_firedTriggers.Clear();
	}

#pragma warning disable CS0618 // AnimGraph1's own setters: obsolete only on engines that have AnimGraph2
	private static void Legacy( SkinnedModelRenderer renderer, string name, float value ) => renderer?.Set( name, value );
	private static void Legacy( SkinnedModelRenderer renderer, string name, bool value ) => renderer?.Set( name, value );
	private static void Legacy( SkinnedModelRenderer renderer, string name, int value ) => renderer?.Set( name, value );
	private static void Legacy( SkinnedModelRenderer renderer, string name, string value ) => renderer?.Set( name, value );
	private static void Legacy( SkinnedModelRenderer renderer, string name, Vector3 value ) => renderer?.Set( name, value );
#pragma warning restore CS0618
}
#endif