Component that presents and simulates an optional tail accessory for a character. It loads tail models and textures, creates/overrides materials, merges with a body renderer, simulates secondary motion with a simple physics-like solver, and drives bone overrides into a native skinned renderer for rendering and preview panels.
using System;
namespace CharacterEditor;
/// <summary>
/// An optional accessory with its own small skeleton. The body supplies only the
/// pelvis attachment: clothing fitting, human morphs and face deformers never run
/// over the tail. Its spring nodes and material copies are retained between frames.
/// </summary>
public sealed class CharacterTailPresentation : Component
{
public const int MaximumBones = 10;
public const string ModelDirectory = "models/characterkit/tails/";
readonly int[] indices = new int[MaximumBones];
readonly Transform[] rest = new Transform[MaximumBones];
readonly Transform[] animationPose = new Transform[MaximumBones];
readonly Vector3[] restOffsets = new Vector3[MaximumBones];
readonly Vector3[] boneScales = new Vector3[MaximumBones];
readonly Vector3[] motionAngles = new Vector3[MaximumBones];
readonly Vector3[] angularVelocities = new Vector3[MaximumBones];
readonly Dictionary<int, Material> materials = new();
readonly Dictionary<int, Material> materialSources = new();
SkinnedModelRenderer body;
Model bodyModel, tailModel, boneLookupModel;
Transform pelvisRest, spineRest;
BoneCollection.Bone pelvisBone, spineBone;
string pelvisName, spineName, skinKey;
Vector3 attachment = new(-4.35f, 0, 37.6f);
Vector3 lastRoot;
Vector3 rootPosition, rootScale = Vector3.One;
Rotation rootRotation, rootInverse;
Rotation lastRotation;
float lengthScale = 1, clock, pendingTime, renderTime, idleBlend;
float silhouetteMargin = 3;
float turnDeflection, liftDeflection;
uint idleGestureSeed;
bool graphInitialized;
float animationTime;
bool reset = true, animatedLastFrame, phaseInitialized, gestureSeedInitialized;
bool hasPreviewTransform;
bool previewSuspended, poseCacheReady, hasPosedBounds;
Transform previewTransform;
BBox posedBounds;
ulong lastFrame = ulong.MaxValue;
ulong graphFrame = ulong.MaxValue;
bool worldGraphFresh;
Color skinTint = Color.White;
Texture skinTexture;
public SkinnedModelRenderer Renderer { get; private set; }
bool manualUpdate;
public bool ManualUpdate
{
get => manualUpdate;
set
{
manualUpdate = value;
if (value && Renderer.IsValid()) Renderer.PlaybackRate = 0;
}
}
public bool Animate { get; set; } = true;
/// <summary>Weight of the additive movement response. Zero retains graph animation.</summary>
public float InertiaStrength { get; set; } = 1;
/// <summary>Native graph renderer. tail_action: 0 automatic, 1 quiet idle, 2–13 paired gestures.</summary>
public void SetAction(CharacterTailAction action)
{
if (!Renderer.IsValid()) return;
int value = Math.Clamp((int)action, 0, 13);
Renderer.Parameters.Set("tail_action", value);
// Manual ScenePanel updates can happen before the scene animation system
// flushes renderer parameters, so also update the native graph immediately.
Renderer.SceneModel?.SetAnimParameter("tail_action", value);
}
public float AnimationTime => animationTime;
public int SimulatedSegments { get; private set; } = 7;
public bool IsAnimating => Animate && Renderer.IsValid() && Renderer.Enabled;
public static string ModelPath(string tip) => ModelDirectory + "demon_" + tip + ".vmdl";
public static async System.Threading.Tasks.Task Load(CharacterAppearanceData data)
{
var model = await Model.LoadAsync(ModelPath(data.Tail.Tip));
if (model is null || model.IsError)
throw new InvalidOperationException("The selected demon tail mesh is unavailable.");
string skinId = System.IO.Path.GetFileNameWithoutExtension(data.SkinPath);
var color = await Texture.LoadAsync($"textures/characterkit/tails/{skinId}_{data.SkinStyle}_color.vtex");
if (color is null || color.IsError)
throw new InvalidOperationException("The selected tail skin is unavailable.");
color.MarkUsed(1024);
foreach (var map in new[] { "normal", "agenormal", "combinedmasks" })
{
var texture = await Texture.LoadAsync($"textures/characterkit/tails/tail_{map}.vtex");
if (texture is null || texture.IsError)
throw new InvalidOperationException("The demon tail skin map is unavailable: " + map);
texture.MarkUsed(1024);
}
}
/// <summary>Conservative posed-box test, so face-only previews can stay cached.</summary>
public bool CouldBeVisible(CameraComponent camera, float aspect)
{
if (tailModel is null || !camera.IsValid() || !body.IsValid()) return false;
if (!hasPosedBounds) return true;
UpdateAttachment();
float horizontal = MathF.Tan(camera.FieldOfView * MathF.PI / 360);
float vertical = horizontal / MathF.Max(.1f, aspect);
var inverse = camera.WorldRotation.Inverse;
int outside = 31;
// Use the posed shaft, including its extended tip, rather than the
// curled asset bounds. A small margin covers the next animation step.
var minimum = posedBounds.Mins - Vector3.One * 2;
var maximum = posedBounds.Maxs + Vector3.One * 2;
for (int corner = 0; corner < 8; corner++)
{
var point = new Vector3((corner & 1) == 0 ? minimum.x : maximum.x,
(corner & 2) == 0 ? minimum.y : maximum.y, (corner & 4) == 0 ? minimum.z : maximum.z);
var local = inverse * (ToWorld(point) - camera.WorldPosition);
int mask = local.x < camera.ZNear ? 1 : 0;
if (local.y > local.x * horizontal) mask |= 2;
if (local.y < -local.x * horizontal) mask |= 4;
if (local.z > local.x * vertical) mask |= 8;
if (local.z < -local.x * vertical) mask |= 16;
outside &= mask;
}
return outside == 0;
}
public void Configure(SkinnedModelRenderer target, CharacterAppearanceData data)
{
body = target;
InitializeGestureSeed();
if (!phaseInitialized)
{
clock = (idleGestureSeed & 0xffff) * .001f;
phaseInitialized = true;
}
if (bodyModel != body.Model || boneLookupModel != body.Model)
{
boneLookupModel = bodyModel = body.Model;
pelvisName = null;
spineName = null;
for (int i = 0; i < bodyModel.BoneCount; i++)
{
if (bodyModel.GetBoneName(i).Equals("pelvis", StringComparison.OrdinalIgnoreCase))
{
pelvisName = bodyModel.GetBoneName(i);
pelvisBone = bodyModel.Bones.AllBones.First(b => b.Index == i);
pelvisRest = bodyModel.GetBoneTransform(i);
}
if (bodyModel.GetBoneName(i).Equals("spine_0", StringComparison.OrdinalIgnoreCase))
{
spineName = bodyModel.GetBoneName(i);
spineBone = bodyModel.Bones.AllBones.First(b => b.Index == i);
spineRest = bodyModel.GetBoneTransform(i);
}
}
reset = true;
poseCacheReady = false;
}
var model = Model.Load(ModelPath(data.Tail.Tip));
if (tailModel != model || !Renderer.IsValid() || indices[0] < 0)
{
if (!Renderer.IsValid()) Renderer = GameObject.Components.Create<SkinnedModelRenderer>();
Renderer.Model = tailModel = model;
Renderer.UseAnimGraph = true;
// This accessory owns its clock so hidden previews and distant tails
// can suspend. Advance evaluates the native graph once per accepted update.
Renderer.PlaybackRate = 0;
graphInitialized = false;
Renderer.DeformationMode = SkinnedModelRenderer.DeformationModeType.None;
Renderer.BoneMergeTarget = null;
materials.Clear();
materialSources.Clear();
skinKey = null;
for (int joint = 0; joint < MaximumBones; joint++)
{
indices[joint] = -1;
string name = $"ck_tail_{joint:00}";
for (int i = 0; i < model.BoneCount; i++)
if (model.GetBoneName(i) == name)
{
indices[joint] = i;
rest[joint] = model.GetBoneTransform(i);
animationPose[joint] = rest[joint];
break;
}
}
reset = true;
}
var scale = .38f + .67f * data.Tail.Length;
int segments = 3 + (int)MathF.Round(data.Tail.Length * 6);
if (lengthScale != scale || SimulatedSegments != segments)
{
lengthScale = scale;
SimulatedSegments = segments;
reset = true;
}
for (int i = 0; i < MaximumBones; i++)
{
restOffsets[i] = i == 0 ? Vector3.Zero : rest[i - 1].Rotation.Inverse * (rest[i].Position - rest[i - 1].Position);
boneScales[i] = rest[i].Scale * lengthScale;
}
poseCacheReady = false;
InitializePoseCache();
// Uniform joint scale avoids the bulges that anisotropic matrices create
// between curved, blended joints. An authored radial morph cancels only
// the girth change, leaving the shortening coherent along the whole mesh.
if (Renderer.Morphs.Names.Contains("ck_tail_girth"))
Renderer.Morphs.Set("ck_tail_girth", (1 / lengthScale - 1) / (1 / .38f - 1), 0);
// Authored deltas include the shortest-tail compensation. Dividing by the
// current uniform scale preserves girth and tip proportions. Additional bulk
// is bounded on very short shafts to respect their tighter bend radius.
if (Renderer.Morphs.Names.Contains("ck_tail_thickness"))
{
Renderer.Morphs.Set("ck_tail_thickness", data.Tail.ShaftBulk * .38f / lengthScale, 0);
float size = data.Tail.TipScale;
Renderer.Morphs.Set("ck_tail_tip_small", MathF.Max(0, 1 - size) * .76f / lengthScale, 0);
Renderer.Morphs.Set("ck_tail_tip_large", MathF.Max(0, size - 1) * .38f / lengthScale, 0);
}
silhouetteMargin = MathF.Max(3 + 5 * data.Tail.Thickness, 6 * data.Tail.TipScale);
Renderer.SceneModel?.Attributes.Set("TailTipColor", data.Tail.EffectiveTipColor(data));
var root = AttachmentPoint(data);
if (root != attachment)
{
attachment = root;
reset = true;
}
ApplySkin(data);
if (reset) Advance(0, false);
}
// The tail joins the upper sacrum, above the buttock lobes. Broad torso edits
// move the root with the skin; local chest/leg/face edits do not move it.
public static Vector3 AttachmentPoint(CharacterAppearanceData data) => CharacterTailAttachment.RestPoint(data);
void ApplySkin(CharacterAppearanceData data)
{
var key = $"{data.SkinPath}|{data.SkinStyle}|{data.SkinColor}|{data.NormalizeSkinTone}|{data.Tail.CustomTipColor}|{data.Tail.Tip}|{data.Tail.EffectiveTipColor(data)}";
if (key == skinKey) return;
skinKey = key;
// These authored tiles retain a clean patch of each selected body's skin.
// They are baked once, not read back or reconstructed while playing.
string skinId = System.IO.Path.GetFileNameWithoutExtension(data.SkinPath);
skinTexture = Texture.Load($"textures/characterkit/tails/{skinId}_{data.SkinStyle}_color.vtex");
skinTint = data.SkinColor;
Material bodySurface = null;
for (int i = 0; i < body.Materials.Count; i++)
{
var original = body.Materials.GetOriginal(i);
if (original is null || !original.Name.Contains("/bodies/", StringComparison.OrdinalIgnoreCase)) continue;
var source = UnifiedHuman.SkinMaterial(original, data.SkinPath, data.BaseShape is not null);
bodySurface = body.Materials.GetOverride(i) ?? source;
skinTint = SkinColorPresentation.MaterialTint(source, data.SkinColor, data.NormalizeSkinTone)
* (data.BaseShape is not null ? UnifiedHuman.BodyToneTint(source, data.SkinPath) : Color.White);
break;
}
for (int i = 0; i < Renderer.Materials.Count; i++)
{
var original = Renderer.Materials.GetOriginal(i);
if (original is null) continue;
if (original.Name.Contains("/legacy_hair/", StringComparison.OrdinalIgnoreCase))
{
// Native head-hair material and strand atlas, with one retained material
// instance for this tuft. No new simulation, skeleton or renderer.
if (!materials.TryGetValue(i, out var hair) || !materialSources.TryGetValue(i, out var hairSource) || hairSource != original)
{
materials[i] = hair = original.CreateCopy($"character-tail-fur-{Id}-{i}.vmat");
materialSources[i] = original;
}
// Material tint is authored in sRGB; renderer hair tint is linear.
var color = data.Tail.EffectiveTipColor(data);
hair.Set("g_vColorTint", new Color(MathF.Pow(color.r, 1 / 2.2f), MathF.Pow(color.g, 1 / 2.2f), MathF.Pow(color.b, 1 / 2.2f)));
Renderer.Materials.SetOverride(i, hair);
continue;
}
bool tip = original.Name.Contains("tip", StringComparison.OrdinalIgnoreCase);
if (tip && data.Tail.CustomTipColor)
{
Renderer.Materials.SetOverride(i, Material.Load("materials/characterkit/tails/demon_custom_tip.vmat"));
continue;
}
// All solid tips use the same skin surface as the shaft unless recolored.
var source = bodySurface ?? original;
if (!materials.TryGetValue(i, out var material) || !materialSources.TryGetValue(i, out var previous) || previous != source)
{
materials[i] = material = source.CreateCopy($"character-tail-{Id}-{i}.vmat");
materialSources[i] = source;
}
material.Set("g_tColor", skinTexture);
// Start with the body's real skin shader settings, then replace UV-
// dependent maps with authored tail tiles. This retains its native
// subsurface/roughness response instead of a shiny generic material.
material.Set("g_tNormal", Texture.Load("textures/characterkit/tails/tail_normal.vtex"));
material.Set("g_tAgeNormal", Texture.Load("textures/characterkit/tails/tail_agenormal.vtex"));
material.Set("g_tCombinedMasks", Texture.Load("textures/characterkit/tails/tail_combinedmasks.vtex"));
material.Set("g_vColorTint", skinTint);
Renderer.Materials.SetOverride(i, material);
}
}
protected override void OnUpdate()
{
if (ManualUpdate || !Renderer.IsValid()) return;
graphFrame = Application.FrameCount;
worldGraphFresh = false;
Renderer.PlaybackRate = 0;
if (!Renderer.Enabled || !body.IsValid() || !body.Enabled || !Animate) return;
float interval = 0;
var camera = Scene.Camera;
if (camera.IsValid())
{
var difference = body.WorldPosition - camera.WorldPosition;
float distance = difference.Length;
if (distance > 1600 || distance > 100 && Vector3.Dot(difference.Normal, camera.WorldRotation.Forward) < -.25f) return;
interval = distance > 900 ? 1f / 15 : distance > 400 ? 1f / 30 : 0;
}
float elapsed = renderTime + Time.Delta;
if (elapsed < interval || elapsed > .15f) return;
var native = Renderer.SceneModel;
if (native is null) return;
native.ClearBoneOverrides();
if (!graphInitialized)
{
// One-time phase variation before joining normal scene animation.
native.PlaybackRate = 1;
native.Update((idleGestureSeed & 0xffff) * .001f % 9);
graphInitialized = true;
}
// The scene animation stage owns world playback. A throttled update
// advances accumulated time once; no manual native Update follows it.
Renderer.PlaybackRate = elapsed / MathF.Max(Time.Delta, .000001f);
worldGraphFresh = true;
}
protected override void OnPreRender()
{
if (ManualUpdate || lastFrame == Application.FrameCount) return;
lastFrame = Application.FrameCount;
if (!Renderer.IsValid() || !Renderer.Enabled || !body.IsValid() || !body.Enabled)
{
reset = true;
return;
}
float interval = 0;
bool animate = Animate;
var camera = Scene.Camera;
if (camera.IsValid())
{
var difference = body.WorldPosition - camera.WorldPosition;
float distance = difference.Length;
if (distance > 2200 || distance > 100 && Vector3.Dot(difference.Normal, camera.WorldRotation.Forward) < -.25f)
{
reset = true;
renderTime = 0;
return;
}
interval = distance > 1600 ? .2f : distance > 900 ? 1f / 15 : distance > 400 ? 1f / 30 : 0;
// At this size the silhouette matters, not secondary motion. Keep a
// stable authored pose rather than repeatedly restarting a slow solver.
if (distance > 1600) animate = false;
}
renderTime += Time.Delta;
if (renderTime < interval) return;
Advance(renderTime, animate);
renderTime = 0;
}
/// <summary>ScenePanel previews call this explicitly; static cards stay frozen.</summary>
public void TickPreview(float delta, bool animate, bool suspend = false)
{
ManualUpdate = true;
Animate = animate;
if (!body.IsValid()) return;
if (suspend)
{
previewSuspended = true;
return;
}
var transform = body.WorldTransform;
if (!animate && !reset && !animatedLastFrame && hasPreviewTransform && previewTransform == transform) return;
previewTransform = transform;
hasPreviewTransform = true;
Advance(delta, animate);
}
public void Advance(float delta, bool animate)
{
if (!body.IsValid() || !Renderer.IsValid() || Renderer.SceneModel is null || tailModel is null || indices[0] < 0) return;
InitializeGestureSeed();
InitializePoseCache();
UpdateAttachment();
var rotation = rootRotation;
var position = rootPosition;
if (!animate)
{
if (reset || animatedLastFrame || position != lastRoot || rotation != lastRotation)
{
ResetMotion();
ApplyPose(true);
}
lastRoot = position;
lastRotation = rotation;
animatedLastFrame = false;
previewSuspended = false;
return;
}
bool interrupted = previewSuspended || delta > .15f;
if (!animatedLastFrame || !previewSuspended && (position - lastRoot).LengthSquared > 400) reset = true;
if (interrupted)
{
// A hidden preview or ordinary hitch must not snap a mid-gesture
// tail back to its curled bind pose. Resume from the retained pose,
// dropping stale motion input and missed time instead of that pose.
delta = 0;
turnDeflection = liftDeflection = 0;
for (int i = 0; i <= SimulatedSegments; i++) angularVelocities[i] = Vector3.Zero;
}
if (!reset && !interrupted)
{
float dt = MathF.Max(.001f, delta);
var localVelocity = rootInverse * ((position - lastRoot) / dt);
var previousForward = rootInverse * lastRotation.Forward;
float turnDelta = MathF.Atan2(previousForward.y, previousForward.x) * (180 / MathF.PI);
float turnSpeed = turnDelta / dt;
turnDeflection = Math.Clamp(turnSpeed * .08f + localVelocity.y * .14f, -32, 32);
liftDeflection = Math.Clamp(localVelocity.x * .07f - localVelocity.z * .08f, -12, 12);
for (int i = 1; i <= SimulatedSegments; i++)
{
float fraction = (float)i / SimulatedSegments;
float envelope = fraction * fraction * (3 - 2 * fraction);
motionAngles[i].y += turnDelta * .65f * envelope;
ConstrainMotion(i, envelope);
}
}
lastRoot = position;
lastRotation = rotation;
animatedLastFrame = true;
previewSuspended = false;
if (reset) ResetMotion();
pendingTime = MathF.Min(.1f, pendingTime + Math.Clamp(delta, 0, .1f));
const float step = 1f / 120;
while (pendingTime >= step)
{
clock += step;
Simulate(step);
pendingTime -= step;
}
ApplyPose(false, Math.Clamp(delta, 0, .1f));
}
void UpdateAttachment()
{
var rotation = body.WorldRotation;
var position = body.WorldPosition + rotation * attachment;
if (pelvisName is not null && body.TryGetBoneTransform(pelvisBone, out var pelvis))
{
rotation = pelvis.Rotation * pelvisRest.Rotation.Inverse;
position = pelvis.Position + rotation * ((attachment - pelvisRest.Position) * body.WorldScale);
if (spineName is not null && body.TryGetBoneTransform(spineBone, out var spine))
{
var spineRotation = spine.Rotation * spineRest.Rotation.Inverse;
var spinePosition = spine.Position + spineRotation * ((attachment - spineRest.Position) * body.WorldScale);
position = Vector3.Lerp(position, spinePosition, .25f);
rotation = Rotation.Slerp(rotation, spineRotation, .25f);
}
}
GameObject.WorldTransform = new Transform(position, rotation, body.WorldScale);
rootPosition = position;
rootRotation = rotation;
rootInverse = rotation.Inverse;
rootScale = body.WorldScale;
}
Vector3 ToWorld(Vector3 point) => rootPosition + rootRotation * (point * rootScale);
void ResetMotion()
{
for (int i = 0; i <= SimulatedSegments; i++)
{
motionAngles[i] = Vector3.Zero;
angularVelocities[i] = Vector3.Zero;
}
pendingTime = 0;
idleBlend = 0;
turnDeflection = liftDeflection = 0;
reset = false;
}
void Simulate(float step)
{
idleBlend = MathF.Min(1, idleBlend + step * 2);
for (int i = 1; i <= SimulatedSegments; i++)
{
float fraction = (float)i / SimulatedSegments;
float envelope = fraction * fraction * (3 - 2 * fraction);
var target = new Vector3(liftDeflection, turnDeflection, 0) * envelope;
// The base answers first; the softer tip follows and briefly settles
// after a stop. Angular limits keep this from becoming a loose whip.
float stiffness = 150 - 105 * fraction;
float damping = 22 - 13 * fraction;
angularVelocities[i] += ((target - motionAngles[i]) * stiffness - angularVelocities[i] * damping) * step;
motionAngles[i] += angularVelocities[i] * step;
ConstrainMotion(i, envelope);
}
}
void ConstrainMotion(int index, float envelope)
{
float yawLimit = 36 * envelope, liftLimit = 16 * envelope;
motionAngles[index].y = Math.Clamp(motionAngles[index].y, -yawLimit, yawLimit);
motionAngles[index].x = Math.Clamp(motionAngles[index].x, -liftLimit, liftLimit);
if (MathF.Abs(motionAngles[index].y) >= yawLimit && motionAngles[index].y * angularVelocities[index].y > 0)
angularVelocities[index].y = 0;
if (MathF.Abs(motionAngles[index].x) >= liftLimit && motionAngles[index].x * angularVelocities[index].x > 0)
angularVelocities[index].x = 0;
}
Vector3 SampleMotion(float fraction)
{
float location = Math.Clamp(fraction, 0, 1) * SimulatedSegments;
int index = Math.Min(SimulatedSegments - 1, (int)location);
float t = location - index;
var a = motionAngles[Math.Max(0, index - 1)];
var b = motionAngles[index];
var c = motionAngles[index + 1];
var d = motionAngles[Math.Min(SimulatedSegments, index + 2)];
return .5f * ((2 * b) + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t * t + (-a + 3 * b - 3 * c + d) * t * t * t);
}
void InitializeGestureSeed()
{
if (gestureSeedInitialized) return;
idleGestureSeed = unchecked((uint)Id.GetHashCode());
gestureSeedInitialized = true;
}
void InitializePoseCache()
{
if (poseCacheReady) return;
for (int i = 0; i < MaximumBones; i++)
restOffsets[i] = i == 0 ? Vector3.Zero : rest[i - 1].Rotation.Inverse * (rest[i].Position - rest[i - 1].Position);
poseCacheReady = true;
}
static void IncludePoint(ref Vector3 minimum, ref Vector3 maximum, Vector3 point)
{
minimum = new Vector3(MathF.Min(minimum.x, point.x), MathF.Min(minimum.y, point.y), MathF.Min(minimum.z, point.z));
maximum = new Vector3(MathF.Max(maximum.x, point.x), MathF.Max(maximum.y, point.y), MathF.Max(maximum.z, point.z));
}
void ApplyPose(bool staticPose = false, float delta = 0)
{
var sceneModel = Renderer.SceneModel;
bool sceneDriven = !ManualUpdate && graphFrame == Application.FrameCount;
if (!staticPose)
{
if (!sceneDriven)
{
// ScenePanel previews have an explicit clock, independent of the
// engine's world animation stage and the user's play-session time.
sceneModel.Transform = GameObject.WorldTransform;
sceneModel.ClearBoneOverrides();
float advance = delta;
if (!graphInitialized)
{
advance += (idleGestureSeed & 0xffff) * .001f % 9;
graphInitialized = true;
}
sceneModel.PlaybackRate = 1;
sceneModel.Update(advance);
sceneModel.PlaybackRate = 0;
}
animationTime += delta;
if (!sceneDriven || worldGraphFresh)
{
// Read in the animation stage's transform before moving the renderer
// to this frame's newly animated pelvis attachment.
var inverse = sceneModel.Transform.Rotation.Inverse;
for (int i = 0; i < MaximumBones; i++)
{
var world = sceneModel.GetBoneWorldTransform(indices[i]);
animationPose[i] = new Transform(Vector3.Zero, inverse * world.Rotation, Vector3.One);
}
}
}
sceneModel.Transform = GameObject.WorldTransform;
var previous = new Transform(Vector3.Zero, Rotation.Identity, Vector3.One);
var minimum = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
var maximum = -minimum;
float strength = float.IsFinite(InertiaStrength) ? Math.Clamp(InertiaStrength, 0, 1) : 1;
for (int i = 0; i < MaximumBones; i++)
{
if (indices[i] < 0) continue;
var rotation = rest[i].Rotation;
if (!staticPose)
{
var motion = SampleMotion(i / (float)(MaximumBones - 1)) * strength;
var animated = Rotation.Slerp(rest[i].Rotation, animationPose[i].Rotation, idleBlend);
rotation = Rotation.FromYaw(motion.y) * Rotation.FromAxis(Vector3.Left, motion.x) * animated;
}
// FK preserves authored link lengths through graph crossfades and inertia.
var point = i == 0 ? rest[0].Position * lengthScale
: previous.Position + previous.Rotation * (restOffsets[i] * lengthScale);
var transform = new Transform(point, rotation, boneScales[i]);
// Keep the complete pose together. Direct world-bone writes do not
// finalize the skinning palette used by the renderer.
sceneModel.SetBoneOverride(indices[i], transform);
IncludePoint(ref minimum, ref maximum, point);
previous = transform;
}
var end = previous.Position + previous.Rotation * (restOffsets[MaximumBones - 1].Normal
* (restOffsets[MaximumBones - 1].Length * lengthScale + 3));
IncludePoint(ref minimum, ref maximum, end);
posedBounds = new BBox(minimum - Vector3.One * silhouetteMargin, maximum + Vector3.One * silhouetteMargin);
hasPosedBounds = true;
sceneModel.LocalBounds = posedBounds;
// Commit overrides through the native bone-update path, with graph time
// paused. This publishes one coherent skinning palette without advancing
// the authored animation again.
float playbackRate = sceneModel.PlaybackRate;
sceneModel.PlaybackRate = 0;
sceneModel.Update(0);
sceneModel.PlaybackRate = playbackRate;
}
}
/// <summary>Optional action selection on the tail's independent native AnimGraph.</summary>
public enum CharacterTailAction
{
Automatic, Idle, SwishLeft, SwishRight, FlickLeft, FlickRight, StretchLeft, StretchRight,
SideCurlLeft, SideCurlRight, SWaveLeft, SWaveRight, CurlLiftLeft, CurlLiftRight
}