using System; using System.Collections.Generic; using AcDream.Core.Terrain; using AcDream.Core.World; namespace AcDream.App.Streaming; /// /// Called once per frame from GameWindow.OnUpdate. Owns the /// and uses delegates into /// so tests can inject fakes. All work /// happens on the render thread; the streamer itself is background. /// /// /// Threading: not thread-safe. All calls must happen on the render thread. /// /// public sealed class StreamingController { private readonly Action _enqueueLoad; private readonly Action _enqueueUnload; private readonly Func> _drainCompletions; private readonly Action _applyTerrain; private readonly Action? _removeTerrain; private readonly GpuWorldState _state; private StreamingRegion? _region; /// /// Near-tier radius (LBs from observer that load full detail: terrain + /// scenery + entities). Set at construction; readable thereafter. /// /// /// Mutating after the first has no effect — the /// internal snapshots both radii on its /// constructor. Treat as init-only post-Tick. /// public int NearRadius { get; } /// /// Far-tier radius (LBs from observer that load terrain only). Set at /// construction; readable thereafter. /// /// /// Mutating after the first has no effect — see . /// public int FarRadius { get; } /// /// Cap on completions drained per 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). /// /// /// 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 /// 's pending-spawn list, so spreading /// completions doesn't lose any data. /// /// public int MaxCompletionsPerFrame { get; set; } = 4; public StreamingController( Action enqueueLoad, Action enqueueUnload, Func> drainCompletions, Action applyTerrain, GpuWorldState state, int nearRadius, int farRadius, Action? removeTerrain = null) { _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; _drainCompletions = drainCompletions; _applyTerrain = applyTerrain; _removeTerrain = removeTerrain; _state = state; NearRadius = nearRadius; FarRadius = farRadius; } /// /// Advance one frame. / /// are landblock coordinates (0..255) of the current viewer — the camera /// in offline mode, the server-sent player position in live. /// /// Two-tier model (Phase A.5 T13): /// /// → enqueue LoadFar (terrain only, no entities) /// → enqueue LoadNear (terrain + entities) /// → enqueue PromoteToNear (entity layer for already-loaded terrain) /// → drop entities on render thread immediately (terrain stays) /// → enqueue full unload /// /// public void Tick(int observerCx, int observerCy) { if (_region is null) { _region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius); var bootstrap = _region.ComputeFirstTickDiff(); foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar); foreach (var id in bootstrap.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear); _region.MarkResidentFromBootstrap(); } else if (_region.CenterX != observerCx || _region.CenterY != observerCy) { var diff = _region.RecenterTo(observerCx, observerCy); foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar); foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear); foreach (var id in diff.ToPromote) _enqueueLoad(id, LandblockStreamJobKind.PromoteToNear); foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id); foreach (var id in diff.ToUnload) _enqueueUnload(id); } // Drain up to N completions per frame so a big diff doesn't spike // GPU upload time. Remaining completions wait for the next frame. var drained = _drainCompletions(MaxCompletionsPerFrame); foreach (var result in drained) { switch (result) { case LandblockStreamResult.Loaded loaded: _applyTerrain(loaded.Landblock, loaded.MeshData); _state.AddLandblock(loaded.Landblock); break; case LandblockStreamResult.Promoted promoted: _state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities); break; case LandblockStreamResult.Unloaded unloaded: _state.RemoveLandblock(unloaded.LandblockId); _removeTerrain?.Invoke(unloaded.LandblockId); break; case LandblockStreamResult.Failed failed: Console.WriteLine( $"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}"); break; case LandblockStreamResult.WorkerCrashed crashed: Console.WriteLine( $"streaming: worker CRASHED: {crashed.Error}"); break; } } } }