feat(rendering): compare scene-built PView candidates

Build a same-frame candidate product from incremental render-scene indices and compare its exact PView routes against the accepted current path without changing the production draw source.
This commit is contained in:
Erik 2026-07-25 00:12:53 +02:00
parent 8c638654be
commit e346f8bbaf
13 changed files with 1187 additions and 9 deletions

View file

@ -24,7 +24,7 @@ retail-faithful gameplay
| F4 — dynamic indices | complete | The contained scene now maintains outdoor/static, per-cell static/dynamic, special dynamic-route, translucent, selectable, light-candidate, and dirty indices incrementally. Final-frame live/equipped synchronization remains active-only, and active animated statics are synchronized from the scheduler's active workset rather than a resident-world scan. The production scene remains unconstructed and non-drawing. |
| F5 | complete | Exact `81e2f1a5` passed capped and uncapped nine-stop routes plus uncapped dense Arwic. Across the final checkpoints the referee completed 1,663 + 1,677 + 81 comparisons with zero mismatches, zero pending deltas, zero rejected operations, exact binary/source identity, and graceful shutdown. Evidence: `docs/research/2026-07-24-slice-f5-render-scene-shadow-gate.md`. |
| G0 — borrowed frame product | complete | Two reusable arenas publish exact generation/frame-stamped borrowed views for scene candidates, cell ranges, transforms, classification, light sets, selection, PView/clip references, counts, and source digest. Release/reuse invalidates every copied view; abort never publishes; frame N may build while N-1 is borrowed; the warm path allocates zero bytes. Eight focused tests pass inside the 3,743-App / 8,227-solution Release gate. |
| G1 — scene-query replacement | active | Build the candidate product from the incremental scene and compare it with the current PView partition without changing the draw source. |
| G1 — scene-query replacement | code gate complete; connected gate pending | The incremental scene now builds the same-frame candidate product from outdoor, per-cell, look-in, outside-stage, and dynamic indices, preserving PView route multiplicity and hidden-versus-withdrawn ownership. A compare-only controller checks canonical route/cell/entity membership against the accepted current PView path; lifecycle JSON publishes counts, digests, and the first mismatch. Twenty-four focused render-scene/frame-product tests pass inside the 3,745-App / 8,229-solution Release gate. No production draw consumer has switched. |
| G2G5 | pending | No production consumer has switched. |
The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is

View file

@ -388,6 +388,14 @@ internal sealed class FrameRootCompositionPhase
currentRenderSceneOracle,
message => d.Log("[UI-PROBE] " + message))
: null;
RenderScenePViewFrameProductController? renderFrameProduct =
currentRenderSceneOracle is not null
&& live.RenderSceneShadow is not null
? new RenderScenePViewFrameProductController(
live.RenderSceneShadow,
currentRenderSceneOracle,
message => d.Log("[UI-PROBE] " + message))
: null;
live.DrawDispatcher.SetCurrentRenderSceneObserver(
currentRenderSceneOracle);
live.SelectionScene.SetCurrentRenderSceneObserver(
@ -402,7 +410,9 @@ internal sealed class FrameRootCompositionPhase
d.RetailAlphaQueue,
d.ParticleVisibility,
new WorldScenePViewRenderer(
new RetailPViewRenderer(currentRenderSceneOracle),
new RetailPViewRenderer(
currentRenderSceneOracle,
renderFrameProduct),
retailPViewPassExecutor,
retailPViewPassExecutor),
retailPViewCells,
@ -436,7 +446,8 @@ internal sealed class FrameRootCompositionPhase
content.Dats,
foundation.Residency,
currentRenderSceneOracle,
renderSceneShadowComparison);
renderSceneShadowComparison,
renderFrameProduct);
lifecycleAutomation =
new WorldLifecycleAutomationController(
() => session.WorldReveal.Snapshot,

View file

@ -41,6 +41,7 @@ internal sealed record WorldLifecycleResourceSnapshot(
int MaterializedLiveEntities,
CurrentRenderSceneOracleSnapshot RenderSceneOracle,
RenderSceneShadowComparisonSnapshot RenderSceneShadow,
RenderFrameProductComparisonSnapshot RenderFrameProduct,
int PendingLiveTeardowns,
int PendingLandblockRetirements,
int ParticleEmitters,

View file

@ -43,6 +43,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
_renderSceneOracle;
private readonly IRenderSceneShadowSnapshotSource?
_renderSceneShadow;
private readonly IRenderFrameProductSnapshotSource?
_renderFrameProduct;
public WorldLifecycleResourceSnapshotSource(
GpuWorldState world,
@ -62,7 +64,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
IDatReaderWriter dats,
ResidencyManager residency,
ICurrentRenderSceneOracleSnapshotSource? renderSceneOracle = null,
IRenderSceneShadowSnapshotSource? renderSceneShadow = null)
IRenderSceneShadowSnapshotSource? renderSceneShadow = null,
IRenderFrameProductSnapshotSource? renderFrameProduct = null)
{
_world = world ?? throw new ArgumentNullException(nameof(world));
_animations = animations
@ -91,6 +94,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
?? throw new ArgumentNullException(nameof(residency));
_renderSceneOracle = renderSceneOracle;
_renderSceneShadow = renderSceneShadow;
_renderFrameProduct = renderFrameProduct;
}
public WorldLifecycleResourceSnapshot Capture(RenderFrameOutcome outcome)
@ -136,6 +140,9 @@ internal sealed class WorldLifecycleResourceSnapshotSource
RenderSceneShadow:
_renderSceneShadow?.CaptureCheckpointSnapshot()
?? RenderSceneShadowComparisonSnapshot.Disabled,
RenderFrameProduct:
_renderFrameProduct?.Snapshot
?? RenderFrameProductComparisonSnapshot.Disabled,
PendingLiveTeardowns: _liveEntities.PendingTeardownCount,
PendingLandblockRetirements: _streaming.PendingRetirementCount,
ParticleEmitters: _particles.ActiveEmitterCount,

View file

@ -15,6 +15,8 @@ public sealed class RetailPViewRenderer
{
private readonly InteriorEntityPartition.IObserver? _partitionObserver;
private readonly ICurrentRenderPViewObserver? _candidateObserver;
private readonly RenderScenePViewFrameProductController?
_sceneFrameProduct;
private readonly PortalVisibilityFrame _mainPortalFrameScratch = new();
private readonly ClipFrameAssembly _clipAssemblyScratch = new();
private readonly ViewconeCuller _viewconeScratch = new();
@ -71,10 +73,19 @@ public sealed class RetailPViewRenderer
}
internal RetailPViewRenderer(
InteriorEntityPartition.IObserver? partitionObserver)
InteriorEntityPartition.IObserver? partitionObserver,
RenderScenePViewFrameProductController? sceneFrameProduct = null)
{
if (sceneFrameProduct is not null
&& partitionObserver is not ICurrentRenderPViewObserver)
{
throw new ArgumentException(
"The scene frame-product referee requires the current PView observer.",
nameof(partitionObserver));
}
_partitionObserver = partitionObserver;
_candidateObserver = partitionObserver as ICurrentRenderPViewObserver;
_sceneFrameProduct = sceneFrameProduct;
}
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
@ -239,6 +250,14 @@ public sealed class RetailPViewRenderer
DrawDynamicsLast(ctx, passes, partition, viewcone, ctx.RootCell.IsOutdoorNode);
_candidateObserver?.CompletePViewFrame();
_sceneFrameProduct?.BuildAndCompare(
pvFrame,
clipAssembly,
viewcone,
_lookInFrames,
drawableCells,
ctx.Cells,
ctx.RootCell.IsOutdoorNode);
return result;
}
catch

