feat(app): Phase A.1 — StreamingController glue

Called once per frame from OnUpdate. Owns a StreamingRegion and uses
delegates into LandblockStreamer + a terrain-apply callback so unit
tests can inject fakes. Handles first-tick bootstrap (whole window
loads), boundary recenter (diff against previous center), and
drain completions (up to N per frame to cap GPU upload spikes).

4 new tests, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 22:26:55 +02:00
parent 6b70b1201d
commit 9067c4f60b
2 changed files with 198 additions and 0 deletions

View file

@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Called once per frame from <c>GameWindow.OnUpdate</c>. Owns the
/// <see cref="StreamingRegion"/> and uses delegates into
/// <see cref="LandblockStreamer"/> so tests can inject fakes. All work
/// happens on the render thread; the streamer itself is background.
///
/// <remarks>
/// Threading: not thread-safe. All calls must happen on the render thread.
/// </remarks>
/// </summary>
public sealed class StreamingController
{
private readonly Action<uint> _enqueueLoad;
private readonly Action<uint> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LoadedLandblock> _applyTerrain;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
public int Radius { get; set; }
public int MaxCompletionsPerFrame { get; set; } = 4;
public StreamingController(
Action<uint> enqueueLoad,
Action<uint> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
Action<LoadedLandblock> applyTerrain,
GpuWorldState state,
int radius)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_applyTerrain = applyTerrain;
_state = state;
Radius = radius;
}
/// <summary>
/// Advance one frame. <paramref name="observerCx"/>/<paramref name="observerCy"/>
/// are landblock coordinates (0..255) of the current viewer — the camera
/// in offline mode, the server-sent player position in live.
/// </summary>
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;
}
}
}
}