feat(teleport A): StreamingController priority-apply for the teleport destination LB
Adds PriorityLandblockId (uint, default 0) + _deferredApply buffer. DrainAndApply now: (1) applies up to budget from the deferred buffer, (2) when PriorityLandblockId != 0, hunts the worker outbox in chunks applying the priority LB immediately on match and buffering any non-priority items drained past it for later frames, (3) falls back to normal drain when no priority is set or not found. Extracts ApplyResult(result) + ResultLandblockId(result) helpers so both the priority and normal paths share identical side-effects. No existing behaviour changes on the non-priority path. 21 streaming tests pass (19 existing + 2 new priority-apply tests). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b869128df3
commit
1f6baa6cfc
2 changed files with 228 additions and 28 deletions
|
|
@ -89,6 +89,28 @@ public sealed class StreamingController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int MaxCompletionsPerFrame { get; set; } = 4;
|
public int MaxCompletionsPerFrame { get; set; } = 4;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #138: the teleport destination landblock id. When non-zero,
|
||||||
|
/// <see cref="DrainAndApply"/> applies this landblock's
|
||||||
|
/// <see cref="LandblockStreamResult.Loaded"/> or
|
||||||
|
/// <see cref="LandblockStreamResult.Promoted"/> completion immediately
|
||||||
|
/// — even if it sits past the <see cref="MaxCompletionsPerFrame"/>
|
||||||
|
/// position in the outbox — so the player can materialise at the
|
||||||
|
/// destination without waiting for every earlier-queued landblock to
|
||||||
|
/// drain first. Non-priority completions drained past it are buffered
|
||||||
|
/// in <see cref="_deferredApply"/> and applied over subsequent frames,
|
||||||
|
/// so no completions are lost and there is no GPU spike.
|
||||||
|
///
|
||||||
|
/// <para>Set by <c>GameWindow</c> when a teleport starts; reset to 0
|
||||||
|
/// once the destination landblock has been applied (or when the
|
||||||
|
/// teleport destination changes).</para>
|
||||||
|
/// </summary>
|
||||||
|
public uint PriorityLandblockId { get; set; }
|
||||||
|
|
||||||
|
// Completions that were drained past a priority item get buffered here
|
||||||
|
// so they still apply over subsequent frames without loss.
|
||||||
|
private readonly List<LandblockStreamResult> _deferredApply = new();
|
||||||
|
|
||||||
public StreamingController(
|
public StreamingController(
|
||||||
Action<uint, LandblockStreamJobKind> enqueueLoad,
|
Action<uint, LandblockStreamJobKind> enqueueLoad,
|
||||||
Action<uint> enqueueUnload,
|
Action<uint> enqueueUnload,
|
||||||
|
|
@ -299,40 +321,133 @@ public sealed class StreamingController
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Drain up to N completions per frame so a big diff doesn't spike GPU
|
/// Drain up to N completions per frame so a big diff doesn't spike GPU
|
||||||
/// upload time. Remaining completions wait for the next frame.
|
/// upload time. Remaining completions wait for the next frame.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// When <see cref="PriorityLandblockId"/> is set (non-zero), the priority
|
||||||
|
/// landblock is applied immediately even if it sits past position N in the
|
||||||
|
/// outbox. Non-priority completions drained past it are buffered in
|
||||||
|
/// <see cref="_deferredApply"/> and applied over subsequent frames
|
||||||
|
/// (no loss, no GPU spike).
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void DrainAndApply()
|
private void DrainAndApply()
|
||||||
{
|
{
|
||||||
var drained = _drainCompletions(MaxCompletionsPerFrame);
|
int budget = MaxCompletionsPerFrame;
|
||||||
foreach (var result in drained)
|
|
||||||
|
// --- Step 1: drain the deferred buffer first (items held from a prior
|
||||||
|
// priority-hunt that overshot the cap). Apply up to `budget` of them.
|
||||||
|
int deferredApplied = 0;
|
||||||
|
while (deferredApplied < budget && _deferredApply.Count > 0)
|
||||||
{
|
{
|
||||||
switch (result)
|
var item = _deferredApply[0];
|
||||||
|
_deferredApply.RemoveAt(0);
|
||||||
|
ApplyResult(item);
|
||||||
|
deferredApplied++;
|
||||||
|
}
|
||||||
|
budget -= deferredApplied;
|
||||||
|
|
||||||
|
if (budget <= 0) return;
|
||||||
|
|
||||||
|
// --- Step 2: priority hunt (only when a destination is set).
|
||||||
|
if (PriorityLandblockId != 0u)
|
||||||
|
{
|
||||||
|
// Drain in chunks of MaxCompletionsPerFrame (bounded at 64 total
|
||||||
|
// iterations to avoid an unbounded loop if the outbox is huge).
|
||||||
|
const int MaxHuntIterations = 64;
|
||||||
|
int hunted = 0;
|
||||||
|
bool found = false;
|
||||||
|
|
||||||
|
while (hunted < MaxHuntIterations && !found)
|
||||||
{
|
{
|
||||||
case LandblockStreamResult.Loaded loaded:
|
var chunk = _drainCompletions(MaxCompletionsPerFrame);
|
||||||
_applyTerrain(loaded.Landblock, loaded.MeshData);
|
if (chunk.Count == 0) break;
|
||||||
_state.AddLandblock(loaded.Landblock);
|
|
||||||
// #138: after the landblock is in _loaded (so AppendLiveEntity
|
foreach (var result in chunk)
|
||||||
// hot-paths), restore any retained server objects ACE won't
|
{
|
||||||
// re-send. Fired AFTER AddLandblock, never before.
|
hunted++;
|
||||||
_onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);
|
if (ResultLandblockId(result) == PriorityLandblockId)
|
||||||
break;
|
{
|
||||||
case LandblockStreamResult.Promoted promoted:
|
ApplyResult(result);
|
||||||
_applyTerrain(promoted.Landblock, promoted.MeshData);
|
budget--; // priority counts against the budget
|
||||||
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
|
found = true;
|
||||||
_onLandblockLoaded?.Invoke(promoted.LandblockId);
|
// Remaining items in this chunk go to deferred — they
|
||||||
break;
|
// were drained past the cap to find the priority; don't
|
||||||
case LandblockStreamResult.Unloaded unloaded:
|
// drop them.
|
||||||
_state.RemoveLandblock(unloaded.LandblockId);
|
}
|
||||||
_removeTerrain?.Invoke(unloaded.LandblockId);
|
else
|
||||||
break;
|
{
|
||||||
case LandblockStreamResult.Failed failed:
|
_deferredApply.Add(result);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (found)
|
||||||
|
{
|
||||||
|
// Now apply up to remaining budget from deferred (mix from the
|
||||||
|
// chunk we just buffered + any pre-existing deferred items).
|
||||||
|
while (budget > 0 && _deferredApply.Count > 0)
|
||||||
|
{
|
||||||
|
var item = _deferredApply[0];
|
||||||
|
_deferredApply.RemoveAt(0);
|
||||||
|
ApplyResult(item);
|
||||||
|
budget--;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Priority not found in the outbox this frame: fall through to
|
||||||
|
// normal drain (the deferred buffer was already partially applied
|
||||||
|
// above; budget was already decremented).
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 3: normal path — drain up to remaining budget from the
|
||||||
|
// worker's outbox (priority was 0, or not found this frame).
|
||||||
|
var drained = _drainCompletions(budget);
|
||||||
|
foreach (var result in drained)
|
||||||
|
ApplyResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply a single <see cref="LandblockStreamResult"/> with the full side-
|
||||||
|
/// effects: terrain upload, GPU state, and the re-hydration callback.
|
||||||
|
/// Extracted from the inline switch in the original <c>DrainAndApply</c>
|
||||||
|
/// so both the priority-hunt path and the normal drain path share it.
|
||||||
|
/// </summary>
|
||||||
|
private void ApplyResult(LandblockStreamResult result)
|
||||||
|
{
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case LandblockStreamResult.Loaded loaded:
|
||||||
|
_applyTerrain(loaded.Landblock, loaded.MeshData);
|
||||||
|
_state.AddLandblock(loaded.Landblock);
|
||||||
|
// #138: after the landblock is in _loaded (so AppendLiveEntity
|
||||||
|
// hot-paths), restore any retained server objects ACE won't
|
||||||
|
// re-send. Fired AFTER AddLandblock, never before.
|
||||||
|
_onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);
|
||||||
|
break;
|
||||||
|
case LandblockStreamResult.Promoted promoted:
|
||||||
|
_applyTerrain(promoted.Landblock, promoted.MeshData);
|
||||||
|
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
|
||||||
|
_onLandblockLoaded?.Invoke(promoted.LandblockId);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the landblock id associated with <paramref name="result"/>.
|
||||||
|
/// For <see cref="LandblockStreamResult.WorkerCrashed"/> this is 0 by
|
||||||
|
/// convention (not tied to a specific landblock).
|
||||||
|
/// </summary>
|
||||||
|
private static uint ResultLandblockId(LandblockStreamResult result) => result.LandblockId;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.Core.Terrain;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Streaming;
|
||||||
|
|
||||||
|
public class StreamingControllerPriorityApplyTests
|
||||||
|
{
|
||||||
|
private static LandblockStreamResult.Loaded LoadedOf(uint canonicalId)
|
||||||
|
=> new(canonicalId, LandblockStreamTier.Near,
|
||||||
|
new LoadedLandblock(canonicalId, new LandBlock(), Array.Empty<WorldEntity>()),
|
||||||
|
new LandblockMeshData(Array.Empty<TerrainVertex>(), Array.Empty<uint>()));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PriorityLandblock_isApplied_evenWhenBeyondPerFrameCap()
|
||||||
|
{
|
||||||
|
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
|
||||||
|
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
|
||||||
|
{
|
||||||
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 0)),
|
||||||
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 1)),
|
||||||
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 2)),
|
||||||
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 3)),
|
||||||
|
LoadedOf(priority),
|
||||||
|
});
|
||||||
|
|
||||||
|
var applied = new List<uint>();
|
||||||
|
var state = new GpuWorldState();
|
||||||
|
var ctrl = new StreamingController(
|
||||||
|
enqueueLoad: (_, _) => { },
|
||||||
|
enqueueUnload: _ => { },
|
||||||
|
drainCompletions: max =>
|
||||||
|
{
|
||||||
|
var batch = new List<LandblockStreamResult>();
|
||||||
|
while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue());
|
||||||
|
return batch;
|
||||||
|
},
|
||||||
|
applyTerrain: (lb, _) => applied.Add(lb.LandblockId),
|
||||||
|
state: state, nearRadius: 4, farRadius: 12)
|
||||||
|
{ MaxCompletionsPerFrame = 4 };
|
||||||
|
|
||||||
|
ctrl.PriorityLandblockId = priority;
|
||||||
|
ctrl.Tick(169, 180);
|
||||||
|
|
||||||
|
Assert.Contains(priority, applied); // priority applied THIS tick
|
||||||
|
Assert.True(applied.Count <= 5); // did not blindly flush the whole outbox
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deferred_nonPriority_completions_applyOnLaterFrames_withoutLoss()
|
||||||
|
{
|
||||||
|
// After the priority is found, the non-priority items drained past it must
|
||||||
|
// still be applied (over subsequent ticks), never dropped.
|
||||||
|
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
|
||||||
|
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
|
||||||
|
{
|
||||||
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 0)),
|
||||||
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 1)),
|
||||||
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 2)),
|
||||||
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 3)),
|
||||||
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 4)),
|
||||||
|
LoadedOf(priority),
|
||||||
|
});
|
||||||
|
var applied = new List<uint>();
|
||||||
|
var state = new GpuWorldState();
|
||||||
|
var ctrl = new StreamingController(
|
||||||
|
(_, _) => { }, _ => { },
|
||||||
|
max => { var b = new List<LandblockStreamResult>(); while (b.Count < max && outbox.Count > 0) b.Add(outbox.Dequeue()); return b; },
|
||||||
|
(lb, _) => applied.Add(lb.LandblockId),
|
||||||
|
state, 4, 12) { MaxCompletionsPerFrame = 4 };
|
||||||
|
ctrl.PriorityLandblockId = priority;
|
||||||
|
|
||||||
|
ctrl.Tick(169, 180); // priority + some others
|
||||||
|
ctrl.PriorityLandblockId = 0u;
|
||||||
|
ctrl.Tick(169, 180); // drains the deferred remainder
|
||||||
|
ctrl.Tick(169, 180);
|
||||||
|
|
||||||
|
Assert.Contains(priority, applied);
|
||||||
|
Assert.Equal(6, applied.Count); // all six applied, none lost
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue