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