diff --git a/Assets/Gothic-Core/Editor/Tools/AnimationSystemWindowTool.cs b/Assets/Gothic-Core/Editor/Tools/AnimationSystemWindowTool.cs index 11ef61c17..a0d4a423b 100644 --- a/Assets/Gothic-Core/Editor/Tools/AnimationSystemWindowTool.cs +++ b/Assets/Gothic-Core/Editor/Tools/AnimationSystemWindowTool.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using Gothic.Core.Adapters; using Gothic.Core.Adapters.Animations; using Gothic.Core.Adapters.Npc; using Gothic.Core.Const; @@ -59,7 +58,6 @@ private void OnGUI() DrawAiActionInfo(); DrawBreakpointInfo(); DrawAnimationInfo(); - DrawBoneStates(); EditorGUILayout.EndScrollView(); @@ -106,7 +104,7 @@ private void DrawNpcSelection() var no = 0; // Add additional empty element to the Dictionary _animationSystems = emptyElement - .Concat(FindObjectsOfType() + .Concat(FindObjectsByType(FindObjectsSortMode.None) .Select(animComp => new { animComp.GetComponentInParent().name, animComp })) .ToDictionary(i => $"#{no++} - {i.name}", i => i.animComp); // We need to have a unique key for the Dict as e.g. Meatbug will be there multiple times. } @@ -256,78 +254,43 @@ private void DrawAnimationInfo() { DrawDivider(); + var originalBackgroundColor = GUI.backgroundColor; + EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("Layer - Animation", EditorStyles.boldLabel); EditorGUILayout.LabelField("Time x/y - State", EditorStyles.boldLabel); + EditorGUILayout.LabelField("Blend Weight", EditorStyles.boldLabel); EditorGUILayout.EndHorizontal(); - // Access the currently playing animations from selected AnimationSystem + // Access the currently playing animations from selected AnimationSystem. + // Per-bone weights are gone: bone subsets are handled per track inside AnimationPoseJob. foreach (var trackInstance in _targetAnimationSystem.DebugTrackInstances) { EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField($"{trackInstance.Track.Layer:D2} - {trackInstance.Track.AliasName ?? trackInstance.Track.Name}"); EditorGUILayout.LabelField( $"{trackInstance.CurrentTime:F2} / {trackInstance.Track.Duration:F2} - {trackInstance.State}"); - EditorGUILayout.EndHorizontal(); - } - } - - private void DrawBoneStates() - { - DrawDivider(); - - var originalBackgroundColor = GUI.backgroundColor; - - EditorGUILayout.Space(10); - EditorGUILayout.LabelField("Bone States", EditorStyles.boldLabel); - EditorGUILayout.Space(); - // "" | "S_WALK" | "T_DIALOGGESTURE_00" | ... - EditorGUILayout.BeginHorizontal(); - EditorGUILayout.LabelField("-"); - foreach (var trackInstance in _targetAnimationSystem.DebugTrackInstances) - { - EditorGUILayout.LabelField(trackInstance.Track.AliasName ?? trackInstance.Track.Name); - } - EditorGUILayout.EndHorizontal(); - - // "L_ARM" | BlendIn(0.32f) /--- / | Play(1.00f) /-----/ | ... - foreach (var boneName in _targetAnimationSystem.DebugBoneNames) - { - EditorGUILayout.BeginHorizontal(); - EditorGUILayout.LabelField(boneName); - foreach (var trackInstance in _targetAnimationSystem.DebugTrackInstances) + switch (trackInstance.State) { - var boneIndex = Array.IndexOf(trackInstance.Track.BoneNames, boneName); - - if (boneIndex == -1) - { - EditorGUILayout.LabelField("-"); - continue; - } - - switch (trackInstance.BoneStates[boneIndex]) - { - case AnimationState.None: - case AnimationState.BlendIn: - case AnimationState.Play: - GUI.backgroundColor = Color.Lerp(Color.red, Color.green, trackInstance.BoneBlendWeights[boneIndex]); - break; - case AnimationState.BlendOut: - case AnimationState.Stop: - GUI.backgroundColor = Color.Lerp(Color.grey, Color.green, trackInstance.BoneBlendWeights[boneIndex]); - break; - default: - throw new ArgumentOutOfRangeException(); - } - - // Reserve a rectangle for the progress bar - var progressRect = EditorGUILayout.GetControlRect(GUILayout.Height(20)); - EditorGUI.ProgressBar(progressRect, trackInstance.BoneBlendWeights[boneIndex], - $"{trackInstance.BoneStates[boneIndex]}({trackInstance.BoneBlendWeights[boneIndex]:F2})"); + case AnimationState.None: + case AnimationState.BlendIn: + case AnimationState.Play: + GUI.backgroundColor = Color.Lerp(Color.red, Color.green, trackInstance.Weight); + break; + case AnimationState.BlendOut: + case AnimationState.Stop: + GUI.backgroundColor = Color.Lerp(Color.grey, Color.green, trackInstance.Weight); + break; + default: + throw new ArgumentOutOfRangeException(); } - EditorGUILayout.EndHorizontal(); + + var progressRect = EditorGUILayout.GetControlRect(GUILayout.Height(20)); + EditorGUI.ProgressBar(progressRect, trackInstance.Weight, $"{trackInstance.State}({trackInstance.Weight:F2})"); GUI.backgroundColor = originalBackgroundColor; + + EditorGUILayout.EndHorizontal(); } } diff --git a/Assets/Gothic-Core/Scripts/Adapters/Animations/AnimationSystem.cs b/Assets/Gothic-Core/Scripts/Adapters/Animations/AnimationSystem.cs index 3ca3351ec..54e15316f 100644 --- a/Assets/Gothic-Core/Scripts/Adapters/Animations/AnimationSystem.cs +++ b/Assets/Gothic-Core/Scripts/Adapters/Animations/AnimationSystem.cs @@ -3,7 +3,6 @@ using System.Linq; using Gothic.Core.Adapters.Animations.Morph; using Gothic.Core.Adapters.Npc; -using Gothic.Core.Const; using Gothic.Core.Extensions; using Gothic.Core.Logging; using Gothic.Core.Manager; @@ -13,7 +12,10 @@ using Gothic.Core.Services.Vobs; using MyBox; using Reflex.Attributes; +using Unity.Collections; using UnityEngine; +using UnityEngine.Animations; +using UnityEngine.Playables; using ZenKit; using AnimationState = Gothic.Core.Models.Animations.AnimationState; using EventType = ZenKit.EventType; @@ -24,13 +26,18 @@ namespace Gothic.Core.Adapters.Animations /// /// NPC component to handle animations. The Blending is using the official Gothic animation information: /// https://www.worldofgothic.de/modifikation/index.php?go=animationen + /// + /// Gothic's layer/blend model is executed by AnimationPoseJob inside a per-NPC PlayableGraph: the job + /// samples the baked .man data and applies tracks in (Gothic layer ASC, creation time ASC) order, each + /// overriding exactly the bones it drives at its blend weight, on top of the skeleton rest pose. + /// This class manages the Gothic runtime state feeding that job: playback clocks, blend-weight ramps, + /// animation events, root motion, and idle fallback. /// public class AnimationSystem : BasePlayerBehaviour { #if UNITY_EDITOR // These properties are normally private. For the Debug Window in Editor Mode, we allow to read them. public List DebugTrackInstances => _trackInstances; - public string[] DebugBoneNames => _boneNames; public bool DebugPauseAtPlayAnimation; public bool DebugPauseAtStopAnimation; @@ -43,18 +50,46 @@ public class AnimationSystem : BasePlayerBehaviour [Inject] private readonly NpcService _npcService; - public Transform RootBone; - - // Caching bone Transforms makes it faster to apply them to animations later. - private string[] _boneNames; + // Initial bone pose is needed to reset culled-out NPCs to an idle starting state. private Transform[] _bones; private Vector3[] _initialMeshBonePos; private Quaternion[] _initialMeshBoneRot; + + private Animator _animator; + private PlayableGraph _graph; + private AnimationScriptPlayable _posePlayable; + private AnimationSkeleton _skeleton; + // Stream handles to the bone transforms, index == mdh node index (matches AnimationTrack.BoneToNode). + private NativeArray _handles; + // Pose of the previous job evaluation (initialized with the rest pose). Bones without an active track + // keep their last pose (Gothic behavior) instead of snapping back to rest - e.g. s_Bench_S1 doesn't + // drive BIP01 and relies on the height the sit-down transition left it at. + private NativeArray _posePositions; + private NativeArray _poseRotations; + // Reusable buffer for the per-frame job weights (see CalculateSlotWeights). + private float[] _slotWeights = new float[AnimationPoseJob.MaxTracks]; + + // Walk capsule following the animated root height (see UpdateRootCollider). + private CapsuleCollider _walkCapsule; + private float _walkCapsuleBaseRadius; + private float _restRootHeight; + private Transform _rootBone; + private float _appliedRootHeightOffset; + // Re-size only on real pose changes (kneeling, flying, jumps) - not for the few-cm bob of walk cycles. + private const float _rootColliderUpdateThreshold = 0.05f; + private List _trackInstances = new(); + // Reusable snapshot for Update(): instances can be added (NextAni/idle) or removed while iterating. + private List _updateSnapshot = new(); private bool _isSittingInverted; + private Quaternion _lastInvertedRotation; - // Some sitting animations are rotated wrong. They need to be inverted in y-axis. - private string[] _animationsToInvertYAxis = { "S_BENCH_S1", "S_THRONE_S1" }; + // Cached to avoid a delegate allocation per PlayAnimation call. + private static readonly Comparison _trackOrderComparison = (instanceA, instanceB) => + { + var layerComparison = instanceA.Track.Layer.CompareTo(instanceB.Track.Layer); + return layerComparison != 0 ? layerComparison : instanceA.CreationTime.CompareTo(instanceB.CreationTime); + }; // Attack information @@ -76,13 +111,246 @@ protected override void Awake() private void Start() { - Dictionary bones = new(); - CollectBones(RootBone, bones); + var bones = new List(); + // Collect from the NPC root, not RootBone: some skeletons (e.g. Bloodfly's "BIP01 CENTER") are + // created as siblings of the prefab's BIP01 and would be missed otherwise. + CollectBones(Go.transform, bones); + + _bones = bones.ToArray(); + _initialMeshBonePos = _bones.Select(i => i.localPosition).ToArray(); + _initialMeshBoneRot = _bones.Select(i => i.localRotation).ToArray(); + + CreateGraph(); + ResizeRootCollider(); + } + + /// + /// Every clip pins the skeleton root to local zero horizontally (vertically it poses an offset around + /// the rest height), so physics decides how high the NPC stands: it settles where the walk capsule + /// touches the ground. The capsule (reparented under the NPC root by RootCollisionHandler) must + /// therefore end exactly at foot level = RootTranslation.y below the NPC root. The prefab default + /// (1m, human-sized) makes smaller skeletons like Molerat or Gobbo hover above the ground. + /// + private void ResizeRootCollider() + { + var rootHeight = _animationService.GetRootBoneHeight(Properties.MdsNameBase); + var colliderTransform = PrefabProps.ColliderRootMotion; + + if (rootHeight <= 0f || colliderTransform == null || + !colliderTransform.TryGetComponent(out var capsule)) + { + return; + } + + _walkCapsule = capsule; + _restRootHeight = rootHeight; + // Unity clamps height to 2*radius, so the radius is reduced for skeletons smaller than the capsule. + _walkCapsuleBaseRadius = Mathf.Min(capsule.radius, rootHeight); + + UpdateRootCollider(0f); + } + + /// + /// Follow the animated root height with the walk capsule. All values are local to the NPC root (the + /// capsule's parent): the bottom always stays at foot level (physics settles the NPC on it - it must + /// not move, or the NPC would re-settle), while the top tracks the root bone's baked Y offset: + /// kneeling (s_Pray) or sitting poses shrink the capsule, flying (Bloodfly) or jumping raises it. + /// offset == 0 yields the rest pose capsule, symmetric around the root bone's rest height + /// (identical to the human prefab: 1m radius around BIP01). + /// + private void UpdateRootCollider(float rootHeightOffset) + { + var bottom = -_restRootHeight; + var top = _restRootHeight + rootHeightOffset; + + // Lying poses can push the root (almost) to the ground - keep a minimal cylinder for collisions. + var minHeight = Mathf.Min(0.2f, _restRootHeight); + var height = Mathf.Max(top - bottom, minHeight); + + _walkCapsule.radius = Mathf.Min(_walkCapsuleBaseRadius, height / 2f); + _walkCapsule.height = height; + _walkCapsule.center = new Vector3(0f, bottom + height / 2f, 0f); + + _appliedRootHeightOffset = rootHeightOffset; + } + + /// + /// The animated root height is only known after the Animator wrote the pose, i.e. in LateUpdate(). + /// + private void FollowRootColliderHeight() + { + if (_walkCapsule == null || _rootBone == null) + return; + + var rootHeightOffset = _rootBone.localPosition.y; + if (Mathf.Abs(rootHeightOffset - _appliedRootHeightOffset) < _rootColliderUpdateThreshold) + return; + + UpdateRootCollider(rootHeightOffset); + } + + /// + /// The graph is created once the bone GameObjects exist (mesh builders run before Start()), as the + /// stream handles bind directly to the bone transforms. + /// + private void CreateGraph() + { + if (_graph.IsValid()) + { + return; + } + + _skeleton = _animationService.GetSkeleton(Properties.MdsNameBase); + if (_skeleton == null) + { + Logger.LogError($"No model hierarchy found for >{Properties.MdsNameBase}< - animations are disabled on {Go.name}.", LogCat.Animation); + return; + } + + _animator = gameObject.TryGetComponent(out var existingAnimator) + ? existingAnimator + : gameObject.AddComponent(); + _animator.applyRootMotion = false; // Root motion is applied manually (see ApplyFinalMovement). + _animator.cullingMode = AnimatorCullingMode.AlwaysAnimate; // NPC culling is handled by our own culling domain. + + _graph = PlayableGraph.Create($"AnimationSystem-{Go.name}"); + _graph.SetTimeUpdateMode(DirectorUpdateMode.GameTime); + + BindBones(); + + _posePositions = new NativeArray(_skeleton.RestPositions, Allocator.Persistent); + _poseRotations = new NativeArray(_skeleton.RestRotations, Allocator.Persistent); + + _posePlayable = AnimationScriptPlayable.Create(_graph, BuildJobData()); + + var output = AnimationPlayableOutput.Create(_graph, "Animation", _animator); + output.SetSourcePlayable(_posePlayable); + + _graph.Play(); + } + + private void BindBones() + { + _handles = new NativeArray(_skeleton.NodeCount, Allocator.Persistent); + + for (var node = 0; node < _skeleton.NodeCount; node++) + { + var boneTransform = transform.Find(_skeleton.Paths[node]); + if (boneTransform == null) + { + // The job skips default handles (IsValid() == false). + Logger.LogWarning($"Bone >{_skeleton.Paths[node]}< not found below {Go.name} - it won't be animated.", LogCat.Animation); + continue; + } + + if (node == _skeleton.RootNodeIndex) + { + // The walk capsule follows this bone's animated height (see FollowRootColliderHeight). + _rootBone = boneTransform; + } + + _handles[node] = _animator.BindStreamTransform(boneTransform); + } + } + + /// + /// Snapshot of all active tracks for AnimationPoseJob. Rebuilt (cheap struct copy) every frame, as + /// playback clocks and blend weights change constantly. Unused slots get filler arrays - the job's + /// safety system requires created arrays even when TrackCount keeps them untouched. + /// + private AnimationPoseJob BuildJobData() + { + var job = new AnimationPoseJob + { + Handles = _handles, + PosePositions = _posePositions, + PoseRotations = _poseRotations, + TrackCount = Mathf.Min(_trackInstances.Count, AnimationPoseJob.MaxTracks) + }; + + // If we ever exceed the slots, drop the lowest layers - they'd be overridden by the higher ones anyway. + var firstInstance = _trackInstances.Count - job.TrackCount; + + CalculateSlotWeights(firstInstance, job.TrackCount); + + for (var slot = 0; slot < job.TrackCount; slot++) + { + var instance = _trackInstances[firstInstance + slot]; + var track = instance.Track; + + job.SetTrack(slot, new AnimationPoseJobTrack + { + Frame = instance.CurrentFrame, + FrameCount = track.BakedFrameCount, + BoneCount = track.BoneCount, + Weight = _slotWeights[slot] + }, track.Positions, track.Rotations, track.BoneToNode); + } + + for (var slot = job.TrackCount; slot < AnimationPoseJob.MaxTracks; slot++) + { + job.SetTrack(slot, default, _skeleton.RestPositions, _skeleton.RestRotations, _skeleton.EmptyBoneMap); + } + + return job; + } + + /// + /// The job applies slots sequentially (lerp over the result so far). For a same-layer crossfade + /// (A blending out while B blends in, Gothic weights summing to ~1) a naive sequential application + /// would leave A only weightA * (1 - weightB) and let the rest pose bleed through. + /// Boost earlier same-layer slots so their final contribution matches their Gothic weight: + /// effective = weight / (1 - sum of later same-layer weights). Across layers the raw weight is kept, + /// as higher layers intentionally override lower ones. + /// + private void CalculateSlotWeights(int firstInstance, int trackCount) + { + var layerTailWeight = 0f; + + for (var slot = trackCount - 1; slot >= 0; slot--) + { + var instance = _trackInstances[firstInstance + slot]; + + var isSameLayerAsNext = slot < trackCount - 1 && + _trackInstances[firstInstance + slot + 1].Track.Layer == instance.Track.Layer; + if (!isSameLayerAsNext) + { + layerTailWeight = 0f; + } + + _slotWeights[slot] = layerTailWeight >= 1f + ? 0f // Fully covered by newer animations on the same layer. + : Mathf.Min(1f, instance.Weight / (1f - layerTailWeight)); + + layerTailWeight += instance.Weight; + } + } + + private void UpdateJobData() + { + if (_graph.IsValid()) + { + _posePlayable.SetJobData(BuildJobData()); + } + } + + private void OnDestroy() + { + if (_graph.IsValid()) + { + _graph.Destroy(); + } + + if (_handles.IsCreated) + { + _handles.Dispose(); + } - _boneNames = bones.Keys.ToArray(); - _bones = bones.Values.ToArray(); - _initialMeshBonePos = _bones.Select(i => i.transform.localPosition).ToArray(); - _initialMeshBoneRot = _bones.Select(i => i.transform.localRotation).ToArray(); + if (_posePositions.IsCreated) + { + _posePositions.Dispose(); + _poseRotations.Dispose(); + } } #if UNITY_EDITOR @@ -95,22 +363,39 @@ private void OnValidate() public void DisableObject() { + _trackInstances.Clear(); + + // Forget the last animated pose - the NPC restarts from an idle rest state when culled in again. + if (_posePositions.IsCreated) + { + _posePositions.CopyFrom(_skeleton.RestPositions); + _poseRotations.CopyFrom(_skeleton.RestRotations); + } + + UpdateJobData(); + // If an NPC is culled out, the old positions are still set. We need to reset them to ensure we have an idle NPC starting. for (var i = 0; i < _bones.Length; i++) { _bones[i].SetLocalPositionAndRotation(_initialMeshBonePos[i], _initialMeshBoneRot[i]); } + // The bones are back at the rest pose, so the walk capsule needs to match it again + // (LateUpdate won't run while the NPC is culled out). + if (_walkCapsule != null) + { + UpdateRootCollider(0f); + } + DisableAttack(); - _trackInstances.Clear(); } - private void CollectBones(Transform bone, Dictionary bones) + private void CollectBones(Transform bone, List bones) { // Bones always start with BIP01. Other elements are Prefab specific. if (bone.name.StartsWith("BIP01") || bone.name.StartsWith("ZS_")) { - bones.Add(bone.name, bone); + bones.Add(bone); } foreach (Transform child in bone) @@ -137,60 +422,60 @@ public bool PlayAnimation(string animationName) return false; } - // FIXME - Now we need to handle animation flags: M - Move and R - Rotate. - // Then S_ROTATEL will work properly and stop once rotated enough. - Logger.LogEditor($"Playing animation: {newTrack.Name}, alias: {newTrack.AliasName ?? "-"} by: {RootBone.parent.parent.name}", LogCat.Animation); + Logger.LogEditor($"Playing animation: {newTrack.Name}, alias: {newTrack.AliasName ?? "-"} by: {Go.name}", LogCat.Animation); if (IsAlreadyPlaying(newTrack)) return true; - var newTrackInstance = new AnimationTrackInstance(newTrack); - var newTrackLayer = newTrackInstance.Track.Layer; - - // Handle existing Track Blending based on layer of new Track. - for (var i = 0; i < _trackInstances.Count; i++) + // Tracks on the same layer blend out with the BlendIn time of the new track. + // Lower/higher layer interplay needs no special handling: AnimationPoseJob applies higher layers + // over lower ones for exactly the bones they drive, and lower layers shine through again on blend out. + foreach (var instance in _trackInstances) { - var trackInstance = _trackInstances[i]; - var trackLayer = trackInstance.Track.Layer; - - if (trackLayer < newTrackLayer) - { - BlendOutTrackBones(trackInstance, newTrackInstance); - } - else if (trackLayer == newTrackLayer) - { - BlendOutTrack(trackInstance, newTrackInstance); - } - else if (trackLayer > newTrackLayer) + if (instance.Track.Layer == newTrack.Layer) { - StopTrackBones(trackInstance, newTrackInstance); + // From Documentation: + // E: Diese Flag sorgt dafür, dass die Ani erst gestartet wird, wenn eine zur Zeit aktive Ani im selben + // Layer ihren letzten Frame erreicht hat und somit beendet wird. + if (newTrack.Flags.HasFlag(AnimationFlags.Queue)) + { + // FIXME - Implement + Logger.LogWarning("AnimationFlags.Queue not implemented yet.", LogCat.Animation); + } + + instance.BlendOutTrack(newTrack.BlendIn); } } - PrePlayAnimation(newTrackInstance); - _trackInstances.Add(newTrackInstance); + // PlayAnimation can be reached from another component's Start() before our own Start() ran. + CreateGraph(); - // As Blending isn't always 1f at each time, we ensure some smoothness by sorting the TrackInstances like: - // ORDER BY Track.Layer DESC AND Instance.CreationTime DESC - // Newer (higher) CreationTime has higher precedence and will "forcefully" turn down the older animation on same layer. - _trackInstances.Sort((instanceA, instanceB) => + var newInstance = new AnimationTrackInstance(newTrack); + + PrePlayAnimation(newInstance); + _trackInstances.Add(newInstance); + + // AnimationPoseJob applies tracks in list order: later entries override earlier ones (for their bones). + // ORDER BY Track.Layer ASC, Instance.CreationTime ASC --> higher Gothic layers and newer instances win. + _trackInstances.Sort(_trackOrderComparison); + + if (_trackInstances.Count > AnimationPoseJob.MaxTracks) { - var layerComparison = instanceB.Track.Layer.CompareTo(instanceA.Track.Layer); // DESC - return layerComparison != 0 ? layerComparison : instanceB.CreationTime.CompareTo(instanceA.CreationTime); // DESC - }); + Logger.LogWarning($"More than {AnimationPoseJob.MaxTracks} animations playing on {Go.name} - the lowest layers are skipped.", LogCat.Animation); + } return true; } private bool IsAlreadyPlaying(AnimationTrack newTrack) { - for (var i = 0; i < _trackInstances.Count; i++) + foreach (var instance in _trackInstances) { - if (newTrack.IsSameAnimation(_trackInstances[i].Track)) + if (newTrack.IsSameAnimation(instance.Track)) { // e.g., t_warn might be called in parallel, when one warning is currently fading out. - if (_trackInstances[i].State == AnimationState.Play || - _trackInstances[i].State == AnimationState.BlendIn) + if (instance.State == AnimationState.Play || + instance.State == AnimationState.BlendIn) { return true; } @@ -207,16 +492,17 @@ public bool PlayIdleAnimation() public float GetAnimationDuration(string animationName) { - for (var i = 0; i < _trackInstances.Count; i++) + foreach (var instance in _trackInstances) { - var instance = _trackInstances[i]; - if (instance.Track.Name.EqualsIgnoreCase(animationName) || instance.Track.AliasName.EqualsIgnoreCase(animationName)) + if (instance.Track.MatchesName(animationName)) { return instance.Track.Duration; } } - return 0f; + // Not playing right now - resolve via track cache instead of returning a bogus 0-duration. + var track = _animationService.GetTrack(animationName, Properties.MdsNameBase, Properties.MdsNameOverlay); + return track?.Duration ?? 0f; } public void StopAnimation(string animationName) @@ -229,113 +515,21 @@ public void StopAnimation(string animationName) } #endif - var trackToStop = _animationService.GetTrack(animationName, Properties.MdsNameBase, Properties.MdsNameOverlay); - Logger.LogEditor($"Stopping animation: {animationName}", LogCat.Animation); - AnimationTrackInstance instanceToStop = null; - // Fetch and blend out Animation. - for (var i = 0; i < _trackInstances.Count; i++) + foreach (var instance in _trackInstances) { - var instance = _trackInstances[i]; - // If animation is found, then mark it as "BlendOut" - if (instance.Track.Name.EqualsIgnoreCase(trackToStop.Name)) - { - instanceToStop = instance; - instance.BlendOutTrack(instance.Track.BlendOut); - - if (AttackAnimation == trackToStop.Name) - AttackAnimation = null; - // Do not break. We could potentially need to stop multiple instances of the same animation. - } - } - - if (instanceToStop == null) - { - return; - } - - // Ramp up bones on animation with lower level as the higher level bones will blend out. - for (var i = 0; i < _trackInstances.Count; i++) - { - var instance = _trackInstances[i]; - if (instance.Track.Name.EqualsIgnoreCase(animationName)) + if (!instance.Track.MatchesName(animationName)) { continue; } - // We BlendIn track bones from lower level animations, but only! if the animation isn't in a full-stop phase (!BlendOut/!Stop) - if (instance.Track.Layer < instanceToStop.Track.Layer && (instance.State is AnimationState.BlendIn or AnimationState.Play)) - { - instance.BlendInBones(instanceToStop.Track.BoneNames, instanceToStop.Track.BlendOut); - } - } - } - - /// - /// Higher level Animations might have only a few bones which might be handled by a lower layer animation. - /// We therefore need to blend out the other animation(s) Bones, not the whole animation. - /// - private void BlendOutTrackBones(AnimationTrackInstance lowerLayerTrack, AnimationTrackInstance higherLayerTrack) - { - lowerLayerTrack.BlendOutBones(higherLayerTrack.Track.BoneNames, higherLayerTrack.Track.BlendIn); - } - - /// - /// Tracks on the same layer will either need to stop immediately or blend out at the current frame. - /// - private void BlendOutTrack(AnimationTrackInstance oldTrack, AnimationTrackInstance newTrack) - { - // From Documentation: - // E: Diese Flag sorgt dafür, dass die Ani erst gestartet wird, wenn eine zur Zeit aktive Ani im selben Layer ihren letzten Frame - // erreicht hat und somit beendet wird. Sinnvoll z.B. in folgenden Fall: ani "s_walk", ani "t_walk_2_stand", ani "s_stand", wobei alle Anis als ASC-Anis vorliegen. - var isStartAtLastFrame = newTrack.Track.Flags.HasFlag(AnimationFlags.Queue); - - if (isStartAtLastFrame) - { - // FIXME - Implement - Logger.LogError("AnimationFlags.Queue not implemented yet.", LogCat.Animation); - } - // else - // { - oldTrack.BlendOutTrack(newTrack.Track.BlendIn); - // } - } - - /// - /// The current instance starts blending out, which means, that lower layer bones can blend in again. - /// - private void BlendInOtherTrackBones(AnimationTrackInstance instanceBlendingOut) - { - foreach (var trackInstance in _trackInstances) - { - if (trackInstance.Track.Layer < instanceBlendingOut.Track.Layer) - { - trackInstance.BlendInBones(instanceBlendingOut.Track.BoneNames, instanceBlendingOut.Track.BlendOut); - } - } - } - - /// - /// If we start a new instance, we need to apply, which bones should not be started, as e.g. T_DIALOGGESTURE_ from - /// a higher level forced the animation to stop bones. - /// - private void StopTrackBones(AnimationTrackInstance lowerLayerTrack, AnimationTrackInstance higherLayerTrack) - { - var bonesToSkip = new List(); - for (var i = 0; i < higherLayerTrack.Track.BoneCount; i++) - { - // TODO - If a higher layer bone is blending out, we should align lower level bone blend in times so that we have 1f weight at all time. - // If the animation has bones in a BlendOut state, we do not skip them for our lower level animation. - if (higherLayerTrack.BoneStates[i] == AnimationState.BlendOut) - { - continue; - } + instance.BlendOutTrack(instance.Track.BlendOut); - bonesToSkip.Add(higherLayerTrack.Track.BoneNames[i]); + if (instance.Track.MatchesName(AttackAnimation)) + AttackAnimation = null; + // Do not break. We could potentially need to stop multiple instances of the same animation. } - - lowerLayerTrack.BlendOutBones(bonesToSkip.ToArray(), 0f); } /// @@ -365,16 +559,15 @@ private void Update() return; } - // Update all tracks - // ToArray() -> We need to copy the array, as we are modifying it. - foreach (var instance in _trackInstances.ToArray()) + // Iterate over a snapshot: NextAni chaining and the idle fallback add new instances (and re-sort) while we loop. + _updateSnapshot.Clear(); + _updateSnapshot.AddRange(_trackInstances); + foreach (var instance in _updateSnapshot) { switch (instance.Update(Time.deltaTime)) { case AnimationState.None: - break; case AnimationState.BlendIn: - break; case AnimationState.Play: break; case AnimationState.BlendOut: @@ -382,34 +575,49 @@ private void Update() { PlayAnimation(instance.Track.NextAni); } - - BlendInOtherTrackBones(instance); + CheckAndSetIdleAnimation(); break; case AnimationState.Stop: PreStopAnimation(instance); _trackInstances.Remove(instance); + + // Externally stopped tracks (e.g. AI_StopAni, end of a walk) never pass through the + // BlendOut case above. Without this check an NPC whose last animation was stopped + // would freeze in the rest pose instead of falling back to its breathing idle. + CheckAndSetIdleAnimation(); break; default: throw new ArgumentOutOfRangeException(); } } - ApplyFinalPose(); + // Feed the updated clocks and blend weights into the animation job. Posing itself happens there. + UpdateJobData(); + ApplyFinalMovement(); - ApplyFinalRotation(); ApplyEvents(); } + /// + /// The Animator evaluates after Update() and would overwrite transform changes made there. + /// Pose post-processing therefore needs to happen in LateUpdate(). + /// + private void LateUpdate() + { + FollowRootColliderHeight(); + ApplyFinalRotation(); + } + private void PrePlayAnimation(AnimationTrackInstance instance) { - if (_animationsToInvertYAxis.Contains(instance.Track.Name.ToUpper())) + if (instance.Track.InvertYAxis) _isSittingInverted = true; } - + private void PreStopAnimation(AnimationTrackInstance instance) { - if (_animationsToInvertYAxis.Contains(instance.Track.Name.ToUpper())) + if (instance.Track.InvertYAxis) _isSittingInverted = false; if (AttackAnimation.EqualsIgnoreCase(instance.AnimationName)) @@ -430,86 +638,19 @@ private void DisableAttack() // FIXME - We need to disable all limbs, if they are still active from current attack window. } - private void ApplyFinalPose() - { - // Accumulate poses from all tracks - for (var boneIndex = 0; boneIndex < _boneNames.Length; boneIndex++) - { - var boneName = _boneNames[boneIndex]; - var bone = _bones[boneIndex]; - - var finalPosition = Vector3.zero; - var finalRotation = Quaternion.identity; - - var boneWeightSum = 0f; - for (var i = 0; i < _trackInstances.Count; i++) - { - var trackInstance = _trackInstances[i]; - var trackInstanceBoneIndex = trackInstance.GetBoneIndex(boneName); - - // This track doesn't include the requested bone. - if (trackInstanceBoneIndex == -1) - { - continue; - } - - var trackInstanceBoneWeight = trackInstance.BoneBlendWeights[trackInstanceBoneIndex]; - boneWeightSum += trackInstanceBoneWeight; - - // If we have some fast-changing situations like T_DIALOGGESTURE_ is blending out and another one is blending in - in between, - // We need to dynamically handle overweighting. We do so by reducing weights on lower layers - // (e.g. in the DIALOG case, T_WALK would be blended out more, as we sort the trackInstance list from high to low layer). - if (boneWeightSum > 1f) - { - // Fetch amount of weight higher than 1f on boneWeightSum - var amountOfOverWeight = boneWeightSum - 1f; - boneWeightSum = 1f; - - trackInstanceBoneWeight -= amountOfOverWeight; - } - - trackInstance.GetBonePose(trackInstanceBoneIndex, out var position, out var rotation); - - - finalPosition += position * trackInstanceBoneWeight; - - // The first animation for a bone will define the start point of the rotation. Starting with Q.Identity is wrong and causes hickups. - if (i == 0) - finalRotation = rotation; - else - finalRotation = Quaternion.Slerp(finalRotation, rotation, trackInstanceBoneWeight); - } - - // If we under blended the current object, we need to apply positions from the mesh itself. - // Otherwise, we might have some 0.1f weight of animations alone and the NPC will implode like a black hole at 0,0,0. - // This should be a rare case where we won't have a sum of 1.0. Just a safety treatment. - if (boneWeightSum < 1f) - { - finalPosition += _initialMeshBonePos[boneIndex] * (1 - boneWeightSum); - finalRotation = Quaternion.Slerp(finalRotation, _initialMeshBoneRot[boneIndex], 1 - boneWeightSum); - } - - bone.localPosition = finalPosition; - bone.localRotation = finalRotation; - } - } - private void ApplyFinalMovement() { var finalMovement = Vector3.zero; - for (var i = 0; i < _trackInstances.Count; i++) + foreach (var instance in _trackInstances) { - var trackInstance = _trackInstances[i]; - - if (!trackInstance.Track.IsMoving) - continue; - - // Stop, if we have no Root bone - var boneIndex = trackInstance.GetBoneIndex(Constants.Animations.RootBoneName); - if (boneIndex == -1) + // Only movement tracks (walk, run, strafe) translate the NPC. Vertical pose motion + // (fly height, sitting down, jump arcs) is baked into the root bone's Y channel instead. + // During blend-out, root motion stops to prevent residual sliding + // (e.g. NPC sliding forward when walk is replaced by a turn animation). + if (!instance.Track.IsMoving || instance.State == AnimationState.BlendOut) continue; - finalMovement += trackInstance.Track.MovementSpeed * trackInstance.BoneBlendWeights[boneIndex] * Time.deltaTime; + finalMovement += instance.Track.MovementSpeed * Time.deltaTime; } // Pos change is applied with rotated value. @@ -521,8 +662,16 @@ private void ApplyFinalRotation() if (!_isSittingInverted) return; - var currentRotation = PrefabProps.Bip01.transform.localRotation.eulerAngles; - PrefabProps.Bip01.transform.localRotation = Quaternion.Euler(currentRotation.x, -currentRotation.y, currentRotation.z); + var bip01 = PrefabProps.Bip01.transform; + + // Only invert poses freshly written by the Animator. If the rotation still holds our own last write + // (i.e. no clip drove the bone this frame), inverting again would flip-flop the NPC every frame. + if (bip01.localRotation == _lastInvertedRotation) + return; + + var currentRotation = bip01.localRotation.eulerAngles; + _lastInvertedRotation = Quaternion.Euler(currentRotation.x, -currentRotation.y, currentRotation.z); + bip01.localRotation = _lastInvertedRotation; } private void ApplyEvents() @@ -531,6 +680,9 @@ private void ApplyEvents() { var trackInstance = _trackInstances[i]; + if (!trackInstance.Track.HasEvents) + continue; + ApplyEventTags(trackInstance); ApplySfxEvents(trackInstance); ApplyPfxEvents(trackInstance); @@ -662,7 +814,27 @@ public bool IsPlaying(string animationName) if (trackInstance.Track.Name.EqualsIgnoreCase(animationName)) return true; } - + + return false; + } + + /// + /// Returns true when the named animation track has entered its blend-out phase + /// ahead of its natural end — i.e. it was stopped externally (e.g. AI_StopAni). + /// The owning action can detect this and finish itself without waiting for the + /// full duration timer to expire. + /// + public bool IsAnimationBlendingOut(string animationName) + { + foreach (var instance in _trackInstances) + { + if (instance.Track.MatchesName(animationName) + && instance.State == AnimationState.BlendOut) + { + return true; + } + } + return false; } } diff --git a/Assets/Gothic-Core/Scripts/Adapters/Npc/RootCollisionHandler.cs b/Assets/Gothic-Core/Scripts/Adapters/Npc/RootCollisionHandler.cs index 9ec0d1f6b..8794d52b6 100644 --- a/Assets/Gothic-Core/Scripts/Adapters/Npc/RootCollisionHandler.cs +++ b/Assets/Gothic-Core/Scripts/Adapters/Npc/RootCollisionHandler.cs @@ -21,6 +21,23 @@ protected override void Awake() NpcData.PrefabProps.ColliderRootMotion = gameObject.transform; } + /// + /// The capsule must not live under the animated skeleton root: Update() transfers the physics + /// displacement to Go in this transform's parent space, and the root bone's rest rotation differs + /// per species. Humans only get away with it because BIP01 is purely yawed - Bloodfly's tilted + /// "BIP01 CENTER" turned the vertical settling displacement into a permanent horizontal slide + /// ("flies backwards"), with the capsule lying sideways on top. The animated root Y (sitting, + /// flying) would also drag the capsule along and push the NPC up/down with it. + /// AnimationSystem.FollowRootColliderHeight() resizes the capsule for pose changes instead. + /// Reparenting happens in Start(): NpcData.Go is only assigned after the prefab is instantiated, + /// and the mesh builder has reshaped the bone hierarchy by then. + /// + private void Start() + { + transform.SetParent(Go.transform, false); + transform.SetLocalPositionAndRotation(Vector3.zero, Quaternion.identity); + } + /// /// We need to apply physics on the NPC itself. /// General movement and animations are handled within AnimationSystem.cs. This Collider object is to add physics on top. @@ -39,8 +56,8 @@ private void Update() * NPC GO hierarchy: * * root + * /RootCollisionHandler <- physics (gravity settling) is calculated here and merged to root * /BIP01/ <- animation root - * /RootCollisionHandler <- Moved with animation as inside BIP01, but physics are applied and merged to root * /... <- animation bones */ diff --git a/Assets/Gothic-Core/Scripts/Models/Animations/AnimationKeyFrame.cs b/Assets/Gothic-Core/Scripts/Models/Animations/AnimationKeyFrame.cs deleted file mode 100644 index fbb156eed..000000000 --- a/Assets/Gothic-Core/Scripts/Models/Animations/AnimationKeyFrame.cs +++ /dev/null @@ -1,10 +0,0 @@ -using UnityEngine; - -namespace Gothic.Core.Models.Animations -{ - public struct AnimationKeyFrame - { - public Vector3 Position; - public Quaternion Rotation; - } -} diff --git a/Assets/Gothic-Core/Scripts/Models/Animations/AnimationPoseJob.cs b/Assets/Gothic-Core/Scripts/Models/Animations/AnimationPoseJob.cs new file mode 100644 index 000000000..32320d0d7 --- /dev/null +++ b/Assets/Gothic-Core/Scripts/Models/Animations/AnimationPoseJob.cs @@ -0,0 +1,201 @@ +using Unity.Burst; +using Unity.Collections; +using UnityEngine; +using UnityEngine.Animations; + +namespace Gothic.Core.Models.Animations +{ + /// + /// Per-slot parameters of one active track inside AnimationPoseJob. + /// + public struct AnimationPoseJobTrack + { + public float Frame; // Playback position in (fractional) sample frames. + public int FrameCount; // Baked sample frames of the track. + public int BoneCount; + public float Weight; // Gothic blend weight (0..1). + } + + /// + /// Samples and blends all active Gothic animation tracks of one NPC inside Unity's animation pass + /// (worker threads, evaluated between Update and LateUpdate). + /// + /// Why a job instead of baked AnimationClips: AnimationClip.SetCurve only works on legacy clips outside + /// the Editor ("Can't use AnimationClip::SetCurve at Runtime on non Legacy AnimationClips"), and legacy + /// clips can't be used with Playables. The job reads the .man samples (NativeArrays baked once per track + /// by AnimationService) and writes the final pose itself: + /// 1. Every bone starts at the pose of the previous evaluation (PosePositions/PoseRotations). Bones no + /// active track drives keep their last pose, like in the original engine - e.g. s_Bench_S1 doesn't + /// drive BIP01 and relies on the height the t_Bench_S0_2_S1 transition left it at. + /// 2. Tracks are applied in (Gothic layer ASC, creation ASC) order; each overrides exactly the bones it + /// drives, blended over the layers below by its current weight - Gothic's layer/subset semantics. + /// 3. The blended result is read back into the pose buffers for the next evaluation. + /// + /// Slots are fixed because jobs can't hold a variable amount of NativeArrays. Gothic rarely plays more + /// than 3 tracks at once on one NPC; overflow is logged by AnimationSystem. + /// + [BurstCompile] + public struct AnimationPoseJob : IAnimationJob + { + public const int MaxTracks = 8; + + [ReadOnly] public NativeArray Handles; // One per mdh node. + + // Persistent pose of the previous evaluation, owned per NPC by AnimationSystem (initialized with the + // skeleton rest pose). Read as the blend base, written back with the final result of this evaluation. + public NativeArray PosePositions; + public NativeArray PoseRotations; + + public int TrackCount; + + public AnimationPoseJobTrack Track0; + public AnimationPoseJobTrack Track1; + public AnimationPoseJobTrack Track2; + public AnimationPoseJobTrack Track3; + public AnimationPoseJobTrack Track4; + public AnimationPoseJobTrack Track5; + public AnimationPoseJobTrack Track6; + public AnimationPoseJobTrack Track7; + + [ReadOnly] public NativeArray Positions0; + [ReadOnly] public NativeArray Positions1; + [ReadOnly] public NativeArray Positions2; + [ReadOnly] public NativeArray Positions3; + [ReadOnly] public NativeArray Positions4; + [ReadOnly] public NativeArray Positions5; + [ReadOnly] public NativeArray Positions6; + [ReadOnly] public NativeArray Positions7; + + [ReadOnly] public NativeArray Rotations0; + [ReadOnly] public NativeArray Rotations1; + [ReadOnly] public NativeArray Rotations2; + [ReadOnly] public NativeArray Rotations3; + [ReadOnly] public NativeArray Rotations4; + [ReadOnly] public NativeArray Rotations5; + [ReadOnly] public NativeArray Rotations6; + [ReadOnly] public NativeArray Rotations7; + + [ReadOnly] public NativeArray BoneToNode0; + [ReadOnly] public NativeArray BoneToNode1; + [ReadOnly] public NativeArray BoneToNode2; + [ReadOnly] public NativeArray BoneToNode3; + [ReadOnly] public NativeArray BoneToNode4; + [ReadOnly] public NativeArray BoneToNode5; + [ReadOnly] public NativeArray BoneToNode6; + [ReadOnly] public NativeArray BoneToNode7; + + /// + /// Assign one track slot. Unused slots must still hold created arrays - AnimationSystem fills them + /// with the rest pose arrays and an empty bone map. + /// + public void SetTrack(int slot, AnimationPoseJobTrack track, NativeArray positions, + NativeArray rotations, NativeArray boneToNode) + { + switch (slot) + { + case 0: Track0 = track; Positions0 = positions; Rotations0 = rotations; BoneToNode0 = boneToNode; break; + case 1: Track1 = track; Positions1 = positions; Rotations1 = rotations; BoneToNode1 = boneToNode; break; + case 2: Track2 = track; Positions2 = positions; Rotations2 = rotations; BoneToNode2 = boneToNode; break; + case 3: Track3 = track; Positions3 = positions; Rotations3 = rotations; BoneToNode3 = boneToNode; break; + case 4: Track4 = track; Positions4 = positions; Rotations4 = rotations; BoneToNode4 = boneToNode; break; + case 5: Track5 = track; Positions5 = positions; Rotations5 = rotations; BoneToNode5 = boneToNode; break; + case 6: Track6 = track; Positions6 = positions; Rotations6 = rotations; BoneToNode6 = boneToNode; break; + case 7: Track7 = track; Positions7 = positions; Rotations7 = rotations; BoneToNode7 = boneToNode; break; + } + } + + public void ProcessRootMotion(AnimationStream stream) + { + // Root motion is applied on the NPC root by AnimationSystem.ApplyFinalMovement (Gothic rubber band). + } + + public void ProcessAnimation(AnimationStream stream) + { + // Start from the previous evaluation's pose: bones no active track drives keep their last pose + // (Gothic behavior), and blend weights < 1 fade from the currently visible pose. + for (var node = 0; node < Handles.Length; node++) + { + var handle = Handles[node]; + if (!handle.IsValid(stream)) + continue; + + handle.SetLocalPosition(stream, PosePositions[node]); + handle.SetLocalRotation(stream, PoseRotations[node]); + } + + if (TrackCount > 0) ApplyTrack(stream, Track0, Positions0, Rotations0, BoneToNode0); + if (TrackCount > 1) ApplyTrack(stream, Track1, Positions1, Rotations1, BoneToNode1); + if (TrackCount > 2) ApplyTrack(stream, Track2, Positions2, Rotations2, BoneToNode2); + if (TrackCount > 3) ApplyTrack(stream, Track3, Positions3, Rotations3, BoneToNode3); + if (TrackCount > 4) ApplyTrack(stream, Track4, Positions4, Rotations4, BoneToNode4); + if (TrackCount > 5) ApplyTrack(stream, Track5, Positions5, Rotations5, BoneToNode5); + if (TrackCount > 6) ApplyTrack(stream, Track6, Positions6, Rotations6, BoneToNode6); + if (TrackCount > 7) ApplyTrack(stream, Track7, Positions7, Rotations7, BoneToNode7); + + // Persist the final pose as the base of the next evaluation. + for (var node = 0; node < Handles.Length; node++) + { + var handle = Handles[node]; + if (!handle.IsValid(stream)) + continue; + + PosePositions[node] = handle.GetLocalPosition(stream); + PoseRotations[node] = handle.GetLocalRotation(stream); + } + } + + private void ApplyTrack(AnimationStream stream, AnimationPoseJobTrack track, + NativeArray positions, NativeArray rotations, NativeArray boneToNode) + { + if (track.Weight <= 0f || track.BoneCount == 0) + return; + + var frame0 = Mathf.Clamp((int)track.Frame, 0, track.FrameCount - 1); + var frame1 = Mathf.Min(frame0 + 1, track.FrameCount - 1); + var frameBlend = Mathf.Clamp01(track.Frame - frame0); + + for (var bone = 0; bone < track.BoneCount; bone++) + { + var handle = Handles[boneToNode[bone]]; + if (!handle.IsValid(stream)) + continue; + + // Keys are hemisphere-aligned at bake time, so the normalized lerp takes the short way. + var position = Vector3.Lerp( + positions[frame0 * track.BoneCount + bone], positions[frame1 * track.BoneCount + bone], frameBlend); + var rotation = Nlerp( + rotations[frame0 * track.BoneCount + bone], rotations[frame1 * track.BoneCount + bone], frameBlend); + + if (track.Weight < 1f) + { + // Blend over whatever lower layers (or the rest pose) wrote before us. + position = Vector3.Lerp(handle.GetLocalPosition(stream), position, track.Weight); + rotation = Nlerp(handle.GetLocalRotation(stream), rotation, track.Weight); + } + + handle.SetLocalPosition(stream, position); + handle.SetLocalRotation(stream, rotation); + } + } + + /// + /// Normalized lerp instead of slerp: between consecutive .man frames the angles are tiny and for + /// blend ramps both endpoints are the same poses - the angular deviation stays invisible, while a + /// dot + sqrt replaces the per-bone trigonometry (this runs per bone, per track, per NPC, per frame). + /// + private static Quaternion Nlerp(Quaternion from, Quaternion to, float weight) + { + // Take the short way around - blend sources aren't hemisphere-aligned with each other. + var dot = from.x * to.x + from.y * to.y + from.z * to.z + from.w * to.w; + var sign = dot < 0f ? -1f : 1f; + + var x = from.x + (to.x * sign - from.x) * weight; + var y = from.y + (to.y * sign - from.y) * weight; + var z = from.z + (to.z * sign - from.z) * weight; + var w = from.w + (to.w * sign - from.w) * weight; + + var scale = 1f / Mathf.Sqrt(x * x + y * y + z * z + w * w); + return new Quaternion(x * scale, y * scale, z * scale, w * scale); + } + } +} diff --git a/Assets/Gothic-Core/Scripts/Models/Animations/AnimationKeyFrame.cs.meta b/Assets/Gothic-Core/Scripts/Models/Animations/AnimationPoseJob.cs.meta similarity index 83% rename from Assets/Gothic-Core/Scripts/Models/Animations/AnimationKeyFrame.cs.meta rename to Assets/Gothic-Core/Scripts/Models/Animations/AnimationPoseJob.cs.meta index ee625369d..cc10589a6 100644 --- a/Assets/Gothic-Core/Scripts/Models/Animations/AnimationKeyFrame.cs.meta +++ b/Assets/Gothic-Core/Scripts/Models/Animations/AnimationPoseJob.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 8116d1069564415ba939218c5d763201 +guid: a18e8b04c7034ab3b3419922c4bc157f MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/Gothic-Core/Scripts/Models/Animations/AnimationSkeleton.cs b/Assets/Gothic-Core/Scripts/Models/Animations/AnimationSkeleton.cs new file mode 100644 index 000000000..c3cc48214 --- /dev/null +++ b/Assets/Gothic-Core/Scripts/Models/Animations/AnimationSkeleton.cs @@ -0,0 +1,40 @@ +using Unity.Collections; +using UnityEngine; + +namespace Gothic.Core.Models.Animations +{ + /// + /// Skeleton data of one model hierarchy (.mdh), baked once by AnimationService and shared across all NPCs + /// using the same model script: transform paths to resolve the bone GameObjects, the rest pose written for + /// bones no animation drives, and root bone information (its position is owned by root motion). + /// + public class AnimationSkeleton + { + // Bone paths relative to the NPC root (e.g. "BIP01/BIP01 PELVIS/..."), index == mdh node index. + public string[] Paths; + + public int RootNodeIndex; + + // RootTranslation.y in meters - resting height of the root bone above the feet. + // Used to size the physics walk capsule (see AnimationSystem.ResizeRootCollider). + public float RootHeight; + + public NativeArray RestPositions; + public NativeArray RestRotations; + + // Zero-length placeholder for unused job slots (job NativeArray fields always need a created array). + public NativeArray EmptyBoneMap; + + public int NodeCount => Paths.Length; + + public void Dispose() + { + if (RestPositions.IsCreated) + RestPositions.Dispose(); + if (RestRotations.IsCreated) + RestRotations.Dispose(); + if (EmptyBoneMap.IsCreated) + EmptyBoneMap.Dispose(); + } + } +} diff --git a/Assets/Gothic-Core/Scripts/Models/Animations/AnimationSkeleton.cs.meta b/Assets/Gothic-Core/Scripts/Models/Animations/AnimationSkeleton.cs.meta new file mode 100644 index 000000000..4f7947b26 --- /dev/null +++ b/Assets/Gothic-Core/Scripts/Models/Animations/AnimationSkeleton.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 43b9d8f32cb6463ca7de08522e78e83a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Gothic-Core/Scripts/Models/Animations/AnimationTrack.cs b/Assets/Gothic-Core/Scripts/Models/Animations/AnimationTrack.cs index 51c1ea619..afcca0af2 100644 --- a/Assets/Gothic-Core/Scripts/Models/Animations/AnimationTrack.cs +++ b/Assets/Gothic-Core/Scripts/Models/Animations/AnimationTrack.cs @@ -1,10 +1,16 @@ using System.Collections.Generic; using Gothic.Core.Extensions; +using Unity.Collections; using UnityEngine; using ZenKit; namespace Gothic.Core.Models.Animations { + /// + /// Immutable, fully managed metadata for one Gothic animation, baked once by AnimationService and shared + /// across all NPCs using the same model script. Pose data is baked into NativeArrays which are sampled + /// and blended inside AnimationPoseJob on Unity's animation worker threads. + /// public class AnimationTrack { public enum Type @@ -13,73 +19,103 @@ public enum Type Alias } - public Type TrackType = Type.Animation; - private IAnimation _animation; - private IAnimationAlias _animationAlias; + public Type TrackType; - // AnimationType (Animation/Alias) specific values - public int Layer => TrackType == Type.Animation ? _animation.Layer : _animationAlias.Layer; - public string NextAni => TrackType == Type.Animation ? _animation.Next : _animationAlias.Next; - public float BlendIn => TrackType == Type.Animation ? _animation.BlendIn : _animationAlias.BlendIn; - public float BlendOut => TrackType == Type.Animation ? _animation.BlendOut : _animationAlias.BlendOut; - public AnimationFlags Flags => TrackType == Type.Animation ? _animation.Flags : _animationAlias.Flags; - public string AliasName => TrackType == Type.Animation ? null : _animationAlias.Name; - public AnimationDirection AniDir => TrackType == Type.Animation ? _animation.Direction : _animationAlias.Direction; + // MDS metadata. For aliases, these are the values of the alias entry (layer/next/blend times can differ). + public string Name; + public string AliasName; // null, if TrackType == Animation + public int Layer; + public string NextAni; + public float BlendIn; + public float BlendOut; + public AnimationFlags Flags; + public AnimationDirection Direction; + public bool IsLooping; - // To ensure, Animation/Alias specific values are always used, we make actual IAnimation private. Therefore we need to expose - // remaining properties. - public string Name => _animation.Name; - public int FirstFrame => _animation.FirstFrame; - public int EventTagCount => _animation.EventTagCount; - public int ParticleEffectCount => _animation.ParticleEffectCount; - public int ParticleEffectStopCount => _animation.ParticleEffectStopCount; - public int SoundEffectCount => _animation.SoundEffectCount; - public int SoundEffectGroundCount => _animation.SoundEffectGroundCount; - public int MorphAnimationCount => _animation.MorphAnimationCount; - public int CameraTremorCount => _animation.CameraTremorCount; - public List EventTags => _animation.EventTags; - public List ParticleEffects => _animation.ParticleEffects; - public List ParticleEffectsStop => _animation.ParticleEffectsStop; - public List SoundEffects => _animation.SoundEffects; - public List SoundEffectsGround => _animation.SoundEffectsGround; - public List MorphAnimations => _animation.MorphAnimations; - public List CameraTremors => _animation.CameraTremors; + // Sitting poses (bench/throne) authored with a flipped Y rotation. Resolved once at bake time so the + // per-frame pose path and PlayAnimation/StopAnimation needn't string-match the animation name. + public bool InvertYAxis; - public string[] BoneNames; - public Dictionary BoneNamesDictionary; // this exists as it's faster to search in a dict instead of linear array search - public int BoneCount; + // Frame metadata used to map Gothic event frame numbers onto baked clip time. + public int FirstFrame; public int FrameCount; - public AnimationKeyFrame[] KeyFrames; - public IModelAnimation ModelAnimation; + public float Fps; + public float FpsSource; public float Duration; public float FrameTime; + // Pose samples baked from the .man file (persistent, shared across NPCs). Layout: [frame * BoneCount + bone]. + // The root bone's X/Z positions are pinned to zero - horizontal translation is applied as root motion + // instead. Its Y channel holds the offset from the skeleton rest height (flying, sitting, jump arcs). + public NativeArray Positions; + public NativeArray Rotations; + // Track bone index -> mdh node index (mdh nodes match the bone GameObjects/stream handles 1:1). + public NativeArray BoneToNode; + public int BoneCount; + // Frames actually baked - can be lower than FrameCount when the .man holds fewer samples. + public int BakedFrameCount; + // False for tracks sharing the buffers of the first-baked track of the same real animation + // (AniAlias entries) - they must not dispose them. + public bool OwnsBakedData = true; + // Cached so per-frame event scans can skip tracks without any events (the vast majority). + public bool HasEvents; + + // Root motion (calculated from BIP01 samples; see AnimationService.SetClipMovementSpeed). public bool IsMoving; public Vector3 MovementSpeed; + // Animation events, cached into managed objects once (no native interop during playback). + public List EventTags; + public List SoundEffects; + public List ParticleEffects; + public List MorphAnimations; - public AnimationTrack(IAnimation anim, IAnimationAlias animAlias, IModelAnimation modelAnimation) + /// + /// Name as requested by the game logic. Aliases are requested (and therefore compared) by their alias name. + /// + public string DisplayName => AliasName ?? Name; + + public bool IsSameAnimation(AnimationTrack otherTrack) { - TrackType = animAlias == null ? Type.Animation : Type.Alias; - _animation = anim; - _animationAlias = animAlias; - ModelAnimation = modelAnimation; + return DisplayName.EqualsIgnoreCase(otherTrack.DisplayName); } - public void GetBonePose(int boneIndex, int frameIndex, out Vector3 position, out Quaternion rotation) + public bool MatchesName(string animationName) { - var keyFrame = KeyFrames[frameIndex * BoneCount + boneIndex]; + // EqualsIgnoreCase null-guards its receiver, so a null argument would spuriously match any track + // whose Name/AliasName is also null (every non-alias track has a null AliasName). + if (animationName == null) + return false; - position = keyFrame.Position; - rotation = keyFrame.Rotation; + return Name.EqualsIgnoreCase(animationName) || AliasName.EqualsIgnoreCase(animationName); } - public bool IsSameAnimation(AnimationTrack otherTrack) + /// + /// Reuse the native buffers of an already baked track of the same real animation instead of baking + /// a duplicate. Samples are always baked forward; tracks with Direction == Backward are played back + /// to front at runtime (AnimationTrackInstance.CurrentFrame), so sharing is safe for them too. + /// + public void ShareBakedSamples(AnimationTrack bakedSource) { - var nameOfSelf = TrackType == Type.Animation ? _animation.Name : _animationAlias.Name; - var nameOfOther = otherTrack.TrackType == Type.Animation ? otherTrack.Name : otherTrack.AliasName; - - return nameOfSelf.EqualsIgnoreCase(nameOfOther); + OwnsBakedData = false; + Positions = bakedSource.Positions; + Rotations = bakedSource.Rotations; + BoneToNode = bakedSource.BoneToNode; + BoneCount = bakedSource.BoneCount; + BakedFrameCount = bakedSource.BakedFrameCount; + } + + public void Dispose() + { + if (!OwnsBakedData) + return; + + if (Positions.IsCreated) + Positions.Dispose(); + if (Rotations.IsCreated) + Rotations.Dispose(); + if (BoneToNode.IsCreated) + BoneToNode.Dispose(); } } } diff --git a/Assets/Gothic-Core/Scripts/Models/Animations/AnimationTrackInstance.cs b/Assets/Gothic-Core/Scripts/Models/Animations/AnimationTrackInstance.cs index caa168eb4..e51e68ef3 100644 --- a/Assets/Gothic-Core/Scripts/Models/Animations/AnimationTrackInstance.cs +++ b/Assets/Gothic-Core/Scripts/Models/Animations/AnimationTrackInstance.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Gothic.Core.Extensions; using JetBrains.Annotations; using UnityEngine; using ZenKit; @@ -9,386 +8,140 @@ namespace Gothic.Core.Models.Animations { /// /// Currently playing instance of a Track on a NPC. + /// + /// Pose sampling and blending are done inside AnimationPoseJob. This class only owns the Gothic-specific + /// runtime state: playback clock (with Gothic looping/freeze-on-blend-out semantics), blend weight ramps, + /// and the event timeline. /// public class AnimationTrackInstance { - public int CreationTime; + public readonly AnimationTrack Track; + public readonly int CreationTime; - public AnimationTrack Track; - - // Value for this specific point in time - public float CurrentTime; - public int CurrentKeyFrameIndex; - public float CurrentKeyFrameTime; - public float NextKeyframeTime; public AnimationState State; - public AnimationState[] BoneStates; - public float[] BoneBlendWeights; - public float[] BoneBlendTimes; - public int BoneAmountStatePlay; - public int BoneAmountStateStop; - - // Data which might be overwritten by an Alias. We therefore set them now. - public string AnimationName; + public float CurrentTime; + public float Weight; + public string AnimationName => Track.DisplayName; - // FIXME - Wrong - according to Docs, once the last frame is reached, the animation blends out at this pos+rot. - // FIXME - Docs: Anzumerken ist hierbei, dass das Herunterregeln des Einflusses erst beginnt, sobald der letzte Frame der Ani abgespielt worden ist - public bool IsLooping; + /// + /// Playback position in (fractional) sample frames, fed into AnimationPoseJob. + /// Reversed tracks (ani/aniAlias with direction R, e.g. t_Sit_2_Stand aliasing t_Stand_2_Sit) play + /// the shared forward-baked samples back to front - the clock itself always runs forward. + /// + public float CurrentFrame => Track.Direction == AnimationDirection.Backward + ? Track.BakedFrameCount - 1 - CurrentTime / Track.FrameTime + : CurrentTime / Track.FrameTime; - private bool _didFrameChangeThisUpdate; + private float _blendDuration; - private int _lastExecutedAnimationEvent; - private int _lastExecutedPfxEvent; - private int _lastExecutedSfxEvent; - private int _lastExecutedMorphEvent; + // Frame position of the previous Update. Events fire when their frame is crossed between two Updates. + private float _previousFrame = -1f; - private int _animationEventsToExecuteThisUpdate; - private int _pfxEventsToExecuteThisUpdate; - private int _sfxEventsToExecuteThisUpdate; - private int _morphEventsToExecuteThisUpdate; + // Reused buffers to avoid per-frame allocations. Null is returned when empty (see GetPending*()). + // Only allocated when the track has events at all - most tracks (and instances) have none. + private readonly List _pendingEventTags; + private readonly List _pendingSoundEffects; + private readonly List _pendingParticleEffects; + private readonly List _pendingMorphAnimations; public AnimationTrackInstance(AnimationTrack track) { CreationTime = Time.frameCount; Track = track; - State = AnimationState.BlendIn; CurrentTime = 0f; - CurrentKeyFrameIndex = 0; - CurrentKeyFrameTime = 0f; - NextKeyframeTime = track.FrameTime; - // Looping if this == next. If an alias is used, we expect the same alias being selected. Looks promising so far. - if (track.TrackType == AnimationTrack.Type.Animation) - IsLooping = track.Name.EqualsIgnoreCase(track.NextAni); - else - IsLooping = track.AliasName.EqualsIgnoreCase(track.NextAni); - - BoneStates = new AnimationState[Track.BoneCount]; - BoneBlendWeights = new float[Track.BoneCount]; - BoneBlendTimes = new float[Track.BoneCount]; + if (track.HasEvents) + { + _pendingEventTags = new List(); + _pendingSoundEffects = new List(); + _pendingParticleEffects = new List(); + _pendingMorphAnimations = new List(); + } - AnimationState initialBoneState; - float initialBoneWeight; - // If w have no BlendIn time, we need to set our animation to play fully right from the start. - if (Track.BlendIn == 0) + // If we have no BlendIn time, the animation needs to play at full weight right from the start. + if (track.BlendIn <= 0f) { - initialBoneState = AnimationState.Play; - initialBoneWeight = 1f; - BoneAmountStatePlay = Track.BoneCount; State = AnimationState.Play; + Weight = 1f; } else { - initialBoneState = AnimationState.BlendIn; - initialBoneWeight = 0f; + State = AnimationState.BlendIn; + Weight = 0f; + _blendDuration = track.BlendIn; } - - for (var i = 0; i < Track.BoneCount; i++) - { - BoneStates[i] = initialBoneState; - BoneBlendWeights[i] = initialBoneWeight; - BoneBlendTimes[i] = Track.BlendIn; - } - - BoneAmountStateStop = 0; - - _lastExecutedAnimationEvent = -1; - _lastExecutedPfxEvent = -1; - _lastExecutedSfxEvent = -1; - _lastExecutedMorphEvent = -1; } /// - /// Update animation information at each frame. - /// Return status change of whole animation if happening now. (e.g. BlendOut). + /// Update playback clock, blend weight, and event timeline each frame. + /// Returns the state change happening this frame (e.g. BlendOut once the last frame is reached) or None. /// public AnimationState Update(float deltaTime) { - CurrentTime += deltaTime; - - if (!IsLooping && State == AnimationState.Play && CurrentTime >= Track.Duration) - { - BlendOutTrack(Track.BlendOut); - return AnimationState.BlendOut; - } - - UpdateTrackFrame(); - UpdateEvents(); - UpdateBoneWeights(deltaTime); - return UpdateState(); - } - - private void UpdateTrackFrame() - { - _didFrameChangeThisUpdate = false; - - // If the whole track blends ut, we do not proceed further. (Either we're at the last frame already, or another track stopped us). - if (State == AnimationState.BlendOut) - { - return; - } + var stateChange = UpdateWeight(deltaTime); - if (CurrentTime >= Track.Duration) + if (State == AnimationState.Stop) { - _didFrameChangeThisUpdate = true; - // Restart from the beginning - _lastExecutedAnimationEvent = -1; - _lastExecutedPfxEvent = -1; - _lastExecutedSfxEvent = -1; - _lastExecutedMorphEvent = -1; - - CurrentTime %= Track.Duration; - CurrentKeyFrameIndex = 0; - CurrentKeyFrameTime = 0f; - NextKeyframeTime = CurrentKeyFrameIndex * Track.FrameTime; + return AnimationState.Stop; } - if (CurrentTime >= NextKeyframeTime) + var wrapped = false; + if (State != AnimationState.BlendOut) { - _didFrameChangeThisUpdate = true; + CurrentTime += deltaTime; - CurrentKeyFrameIndex = (int)(CurrentTime / Track.FrameTime); // Round down - if (Track.KeyFrames.Length > CurrentKeyFrameIndex + 1) + if (CurrentTime >= Track.Duration) { - CurrentKeyFrameTime = CurrentKeyFrameIndex * Track.FrameTime; - NextKeyframeTime = (CurrentKeyFrameIndex + 1) * Track.FrameTime; - } - else - { - CurrentKeyFrameTime = Track.Duration; - NextKeyframeTime = float.MaxValue; - } - } - } - - /// - /// Whenever an animation frame changed, we need to check, if an animation is now in time range and add it to the "Execute" list. - /// - private void UpdateEvents() - { - // If we had some executions last frame, we update the last executed event now. - _lastExecutedAnimationEvent += _animationEventsToExecuteThisUpdate; - _lastExecutedPfxEvent += _pfxEventsToExecuteThisUpdate; - _lastExecutedSfxEvent += _sfxEventsToExecuteThisUpdate; - _lastExecutedMorphEvent += _morphEventsToExecuteThisUpdate; - _animationEventsToExecuteThisUpdate = 0; - _pfxEventsToExecuteThisUpdate = 0; - _sfxEventsToExecuteThisUpdate = 0; - _morphEventsToExecuteThisUpdate = 0; - - // We need to check for new events, if we moved to another frame only. - if (!_didFrameChangeThisUpdate) - { - return; - } - - // AnimationEvents - for (var i = _lastExecutedAnimationEvent + 1; i < Track.EventTagCount; i++) - { - var animationEvent = Track.EventTags[i]; - - // Event values are handled based on frame normalization (e.g. animation frames are 39...49 --> 10 frames) - // an event at 49 is then at frameIndex=9 - // event Frame=0 has special handling: use as frame 0 without additional normalization - var animationEventFrame = animationEvent.Frame == 0 ? 0 : ClampFrame(animationEvent.Frame); - - if (animationEventFrame <= CurrentKeyFrameIndex) - { - _animationEventsToExecuteThisUpdate++; - } - // We passed the events which need to be played this frame. - else - { - break; - } - } - - // PFX - for (var i = _lastExecutedPfxEvent + 1; i < Track.ParticleEffectCount; i++) - { - var pfxEvent = Track.ParticleEffects[i]; - var pfxEventFrame = ClampFrame(pfxEvent.Frame); - - if (pfxEventFrame <= CurrentKeyFrameIndex) - { - _pfxEventsToExecuteThisUpdate++; - } - // We passed the events which need to be played this frame. - else - { - break; - } - } - - // SFX - for (var i = _lastExecutedSfxEvent + 1; i < Track.SoundEffectCount; i++) - { - var sfxEvent = Track.SoundEffects[i]; - var sfxEventFrame = ClampFrame(sfxEvent.Frame); - - if (sfxEventFrame <= CurrentKeyFrameIndex) - { - _sfxEventsToExecuteThisUpdate++; - } - else - { - break; + if (Track.IsLooping) + { + wrapped = true; + CurrentTime %= Track.Duration; + } + else + { + // Once the last frame is reached, the animation blends out while freezing at this pose. + CurrentTime = Track.Duration; + BlendOutTrack(Track.BlendOut); + stateChange = AnimationState.BlendOut; + } } + // While blending out, CurrentTime stays put - the pose freezes at its last frame (Gothic behavior). } - // MorphEvents - for (var i = _lastExecutedMorphEvent + 1; i < Track.MorphAnimationCount; i++) - { - var morphEvent = Track.MorphAnimations[i]; - var morphEventFrame = ClampFrame(morphEvent.Frame); + CollectPendingEvents(wrapped); - if (morphEventFrame <= CurrentKeyFrameIndex) - { - _morphEventsToExecuteThisUpdate++; - } - else - { - break; - } - } + return stateChange; } - /// - /// This method solves multiple circumstances: - /// (1). Gothic animations won't always start from frame 0. e.g. t_Potion_Random_1 expects to work from frame 45+. - /// --> This might be, as the animations are "behind" another and could be one single animation in Gothic. - /// --> But in Gothic, we create every transition animation separately and therefore normalize to start from frame 0. - /// (2). G1 animation key frames are optimized and not always aligned with 25fps (e.g. t_Potion_* leverages 10 frames only). - /// But the animation event frame numbers are matching 25fps. - /// --> In Unity we only store the key frames and fps value provided (e.g. 10fps), as Unity will interpolate on it's own. - /// --> But then we need to calculate the ratio between the fpsSource (G1=25fps) and the actual fps (e.g. 10fps). - /// (3). Some animation events seem to be executed before or after the actual animation. - /// --> We take care by checking its boundaries. - /// - private float ClampFrame(int expectedFrame) + private AnimationState UpdateWeight(float deltaTime) { - // (2). calculate ration between FpsSource and the animations Fps. - var animationRatio = Track.ModelAnimation.Fps / Track.ModelAnimation.FpsSource; - - // (1). Norm to start frame of 1 - // (2). Norm to fpsSource (==25 in G1) - expectedFrame = (int)Math.Round((expectedFrame - Track.FirstFrame) * animationRatio); - - // (3). check for misaligned animation frame boundaries (if any). - if (expectedFrame < 0) - { - return 0; - } - - if (expectedFrame >= Track.ModelAnimation.FrameCount) - { - return Track.ModelAnimation.FrameCount - 1; - } - - return expectedFrame; - } - - /// - /// Apply BlendWeight changes to each bone - /// - private void UpdateBoneWeights(float deltaTime) - { - for (var i = 0; i < Track.BoneCount; i++) - { - switch (BoneStates[i]) - { - case AnimationState.BlendIn: - BoneBlendWeights[i] += deltaTime / BoneBlendTimes[i]; - - if (BoneBlendWeights[i] >= 1f) - { - BoneBlendWeights[i] = 1f; - BoneStates[i] = AnimationState.Play; - BoneAmountStatePlay++; - } - break; - case AnimationState.BlendOut: - if (BoneStates[i] == AnimationState.BlendOut) - { - BoneBlendWeights[i] -= deltaTime / BoneBlendTimes[i]; - - if (BoneBlendWeights[i] <= 0f) - { - BoneBlendWeights[i] = 0f; - BoneStates[i] = AnimationState.Stop; - BoneAmountStateStop++; - } - } - break; - } - } - } - - /// - /// Update main state of Animation. If nothing changed, we return AnimationState.None. - /// - private AnimationState UpdateState() - { - // Update State if blending is done for all bones. switch (State) { case AnimationState.BlendIn: - if (BoneAmountStatePlay >= Track.BoneCount) + Weight += _blendDuration <= 0f ? 1f : deltaTime / _blendDuration; + if (Weight >= 1f) { - BoneAmountStatePlay = Track.BoneCount; + Weight = 1f; State = AnimationState.Play; return AnimationState.Play; } break; - case AnimationState.Play: - break; case AnimationState.BlendOut: - if (BoneAmountStateStop >= Track.BoneCount) + Weight -= _blendDuration <= 0f ? 1f : deltaTime / _blendDuration; + if (Weight <= 0f) { - BoneAmountStateStop = Track.BoneCount; + Weight = 0f; State = AnimationState.Stop; return AnimationState.Stop; } break; - default: - throw new ArgumentOutOfRangeException(); } return AnimationState.None; } - public void GetBonePose(int boneIndex, out Vector3 position, out Quaternion rotation) - { - Track.GetBonePose(boneIndex, CurrentKeyFrameIndex, out position, out rotation); - // HINT: If we want to have the real animations only, remove the rest of this method. Then animations play with 10/30/60fps depending on their data. - - // No Lerping, if we're already Blending Out (aka we return the same frame until weight==0) - if (State == AnimationState.BlendOut) - { - return; - } - - var nextFrameIndex = CurrentKeyFrameIndex + 1; - if (nextFrameIndex >= Track.FrameCount) // We're already at the last frame. - { - nextFrameIndex = 0; - - // We only lerp with first element, if the track is looping. - if (!IsLooping) - { - return; - } - } - - Track.GetBonePose(boneIndex, nextFrameIndex, out var nextPosition, out var nextRotation); - - var interpolation = Mathf.InverseLerp(CurrentKeyFrameTime, NextKeyframeTime, CurrentTime); - position = Vector3.Lerp(position, nextPosition, interpolation); - rotation = Quaternion.Slerp(rotation, nextRotation, interpolation); - } - /// /// About BlendOut times: /// a.) the new animation has higher or same Layer, then its BlendIn time is used as our BlendOut time. @@ -396,120 +149,124 @@ public void GetBonePose(int boneIndex, out Vector3 position, out Quaternion rota /// public void BlendOutTrack(float blendOutTime) { - State = AnimationState.BlendOut; - - for (var i = 0; i < Track.BoneCount; i++) + if (State is AnimationState.BlendOut or AnimationState.Stop) { - switch (BoneStates[i]) - { - case AnimationState.BlendIn: - case AnimationState.Play: - BoneBlendTimes[i] = blendOutTime; - BoneStates[i] = AnimationState.BlendOut; - break; - case AnimationState.BlendOut: - case AnimationState.Stop: - break; - default: - throw new ArgumentOutOfRangeException(); - } + return; } + + State = AnimationState.BlendOut; + _blendDuration = blendOutTime; } - private void ProcessBoneStateChange(string boneName, AnimationState targetState, float blendTime, - AnimationState conditionState, ref int counter) + /// + /// Collect all events whose (normalized) frame was crossed since the previous Update. + /// Handles loop wrap-around so that events close to the last frame aren't dropped (the old + /// implementation reset its cursors on wrap and silently skipped them). + /// + private void CollectPendingEvents(bool wrapped) { - // Use the dictionary for efficient bone index lookup - if (!Track.BoneNamesDictionary.TryGetValue(boneName, out var boneIndex)) + // No events on this track - skip the whole timeline bookkeeping (the common case). + if (!Track.HasEvents) { return; } - // Check if the bone is currently in the specified condition state and decrement counter if so - if (BoneStates[boneIndex] == conditionState) - { - counter--; - } + _pendingEventTags.Clear(); + _pendingSoundEffects.Clear(); + _pendingParticleEffects.Clear(); + _pendingMorphAnimations.Clear(); - // Update the bone's state and blend time - BoneStates[boneIndex] = targetState; - BoneBlendTimes[boneIndex] = blendTime; - } + var currentFrame = CurrentTime / Track.FrameTime; - public void BlendOutBones(string[] boneNames, float blendOutTime) - { - // Skip partial BlendOut if we're already blending out. - if (State == AnimationState.BlendOut) + foreach (var eventTag in Track.EventTags) { - return; + // Event frame=0 has special handling: use as frame 0 without normalization. + var frame = eventTag.Frame == 0 ? 0f : ClampFrame(eventTag.Frame); + if (IsFrameCrossed(frame, currentFrame, wrapped)) + _pendingEventTags.Add(eventTag); } - foreach (var boneName in boneNames) + foreach (var sfx in Track.SoundEffects) { - ProcessBoneStateChange(boneName, AnimationState.BlendOut, blendOutTime, - AnimationState.Play, ref BoneAmountStatePlay); + if (IsFrameCrossed(ClampFrame(sfx.Frame), currentFrame, wrapped)) + _pendingSoundEffects.Add(sfx); } - } - public void BlendInBones(string[] boneNames, float blendInTime) - { - // Skip partial BlendIn if we're already blending in. - if (State == AnimationState.BlendIn) + foreach (var pfx in Track.ParticleEffects) { - return; + if (IsFrameCrossed(ClampFrame(pfx.Frame), currentFrame, wrapped)) + _pendingParticleEffects.Add(pfx); } - foreach (var boneName in boneNames) + foreach (var morph in Track.MorphAnimations) { - ProcessBoneStateChange(boneName, AnimationState.BlendIn, blendInTime, - AnimationState.Stop, ref BoneAmountStateStop); + if (IsFrameCrossed(ClampFrame(morph.Frame), currentFrame, wrapped)) + _pendingMorphAnimations.Add(morph); } - } - public int GetBoneIndex(string boneName) - { - return Track.BoneNamesDictionary.GetValueOrDefault(boneName, -1); + _previousFrame = currentFrame; } - [CanBeNull] - public List GetPendingEventTags() + private bool IsFrameCrossed(float eventFrame, float currentFrame, bool wrapped) { - if (_animationEventsToExecuteThisUpdate == 0) + if (wrapped) { - return null; + // We passed the end of the animation and restarted: fire tail events and early events alike. + return eventFrame > _previousFrame || eventFrame <= currentFrame; } - return Track.EventTags.GetRange(_lastExecutedAnimationEvent + 1, _animationEventsToExecuteThisUpdate); + return eventFrame > _previousFrame && eventFrame <= currentFrame; } - public List GetPendingParticleEffects() + /// + /// This method solves multiple circumstances: + /// (1). Gothic animations won't always start from frame 0. e.g. t_Potion_Random_1 expects to work from frame 45+. + /// --> This might be, as the animations are "behind" another and could be one single animation in Gothic. + /// --> But in Gothic, we create every transition animation separately and therefore normalize to start from frame 0. + /// (2). G1 animation key frames are optimized and not always aligned with 25fps (e.g. t_Potion_* leverages 10 frames only). + /// But the animation event frame numbers are matching 25fps. + /// --> We therefore calculate the ratio between the fpsSource (G1=25fps) and the actual fps (e.g. 10fps). + /// (3). Some animation events seem to be executed before or after the actual animation. + /// --> We take care by checking its boundaries. + /// + private float ClampFrame(int expectedFrame) { - if (_pfxEventsToExecuteThisUpdate == 0) - { - return null; - } + // (2). calculate ratio between FpsSource and the animation's Fps. + var animationRatio = Track.Fps / Track.FpsSource; + + // (1). Norm to start frame of 0. (2). Norm to fpsSource (==25 in G1). + var frame = (float)Math.Round((expectedFrame - Track.FirstFrame) * animationRatio); + + // Reversed tracks play their samples back to front - the event timeline mirrors with them. + if (Track.Direction == AnimationDirection.Backward) + frame = Track.FrameCount - 1 - frame; - return Track.ParticleEffects.GetRange(_lastExecutedPfxEvent + 1, _pfxEventsToExecuteThisUpdate); + // (3). check for misaligned animation frame boundaries (if any). + return Mathf.Clamp(frame, 0, Track.FrameCount - 1); } - public List GetPendingMorphAnimations() + [CanBeNull] + public List GetPendingEventTags() { - if (_morphEventsToExecuteThisUpdate == 0) - { - return null; - } - - return Track.MorphAnimations.GetRange(_lastExecutedMorphEvent + 1, _morphEventsToExecuteThisUpdate); + return _pendingEventTags == null || _pendingEventTags.Count == 0 ? null : _pendingEventTags; } + [CanBeNull] public List GetPendingSoundEffects() { - if (_sfxEventsToExecuteThisUpdate == 0) - { - return null; - } + return _pendingSoundEffects == null || _pendingSoundEffects.Count == 0 ? null : _pendingSoundEffects; + } - return Track.SoundEffects.GetRange(_lastExecutedSfxEvent + 1, _sfxEventsToExecuteThisUpdate); + [CanBeNull] + public List GetPendingParticleEffects() + { + return _pendingParticleEffects == null || _pendingParticleEffects.Count == 0 ? null : _pendingParticleEffects; + } + + [CanBeNull] + public List GetPendingMorphAnimations() + { + return _pendingMorphAnimations == null || _pendingMorphAnimations.Count == 0 ? null : _pendingMorphAnimations; } } } diff --git a/Assets/Gothic-Core/Scripts/Services/Npc/AnimationService.cs b/Assets/Gothic-Core/Scripts/Services/Npc/AnimationService.cs index fdc85bcbc..64dd9aa9a 100644 --- a/Assets/Gothic-Core/Scripts/Services/Npc/AnimationService.cs +++ b/Assets/Gothic-Core/Scripts/Services/Npc/AnimationService.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using Gothic.Core.Const; using Gothic.Core.Extensions; using Gothic.Core.Logging; using Gothic.Core.Models.Animations; @@ -11,22 +10,79 @@ using Gothic.Core.Services.Caches; using JetBrains.Annotations; using Reflex.Attributes; +using Unity.Collections; using UnityEngine; using ZenKit; using Logger = Gothic.Core.Logging.Logger; namespace Gothic.Core.Services.Npc { + /// + /// Resolves Gothic animations (MDS base + overlay) into AnimationTracks. Pose data is baked once into + /// persistent NativeArrays, sampled and blended at runtime by AnimationPoseJob inside each NPC's + /// PlayableGraph. (Baking runtime AnimationClips is not possible: AnimationClip.SetCurve only works on + /// legacy clips outside the Editor, and legacy clips can't be used with Playables.) + /// public class AnimationService { - private const float _movementThreshold = 0.3f; // If magnitude of first and last frame positions is higher than this, we have a movement animation. - - [ItemCanBeNull] - private Dictionary _tracks = new(); + // Tracks whose root moves faster than this are movement animations (root motion is applied to the NPC). + // Speed-based, not displacement-based: short walk cycles of small creatures (e.g. Bloodfly's + // s_FistWalkL moves only 0.45m per loop) must count, while slow pose drifts (t_Warn) must not. + private const float _minMovementSpeed = 0.1f; // m/s + + // Gothic bench/throne sitting poses are authored with a flipped Y rotation that must be inverted when + // applied. Resolved into AnimationTrack.InvertYAxis once at bake time so the per-frame pose path (and + // every PlayAnimation/StopAnimation) needn't string-match. Compared by the (non-alias) animation name. + private static readonly HashSet _animationsToInvertYAxis = + new(StringComparer.OrdinalIgnoreCase) { "S_BENCH_S1", "S_THRONE_S1" }; + + // Track cache, two levels: MDS name (as passed by callers, any casing/extension) -> animation name -> track. + // Both levels ignore case, so the per-PlayAnimation hot path runs without any string normalization + // allocations (ToLower/extension stripping) - important on mobile chipsets (GC pressure). + // Inner values can be null: a null track is the negative cache for unknown animation names. + private readonly Dictionary> _tracksByMdsVariant = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _tracksByPreparedMds = new(); + + // First track baked per real animation (mds-animName). AniAlias tracks share these native buffers + // instead of baking duplicates - in HUMANS.MDS every 4th entry is an alias. + private readonly Dictionary _bakedSourceTracks = new(); + + // Skeleton data (bone paths, rest pose, root node) per model hierarchy. + private Dictionary _skeletons = new(); [Inject] private readonly ResourceCacheService _resourceCacheService; + public AnimationService() + { + // Tracks and skeletons hold persistent NativeArrays - free them when the game (or PlayMode) ends. + Application.quitting += DisposeNativeData; + } + + private void DisposeNativeData() + { + Application.quitting -= DisposeNativeData; + + foreach (var mdsTracks in _tracksByPreparedMds.Values) + { + foreach (var track in mdsTracks.Values) + { + // Alias tracks share the buffers of their baked source track (OwnsBakedData == false). + track?.Dispose(); + } + } + _tracksByPreparedMds.Clear(); + _tracksByMdsVariant.Clear(); + _bakedSourceTracks.Clear(); + + foreach (var skeleton in _skeletons.Values) + { + skeleton.Dispose(); + } + _skeletons.Clear(); + } + + /// /// Try to load animation based on both MDS values: overlay + base. /// @@ -47,9 +103,9 @@ private AnimationTrack GetTrack(string animName, string mdsName) return null; } - var name = GetCombinedAnimationKey(mdsName, animName); + var mdsTracks = GetMdsTrackCache(mdsName); - if (_tracks.TryGetValue(name, out var track)) + if (mdsTracks.TryGetValue(animName, out var track)) { return track; } @@ -61,12 +117,8 @@ private AnimationTrack GetTrack(string animName, string mdsName) // AniAlias lookup // Looking up multiple animation types (Animation/AniAlias) for the actual animation name. - // HINT: This also means, that we potentially create a Track based on duplicate animation data for AniAlias. - // As we have no memory issue, this is neglectable for now. if (anim == null) { - // FIXME - Alias values aren't overwritten as of today, they need to be handled: - // aniAlias (ANI_NAME LAYER NEXT_ANI BLEND_IN BLEND_OUT FLAGS ALIAS_NAME ANI_DIR) animAlias = mds.AnimationAliases.FirstOrDefault(i => i.Name.EqualsIgnoreCase(animName)); if (animAlias != null) { @@ -78,16 +130,15 @@ private AnimationTrack GetTrack(string animName, string mdsName) if (anim == null) { // Caching an empty track means, we don't need to try creating this track again. - _tracks.Add(name, null); + mdsTracks.Add(animName, null); return null; } - animName = anim.Name; // We need to use the actual name, as we might have an alias. - var modelAnimation = _resourceCacheService.TryGetModelAnimation(mdsName, animName); + var modelAnimation = _resourceCacheService.TryGetModelAnimation(mdsName, anim.Name); if (modelAnimation == null) { // Caching an empty track means, we don't need to try creating this track again. - _tracks.Add(name, null); + mdsTracks.Add(animName, null); return null; } @@ -95,89 +146,239 @@ private AnimationTrack GetTrack(string animName, string mdsName) var mdhName = mdsName; var mdh = _resourceCacheService.TryGetModelHierarchy(mdhName); - track = CreateTrack(modelAnimation, mdh, anim, animAlias); - AddTrackDuration(track, modelAnimation); - SetClipMovementSpeed(track, modelAnimation, mdh); + var skeleton = GetSkeleton(mdhName, mdh); + + // Aliases bake bit-identical buffers (samples are always baked forward; reversed Direction is + // applied at playback via AnimationTrackInstance.CurrentFrame), so all tracks of the same real + // animation share the buffers of whichever track got baked first. + var bakedKey = GetCombinedAnimationKey(mdsName, anim.Name); + _bakedSourceTracks.TryGetValue(bakedKey, out var bakedSource); + + track = CreateTrack(modelAnimation, skeleton, anim, animAlias, bakedSource); + SetClipMovementSpeed(track, modelAnimation, skeleton); + + if (bakedSource == null) + { + _bakedSourceTracks.Add(bakedKey, track); + } - _tracks.Add(name, track); + mdsTracks.Add(animName, track); return track; } + /// + /// Per-MDS track dictionary. Callers pass MDS names in different spellings (casing, file extension): + /// each spelling is normalized exactly once, afterwards lookups are two allocation-free TryGetValues. + /// + private Dictionary GetMdsTrackCache(string mdsName) + { + if (_tracksByMdsVariant.TryGetValue(mdsName, out var mdsTracks)) + { + return mdsTracks; + } + + var preparedKey = GetPreparedKey(mdsName); + if (!_tracksByPreparedMds.TryGetValue(preparedKey, out mdsTracks)) + { + mdsTracks = new Dictionary(StringComparer.OrdinalIgnoreCase); + _tracksByPreparedMds.Add(preparedKey, mdsTracks); + } + + _tracksByMdsVariant.Add(mdsName, mdsTracks); + return mdsTracks; + } + private AnimationTrack CreateTrack(IModelAnimation modelAnimation, - IModelHierarchy modelHierarchy, IAnimation anim, IAnimationAlias animAlias) + AnimationSkeleton skeleton, IAnimation anim, IAnimationAlias animAlias, AnimationTrack bakedSource) { - var track = new AnimationTrack(anim, animAlias, modelAnimation); - - // Get bone names from model hierarchy using node indices - track.BoneNames = modelAnimation.NodeIndices - .Select(nodeIndex => modelHierarchy.Nodes[nodeIndex].Name) - .ToArray(); - - track.BoneNamesDictionary = new Dictionary(modelAnimation.NodeCount); - // Store the actual node indices used by this animation - var animationNodeIndices = modelAnimation.NodeIndices.ToArray(); - for (int i = 0; i < animationNodeIndices.Length; i++) + var isAlias = animAlias != null; + var track = new AnimationTrack + { + TrackType = isAlias ? AnimationTrack.Type.Alias : AnimationTrack.Type.Animation, + Name = anim.Name, + AliasName = isAlias ? animAlias.Name : null, + + // Alias entries overwrite layer/next/blend/flags/direction of the aliased animation. + Layer = isAlias ? animAlias.Layer : anim.Layer, + NextAni = isAlias ? animAlias.Next : anim.Next, + BlendIn = isAlias ? animAlias.BlendIn : anim.BlendIn, + BlendOut = isAlias ? animAlias.BlendOut : anim.BlendOut, + Flags = isAlias ? animAlias.Flags : anim.Flags, + Direction = isAlias ? animAlias.Direction : anim.Direction, + + FirstFrame = anim.FirstFrame, + FrameCount = modelAnimation.FrameCount, + Fps = modelAnimation.Fps, + FpsSource = modelAnimation.FpsSource, + Duration = modelAnimation.FrameCount / modelAnimation.Fps, + FrameTime = 1 / modelAnimation.Fps, + + // Cache() turns the native interop objects into plain managed copies once. The event lists are + // read-only at runtime, so tracks sharing baked data also share them instead of re-marshalling. + EventTags = bakedSource?.EventTags ?? anim.EventTags.Select(i => i.Cache()).ToList(), + SoundEffects = bakedSource?.SoundEffects ?? anim.SoundEffects.Select(i => i.Cache()).ToList(), + ParticleEffects = bakedSource?.ParticleEffects ?? anim.ParticleEffects.Select(i => i.Cache()).ToList(), + MorphAnimations = bakedSource?.MorphAnimations ?? anim.MorphAnimations.Select(i => i.Cache()).ToList() + }; + + // Looping if this == next. If an alias is used, we expect the same alias being selected. + track.IsLooping = track.DisplayName.EqualsIgnoreCase(track.NextAni); + + // Cached so the per-frame event scan can skip the vast majority of tracks without any events. + track.HasEvents = track.EventTags.Count > 0 || track.SoundEffects.Count > 0 || + track.ParticleEffects.Count > 0 || track.MorphAnimations.Count > 0; + + track.InvertYAxis = _animationsToInvertYAxis.Contains(track.Name); + + if (bakedSource != null) + { + track.ShareBakedSamples(bakedSource); + } + else + { + BakeSamples(track, modelAnimation, skeleton, modelAnimation.NodeIndices.ToArray()); + } + + if (track.Flags.HasFlag(AnimationFlags.Rotate)) { - var actualNodeIndex = animationNodeIndices[i]; - var boneName = modelHierarchy.Nodes[actualNodeIndex].Name; - // Map bone name to its index within this animation track's bone list (0 to BoneCount-1) - track.BoneNamesDictionary.Add(boneName, i); + Logger.LogWarning($"{track.Name}: Rotation animations are not supported yet.", LogCat.Animation); } - // Process animation samples - track.BoneCount = modelAnimation.NodeCount; - track.FrameCount = modelAnimation.FrameCount; - track.KeyFrames = new AnimationKeyFrame[modelAnimation.Samples.Count]; - track.FrameTime = 1 / modelAnimation.Fps; + return track; + } + + /// + /// Bake the .man samples into NativeArrays read by AnimationPoseJob (localPosition + localRotation per + /// bone per frame). The job interpolates linearly between frames, matching the original game's sampling. + /// + private void BakeSamples(AnimationTrack track, IModelAnimation modelAnimation, + AnimationSkeleton skeleton, int[] nodeIndices) + { + var samples = modelAnimation.Samples; + var boneCount = nodeIndices.Length; + var frameCount = Math.Min(track.FrameCount, samples.Count / boneCount); + + track.BoneCount = boneCount; + track.BakedFrameCount = frameCount; + track.Positions = new NativeArray(frameCount * boneCount, Allocator.Persistent); + track.Rotations = new NativeArray(frameCount * boneCount, Allocator.Persistent); + track.BoneToNode = new NativeArray(nodeIndices, Allocator.Persistent); - for (var frameIndex = 0; frameIndex < modelAnimation.Samples.Count / track.BoneCount; frameIndex++) + for (var boneIndex = 0; boneIndex < boneCount; boneIndex++) { - for (var nodeIndex = 0; nodeIndex < track.BoneCount; nodeIndex++) + // The hierarchy root isn't always named BIP01 (e.g. Bloodfly uses "BIP01 CENTER"). + // Its horizontal translation is applied separately as root motion + // (AnimationSystem.ApplyFinalMovement), so X/Z stay pinned at zero. The vertical channel is + // kept as an offset from the skeleton rest height: the NPC root rests at RootHeight above the + // ground (walk capsule), so this offset poses the bone at the animated world height + // (e.g. Bloodfly flying at 1.25m instead of its 0.4m rest height, jump arcs, sitting down). + var isRootBone = nodeIndices[boneIndex] == skeleton.RootNodeIndex; + + var previousRotation = Quaternion.identity; + for (var frame = 0; frame < frameCount; frame++) { - var sampleIndex = frameIndex * track.BoneCount + nodeIndex; - var sample = modelAnimation.Samples[sampleIndex]; - var boneName = track.BoneNames[nodeIndex]; + var sampleIndex = frame * boneCount + boneIndex; + var sample = samples[sampleIndex]; - track.KeyFrames[sampleIndex] = new AnimationKeyFrame - { - Position = sample.Position.ToUnityVector(), - Rotation = new Quaternion( - sample.Rotation.X, - sample.Rotation.Y, - sample.Rotation.Z, - -sample.Rotation.W) // Note: W is negated as per original code - }; - - // Special handling for root bone (BIP01) - if (boneName == Constants.Animations.RootBoneName) + // W is negated to convert from Gothic's to Unity's coordinate system handedness. + var rotation = new Quaternion(sample.Rotation.X, sample.Rotation.Y, sample.Rotation.Z, -sample.Rotation.W); + + // Keep neighboring quaternions on the same hemisphere so the job Slerps the short way. + if (frame > 0 && Quaternion.Dot(previousRotation, rotation) < 0f) { - track.KeyFrames[sampleIndex].Position = Vector3.zero; + rotation = new Quaternion(-rotation.x, -rotation.y, -rotation.z, -rotation.w); } + previousRotation = rotation; + + var position = sample.Position.ToUnityVector(); + track.Positions[sampleIndex] = isRootBone + ? new Vector3(0f, position.y - skeleton.RootHeight, 0f) + : position; + track.Rotations[sampleIndex] = rotation; } } + } - if (track.Flags.HasFlag(AnimationFlags.Rotate)) + private AnimationSkeleton GetSkeleton(string mdhName, IModelHierarchy mdh) + { + var key = GetPreparedKey(mdhName); + if (_skeletons.TryGetValue(key, out var cachedSkeleton)) { - Logger.LogWarning($"{track.Name}: Rotation animations are not supported yet.", LogCat.Animation); + return cachedSkeleton; } - return track; + var nodes = mdh.Nodes; + var skeleton = new AnimationSkeleton + { + Paths = new string[nodes.Count], + RestPositions = new NativeArray(nodes.Count, Allocator.Persistent), + RestRotations = new NativeArray(nodes.Count, Allocator.Persistent), + EmptyBoneMap = new NativeArray(0, Allocator.Persistent), + RootNodeIndex = -1, + RootHeight = mdh.RootTranslation.ToUnityVector().y + }; + + for (var i = 0; i < nodes.Count; i++) + { + var node = nodes[i]; + var isRootNode = node.ParentIndex == -1; + + skeleton.Paths[i] = isRootNode + ? node.Name + : $"{skeleton.Paths[node.ParentIndex]}/{node.Name}"; + + if (isRootNode && skeleton.RootNodeIndex == -1) + { + skeleton.RootNodeIndex = i; + } + + // Same decomposition the mesh builders use to place the bone GameObjects (AbstractMeshBuilder). + // The root node rests at zero: its position is owned by root motion and pinned in every track. + var restMatrix = node.Transform.ToUnityMatrix(); + skeleton.RestPositions[i] = isRootNode ? Vector3.zero : restMatrix.GetPosition() / 100f; + skeleton.RestRotations[i] = restMatrix.rotation; + } + + _skeletons.Add(key, skeleton); + return skeleton; + } + + /// + /// Skeleton of the given model script - used by AnimationSystem to build its stream handles and job data. + /// + [CanBeNull] + public AnimationSkeleton GetSkeleton(string mdsName) + { + if (mdsName == null) + { + return null; + } + + var mdh = _resourceCacheService.TryGetModelHierarchy(mdsName, false); + return mdh == null ? null : GetSkeleton(mdsName, mdh); } - private void AddTrackDuration(AnimationTrack track, IModelAnimation modelAnimation) + /// + /// Resting height of the skeleton root above the feet (mdh RootTranslation.y) in meters. + /// Needed to place the physics walk capsule: as every clip pins the root bone to local zero, the + /// capsule below it has to end exactly this far down for the feet to touch the ground. + /// + public float GetRootBoneHeight(string mdsName) { - track.Duration = modelAnimation.FrameCount / modelAnimation.Fps; + return GetSkeleton(mdsName)?.RootHeight ?? 0f; } /// - /// Based on first node (BIP01), we calculate its start position and end position of the animation. - /// If it's above a threshold, we have a movement animation. + /// Based on the root node, we calculate its start position and end position of the animation. + /// If XZ movement is above a threshold, it is a movement animation (walk, run, strafe). + /// Vertical root motion is not part of MovementSpeed: it is baked into the root bone's Y channel + /// as a pose offset (see BakeSamples), which is sample-exact and never fights the physics capsule. /// /// We don't use Flags.Move as also idle animations would move the characters (based on animation data). /// Using our own calculation is a workaround found on OpenGothic. /// - private void SetClipMovementSpeed(AnimationTrack track, IModelAnimation modelAnim, IModelHierarchy mdh) + private void SetClipMovementSpeed(AnimationTrack track, IModelAnimation modelAnim, AnimationSkeleton skeleton) { // We assume, that only lowest level animations are movement animations. (e.g. S_WALKL) if (track.Layer != 1) @@ -185,11 +386,8 @@ private void SetClipMovementSpeed(AnimationTrack track, IModelAnimation modelAni return; } - var firstBoneIndex = modelAnim.NodeIndices.First(); - var isRootBoneExisting = mdh.Nodes[firstBoneIndex].Name == Constants.Animations.RootBoneName; - - // I don't think it will ever happen, but better safe than sorry. - if (!isRootBoneExisting) + // The root isn't always named BIP01 (e.g. Bloodfly's "BIP01 CENTER") - match by hierarchy instead. + if (modelAnim.NodeIndices.First() != skeleton.RootNodeIndex) { return; } @@ -198,26 +396,24 @@ private void SetClipMovementSpeed(AnimationTrack track, IModelAnimation modelAni var firstSample = modelAnim.Samples[0]; var lastSample = modelAnim.Samples[modelAnim.SampleCount - boneCount]; - // We track xz axis for movement check only. - // Otherwise, e.g. T_STAND_2_SIT will be marked as "move" var unityFirstSamplePos = firstSample.Position.ToUnityVector(); var unityLastSamplePos = lastSample.Position.ToUnityVector(); - var firstSampleMovePos = new Vector3(unityFirstSamplePos.x, 0, unityFirstSamplePos.z); - var lastSampleMovePos = new Vector3(unityLastSamplePos.x, 0, unityLastSamplePos.z); - var movementCheck = lastSampleMovePos - firstSampleMovePos; - if (movementCheck.sqrMagnitude < _movementThreshold) + var movement = unityLastSamplePos - unityFirstSamplePos; + + // Only XZ motion counts: vertical pose motion (fly height, sit, jump arcs) lives in the + // baked root bone Y channel, not in root motion. + var xzMovement = new Vector3(movement.x, 0, movement.z); + if (xzMovement.magnitude / track.Duration >= _minMovementSpeed) { - return; - } + track.IsMoving = true; + track.MovementSpeed = xzMovement / track.Duration; - track.IsMoving = true; - - // For the actual movement, we also include y-axis (for climbing ladders/jumping later; not yet tested though'). - var movement = unityLastSamplePos - unityFirstSamplePos; - - // TODO - We can also check if we do a "movement" calculation based on each frame. Then animations might "wiggle" during walk instead of walking on a rubber band. - track.MovementSpeed = movement / track.Duration; + // Reversed tracks (e.g. t_Bench_S1_2_S0 aliasing t_Bench_S0_2_S1 with direction R) play the + // samples back to front, so their root motion runs the opposite way too. + if (track.Direction == AnimationDirection.Backward) + track.MovementSpeed = -track.MovementSpeed; + } } /// @@ -246,70 +442,76 @@ private string GetPreparedKey(string key) return lowerKey.Replace(extension, ""); } - + public string GetAnimationName(VmGothicEnums.AnimationType type, NpcContainer npc, VmGothicEnums.WeaponState? overrideWeaponState = null) { // The name of the currently active weapon == prefix of animation. var fightMode = (VmGothicEnums.WeaponState)npc.Vob.FightMode; - string weaponStateString; - if (overrideWeaponState.HasValue) - weaponStateString = overrideWeaponState.Value.ToString(); - else if (fightMode == VmGothicEnums.WeaponState.NoWeapon) - weaponStateString = ""; - else - weaponStateString = fightMode.ToString(); - - var walkModeString = GetWalkModeString((VmGothicEnums.WalkMode)npc.Vob.AiHuman.WalkMode); + var weaponStateString = GetWeaponAnimationPrefix(overrideWeaponState ?? fightMode); + + var walkMode = (VmGothicEnums.WalkMode)npc.Vob.AiHuman.WalkMode; + var walkModeString = GetWalkModeString(walkMode); var animationName = type switch { VmGothicEnums.AnimationType.Idle => GetIdleAnimationName(weaponStateString, walkModeString), - VmGothicEnums.AnimationType.Move => $"{GetIdleAnimationName(weaponStateString, walkModeString)}L", + VmGothicEnums.AnimationType.Move => GetMoveAnimationName(weaponStateString, walkMode, walkModeString), VmGothicEnums.AnimationType.Attack => $"s_{weaponStateString}Attack", VmGothicEnums.AnimationType.MoveL => $"t_{weaponStateString}{walkModeString}StrafeL", VmGothicEnums.AnimationType.MoveR => $"t_{weaponStateString}{walkModeString}StrafeR", VmGothicEnums.AnimationType.RotL => $"T_{weaponStateString}{walkModeString}TurnL", VmGothicEnums.AnimationType.RotR => $"T_{weaponStateString}{walkModeString}TurnR", VmGothicEnums.AnimationType.StumbleA => "T_Stumble", + VmGothicEnums.AnimationType.StumbleB => "T_StumbleB", VmGothicEnums.AnimationType.DeadA => "T_Dead", VmGothicEnums.AnimationType.DeadB => "T_DeadB", - VmGothicEnums.AnimationType.StumbleB or + // Walking backwards (e.g. s_WalkBL, s_FistWalkBL). A run-backwards loop doesn't exist in the assets. + VmGothicEnums.AnimationType.MoveBack => $"S_{weaponStateString}{walkModeString}BL", + VmGothicEnums.AnimationType.AttackL => $"t_{weaponStateString}AttackL", + VmGothicEnums.AnimationType.AttackR => $"t_{weaponStateString}AttackR", + // Parades exist per direction (_O/_U/_L/_R); _O (high) is the default block. + VmGothicEnums.AnimationType.AttackBlock => $"t_{weaponStateString}Parade_O", + VmGothicEnums.AnimationType.AttackFinish => $"t_{weaponStateString}SFinish", + VmGothicEnums.AnimationType.AimBow => $"S_{weaponStateString}Aim", + VmGothicEnums.AnimationType.Fall => "S_FallDn", + VmGothicEnums.AnimationType.FallDeep or + VmGothicEnums.AnimationType.FallDeepA => "S_Fall", + VmGothicEnums.AnimationType.FallDeepB => "S_FallB", + VmGothicEnums.AnimationType.Fallen or + VmGothicEnums.AnimationType.FallenA => "S_Fallen", + VmGothicEnums.AnimationType.FallenB => "S_FallenB", + VmGothicEnums.AnimationType.Jump => "S_Jump", + VmGothicEnums.AnimationType.JumpUpLow => "S_JumpUpLow", + VmGothicEnums.AnimationType.JumpUpMid => "S_JumpUpMid", + VmGothicEnums.AnimationType.JumpUp => "S_JumpUp", + VmGothicEnums.AnimationType.JumpHang => "S_Hang", + VmGothicEnums.AnimationType.SlideA => "S_Slide", + VmGothicEnums.AnimationType.SlideB => "S_SlideB", + VmGothicEnums.AnimationType.UnconsciousA => "S_Wounded", + VmGothicEnums.AnimationType.UnconsciousB => "S_WoundedB", + VmGothicEnums.AnimationType.PointAt => "T_Point", + VmGothicEnums.AnimationType.ItmGet => "T_Stand_2_IGet", + // No animations exist in the assets for these (checked against the original .mds files). VmGothicEnums.AnimationType.NoAnim or - VmGothicEnums.AnimationType.MoveBack or VmGothicEnums.AnimationType.WhirlL or VmGothicEnums.AnimationType.WhirlR or - VmGothicEnums.AnimationType.Fall or - VmGothicEnums.AnimationType.FallDeep or - VmGothicEnums.AnimationType.FallDeepA or - VmGothicEnums.AnimationType.FallDeepB or - VmGothicEnums.AnimationType.Jump or - VmGothicEnums.AnimationType.JumpUpLow or - VmGothicEnums.AnimationType.JumpUpMid or - VmGothicEnums.AnimationType.JumpUp or - VmGothicEnums.AnimationType.JumpHang or - VmGothicEnums.AnimationType.Fallen or - VmGothicEnums.AnimationType.FallenA or - VmGothicEnums.AnimationType.FallenB or - VmGothicEnums.AnimationType.SlideA or - VmGothicEnums.AnimationType.SlideB or - VmGothicEnums.AnimationType.UnconsciousA or - VmGothicEnums.AnimationType.UnconsciousB or VmGothicEnums.AnimationType.InteractIn or VmGothicEnums.AnimationType.InteractOut or VmGothicEnums.AnimationType.InteractToStand or VmGothicEnums.AnimationType.InteractFromStand or - VmGothicEnums.AnimationType.AttackL or - VmGothicEnums.AnimationType.AttackR or - VmGothicEnums.AnimationType.AttackBlock or - VmGothicEnums.AnimationType.AttackFinish or - VmGothicEnums.AnimationType.AimBow or - VmGothicEnums.AnimationType.PointAt or - VmGothicEnums.AnimationType.ItmGet or VmGothicEnums.AnimationType.ItmDrop or VmGothicEnums.AnimationType.MagNoMana or _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) }; + // Wading has no own idle animation in the assets (only the WalkW move/turn/strafe loops exist). + // Stand with the on-land Walk idle instead. + if (type == VmGothicEnums.AnimationType.Idle && walkMode == VmGothicEnums.WalkMode.Water && + GetTrack(animationName, npc.Props.MdsNameBase, npc.Props.MdsNameOverlay) == null) + { + animationName = GetIdleAnimationName(weaponStateString, "WALK"); + } + // Some monsters like Gobbo are *W1H* with their Mace, but the animations are *Fist* only. if (GetTrack(animationName, npc.Props.MdsNameBase, npc.Props.MdsNameOverlay) == null && overrideWeaponState == null) @@ -327,17 +529,46 @@ private string GetWalkModeString(VmGothicEnums.WalkMode walkMode) VmGothicEnums.WalkMode.Walk => "WALK", VmGothicEnums.WalkMode.Run => "RUN", VmGothicEnums.WalkMode.Sneak => "SNEAK", - VmGothicEnums.WalkMode.Water => "WATER", + // Wading is named WalkW inside the .mds files (s_WalkWL, t_WalkWTurnL, ...). + VmGothicEnums.WalkMode.Water => "WALKW", VmGothicEnums.WalkMode.Swim => "SWIM", VmGothicEnums.WalkMode.Dive => "DIVE", _ => throw new ArgumentOutOfRangeException(nameof(walkMode), walkMode, null) }; } + /// + /// Animation names carry these weapon prefixes (e.g. s_1hRun, t_2hRunTurnL, s_MagWalk). + /// The WeaponState enum names (W1H, W2H, Mage) do not match the names inside the .mds files. + /// + public static string GetWeaponAnimationPrefix(VmGothicEnums.WeaponState weaponState) + { + return weaponState switch + { + VmGothicEnums.WeaponState.NoWeapon => "", + VmGothicEnums.WeaponState.Fist => "FIST", + VmGothicEnums.WeaponState.W1H => "1H", + VmGothicEnums.WeaponState.W2H => "2H", + VmGothicEnums.WeaponState.Bow => "BOW", + VmGothicEnums.WeaponState.CBow => "CBOW", + VmGothicEnums.WeaponState.Mage => "MAG", + _ => throw new ArgumentOutOfRangeException(nameof(weaponState), weaponState, null) + }; + } + + private static string GetMoveAnimationName(string weaponStateString, VmGothicEnums.WalkMode walkMode, string walkModeString) + { + // Swimming and diving have no walk loop - forward movement is a dedicated F animation without weapon variants. + if (walkMode is VmGothicEnums.WalkMode.Swim or VmGothicEnums.WalkMode.Dive) + return $"S_{walkModeString}F"; + + return $"{GetIdleAnimationName(weaponStateString, walkModeString)}L"; + } + /// /// Will be reused. Therefore, it's a separate method. /// - private string GetIdleAnimationName(string weaponStateString, string walkModeString) + private static string GetIdleAnimationName(string weaponStateString, string walkModeString) { return $"S_{weaponStateString}{walkModeString}"; } diff --git a/Assets/Gothic-Core/Scripts/Services/Vobs/VobService.cs b/Assets/Gothic-Core/Scripts/Services/Vobs/VobService.cs index ac165c29b..8553f87a7 100644 --- a/Assets/Gothic-Core/Scripts/Services/Vobs/VobService.cs +++ b/Assets/Gothic-Core/Scripts/Services/Vobs/VobService.cs @@ -195,7 +195,7 @@ private IEnumerator InitVobCoroutine() { Logger.LogError($"Failed to init VOB {item.name}: {e}", LogCat.Vob); } - + } yield return _frameSkipperService.TrySkipToNextFrameCoroutine();