View file

@ -9,6 +9,22 @@ internal enum RenderFrameBlendClass : byte
Alpha,
}
internal enum RenderFrameCandidateRoute : byte
{
LandscapeOutdoorStatic,
LandscapeOutsideDynamic,
LookInObject,
CellStatic,
DynamicLast,
}
internal readonly record struct RenderFrameCandidateRange(
RenderFrameCandidateRoute Route,
int RouteIndex,
uint CellId,
int Offset,
int Count);
internal readonly record struct RenderFrameCellRange(
uint CellId,
int PViewOrder,
@ -60,7 +76,8 @@ internal readonly record struct RenderFrameDiagnosticCounts(
int OpaqueClassificationCount,
int AlphaClassificationCount,
int LightSetCount,
int SelectionPartCount);
int SelectionPartCount,
int RouteCandidateCount);
/// <summary>
/// A validated borrowed view over one reusable frame arena. Copies are safe:
@ -125,6 +142,12 @@ internal readonly struct RenderFrameView
public ReadOnlySpan<RenderFrameSelectionPart> SelectionParts =>
Arena.SelectionParts;
public ReadOnlySpan<RenderProjectionRecord> RouteCandidates =>
Arena.RouteCandidates;
public ReadOnlySpan<RenderFrameCandidateRange> RouteRanges =>
Arena.RouteRanges;
public PortalVisibilityFrame? PortalFrame => Arena.PortalFrame;
public ClipFrameAssembly? ClipAssembly => Arena.ClipAssembly;
@ -172,6 +195,8 @@ internal sealed class RenderFrameArena
private RenderFrameClassificationRecord[] _classifications = [];
private RenderFrameObjectLightSet[] _lightSets = [];
private RenderFrameSelectionPart[] _selectionParts = [];
private RenderProjectionRecord[] _routeCandidates = [];
private RenderFrameCandidateRange[] _routeRanges = [];
private int _outdoorCount;
private int _cellStaticCount;
private int _cellRangeCount;
@ -180,6 +205,8 @@ internal sealed class RenderFrameArena
private int _classificationCount;
private int _lightSetCount;
private int _selectionPartCount;
private int _routeCandidateCount;
private int _routeRangeCount;
private int _opaqueClassificationCount;
private int _alphaClassificationCount;
private ulong _epoch;
@ -206,7 +233,8 @@ internal sealed class RenderFrameArena
_opaqueClassificationCount,
_alphaClassificationCount,
_lightSetCount,
_selectionPartCount);
_selectionPartCount,
_routeCandidateCount);
internal ReadOnlySpan<RenderProjectionRecord> OutdoorStaticCandidates =>
_outdoor.AsSpan(0, _outdoorCount);
@ -232,6 +260,12 @@ internal sealed class RenderFrameArena
internal ReadOnlySpan<RenderFrameSelectionPart> SelectionParts =>
_selectionParts.AsSpan(0, _selectionPartCount);
internal ReadOnlySpan<RenderProjectionRecord> RouteCandidates =>
_routeCandidates.AsSpan(0, _routeCandidateCount);
internal ReadOnlySpan<RenderFrameCandidateRange> RouteRanges =>
_routeRanges.AsSpan(0, _routeRangeCount);
internal ulong BeginWrite(
RenderSceneGeneration generation,
ulong frameSequence)
@ -253,6 +287,8 @@ internal sealed class RenderFrameArena
_classificationCount = 0;
_lightSetCount = 0;
_selectionPartCount = 0;
_routeCandidateCount = 0;
_routeRangeCount = 0;
_opaqueClassificationCount = 0;
_alphaClassificationCount = 0;
PortalFrame = null;
@ -393,6 +429,34 @@ internal sealed class RenderFrameArena
_selectionParts[_selectionPartCount++] = part;
}
internal void AddRouteRange(
ulong epoch,
RenderFrameCandidateRoute route,
int routeIndex,
uint cellId,
ReadOnlySpan<RenderProjectionRecord> records)
{
EnsureBuilding(epoch);
if (routeIndex < 0)
throw new ArgumentOutOfRangeException(nameof(routeIndex));
if (records.Length == 0)
return;
int offset = _routeCandidateCount;
EnsureCapacity(
ref _routeCandidates,
checked(offset + records.Length));
records.CopyTo(_routeCandidates.AsSpan(offset));
_routeCandidateCount += records.Length;
EnsureCapacity(ref _routeRanges, _routeRangeCount + 1);
_routeRanges[_routeRangeCount++] = new RenderFrameCandidateRange(
route,
routeIndex,
cellId,
offset,
records.Length);
}
internal void Publish(ulong epoch)
{
EnsureBuilding(epoch);
@ -549,6 +613,18 @@ internal readonly struct RenderFrameWriter
public void AddSelectionPart(in RenderFrameSelectionPart part) =>
Arena.AddSelectionPart(_epoch, in part);
public void AddRouteRange(
RenderFrameCandidateRoute route,
int routeIndex,
uint cellId,
ReadOnlySpan<RenderProjectionRecord> records) =>
Arena.AddRouteRange(
_epoch,
route,
routeIndex,
cellId,
records);
public void Publish() =>
Owner.Publish(_arenaIndex, _epoch);

View file

@ -27,6 +27,8 @@ internal static class RenderProjectionRecordFactory
ownerLandblockId,
entity);
RenderProjectionFlags flags = RenderProjectionFlags.Selectable;
if (spatiallyVisible)
flags |= RenderProjectionFlags.SpatiallyResident;
if (entity.MeshRefs.Count > 0
&& spatiallyVisible
&& entity.IsDrawVisible

View file

@ -13,6 +13,7 @@ internal readonly record struct RenderProjectionId
internal ulong RawValue => _value;
internal byte Domain => (byte)(_value >> 56);
internal RenderSortKey ToSortKey() => new(_value);
internal void AddTo(ref StableRenderHash128 hash) => hash.Add(_value);
public int CompareTo(RenderProjectionId other) =>
_value.CompareTo(other._value);
@ -104,6 +105,13 @@ internal enum RenderProjectionFlags : uint
Selectable = 1 << 4,
LightCandidate = 1 << 5,
PortalStraddling = 1 << 6,
/// <summary>
/// The projection currently participates in the accepted spatial
/// presentation workset. Hidden objects retain this bit; a
/// portal-withdrawn attachment does not. This is derived render
/// ownership, not gameplay PhysicsState.
/// </summary>
SpatiallyResident = 1 << 7,
}
[Flags]

