fix(app): Phase A.1 — restore MaxCompletionsPerFrame=4 (uncap caused OOM)

The previous fix in f792931 set MaxCompletionsPerFrame to int.MaxValue
on the theory that synchronous loading made the cap pointless. That
ignored the GPU upload cost: applying 25 landblocks in one Tick
allocates ~25 terrain VBOs + hundreds of entity GfxObj sub-mesh VBOs
+ all unique texture uploads in a single frame, which observably
crashes with OutOfMemoryException on the first frame after login.

The pending-spawn list (also added in f792931) is what actually
fixes the spawn-drop bug — it makes the cap safe by parking
late-arriving spawns until their landblock loads. With both fixes:

- Cap=4 spreads the 25-landblock first-frame load over ~7 frames
  (~116ms at 60fps, below human perception)
- Spawns for the 21 not-yet-loaded landblocks land in pending and
  back-fill as each one arrives over the next 6 frames
- No data lost, no OOM

219 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 23:23:11 +02:00
parent f792931d21
commit 133c22ed2f

View file

@ -26,26 +26,22 @@ public sealed class StreamingController
public int Radius { get; set; }
/// <summary>
/// Cap on completions drained per <see cref="Tick"/> call. Defaults to
/// effectively unlimited because the current <c>LandblockStreamer</c>
/// is synchronous — every <c>EnqueueLoad</c> writes to the outbox on
/// the same thread, so by the time we drain there's no backlog
/// to spread, and the cap only serves to *delay* applying landblocks
/// the user is already trying to look at.
/// Cap on completions drained per <see cref="Tick"/> call. The cap is
/// the GPU upload budget for one frame: terrain mesh + per-entity GfxObj
/// sub-mesh uploads + texture uploads for one landblock take a few ms;
/// applying 25 of them in a single frame produces a memory spike
/// (observed: out-of-memory crash on the 5×5 first-frame load).
///
/// <para>
/// The original async design used a small cap (4) to limit per-frame
/// GPU upload spikes. That reasoning becomes relevant again if/when
/// the streamer moves back to async loading; lower this knob then.
/// Crucially, dropping completions to a lower frame is what was
/// silently breaking live spawns: the post-login spawn flood would
/// arrive on a frame where only 4 of the 25 visible-window landblocks
/// had been applied, the spawns for the other 21 hit
/// <c>AppendLiveEntity</c> with no matching loaded slot, and got
/// dropped (now: parked in the pending bucket).
/// 4 is the original async-streamer value; it spreads a 5×5 first-frame
/// load over ~7 frames (~116ms at 60fps), which is below the human
/// perception threshold. Spawn races that previously dropped entities
/// while landblocks were in flight are now handled by
/// <see cref="GpuWorldState"/>'s pending-spawn list, so spreading
/// completions doesn't lose any data.
/// </para>
/// </summary>
public int MaxCompletionsPerFrame { get; set; } = int.MaxValue;
public int MaxCompletionsPerFrame { get; set; } = 4;
public StreamingController(
Action<uint> enqueueLoad,