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:
[`../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

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 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
/// 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 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<uint>? 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<uint>? 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<WorldEntity> Entities,
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
// 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<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>

View file

@ -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);
}
/// <summary>
@ -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
}
}
// 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(
_debugVmConsumerActive,
consumerActive: true,
visible,
total,
nearestOrigin,
_physics.ShadowObjects.AllEntriesForDebug());
}
return new WorldSceneDiagnosticOutcome(visible, total);
}

View file

@ -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);

View file

@ -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<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? 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<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries =>
_world.LandblockEntries;
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds =>
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> 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, (Vector3 Min, Vector3 Max)> _aabbs = 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(
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);
}
/// <summary>
@ -219,49 +235,77 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
/// explicitly invalidate exactly the affected index generation.
/// </para>
/// </summary>
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
=> EnumerateLandblockEntries(includeAnimatedIndex: true);
=> GetLandblockEntriesView(includeAnimatedIndex: true);
/// <summary>
/// Per-landblock render entries without the animated lookup dictionary.
/// Static render passes use this to avoid rebuilding an index they cannot
/// consume.
/// </summary>
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntriesWithoutAnimatedIndex
=> EnumerateLandblockEntries(includeAnimatedIndex: false);
=> GetLandblockEntriesView(includeAnimatedIndex: false);
/// <summary>
/// Lightweight bounds-only enumeration for overlays and diagnostics.
/// Does not walk entity lists.
/// </summary>
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,
private IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> EnumerateLandblockEntries(
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> GetLandblockEntriesView(
bool includeAnimatedIndex)
{
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++)
{
@ -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<ulong>();
WorldEntity[] staticEntities = entities

View file

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

View file

@ -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<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]
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<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries =>
Array.Empty<(uint, Vector3, Vector3, IReadOnlyList<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)>();
}
@ -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);

View file

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