perf(rendering): retain synchronous frame scratch

Reuse the PView frame input, publish mutation-invalidated landblock views, and avoid constructing optional shadow iterators while preserving title and lifecycle visibility facts.
This commit is contained in:
Erik 2026-07-25 05:28:30 +02:00
parent b9cbf5e040
commit b3427554c3
10 changed files with 397 additions and 129 deletions

View file

@ -80,21 +80,26 @@ subtree-withdrawal suites preserve exact event and resource counts.
Landed evidence: Landed evidence:
[`../research/2026-07-25-slice-h-a3-attached-pose-reconcile.md`](../research/2026-07-25-slice-h-a3-attached-pose-reconcile.md). [`../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 1. Reuse one synchronous `RetailPViewFrameInput` owned by
`WorldSceneRenderer`; reset every field before each draw. `WorldSceneRenderer`; reset every field before each draw.
2. Expose stable borrowed landblock entry/bounds views instead of creating 2. Expose stable borrowed landblock entry/bounds views instead of creating
iterator state in each frame. iterator state in each frame.
3. Consumer-gate diagnostic landblock visibility counting and shadow 3. Consumer-gate optional shadow enumeration. Preserve landblock visibility
enumeration. When no DebugVM/collision/probe consumer exists, publish a counting because the normal title and lifecycle checkpoints are active
neutral diagnostic outcome without scanning resident landblocks. consumers of `lb V/T`; neutralizing it would be a visible/automation
behavior change.
4. Do not rework `InteriorEntityPartition` while the G4 referee remains. 4. Do not rework `InteriorEntityPartition` while the G4 referee remains.
Issue #241 closes with G5 old-route deletion, not by editing the fallback. Issue #241 closes with G5 old-route deletion, not by editing the fallback.
5. Profile capped and uncapped production paths and retain evidence. 5. Profile capped and uncapped production paths and retain evidence.
Gate: identical framebuffer/scene digest, no per-frame PView input allocation, 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 ## 3. H-b — exact light top-k

View file

@ -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.

View file

@ -1321,41 +1321,100 @@ internal sealed class BuildingGroupScratch
public sealed class RetailPViewFrameInput public sealed class RetailPViewFrameInput
{ {
public required LoadedCell RootCell { get; init; } public LoadedCell RootCell { get; private set; } = null!;
/// <summary>R-A2: nearby building cells (BuildingId-tagged) flooded per-building when the root is the /// <summary>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 <see cref="DrawInside"/>.</summary> /// outdoor node. Null for interior roots. Grouped by BuildingId inside <see cref="DrawInside"/>.</summary>
public IReadOnlyList<LoadedCell>? NearbyBuildingCells { get; init; } public IReadOnlyList<LoadedCell>? NearbyBuildingCells { get; private set; }
public required Vector3 ViewerEyePos { get; init; } public Vector3 ViewerEyePos { get; private set; }
public required Matrix4x4 ViewProjection { get; init; } public Matrix4x4 ViewProjection { get; private set; }
public required IRetailPViewCellSource Cells { get; init; } public IRetailPViewCellSource Cells { get; private set; } = null!;
public required ICamera Camera { get; init; } public ICamera Camera { get; private set; } = null!;
public required Vector3 CameraWorldPosition { get; init; } public Vector3 CameraWorldPosition { get; private set; }
public required FrustumPlanes? Frustum { get; init; } public FrustumPlanes? Frustum { get; private set; }
public required uint? PlayerLandblockId { get; init; } public uint? PlayerLandblockId { get; private set; }
public required HashSet<uint>? AnimatedEntityIds { get; init; } public HashSet<uint>? AnimatedEntityIds { get; private set; }
public required int RenderCenterLbX { get; init; } public int RenderCenterLbX { get; private set; }
public required int RenderCenterLbY { get; init; } public int RenderCenterLbY { get; private set; }
public required int RenderRadius { get; init; } public int RenderRadius { get; private set; }
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities, IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
{ get; init; } { get; private set; } = Array.Empty<(uint, Vector3, Vector3,
IReadOnlyList<WorldEntity>,
IReadOnlyDictionary<uint, WorldEntity>?)>();
// Pass-presentation and diagnostic values consumed synchronously by the // Pass-presentation and diagnostic values consumed synchronously by the
// typed executor. This input is data-only and is never retained. // typed executor. This input is data-only and is never retained.
public required bool RenderSky { get; init; } public bool RenderSky { get; private set; }
public required bool RenderWeather { get; init; } public bool RenderWeather { get; private set; }
public required float DayFraction { get; init; } public float DayFraction { get; private set; }
public required DayGroupData? ActiveDayGroup { get; init; } public DayGroupData? ActiveDayGroup { get; private set; }
public required SkyKeyframe SkyKeyframe { get; init; } public SkyKeyframe SkyKeyframe { get; private set; }
public required bool EnvironOverrideActive { get; init; } public bool EnvironOverrideActive { get; private set; }
public required uint ViewerCellId { get; init; } public uint ViewerCellId { get; private set; }
public required uint PlayerCellId { get; init; } public uint PlayerCellId { get; private set; }
public required Vector3 PlayerViewPosition { get; init; } public Vector3 PlayerViewPosition { get; private set; }
public required Matrix4x4 CameraView { get; init; } public Matrix4x4 CameraView { get; private set; }
public required CameraCellResolution CameraCellResolution { get; init; } public CameraCellResolution CameraCellResolution { get; private set; }
internal RetailPViewFrameInput Reset(
LoadedCell rootCell,
IReadOnlyList<LoadedCell>? nearbyBuildingCells,
Vector3 viewerEyePos,
Matrix4x4 viewProjection,
IRetailPViewCellSource cells,
ICamera camera,
Vector3 cameraWorldPosition,
FrustumPlanes? frustum,
uint? playerLandblockId,
HashSet<uint>? animatedEntityIds,
int renderCenterLbX,
int renderCenterLbY,
int renderRadius,
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? 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;
}
} }
/// <summary> /// <summary>

View file

@ -44,7 +44,7 @@ internal interface IWorldSceneDiagnostics
WorldSceneDiagnosticOutcome DrawAndPublish( WorldSceneDiagnosticOutcome DrawAndPublish(
in WorldCameraFrame camera, in WorldCameraFrame camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds); IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds);
} }
/// <summary> /// <summary>
@ -168,16 +168,16 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
public WorldSceneDiagnosticOutcome DrawAndPublish( public WorldSceneDiagnosticOutcome DrawAndPublish(
in WorldCameraFrame camera, in WorldCameraFrame camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds) IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
{ {
ArgumentNullException.ThrowIfNull(bounds); ArgumentNullException.ThrowIfNull(bounds);
DrawCollisionWireframes(in camera); DrawCollisionWireframes(in camera);
int visible = 0; int visible = 0;
int total = 0; int total = bounds.Count;
foreach (var entry in bounds) for (int index = 0; index < bounds.Count; index++)
{ {
total++; var entry = bounds[index];
if (FrustumCuller.IsAabbVisible( if (FrustumCuller.IsAabbVisible(
camera.Frustum, camera.Frustum,
entry.AabbMin, entry.AabbMin,
@ -187,16 +187,21 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
} }
} }
Vector3 nearestOrigin = // AllEntriesForDebug is a yield sequence. Do not even construct its
_mode.IsPlayerMode && _player.Controller is { } controller // iterator unless the developer UI is mounted and consumes the facts.
? controller.Position if (_debugVmConsumerActive)
: camera.Position; {
_debugVm.PublishDebugVmFacts( Vector3 nearestOrigin =
_debugVmConsumerActive, _mode.IsPlayerMode && _player.Controller is { } controller
visible, ? controller.Position
total, : camera.Position;
nearestOrigin, _debugVm.PublishDebugVmFacts(
_physics.ShadowObjects.AllEntriesForDebug()); consumerActive: true,
visible,
total,
nearestOrigin,
_physics.ShadowObjects.AllEntriesForDebug());
}
return new WorldSceneDiagnosticOutcome(visible, total); return new WorldSceneDiagnosticOutcome(visible, total);
} }

View file

@ -61,6 +61,7 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
private readonly IWorldRenderRangeSource _renderRange; private readonly IWorldRenderRangeSource _renderRange;
private readonly IWorldSceneDiagnostics _diagnostics; private readonly IWorldSceneDiagnostics _diagnostics;
private readonly IWorldGenerationAvailability _availability; private readonly IWorldGenerationAvailability _availability;
private readonly RetailPViewFrameInput _pviewFrameInput = new();
public WorldSceneRenderer( public WorldSceneRenderer(
IRenderFrameFoundationSource foundation, IRenderFrameFoundationSource foundation,
@ -174,34 +175,32 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
{ {
pviewFrameStarted = true; pviewFrameStarted = true;
pviewResult = _pview.DrawInside( pviewResult = _pview.DrawInside(
new RetailPViewFrameInput _pviewFrameInput.Reset(
{ clipRoot,
RootCell = clipRoot, world.Buildings.NearbyBuildingCells,
NearbyBuildingCells = world.Buildings.NearbyBuildingCells, roots.ViewerEyePosition,
ViewerEyePos = roots.ViewerEyePosition, camera.ViewProjection,
ViewProjection = camera.ViewProjection, _pviewCells,
Cells = _pviewCells, camera.Camera,
Camera = camera.Camera, camera.Position,
CameraWorldPosition = camera.Position, camera.Frustum,
Frustum = camera.Frustum, roots.PlayerLandblockId,
PlayerLandblockId = roots.PlayerLandblockId, world.AnimatedEntityIds,
AnimatedEntityIds = world.AnimatedEntityIds, roots.RenderCenterLandblockX,
RenderCenterLbX = roots.RenderCenterLandblockX, roots.RenderCenterLandblockY,
RenderCenterLbY = roots.RenderCenterLandblockY, _renderRange.NearRadius,
RenderRadius = _renderRange.NearRadius, _entities.LandblockEntries,
LandblockEntries = _entities.LandblockEntries, renderSky,
RenderSky = renderSky, roots.PlayerSeenOutside,
RenderWeather = roots.PlayerSeenOutside, _sky.DayFraction,
DayFraction = _sky.DayFraction, _sky.ActiveDayGroup,
ActiveDayGroup = _sky.ActiveDayGroup, foundation.Sky,
SkyKeyframe = foundation.Sky, foundation.EnvironOverrideActive,
EnvironOverrideActive = foundation.EnvironOverrideActive, roots.ViewerCellId,
ViewerCellId = roots.ViewerCellId, roots.PlayerCellId,
PlayerCellId = roots.PlayerCellId, roots.PlayerViewPosition,
PlayerViewPosition = roots.PlayerViewPosition, camera.Camera.View,
CameraView = camera.Camera.View, _diagnostics.CameraCellResolution));
CameraCellResolution = _diagnostics.CameraCellResolution,
});
_particleVisibility.MarkVisibleCells(pviewResult.DrawableCells); _particleVisibility.MarkVisibleCells(pviewResult.DrawableCells);
_frames.ObserveDrawableCells(pviewResult.DrawableCells); _frames.ObserveDrawableCells(pviewResult.DrawableCells);

View file

@ -15,12 +15,12 @@ internal interface IWorldSceneSkyStateSource
internal interface IWorldSceneEntitySource internal interface IWorldSceneEntitySource
{ {
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities, IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
{ get; } { get; }
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds { get; } IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds { get; }
} }
internal sealed class RuntimeWorldSceneEntitySource : IWorldSceneEntitySource internal sealed class RuntimeWorldSceneEntitySource : IWorldSceneEntitySource
@ -32,12 +32,12 @@ internal sealed class RuntimeWorldSceneEntitySource : IWorldSceneEntitySource
_world = world ?? throw new ArgumentNullException(nameof(world)); _world = world ?? throw new ArgumentNullException(nameof(world));
} }
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities, IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries => IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries =>
_world.LandblockEntries; _world.LandblockEntries;
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds => public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds =>
_world.LandblockBounds; _world.LandblockBounds;
} }

View file

@ -97,6 +97,21 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
private readonly Dictionary<uint, LandblockStreamTier> _tierByLandblock = new(); private readonly Dictionary<uint, LandblockStreamTier> _tierByLandblock = new();
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new(); private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
private readonly Dictionary<uint, AnimatedEntityIndex> _animatedIndexByLandblock = new(); private readonly Dictionary<uint, AnimatedEntityIndex> _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<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)>
_landblockEntriesView = [];
private readonly List<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? 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( private sealed record AnimatedEntityIndex(
LoadedLandblock Source, LoadedLandblock Source,
@ -201,6 +216,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
public void SetLandblockAabb(uint landblockId, Vector3 min, Vector3 max) public void SetLandblockAabb(uint landblockId, Vector3 min, Vector3 max)
{ {
_aabbs[landblockId] = (min, max); _aabbs[landblockId] = (min, max);
InvalidateLandblockRenderViews(entries: true, bounds: true);
} }
/// <summary> /// <summary>
@ -219,49 +235,77 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
/// explicitly invalidate exactly the affected index generation. /// explicitly invalidate exactly the affected index generation.
/// </para> /// </para>
/// </summary> /// </summary>
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities, IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
=> EnumerateLandblockEntries(includeAnimatedIndex: true); => GetLandblockEntriesView(includeAnimatedIndex: true);
/// <summary> /// <summary>
/// Per-landblock render entries without the animated lookup dictionary. /// Per-landblock render entries without the animated lookup dictionary.
/// Static render passes use this to avoid rebuilding an index they cannot /// Static render passes use this to avoid rebuilding an index they cannot
/// consume. /// consume.
/// </summary> /// </summary>
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities, IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntriesWithoutAnimatedIndex IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntriesWithoutAnimatedIndex
=> EnumerateLandblockEntries(includeAnimatedIndex: false); => GetLandblockEntriesView(includeAnimatedIndex: false);
/// <summary> /// <summary>
/// Lightweight bounds-only enumeration for overlays and diagnostics. /// Lightweight bounds-only enumeration for overlays and diagnostics.
/// Does not walk entity lists. /// Does not walk entity lists.
/// </summary> /// </summary>
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds
{ {
get get
{ {
if (!_availability.IsWorldAvailable) 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)) uint landblockId = _renderTraversalLandblockSlots[slot];
yield return (kvp.Key, aabb.Min, aabb.Max); if (landblockId == 0)
continue;
if (_aabbs.TryGetValue(landblockId, out var aabb))
_landblockBoundsView.Add((landblockId, aabb.Min, aabb.Max));
else 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, private IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities, IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> EnumerateLandblockEntries( IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> GetLandblockEntriesView(
bool includeAnimatedIndex) bool includeAnimatedIndex)
{ {
if (!_availability.IsWorldAvailable) if (!_availability.IsWorldAvailable)
yield break; {
return Array.Empty<(uint, Vector3, Vector3,
IReadOnlyList<WorldEntity>,
IReadOnlyDictionary<uint, WorldEntity>?)>();
}
List<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> view =
includeAnimatedIndex
? _landblockEntriesView
: _landblockEntriesWithoutAnimatedIndexView;
bool dirty = includeAnimatedIndex
? _landblockEntriesViewDirty
: _landblockEntriesWithoutAnimatedIndexViewDirty;
if (!dirty)
return view;
view.Clear();
for (int slot = 0; slot < _renderTraversalLandblockSlots.Count; slot++) for (int slot = 0; slot < _renderTraversalLandblockSlots.Count; slot++)
{ {
@ -287,10 +331,29 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
} }
if (_aabbs.TryGetValue(landblockId, out var aabb)) 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 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( internal bool TryGetRenderTraversalSortKey(
@ -361,6 +424,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
throw new InvalidOperationException( throw new InvalidOperationException(
$"Landblock 0x{landblockId:X8} already owns a render traversal slot."); $"Landblock 0x{landblockId:X8} already owns a render traversal slot.");
} }
InvalidateLandblockRenderViews(entries: true, bounds: true);
} }
private bool RemoveLoadedLandblock( private bool RemoveLoadedLandblock(
@ -381,6 +445,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_renderTraversalLandblockSlots[slot] = 0; _renderTraversalLandblockSlots[slot] = 0;
_freeRenderTraversalLandblockSlots.Push(slot); _freeRenderTraversalLandblockSlots.Push(slot);
InvalidateLandblockRenderViews(entries: true, bounds: true);
return true; return true;
} }
@ -726,6 +791,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
int bucketIndex = entities.Count; int bucketIndex = entities.Count;
entities.Add(entity); entities.Add(entity);
_animatedIndexByLandblock.Remove(landblockId); _animatedIndexByLandblock.Remove(landblockId);
InvalidateLandblockRenderViews(entries: true, bounds: false);
AddFlatEntity(entity); AddFlatEntity(entity);
SetProjectionLocation(entity, landblockId, isLoaded: true, bucketIndex); SetProjectionLocation(entity, landblockId, isLoaded: true, bucketIndex);
AdjustVisibleLiveProjection(entity, +1); AdjustVisibleLiveProjection(entity, +1);
@ -765,6 +831,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
entities.RemoveAt(lastIndex); entities.RemoveAt(lastIndex);
_animatedIndexByLandblock.Remove(location.LandblockId); _animatedIndexByLandblock.Remove(location.LandblockId);
InvalidateLandblockRenderViews(entries: true, bounds: false);
RemoveFlatEntity(entity); RemoveFlatEntity(entity);
RemoveProjectionLocation(entity); RemoveProjectionLocation(entity);
AdjustVisibleLiveProjection(entity, -1); AdjustVisibleLiveProjection(entity, -1);
@ -1463,6 +1530,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
} }
_loaded[canonical] = NormalizeLoadedLandblock(lb, retainedLive); _loaded[canonical] = NormalizeLoadedLandblock(lb, retainedLive);
_animatedIndexByLandblock.Remove(canonical); _animatedIndexByLandblock.Remove(canonical);
InvalidateLandblockRenderViews(entries: true, bounds: false);
_tierByLandblock[canonical] = LandblockStreamTier.Far; _tierByLandblock[canonical] = LandblockStreamTier.Far;
} }
@ -1595,6 +1663,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
AdjustVisibleLiveProjection(entity, +1); AdjustVisibleLiveProjection(entity, +1);
} }
_animatedIndexByLandblock.Remove(canonical); _animatedIndexByLandblock.Remove(canonical);
InvalidateLandblockRenderViews(entries: true, bounds: false);
_tierByLandblock[canonical] = LandblockStreamTier.Near; _tierByLandblock[canonical] = LandblockStreamTier.Near;
ulong[] effectiveRenderIds = additionalRenderIds?.ToArray() ?? Array.Empty<ulong>(); ulong[] effectiveRenderIds = additionalRenderIds?.ToArray() ?? Array.Empty<ulong>();
WorldEntity[] staticEntities = entities WorldEntity[] staticEntities = entities

View file

@ -418,34 +418,32 @@ public sealed class RetailPViewPassExecutorTests
null)); null));
} }
return new RetailPViewFrameInput return new RetailPViewFrameInput().Reset(
{ root,
RootCell = root, nearbyBuildingCells,
NearbyBuildingCells = nearbyBuildingCells, Vector3.Zero,
ViewerEyePos = Vector3.Zero, TestCamera.ViewProjection,
ViewProjection = TestCamera.ViewProjection, new DictionaryCellSource(cells),
Cells = new DictionaryCellSource(cells), new TestCamera(),
Camera = new TestCamera(), Vector3.Zero,
CameraWorldPosition = Vector3.Zero, frustum: null,
Frustum = null, playerLandblockId: root.CellId & 0xFFFF0000u,
PlayerLandblockId = root.CellId & 0xFFFF0000u, animatedEntityIds: null,
AnimatedEntityIds = null, renderCenterLbX: 0,
RenderCenterLbX = 0, renderCenterLbY: 0,
RenderCenterLbY = 0, renderRadius: 1,
RenderRadius = 1, landblockEntries: entries,
LandblockEntries = entries, renderSky: true,
RenderSky = true, renderWeather: true,
RenderWeather = true, dayFraction: 0f,
DayFraction = 0f, activeDayGroup: null,
ActiveDayGroup = null, skyKeyframe: default,
SkyKeyframe = default, environOverrideActive: false,
EnvironOverrideActive = false, viewerCellId: root.CellId,
ViewerCellId = root.CellId, playerCellId: root.CellId,
PlayerCellId = root.CellId, playerViewPosition: Vector3.Zero,
PlayerViewPosition = Vector3.Zero, cameraView: TestCamera.ViewMatrix,
CameraView = TestCamera.ViewMatrix, cameraCellResolution: CameraCellResolution.None);
CameraCellResolution = CameraCellResolution.None,
};
} }
private static LoadedCell InteriorWithExit(uint cellId) private static LoadedCell InteriorWithExit(uint cellId)

View file

@ -153,6 +153,32 @@ public sealed class WorldSceneRendererTests
Assert.Equal(4, rig.PView.LastInput.RenderRadius); 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<RetailPViewFrameInput>(
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] [Fact]
public void OutdoorPView_UsesPViewOwnersForPostWorldParticlesWithoutFlatWeather() public void OutdoorPView_UsesPViewOwnersForPostWorldParticlesWithoutFlatWeather()
{ {
@ -474,13 +500,13 @@ public sealed class WorldSceneRendererTests
private sealed class EntitySource : IWorldSceneEntitySource private sealed class EntitySource : IWorldSceneEntitySource
{ {
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities, IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries => IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries =>
Array.Empty<(uint, Vector3, Vector3, IReadOnlyList<WorldEntity>, Array.Empty<(uint, Vector3, Vector3, IReadOnlyList<WorldEntity>,
IReadOnlyDictionary<uint, WorldEntity>?)>(); IReadOnlyDictionary<uint, WorldEntity>?)>();
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds => public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds =>
Array.Empty<(uint, Vector3, Vector3)>(); Array.Empty<(uint, Vector3, Vector3)>();
} }
@ -697,7 +723,7 @@ public sealed class WorldSceneRendererTests
public WorldSceneDiagnosticOutcome DrawAndPublish( public WorldSceneDiagnosticOutcome DrawAndPublish(
in WorldCameraFrame camera, in WorldCameraFrame camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds) IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
{ {
calls.Add("diagnostics:draw"); calls.Add("diagnostics:draw");
return new WorldSceneDiagnosticOutcome(3, 8); return new WorldSceneDiagnosticOutcome(3, 8);

View file

@ -68,6 +68,44 @@ public sealed class GpuWorldStateRenderTraversalTests
Assert.False(state.TryGetRenderTraversalSortKey(pending, out _)); 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( private static void AssertSort(
GpuWorldState state, GpuWorldState state,
WorldEntity entity, WorldEntity entity,