diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index bbb5140..4578f20 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -28,6 +28,37 @@ public sealed class GameWindow : IDisposable private TextureCache? _textureCache; private IReadOnlyList _entities = Array.Empty(); + /// + /// Phase 6.4: per-entity animation playback state for entities whose + /// MotionTable resolved to a real cycle. The render loop ticks each + /// of these every frame, advances the current frame number, then + /// rebuilds the entity's MeshRefs by re-flattening the Setup against + /// the new . + /// Static decorations and entities with no motion table never + /// appear in this map. + /// + private readonly Dictionary _animatedEntities = new(); + + private sealed class AnimatedEntity + { + public required AcDream.Core.World.WorldEntity Entity; + public required DatReaderWriter.DBObjs.Setup Setup; + public required DatReaderWriter.DBObjs.Animation Animation; + public required int LowFrame; + public required int HighFrame; + public required float Framerate; // frames per second + public required float Scale; // server ObjScale baked into part transforms each tick + /// + /// Per-part identity carried over from the hydration pass: the + /// (post-AnimPartChanges) GfxObjId and the (post-resolution) + /// surface override map. The transform is recomputed every tick + /// from the current animation frame; only these two fields are + /// reused unchanged. + /// + public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary? SurfaceOverrides)> PartTemplate; + public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame] + } + // Phase 4.7: optional live connection to an ACE server. Enabled only when // ACDREAM_LIVE=1 is in the environment — fully backward compatible with // the offline rendering pipeline. @@ -638,11 +669,23 @@ public sealed class GameWindow : IDisposable // which motion table to use via PhysicsDescriptionFlag.MTable. // Without this override the resolver returns null and we fall // back to PlacementFrames[Default] which renders the wrong pose. - var idleFrame = AcDream.Core.Meshing.MotionResolver.GetIdleFrame( + // Phase 6.4: prefer the full cycle so we can play it forward over + // time. Falls back to GetIdleFrame's static-frame behavior when + // the cycle resolves but only the first frame is rendered (no + // animated entry registered) — this happens for entities the + // resolver short-circuits on. + var idleCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle( setup, _dats, motionTableIdOverride: spawn.MotionTableId, stanceOverride: stanceOverride, commandOverride: commandOverride); + DatReaderWriter.Types.AnimationFrame? idleFrame = null; + if (idleCycle is not null) + { + int startIdx = idleCycle.LowFrame; + if (startIdx < 0 || startIdx >= idleCycle.Animation.PartFrames.Count) startIdx = 0; + idleFrame = idleCycle.Animation.PartFrames[startIdx]; + } var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup, idleFrame); // Apply the server's AnimPartChanges: "replace part at index N @@ -816,6 +859,34 @@ public sealed class GameWindow : IDisposable _entities = extended; _liveSpawnHydrated++; + // Phase 6.4: register for per-frame playback if we resolved a real + // cycle with a non-zero framerate and at least two frames in the + // cycle (single-frame poses are static and don't need ticking). + if (idleCycle is not null && idleCycle.Framerate != 0f + && idleCycle.HighFrame > idleCycle.LowFrame + && idleCycle.Animation.PartFrames.Count > 1) + { + // Snapshot per-part identity from the hydrated meshRefs so the + // tick can rebuild MeshRefs without redoing AnimPartChanges or + // texture-override resolution every frame. + var template = new (uint, IReadOnlyDictionary?)[meshRefs.Count]; + for (int i = 0; i < meshRefs.Count; i++) + template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides); + + _animatedEntities[entity.Id] = new AnimatedEntity + { + Entity = entity, + Setup = setup, + Animation = idleCycle.Animation, + LowFrame = Math.Max(0, idleCycle.LowFrame), + HighFrame = Math.Min(idleCycle.HighFrame, idleCycle.Animation.PartFrames.Count - 1), + Framerate = idleCycle.Framerate, + Scale = scale, + PartTemplate = template, + CurrFrame = idleCycle.LowFrame, + }; + } + // Dump a summary periodically so we can see drop breakdowns without // waiting for a graceful shutdown. if (_liveSpawnReceived % 20 == 0) @@ -889,6 +960,12 @@ public sealed class GameWindow : IDisposable private void OnRender(double deltaSeconds) { _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); + + // Phase 6.4: advance per-entity animation playback before drawing + // so the renderer always sees the up-to-date per-part transforms. + if (_animatedEntities.Count > 0) + TickAnimations((float)deltaSeconds); + if (_cameraController is not null) { _terrain?.Draw(_cameraController.Active); @@ -896,6 +973,88 @@ public sealed class GameWindow : IDisposable } } + /// + /// Phase 6.4: advance every animated entity's frame counter by + /// * Framerate, wrapping around the cycle's + /// [LowFrame..HighFrame] interval, then rebuild that entity's + /// MeshRefs from the new frame's per-part transforms. Static + /// entities (no AnimatedEntity record) are untouched. The static + /// renderer reads the new MeshRefs on the next Draw call. + /// + private void TickAnimations(float dt) + { + foreach (var kv in _animatedEntities) + { + var ae = kv.Value; + int span = ae.HighFrame - ae.LowFrame; + if (span <= 0) continue; + + ae.CurrFrame += dt * ae.Framerate; + // Wrap into [LowFrame, HighFrame]. Use a guarded modulo so + // big dts (first frame after a stall) don't blow the loop. + if (ae.CurrFrame > ae.HighFrame) + { + float over = ae.CurrFrame - ae.LowFrame; + ae.CurrFrame = ae.LowFrame + (over % (span + 1)); + } + else if (ae.CurrFrame < ae.LowFrame) + { + ae.CurrFrame = ae.LowFrame; + } + + int frameIdx = (int)ae.CurrFrame; + if (frameIdx < 0 || frameIdx >= ae.Animation.PartFrames.Count) + frameIdx = ae.LowFrame; + var partFrames = ae.Animation.PartFrames[frameIdx].Frames; + + int partCount = ae.PartTemplate.Count; + var newMeshRefs = new List(partCount); + var scaleMat = ae.Scale == 1.0f + ? System.Numerics.Matrix4x4.Identity + : System.Numerics.Matrix4x4.CreateScale(ae.Scale); + + for (int i = 0; i < partCount; i++) + { + // Frame source: the animation's per-part transform when + // the index is in range, otherwise identity (parts that + // outnumber the animation's bone list — rare but defensive). + DatReaderWriter.Types.Frame frame; + if (i < partFrames.Count) + frame = partFrames[i]; + else + frame = new DatReaderWriter.Types.Frame + { + Origin = System.Numerics.Vector3.Zero, + Orientation = System.Numerics.Quaternion.Identity, + }; + + // Per-part default scale from the Setup, matching SetupMesh.Flatten's + // composition order: scale → rotate → translate. + var defaultScale = i < ae.Setup.DefaultScale.Count + ? ae.Setup.DefaultScale[i] + : System.Numerics.Vector3.One; + + var partTransform = + System.Numerics.Matrix4x4.CreateScale(defaultScale) * + System.Numerics.Matrix4x4.CreateFromQuaternion(frame.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(frame.Origin); + + // Bake the entity's ObjScale on top, matching the hydration + // order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned. + if (ae.Scale != 1.0f) + partTransform = partTransform * scaleMat; + + var template = ae.PartTemplate[i]; + newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, partTransform) + { + SurfaceOverrides = template.SurfaceOverrides, + }); + } + + ae.Entity.MeshRefs = newMeshRefs; + } + } + private void OnClosing() { _liveSession?.Dispose(); diff --git a/src/AcDream.Core/Meshing/MotionResolver.cs b/src/AcDream.Core/Meshing/MotionResolver.cs index 12c566d..fdd118f 100644 --- a/src/AcDream.Core/Meshing/MotionResolver.cs +++ b/src/AcDream.Core/Meshing/MotionResolver.cs @@ -50,12 +50,65 @@ public static class MotionResolver /// — pass that here when present so character outfits/states use the /// correct table. /// + /// + /// Per-entity idle-cycle playback descriptor returned by + /// . Holds the resolved Animation plus the + /// frame range and framerate from the MotionTable's AnimData entry. + /// Phase 6.4 uses this to drive per-frame playback (so creatures + /// breathe instead of being frozen on a single frame). + /// + public sealed record IdleCycle( + Animation Animation, + int LowFrame, + int HighFrame, + float Framerate); + + /// + /// Same resolution algorithm as but + /// returns the full cycle metadata so the caller can advance frames + /// over time. Returns null if any link in the chain is missing. + /// + public static IdleCycle? GetIdleCycle( + Setup setup, + DatCollection dats, + uint? motionTableIdOverride = null, + ushort? stanceOverride = null, + ushort? commandOverride = null) + { + var resolved = ResolveIdleCycleInternal(setup, dats, motionTableIdOverride, stanceOverride, commandOverride); + if (resolved is null) return null; + var (anim, ad) = resolved.Value; + return new IdleCycle(anim, ad.LowFrame, ad.HighFrame, ad.Framerate); + } + public static AnimationFrame? GetIdleFrame( Setup setup, DatCollection dats, uint? motionTableIdOverride = null, ushort? stanceOverride = null, ushort? commandOverride = null) + { + var resolved = ResolveIdleCycleInternal(setup, dats, motionTableIdOverride, stanceOverride, commandOverride); + if (resolved is null) return null; + var (animation, animData) = resolved.Value; + int frameIdx = animData.LowFrame; + if (frameIdx < 0 || frameIdx >= animation.PartFrames.Count) + frameIdx = 0; + return animation.PartFrames[frameIdx]; + } + + /// + /// Shared cycle-resolution path for both + /// and . Returns the loaded Animation + + /// the AnimData that describes its frame range and framerate, or + /// null if any link in the chain is missing. + /// + private static (Animation, AnimData)? ResolveIdleCycleInternal( + Setup setup, + DatCollection dats, + uint? motionTableIdOverride, + ushort? stanceOverride, + ushort? commandOverride) { ArgumentNullException.ThrowIfNull(setup); ArgumentNullException.ThrowIfNull(dats); @@ -127,7 +180,7 @@ public static class MotionResolver var animData = motionData.Anims[0]; - // Step 3: load the Animation and grab the first frame. + // Load the Animation referenced by the cycle. uint animId = (uint)animData.AnimId; if (animId == 0) return null; @@ -135,12 +188,6 @@ public static class MotionResolver if (animation is null) return null; if (animation.PartFrames.Count == 0) return null; - // animData.LowFrame is the start frame index of the cycle within the - // animation. Clamp defensively in case it's out of range. - int frameIdx = animData.LowFrame; - if (frameIdx < 0 || frameIdx >= animation.PartFrames.Count) - frameIdx = 0; - - return animation.PartFrames[frameIdx]; + return (animation, animData); } } diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs index 705a5d0..31441fd 100644 --- a/src/AcDream.Core/World/WorldEntity.cs +++ b/src/AcDream.Core/World/WorldEntity.cs @@ -8,7 +8,13 @@ public sealed class WorldEntity public required uint SourceGfxObjOrSetupId { get; init; } public required Vector3 Position { get; init; } public required Quaternion Rotation { get; init; } - public required IReadOnlyList MeshRefs { get; init; } + /// + /// Per-part mesh references with their root-relative transforms. + /// Mutable so the animation tick can replace it each frame for + /// entities that play a cycle (Phase 6.4); static entities set it + /// once at hydration and never touch it again. + /// + public required IReadOnlyList MeshRefs { get; set; } /// /// Optional per-entity palette override (server-specified base +