using System; using System.Collections.Generic; 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 GpuWorldState _state; private StreamingRegion? _region; public int Radius { get; set; } /// /// Cap on completions drained per call. Defaults to /// effectively unlimited because the current LandblockStreamer /// is synchronous — every EnqueueLoad 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. /// /// /// 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 /// AppendLiveEntity with no matching loaded slot, and got /// dropped (now: parked in the pending bucket). /// /// public int MaxCompletionsPerFrame { get; set; } = int.MaxValue; public StreamingController( Action enqueueLoad, Action enqueueUnload, Func> drainCompletions, Action applyTerrain, GpuWorldState state, int radius) { _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; _drainCompletions = drainCompletions; _applyTerrain = applyTerrain; _state = state; Radius = radius; } /// /// Advance one frame. / /// are landblock coordinates (0..255) of the current viewer — the camera /// in offline mode, the server-sent player position in live. /// public void Tick(int observerCx, int observerCy) { // First-tick bootstrap: no region yet, so the whole visible window // is a load diff. if (_region is null) { _region = new StreamingRegion(observerCx, observerCy, Radius); foreach (var id in _region.Visible) _enqueueLoad(id); } else if (_region.CenterX != observerCx || _region.CenterY != observerCy) { var diff = _region.RecenterTo(observerCx, observerCy); foreach (var id in diff.ToLoad) _enqueueLoad(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); _state.AddLandblock(loaded.Landblock); break; case LandblockStreamResult.Unloaded unloaded: _state.RemoveLandblock(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; } } } }