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

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

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,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
=> EnumerateLandblockEntries(includeAnimatedIndex: true);
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
=> 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,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntriesWithoutAnimatedIndex
=> EnumerateLandblockEntries(includeAnimatedIndex: false);
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntriesWithoutAnimatedIndex
=> 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,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> EnumerateLandblockEntries(
private IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
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