diff --git a/docs/plans/2026-07-25-modern-runtime-slice-h.md b/docs/plans/2026-07-25-modern-runtime-slice-h.md
index 2b6673b2..570556c7 100644
--- a/docs/plans/2026-07-25-modern-runtime-slice-h.md
+++ b/docs/plans/2026-07-25-modern-runtime-slice-h.md
@@ -80,21 +80,26 @@ subtree-withdrawal suites preserve exact event and resource counts.
Landed evidence:
[`../research/2026-07-25-slice-h-a3-attached-pose-reconcile.md`](../research/2026-07-25-slice-h-a3-attached-pose-reconcile.md).
-### H-a4 — diagnostics and frame scratch
+### H-a4 — diagnostics and frame scratch — COMPLETE
1. Reuse one synchronous `RetailPViewFrameInput` owned by
`WorldSceneRenderer`; reset every field before each draw.
2. Expose stable borrowed landblock entry/bounds views instead of creating
iterator state in each frame.
-3. Consumer-gate diagnostic landblock visibility counting and shadow
- enumeration. When no DebugVM/collision/probe consumer exists, publish a
- neutral diagnostic outcome without scanning resident landblocks.
+3. Consumer-gate optional shadow enumeration. Preserve landblock visibility
+ counting because the normal title and lifecycle checkpoints are active
+ consumers of `lb V/T`; neutralizing it would be a visible/automation
+ behavior change.
4. Do not rework `InteriorEntityPartition` while the G4 referee remains.
Issue #241 closes with G5 old-route deletion, not by editing the fallback.
5. Profile capped and uncapped production paths and retain evidence.
Gate: identical framebuffer/scene digest, no per-frame PView input allocation,
-and no diagnostic world traversal with all consumers disabled.
+no iterator allocation for stable landblock views, and no shadow traversal
+with all optional consumers disabled.
+
+Landed evidence:
+[`../research/2026-07-25-slice-h-a4-frame-scratch.md`](../research/2026-07-25-slice-h-a4-frame-scratch.md).
## 3. H-b — exact light top-k
diff --git a/docs/research/2026-07-25-slice-h-a4-frame-scratch.md b/docs/research/2026-07-25-slice-h-a4-frame-scratch.md
new file mode 100644
index 00000000..f3971a35
--- /dev/null
+++ b/docs/research/2026-07-25-slice-h-a4-frame-scratch.md
@@ -0,0 +1,69 @@
+# Slice H-a4 — retained frame scratch and diagnostic traversal
+
+## Scope
+
+This is a behavior-preserving ownership/allocation correction. It does not
+change retail visibility, PView construction, spatial ordering, collision
+debug drawing, title facts, or lifecycle checkpoint facts.
+
+## Findings
+
+Three independent steady-frame allocation sources remained:
+
+1. `WorldSceneRenderer` constructed one `RetailPViewFrameInput` reference
+ object for every PView frame even though `DrawInside` consumes it
+ synchronously.
+2. `GpuWorldState.LandblockEntries` and `LandblockBounds` were C# iterator
+ methods, so merely obtaining each sequence constructed fresh iterator
+ state. The underlying spatial membership changed only at update-thread
+ publication boundaries.
+3. `WorldSceneDiagnosticsController` passed
+ `ShadowObjectRegistry.AllEntriesForDebug()` to the DebugVM publisher even
+ when no developer UI consumer existed. The publisher correctly avoided
+ enumeration, but the yield sequence itself had already been constructed.
+
+The execution plan initially described landblock visibility counting as an
+unconsumed diagnostic. That was incorrect in the current application:
+`WorldRenderFrameOutcome` feeds the always-visible FPS/title `lb V/T` values
+and connected lifecycle snapshots. Returning a neutral count would therefore
+change observable behavior and invalidate lifecycle evidence. The count is
+retained; only its iterator allocation is removed.
+
+## Implementation
+
+- `WorldSceneRenderer` owns one `RetailPViewFrameInput` and resets every field
+ before the synchronous `DrawInside` call. The input is publicly read-only
+ and internally mutable so downstream render owners cannot change borrowed
+ frame facts.
+- `GpuWorldState` exposes retained entry, no-animated-index entry, and bounds
+ lists. They rebuild only after landblock membership, resident entity-list,
+ animated-index, or AABB changes. Existing render-traversal slot order is
+ preserved exactly.
+- Landblock diagnostic counting uses indexed access over the retained bounds
+ view.
+- Shadow enumeration is constructed only when the DebugVM is mounted. The
+ independent collision-wireframe switch keeps its existing enumeration.
+- `InteriorEntityPartition` remains untouched while the G4 referee is still
+ retained. Its removal and issue #241 close remain G5 work after the user
+ accepts the G4 visual gate.
+
+## Conformance
+
+- Stable retained render-view getters allocate zero bytes over 1,000 polls.
+- A spatial mutation refreshes the same borrowed list identity and publishes
+ the new count.
+- Two successive PView frames receive the same input object, reset with the
+ current frame values.
+- Existing quiescence, render traversal, animated-index, PView executor,
+ abort/recovery, and DebugVM tests remain unchanged in behavior.
+
+## Rollback boundary
+
+This work is independent of the G4 production-source cutover. If tomorrow's
+visual gate rejects G4, use:
+
+```text
+git revert ef1d263337997bb030eadb7b8e71d73dc659907a
+```
+
+Do not revert this Slice H-a4 commit, G3, or the portal-warmup corrections.
diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
index 2e9904be..c9997e72 100644
--- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs
+++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
@@ -1321,41 +1321,100 @@ internal sealed class BuildingGroupScratch
public sealed class RetailPViewFrameInput
{
- public required LoadedCell RootCell { get; init; }
+ public LoadedCell RootCell { get; private set; } = null!;
/// R-A2: nearby building cells (BuildingId-tagged) flooded per-building when the root is the
/// outdoor node. Null for interior roots. Grouped by BuildingId inside .
- public IReadOnlyList? NearbyBuildingCells { get; init; }
+ public IReadOnlyList? NearbyBuildingCells { get; private set; }
- public required Vector3 ViewerEyePos { get; init; }
- public required Matrix4x4 ViewProjection { get; init; }
- public required IRetailPViewCellSource Cells { get; init; }
- public required ICamera Camera { get; init; }
- public required Vector3 CameraWorldPosition { get; init; }
- public required FrustumPlanes? Frustum { get; init; }
- public required uint? PlayerLandblockId { get; init; }
- public required HashSet? AnimatedEntityIds { get; init; }
- public required int RenderCenterLbX { get; init; }
- public required int RenderCenterLbY { get; init; }
- public required int RenderRadius { get; init; }
- public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ public Vector3 ViewerEyePos { get; private set; }
+ public Matrix4x4 ViewProjection { get; private set; }
+ public IRetailPViewCellSource Cells { get; private set; } = null!;
+ public ICamera Camera { get; private set; } = null!;
+ public Vector3 CameraWorldPosition { get; private set; }
+ public FrustumPlanes? Frustum { get; private set; }
+ public uint? PlayerLandblockId { get; private set; }
+ public HashSet? AnimatedEntityIds { get; private set; }
+ public int RenderCenterLbX { get; private set; }
+ public int RenderCenterLbY { get; private set; }
+ public int RenderRadius { get; private set; }
+ public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList Entities,
IReadOnlyDictionary? AnimatedById)> LandblockEntries
- { get; init; }
+ { get; private set; } = Array.Empty<(uint, Vector3, Vector3,
+ IReadOnlyList,
+ IReadOnlyDictionary?)>();
// Pass-presentation and diagnostic values consumed synchronously by the
// typed executor. This input is data-only and is never retained.
- public required bool RenderSky { get; init; }
- public required bool RenderWeather { get; init; }
- public required float DayFraction { get; init; }
- public required DayGroupData? ActiveDayGroup { get; init; }
- public required SkyKeyframe SkyKeyframe { get; init; }
- public required bool EnvironOverrideActive { get; init; }
- public required uint ViewerCellId { get; init; }
- public required uint PlayerCellId { get; init; }
- public required Vector3 PlayerViewPosition { get; init; }
- public required Matrix4x4 CameraView { get; init; }
- public required CameraCellResolution CameraCellResolution { get; init; }
+ public bool RenderSky { get; private set; }
+ public bool RenderWeather { get; private set; }
+ public float DayFraction { get; private set; }
+ public DayGroupData? ActiveDayGroup { get; private set; }
+ public SkyKeyframe SkyKeyframe { get; private set; }
+ public bool EnvironOverrideActive { get; private set; }
+ public uint ViewerCellId { get; private set; }
+ public uint PlayerCellId { get; private set; }
+ public Vector3 PlayerViewPosition { get; private set; }
+ public Matrix4x4 CameraView { get; private set; }
+ public CameraCellResolution CameraCellResolution { get; private set; }
+
+ internal RetailPViewFrameInput Reset(
+ LoadedCell rootCell,
+ IReadOnlyList? nearbyBuildingCells,
+ Vector3 viewerEyePos,
+ Matrix4x4 viewProjection,
+ IRetailPViewCellSource cells,
+ ICamera camera,
+ Vector3 cameraWorldPosition,
+ FrustumPlanes? frustum,
+ uint? playerLandblockId,
+ HashSet? animatedEntityIds,
+ int renderCenterLbX,
+ int renderCenterLbY,
+ int renderRadius,
+ IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ IReadOnlyList Entities,
+ IReadOnlyDictionary? AnimatedById)> landblockEntries,
+ bool renderSky,
+ bool renderWeather,
+ float dayFraction,
+ DayGroupData? activeDayGroup,
+ SkyKeyframe skyKeyframe,
+ bool environOverrideActive,
+ uint viewerCellId,
+ uint playerCellId,
+ Vector3 playerViewPosition,
+ Matrix4x4 cameraView,
+ CameraCellResolution cameraCellResolution)
+ {
+ RootCell = rootCell;
+ NearbyBuildingCells = nearbyBuildingCells;
+ ViewerEyePos = viewerEyePos;
+ ViewProjection = viewProjection;
+ Cells = cells;
+ Camera = camera;
+ CameraWorldPosition = cameraWorldPosition;
+ Frustum = frustum;
+ PlayerLandblockId = playerLandblockId;
+ AnimatedEntityIds = animatedEntityIds;
+ RenderCenterLbX = renderCenterLbX;
+ RenderCenterLbY = renderCenterLbY;
+ RenderRadius = renderRadius;
+ LandblockEntries = landblockEntries;
+ RenderSky = renderSky;
+ RenderWeather = renderWeather;
+ DayFraction = dayFraction;
+ ActiveDayGroup = activeDayGroup;
+ SkyKeyframe = skyKeyframe;
+ EnvironOverrideActive = environOverrideActive;
+ ViewerCellId = viewerCellId;
+ PlayerCellId = playerCellId;
+ PlayerViewPosition = playerViewPosition;
+ CameraView = cameraView;
+ CameraCellResolution = cameraCellResolution;
+ return this;
+ }
}
///
diff --git a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs
index a2e23b92..04d245cb 100644
--- a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs
+++ b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs
@@ -44,7 +44,7 @@ internal interface IWorldSceneDiagnostics
WorldSceneDiagnosticOutcome DrawAndPublish(
in WorldCameraFrame camera,
- IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds);
+ IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds);
}
///
@@ -168,16 +168,16 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
public WorldSceneDiagnosticOutcome DrawAndPublish(
in WorldCameraFrame camera,
- IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
+ IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
{
ArgumentNullException.ThrowIfNull(bounds);
DrawCollisionWireframes(in camera);
int visible = 0;
- int total = 0;
- foreach (var entry in bounds)
+ int total = bounds.Count;
+ for (int index = 0; index < bounds.Count; index++)
{
- total++;
+ var entry = bounds[index];
if (FrustumCuller.IsAabbVisible(
camera.Frustum,
entry.AabbMin,
@@ -187,16 +187,21 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
}
}
- Vector3 nearestOrigin =
- _mode.IsPlayerMode && _player.Controller is { } controller
- ? controller.Position
- : camera.Position;
- _debugVm.PublishDebugVmFacts(
- _debugVmConsumerActive,
- visible,
- total,
- nearestOrigin,
- _physics.ShadowObjects.AllEntriesForDebug());
+ // AllEntriesForDebug is a yield sequence. Do not even construct its
+ // iterator unless the developer UI is mounted and consumes the facts.
+ if (_debugVmConsumerActive)
+ {
+ Vector3 nearestOrigin =
+ _mode.IsPlayerMode && _player.Controller is { } controller
+ ? controller.Position
+ : camera.Position;
+ _debugVm.PublishDebugVmFacts(
+ consumerActive: true,
+ visible,
+ total,
+ nearestOrigin,
+ _physics.ShadowObjects.AllEntriesForDebug());
+ }
return new WorldSceneDiagnosticOutcome(visible, total);
}
diff --git a/src/AcDream.App/Rendering/WorldSceneRenderer.cs b/src/AcDream.App/Rendering/WorldSceneRenderer.cs
index cd901d23..39a38d34 100644
--- a/src/AcDream.App/Rendering/WorldSceneRenderer.cs
+++ b/src/AcDream.App/Rendering/WorldSceneRenderer.cs
@@ -61,6 +61,7 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
private readonly IWorldRenderRangeSource _renderRange;
private readonly IWorldSceneDiagnostics _diagnostics;
private readonly IWorldGenerationAvailability _availability;
+ private readonly RetailPViewFrameInput _pviewFrameInput = new();
public WorldSceneRenderer(
IRenderFrameFoundationSource foundation,
@@ -174,34 +175,32 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
{
pviewFrameStarted = true;
pviewResult = _pview.DrawInside(
- new RetailPViewFrameInput
- {
- RootCell = clipRoot,
- NearbyBuildingCells = world.Buildings.NearbyBuildingCells,
- ViewerEyePos = roots.ViewerEyePosition,
- ViewProjection = camera.ViewProjection,
- Cells = _pviewCells,
- Camera = camera.Camera,
- CameraWorldPosition = camera.Position,
- Frustum = camera.Frustum,
- PlayerLandblockId = roots.PlayerLandblockId,
- AnimatedEntityIds = world.AnimatedEntityIds,
- RenderCenterLbX = roots.RenderCenterLandblockX,
- RenderCenterLbY = roots.RenderCenterLandblockY,
- RenderRadius = _renderRange.NearRadius,
- LandblockEntries = _entities.LandblockEntries,
- RenderSky = renderSky,
- RenderWeather = roots.PlayerSeenOutside,
- DayFraction = _sky.DayFraction,
- ActiveDayGroup = _sky.ActiveDayGroup,
- SkyKeyframe = foundation.Sky,
- EnvironOverrideActive = foundation.EnvironOverrideActive,
- ViewerCellId = roots.ViewerCellId,
- PlayerCellId = roots.PlayerCellId,
- PlayerViewPosition = roots.PlayerViewPosition,
- CameraView = camera.Camera.View,
- CameraCellResolution = _diagnostics.CameraCellResolution,
- });
+ _pviewFrameInput.Reset(
+ clipRoot,
+ world.Buildings.NearbyBuildingCells,
+ roots.ViewerEyePosition,
+ camera.ViewProjection,
+ _pviewCells,
+ camera.Camera,
+ camera.Position,
+ camera.Frustum,
+ roots.PlayerLandblockId,
+ world.AnimatedEntityIds,
+ roots.RenderCenterLandblockX,
+ roots.RenderCenterLandblockY,
+ _renderRange.NearRadius,
+ _entities.LandblockEntries,
+ renderSky,
+ roots.PlayerSeenOutside,
+ _sky.DayFraction,
+ _sky.ActiveDayGroup,
+ foundation.Sky,
+ foundation.EnvironOverrideActive,
+ roots.ViewerCellId,
+ roots.PlayerCellId,
+ roots.PlayerViewPosition,
+ camera.Camera.View,
+ _diagnostics.CameraCellResolution));
_particleVisibility.MarkVisibleCells(pviewResult.DrawableCells);
_frames.ObserveDrawableCells(pviewResult.DrawableCells);
diff --git a/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs b/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs
index 9a0ca9a8..5a8fa341 100644
--- a/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs
+++ b/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs
@@ -15,12 +15,12 @@ internal interface IWorldSceneSkyStateSource
internal interface IWorldSceneEntitySource
{
- IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList Entities,
IReadOnlyDictionary? AnimatedById)> LandblockEntries
{ get; }
- IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds { get; }
+ IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds { get; }
}
internal sealed class RuntimeWorldSceneEntitySource : IWorldSceneEntitySource
@@ -32,12 +32,12 @@ internal sealed class RuntimeWorldSceneEntitySource : IWorldSceneEntitySource
_world = world ?? throw new ArgumentNullException(nameof(world));
}
- public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList Entities,
IReadOnlyDictionary? AnimatedById)> LandblockEntries =>
_world.LandblockEntries;
- public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds =>
+ public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds =>
_world.LandblockBounds;
}
diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs
index b5ddb6d3..f88f7bf8 100644
--- a/src/AcDream.App/Streaming/GpuWorldState.cs
+++ b/src/AcDream.App/Streaming/GpuWorldState.cs
@@ -97,6 +97,21 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
private readonly Dictionary _tierByLandblock = new();
private readonly Dictionary _aabbs = new();
private readonly Dictionary _animatedIndexByLandblock = new();
+ // H-a4: synchronous render borrows. The former yield properties created
+ // fresh iterator state at every per-frame call site.
+ private readonly List<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ IReadOnlyList Entities,
+ IReadOnlyDictionary? AnimatedById)>
+ _landblockEntriesView = [];
+ private readonly List<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ IReadOnlyList Entities,
+ IReadOnlyDictionary? AnimatedById)>
+ _landblockEntriesWithoutAnimatedIndexView = [];
+ private readonly List<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)>
+ _landblockBoundsView = [];
+ private bool _landblockEntriesViewDirty = true;
+ private bool _landblockEntriesWithoutAnimatedIndexViewDirty = true;
+ private bool _landblockBoundsViewDirty = true;
private sealed record AnimatedEntityIndex(
LoadedLandblock Source,
@@ -201,6 +216,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
public void SetLandblockAabb(uint landblockId, Vector3 min, Vector3 max)
{
_aabbs[landblockId] = (min, max);
+ InvalidateLandblockRenderViews(entries: true, bounds: true);
}
///
@@ -219,49 +235,77 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
/// explicitly invalidate exactly the affected index generation.
///
///
- public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
- IReadOnlyList Entities,
- IReadOnlyDictionary? AnimatedById)> LandblockEntries
- => EnumerateLandblockEntries(includeAnimatedIndex: true);
+ public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ IReadOnlyList Entities,
+ IReadOnlyDictionary? AnimatedById)> LandblockEntries
+ => GetLandblockEntriesView(includeAnimatedIndex: true);
///
/// Per-landblock render entries without the animated lookup dictionary.
/// Static render passes use this to avoid rebuilding an index they cannot
/// consume.
///
- public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
- IReadOnlyList Entities,
- IReadOnlyDictionary? AnimatedById)> LandblockEntriesWithoutAnimatedIndex
- => EnumerateLandblockEntries(includeAnimatedIndex: false);
+ public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ IReadOnlyList Entities,
+ IReadOnlyDictionary? AnimatedById)> LandblockEntriesWithoutAnimatedIndex
+ => GetLandblockEntriesView(includeAnimatedIndex: false);
///
/// Lightweight bounds-only enumeration for overlays and diagnostics.
/// Does not walk entity lists.
///
- public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds
+ public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds
{
get
{
if (!_availability.IsWorldAvailable)
- yield break;
+ return Array.Empty<(uint, Vector3, Vector3)>();
+ if (!_landblockBoundsViewDirty)
+ return _landblockBoundsView;
- foreach (var kvp in _loaded)
+ _landblockBoundsView.Clear();
+ for (int slot = 0; slot < _renderTraversalLandblockSlots.Count; slot++)
{
- if (_aabbs.TryGetValue(kvp.Key, out var aabb))
- yield return (kvp.Key, aabb.Min, aabb.Max);
+ uint landblockId = _renderTraversalLandblockSlots[slot];
+ if (landblockId == 0)
+ continue;
+ if (_aabbs.TryGetValue(landblockId, out var aabb))
+ _landblockBoundsView.Add((landblockId, aabb.Min, aabb.Max));
else
- yield return (kvp.Key, Vector3.Zero, Vector3.Zero);
+ _landblockBoundsView.Add(
+ (landblockId, Vector3.Zero, Vector3.Zero));
}
+
+ _landblockBoundsViewDirty = false;
+ return _landblockBoundsView;
}
}
- private IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
- IReadOnlyList Entities,
- IReadOnlyDictionary? AnimatedById)> EnumerateLandblockEntries(
+ private IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ IReadOnlyList Entities,
+ IReadOnlyDictionary? AnimatedById)> GetLandblockEntriesView(
bool includeAnimatedIndex)
{
if (!_availability.IsWorldAvailable)
- yield break;
+ {
+ return Array.Empty<(uint, Vector3, Vector3,
+ IReadOnlyList,
+ IReadOnlyDictionary?)>();
+ }
+
+ List<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ IReadOnlyList Entities,
+ IReadOnlyDictionary? AnimatedById)> view =
+ includeAnimatedIndex
+ ? _landblockEntriesView
+ : _landblockEntriesWithoutAnimatedIndexView;
+ bool dirty = includeAnimatedIndex
+ ? _landblockEntriesViewDirty
+ : _landblockEntriesWithoutAnimatedIndexViewDirty;
+ if (!dirty)
+ return view;
+
+ view.Clear();
for (int slot = 0; slot < _renderTraversalLandblockSlots.Count; slot++)
{
@@ -287,10 +331,29 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
}
if (_aabbs.TryGetValue(landblockId, out var aabb))
- yield return (landblockId, aabb.Min, aabb.Max, landblock.Entities, byId);
+ view.Add(
+ (landblockId, aabb.Min, aabb.Max, landblock.Entities, byId));
else
- yield return (landblockId, Vector3.Zero, Vector3.Zero, landblock.Entities, byId);
+ view.Add(
+ (landblockId, Vector3.Zero, Vector3.Zero, landblock.Entities, byId));
}
+
+ if (includeAnimatedIndex)
+ _landblockEntriesViewDirty = false;
+ else
+ _landblockEntriesWithoutAnimatedIndexViewDirty = false;
+ return view;
+ }
+
+ private void InvalidateLandblockRenderViews(bool entries, bool bounds)
+ {
+ if (entries)
+ {
+ _landblockEntriesViewDirty = true;
+ _landblockEntriesWithoutAnimatedIndexViewDirty = true;
+ }
+ if (bounds)
+ _landblockBoundsViewDirty = true;
}
internal bool TryGetRenderTraversalSortKey(
@@ -361,6 +424,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
throw new InvalidOperationException(
$"Landblock 0x{landblockId:X8} already owns a render traversal slot.");
}
+ InvalidateLandblockRenderViews(entries: true, bounds: true);
}
private bool RemoveLoadedLandblock(
@@ -381,6 +445,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_renderTraversalLandblockSlots[slot] = 0;
_freeRenderTraversalLandblockSlots.Push(slot);
+ InvalidateLandblockRenderViews(entries: true, bounds: true);
return true;
}
@@ -726,6 +791,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
int bucketIndex = entities.Count;
entities.Add(entity);
_animatedIndexByLandblock.Remove(landblockId);
+ InvalidateLandblockRenderViews(entries: true, bounds: false);
AddFlatEntity(entity);
SetProjectionLocation(entity, landblockId, isLoaded: true, bucketIndex);
AdjustVisibleLiveProjection(entity, +1);
@@ -765,6 +831,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
entities.RemoveAt(lastIndex);
_animatedIndexByLandblock.Remove(location.LandblockId);
+ InvalidateLandblockRenderViews(entries: true, bounds: false);
RemoveFlatEntity(entity);
RemoveProjectionLocation(entity);
AdjustVisibleLiveProjection(entity, -1);
@@ -1463,6 +1530,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
}
_loaded[canonical] = NormalizeLoadedLandblock(lb, retainedLive);
_animatedIndexByLandblock.Remove(canonical);
+ InvalidateLandblockRenderViews(entries: true, bounds: false);
_tierByLandblock[canonical] = LandblockStreamTier.Far;
}
@@ -1595,6 +1663,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
AdjustVisibleLiveProjection(entity, +1);
}
_animatedIndexByLandblock.Remove(canonical);
+ InvalidateLandblockRenderViews(entries: true, bounds: false);
_tierByLandblock[canonical] = LandblockStreamTier.Near;
ulong[] effectiveRenderIds = additionalRenderIds?.ToArray() ?? Array.Empty();
WorldEntity[] staticEntities = entities
diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
index cc5fc6af..cf313e09 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
@@ -418,34 +418,32 @@ public sealed class RetailPViewPassExecutorTests
null));
}
- return new RetailPViewFrameInput
- {
- RootCell = root,
- NearbyBuildingCells = nearbyBuildingCells,
- ViewerEyePos = Vector3.Zero,
- ViewProjection = TestCamera.ViewProjection,
- Cells = new DictionaryCellSource(cells),
- Camera = new TestCamera(),
- CameraWorldPosition = Vector3.Zero,
- Frustum = null,
- PlayerLandblockId = root.CellId & 0xFFFF0000u,
- AnimatedEntityIds = null,
- RenderCenterLbX = 0,
- RenderCenterLbY = 0,
- RenderRadius = 1,
- LandblockEntries = entries,
- RenderSky = true,
- RenderWeather = true,
- DayFraction = 0f,
- ActiveDayGroup = null,
- SkyKeyframe = default,
- EnvironOverrideActive = false,
- ViewerCellId = root.CellId,
- PlayerCellId = root.CellId,
- PlayerViewPosition = Vector3.Zero,
- CameraView = TestCamera.ViewMatrix,
- CameraCellResolution = CameraCellResolution.None,
- };
+ return new RetailPViewFrameInput().Reset(
+ root,
+ nearbyBuildingCells,
+ Vector3.Zero,
+ TestCamera.ViewProjection,
+ new DictionaryCellSource(cells),
+ new TestCamera(),
+ Vector3.Zero,
+ frustum: null,
+ playerLandblockId: root.CellId & 0xFFFF0000u,
+ animatedEntityIds: null,
+ renderCenterLbX: 0,
+ renderCenterLbY: 0,
+ renderRadius: 1,
+ landblockEntries: entries,
+ renderSky: true,
+ renderWeather: true,
+ dayFraction: 0f,
+ activeDayGroup: null,
+ skyKeyframe: default,
+ environOverrideActive: false,
+ viewerCellId: root.CellId,
+ playerCellId: root.CellId,
+ playerViewPosition: Vector3.Zero,
+ cameraView: TestCamera.ViewMatrix,
+ cameraCellResolution: CameraCellResolution.None);
}
private static LoadedCell InteriorWithExit(uint cellId)
diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
index 1f2940b9..07e96b10 100644
--- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
@@ -153,6 +153,32 @@ public sealed class WorldSceneRendererTests
Assert.Equal(4, rig.PView.LastInput.RenderRadius);
}
+ [Fact]
+ public void PViewWorld_ReusesOneSynchronousFrameInputAcrossFrames()
+ {
+ var root = new LoadedCell
+ {
+ CellId = 0x01010001u,
+ IsOutdoorNode = false,
+ };
+ var rig = new Rig(
+ portalVisible: false,
+ waitingForLogin: false,
+ clipRoot: root);
+
+ rig.Renderer.Render(default);
+ RetailPViewFrameInput first = Assert.IsType(
+ rig.PView.LastInput);
+ rig.Calls.Clear();
+
+ rig.Renderer.Render(default);
+
+ Assert.Same(first, rig.PView.LastInput);
+ Assert.Same(root, rig.PView.LastInput!.RootCell);
+ Assert.Equal(rig.DayFraction, rig.PView.LastInput.DayFraction);
+ Assert.Contains("pview:draw", rig.Calls);
+ }
+
[Fact]
public void OutdoorPView_UsesPViewOwnersForPostWorldParticlesWithoutFlatWeather()
{
@@ -474,13 +500,13 @@ public sealed class WorldSceneRendererTests
private sealed class EntitySource : IWorldSceneEntitySource
{
- public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList Entities,
IReadOnlyDictionary? AnimatedById)> LandblockEntries =>
Array.Empty<(uint, Vector3, Vector3, IReadOnlyList,
IReadOnlyDictionary?)>();
- public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds =>
+ public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds =>
Array.Empty<(uint, Vector3, Vector3)>();
}
@@ -697,7 +723,7 @@ public sealed class WorldSceneRendererTests
public WorldSceneDiagnosticOutcome DrawAndPublish(
in WorldCameraFrame camera,
- IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
+ IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
{
calls.Add("diagnostics:draw");
return new WorldSceneDiagnosticOutcome(3, 8);
diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateRenderTraversalTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateRenderTraversalTests.cs
index 24aec76d..a1bf84e1 100644
--- a/tests/AcDream.App.Tests/Streaming/GpuWorldStateRenderTraversalTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateRenderTraversalTests.cs
@@ -68,6 +68,44 @@ public sealed class GpuWorldStateRenderTraversalTests
Assert.False(state.TryGetRenderTraversalSortKey(pending, out _));
}
+ [Fact]
+ public void StableRenderViews_AllocateOnlyWhenSpatialContentChanges()
+ {
+ var state = new GpuWorldState();
+ state.AddLandblock(Landblock(
+ FirstLandblock,
+ Entity(1, 0x80000001u)));
+ state.SetLandblockAabb(
+ FirstLandblock,
+ new Vector3(1f, 2f, 3f),
+ new Vector3(4f, 5f, 6f));
+
+ var entries = state.LandblockEntries;
+ var bounds = state.LandblockBounds;
+ _ = entries.Count;
+ _ = bounds.Count;
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ for (int iteration = 0; iteration < 1_000; iteration++)
+ {
+ _ = state.LandblockEntries.Count;
+ _ = state.LandblockBounds.Count;
+ }
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.Equal(0, allocated);
+ Assert.Same(entries, state.LandblockEntries);
+ Assert.Same(bounds, state.LandblockBounds);
+
+ state.AddLandblock(Landblock(
+ SecondLandblock,
+ Entity(2, 0x80000002u)));
+
+ Assert.Same(entries, state.LandblockEntries);
+ Assert.Same(bounds, state.LandblockBounds);
+ Assert.Equal(2, entries.Count);
+ Assert.Equal(2, bounds.Count);
+ }
+
private static void AssertSort(
GpuWorldState state,
WorldEntity entity,