CharacterKit/CharacterStatureMotion.cs

A runtime helper and system that temporarily adjusts ground-speed parameters for scaled characters during animation graph evaluation. CharacterStatureMotion configures per-renderer stride scaling, applies compensated move/wish groundspeeds to the renderer parameters during bone update, and restores original values; CharacterStatureMotionSystem schedules apply/restore calls across update stages and manages registrations.

Native Interop
using System;
using System.Collections.Generic;

namespace CharacterEditor;

/// <summary>
/// Temporarily converts controller ground speeds for a resized character while
/// the native animation graph evaluates. Controllers keep their own raw values,
/// gait selection, directional blends, vertical speeds and playback clocks.
/// Create one instance per world character and dispose it with its presentation.
/// </summary>
public sealed class CharacterStatureMotion : IDisposable
{
	struct Input
	{
		public bool Owned;
		public float Raw, Applied;
	}

	SkinnedModelRenderer target;
	CharacterStatureMotionSystem system;
	Input move, wish;
	float configuredStrideScale = 1, strideScale = 1;
	internal SkinnedModelRenderer Target => target;
	/// <summary>Combined world, growth and native-height stride scale at the latest evaluation.</summary>
	public float DistanceScale => strideScale;
	public bool IsCompensating => move.Owned || wish.Owned;

	/// <summary>
	/// Call after the body transform and growth pose are configured. Supply the
	/// growth pose's StrideFactor when available. Omitted uniform scale uses the
	/// renderer's actual world scale, including any host object's scale.
	/// This does not force animation on or change graph parameters outside its
	/// short evaluation interval. Neutral appearances remain registered so an
	/// external controller can change native height later; identity evaluations
	/// do not write any animation parameters.
	/// </summary>
	public void Configure(SkinnedModelRenderer body, CharacterAppearanceData appearance,
		float actualUniformScale = float.NaN, float actualGrowthStride = float.NaN)
	{
		if (target != body)
		{
			Release();
			target = body;
		}
		if (!body.IsValid() || body.Scene.IsEditor)
		{
			Release();
			return;
		}
		if (!float.IsFinite(actualUniformScale)) actualUniformScale = MathF.Abs(body.WorldScale.z);
		configuredStrideScale = CharacterStature.StrideScale(appearance, actualUniformScale, actualGrowthStride);
		var current = body.Scene.GetSystem<CharacterStatureMotionSystem>();
		if (system == current) return;
		Release();
		if (current.Register(this)) system = current;
	}

	internal void Apply()
	{
		// Also makes duplicate evaluation hooks safe: undo only our last values
		// before reading the latest controller inputs for this evaluation.
		Restore();
		strideScale = configuredStrideScale;
		if (!target.IsValid() || !target.Enabled || !target.GameObject.Active || !target.UseAnimGraph || target.SceneModel is null) return;
		// The stock human graph scales foot targets by (1 + scale_height) / 2.
		// Read the controller's current value after its update, without owning or
		// resetting it. Missing/invalid values keep the graph contribution neutral.
		float graphHeight = target.Parameters.GetFloat("scale_height");
		if (float.IsFinite(graphHeight) && graphHeight > 0)
		{
			float combined = configuredStrideScale * (.5f + .5f * graphHeight);
			if (float.IsFinite(combined) && combined > .001f) strideScale = combined;
		}
		if (strideScale == 1) return;
		// Ground cadence must not alter the driver's jump, swimming, noclip,
		// sitting or special-movement transitions.
		if (!target.Parameters.GetBool("b_grounded") || target.Parameters.GetBool("b_swim")
			|| target.Parameters.GetBool("b_noclip") || target.Parameters.GetBool("b_sit")
			|| target.Parameters.GetInt("sit") != 0
			|| target.Parameters.GetInt("special_movement_states") != 0) return;
		Apply("move_groundspeed", ref move);
		Apply("wish_groundspeed", ref wish);
	}

	void Apply(string parameter, ref Input input)
	{
		float raw = target.Parameters.GetFloat(parameter);
		if (!float.IsFinite(raw) || raw == 0) return;
		float adjusted = CharacterStature.CompensateGroundSpeed(raw, strideScale);
		if (adjusted == raw) return;
		input.Raw = raw;
		input.Applied = adjusted;
		input.Owned = true;
		target.Parameters.Set(parameter, adjusted);
	}

	/// <summary>
	/// Restore only values still equal to this instance's last write. A caller
	/// changing a parameter during the evaluation interval retains its new value.
	/// Standard controllers run outside that interval and always see raw inputs.
	/// </summary>
	public void Restore()
	{
		Restore("move_groundspeed", ref move);
		Restore("wish_groundspeed", ref wish);
	}

	void Restore(string parameter, ref Input input)
	{
		if (!input.Owned) return;
		if (target.IsValid() && target.SceneModel is not null && target.Parameters.GetFloat(parameter) == input.Applied)
			target.Parameters.Set(parameter, input.Raw);
		input = default;
	}

	void Release()
	{
		Restore();
		system?.Unregister(this);
		system = null;
	}

	internal void SystemReleased(CharacterStatureMotionSystem source)
	{
		Restore();
		if (system == source) system = null;
	}

	public void Dispose()
	{
		Release();
		target = null;
		configuredStrideScale = strideScale = 1;
	}
}

/// <summary>
/// The native pose evaluates at UpdateBones priority 0. Cadence input conversion
/// runs at -200; ground-contact/growth composition may refresh the pose at +100;
/// raw inputs are restored at +1000, before rendering or the next controller tick.
/// Early guards restore an interrupted evaluation before variable/fixed updates.
/// </summary>
public sealed class CharacterStatureMotionSystem : GameObjectSystem<CharacterStatureMotionSystem>
{
	readonly List<CharacterStatureMotion> members = new();

	public CharacterStatureMotionSystem(Scene scene) : base(scene)
	{
		Listen(Stage.StartUpdate, -1000, Restore, "Restore stature motion inputs");
		Listen(Stage.StartFixedUpdate, -1000, Restore, "Restore fixed-step stature motion inputs");
		Listen(Stage.UpdateBones, -200, Apply, "Convert stature ground cadence");
		Listen(Stage.UpdateBones, 1000, Restore, "Restore controller ground speeds");
		Listen(Stage.FinishUpdate, -1000, Restore, "Restore interrupted stature evaluation");
	}

	internal bool Register(CharacterStatureMotion member)
	{
		foreach (var other in members)
		{
			if (other == member) return true;
			// A presentation accidentally registered twice must never divide twice.
			if (other.Target == member.Target) return false;
		}
		members.Add(member);
		return true;
	}

	internal void Unregister(CharacterStatureMotion member) => members.Remove(member);

	void Apply()
	{
		for (int i = members.Count - 1; i >= 0; i--)
		{
			var member = members[i];
			if (!member.Target.IsValid() || member.Target.Scene != Scene)
			{
				member.SystemReleased(this);
				members.RemoveAt(i);
				continue;
			}
			member.Apply();
		}
	}

	void Restore()
	{
		foreach (var member in members) member.Restore();
	}

	public override void Dispose()
	{
		foreach (var member in members) member.SystemReleased(this);
		members.Clear();
		base.Dispose();
	}
}