feat(A.5 T18): use cached WorldEntity AABB in dispatcher; populate at register

Per Phase A.5 spec §4.6 Change #2: WalkEntities's per-entity AABB
frustum cull was recomputing Position±5 per frame per entity. With
~10.7K entities (N1=4) at 240 FPS that is ~2.5M wasted Vector3
ops/sec.

Read the AABB from the WorldEntity cache (T8 schema) instead.
RefreshAabb runs lazily on AabbDirty=true. Populate at register time:

- LandblockLoader.BuildEntitiesFromInfo: RefreshAabb after each new
  WorldEntity construction (stabs + buildings). Refactored from
  inline object-initializer to named variable to enable the call.
- EntitySpawnAdapter.OnCreate: RefreshAabb after entity state init
  (position/rotation already set via the WorldEntity passed in).

Dynamic entities (NPCs, players) move every frame via direct
Position writes in GameWindow.cs. Migrated all three per-frame
write sites to SetPosition() (T8 mutator) so AabbDirty propagates:
  - line 5942: player entity render position update
  - line 6951: remote animated entity interpolated path
  - line 7279: remote animated entity landing/movement path

The lazy RefreshAabb in WalkEntities catches up on the next frame
after any SetPosition call — render thread only, no races.

Build green, 986 passed / 8 pre-existing failures unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-10 08:20:20 +02:00
parent 003443cd1a
commit 0afd741ea7
3 changed files with 17 additions and 7 deletions

View file

@ -5939,7 +5939,7 @@ public sealed class GameWindow : IDisposable
// the physics-resolved location each frame. // the physics-resolved location each frame.
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
{ {
pe.Position = result.RenderPosition; pe.SetPosition(result.RenderPosition); // A.5 T18: SetPosition propagates AabbDirty
pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f); System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f);
@ -6948,7 +6948,7 @@ public sealed class GameWindow : IDisposable
rm.MaxSeqSpeedSinceLastUP = seqSpeedNow; rm.MaxSeqSpeedSinceLastUP = seqSpeedNow;
} }
ae.Entity.Position = rm.Body.Position; ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
ae.Entity.Rotation = rm.Body.Orientation; ae.Entity.Rotation = rm.Body.Orientation;
} }
else else
@ -7276,7 +7276,7 @@ public sealed class GameWindow : IDisposable
} }
} }
ae.Entity.Position = rm.Body.Position; ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
ae.Entity.Rotation = rm.Body.Orientation; ae.Entity.Rotation = rm.Body.Orientation;
} }
} }

View file

@ -128,6 +128,12 @@ public sealed class EntitySpawnAdapter
} }
} }
// A.5 T18: populate cached AABB so WalkEntities reads from the cache
// rather than recomputing Position±5 per frame. Called here because
// all entity-state initialization (position, rotation) is complete
// by this point via the WorldEntity passed in.
entity.RefreshAabb();
// Build the per-entity AnimatedEntityState. The sequencer factory // Build the per-entity AnimatedEntityState. The sequencer factory
// may return a stub (in tests) or a fully-constructed sequencer from // may return a stub (in tests) or a fully-constructed sequencer from
// the MotionTable (in production). Factory must not return null — // the MotionTable (in production). Factory must not return null —

View file

@ -42,28 +42,32 @@ public static class LandblockLoader
{ {
if (!IsSupported(stab.Id)) if (!IsSupported(stab.Id))
continue; continue;
result.Add(new WorldEntity var stabEntity = new WorldEntity
{ {
Id = nextId++, Id = nextId++,
SourceGfxObjOrSetupId = stab.Id, SourceGfxObjOrSetupId = stab.Id,
Position = stab.Frame.Origin, Position = stab.Frame.Origin,
Rotation = stab.Frame.Orientation, Rotation = stab.Frame.Orientation,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
}); };
stabEntity.RefreshAabb(); // A.5 T18: populate cached AABB at construction
result.Add(stabEntity);
} }
foreach (var building in info.Buildings) foreach (var building in info.Buildings)
{ {
if (!IsSupported(building.ModelId)) if (!IsSupported(building.ModelId))
continue; continue;
result.Add(new WorldEntity var buildingEntity = new WorldEntity
{ {
Id = nextId++, Id = nextId++,
SourceGfxObjOrSetupId = building.ModelId, SourceGfxObjOrSetupId = building.ModelId,
Position = building.Frame.Origin, Position = building.Frame.Origin,
Rotation = building.Frame.Orientation, Rotation = building.Frame.Orientation,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
}); };
buildingEntity.RefreshAabb(); // A.5 T18: populate cached AABB at construction
result.Add(buildingEntity);
} }
return result; return result;