View file

@ -0,0 +1,685 @@
using System.Numerics;
namespace AcDream.App.Rendering.Scene;
internal readonly record struct RenderScenePViewBuildInput(
RenderSceneQuery Scene,
RenderSceneDigest SourceDigest,
PortalVisibilityFrame PortalFrame,
ClipFrameAssembly ClipAssembly,
ViewconeCuller Viewcone,
IReadOnlyList<PortalVisibilityFrame> LookInFrames,
HashSet<uint> DrawableCells,
IRetailPViewCellSource Cells,
bool RootIsOutdoor);
internal readonly record struct RenderFrameProductComparisonSnapshot(
bool Enabled,
ulong ProductFrameSequence,
ulong CurrentPViewFrameSequence,
ulong ComparisonCount,
ulong SuccessfulComparisonCount,
int ExpectedCandidateCount,
int ActualCandidateCount,
long MismatchCount,
string? FirstMismatch,
RenderSceneHash128 ExpectedDigest,
RenderSceneHash128 ActualDigest,
RenderFrameDiagnosticCounts ProductCounts)
{
public static RenderFrameProductComparisonSnapshot Disabled { get; } =
new(
Enabled: false,
ProductFrameSequence: 0,
CurrentPViewFrameSequence: 0,
ComparisonCount: 0,
SuccessfulComparisonCount: 0,
ExpectedCandidateCount: 0,
ActualCandidateCount: 0,
MismatchCount: 0,
FirstMismatch: null,
ExpectedDigest: RenderSceneHash128.Empty,
ActualDigest: RenderSceneHash128.Empty,
ProductCounts: default);
}
internal interface IRenderFrameProductSnapshotSource
{
RenderFrameProductComparisonSnapshot Snapshot { get; }
}
/// <summary>
/// G1 compare-only owner. It builds the candidate frame product directly from
/// the incremental scene after the accepted PView traversal, borrows it in the
/// same render frame, and compares route membership against the current-path
/// observer. It never supplies a production draw input.
/// </summary>
internal sealed class RenderScenePViewFrameProductController :
IRenderFrameProductSnapshotSource
{
private readonly RenderSceneShadowRuntime _shadow;
private readonly CurrentRenderSceneOracle _current;
private readonly RenderScenePViewFrameBuilder _builder = new();
private readonly RenderFrameExchange _exchange = new();
private readonly List<CandidateKey> _expected = [];
private readonly List<CandidateKey> _actual = [];
private readonly Action<string>? _log;
private ulong _productFrameSequence;
private ulong _comparisonCount;
private ulong _successfulComparisonCount;
private long _mismatchCount;
private string? _firstMismatch;
public RenderScenePViewFrameProductController(
RenderSceneShadowRuntime shadow,
CurrentRenderSceneOracle current,
Action<string>? log = null)
{
_shadow = shadow ?? throw new ArgumentNullException(nameof(shadow));
_current = current ?? throw new ArgumentNullException(nameof(current));
_log = log;
Snapshot = RenderFrameProductComparisonSnapshot.Disabled with
{
Enabled = true,
};
}
public RenderFrameProductComparisonSnapshot Snapshot { get; private set; }
public void BuildAndCompare(
PortalVisibilityFrame portalFrame,
ClipFrameAssembly clipAssembly,
ViewconeCuller viewcone,
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
HashSet<uint> drawableCells,
IRetailPViewCellSource cells,
bool rootIsOutdoor)
{
ArgumentNullException.ThrowIfNull(portalFrame);
ArgumentNullException.ThrowIfNull(clipAssembly);
ArgumentNullException.ThrowIfNull(viewcone);
ArgumentNullException.ThrowIfNull(lookInFrames);
ArgumentNullException.ThrowIfNull(drawableCells);
ArgumentNullException.ThrowIfNull(cells);
ulong frameSequence = checked(++_productFrameSequence);
RenderSceneQuery scene = _shadow.Query;
var input = new RenderScenePViewBuildInput(
scene,
_shadow.BuildDigest(),
portalFrame,
clipAssembly,
viewcone,
lookInFrames,
drawableCells,
cells,
rootIsOutdoor);
_builder.Build(_exchange, frameSequence, in input);
RenderFrameView view = _exchange.BorrowLatest(
scene.Generation,
frameSequence);
try
{
Compare(in view);
}
finally
{
_exchange.Release(in view);
}
}
private void Compare(in RenderFrameView view)
{
_comparisonCount = checked(_comparisonCount + 1);
_expected.Clear();
_actual.Clear();
IReadOnlyList<CurrentRenderPViewCandidateFingerprint> expected =
_current.PViewCandidates;
if (_expected.Capacity < expected.Count)
_expected.Capacity = expected.Count;
for (int i = 0; i < expected.Count; i++)
{
CurrentRenderPViewCandidateFingerprint candidate = expected[i];
CurrentRenderProjectionFingerprint projection =
candidate.Projection;
_expected.Add(new CandidateKey(
Map(candidate.Route),
candidate.RouteIndex,
candidate.CellId,
ExpectedId(in projection)));
}
ReadOnlySpan<RenderFrameCandidateRange> ranges = view.RouteRanges;
ReadOnlySpan<RenderProjectionRecord> records = view.RouteCandidates;
if (_actual.Capacity < records.Length)
_actual.Capacity = records.Length;
for (int rangeIndex = 0; rangeIndex < ranges.Length; rangeIndex++)
{
RenderFrameCandidateRange range = ranges[rangeIndex];
int end = checked(range.Offset + range.Count);
if ((uint)range.Offset > (uint)records.Length
|| (uint)end > (uint)records.Length)
{
throw new InvalidOperationException(
$"Render-frame route range {rangeIndex} exceeds its candidate storage.");
}
for (int index = range.Offset; index < end; index++)
{
_actual.Add(new CandidateKey(
range.Route,
range.RouteIndex,
range.CellId,
records[index].Id));
}
}
_expected.Sort(CandidateKeyComparer.Instance);
_actual.Sort(CandidateKeyComparer.Instance);
RenderSceneHash128 expectedDigest = BuildDigest(_expected);
RenderSceneHash128 actualDigest = BuildDigest(_actual);
string? mismatch = CompareKeys();
if (mismatch is null)
{
_successfulComparisonCount =
checked(_successfulComparisonCount + 1);
}
else
{
_mismatchCount = checked(_mismatchCount + 1);
if (_firstMismatch is null)
{
_firstMismatch = mismatch;
_log?.Invoke(
"[render-frame-product] first mismatch: " + mismatch);
}
}
Snapshot = new RenderFrameProductComparisonSnapshot(
Enabled: true,
ProductFrameSequence: view.FrameSequence,
CurrentPViewFrameSequence:
_current.Snapshot.CompletedPViewFrameSequence,
ComparisonCount: _comparisonCount,
SuccessfulComparisonCount: _successfulComparisonCount,
ExpectedCandidateCount: _expected.Count,
ActualCandidateCount: _actual.Count,
MismatchCount: _mismatchCount,
FirstMismatch: _firstMismatch,
ExpectedDigest: expectedDigest,
ActualDigest: actualDigest,
ProductCounts: view.DiagnosticCounts);
}
private string? CompareKeys()
{
if (_expected.Count != _actual.Count)
{
return $"field=count expected={_expected.Count} "
+ $"actual={_actual.Count}";
}
for (int i = 0; i < _expected.Count; i++)
{
if (_expected[i] != _actual[i])
{
return $"field=candidate index={i} "
+ $"expected={_expected[i]} actual={_actual[i]}";
}
}
return null;
}
private static RenderSceneHash128 BuildDigest(
List<CandidateKey> candidates)
{
StableRenderHash128 hash = StableRenderHash128.Create();
hash.Add(candidates.Count);
for (int i = 0; i < candidates.Count; i++)
{
CandidateKey candidate = candidates[i];
hash.Add((byte)candidate.Route);
hash.Add(candidate.RouteIndex);
hash.Add(candidate.CellId);
candidate.ProjectionId.AddTo(ref hash);
}
return hash.Finish();
}
private static RenderProjectionId ExpectedId(
in CurrentRenderProjectionFingerprint projection) =>
projection.ServerGuid == 0
? StaticRenderProjectionJournal.StaticEntityId(
projection.LandblockId,
projection.EntityId)
: LiveRenderProjectionJournal.ProjectionId(
projection.EntityId);
private static RenderFrameCandidateRoute Map(
CurrentRenderPViewRoute route) =>
route switch
{
CurrentRenderPViewRoute.LandscapeOutdoorStatic =>
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
CurrentRenderPViewRoute.LandscapeOutsideDynamic =>
RenderFrameCandidateRoute.LandscapeOutsideDynamic,
CurrentRenderPViewRoute.LookInObject =>
RenderFrameCandidateRoute.LookInObject,
CurrentRenderPViewRoute.CellStatic =>
RenderFrameCandidateRoute.CellStatic,
CurrentRenderPViewRoute.DynamicLast =>
RenderFrameCandidateRoute.DynamicLast,
_ => throw new ArgumentOutOfRangeException(
nameof(route),
route,
null),
};
private readonly record struct CandidateKey(
RenderFrameCandidateRoute Route,
int RouteIndex,
uint CellId,
RenderProjectionId ProjectionId);
private sealed class CandidateKeyComparer : IComparer<CandidateKey>
{
public static CandidateKeyComparer Instance { get; } = new();
private CandidateKeyComparer()
{
}
public int Compare(CandidateKey left, CandidateKey right)
{
int value = left.Route.CompareTo(right.Route);
if (value != 0) return value;
value = left.RouteIndex.CompareTo(right.RouteIndex);
if (value != 0) return value;
value = left.CellId.CompareTo(right.CellId);
if (value != 0) return value;
return left.ProjectionId.CompareTo(right.ProjectionId);
}
}
}
/// <summary>
/// Allocation-free scene-query projection for one accepted PView frame.
/// It applies the existing viewcone and outside-stage predicates verbatim,
/// changing only the source of candidate records.
/// </summary>
internal sealed class RenderScenePViewFrameBuilder
{
private const byte EnvCellProjectionDomain = 2;
private readonly HashSet<RenderProjectionId> _transformIds = [];
private RenderProjectionRecord[] _outdoor = [];
private RenderProjectionRecord[] _dynamics = [];
private RenderProjectionRecord[] _cell = [];
private RenderProjectionRecord[] _survivors = [];
private RenderProjectionRecord[] _cellRoute = [];
private int _outdoorCount;
private int _dynamicCount;
private int _cellRouteCount;
public void Build(
RenderFrameExchange exchange,
ulong frameSequence,
in RenderScenePViewBuildInput input)
{
ArgumentNullException.ThrowIfNull(exchange);
RenderFrameWriter writer =
exchange.BeginBuild(input.Scene.Generation, frameSequence);
try
{
writer.SetBorrowedProducts(
input.PortalFrame,
input.ClipAssembly);
RenderSceneDigest digest = input.SourceDigest;
writer.SetSourceDigest(in digest);
_transformIds.Clear();
LoadSceneIndices(input.Scene);
BuildOutdoorRoutes(writer, in input);
BuildLookInRoutes(writer, in input);
BuildOutsideDynamicRoutes(writer, in input);
BuildCellStaticRoute(writer, in input);
BuildDynamicLastRoute(writer, in input);
writer.Publish();
}
catch
{
writer.Abort();
throw;
}
}
private void LoadSceneIndices(RenderSceneQuery scene)
{
RenderSceneIndexCounts counts = scene.IndexCounts;
EnsureCapacity(ref _outdoor, counts.OutdoorStatic);
_outdoorCount = scene.CopyIndexTo(
RenderSceneIndex.OutdoorStatic,
_outdoor);
_outdoorCount = CompactAndSort(
_outdoor,
_outdoorCount);
EnsureCapacity(ref _dynamics, counts.Dynamic);
_dynamicCount = scene.CopyIndexTo(
RenderSceneIndex.Dynamic,
_dynamics);
_dynamicCount = CompactAndSort(
_dynamics,
_dynamicCount);
}
private void BuildOutdoorRoutes(
RenderFrameWriter writer,
in RenderScenePViewBuildInput input)
{
int sliceCount = input.ClipAssembly.OutsideViewSlices.Length;
for (int sliceIndex = 0; sliceIndex < sliceCount; sliceIndex++)
{
int count = 0;
EnsureCapacity(ref _survivors, _outdoorCount);
for (int i = 0; i < _outdoorCount; i++)
{
RenderProjectionRecord record = _outdoor[i];
Sphere(in record, out Vector3 center, out float radius);
if (!input.Viewcone.SphereVisibleInOutsideSlice(
sliceIndex,
in center,
radius))
{
continue;
}
_survivors[count++] = record;
writer.AddOutdoor(in record);
AddTransform(writer, in record);
}
writer.AddRouteRange(
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
sliceIndex,
0,
_survivors.AsSpan(0, count));
}
}
private void BuildLookInRoutes(
RenderFrameWriter writer,
in RenderScenePViewBuildInput input)
{
for (int frameIndex = 0;
frameIndex < input.LookInFrames.Count;
frameIndex++)
{
PortalVisibilityFrame frame = input.LookInFrames[frameIndex];
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
int count = LoadCell(
input.Scene,
cellId,
includeDynamics: true);
if (count == 0)
continue;
for (int index = 0; index < count; index++)
AddTransform(writer, in _cell[index]);
writer.AddRouteRange(
RenderFrameCandidateRoute.LookInObject,
i,
cellId,
_cell.AsSpan(0, count));
}
}
}
private void BuildOutsideDynamicRoutes(
RenderFrameWriter writer,
in RenderScenePViewBuildInput input)
{
if (input.RootIsOutdoor)
return;
int sliceCount = input.ClipAssembly.OutsideViewSlices.Length;
for (int sliceIndex = 0; sliceIndex < sliceCount; sliceIndex++)
{
int count = 0;
EnsureCapacity(ref _survivors, _dynamicCount);
for (int i = 0; i < _dynamicCount; i++)
{
RenderProjectionRecord record = _dynamics[i];
Sphere(in record, out Vector3 center, out float radius);
if (!RetailPViewRenderer.DynamicDrawsInOutsideStage(
ParentCell(in record),
center,
radius,
input.DrawableCells,
input.Cells)
|| !input.Viewcone.SphereVisibleInOutsideSlice(
sliceIndex,
in center,
radius))
{
continue;
}
_survivors[count++] = record;
writer.AddDynamic(in record);
AddTransform(writer, in record);
}
writer.AddRouteRange(
RenderFrameCandidateRoute.LandscapeOutsideDynamic,
sliceIndex,
0,
_survivors.AsSpan(0, count));
}
}
private void BuildCellStaticRoute(
RenderFrameWriter writer,
in RenderScenePViewBuildInput input)
{
_cellRouteCount = 0;
IReadOnlyList<uint> ordered = input.PortalFrame.OrderedVisibleCells;
for (int i = ordered.Count - 1; i >= 0; i--)
{
uint cellId = ordered[i];
if (!input.DrawableCells.Contains(cellId))
continue;
int loaded = LoadCell(
input.Scene,
cellId,
includeDynamics: false);
int count = 0;
for (int index = 0; index < loaded; index++)
{
RenderProjectionRecord record = _cell[index];
Sphere(in record, out Vector3 center, out float radius);
if (!input.Viewcone.SphereVisibleInCell(
cellId,
in center,
radius))
{
continue;
}
_cell[count++] = record;
AddTransform(writer, in record);
}
writer.AddCellRange(
cellId,
i,
_cell.AsSpan(0, count));
EnsureCapacity(
ref _cellRoute,
checked(_cellRouteCount + count));
_cell.AsSpan(0, count).CopyTo(
_cellRoute.AsSpan(_cellRouteCount));
_cellRouteCount += count;
}
writer.AddRouteRange(
RenderFrameCandidateRoute.CellStatic,
0,
0,
_cellRoute.AsSpan(0, _cellRouteCount));
}
private void BuildDynamicLastRoute(
RenderFrameWriter writer,
in RenderScenePViewBuildInput input)
{
int count = 0;
EnsureCapacity(ref _survivors, _dynamicCount);
for (int i = 0; i < _dynamicCount; i++)
{
RenderProjectionRecord record = _dynamics[i];
uint? parentCellId = ParentCell(in record);
bool indoor = InteriorEntityPartition.IsIndoorCellId(
parentCellId);
if (!input.RootIsOutdoor && !indoor)
continue;
Sphere(in record, out Vector3 center, out float radius);
bool visible = indoor
? input.Viewcone.SphereVisibleInCell(
parentCellId!.Value,
in center,
radius)
: input.Viewcone.SphereVisibleOutside(
in center,
radius);
if (!visible)
continue;
_survivors[count++] = record;
writer.AddDynamic(in record);
AddTransform(writer, in record);
}
writer.AddRouteRange(
RenderFrameCandidateRoute.DynamicLast,
0,
0,
_survivors.AsSpan(0, count));
}
private int LoadCell(
RenderSceneQuery scene,
uint cellId,
bool includeDynamics)
{
int staticCount = scene.GetCellStaticCount(cellId);
int dynamicCount = includeDynamics
? scene.GetCellDynamicCount(cellId)
: 0;
EnsureCapacity(
ref _cell,
checked(staticCount + dynamicCount));
int count = scene.CopyCellStaticsTo(
cellId,
_cell.AsSpan(0, staticCount));
if (dynamicCount > 0)
{
count += scene.CopyCellDynamicsTo(
cellId,
_cell.AsSpan(count, dynamicCount));
}
return CompactAndSort(_cell, count);
}
private void AddTransform(
RenderFrameWriter writer,
in RenderProjectionRecord record)
{
if (!_transformIds.Add(record.Id))
return;
writer.AddTransform(new RenderFrameTransformRecord(
record.Id,
-1,
record.Transform.LocalToWorld));
}
private static int CompactAndSort(
RenderProjectionRecord[] records,
int count)
{
int write = 0;
for (int read = 0; read < count; read++)
{
RenderProjectionRecord record = records[read];
if (record.Id.Domain == EnvCellProjectionDomain
|| record.MeshSet.MeshCount <= 0
|| (record.Flags
& RenderProjectionFlags.SpatiallyResident) == 0)
{
continue;
}
records[write++] = record;
}
Array.Sort(
records,
0,
write,
RenderProjectionRecordSortComparer.Instance);
return write;
}
private static uint? ParentCell(in RenderProjectionRecord record) =>
record.Source.ParentCellId == 0
? null
: record.Source.ParentCellId;
private static void Sphere(
in RenderProjectionRecord record,
out Vector3 center,
out float radius)
{
center = (record.Bounds.Minimum + record.Bounds.Maximum) * 0.5f;
radius =
(record.Bounds.Maximum - record.Bounds.Minimum).Length() * 0.5f;
}
private static void EnsureCapacity<T>(ref T[] values, int required)
{
if (values.Length >= required)
return;
int capacity = values.Length == 0 ? 4 : values.Length;
while (capacity < required)
capacity = checked(capacity * 2);
Array.Resize(ref values, capacity);
}
private sealed class RenderProjectionRecordSortComparer :
IComparer<RenderProjectionRecord>
{
public static RenderProjectionRecordSortComparer Instance { get; } =
new();
private RenderProjectionRecordSortComparer()
{
}
public int Compare(
RenderProjectionRecord left,
RenderProjectionRecord right)
{
int value = left.SortKey.Value.CompareTo(right.SortKey.Value);
return value != 0
? value
: left.Id.CompareTo(right.Id);
}
}
}

