fix(world): AP-48 client-side visibility cull (FPS sink across portal-hops)

acdream accumulated every CreateObject from every town visited and never pruned by
distance/time (only on server DeleteObject / respawn de-dup), so the entity tables +
the O(N^2) TickAnimations scan grew with each hop and sank FPS (confirmed in Release).

Faithful port of holtburger liveness.rs (ACE_DESTRUCTION_TIMEOUT_SECS=25,
CONSERVATIVE_VISIBILITY_DISTANCE_M=384): a world entity is evicted only after being
>384m AND outside the 3x3 landblock neighborhood for 25s continuous (arm-on-leave /
clear-on-return). Logic in a pure, unit-tested EntityVisibilityCuller; GameWindow
wires a 1Hz tick that snapshots the world entities + player and tears each evicted
guid down through the existing pruner. Player + held/equipped/contained items are
excluded (player by guid; inventory items never carry a world position so they never
enter the culled map). A re-created object starts fresh (deadline cleared on remove).
Skipped during a teleport hold (frozen player position). AD-32 registered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-22 15:45:07 +02:00
parent 76c7b1594b
commit e5b2d15b63
4 changed files with 268 additions and 1 deletions

View file

@ -840,6 +840,13 @@ public sealed class GameWindow : IDisposable
/// keys the render list; this parallel dictionary keys by server guid.
/// </summary>
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
// AP-48: client-side visibility cull (holtburger liveness.rs port) — prunes world
// entities the player left far behind so the tables don't accumulate every town's
// objects across portal-hops (which sank FPS). 1 Hz; the player + held/equipped items
// are excluded (see TickEntityCull).
private readonly AcDream.App.World.EntityVisibilityCuller _entityCuller = new();
private double _cullAccum;
/// <summary>
/// Latest <see cref="AcDream.Core.Net.WorldSession.EntitySpawn"/> for each
/// guid. Captured at the end of <see cref="OnLiveEntitySpawnedLocked"/> so
@ -3828,8 +3835,61 @@ public sealed class GameWindow : IDisposable
}
}
// AP-48 cull wiring (logic lives in EntityVisibilityCuller). Snapshots the world
// entities + player, asks the culler who has been >384m AND outside the 3×3 landblock
// neighborhood for 25s, and tears each down through the existing pruner. Throttled to
// 1 Hz (holtburger runs it per world tick; per-frame is wasteful). Skipped during a
// teleport hold (the player WorldEntity.Position is frozen). Held/equipped/contained
// items never enter _entitiesByServerGuid (OnLiveEntitySpawnedLocked early-returns on a
// null world position), so the cull is structurally incapable of touching them; the
// ClientObjectTable weenie row is dropped only for genuinely world-placed objects.
private void TickEntityCull(double dt)
{
_cullAccum += dt;
if (_cullAccum < 1.0) return;
_cullAccum = 0.0;
if (_playerController is { State: AcDream.App.Input.PlayerState.PortalSpace }) return;
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return;
uint plb = pe.ParentCellId ?? 0u;
var player = new AcDream.App.World.EntityCullInfo(
_playerServerGuid, pe.Position,
(int)((plb >> 24) & 0xFFu), (int)((plb >> 16) & 0xFFu), plb != 0u);
// Snapshot first (the eviction below mutates _entitiesByServerGuid).
var snapshot = new System.Collections.Generic.List<AcDream.App.World.EntityCullInfo>(
_entitiesByServerGuid.Count);
foreach (var kv in _entitiesByServerGuid)
{
uint elb = kv.Value.ParentCellId ?? 0u;
snapshot.Add(new AcDream.App.World.EntityCullInfo(
kv.Key, kv.Value.Position,
(int)((elb >> 24) & 0xFFu), (int)((elb >> 16) & 0xFFu), elb != 0u));
}
double now = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
var evicted = _entityCuller.Tick(snapshot, player, now);
foreach (var guid in evicted)
{
var row = Objects.Get(guid);
RemoveLiveEntityByServerGuid(guid);
// has_nonworld_retention parity: keep the weenie row if it's held/equipped (a far
// ground item open in a container view); drop it only for world-placed objects.
if (row is null || (row.ContainerId == 0u && row.WielderId == 0u))
Objects.Remove(guid);
}
}
private bool RemoveLiveEntityByServerGuid(uint serverGuid)
{
// AP-48: clear any armed cull deadline for this guid BEFORE the early-return, so a
// guid that was culled (and is no longer in _entitiesByServerGuid) but is then
// re-created by the server starts fresh — never inherits a stale, already-expired
// deadline that would evict it on the next cull tick. Mirrors holtburger's
// clear-on-upsert/delete.
_entityCuller.Forget(serverGuid);
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
return false;
@ -7584,6 +7644,10 @@ public sealed class GameWindow : IDisposable
// Step 2: routed through the controller; functionally identical.
_liveSessionController?.Tick();
// AP-48: 1 Hz visibility cull. Runs after the live-session drain so the player's
// WorldEntity.Position/ParentCellId reflect this frame's UpdatePosition.
TickEntityCull(dt);
// Retail teleport transit. Runs AFTER streaming (which priority-applies the
// destination landblock this frame) and the live-session drain, so a destination
// that became resident this frame materializes the same frame. The TAS holds at