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.
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();
}
}