View file

@ -336,7 +336,8 @@ internal sealed class StaticRenderProjectionJournal :
new RenderWorldBounds(
shell.WorldBounds.Min,
shell.WorldBounds.Max),
RenderProjectionFlags.Draw,
RenderProjectionFlags.Draw
| RenderProjectionFlags.SpatiallyResident,
default,
id.ToSortKey(),
RenderDirtyMask.All,

View file

@ -377,6 +377,7 @@ public sealed class WorldLifecycleAutomationControllerTests
private static WorldLifecycleResourceSnapshot EmptyResources() => new(
0, 0, 0, 0, 0, 0, 0, CurrentRenderSceneOracleSnapshot.Disabled,
RenderSceneShadowComparisonSnapshot.Disabled,
RenderFrameProductComparisonSnapshot.Disabled,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0L, 0, 0L, 0L, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L,
0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L,

View file

@ -49,6 +49,11 @@ public sealed class RenderFrameProductTests
0x01000001,
Matrix4x4.Identity,
SelectionMesh()));
writer.AddRouteRange(
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
0,
0,
[outdoor]);
var digest = new RenderSceneDigest(
Generation,
new RenderProjectionCounts(Total: 4, 1, 2, 1, 0, 0),
@ -70,8 +75,17 @@ public sealed class RenderFrameProductTests
Assert.Equal(2, view.Classifications.Length);
Assert.Equal(1, view.LightSets.Length);
Assert.Equal(1, view.SelectionParts.Length);
Assert.Equal(outdoor.Id, view.RouteCandidates[0].Id);
Assert.Equal(
new RenderFrameDiagnosticCounts(1, 2, 1, 1, 1, 1, 1, 1),
new RenderFrameCandidateRange(
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
0,
0,
0,
1),
view.RouteRanges[0]);
Assert.Equal(
new RenderFrameDiagnosticCounts(1, 2, 1, 1, 1, 1, 1, 1, 1),
view.DiagnosticCounts);
Assert.Equal(digest, view.SourceDigest);

View file

@ -0,0 +1,353 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Scene.Arch;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
public sealed class RenderScenePViewFrameProductTests
{
private const uint Landblock = 0xA9B4FFFF;
private const uint Cell = 0xA9B40170;
private static readonly RenderSceneGeneration Generation =
RenderSceneGeneration.FromRaw(1);
[Fact]
public void Builder_PreservesPViewRoutesAndExcludesShellsAndWithdrawnOwners()
{
using var scene = new ArchRenderScene(Generation);
RenderProjectionRecord outdoor = Record(
0x0100_0000_0000_0001,
RenderProjectionClass.OutdoorStatic);
RenderProjectionRecord hidden = Record(
0x0100_0000_0000_0002,
RenderProjectionClass.OutdoorStatic,
flags: RenderProjectionFlags.Hidden
| RenderProjectionFlags.SpatiallyResident);
RenderProjectionRecord withdrawn = Record(
0x0100_0000_0000_0003,
RenderProjectionClass.OutdoorStatic,
flags: RenderProjectionFlags.Draw);
RenderProjectionRecord shell = Record(
0x0200_0000_0000_0004,
RenderProjectionClass.IndoorCellStatic,
parentCell: Cell);
RenderProjectionRecord cellStatic = Record(
0x0100_0000_0000_0005,
RenderProjectionClass.IndoorCellStatic,
parentCell: Cell);
RenderProjectionRecord outdoorDynamic = Record(
0x0300_0000_0000_0006,
RenderProjectionClass.LiveDynamicRoot);
RenderProjectionRecord cellDynamic = Record(
0x0300_0000_0000_0007,
RenderProjectionClass.LiveDynamicRoot,
parentCell: Cell);
RenderProjectionRecord[] records =
[
outdoor,
hidden,
withdrawn,
shell,
cellStatic,
outdoorDynamic,
cellDynamic,
];
var deltas = new RenderProjectionDelta[records.Length];
for (int index = 0; index < records.Length; index++)
{
deltas[index] = RenderProjectionDelta.Register(
Generation,
(ulong)index + 1,
records[index]);
}
scene.Apply(deltas);
PortalVisibilityFrame portal = Portal(Cell);
ClipFrameAssembly clip = FullScreenClip(Cell);
ViewconeCuller viewcone =
ViewconeCuller.Build(clip, Matrix4x4.Identity);
var exchange = new RenderFrameExchange();
var builder = new RenderScenePViewFrameBuilder();
RenderSceneDigest digest =
scene.BuildDigest(new RenderSceneDigestBuffer());
var input = new RenderScenePViewBuildInput(
scene.OpenQuery(),
digest,
portal,
clip,
viewcone,
[],
[Cell],
EmptyCellSource.Instance,
RootIsOutdoor: true);
builder.Build(exchange, frameSequence: 1, in input);
RenderFrameView view = exchange.BorrowLatest(Generation, 1);
try
{
Assert.Equal(
[outdoor.Id, hidden.Id],
view.OutdoorStaticCandidates.ToArray()
.Select(static item => item.Id));
Assert.Equal(
[cellStatic.Id],
view.CellStaticCandidates.ToArray()
.Select(static item => item.Id));
Assert.Equal(
[outdoorDynamic.Id, cellDynamic.Id],
view.DynamicCandidates.ToArray()
.Select(static item => item.Id));
Assert.Equal(5, view.Transforms.Length);
Assert.Equal(5, view.RouteCandidates.Length);
Assert.Equal(3, view.RouteRanges.Length);
Assert.DoesNotContain(
view.RouteCandidates.ToArray(),
item => item.Id == withdrawn.Id || item.Id == shell.Id);
Assert.Equal(
new RenderFrameDiagnosticCounts(
OutdoorStaticCandidates: 2,
CellStaticCandidates: 1,
DynamicCandidates: 2,
TransformCount: 5,
OpaqueClassificationCount: 0,
AlphaClassificationCount: 0,
LightSetCount: 0,
SelectionPartCount: 0,
RouteCandidateCount: 5),
view.DiagnosticCounts);
Assert.Same(portal, view.PortalFrame);
Assert.Same(clip, view.ClipAssembly);
Assert.Equal(digest, view.SourceDigest);
}
finally
{
exchange.Release(in view);
}
}
[Fact]
public void Controller_MatchesCurrentPViewThenNamesAWithdrawnCandidate()
{
WorldEntity outdoor = Entity(10, serverGuid: 0, parentCell: null);
WorldEntity cellStatic = Entity(11, serverGuid: 0, parentCell: Cell);
WorldEntity outdoorDynamic =
Entity(1_000_012, 0x8000_0012, parentCell: null);
WorldEntity cellDynamic =
Entity(1_000_013, 0x8000_0013, parentCell: Cell);
WorldEntity[] entities =
[outdoor, cellStatic, outdoorDynamic, cellDynamic];
var oracle = new CurrentRenderSceneOracle();
var partition = new InteriorEntityPartition.Result();
InteriorEntityPartition.Partition(
partition,
[Cell],
[Entry(entities)],
oracle);
oracle.BeginPViewFrame();
oracle.ObservePViewBucket(
CurrentRenderPViewRoute.LandscapeOutdoorStatic,
0,
0,
[outdoor]);
oracle.ObservePViewBucket(
CurrentRenderPViewRoute.CellStatic,
0,
0,
[cellStatic]);
oracle.ObservePViewBucket(
CurrentRenderPViewRoute.DynamicLast,
0,
0,
[outdoorDynamic, cellDynamic]);
oracle.CompletePViewFrame();
using var shadow = new RenderSceneShadowRuntime(Generation);
foreach (WorldEntity entity in entities)
{
RenderProjectionRecord record = Project(entity);
shadow.Journal.Register(in record);
}
shadow.DrainUpdateBoundary();
var logs = new List<string>();
var controller = new RenderScenePViewFrameProductController(
shadow,
oracle,
logs.Add);
PortalVisibilityFrame portal = Portal(Cell);
ClipFrameAssembly clip = FullScreenClip(Cell);
ViewconeCuller viewcone =
ViewconeCuller.Build(clip, Matrix4x4.Identity);
controller.BuildAndCompare(
portal,
clip,
viewcone,
[],
[Cell],
EmptyCellSource.Instance,
rootIsOutdoor: true);
RenderFrameProductComparisonSnapshot matched = controller.Snapshot;
Assert.True(matched.Enabled);
Assert.Equal(1uL, matched.ComparisonCount);
Assert.Equal(1uL, matched.SuccessfulComparisonCount);
Assert.Equal(4, matched.ExpectedCandidateCount);
Assert.Equal(4, matched.ActualCandidateCount);
Assert.Equal(0, matched.MismatchCount);
Assert.Null(matched.FirstMismatch);
Assert.Equal(matched.ExpectedDigest, matched.ActualDigest);
Assert.Empty(logs);
RenderProjectionId withdrawnId =
LiveRenderProjectionJournal.ProjectionId(cellDynamic.Id);
shadow.Journal.Unregister(
withdrawnId,
RenderOwnerIncarnation.FromRaw(cellDynamic.Id));
shadow.DrainUpdateBoundary();
controller.BuildAndCompare(
portal,
clip,
viewcone,
[],
[Cell],
EmptyCellSource.Instance,
rootIsOutdoor: true);
RenderFrameProductComparisonSnapshot mismatch = controller.Snapshot;
Assert.Equal(2uL, mismatch.ComparisonCount);
Assert.Equal(1uL, mismatch.SuccessfulComparisonCount);
Assert.Equal(1, mismatch.MismatchCount);
Assert.Contains("field=count expected=4 actual=3", mismatch.FirstMismatch);
Assert.Single(logs);
}
private static RenderProjectionRecord Project(WorldEntity entity)
{
bool dynamic = entity.ServerGuid != 0;
RenderProjectionId id = dynamic
? LiveRenderProjectionJournal.ProjectionId(entity.Id)
: StaticRenderProjectionJournal.StaticEntityId(
Landblock,
entity.Id);
RenderProjectionClass projectionClass = dynamic
? RenderProjectionClass.LiveDynamicRoot
: entity.ParentCellId is uint parentCell
&& InteriorEntityPartition.IsIndoorCellId(parentCell)
? RenderProjectionClass.IndoorCellStatic
: RenderProjectionClass.OutdoorStatic;
return RenderProjectionRecordFactory.ProjectEntity(
id,
projectionClass,
RenderOwnerIncarnation.FromRaw(entity.Id),
Landblock,
entity.ParentCellId ?? Landblock,
entity,
spatiallyVisible: true);
}
private static RenderProjectionRecord Record(
ulong id,
RenderProjectionClass projectionClass,
uint? parentCell = null,
RenderProjectionFlags flags =
RenderProjectionFlags.Draw
| RenderProjectionFlags.SpatiallyResident)
{
uint fullCellId = parentCell ?? Landblock;
Matrix4x4 transform = Matrix4x4.Identity;
return new RenderProjectionRecord(
RenderProjectionId.FromRaw(id),
projectionClass,
RenderOwnerIncarnation.FromRaw(id),
new RenderTransform(transform),
new PreviousRenderTransform(transform),
new RenderMeshSet(RenderAssetHandle.FromRaw(id), 1, 1),
new RenderMaterialVariant(0, 0, 1),
new RenderSpatialResidency(
RenderSpatialBucket.FromRaw(fullCellId),
Landblock,
fullCellId),
new RenderWorldBounds(
new Vector3(-1),
new Vector3(1)),
flags,
default,
new RenderSortKey(id),
RenderDirtyMask.All,
new RenderSourceMetadata(
LocalEntityId: (uint)id,
ServerGuid: projectionClass
is RenderProjectionClass.LiveDynamicRoot ? (uint)id : 0,
SourceId: 1,
ParentCellId: parentCell ?? 0,
EffectCellId: 0,
BuildingShellAnchorCellId: 0,
TransformFingerprint: default,
GeometryFingerprint: default,
AppearanceFingerprint: default));
}
private static WorldEntity Entity(
uint id,
uint serverGuid,
uint? parentCell) =>
new()
{
Id = id,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x0200_0001,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs =
[
new MeshRef(0x0100_0001, Matrix4x4.Identity),
],
ParentCellId = parentCell,
};
private static PortalVisibilityFrame Portal(uint cellId)
{
var frame = new PortalVisibilityFrame();
frame.OrderedVisibleCells.Add(cellId);
return frame;
}
private static ClipFrameAssembly FullScreenClip(uint cellId)
{
var slice = new ClipViewSlice(
Slot: 0,
NdcAabb: new Vector4(-1, -1, 1, 1),
Planes: []);
var assembly = new ClipFrameAssembly();
assembly.SetOutsideViewSlices([slice]);
assembly.CellIdToViewSlices[cellId] = [slice];
return assembly;
}
private static (
uint LandblockId,
Vector3 AabbMin,
Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)
Entry(IReadOnlyList<WorldEntity> entities) =>
(
Landblock,
Vector3.Zero,
Vector3.One,
entities,
null);
private sealed class EmptyCellSource : IRetailPViewCellSource
{
public static EmptyCellSource Instance { get; } = new();
private EmptyCellSource()
{
}
public LoadedCell? Find(uint cellId) => null;
}
}