feat(core+app): Phase 6.4 — per-frame animation playback (breathing/idle cycles)
Phase 6.1-6.3 resolved the right cycle and rendered its first frame as a static pose. Phase 6.4 actually walks the cycle over time so creatures, characters, and props animate their idle motion — the breathing the user noticed was missing after Phase 6.1. MotionResolver gains GetIdleCycle() returning IdleCycle(Animation, LowFrame, HighFrame, Framerate). The existing GetIdleFrame() now shares a private ResolveIdleCycleInternal helper, so the resolution algorithm (motion-table override, stance/command priority, fallback) is identical for both entry points and stays in one place. WorldEntity.MeshRefs becomes a get/set so the per-frame tick can swap in fresh per-part transforms without rebuilding the entity. Static decorations never get touched. GameWindow keeps a Dictionary<entityId, AnimatedEntity> for entities whose motion table resolved to a multi-frame, non-zero-framerate cycle. AnimatedEntity caches a per-part template (gfxObjId + surfaceOverrides + scale) snapshot taken from the hydration pass so the tick doesn't redo AnimPartChange/TextureChange resolution every frame — only the per-part transform matrices are recomputed. OnRender calls TickAnimations(dt) before Draw. The tick advances each entity's CurrFrame by dt*Framerate, wraps it inside [LowFrame, HighFrame], samples the corresponding AnimationFrame, and rebuilds the entity's MeshRefs by composing scale → quaternion rotate → translate per part in the same order SetupMesh.Flatten uses, then baking the entity's ObjScale on top in the same PartTransform * scaleMat order as the hydration path. 160 tests green. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b96167e066
commit
f0fa067566
3 changed files with 222 additions and 10 deletions
|
|
@ -28,6 +28,37 @@ public sealed class GameWindow : IDisposable
|
||||||
private TextureCache? _textureCache;
|
private TextureCache? _textureCache;
|
||||||
private IReadOnlyList<AcDream.Core.World.WorldEntity> _entities = Array.Empty<AcDream.Core.World.WorldEntity>();
|
private IReadOnlyList<AcDream.Core.World.WorldEntity> _entities = Array.Empty<AcDream.Core.World.WorldEntity>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="DatReaderWriter.Types.AnimationFrame"/>.
|
||||||
|
/// Static decorations and entities with no motion table never
|
||||||
|
/// appear in this map.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Dictionary<uint, AnimatedEntity> _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
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary<uint, uint>? SurfaceOverrides)> PartTemplate;
|
||||||
|
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 4.7: optional live connection to an ACE server. Enabled only when
|
// Phase 4.7: optional live connection to an ACE server. Enabled only when
|
||||||
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
|
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
|
||||||
// the offline rendering pipeline.
|
// the offline rendering pipeline.
|
||||||
|
|
@ -638,11 +669,23 @@ public sealed class GameWindow : IDisposable
|
||||||
// which motion table to use via PhysicsDescriptionFlag.MTable.
|
// which motion table to use via PhysicsDescriptionFlag.MTable.
|
||||||
// Without this override the resolver returns null and we fall
|
// Without this override the resolver returns null and we fall
|
||||||
// back to PlacementFrames[Default] which renders the wrong pose.
|
// 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,
|
setup, _dats,
|
||||||
motionTableIdOverride: spawn.MotionTableId,
|
motionTableIdOverride: spawn.MotionTableId,
|
||||||
stanceOverride: stanceOverride,
|
stanceOverride: stanceOverride,
|
||||||
commandOverride: commandOverride);
|
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);
|
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup, idleFrame);
|
||||||
|
|
||||||
// Apply the server's AnimPartChanges: "replace part at index N
|
// Apply the server's AnimPartChanges: "replace part at index N
|
||||||
|
|
@ -816,6 +859,34 @@ public sealed class GameWindow : IDisposable
|
||||||
_entities = extended;
|
_entities = extended;
|
||||||
_liveSpawnHydrated++;
|
_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<uint, uint>?)[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
|
// Dump a summary periodically so we can see drop breakdowns without
|
||||||
// waiting for a graceful shutdown.
|
// waiting for a graceful shutdown.
|
||||||
if (_liveSpawnReceived % 20 == 0)
|
if (_liveSpawnReceived % 20 == 0)
|
||||||
|
|
@ -889,6 +960,12 @@ public sealed class GameWindow : IDisposable
|
||||||
private void OnRender(double deltaSeconds)
|
private void OnRender(double deltaSeconds)
|
||||||
{
|
{
|
||||||
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
_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)
|
if (_cameraController is not null)
|
||||||
{
|
{
|
||||||
_terrain?.Draw(_cameraController.Active);
|
_terrain?.Draw(_cameraController.Active);
|
||||||
|
|
@ -896,6 +973,88 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 6.4: advance every animated entity's frame counter by
|
||||||
|
/// <paramref name="dt"/> * 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.
|
||||||
|
/// </summary>
|
||||||
|
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<AcDream.Core.World.MeshRef>(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()
|
private void OnClosing()
|
||||||
{
|
{
|
||||||
_liveSession?.Dispose();
|
_liveSession?.Dispose();
|
||||||
|
|
|
||||||
|
|
@ -50,12 +50,65 @@ public static class MotionResolver
|
||||||
/// — pass that here when present so character outfits/states use the
|
/// — pass that here when present so character outfits/states use the
|
||||||
/// correct table.
|
/// correct table.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <summary>
|
||||||
|
/// Per-entity idle-cycle playback descriptor returned by
|
||||||
|
/// <see cref="GetIdleCycle"/>. 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).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record IdleCycle(
|
||||||
|
Animation Animation,
|
||||||
|
int LowFrame,
|
||||||
|
int HighFrame,
|
||||||
|
float Framerate);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same resolution algorithm as <see cref="GetIdleFrame"/> but
|
||||||
|
/// returns the full cycle metadata so the caller can advance frames
|
||||||
|
/// over time. Returns null if any link in the chain is missing.
|
||||||
|
/// </summary>
|
||||||
|
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(
|
public static AnimationFrame? GetIdleFrame(
|
||||||
Setup setup,
|
Setup setup,
|
||||||
DatCollection dats,
|
DatCollection dats,
|
||||||
uint? motionTableIdOverride = null,
|
uint? motionTableIdOverride = null,
|
||||||
ushort? stanceOverride = null,
|
ushort? stanceOverride = null,
|
||||||
ushort? commandOverride = 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared cycle-resolution path for both <see cref="GetIdleFrame"/>
|
||||||
|
/// and <see cref="GetIdleCycle"/>. Returns the loaded Animation +
|
||||||
|
/// the AnimData that describes its frame range and framerate, or
|
||||||
|
/// null if any link in the chain is missing.
|
||||||
|
/// </summary>
|
||||||
|
private static (Animation, AnimData)? ResolveIdleCycleInternal(
|
||||||
|
Setup setup,
|
||||||
|
DatCollection dats,
|
||||||
|
uint? motionTableIdOverride,
|
||||||
|
ushort? stanceOverride,
|
||||||
|
ushort? commandOverride)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(setup);
|
ArgumentNullException.ThrowIfNull(setup);
|
||||||
ArgumentNullException.ThrowIfNull(dats);
|
ArgumentNullException.ThrowIfNull(dats);
|
||||||
|
|
@ -127,7 +180,7 @@ public static class MotionResolver
|
||||||
|
|
||||||
var animData = motionData.Anims[0];
|
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;
|
uint animId = (uint)animData.AnimId;
|
||||||
if (animId == 0) return null;
|
if (animId == 0) return null;
|
||||||
|
|
||||||
|
|
@ -135,12 +188,6 @@ public static class MotionResolver
|
||||||
if (animation is null) return null;
|
if (animation is null) return null;
|
||||||
if (animation.PartFrames.Count == 0) return null;
|
if (animation.PartFrames.Count == 0) return null;
|
||||||
|
|
||||||
// animData.LowFrame is the start frame index of the cycle within the
|
return (animation, animData);
|
||||||
// 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];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,13 @@ public sealed class WorldEntity
|
||||||
public required uint SourceGfxObjOrSetupId { get; init; }
|
public required uint SourceGfxObjOrSetupId { get; init; }
|
||||||
public required Vector3 Position { get; init; }
|
public required Vector3 Position { get; init; }
|
||||||
public required Quaternion Rotation { get; init; }
|
public required Quaternion Rotation { get; init; }
|
||||||
public required IReadOnlyList<MeshRef> MeshRefs { get; init; }
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public required IReadOnlyList<MeshRef> MeshRefs { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional per-entity palette override (server-specified base +
|
/// Optional per-entity palette override (server-specified base +
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue