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

@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.World;
/// <summary>Per-entity world-position + landblock snapshot fed to the culler.</summary>
public readonly record struct EntityCullInfo(uint Guid, Vector3 Pos, int LbX, int LbY, bool HasLb);
/// <summary>
/// AP-48 fix: a client-side visibility cull that prunes world entities the player has left
/// far behind, so the entity tables don't accumulate every object from every town visited
/// (which sank FPS the more the player portal-hopped). Faithful port of holtburger's
/// <c>liveness.rs</c> (<c>crates/holtburger-world/src/state/liveness.rs</c>): an entity is
/// "conservatively visible" if it's in the 3×3 landblock neighborhood OR within 384 m of the
/// player; it is evicted only after being continuously OUT of visibility for 25 s
/// (arm-on-leave / clear-on-return). The 25 s is time-since-it-left-visibility, NOT
/// time-since-last-update.
///
/// <para>This class is pure: it owns only the per-guid prune deadlines and decides WHO to
/// evict; the caller (GameWindow) supplies the entity snapshots and performs the actual
/// teardown through its existing pruner. The local player and any held/equipped/contained
/// item are excluded by the caller (the player by guid; inventory items never have a world
/// position so they never appear in the snapshot).</para>
/// </summary>
public sealed class EntityVisibilityCuller
{
/// <summary>holtburger <c>CONSERVATIVE_VISIBILITY_DISTANCE_M</c> (liveness.rs:12).</summary>
public const double VisibilityDistanceM = 384.0;
/// <summary>holtburger <c>ACE_DESTRUCTION_TIMEOUT_SECS</c> (liveness.rs:11).</summary>
public const double DestructionTimeoutSec = 25.0;
private readonly Dictionary<uint, double> _deadline = new();
/// <summary>Number of entities currently armed for eviction (diagnostic).</summary>
public int ArmedCount => _deadline.Count;
/// <summary>
/// "Conservatively visible": in the 3×3 landblock neighborhood (own + adjacent landblocks)
/// OR within <see cref="VisibilityDistanceM"/> of the player. The landblock test uses the
/// absolute landblock id (frame-independent), so a streaming recenter on teleport doesn't
/// false-positive a near entity whose offset position is momentarily stale.
/// </summary>
public static bool IsVisible(
Vector3 entityPos, int eLbX, int eLbY, bool entityHasLb,
Vector3 playerPos, int pLbX, int pLbY)
{
bool nearLb = entityHasLb && Math.Abs(eLbX - pLbX) <= 1 && Math.Abs(eLbY - pLbY) <= 1;
return nearLb
|| Vector3.DistanceSquared(entityPos, playerPos)
<= VisibilityDistanceM * VisibilityDistanceM;
}
/// <summary>
/// Pure per-entity state transition. Given current visibility, the entity's current
/// prune deadline (null = none armed) and the current time, returns the new deadline
/// (null = cleared) and whether to evict now. Visible → clear + keep; not-visible +
/// unarmed → arm at now+25s; not-visible + armed-and-expired → evict; not-visible +
/// armed-not-expired → keep.
/// </summary>
public static (double? newDeadline, bool evict) Step(bool visible, double? deadline, double now)
{
if (visible) return (null, false);
if (deadline is null) return (now + DestructionTimeoutSec, false);
if (deadline.Value <= now) return (null, true);
return (deadline, false);
}
/// <summary>
/// Advance the cull over the current world entities and return the guids to evict. The
/// caller must skip the local player BEFORE calling (or rely on the per-guid
/// <paramref name="playerGuid"/> filter here) and perform the teardown for each returned
/// guid. Mutates only the internal deadline map; does not touch the entities.
/// </summary>
public IReadOnlyList<uint> Tick(IEnumerable<EntityCullInfo> entities, EntityCullInfo player, double now)
{
List<uint>? evict = null;
foreach (var e in entities)
{
if (e.Guid == player.Guid) continue; // never cull the local player
bool visible = IsVisible(e.Pos, e.LbX, e.LbY, e.HasLb, player.Pos, player.LbX, player.LbY);
double? current = _deadline.TryGetValue(e.Guid, out var d) ? d : null;
var (newDeadline, doEvict) = Step(visible, current, now);
if (newDeadline is { } nd) _deadline[e.Guid] = nd; else _deadline.Remove(e.Guid);
if (doEvict) (evict ??= new List<uint>()).Add(e.Guid);
}
return (IReadOnlyList<uint>?)evict ?? Array.Empty<uint>();
}
/// <summary>Drop any armed deadline for a guid (e.g. when it's explicitly deleted or
/// re-created by the server, matching holtburger's clear-on-upsert).</summary>
public void Forget(uint guid) => _deadline.Remove(guid);
}