feat(rendering): establish current-path scene referee

Capture deterministic, landblock-aware fingerprints from the accepted partition only during lifecycle automation. This gives the F/G shadow scene an independent oracle without changing normal draw decisions or adding disabled-path per-entity cost.
This commit is contained in:
Erik 2026-07-24 21:13:11 +02:00
parent 5ecaa5612d
commit b2b67341ac
9 changed files with 845 additions and 5 deletions

View file

@ -1,6 +1,7 @@
using AcDream.App.Diagnostics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Settings;
using AcDream.App.Update;
@ -368,6 +369,11 @@ internal sealed class FrameRootCompositionPhase
live.SkyRenderer,
content.ParticleSystem,
live.ParticleRenderer);
CurrentRenderSceneOracle? currentRenderSceneOracle =
interaction.RetainedUi?.Screenshots is not null
&& d.Options.AutomationArtifactDirectory is not null
? new CurrentRenderSceneOracle()
: null;
var worldSceneRenderer = new WorldSceneRenderer(
renderFrameResources,
renderLoginState,
@ -378,7 +384,7 @@ internal sealed class FrameRootCompositionPhase
d.RetailAlphaQueue,
d.ParticleVisibility,
new WorldScenePViewRenderer(
new RetailPViewRenderer(),
new RetailPViewRenderer(currentRenderSceneOracle),
retailPViewPassExecutor,
retailPViewPassExecutor),
retailPViewCells,
@ -410,7 +416,8 @@ internal sealed class FrameRootCompositionPhase
live.DrawDispatcher,
d.FrameProfiler,
content.Dats,
foundation.Residency);
foundation.Residency,
currentRenderSceneOracle);
lifecycleAutomation =
new WorldLifecycleAutomationController(
() => session.WorldReveal.Snapshot,

View file

@ -1,6 +1,7 @@
using System.Text.Json;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Scene;
using AcDream.App.Streaming;
using AcDream.App.UI.Testing;
@ -38,6 +39,7 @@ internal sealed record WorldLifecycleResourceSnapshot(
int TotalLandblocks,
int LiveEntities,
int MaterializedLiveEntities,
CurrentRenderSceneOracleSnapshot RenderSceneOracle,
int PendingLiveTeardowns,
int PendingLandblockRetirements,
int ParticleEmitters,

View file

@ -1,4 +1,5 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Residency;
@ -38,6 +39,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
private readonly FrameProfiler _frameProfiler;
private readonly IDatReaderWriter _dats;
private readonly ResidencyManager _residency;
private readonly ICurrentRenderSceneOracleSnapshotSource?
_renderSceneOracle;
public WorldLifecycleResourceSnapshotSource(
GpuWorldState world,
@ -55,7 +58,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
WbDrawDispatcher dispatcher,
FrameProfiler frameProfiler,
IDatReaderWriter dats,
ResidencyManager residency)
ResidencyManager residency,
ICurrentRenderSceneOracleSnapshotSource? renderSceneOracle = null)
{
_world = world ?? throw new ArgumentNullException(nameof(world));
_animations = animations
@ -82,6 +86,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_residency = residency
?? throw new ArgumentNullException(nameof(residency));
_renderSceneOracle = renderSceneOracle;
}
public WorldLifecycleResourceSnapshot Capture(RenderFrameOutcome outcome)
@ -122,6 +127,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
TotalLandblocks: outcome.World.TotalLandblocks,
LiveEntities: _liveEntities.Count,
MaterializedLiveEntities: _liveEntities.MaterializedCount,
RenderSceneOracle: _renderSceneOracle?.Snapshot
?? CurrentRenderSceneOracleSnapshot.Disabled,
PendingLiveTeardowns: _liveEntities.PendingTeardownCount,
PendingLandblockRetirements: _streaming.PendingRetirementCount,
ParticleEmitters: _particles.ActiveEmitterCount,

View file

@ -30,6 +30,27 @@ namespace AcDream.App.Rendering;
/// </summary>
public static class InteriorEntityPartition
{
internal enum ProjectionClass : byte
{
OutdoorStatic,
CellStatic,
Dynamic,
}
internal interface IObserver
{
void BeginFrame();
void Observe(
uint landblockId,
WorldEntity entity,
ProjectionClass projectionClass);
void Complete(Result result);
void AbortFrame();
}
public sealed class Result
{
public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
@ -143,6 +164,79 @@ public static class InteriorEntityPartition
result.PruneEmptyCellBuckets();
}
/// <summary>
/// Current-path referee overload. The observer sees the exact branch that
/// populated each accepted partition entry, including its owning
/// landblock. A null observer is the production fast path and performs no
/// diagnostic allocation or hashing.
/// </summary>
internal static void Partition(
Result result,
HashSet<uint> visibleCells,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
IObserver? observer)
{
if (observer is null)
{
Partition(result, visibleCells, landblockEntries);
return;
}
observer.BeginFrame();
try
{
result.ClearForReuse();
foreach (var entry in landblockEntries)
{
foreach (var e in entry.Entities)
{
if (e.MeshRefs.Count == 0) continue;
// Retail contract: every server-spawned entity is a
// DYNAMIC and draws in the last pass, regardless of cell.
if (e.ServerGuid != 0)
{
result.Dynamics.Add(e);
observer.Observe(
entry.LandblockId,
e,
ProjectionClass.Dynamic);
}
else if (e.ParentCellId is uint cell && IsIndoorCellId(cell))
{
if (!visibleCells.Contains(cell))
continue;
if (!result.ByCell.TryGetValue(cell, out var list))
result.ByCell[cell] = list = new List<WorldEntity>();
list.Add(e);
observer.Observe(
entry.LandblockId,
e,
ProjectionClass.CellStatic);
}
else
{
result.OutdoorStatic.Add(e);
observer.Observe(
entry.LandblockId,
e,
ProjectionClass.OutdoorStatic);
}
}
}
result.PruneEmptyCellBuckets();
observer.Complete(result);
}
catch
{
observer.AbortFrame();
throw;
}
}
/// <summary>Shared indoor classification — keep DrawDynamicsLast, the
/// outside-stage assignment (#118), and the partition in lockstep.</summary>
public static bool IsIndoorCellId(uint cellId)

View file

@ -12,6 +12,7 @@ namespace AcDream.App.Rendering;
/// </summary>
public sealed class RetailPViewRenderer
{
private readonly InteriorEntityPartition.IObserver? _partitionObserver;
private readonly PortalVisibilityFrame _mainPortalFrameScratch = new();
private readonly ClipFrameAssembly _clipAssemblyScratch = new();
private readonly ViewconeCuller _viewconeScratch = new();
@ -63,6 +64,16 @@ public sealed class RetailPViewRenderer
private readonly HashSet<uint> _drawableCellsScratch = new();
private readonly RetailPViewScratchRetention _scratchRetention = new();
public RetailPViewRenderer()
{
}
internal RetailPViewRenderer(
InteriorEntityPartition.IObserver? partitionObserver)
{
_partitionObserver = partitionObserver;
}
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the
@ -156,7 +167,11 @@ public sealed class RetailPViewRenderer
passes.PrepareCellBatches(ctx, prepareCells);
InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries);
InteriorEntityPartition.Partition(
_partitionResult,
prepareCells,
ctx.LandblockEntries,
_partitionObserver);
var partition = _partitionResult;
RetailPViewFrameResult result = _frameResultScratch.Reset(
pvFrame,

View file

@ -0,0 +1,389 @@
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Scene;
internal readonly record struct RenderSceneHash128(ulong Low, ulong High)
{
public static RenderSceneHash128 Empty { get; } = new(0, 0);
public override string ToString() => $"{High:X16}{Low:X16}";
}
internal readonly record struct CurrentRenderProjectionFingerprint(
InteriorEntityPartition.ProjectionClass ProjectionClass,
uint LandblockId,
uint EntityId,
uint ServerGuid,
uint SourceId,
uint ParentCellId,
uint EffectCellId,
uint BuildingShellAnchorCellId,
uint Flags,
int MeshCount,
RenderSceneHash128 Transform,
RenderSceneHash128 Geometry,
RenderSceneHash128 Appearance);
internal readonly record struct CurrentRenderSceneOracleSnapshot(
bool Enabled,
ulong CompletedFrameSequence,
int AbortedFrames,
int ProjectionCount,
int OutdoorStaticCount,
int CellStaticCount,
int DynamicCount,
int CellBucketCount,
RenderSceneHash128 Digest)
{
public static CurrentRenderSceneOracleSnapshot Disabled { get; } = new(
Enabled: false,
CompletedFrameSequence: 0,
AbortedFrames: 0,
ProjectionCount: 0,
OutdoorStaticCount: 0,
CellStaticCount: 0,
DynamicCount: 0,
CellBucketCount: 0,
Digest: RenderSceneHash128.Empty);
}
internal interface ICurrentRenderSceneOracleSnapshotSource
{
CurrentRenderSceneOracleSnapshot Snapshot { get; }
}
/// <summary>
/// Deterministic fingerprint of the already-accepted
/// <see cref="InteriorEntityPartition"/> output. This is the Slice-F referee:
/// it records current behavior before a shadow render scene exists, and never
/// makes a draw or visibility decision itself.
///
/// Collection storage is retained across frames. Production composition does
/// not construct this owner unless lifecycle automation is enabled, so normal
/// rendering pays no hashing, sorting, or allocation cost.
/// </summary>
internal sealed class CurrentRenderSceneOracle :
InteriorEntityPartition.IObserver,
ICurrentRenderSceneOracleSnapshotSource
{
private readonly List<CurrentRenderProjectionFingerprint> _projections = [];
private ulong _frameSequence;
private int _abortedFrames;
private int _outdoorStaticCount;
private int _cellStaticCount;
private int _dynamicCount;
private bool _frameOpen;
public CurrentRenderSceneOracleSnapshot Snapshot { get; private set; } =
CurrentRenderSceneOracleSnapshot.Disabled with { Enabled = true };
internal IReadOnlyList<CurrentRenderProjectionFingerprint> Projections =>
_projections;
public void BeginFrame()
{
if (_frameOpen)
{
throw new InvalidOperationException(
"The current render-scene oracle cannot begin a second frame before completing or aborting the first.");
}
_frameOpen = true;
_projections.Clear();
_outdoorStaticCount = 0;
_cellStaticCount = 0;
_dynamicCount = 0;
}
public void Observe(
uint landblockId,
WorldEntity entity,
InteriorEntityPartition.ProjectionClass projectionClass)
{
if (!_frameOpen)
{
throw new InvalidOperationException(
"The current render-scene oracle received a projection outside an open frame.");
}
ArgumentNullException.ThrowIfNull(entity);
switch (projectionClass)
{
case InteriorEntityPartition.ProjectionClass.OutdoorStatic:
_outdoorStaticCount++;
break;
case InteriorEntityPartition.ProjectionClass.CellStatic:
_cellStaticCount++;
break;
case InteriorEntityPartition.ProjectionClass.Dynamic:
_dynamicCount++;
break;
default:
throw new ArgumentOutOfRangeException(
nameof(projectionClass),
projectionClass,
"Unknown current render projection class.");
}
_projections.Add(CreateFingerprint(
landblockId,
entity,
projectionClass));
}
public void Complete(InteriorEntityPartition.Result result)
{
ArgumentNullException.ThrowIfNull(result);
if (!_frameOpen)
{
throw new InvalidOperationException(
"The current render-scene oracle cannot complete without an open frame.");
}
int cellStaticCount = 0;
foreach (List<WorldEntity> entities in result.ByCell.Values)
cellStaticCount = checked(cellStaticCount + entities.Count);
if (result.OutdoorStatic.Count != _outdoorStaticCount
|| cellStaticCount != _cellStaticCount
|| result.Dynamics.Count != _dynamicCount
|| _projections.Count
!= checked(_outdoorStaticCount + _cellStaticCount + _dynamicCount))
{
throw new InvalidOperationException(
"The current render-scene oracle diverged from the partition it observed.");
}
_projections.Sort(CurrentRenderProjectionFingerprintComparer.Instance);
StableRenderHash128 aggregate = StableRenderHash128.Create();
aggregate.Add(_projections.Count);
foreach (CurrentRenderProjectionFingerprint projection in _projections)
AddProjection(ref aggregate, in projection);
_frameOpen = false;
Snapshot = new CurrentRenderSceneOracleSnapshot(
Enabled: true,
CompletedFrameSequence: checked(++_frameSequence),
AbortedFrames: _abortedFrames,
ProjectionCount: _projections.Count,
OutdoorStaticCount: _outdoorStaticCount,
CellStaticCount: _cellStaticCount,
DynamicCount: _dynamicCount,
CellBucketCount: result.ByCell.Count,
Digest: aggregate.Finish());
}
public void AbortFrame()
{
if (!_frameOpen)
return;
_frameOpen = false;
_projections.Clear();
_outdoorStaticCount = 0;
_cellStaticCount = 0;
_dynamicCount = 0;
_abortedFrames = checked(_abortedFrames + 1);
Snapshot = Snapshot with { AbortedFrames = _abortedFrames };
}
private static CurrentRenderProjectionFingerprint CreateFingerprint(
uint landblockId,
WorldEntity entity,
InteriorEntityPartition.ProjectionClass projectionClass)
{
StableRenderHash128 transform = StableRenderHash128.Create();
transform.Add(entity.Position);
transform.Add(entity.Rotation);
transform.Add(entity.Scale);
StableRenderHash128 geometry = StableRenderHash128.Create();
geometry.Add(entity.MeshRefs.Count);
foreach (MeshRef mesh in entity.MeshRefs)
{
geometry.Add(mesh.GfxObjId);
geometry.Add(mesh.PartTransform);
}
StableRenderHash128 appearance = StableRenderHash128.Create();
if (entity.PaletteOverride is { } palette)
{
appearance.Add(true);
appearance.Add(palette.BasePaletteId);
appearance.Add(palette.SubPalettes.Count);
foreach (PaletteOverride.SubPaletteRange range in palette.SubPalettes)
{
appearance.Add(range.SubPaletteId);
appearance.Add(range.Offset);
appearance.Add(range.Length);
}
}
else
{
appearance.Add(false);
}
appearance.Add(entity.PartOverrides.Count);
foreach (PartOverride part in entity.PartOverrides)
{
appearance.Add(part.PartIndex);
appearance.Add(part.GfxObjId);
}
appearance.Add(entity.HiddenPartsMask);
uint flags = 0;
if (entity.IsDrawVisible)
flags |= 1u << 0;
if (entity.IsAncestorDrawVisible)
flags |= 1u << 1;
if (entity.IsBuildingShell)
flags |= 1u << 2;
if (entity.ParentCellId.HasValue)
flags |= 1u << 3;
if (entity.EffectCellId.HasValue)
flags |= 1u << 4;
if (entity.BuildingShellAnchorCellId.HasValue)
flags |= 1u << 5;
return new CurrentRenderProjectionFingerprint(
ProjectionClass: projectionClass,
LandblockId: landblockId,
EntityId: entity.Id,
ServerGuid: entity.ServerGuid,
SourceId: entity.SourceGfxObjOrSetupId,
ParentCellId: entity.ParentCellId ?? 0,
EffectCellId: entity.EffectCellId ?? 0,
BuildingShellAnchorCellId: entity.BuildingShellAnchorCellId ?? 0,
Flags: flags,
MeshCount: entity.MeshRefs.Count,
Transform: transform.Finish(),
Geometry: geometry.Finish(),
Appearance: appearance.Finish());
}
private static void AddProjection(
ref StableRenderHash128 hash,
in CurrentRenderProjectionFingerprint projection)
{
hash.Add((byte)projection.ProjectionClass);
hash.Add(projection.LandblockId);
hash.Add(projection.EntityId);
hash.Add(projection.ServerGuid);
hash.Add(projection.SourceId);
hash.Add(projection.ParentCellId);
hash.Add(projection.EffectCellId);
hash.Add(projection.BuildingShellAnchorCellId);
hash.Add(projection.Flags);
hash.Add(projection.MeshCount);
hash.Add(projection.Transform.Low);
hash.Add(projection.Transform.High);
hash.Add(projection.Geometry.Low);
hash.Add(projection.Geometry.High);
hash.Add(projection.Appearance.Low);
hash.Add(projection.Appearance.High);
}
private sealed class CurrentRenderProjectionFingerprintComparer :
IComparer<CurrentRenderProjectionFingerprint>
{
public static CurrentRenderProjectionFingerprintComparer Instance { get; } =
new();
public int Compare(
CurrentRenderProjectionFingerprint x,
CurrentRenderProjectionFingerprint y)
{
int value = x.ProjectionClass.CompareTo(y.ProjectionClass);
if (value != 0) return value;
value = x.LandblockId.CompareTo(y.LandblockId);
if (value != 0) return value;
value = x.ParentCellId.CompareTo(y.ParentCellId);
if (value != 0) return value;
value = x.EntityId.CompareTo(y.EntityId);
if (value != 0) return value;
value = x.ServerGuid.CompareTo(y.ServerGuid);
if (value != 0) return value;
value = x.SourceId.CompareTo(y.SourceId);
if (value != 0) return value;
value = x.EffectCellId.CompareTo(y.EffectCellId);
if (value != 0) return value;
value = x.BuildingShellAnchorCellId.CompareTo(
y.BuildingShellAnchorCellId);
if (value != 0) return value;
value = x.Flags.CompareTo(y.Flags);
if (value != 0) return value;
value = x.MeshCount.CompareTo(y.MeshCount);
if (value != 0) return value;
value = x.Transform.High.CompareTo(y.Transform.High);
if (value != 0) return value;
value = x.Transform.Low.CompareTo(y.Transform.Low);
if (value != 0) return value;
value = x.Geometry.High.CompareTo(y.Geometry.High);
if (value != 0) return value;
value = x.Geometry.Low.CompareTo(y.Geometry.Low);
if (value != 0) return value;
value = x.Appearance.High.CompareTo(y.Appearance.High);
if (value != 0) return value;
return x.Appearance.Low.CompareTo(y.Appearance.Low);
}
}
}
internal struct StableRenderHash128
{
private const ulong LowOffset = 14695981039346656037UL;
private const ulong LowPrime = 1099511628211UL;
private const ulong HighOffset = 7809847782465536322UL;
private const ulong HighPrime = 14029467366897019727UL;
private ulong _low;
private ulong _high;
public static StableRenderHash128 Create() => new()
{
_low = LowOffset,
_high = HighOffset,
};
public readonly RenderSceneHash128 Finish() => new(_low, _high);
public void Add(bool value) => Add(value ? (byte)1 : (byte)0);
public void Add(byte value) => Add((ulong)value);
public void Add(int value) => Add(unchecked((uint)value));
public void Add(uint value) => Add((ulong)value);
public void Add(float value) => Add(BitConverter.SingleToUInt32Bits(value));
public void Add(ulong value)
{
_low = unchecked((_low ^ value) * LowPrime);
ulong folded = value ^ BitOperations.RotateLeft(value, 29);
_high = unchecked((_high ^ folded) * HighPrime);
}
public void Add(Vector3 value)
{
Add(value.X);
Add(value.Y);
Add(value.Z);
}
public void Add(Quaternion value)
{
Add(value.X);
Add(value.Y);
Add(value.Z);
Add(value.W);
}
public void Add(Matrix4x4 value)
{
Add(value.M11); Add(value.M12); Add(value.M13); Add(value.M14);
Add(value.M21); Add(value.M22); Add(value.M23); Add(value.M24);
Add(value.M31); Add(value.M32); Add(value.M33); Add(value.M34);
Add(value.M41); Add(value.M42); Add(value.M43); Add(value.M44);
}
}