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:
parent
5ecaa5612d
commit
b2b67341ac
9 changed files with 845 additions and 5 deletions
|
|
@ -12,6 +12,20 @@
|
|||
visibility, clipping, draw order, selection, lighting, animation, or
|
||||
retail-faithful gameplay
|
||||
|
||||
## 0. Execution ledger
|
||||
|
||||
| Unit | State | Evidence / rollback |
|
||||
|---|---|---|
|
||||
| F0a — partition-input referee | implemented; Release gate green | Diagnostic-only `CurrentRenderSceneOracle`; 3,685 App tests / 3 skips and 8,169 complete-solution tests / 5 skips. No production draw decision changed. |
|
||||
| F0b — survivor/dispatcher referee | active | Extends the same current-path oracle through PView survivor and dispatcher classification boundaries. |
|
||||
| F1–F5 | pending | No render-scene storage or shadow projection exists yet. |
|
||||
| G0–G5 | pending | No production consumer has switched. |
|
||||
|
||||
The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0a is
|
||||
non-drawing diagnostic infrastructure and therefore is not a visual-cutover
|
||||
rollback unit. Each later commit that changes the production draw source is
|
||||
listed in section 10 before it is offered for a connected gate.
|
||||
|
||||
## 1. Decision record
|
||||
|
||||
### 1.1 Why this work has a separate approval gate
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
389
src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs
Normal file
389
src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ using System.Text.Json;
|
|||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Residency;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.UI.Testing;
|
||||
using SixLabors.ImageSharp;
|
||||
|
|
@ -114,6 +115,18 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
{
|
||||
LoadedLandblocks = 1,
|
||||
WorldEntities = 42,
|
||||
RenderSceneOracle = new CurrentRenderSceneOracleSnapshot(
|
||||
Enabled: true,
|
||||
CompletedFrameSequence: 19,
|
||||
AbortedFrames: 1,
|
||||
ProjectionCount: 73,
|
||||
OutdoorStaticCount: 41,
|
||||
CellStaticCount: 20,
|
||||
DynamicCount: 12,
|
||||
CellBucketCount: 4,
|
||||
Digest: new RenderSceneHash128(
|
||||
Low: 0x0123456789ABCDEF,
|
||||
High: 0xFEDCBA9876543210)),
|
||||
TrackedGpuBytes = 1234,
|
||||
Residency = new ResidencySnapshot(
|
||||
[
|
||||
|
|
@ -166,6 +179,24 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
Assert.True(json.RootElement.GetProperty("render").GetProperty("presentation")
|
||||
.GetProperty("screenshotCaptured").GetBoolean());
|
||||
Assert.Equal(42, json.RootElement.GetProperty("resources").GetProperty("worldEntities").GetInt32());
|
||||
JsonElement renderSceneOracle = json.RootElement
|
||||
.GetProperty("resources")
|
||||
.GetProperty("renderSceneOracle");
|
||||
Assert.True(renderSceneOracle.GetProperty("enabled").GetBoolean());
|
||||
Assert.Equal(
|
||||
19,
|
||||
renderSceneOracle.GetProperty("completedFrameSequence").GetInt64());
|
||||
Assert.Equal(
|
||||
73,
|
||||
renderSceneOracle.GetProperty("projectionCount").GetInt32());
|
||||
Assert.Equal(
|
||||
0x0123456789ABCDEFuL,
|
||||
renderSceneOracle.GetProperty("digest").GetProperty("low")
|
||||
.GetUInt64());
|
||||
Assert.Equal(
|
||||
0xFEDCBA9876543210uL,
|
||||
renderSceneOracle.GetProperty("digest").GetProperty("high")
|
||||
.GetUInt64());
|
||||
JsonElement residency = json.RootElement
|
||||
.GetProperty("resources")
|
||||
.GetProperty("residency");
|
||||
|
|
@ -304,7 +335,8 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
directory);
|
||||
|
||||
private static WorldLifecycleResourceSnapshot EmptyResources() => new(
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, CurrentRenderSceneOracleSnapshot.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,
|
||||
default, default, 0d, 0d, null);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class CurrentRenderSceneOracleTests
|
||||
{
|
||||
private const uint LandblockA = 0xA9B4FFFF;
|
||||
private const uint LandblockB = 0xA9B5FFFF;
|
||||
private const uint CellA = 0xA9B40170;
|
||||
private const uint CellB = 0xA9B50170;
|
||||
|
||||
[Fact]
|
||||
public void DigestIsStableAcrossLandblockEnumerationOrder()
|
||||
{
|
||||
WorldEntity outdoorA = Entity(id: 7, serverGuid: 0, parentCell: null);
|
||||
WorldEntity cellStatic = Entity(id: 8, serverGuid: 0, parentCell: CellA);
|
||||
WorldEntity live = Entity(
|
||||
id: 1_000_001,
|
||||
serverGuid: 0x8000_0001,
|
||||
parentCell: CellA);
|
||||
// Static IDs are landblock-local. The same id in another landblock is
|
||||
// a distinct projection and must survive the referee sort.
|
||||
WorldEntity outdoorB = Entity(id: 7, serverGuid: 0, parentCell: null);
|
||||
|
||||
var firstEntries = new[]
|
||||
{
|
||||
Entry(LandblockA, outdoorA, cellStatic, live),
|
||||
Entry(LandblockB, outdoorB),
|
||||
};
|
||||
var secondEntries = new[]
|
||||
{
|
||||
Entry(LandblockB, outdoorB),
|
||||
Entry(LandblockA, outdoorA, cellStatic, live),
|
||||
};
|
||||
var visible = new HashSet<uint> { CellA };
|
||||
var result = new InteriorEntityPartition.Result();
|
||||
var oracle = new CurrentRenderSceneOracle();
|
||||
|
||||
InteriorEntityPartition.Partition(
|
||||
result,
|
||||
visible,
|
||||
firstEntries,
|
||||
oracle);
|
||||
CurrentRenderSceneOracleSnapshot first = oracle.Snapshot;
|
||||
|
||||
InteriorEntityPartition.Partition(
|
||||
result,
|
||||
visible,
|
||||
secondEntries,
|
||||
oracle);
|
||||
CurrentRenderSceneOracleSnapshot second = oracle.Snapshot;
|
||||
|
||||
Assert.True(first.Enabled);
|
||||
Assert.Equal(4, first.ProjectionCount);
|
||||
Assert.Equal(2, first.OutdoorStaticCount);
|
||||
Assert.Equal(1, first.CellStaticCount);
|
||||
Assert.Equal(1, first.DynamicCount);
|
||||
Assert.Equal(1, first.CellBucketCount);
|
||||
Assert.Equal(first.Digest, second.Digest);
|
||||
Assert.Equal(first.CompletedFrameSequence + 1, second.CompletedFrameSequence);
|
||||
Assert.Equal(2, oracle.Projections.Count(
|
||||
projection => projection.EntityId == 7));
|
||||
Assert.Contains(
|
||||
oracle.Projections,
|
||||
projection => projection.EntityId == 7
|
||||
&& projection.LandblockId == LandblockA);
|
||||
Assert.Contains(
|
||||
oracle.Projections,
|
||||
projection => projection.EntityId == 7
|
||||
&& projection.LandblockId == LandblockB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObservedPartitionMatchesUninstrumentedProductionPath()
|
||||
{
|
||||
WorldEntity outdoor = Entity(id: 10, serverGuid: 0, parentCell: null);
|
||||
WorldEntity cellStatic = Entity(id: 11, serverGuid: 0, parentCell: CellA);
|
||||
WorldEntity dynamic = Entity(
|
||||
id: 1_000_011,
|
||||
serverGuid: 0x8000_0011,
|
||||
parentCell: CellB);
|
||||
WorldEntity hiddenStatic = Entity(
|
||||
id: 12,
|
||||
serverGuid: 0,
|
||||
parentCell: CellB);
|
||||
var entries = new[]
|
||||
{
|
||||
Entry(LandblockA, outdoor, cellStatic),
|
||||
Entry(LandblockB, dynamic, hiddenStatic),
|
||||
};
|
||||
var visible = new HashSet<uint> { CellA };
|
||||
var production = new InteriorEntityPartition.Result();
|
||||
var observed = new InteriorEntityPartition.Result();
|
||||
var oracle = new CurrentRenderSceneOracle();
|
||||
|
||||
InteriorEntityPartition.Partition(production, visible, entries);
|
||||
InteriorEntityPartition.Partition(observed, visible, entries, oracle);
|
||||
|
||||
Assert.Equal(production.ByCell.Keys, observed.ByCell.Keys);
|
||||
foreach (uint cell in production.ByCell.Keys)
|
||||
Assert.Equal(production.ByCell[cell], observed.ByCell[cell]);
|
||||
Assert.Equal(production.OutdoorStatic, observed.OutdoorStatic);
|
||||
Assert.Equal(production.Dynamics, observed.Dynamics);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FingerprintsNameTransformGeometryAppearanceAndFlagChanges()
|
||||
{
|
||||
var visible = new HashSet<uint> { CellA, CellB };
|
||||
|
||||
CurrentRenderProjectionFingerprint baseline = CaptureSingle(
|
||||
Entity(id: 20, serverGuid: 0, parentCell: CellA),
|
||||
LandblockA,
|
||||
visible);
|
||||
CurrentRenderProjectionFingerprint transform = CaptureSingle(
|
||||
Entity(
|
||||
id: 20,
|
||||
serverGuid: 0,
|
||||
parentCell: CellA,
|
||||
position: new Vector3(1, 2, 3)),
|
||||
LandblockA,
|
||||
visible);
|
||||
CurrentRenderProjectionFingerprint geometry = CaptureSingle(
|
||||
Entity(
|
||||
id: 20,
|
||||
serverGuid: 0,
|
||||
parentCell: CellA,
|
||||
meshId: 0x0100_2222),
|
||||
LandblockA,
|
||||
visible);
|
||||
CurrentRenderProjectionFingerprint appearance = CaptureSingle(
|
||||
Entity(
|
||||
id: 20,
|
||||
serverGuid: 0,
|
||||
parentCell: CellA,
|
||||
palette: new PaletteOverride(
|
||||
0x0400_0001,
|
||||
[new PaletteOverride.SubPaletteRange(0x0400_0002, 3, 4)])),
|
||||
LandblockA,
|
||||
visible);
|
||||
WorldEntity hidden = Entity(id: 20, serverGuid: 0, parentCell: CellA);
|
||||
hidden.IsDrawVisible = false;
|
||||
CurrentRenderProjectionFingerprint flags = CaptureSingle(
|
||||
hidden,
|
||||
LandblockA,
|
||||
visible);
|
||||
CurrentRenderProjectionFingerprint cell = CaptureSingle(
|
||||
Entity(id: 20, serverGuid: 0, parentCell: CellB),
|
||||
LandblockB,
|
||||
visible);
|
||||
|
||||
Assert.NotEqual(baseline.Transform, transform.Transform);
|
||||
Assert.Equal(baseline.Geometry, transform.Geometry);
|
||||
Assert.Equal(baseline.Appearance, transform.Appearance);
|
||||
|
||||
Assert.NotEqual(baseline.Geometry, geometry.Geometry);
|
||||
Assert.Equal(baseline.Transform, geometry.Transform);
|
||||
|
||||
Assert.NotEqual(baseline.Appearance, appearance.Appearance);
|
||||
Assert.NotEqual(baseline.Flags, flags.Flags);
|
||||
Assert.NotEqual(baseline.ParentCellId, cell.ParentCellId);
|
||||
Assert.NotEqual(baseline.LandblockId, cell.LandblockId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonFloodedStaticIsAbsentButNonFloodedDynamicRemains()
|
||||
{
|
||||
WorldEntity hiddenStatic = Entity(
|
||||
id: 30,
|
||||
serverGuid: 0,
|
||||
parentCell: CellB);
|
||||
WorldEntity hiddenDynamic = Entity(
|
||||
id: 1_000_030,
|
||||
serverGuid: 0x8000_0030,
|
||||
parentCell: CellB);
|
||||
var oracle = new CurrentRenderSceneOracle();
|
||||
var result = new InteriorEntityPartition.Result();
|
||||
|
||||
InteriorEntityPartition.Partition(
|
||||
result,
|
||||
new HashSet<uint> { CellA },
|
||||
new[] { Entry(LandblockB, hiddenStatic, hiddenDynamic) },
|
||||
oracle);
|
||||
|
||||
Assert.Equal(1, oracle.Snapshot.ProjectionCount);
|
||||
Assert.Equal(0, oracle.Snapshot.CellStaticCount);
|
||||
Assert.Equal(1, oracle.Snapshot.DynamicCount);
|
||||
CurrentRenderProjectionFingerprint fingerprint =
|
||||
Assert.Single(oracle.Projections);
|
||||
Assert.Equal(hiddenDynamic.Id, fingerprint.EntityId);
|
||||
Assert.Equal(
|
||||
InteriorEntityPartition.ProjectionClass.Dynamic,
|
||||
fingerprint.ProjectionClass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbortPreservesLastCompleteDigestAndRecordsFailure()
|
||||
{
|
||||
var oracle = new CurrentRenderSceneOracle();
|
||||
var result = new InteriorEntityPartition.Result();
|
||||
WorldEntity entity = Entity(id: 40, serverGuid: 0, parentCell: null);
|
||||
|
||||
InteriorEntityPartition.Partition(
|
||||
result,
|
||||
[],
|
||||
new[] { Entry(LandblockA, entity) },
|
||||
oracle);
|
||||
CurrentRenderSceneOracleSnapshot complete = oracle.Snapshot;
|
||||
|
||||
oracle.BeginFrame();
|
||||
oracle.Observe(
|
||||
LandblockA,
|
||||
entity,
|
||||
InteriorEntityPartition.ProjectionClass.OutdoorStatic);
|
||||
oracle.AbortFrame();
|
||||
|
||||
Assert.Equal(complete.Digest, oracle.Snapshot.Digest);
|
||||
Assert.Equal(
|
||||
complete.CompletedFrameSequence,
|
||||
oracle.Snapshot.CompletedFrameSequence);
|
||||
Assert.Equal(complete.AbortedFrames + 1, oracle.Snapshot.AbortedFrames);
|
||||
Assert.Empty(oracle.Projections);
|
||||
}
|
||||
|
||||
private static CurrentRenderProjectionFingerprint CaptureSingle(
|
||||
WorldEntity entity,
|
||||
uint landblockId,
|
||||
HashSet<uint> visible)
|
||||
{
|
||||
var oracle = new CurrentRenderSceneOracle();
|
||||
var result = new InteriorEntityPartition.Result();
|
||||
InteriorEntityPartition.Partition(
|
||||
result,
|
||||
visible,
|
||||
new[] { Entry(landblockId, entity) },
|
||||
oracle);
|
||||
return Assert.Single(oracle.Projections);
|
||||
}
|
||||
|
||||
private static WorldEntity Entity(
|
||||
uint id,
|
||||
uint serverGuid,
|
||||
uint? parentCell,
|
||||
Vector3? position = null,
|
||||
uint meshId = 0x0100_0001,
|
||||
PaletteOverride? palette = null) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = meshId,
|
||||
Position = position ?? Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs =
|
||||
[
|
||||
new MeshRef(
|
||||
meshId,
|
||||
Matrix4x4.CreateTranslation(0.25f, 0.5f, 0.75f)),
|
||||
],
|
||||
ParentCellId = parentCell,
|
||||
PaletteOverride = palette,
|
||||
};
|
||||
|
||||
private static (
|
||||
uint LandblockId,
|
||||
Vector3 AabbMin,
|
||||
Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)
|
||||
Entry(uint landblockId, params WorldEntity[] entities) =>
|
||||
(
|
||||
landblockId,
|
||||
Vector3.Zero,
|
||||
Vector3.One,
|
||||
entities,
|
||||
null);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue