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:
parent
b9cbf5e040
commit
b3427554c3
10 changed files with 397 additions and 129 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
69
docs/research/2026-07-25-slice-h-a4-frame-scratch.md
Normal file
69
docs/research/2026-07-25-slice-h-a4-frame-scratch.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue