fix(app): lift cell meshes 2cm + diagnose animation-registration rejections

The user reported the ground-floor of buildings flickers between the
cell mesh floor and the terrain polygon underneath. Classic Z-fight:
both surfaces are coincident in Z because buildings rest ON the
terrain, and depth-buffer precision picks a winner per-pixel per
frame. Add a 2cm Z lift to every EnvCell transform so the cell floor
wins cleanly. Human-scale invisible.

Separately: NPCs in Phase 6.4 aren't visibly breathing. To tell
whether they're being rejected by our registration filter (framerate
== 0, single-frame cycle, or one-frame animation) vs. just having
short cycles the user can't see, add four rejection counters around
the idleCycle check and print them in the summary line next to the
other spawn diagnostics. Next run will tell us exactly which bucket
eats NPCs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 20:20:49 +02:00
parent d379a75984
commit 5253c7bfe8

View file

@ -72,6 +72,11 @@ public sealed class GameWindow : IDisposable
private int _liveDropReasonNoSetup;
private int _liveDropReasonSetupDatMissing;
private int _liveDropReasonNoMeshRefs;
// Phase 6.4 animation-registration diagnostics
private int _liveAnimRejectNoCycle;
private int _liveAnimRejectFramerate;
private int _liveAnimRejectSingleFrame;
private int _liveAnimRejectPartFrames;
public GameWindow(string datDir, WorldGameState worldGameState, WorldEvents worldEvents)
{
@ -449,9 +454,18 @@ public sealed class GameWindow : IDisposable
// the WorldEntity at identity so the renderer's
// model = PartTransform * entityRoot = cellTransform * I
// gives the correctly positioned cell mesh.
//
// Z lift: buildings sit ON the terrain mesh, so the ground
// floor of every building is coincident in Z with the terrain
// polygon beneath it. Without a bias the two fight for the
// same depth and flicker as the camera moves. A small lift
// (2 cm) is invisible from human scale but breaks the tie
// cleanly in the cell mesh's favor.
var cellOrigin = envCell.Position.Origin + lbOffset
+ new System.Numerics.Vector3(0f, 0f, 0.02f);
var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(envCell.Position.Origin + lbOffset);
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransform);
@ -921,6 +935,17 @@ public sealed class GameWindow : IDisposable
// 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).
// Diagnostic: log why we did / didn't register so we can tell
// which entities fall through the filter.
if (idleCycle is null)
_liveAnimRejectNoCycle++;
else if (idleCycle.Framerate == 0f)
_liveAnimRejectFramerate++;
else if (idleCycle.HighFrame <= idleCycle.LowFrame)
_liveAnimRejectSingleFrame++;
else if (idleCycle.Animation.PartFrames.Count <= 1)
_liveAnimRejectPartFrames++;
if (idleCycle is not null && idleCycle.Framerate != 0f
&& idleCycle.HighFrame > idleCycle.LowFrame
&& idleCycle.Animation.PartFrames.Count > 1)
@ -950,6 +975,10 @@ public sealed class GameWindow : IDisposable
// waiting for a graceful shutdown.
if (_liveSpawnReceived % 20 == 0)
{
Console.WriteLine(
$"live: animated={_animatedEntities.Count} " +
$"animReject: noCycle={_liveAnimRejectNoCycle} fr0={_liveAnimRejectFramerate} " +
$"1frame={_liveAnimRejectSingleFrame} partFrames={_liveAnimRejectPartFrames}");
Console.WriteLine(
$"live: summary recv={_liveSpawnReceived} hydrated={_liveSpawnHydrated} " +
$"drops: noPos={_liveDropReasonNoPos} noSetup={_liveDropReasonNoSetup} " +