refactor(render): delete legacy pview execution machinery
This commit is contained in:
parent
1be4889c1c
commit
69e69408e8
9 changed files with 58 additions and 3627 deletions
|
|
@ -494,13 +494,18 @@ internal sealed class FrameRootCompositionPhase
|
|||
d.ParticleVisibility,
|
||||
new WorldScenePViewRenderer(
|
||||
new RetailPViewRenderer(
|
||||
currentRenderSceneOracle,
|
||||
live.RenderSceneShadow,
|
||||
live.RenderSceneShadow
|
||||
?? throw new InvalidOperationException(
|
||||
"The retail frame walk requires the retained render scene."),
|
||||
// Campaign FW3.2b-2: the walk's production world
|
||||
// data, published/retired with each landblock by the
|
||||
// render publisher (FW3.1).
|
||||
live.LandblockPipeline.RenderPublisher?.WalkBuildings,
|
||||
live.LandblockPipeline.RenderPublisher?.WalkLandscape,
|
||||
live.LandblockPipeline.RenderPublisher?.WalkBuildings
|
||||
?? throw new InvalidOperationException(
|
||||
"The retail frame walk requires the building registry."),
|
||||
live.LandblockPipeline.RenderPublisher?.WalkLandscape
|
||||
?? throw new InvalidOperationException(
|
||||
"The retail frame walk requires the landscape registry."),
|
||||
d.CellVisibility),
|
||||
retailPViewPassExecutor,
|
||||
retailPViewPassExecutor),
|
||||
|
|
|
|||
|
|
@ -86,37 +86,12 @@ internal interface IOutdoorSceneParticleOwnerSource
|
|||
IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; }
|
||||
}
|
||||
|
||||
internal readonly record struct RenderFrameEntityDrawRequest(
|
||||
RenderFrameView View,
|
||||
RenderFrameCandidateRoute Route,
|
||||
int RouteIndex,
|
||||
uint CellId,
|
||||
uint TupleLandblockId);
|
||||
|
||||
internal interface IRenderFrameEntityPassExecutor
|
||||
{
|
||||
void BeginEntityFrame(in RenderFrameView view);
|
||||
|
||||
bool DrawEntityRoute(
|
||||
ICamera camera,
|
||||
in RenderFrameView view,
|
||||
RenderFrameCandidateRoute route,
|
||||
int routeIndex,
|
||||
uint cellId,
|
||||
uint tupleLandblockId);
|
||||
|
||||
void CompleteEntityFrame(in RenderFrameView view);
|
||||
|
||||
void AbortEntityFrame();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6j: backend-neutral. The order it implements is retail's and
|
||||
/// is written once; the four places it touches graphics state directly are owned
|
||||
/// by <see cref="IWorldPassSurface"/>.
|
||||
/// </summary>
|
||||
internal sealed partial class RetailPViewPassExecutor :
|
||||
IRetailPViewPassExecutor,
|
||||
IOutdoorSceneParticleOwnerSource
|
||||
{
|
||||
private readonly IWorldPassSurface _surface;
|
||||
|
|
@ -134,15 +109,9 @@ internal sealed partial class RetailPViewPassExecutor :
|
|||
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
|
||||
private readonly RetailPViewParticleClassifications _particleClassifications = new();
|
||||
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
|
||||
private readonly Dictionary<uint, int> _singleCellClipRouting = new(1);
|
||||
private readonly Dictionary<uint, int> _noCellClipRouting = new(0);
|
||||
|
||||
// ACDREAM_PROBE_WALK_ROOT / cathedral FW4: observation-only counters.
|
||||
// They distinguish the walk-owned far-Z portal punches from the retired
|
||||
// legacy look-in punch route. RetailPViewRenderer samples them for the
|
||||
// diagnostic comparison after the walk-turn look-in routes have replayed.
|
||||
// ACDREAM_PROBE_WALK_ROOT / cathedral FW4: observation-only counter for
|
||||
// the walk-owned far-Z portal punches.
|
||||
internal int WalkLookInPunchCountThisFrame { get; private set; }
|
||||
internal int LegacyLookInPunchCountThisFrame { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Borrowed until the next late landscape pass. The outdoor-root post-world
|
||||
|
|
@ -190,7 +159,6 @@ internal sealed partial class RetailPViewPassExecutor :
|
|||
{
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.WalkRootPhase = "pre";
|
||||
WalkLookInPunchCountThisFrame = 0;
|
||||
LegacyLookInPunchCountThisFrame = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -242,11 +210,6 @@ internal sealed partial class RetailPViewPassExecutor :
|
|||
ClipFrameAssembly reuseAssembly) =>
|
||||
ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly);
|
||||
|
||||
public void AppendLookInClipFrames(
|
||||
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
|
||||
ClipFrameAssembly assembly) =>
|
||||
ClipFrameAssembler.AppendLookInFrames(_clipFrame, lookInFrames, assembly);
|
||||
|
||||
public void PrepareClipFrame(int terrainUploadCount) =>
|
||||
_surface.PrepareClipFrame(terrainUploadCount);
|
||||
|
||||
|
|
@ -263,24 +226,6 @@ internal sealed partial class RetailPViewPassExecutor :
|
|||
_entities.ClearClipRouting();
|
||||
}
|
||||
|
||||
public void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice)
|
||||
{
|
||||
_singleCellClipRouting.Clear();
|
||||
_singleCellClipRouting.Add(cellId, slice.Slot);
|
||||
_envCells.SetClipRouting(_singleCellClipRouting);
|
||||
// Retail DrawMesh only viewcone-checks an object's sphere under the
|
||||
// installed PortalList and then draws the mesh whole. Hard clipping the
|
||||
// object here slices a stationary player when the chase camera crosses
|
||||
// into the opposite cathedral cell while the player remains behind.
|
||||
_entities.ClearClipRouting();
|
||||
}
|
||||
|
||||
private void UseOutdoorPortalViewRouting(ClipViewSlice slice) =>
|
||||
_entities.SetClipRouting(
|
||||
_noCellClipRouting,
|
||||
outdoorSlot: slice.Slot,
|
||||
outdoorVisible: true);
|
||||
|
||||
public void PrepareCellBatches(
|
||||
RetailPViewFrameInput frame,
|
||||
HashSet<uint> visibleCellIds) =>
|
||||
|
|
@ -298,112 +243,9 @@ internal sealed partial class RetailPViewPassExecutor :
|
|||
public bool CellHasTransparentShell(uint cellId) =>
|
||||
_envCells.CellHasTransparent(cellId);
|
||||
|
||||
public void DrawTransparentCellShells(HashSet<uint> cellIds) =>
|
||||
_envCells.Render(WbRenderPass.Transparent, cellIds);
|
||||
|
||||
public void DrawTransparentCellShellsOrdered(IReadOnlyList<uint> cellIds) =>
|
||||
_envCells.RenderTransparentOrdered(cellIds);
|
||||
|
||||
public void DrawEntityBucket(
|
||||
RetailPViewFrameInput frame,
|
||||
IReadOnlyList<WorldEntity> entities,
|
||||
HashSet<uint>? visibleCellIds)
|
||||
{
|
||||
uint landblockId = frame.PlayerLandblockId ?? 0u;
|
||||
var entry = (
|
||||
landblockId,
|
||||
Vector3.Zero,
|
||||
Vector3.Zero,
|
||||
entities,
|
||||
(IReadOnlyDictionary<uint, WorldEntity>?)null);
|
||||
|
||||
_entities.Draw(
|
||||
frame.Camera,
|
||||
new[] { entry },
|
||||
frame.Frustum,
|
||||
neverCullLandblockId: frame.PlayerLandblockId,
|
||||
visibleCellIds: visibleCellIds,
|
||||
animatedEntityIds: frame.AnimatedEntityIds);
|
||||
}
|
||||
|
||||
public void EmitClipRouteProbe(
|
||||
ClipFrameAssembly clipAssembly,
|
||||
ClipViewSlice slice,
|
||||
int sliceIndex) =>
|
||||
_diagnostics.EmitClipRouteProbe(
|
||||
RenderingDiagnostics.ProbeClipRouteEnabled,
|
||||
_clipFrame,
|
||||
clipAssembly,
|
||||
slice,
|
||||
sliceIndex);
|
||||
|
||||
public void DrawLandscapeSlice(
|
||||
RetailPViewFrameInput frame,
|
||||
RetailPViewLandscapeSliceContext context)
|
||||
{
|
||||
ClipViewSlice slice = context.Slice;
|
||||
bool scissor = BeginDoorwayScissor(slice.NdcAabb);
|
||||
_diagnostics.EmitClipRouteScissorProbe(
|
||||
RenderingDiagnostics.ProbeClipRouteEnabled,
|
||||
scissor,
|
||||
slice.NdcAabb);
|
||||
|
||||
_surface.BindTerrainClip();
|
||||
EnableClipDistances();
|
||||
if (frame.RenderSky)
|
||||
{
|
||||
_sky?.RenderSky(
|
||||
frame.Camera,
|
||||
frame.CameraWorldPosition,
|
||||
frame.DayFraction,
|
||||
frame.ActiveDayGroup,
|
||||
frame.SkyKeyframe,
|
||||
frame.EnvironOverrideActive);
|
||||
}
|
||||
|
||||
DisableClipDistances();
|
||||
if (frame.RenderSky && _particles is not null && _particleRenderer is not null)
|
||||
{
|
||||
_particleRenderer.Draw(
|
||||
frame.Camera,
|
||||
frame.CameraWorldPosition,
|
||||
ParticleRenderPass.SkyPreScene);
|
||||
}
|
||||
|
||||
EnableClipDistances();
|
||||
_terrainDiagnostics.Begin();
|
||||
_terrain?.Draw(
|
||||
frame.Camera,
|
||||
frame.Frustum,
|
||||
neverCullLandblockId: frame.PlayerLandblockId,
|
||||
clipPlanes: slice.Planes,
|
||||
ndcClipAabb: slice.NdcAabb);
|
||||
_terrainDiagnostics.Complete();
|
||||
|
||||
DisableClipDistances();
|
||||
if (context.OutdoorEntities.Count > 0)
|
||||
{
|
||||
var sceneryEntry = (
|
||||
frame.PlayerLandblockId ?? 0u,
|
||||
Vector3.Zero,
|
||||
Vector3.Zero,
|
||||
context.OutdoorEntities,
|
||||
(IReadOnlyDictionary<uint, WorldEntity>?)null);
|
||||
_entities.Draw(
|
||||
frame.Camera,
|
||||
new[] { sceneryEntry },
|
||||
frame.Frustum,
|
||||
neverCullLandblockId: frame.PlayerLandblockId,
|
||||
visibleCellIds: null,
|
||||
animatedEntityIds: frame.AnimatedEntityIds);
|
||||
}
|
||||
|
||||
if (scissor)
|
||||
_surface.EndScissor();
|
||||
_entities.ClearClipRouting();
|
||||
DisableClipDistances();
|
||||
}
|
||||
|
||||
public void DrawLandscapeSliceLate(
|
||||
RetailPViewFrameInput frame,
|
||||
RetailPViewLandscapeLateSliceContext context)
|
||||
|
|
@ -503,38 +345,6 @@ internal sealed partial class RetailPViewPassExecutor :
|
|||
DisableClipDistances();
|
||||
}
|
||||
|
||||
public void DrawLandscapeBuildingShellSlice(
|
||||
RetailPViewFrameInput frame,
|
||||
RetailPViewLandscapeBuildingShellSliceContext context)
|
||||
{
|
||||
UseOutdoorPortalViewRouting(context.Slice);
|
||||
bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb);
|
||||
_surface.BindTerrainClip();
|
||||
DisableClipDistances();
|
||||
|
||||
if (context.BuildingShells.Count > 0)
|
||||
{
|
||||
var buildingEntry = (
|
||||
frame.PlayerLandblockId ?? 0u,
|
||||
Vector3.Zero,
|
||||
Vector3.Zero,
|
||||
context.BuildingShells,
|
||||
(IReadOnlyDictionary<uint, WorldEntity>?)null);
|
||||
_entities.Draw(
|
||||
frame.Camera,
|
||||
new[] { buildingEntry },
|
||||
frame.Frustum,
|
||||
neverCullLandblockId: frame.PlayerLandblockId,
|
||||
visibleCellIds: null,
|
||||
animatedEntityIds: frame.AnimatedEntityIds);
|
||||
}
|
||||
|
||||
if (scissor)
|
||||
_surface.EndScissor();
|
||||
_entities.ClearClipRouting();
|
||||
DisableClipDistances();
|
||||
}
|
||||
|
||||
public void ClearInteriorDepth()
|
||||
{
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled)
|
||||
|
|
@ -547,23 +357,6 @@ internal sealed partial class RetailPViewPassExecutor :
|
|||
RetailPViewCellSliceContext context) =>
|
||||
DrawPortalDepthWrite(context, frame, forceFarZ: frame.RootCell.IsOutdoorNode);
|
||||
|
||||
public void DrawLookInPortalPunch(
|
||||
RetailPViewFrameInput frame,
|
||||
RetailPViewCellSliceContext context,
|
||||
int portalIndex)
|
||||
{
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled)
|
||||
{
|
||||
LegacyLookInPunchCountThisFrame++;
|
||||
Console.WriteLine(
|
||||
$"[lookin-punch] source=legacy viewer=0x{frame.ViewerCellId:X8} "
|
||||
+ $"root=0x{frame.RootCell.CellId:X8} cell=0x{context.CellId:X8} "
|
||||
+ $"portal={portalIndex} planes={context.Slice.Planes.Length} "
|
||||
+ $"phase={AcDream.Core.Rendering.RenderingDiagnostics.WalkRootPhase}");
|
||||
}
|
||||
DrawPortalDepthWrite(context, frame, forceFarZ: true, portalIndex);
|
||||
}
|
||||
|
||||
public void DrawUnattachedSceneParticles(
|
||||
RetailPViewFrameInput frame,
|
||||
bool outdoorCells)
|
||||
|
|
@ -643,9 +436,6 @@ internal sealed partial class RetailPViewPassExecutor :
|
|||
|
||||
public void FlushLandscapeAlpha() => _alpha.Flush();
|
||||
|
||||
public void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
|
||||
_alpha.FlushFartherThan(minViewerDistance);
|
||||
|
||||
public void DrawCellParticles(
|
||||
RetailPViewFrameInput frame,
|
||||
RetailPViewCellSliceContext context)
|
||||
|
|
@ -676,29 +466,6 @@ internal sealed partial class RetailPViewPassExecutor :
|
|||
DisableClipDistances();
|
||||
}
|
||||
|
||||
public void DrawDynamicsParticles(
|
||||
RetailPViewFrameInput frame,
|
||||
IReadOnlySet<uint> ownerIds)
|
||||
{
|
||||
if (_particles is null || _particleRenderer is null || ownerIds.Count == 0)
|
||||
return;
|
||||
|
||||
HashSet<uint> dynamics = _particleClassifications.Dynamics;
|
||||
dynamics.Clear();
|
||||
dynamics.UnionWith(ownerIds);
|
||||
if (dynamics.Count == 0)
|
||||
return;
|
||||
|
||||
DisableClipDistances();
|
||||
ProbeWalkParticleRoute("dyn-owners", dynamics);
|
||||
_particleRenderer.DrawForOwners(
|
||||
frame.Camera,
|
||||
frame.CameraWorldPosition,
|
||||
ParticleRenderPass.Scene,
|
||||
dynamics);
|
||||
DisableClipDistances();
|
||||
}
|
||||
|
||||
public void EmitDiagnostics(
|
||||
RetailPViewFrameInput frame,
|
||||
RetailPViewFrameResult result) =>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -30,8 +30,7 @@ namespace AcDream.App.Rendering.Walk;
|
|||
/// </list>
|
||||
///
|
||||
/// The tuple landblock id handed to the classifier is the frame's player
|
||||
/// landblock — the packed path's own convention for every
|
||||
/// <c>RenderFrameEntityDrawRequest</c>.
|
||||
/// landblock, matching the production walk's retained-scene query convention.
|
||||
///
|
||||
/// <para>Campaign FW3.4a: <see cref="GetCellStatics"/>, <see cref="GetOutdoorStatics"/>,
|
||||
/// and <see cref="GetBuildingShellStatics"/> used to materialize their result
|
||||
|
|
|
|||
|
|
@ -41,12 +41,12 @@ internal interface IPreparedWorldSceneFramePhase : IWorldSceneFramePhase
|
|||
internal sealed class WorldScenePViewRenderer : IWorldScenePViewRenderer
|
||||
{
|
||||
private readonly RetailPViewRenderer _renderer;
|
||||
private readonly IRetailPViewPassExecutor _passes;
|
||||
private readonly RetailPViewPassExecutor _passes;
|
||||
private readonly IOutdoorSceneParticleOwnerSource _particles;
|
||||
|
||||
public WorldScenePViewRenderer(
|
||||
RetailPViewRenderer renderer,
|
||||
IRetailPViewPassExecutor passes,
|
||||
RetailPViewPassExecutor passes,
|
||||
IOutdoorSceneParticleOwnerSource particles)
|
||||
{
|
||||
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
|
||||
|
|
|
|||
|
|
@ -1,252 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class BuildingGroupScratchTests
|
||||
{
|
||||
[Fact]
|
||||
public void Rebuild_DropsHistoricalKeys_BoundsRetention_AndPreservesEncounterOrder()
|
||||
{
|
||||
var scratch = new BuildingGroupScratch();
|
||||
int pathologicalCount = BuildingGroupScratch.MaxRetainedGroups * 4;
|
||||
var pathological = new List<LoadedCell>(pathologicalCount);
|
||||
for (int i = 0; i < pathologicalCount; i++)
|
||||
{
|
||||
pathological.Add(new LoadedCell
|
||||
{
|
||||
CellId = (uint)i + 1u,
|
||||
BuildingId = 0x1000_0000u + (uint)i,
|
||||
});
|
||||
}
|
||||
|
||||
scratch.Rebuild(pathological);
|
||||
Assert.Equal(pathologicalCount, scratch.ActiveGroupCount);
|
||||
Assert.True(scratch.MapCapacity > BuildingGroupScratch.MaxRetainedGroups);
|
||||
|
||||
var firstA = new LoadedCell { CellId = 0xAA01u, BuildingId = 0x20u };
|
||||
var second = new LoadedCell { CellId = 0xBB01u, BuildingId = 0x10u };
|
||||
var firstB = new LoadedCell { CellId = 0xAA02u, BuildingId = 0x20u };
|
||||
var unstamped = new LoadedCell { CellId = 0xCC01u };
|
||||
scratch.Rebuild([firstA, second, firstB, unstamped]);
|
||||
|
||||
Assert.Equal(3, scratch.ActiveGroupCount);
|
||||
Assert.Equal(
|
||||
new uint[] { 0x20u, 0x10u, unstamped.CellId },
|
||||
scratch.Groups.Keys.ToArray());
|
||||
Assert.Equal(new[] { firstA, firstB }, scratch.Groups[0x20u]);
|
||||
Assert.Equal(new[] { second }, scratch.Groups[0x10u]);
|
||||
Assert.Equal(new[] { unstamped }, scratch.Groups[unstamped.CellId]);
|
||||
Assert.DoesNotContain(
|
||||
scratch.Groups.Keys,
|
||||
key => key >= 0x1000_0000u);
|
||||
Assert.InRange(
|
||||
scratch.RetainedListCount,
|
||||
0,
|
||||
BuildingGroupScratch.MaxRetainedGroups);
|
||||
Assert.InRange(
|
||||
scratch.MapCapacity,
|
||||
0,
|
||||
BuildingGroupScratch.MaxRetainedGroups);
|
||||
|
||||
scratch.Reset();
|
||||
|
||||
Assert.Equal(0, scratch.ActiveGroupCount);
|
||||
Assert.Empty(scratch.Groups);
|
||||
Assert.InRange(
|
||||
scratch.RetainedListCount,
|
||||
0,
|
||||
BuildingGroupScratch.MaxRetainedGroups);
|
||||
Assert.InRange(
|
||||
scratch.MapCapacity,
|
||||
0,
|
||||
BuildingGroupScratch.MaxRetainedGroups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_DoesNotRetainPathologicalGroupBackingArray()
|
||||
{
|
||||
var scratch = new BuildingGroupScratch();
|
||||
var oversizedGroup = new List<LoadedCell>(
|
||||
BuildingGroupScratch.MaxRetainedCellsPerGroup * 2);
|
||||
for (int i = 0;
|
||||
i < BuildingGroupScratch.MaxRetainedCellsPerGroup * 2;
|
||||
i++)
|
||||
{
|
||||
oversizedGroup.Add(new LoadedCell
|
||||
{
|
||||
CellId = (uint)i + 1u,
|
||||
BuildingId = 0x42u,
|
||||
});
|
||||
}
|
||||
|
||||
scratch.Rebuild(oversizedGroup);
|
||||
Assert.Single(scratch.Groups);
|
||||
Assert.True(
|
||||
scratch.Groups[0x42u].Capacity
|
||||
> BuildingGroupScratch.MaxRetainedCellsPerGroup);
|
||||
|
||||
scratch.Reset();
|
||||
|
||||
Assert.Equal(0, scratch.RetainedListCount);
|
||||
Assert.Empty(scratch.Groups);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RetailPViewScratchRetentionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ClearFrameBuffers_DropsPathologicalHighWater_AfterIdleHysteresis()
|
||||
{
|
||||
var retention = new RetailPViewScratchRetention();
|
||||
int pathologicalCellCount =
|
||||
RetailPViewScratchRetention.MaxRetainedCellItems * 4;
|
||||
int pathologicalFrameCount =
|
||||
RetailPViewScratchRetention.MaxRetainedLookInFrames * 4;
|
||||
var lookInFrames = new List<PortalVisibilityFrame>(pathologicalFrameCount);
|
||||
var lookInPrepare = new HashSet<uint>();
|
||||
var drawableCells = new HashSet<uint>();
|
||||
var shellBatch = new HashSet<uint>();
|
||||
var orderedTransparent = new List<uint>(pathologicalCellCount);
|
||||
|
||||
for (int i = 0; i < pathologicalFrameCount; i++)
|
||||
lookInFrames.Add(new PortalVisibilityFrame());
|
||||
for (int i = 0; i < pathologicalCellCount; i++)
|
||||
{
|
||||
uint id = (uint)i;
|
||||
lookInPrepare.Add(id);
|
||||
drawableCells.Add(id);
|
||||
shellBatch.Add(id);
|
||||
orderedTransparent.Add(id);
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
lookInFrames.Capacity
|
||||
> RetailPViewScratchRetention.MaxRetainedLookInFrames);
|
||||
Assert.True(
|
||||
drawableCells.EnsureCapacity(0)
|
||||
> RetailPViewScratchRetention.MaxRetainedCellItems);
|
||||
|
||||
retention.ClearFrameBuffers(
|
||||
lookInFrames,
|
||||
lookInPrepare,
|
||||
drawableCells,
|
||||
shellBatch,
|
||||
orderedTransparent);
|
||||
|
||||
Assert.Empty(lookInFrames);
|
||||
Assert.Empty(lookInPrepare);
|
||||
Assert.Empty(drawableCells);
|
||||
Assert.Empty(shellBatch);
|
||||
Assert.Empty(orderedTransparent);
|
||||
Assert.True(
|
||||
lookInFrames.Capacity
|
||||
> RetailPViewScratchRetention.MaxRetainedLookInFrames);
|
||||
|
||||
for (int i = 0; i < RetailPViewScratchRetention.CapacityTrimIdleFrames; i++)
|
||||
{
|
||||
retention.ClearFrameBuffers(
|
||||
lookInFrames,
|
||||
lookInPrepare,
|
||||
drawableCells,
|
||||
shellBatch,
|
||||
orderedTransparent);
|
||||
}
|
||||
|
||||
Assert.InRange(
|
||||
lookInFrames.Capacity,
|
||||
0,
|
||||
RetailPViewScratchRetention.MaxRetainedLookInFrames);
|
||||
Assert.InRange(
|
||||
lookInPrepare.EnsureCapacity(0),
|
||||
0,
|
||||
RetailPViewScratchRetention.MaxRetainedCellItems);
|
||||
Assert.InRange(
|
||||
drawableCells.EnsureCapacity(0),
|
||||
0,
|
||||
RetailPViewScratchRetention.MaxRetainedCellItems);
|
||||
Assert.InRange(
|
||||
shellBatch.EnsureCapacity(0),
|
||||
0,
|
||||
RetailPViewScratchRetention.MaxRetainedCellItems);
|
||||
Assert.InRange(
|
||||
orderedTransparent.Capacity,
|
||||
0,
|
||||
RetailPViewScratchRetention.MaxRetainedCellItems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearFrameBuffers_PreservesNormalWarmCapacity()
|
||||
{
|
||||
var retention = new RetailPViewScratchRetention();
|
||||
var lookInFrames = new List<PortalVisibilityFrame>(8);
|
||||
var lookInPrepare = new HashSet<uint>();
|
||||
var drawableCells = new HashSet<uint>();
|
||||
var shellBatch = new HashSet<uint>();
|
||||
var orderedTransparent = new List<uint>(64);
|
||||
for (uint id = 0; id < 64; id++)
|
||||
{
|
||||
lookInPrepare.Add(id);
|
||||
drawableCells.Add(id);
|
||||
shellBatch.Add(id);
|
||||
orderedTransparent.Add(id);
|
||||
}
|
||||
int lookInCapacity = lookInFrames.Capacity;
|
||||
int prepareCapacity = lookInPrepare.EnsureCapacity(0);
|
||||
int drawableCapacity = drawableCells.EnsureCapacity(0);
|
||||
int shellCapacity = shellBatch.EnsureCapacity(0);
|
||||
int transparentCapacity = orderedTransparent.Capacity;
|
||||
|
||||
retention.ClearFrameBuffers(
|
||||
lookInFrames,
|
||||
lookInPrepare,
|
||||
drawableCells,
|
||||
shellBatch,
|
||||
orderedTransparent);
|
||||
|
||||
Assert.Equal(lookInCapacity, lookInFrames.Capacity);
|
||||
Assert.Equal(prepareCapacity, lookInPrepare.EnsureCapacity(0));
|
||||
Assert.Equal(drawableCapacity, drawableCells.EnsureCapacity(0));
|
||||
Assert.Equal(shellCapacity, shellBatch.EnsureCapacity(0));
|
||||
Assert.Equal(transparentCapacity, orderedTransparent.Capacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearFrameBuffers_RecurringLargeWorkingSet_PreservesWarmCapacity()
|
||||
{
|
||||
const int recurringCount =
|
||||
RetailPViewScratchRetention.MaxRetainedCellItems + 163;
|
||||
var retention = new RetailPViewScratchRetention();
|
||||
var lookInFrames = new List<PortalVisibilityFrame>();
|
||||
var lookInPrepare = new HashSet<uint>();
|
||||
var drawableCells = new HashSet<uint>();
|
||||
var shellBatch = new HashSet<uint>();
|
||||
var orderedTransparent = new List<uint>();
|
||||
|
||||
for (int iteration = 0;
|
||||
iteration < RetailPViewScratchRetention.CapacityTrimIdleFrames + 5;
|
||||
iteration++)
|
||||
{
|
||||
for (int i = 0; i < recurringCount; i++)
|
||||
{
|
||||
uint id = (uint)i;
|
||||
drawableCells.Add(id);
|
||||
orderedTransparent.Add(id);
|
||||
}
|
||||
|
||||
int drawableCapacity = drawableCells.EnsureCapacity(0);
|
||||
int transparentCapacity = orderedTransparent.Capacity;
|
||||
retention.ClearFrameBuffers(
|
||||
lookInFrames,
|
||||
lookInPrepare,
|
||||
drawableCells,
|
||||
shellBatch,
|
||||
orderedTransparent);
|
||||
|
||||
Assert.Equal(drawableCapacity, drawableCells.EnsureCapacity(0));
|
||||
Assert.Equal(transparentCapacity, orderedTransparent.Capacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,500 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #118 exit-walk harness (handoff 2026-06-11 §5): the character is clipped and then
|
||||
/// vanishes for a moment when exiting a house — the window where the player is just
|
||||
/// outside the door while the collided viewer (camera) is still indoors.
|
||||
///
|
||||
/// Per step of a deterministic eye+player path crossing the exit doorway of the
|
||||
/// Holtburg corner building (cell 0xA9B40170, dat-loaded via the CornerFloodReplay
|
||||
/// fixture loader), this drives the PRODUCTION decision stack headlessly:
|
||||
/// viewer-cell resolution (healthy-sweep model, see below) →
|
||||
/// <see cref="PortalVisibilityBuilder.Build"/> → <see cref="ClipFrameAssembler.Assemble"/> →
|
||||
/// <see cref="ViewconeCuller.Build"/> → the exact DrawDynamicsLast visibility predicate
|
||||
/// (RetailPViewRenderer.cs:375-401), PLUS the depth relationship DrawDynamicsLast is
|
||||
/// subject to: under an INTERIOR root the exit-portal SEAL stamps the door fan at TRUE
|
||||
/// depth (RetailPViewPassExecutor.DrawPortalDepthWrite, forceFarZ=false) after the full
|
||||
/// depth clear, and dynamics draw depth-tested AFTER it.
|
||||
///
|
||||
/// The four candidates this pins (handoff §5 + this session's read):
|
||||
/// 1. eye/cell incoherence under damping — EXONERATED BY READ for clean exits:
|
||||
/// RetailChaseCamera publishes (Position, ViewerCellId) from the SAME SweepEye call
|
||||
/// (RetailChaseCamera.cs:188-203), published==damped when nothing collides (an open
|
||||
/// doorway), and GameWindow updates the camera (≈:6889) BEFORE the visibility read
|
||||
/// (≈:7361) in the same frame.
|
||||
/// 2. exit-portal side test culling at an ε-outside eye → OutsideView EMPTY →
|
||||
/// SphereVisibleOutside culls ALL outdoor dynamics. Quantified by the stale-root
|
||||
/// diagnostic below (the healthy walk should never produce the incoherent pair).
|
||||
/// 3. doorway-aperture cone tightness → tested per step by the predicate replica.
|
||||
/// 4. (new, this session) SEAL-DEPTH vs dynamics-last ordering: a player whose
|
||||
/// fragments lie BEYOND the door plane z-fails against the seal across the whole
|
||||
/// aperture → invisible while fully outside (and clipped at the plane while
|
||||
/// straddling). Tested per step by the CPU depth check (same viewProj math the GPU
|
||||
/// consumes).
|
||||
///
|
||||
/// Healthy-sweep model: the corner-seal replay (b21bb28) + the camera read above prove
|
||||
/// the sweep resolves the eye's ACTUAL cell same-frame, so the harness derives the
|
||||
/// viewer cell geometrically: door-plane side decides indoor/outdoor; AABB containment
|
||||
/// (smallest containing volume) picks the interior cell. The outdoor root is
|
||||
/// <see cref="OutdoorCellNode.Build"/> exactly as GameWindow builds it (full-screen
|
||||
/// OutsideView ⇒ outdoor dynamics trivially cone-pass).
|
||||
/// </summary>
|
||||
[Collection(CameraDiagnosticsCollection.Name)]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class HouseExitWalkReplayTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public HouseExitWalkReplayTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private const uint ExitCellId = CornerFloodReplayTests.Landblock | 0x0170u;
|
||||
|
||||
// Production humanoid entity sphere (RetailPViewRenderer.EntitySphere: AABB center +
|
||||
// half-diagonal). A ~1.8 m × 0.6 m character AABB gives center ≈ feet+0.9, r ≈ 1.0.
|
||||
private const float PlayerSphereRadius = 1.0f;
|
||||
private static readonly Vector3 PlayerSphereCenterOffset = new(0f, 0f, 0.9f);
|
||||
|
||||
private sealed record ExitDoor(
|
||||
int PortalIndex,
|
||||
Vector3[] WorldVerts,
|
||||
Vector3 WorldCentroid,
|
||||
Vector3 OutwardNormal, // unit, world space, pointing OUT of the building
|
||||
float FloorZ);
|
||||
|
||||
private sealed record WalkStep(
|
||||
int Index,
|
||||
float WalkS, // raw walk parameter (feet travel along the XY exit direction)
|
||||
float CenterS, // signed distance of the SPHERE CENTER to the door plane (the production quantity)
|
||||
float EyeS, // signed distance of the published eye
|
||||
Vector3 PlayerFeet,
|
||||
Vector3 Eye,
|
||||
uint RootCellId, // 0 = outdoor root
|
||||
uint PlayerParentCellId,
|
||||
bool ConeVisible,
|
||||
bool OutsideStage, // production stage assignment (DynamicDrawsInOutsideStage)
|
||||
bool DepthCheckApplies,
|
||||
bool DepthPass,
|
||||
int OutsidePolys,
|
||||
int FloodCells);
|
||||
|
||||
// ── fixture / geometry ──────────────────────────────────────────────
|
||||
|
||||
private static ExitDoor FindExitDoor(LoadedCell cell)
|
||||
{
|
||||
int best = -1;
|
||||
float bestMinZ = float.MaxValue;
|
||||
for (int i = 0; i < cell.Portals.Count; i++)
|
||||
{
|
||||
if (cell.Portals[i].OtherCellId != 0xFFFF) continue;
|
||||
if (i >= cell.PortalPolygons.Count) continue;
|
||||
var poly = cell.PortalPolygons[i];
|
||||
if (poly is null || poly.Length < 3) continue;
|
||||
|
||||
float minZ = float.MaxValue;
|
||||
foreach (var v in poly)
|
||||
minZ = MathF.Min(minZ, Vector3.Transform(v, cell.WorldTransform).Z);
|
||||
// a DOOR reaches the floor; a window doesn't — pick the lowest-silled exit portal
|
||||
if (minZ < bestMinZ) { bestMinZ = minZ; best = i; }
|
||||
}
|
||||
Assert.True(best >= 0, $"cell 0x{cell.CellId:X8} has no exit portal (OtherCellId==0xFFFF)");
|
||||
|
||||
var local = cell.PortalPolygons[best];
|
||||
var world = new Vector3[local.Length];
|
||||
var centroid = Vector3.Zero;
|
||||
float floorZ = float.MaxValue;
|
||||
for (int v = 0; v < local.Length; v++)
|
||||
{
|
||||
world[v] = Vector3.Transform(local[v], cell.WorldTransform);
|
||||
centroid += world[v];
|
||||
floorZ = MathF.Min(floorZ, world[v].Z);
|
||||
}
|
||||
centroid /= local.Length;
|
||||
|
||||
// Outward = away from the cell interior. ClipPlanes[i].InsideSide encodes which
|
||||
// side the cell centroid is on (CornerFloodReplayTests.LoadCell): InsideSide==0
|
||||
// ⇒ interior satisfies dot ≥ 0 ⇒ outward is −Normal; InsideSide==1 ⇒ +Normal.
|
||||
var plane = cell.ClipPlanes[best];
|
||||
var outwardLocal = plane.InsideSide == 0 ? -plane.Normal : plane.Normal;
|
||||
var outwardWorld = Vector3.Normalize(Vector3.TransformNormal(outwardLocal, cell.WorldTransform));
|
||||
|
||||
return new ExitDoor(best, world, centroid, outwardWorld, floorZ);
|
||||
}
|
||||
|
||||
private static float SignedSide(ExitDoor door, Vector3 p)
|
||||
=> Vector3.Dot(door.OutwardNormal, p - door.WorldCentroid);
|
||||
|
||||
private static uint? ResolveInteriorCellByAabb(
|
||||
Dictionary<uint, LoadedCell> cells, Vector3 worldPoint, float margin = 0.05f)
|
||||
{
|
||||
uint? best = null;
|
||||
float bestVolume = float.MaxValue;
|
||||
foreach (var (id, cell) in cells)
|
||||
{
|
||||
var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform);
|
||||
var min = cell.LocalBoundsMin - new Vector3(margin);
|
||||
var max = cell.LocalBoundsMax + new Vector3(margin);
|
||||
if (local.X < min.X || local.Y < min.Y || local.Z < min.Z
|
||||
|| local.X > max.X || local.Y > max.Y || local.Z > max.Z)
|
||||
continue;
|
||||
var ext = cell.LocalBoundsMax - cell.LocalBoundsMin;
|
||||
float volume = MathF.Max(ext.X, 1e-3f) * MathF.Max(ext.Y, 1e-3f) * MathF.Max(ext.Z, 1e-3f);
|
||||
if (volume < bestVolume) { bestVolume = volume; best = id; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Outdoor landcell id for a landblock-local position (dat EnvCell positions are
|
||||
// landblock-local). Only the (low word < 0x100) classification is load-bearing for
|
||||
// the DrawDynamicsLast predicate; the exact landcell is kept honest anyway.
|
||||
private static uint OutdoorLandcellId(Vector3 landblockLocalPos)
|
||||
{
|
||||
int cx = Math.Clamp((int)MathF.Floor(landblockLocalPos.X / 24f), 0, 7);
|
||||
int cy = Math.Clamp((int)MathF.Floor(landblockLocalPos.Y / 24f), 0, 7);
|
||||
return CornerFloodReplayTests.Landblock | (uint)(cx * 8 + cy + 1);
|
||||
}
|
||||
|
||||
// Exact replica of RetailPViewRenderer.DrawDynamicsLast's visibility predicate
|
||||
// (RetailPViewRenderer.cs:386-391). Keep in lockstep with production.
|
||||
private static bool DynamicsConeVisible(ViewconeCuller cone, uint parentCellId, Vector3 c, float r)
|
||||
{
|
||||
bool indoor = (parentCellId & 0xFFFFu) >= 0x0100u && (parentCellId & 0xFFFFu) != 0xFFFFu;
|
||||
return indoor
|
||||
? cone.SphereVisibleInCell(parentCellId, c, r)
|
||||
: cone.SphereVisibleOutside(c, r);
|
||||
}
|
||||
|
||||
// ── the walk ────────────────────────────────────────────────────────
|
||||
|
||||
private List<WalkStep>? RunExitWalk()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return null; }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = CornerFloodReplayTests.LoadBuilding(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
var exitCell = cells[ExitCellId];
|
||||
var door = FindExitDoor(exitCell);
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"exit door: cell=0x{ExitCellId:X8} portal[{door.PortalIndex}] centroid=({door.WorldCentroid.X:F2},{door.WorldCentroid.Y:F2},{door.WorldCentroid.Z:F2}) outward=({door.OutwardNormal.X:F2},{door.OutwardNormal.Y:F2},{door.OutwardNormal.Z:F2}) floorZ={door.FloorZ:F2} verts={door.WorldVerts.Length}"));
|
||||
|
||||
// Walk straight out through the door centroid along the outward normal's XY
|
||||
// projection (doors are vertical; assert so).
|
||||
var out2d = Vector3.Normalize(new Vector3(door.OutwardNormal.X, door.OutwardNormal.Y, 0f));
|
||||
Assert.True(MathF.Abs(door.OutwardNormal.Z) < 0.3f, "exit door is not vertical — walk path invalid");
|
||||
|
||||
float yaw = MathF.Atan2(out2d.Y, out2d.X);
|
||||
const float dt = 1f / 60f;
|
||||
const float stepLen = 0.02f; // 2 cm per frame = 1.2 m/s walk
|
||||
const float sStart = -1.2f, sEnd = 3.0f; // door plane at s=0
|
||||
var velocity = out2d * (stepLen / dt);
|
||||
|
||||
var camera = new RetailChaseCamera { Aspect = 1280f / 720f };
|
||||
|
||||
Vector3 FeetAt(float s) => new(
|
||||
door.WorldCentroid.X + out2d.X * s,
|
||||
door.WorldCentroid.Y + out2d.Y * s,
|
||||
door.FloorZ);
|
||||
|
||||
// Warm the damped boom to convergence at the start pose (retail's convergence
|
||||
// snap freezes it once the lerp step is sub-epsilon).
|
||||
for (int i = 0; i < 240; i++)
|
||||
camera.Update(FeetAt(sStart), yaw, Vector3.Zero, isOnGround: true, Vector3.UnitZ, dt);
|
||||
|
||||
var clipFrame = ClipFrame.NoClip();
|
||||
var steps = new List<WalkStep>();
|
||||
int stepCount = (int)MathF.Round((sEnd - sStart) / stepLen);
|
||||
|
||||
for (int i = 0; i <= stepCount; i++)
|
||||
{
|
||||
float s = sStart + i * stepLen;
|
||||
var feet = FeetAt(s);
|
||||
camera.Update(feet, yaw, velocity, isOnGround: true, Vector3.UnitZ, dt);
|
||||
var eye = camera.Position;
|
||||
var viewProj = camera.View * camera.Projection;
|
||||
|
||||
float eyeS = SignedSide(door, eye);
|
||||
var sphereC = feet + PlayerSphereCenterOffset;
|
||||
float playerS = SignedSide(door, sphereC);
|
||||
|
||||
// membership model: the controller's pick is center point-in-cell (physics
|
||||
// digest, P1) — the player's ParentCellId flips when the sphere CENTER
|
||||
// crosses the door plane.
|
||||
uint playerCell = playerS <= 0f
|
||||
? ExitCellId
|
||||
: OutdoorLandcellId(feet);
|
||||
|
||||
// healthy-sweep viewer resolution: plane side decides indoor/outdoor;
|
||||
// AABB containment picks the interior cell the eye is in.
|
||||
uint rootCellId = 0u;
|
||||
LoadedCell? root = null;
|
||||
if (eyeS <= 0f)
|
||||
{
|
||||
var contained = ResolveInteriorCellByAabb(cells, eye);
|
||||
Assert.True(contained.HasValue,
|
||||
FormattableString.Invariant(
|
||||
$"step {i}: eye=({eye.X:F2},{eye.Y:F2},{eye.Z:F2}) (eyeS={eyeS:F2}) is inside the door plane but no loaded cell AABB contains it — adjust the walk"));
|
||||
rootCellId = contained.Value;
|
||||
root = cells[rootCellId];
|
||||
}
|
||||
else
|
||||
{
|
||||
root = OutdoorCellNode.Build(OutdoorLandcellId(eye));
|
||||
}
|
||||
|
||||
var pv = PortalVisibilityBuilder.Build(root, eye, lookup, viewProj);
|
||||
var asm = ClipFrameAssembler.Assemble(clipFrame, pv);
|
||||
var cone = ViewconeCuller.Build(asm, viewProj);
|
||||
|
||||
bool coneVisible = DynamicsConeVisible(cone, playerCell, sphereC, PlayerSphereRadius);
|
||||
|
||||
// Production stage assignment (#118 fix): an outside-stage dynamic draws
|
||||
// BEFORE the depth clear + seal (retail LScape::draw → DrawSortCell), so
|
||||
// the seal PROTECTS its pixels instead of z-killing them.
|
||||
var drawable = new HashSet<uint>(pv.OrderedVisibleCells);
|
||||
bool outsideStage = rootCellId != 0u
|
||||
&& RetailPViewRenderer.DynamicDrawsInOutsideStage(
|
||||
playerCell,
|
||||
sphereC,
|
||||
PlayerSphereRadius,
|
||||
drawable,
|
||||
new TestCellSource(lookup));
|
||||
|
||||
// Candidate 4: the seal depth check. Applies when the root is INTERIOR
|
||||
// (ClearDepthForInterior + the TRUE-depth seal run, GameWindow:7719-7729),
|
||||
// the player sphere center lies BEYOND the door plane on the ray from the
|
||||
// eye, AND the player draws in the post-seal last pass (not outside-stage).
|
||||
bool depthApplies = false, depthPass = true;
|
||||
if (rootCellId != 0u && coneVisible && playerS > 0f && eyeS < 0f && !outsideStage)
|
||||
{
|
||||
depthApplies = TrySealDepthCheck(door, eye, sphereC, viewProj, out depthPass);
|
||||
}
|
||||
|
||||
steps.Add(new WalkStep(
|
||||
i, s, playerS, eyeS, feet, eye, rootCellId, playerCell, coneVisible, outsideStage,
|
||||
depthApplies, depthPass,
|
||||
pv.OutsideView.Polygons.Count, pv.OrderedVisibleCells.Count));
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CPU model of the depth relationship at the player-sphere-center pixel:
|
||||
/// the SEAL (door fan at true depth, drawn after the full depth clear and before
|
||||
/// dynamics — PortalDepthMaskRenderer, forceFarZ=false) vs the player fragment.
|
||||
/// Returns true (out: pass) when the player's NDC depth at that pixel is ≤ the
|
||||
/// seal's. Returns false (check N/A) when the eye→center ray misses the door fan
|
||||
/// (the seal doesn't cover that pixel; depth there is the cleared far plane).
|
||||
/// </summary>
|
||||
private static bool TrySealDepthCheck(
|
||||
ExitDoor door, Vector3 eye, Vector3 sphereCenter, in Matrix4x4 viewProj, out bool depthPass)
|
||||
{
|
||||
depthPass = true;
|
||||
|
||||
float dEye = Vector3.Dot(door.OutwardNormal, eye - door.WorldCentroid);
|
||||
float dC = Vector3.Dot(door.OutwardNormal, sphereCenter - door.WorldCentroid);
|
||||
if (dEye >= 0f || dC <= 0f) return false; // plane not between eye and center
|
||||
float t = dEye / (dEye - dC);
|
||||
var hit = eye + (sphereCenter - eye) * t;
|
||||
|
||||
// is the ray-plane hit inside the door polygon? (2D point-in-polygon in the
|
||||
// plane's basis — the seal only stamps pixels inside the fan)
|
||||
if (!PointInPolygon(door, hit)) return false;
|
||||
|
||||
var pc = Vector4.Transform(new Vector4(sphereCenter, 1f), viewProj);
|
||||
var hc = Vector4.Transform(new Vector4(hit, 1f), viewProj);
|
||||
if (pc.W <= 1e-6f || hc.W <= 1e-6f) return false; // behind the eye — no pixel
|
||||
|
||||
float playerZ = pc.Z / pc.W;
|
||||
float sealZ = hc.Z / hc.W;
|
||||
// GL depth test default LESS/LEQUAL: smaller NDC z = nearer = passes.
|
||||
depthPass = playerZ <= sealZ + 1e-4f;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool PointInPolygon(ExitDoor door, Vector3 worldPoint)
|
||||
{
|
||||
// build a 2D basis in the door plane
|
||||
var n = door.OutwardNormal;
|
||||
var u = Vector3.Normalize(Vector3.Cross(n, Vector3.UnitZ));
|
||||
if (u.LengthSquared() < 1e-6f) u = Vector3.UnitX;
|
||||
var v = Vector3.Cross(n, u);
|
||||
|
||||
Vector2 Project(Vector3 p) => new(
|
||||
Vector3.Dot(p - door.WorldCentroid, u),
|
||||
Vector3.Dot(p - door.WorldCentroid, v));
|
||||
|
||||
var pt = Project(worldPoint);
|
||||
bool inside = false;
|
||||
for (int i = 0, j = door.WorldVerts.Length - 1; i < door.WorldVerts.Length; j = i++)
|
||||
{
|
||||
var a = Project(door.WorldVerts[i]);
|
||||
var b = Project(door.WorldVerts[j]);
|
||||
if ((a.Y > pt.Y) != (b.Y > pt.Y)
|
||||
&& pt.X < (b.X - a.X) * (pt.Y - a.Y) / (b.Y - a.Y) + a.X)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private void DumpSteps(IEnumerable<WalkStep> steps, Func<WalkStep, bool>? filter = null)
|
||||
{
|
||||
foreach (var st in steps)
|
||||
{
|
||||
if (filter is not null && !filter(st)) continue;
|
||||
string root = st.RootCellId == 0 ? "OUTDOOR " : FormattableString.Invariant($"0x{st.RootCellId:X8}");
|
||||
string cone = st.ConeVisible ? "VIS " : "CULL";
|
||||
string stage = st.OutsideStage ? "outside" : "last ";
|
||||
string depth = st.DepthCheckApplies ? (st.DepthPass ? "pass" : "FAIL") : "n/a ";
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"step={st.Index,3} s={st.WalkS,6:F2} cS={st.CenterS,6:F2} eyeS={st.EyeS,6:F2} root={root} pCell=0x{st.PlayerParentCellId:X8} cone={cone} stage={stage} depth={depth} outPolys={st.OutsidePolys} flood={st.FloodCells}"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── the pins ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Candidates 1–3 (cone level): per step of the exit walk, the player sphere must
|
||||
/// survive the exact DrawDynamicsLast cone predicate. A failing step pins the
|
||||
/// side-test / cone-tightness / incoherence family with its exact geometry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ExitWalk_PlayerStaysConeVisible_EveryStep()
|
||||
{
|
||||
var steps = RunExitWalk();
|
||||
if (steps is null) Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
|
||||
var failures = steps.FindAll(s => !s.ConeVisible);
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
_out.WriteLine($"--- {failures.Count} cone-CULLED steps ---");
|
||||
DumpSteps(failures);
|
||||
}
|
||||
Assert.True(failures.Count == 0,
|
||||
$"{failures.Count}/{steps.Count} steps cone-cull the player (first at step {(failures.Count > 0 ? failures[0].Index : -1)}) — see output");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Candidate 4 (depth level): on every step where the root is interior and the
|
||||
/// cone admits the outdoor player, the player's fragments must also SURVIVE the
|
||||
/// exit-portal SEAL's depth — otherwise DrawDynamicsLast paints nothing (the
|
||||
/// vanish) and a straddling body is cut at the door plane (the clip).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ExitWalk_PlayerSurvivesSealDepth_WhenConeVisible()
|
||||
{
|
||||
var steps = RunExitWalk();
|
||||
if (steps is null) Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
|
||||
var applicable = steps.FindAll(s => s.DepthCheckApplies);
|
||||
_out.WriteLine($"depth check applies on {applicable.Count}/{steps.Count} steps");
|
||||
var failures = applicable.FindAll(s => !s.DepthPass);
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
_out.WriteLine($"--- {failures.Count} seal-depth-FAILED steps ---");
|
||||
DumpSteps(failures);
|
||||
}
|
||||
Assert.True(failures.Count == 0,
|
||||
$"{failures.Count}/{applicable.Count} applicable steps z-fail the player against the exit-portal seal — " +
|
||||
"dynamics drawn after the TRUE-depth seal cannot appear beyond the door plane (see output)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The straddle ("clipped") phase: while the player sphere crosses the door
|
||||
/// plane under an interior root, it must be assigned to the OUTSIDE stage
|
||||
/// (drawn pre-seal — for indoor-classified straddlers that is retail's
|
||||
/// per-overlapped-shadow-cell dual draw, DrawBlock pc:430056-430064), or the
|
||||
/// beyond-plane body half is cut at the plane by the seal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ExitWalk_StraddlingPlayerDrawsInOutsideStage()
|
||||
{
|
||||
var steps = RunExitWalk();
|
||||
if (steps is null) Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
|
||||
var straddling = steps.FindAll(s =>
|
||||
s.RootCellId != 0u && MathF.Abs(s.CenterS) < PlayerSphereRadius);
|
||||
Assert.True(straddling.Count > 0, "walk produced no interior-root straddle steps — geometry changed?");
|
||||
var failures = straddling.FindAll(s => !s.OutsideStage);
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
_out.WriteLine($"--- {failures.Count} straddle steps NOT outside-stage ---");
|
||||
DumpSteps(failures);
|
||||
}
|
||||
Assert.True(failures.Count == 0,
|
||||
$"{failures.Count}/{straddling.Count} straddle steps are not outside-stage-assigned — the seal clips the body at the door plane");
|
||||
}
|
||||
|
||||
/// <summary>Full per-step table for the handoff doc.</summary>
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_ExitWalk_PerStepTable()
|
||||
{
|
||||
var steps = RunExitWalk();
|
||||
if (steps is null) Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
DumpSteps(steps);
|
||||
// transition summary
|
||||
int firstOutPlayer = steps.FindIndex(s => s.CenterS > 0f);
|
||||
int firstOutRoot = steps.FindIndex(s => s.RootCellId == 0u);
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"player center exits at step {firstOutPlayer}; viewer root flips outdoor at step {firstOutRoot}; interior-root/outdoor-player window = {Math.Max(0, firstOutRoot - firstOutPlayer)} steps"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Candidate 2 quantifier (synthetic): the INCOHERENT (root=interior, eye outside)
|
||||
/// pair the healthy sweep never produces. Documents how the flood degrades if the
|
||||
/// root ever lagged the eye: within PortalSideEpsilon the exit portal still
|
||||
/// traverses; beyond it the side test culls and OutsideView goes EMPTY (which would
|
||||
/// cull ALL outdoor content — terrain included, not just the player).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_StaleRootWindow_EyeJustOutside()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = CornerFloodReplayTests.LoadBuilding(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
var exitCell = cells[ExitCellId];
|
||||
var door = FindExitDoor(exitCell);
|
||||
|
||||
var out2d = Vector3.Normalize(new Vector3(door.OutwardNormal.X, door.OutwardNormal.Y, 0f));
|
||||
var eyeBase = new Vector3(door.WorldCentroid.X, door.WorldCentroid.Y, door.FloorZ + 1.8f);
|
||||
var playerC = eyeBase + out2d * 1.5f; // player just outside, in front of the eye
|
||||
|
||||
var clipFrame = ClipFrame.NoClip();
|
||||
foreach (float d in new[] { 0.005f, 0.02f, 0.05f, 0.10f, 0.25f })
|
||||
{
|
||||
var eye = eyeBase + out2d * d;
|
||||
var view = Matrix4x4.CreateLookAt(eye, eye + out2d, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 1280f / 720f, 0.1f, 5000f);
|
||||
var viewProj = view * proj;
|
||||
|
||||
var pv = PortalVisibilityBuilder.Build(exitCell, eye, lookup, viewProj);
|
||||
var asm = ClipFrameAssembler.Assemble(clipFrame, pv);
|
||||
var cone = ViewconeCuller.Build(asm, viewProj);
|
||||
bool playerVisible = cone.SphereVisibleOutside(playerC, PlayerSphereRadius);
|
||||
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"eye {d * 100,5:F1} cm OUTSIDE + root still 0x{ExitCellId:X8}: outPolys={pv.OutsideView.Polygons.Count} flood={pv.OrderedVisibleCells.Count} playerConeVisible={playerVisible}"));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestCellSource(Func<uint, LoadedCell?> find) :
|
||||
IRetailPViewCellSource
|
||||
{
|
||||
public LoadedCell? Find(uint cellId) => find(cellId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,140 +1,17 @@
|
|||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using AcDream.App.Composition;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.App.Tests.Architecture;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class RetailPViewPassExecutorTests
|
||||
{
|
||||
[Fact]
|
||||
public void DrawInside_outdoor_executes_the_real_typed_sequence_without_interior_clear()
|
||||
{
|
||||
var renderer = new RetailPViewRenderer();
|
||||
using var executor = new RecordingExecutor();
|
||||
LoadedCell root = OutdoorCellNode.Build(0xA9B40000u);
|
||||
|
||||
renderer.DrawInside(Frame(root), executor);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"begin",
|
||||
"assemble",
|
||||
"append-look-in-clips",
|
||||
// FW4 slice 1: the clip-region publication moved below the
|
||||
// walk block (a walk-rooted interior frame re-derives the
|
||||
// outside-view slices from the walk's own views first, and
|
||||
// the appended slots must join the same single publication).
|
||||
"indoor-routing",
|
||||
"prepare-clip:3",
|
||||
"prepare-cells",
|
||||
"diagnostics",
|
||||
"terrain-clip",
|
||||
"clear-routing",
|
||||
"landscape-early",
|
||||
"terrain-clip",
|
||||
"clear-routing",
|
||||
"landscape-late",
|
||||
"unattached-particles-outdoor",
|
||||
// #132: an OUTDOOR root does NOT drain at the stage boundary.
|
||||
// The far prefix drains at the pre-punch barrier (retail
|
||||
// DrawBuilding @0x0059F2A0 flushes before its portal-only
|
||||
// far-Z pass), and the full drain runs after the dynamics
|
||||
// pass, where the frame's opaque world depth is complete.
|
||||
"indoor-routing",
|
||||
"indoor-routing",
|
||||
"landscape-alpha-farther",
|
||||
"exit-mask",
|
||||
"indoor-routing",
|
||||
"opaque-shells",
|
||||
"landscape-alpha",
|
||||
"unattached-particles-interior",
|
||||
],
|
||||
executor.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawInside_interior_exit_flushes_before_clear_and_resets_borrowed_result()
|
||||
{
|
||||
var renderer = new RetailPViewRenderer();
|
||||
using var executor = new RecordingExecutor();
|
||||
LoadedCell outdoor = OutdoorCellNode.Build(0xA9B40000u);
|
||||
RetailPViewFrameResult borrowed = renderer.DrawInside(Frame(outdoor), executor);
|
||||
Assert.Contains(outdoor.CellId, borrowed.DrawableCells);
|
||||
|
||||
executor.Operations.Clear();
|
||||
LoadedCell interior = InteriorWithExit(0xA9B40100u);
|
||||
RetailPViewFrameResult reused = renderer.DrawInside(Frame(interior), executor);
|
||||
|
||||
Assert.Same(borrowed, reused);
|
||||
Assert.DoesNotContain(outdoor.CellId, reused.DrawableCells);
|
||||
Assert.Contains(interior.CellId, reused.DrawableCells);
|
||||
AssertAppearsInOrder(
|
||||
string.Join('|', executor.Operations),
|
||||
"landscape-early",
|
||||
"landscape-late",
|
||||
"unattached-particles-outdoor",
|
||||
"landscape-alpha",
|
||||
"interior-depth-clear",
|
||||
"indoor-routing",
|
||||
"exit-mask");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawInside_interior_without_an_outside_slice_skips_landscape_and_depth_clear()
|
||||
{
|
||||
var renderer = new RetailPViewRenderer();
|
||||
using var executor = new RecordingExecutor();
|
||||
var root = new LoadedCell
|
||||
{
|
||||
CellId = 0xA9B40100u,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
|
||||
renderer.DrawInside(Frame(root), executor);
|
||||
|
||||
Assert.DoesNotContain("terrain-clip", executor.Operations);
|
||||
Assert.DoesNotContain("landscape-early", executor.Operations);
|
||||
Assert.DoesNotContain("landscape-late", executor.Operations);
|
||||
Assert.DoesNotContain("landscape-alpha", executor.Operations);
|
||||
Assert.DoesNotContain("interior-depth-clear", executor.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawInside_interior_without_an_outside_slice_still_draws_interior_unattached_particles()
|
||||
{
|
||||
// Repro (Sanctuary middle cell, looking north): spell ground effects
|
||||
// vanished whenever no exit portal was in view, because unattached
|
||||
// emitters submitted once PER outside slice under that slice's
|
||||
// hardware clip slot — zero slices meant zero submissions. Retail
|
||||
// draws such an emitter during its owner cell's walk turn
|
||||
// (ShouldDrawParticles @0x0050FE60) and never clips it to a view.
|
||||
var renderer = new RetailPViewRenderer();
|
||||
using var executor = new RecordingExecutor();
|
||||
var root = new LoadedCell
|
||||
{
|
||||
CellId = 0xA9B40100u,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
|
||||
renderer.DrawInside(Frame(root), executor);
|
||||
|
||||
Assert.Contains("unattached-particles-interior", executor.Operations);
|
||||
Assert.DoesNotContain(
|
||||
"unattached-particles-outdoor",
|
||||
executor.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Particle_classifications_reset_before_an_empty_following_frame()
|
||||
{
|
||||
var classifications = new RetailPViewParticleClassifications();
|
||||
classifications.ReplaceOutdoor([Entity(7u)]);
|
||||
classifications.ReplaceOutdoor(new HashSet<uint> { 7u });
|
||||
classifications.Visible.Add(8u);
|
||||
classifications.Dynamics.Add(9u);
|
||||
|
||||
|
|
@ -145,159 +22,6 @@ public sealed class RetailPViewPassExecutorTests
|
|||
Assert.Empty(classifications.Dynamics);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawInside_real_fixtures_reach_entity_particle_and_transparent_shell_operations()
|
||||
{
|
||||
var renderer = new RetailPViewRenderer();
|
||||
using var executor = new RecordingExecutor { HasTransparentShells = true };
|
||||
LoadedCell interior = InteriorWithExit(0xA9B40100u);
|
||||
WorldEntity cellStatic = Entity(10u, parentCellId: interior.CellId);
|
||||
WorldEntity dynamic = Entity(
|
||||
11u,
|
||||
serverGuid: 0x80000011u,
|
||||
parentCellId: interior.CellId);
|
||||
|
||||
renderer.DrawInside(Frame(interior, [cellStatic, dynamic]), executor);
|
||||
|
||||
Assert.Contains("opaque-shells", executor.Operations);
|
||||
Assert.Contains("transparent-shells-ordered", executor.Operations);
|
||||
Assert.Contains("entity-bucket", executor.Operations);
|
||||
Assert.Contains("cell-particles", executor.Operations);
|
||||
Assert.Contains("dynamics-particles", executor.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawInside_referee_records_the_exact_routed_entity_buckets()
|
||||
{
|
||||
var oracle = new CurrentRenderSceneOracle();
|
||||
var renderer = new RetailPViewRenderer(oracle);
|
||||
using var executor = new RecordingExecutor();
|
||||
LoadedCell interior = InteriorWithExit(0xA9B40100u);
|
||||
WorldEntity cellStatic = Entity(20u, parentCellId: interior.CellId);
|
||||
WorldEntity dynamic = Entity(
|
||||
21u,
|
||||
serverGuid: 0x80000021u,
|
||||
parentCellId: interior.CellId);
|
||||
|
||||
renderer.DrawInside(Frame(interior, [cellStatic, dynamic]), executor);
|
||||
|
||||
Assert.Equal(1uL, oracle.Snapshot.CompletedPViewFrameSequence);
|
||||
Assert.Equal(2, oracle.Snapshot.PViewCandidateCount);
|
||||
Assert.Collection(
|
||||
oracle.PViewCandidates,
|
||||
candidate =>
|
||||
{
|
||||
Assert.Equal(CurrentRenderPViewRoute.CellStatic, candidate.Route);
|
||||
Assert.Equal(cellStatic.Id, candidate.Projection.EntityId);
|
||||
},
|
||||
candidate =>
|
||||
{
|
||||
Assert.Equal(CurrentRenderPViewRoute.DynamicLast, candidate.Route);
|
||||
Assert.Equal(dynamic.Id, candidate.Projection.EntityId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawInside_interior_root_executes_the_nearby_building_look_in_punch()
|
||||
{
|
||||
var renderer = new RetailPViewRenderer();
|
||||
using var executor = new RecordingExecutor();
|
||||
LoadedCell root = InteriorWithExit(0xA9B40100u);
|
||||
root.BuildingId = 1u;
|
||||
LoadedCell[] building = NearbyTwoCellBuilding();
|
||||
|
||||
renderer.DrawInside(
|
||||
Frame(
|
||||
root,
|
||||
nearbyBuildingCells: building,
|
||||
additionalCells: building),
|
||||
executor);
|
||||
|
||||
Assert.Contains("look-in-punch", executor.Operations);
|
||||
AssertAppearsInOrder(
|
||||
string.Join('|', executor.Operations),
|
||||
"landscape-early",
|
||||
"unattached-particles-outdoor",
|
||||
"landscape-static-particles",
|
||||
"landscape-alpha-farther",
|
||||
"look-in-punch",
|
||||
"landscape-late",
|
||||
"landscape-alpha",
|
||||
"interior-depth-clear");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawInside_repaints_the_exterior_building_shell_after_its_look_in()
|
||||
{
|
||||
var renderer = new RetailPViewRenderer();
|
||||
using var executor = new RecordingExecutor();
|
||||
LoadedCell root = InteriorWithExit(0xA9B40100u);
|
||||
root.BuildingId = 1u;
|
||||
LoadedCell[] building = NearbyTwoCellBuilding();
|
||||
WorldEntity exteriorShell = Entity(
|
||||
0x700u,
|
||||
isBuildingShell: true,
|
||||
buildingShellAnchorCellId: building[0].CellId);
|
||||
|
||||
renderer.DrawInside(
|
||||
Frame(
|
||||
root,
|
||||
[exteriorShell],
|
||||
nearbyBuildingCells: building,
|
||||
additionalCells: building),
|
||||
executor);
|
||||
|
||||
AssertAppearsInOrder(
|
||||
string.Join('|', executor.Operations),
|
||||
"landscape-early",
|
||||
"look-in-punch",
|
||||
"landscape-building-shell",
|
||||
"landscape-late");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawInside_pairs_each_look_in_with_only_its_own_shell_and_alpha_barrier()
|
||||
{
|
||||
var renderer = new RetailPViewRenderer();
|
||||
using var executor = new RecordingExecutor();
|
||||
LoadedCell root = InteriorWithExit(0xA9B40100u);
|
||||
root.BuildingId = 1u;
|
||||
LoadedCell[] first = NearbyTwoCellBuilding();
|
||||
LoadedCell[] second = NearbyTwoCellBuilding(
|
||||
0xA9B40172u,
|
||||
0xA9B40173u,
|
||||
buildingId: 3u);
|
||||
LoadedCell[] buildings = [.. first, .. second];
|
||||
WorldEntity[] shells =
|
||||
[
|
||||
Entity(
|
||||
0x700u,
|
||||
isBuildingShell: true,
|
||||
buildingShellAnchorCellId: first[0].CellId),
|
||||
Entity(
|
||||
0x701u,
|
||||
isBuildingShell: true,
|
||||
buildingShellAnchorCellId: second[0].CellId),
|
||||
];
|
||||
|
||||
renderer.DrawInside(
|
||||
Frame(
|
||||
root,
|
||||
shells,
|
||||
nearbyBuildingCells: buildings,
|
||||
additionalCells: buildings),
|
||||
executor);
|
||||
|
||||
AssertAppearsInOrder(
|
||||
string.Join('|', executor.Operations),
|
||||
"look-in-punch",
|
||||
"landscape-building-shell",
|
||||
"landscape-static-particles",
|
||||
"landscape-alpha-farther",
|
||||
"look-in-punch",
|
||||
"landscape-building-shell");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extracted_contracts_retain_no_window_callbacks_or_visibility_owner()
|
||||
{
|
||||
|
|
@ -330,7 +54,8 @@ public sealed class RetailPViewPassExecutorTests
|
|||
nameof(RetailPViewParticleClassifications.BeginFrame)) >= 0);
|
||||
|
||||
MethodInfo landscape = typeof(RetailPViewPassExecutor).GetMethod(
|
||||
nameof(RetailPViewPassExecutor.DrawLandscapeSlice))!;
|
||||
"DrawWalkTerrainSlice",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
IReadOnlyList<CompiledCall> landscapeCalls = CompiledCallGraph.Read(landscape);
|
||||
int diagnosticsBegin = RequiredCallIndex(
|
||||
landscapeCalls,
|
||||
|
|
@ -350,7 +75,7 @@ public sealed class RetailPViewPassExecutorTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void GameWindow_composes_one_executor_instead_of_a_draw_callback_bag()
|
||||
public void Frame_composition_constructs_one_walk_executor()
|
||||
{
|
||||
MethodInfo compose = typeof(FrameRootCompositionPhase).GetMethod(
|
||||
"ComposeCore",
|
||||
|
|
@ -377,368 +102,15 @@ public sealed class RetailPViewPassExecutorTests
|
|||
&& call.Target.Name == ".ctor");
|
||||
}
|
||||
|
||||
private static RetailPViewFrameInput Frame(
|
||||
LoadedCell root,
|
||||
IReadOnlyList<WorldEntity>? entities = null,
|
||||
IReadOnlyList<LoadedCell>? nearbyBuildingCells = null,
|
||||
IReadOnlyList<LoadedCell>? additionalCells = null)
|
||||
{
|
||||
var cells = new Dictionary<uint, LoadedCell> { [root.CellId] = root };
|
||||
if (additionalCells is not null)
|
||||
{
|
||||
foreach (LoadedCell cell in additionalCells)
|
||||
cells[cell.CellId] = cell;
|
||||
}
|
||||
|
||||
var entries = new List<(
|
||||
uint LandblockId,
|
||||
Vector3 AabbMin,
|
||||
Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)>();
|
||||
if (entities is not null)
|
||||
{
|
||||
entries.Add((
|
||||
root.CellId & 0xFFFF0000u,
|
||||
Vector3.Zero,
|
||||
Vector3.Zero,
|
||||
entities,
|
||||
null));
|
||||
}
|
||||
|
||||
return new RetailPViewFrameInput().Reset(
|
||||
root,
|
||||
nearbyBuildingCells,
|
||||
Vector3.Zero,
|
||||
TestCamera.ViewProjection,
|
||||
new DictionaryCellSource(cells),
|
||||
new TestCamera(),
|
||||
Vector3.Zero,
|
||||
frustum: null,
|
||||
playerLandblockId: root.CellId & 0xFFFF0000u,
|
||||
animatedEntityIds: null,
|
||||
renderCenterLbX: 0,
|
||||
renderCenterLbY: 0,
|
||||
renderRadius: 1,
|
||||
landblockEntries: entries,
|
||||
renderSky: true,
|
||||
renderWeather: true,
|
||||
dayFraction: 0f,
|
||||
activeDayGroup: null,
|
||||
skyKeyframe: default,
|
||||
environOverrideActive: false,
|
||||
viewerCellId: root.CellId,
|
||||
playerCellId: root.CellId,
|
||||
playerViewPosition: Vector3.Zero,
|
||||
cameraView: TestCamera.ViewMatrix,
|
||||
cameraCellResolution: CameraCellResolution.None);
|
||||
}
|
||||
|
||||
private static LoadedCell InteriorWithExit(uint cellId)
|
||||
{
|
||||
var cell = new LoadedCell
|
||||
{
|
||||
CellId = cellId,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals = [new CellPortalInfo(0xFFFF, 0, 0, 0)],
|
||||
};
|
||||
cell.PortalPolygons.Add(
|
||||
[
|
||||
new Vector3(-1f, -1f, -2f),
|
||||
new Vector3(1f, -1f, -2f),
|
||||
new Vector3(1f, 1f, -2f),
|
||||
new Vector3(-1f, 1f, -2f),
|
||||
]);
|
||||
return cell;
|
||||
}
|
||||
|
||||
private static LoadedCell[] NearbyTwoCellBuilding(
|
||||
uint vestibuleId = 0xA9B40170u,
|
||||
uint roomId = 0xA9B40171u,
|
||||
uint buildingId = 2u)
|
||||
{
|
||||
var vestibule = new LoadedCell
|
||||
{
|
||||
CellId = vestibuleId,
|
||||
BuildingId = buildingId,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals =
|
||||
[
|
||||
new CellPortalInfo(0xFFFF, 0, 0, 0),
|
||||
new CellPortalInfo((ushort)(roomId & 0xFFFFu), 1, 0, 0),
|
||||
],
|
||||
ClipPlanes =
|
||||
[
|
||||
new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0, 0, 1),
|
||||
D = 3f,
|
||||
InsideSide = 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
vestibule.PortalPolygons.Add(
|
||||
[
|
||||
new Vector3(-0.5f, -0.5f, -2f),
|
||||
new Vector3(0.5f, -0.5f, -2f),
|
||||
new Vector3(0.5f, 0.5f, -2f),
|
||||
new Vector3(-0.5f, 0.5f, -2f),
|
||||
]);
|
||||
vestibule.PortalPolygons.Add(
|
||||
[
|
||||
new Vector3(-0.6f, -0.6f, -4f),
|
||||
new Vector3(0.6f, -0.6f, -4f),
|
||||
new Vector3(0.6f, 0.6f, -4f),
|
||||
new Vector3(-0.6f, 0.6f, -4f),
|
||||
]);
|
||||
|
||||
var room = new LoadedCell
|
||||
{
|
||||
CellId = roomId,
|
||||
BuildingId = buildingId,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals =
|
||||
[
|
||||
new CellPortalInfo(
|
||||
(ushort)(vestibuleId & 0xFFFFu),
|
||||
0,
|
||||
0,
|
||||
1),
|
||||
],
|
||||
};
|
||||
room.PortalPolygons.Add(
|
||||
[
|
||||
new Vector3(-0.6f, -0.6f, -4f),
|
||||
new Vector3(0.6f, -0.6f, -4f),
|
||||
new Vector3(0.6f, 0.6f, -4f),
|
||||
new Vector3(-0.6f, 0.6f, -4f),
|
||||
]);
|
||||
return [vestibule, room];
|
||||
}
|
||||
|
||||
private static WorldEntity Entity(
|
||||
uint id,
|
||||
uint serverGuid = 0,
|
||||
uint? parentCellId = null,
|
||||
bool isBuildingShell = false,
|
||||
uint? buildingShellAnchorCellId = null) => new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = [new MeshRef(1u, Matrix4x4.Identity)],
|
||||
ParentCellId = parentCellId,
|
||||
IsBuildingShell = isBuildingShell,
|
||||
BuildingShellAnchorCellId = buildingShellAnchorCellId,
|
||||
};
|
||||
|
||||
private static void AssertAppearsInOrder(string source, params string[] needles)
|
||||
{
|
||||
int cursor = -1;
|
||||
foreach (string needle in needles)
|
||||
{
|
||||
int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal);
|
||||
Assert.True(next > cursor, $"Missing or out-of-order fragment: {needle}");
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
|
||||
private static int RequiredCallIndex(
|
||||
IReadOnlyList<CompiledCall> calls,
|
||||
Type declaringType,
|
||||
string methodName)
|
||||
{
|
||||
int index = CompiledCallGraph.IndexOf(calls, declaringType, methodName);
|
||||
Assert.True(index >= 0, $"Missing compiled call: {declaringType.Name}.{methodName}");
|
||||
Assert.True(
|
||||
index >= 0,
|
||||
$"Expected call to {declaringType.Name}.{methodName}.");
|
||||
return index;
|
||||
}
|
||||
|
||||
private sealed class DictionaryCellSource(
|
||||
IReadOnlyDictionary<uint, LoadedCell> cells) : IRetailPViewCellSource
|
||||
{
|
||||
public LoadedCell? Find(uint cellId) =>
|
||||
cells.TryGetValue(cellId, out LoadedCell? cell) ? cell : null;
|
||||
}
|
||||
|
||||
private sealed class TestCamera : ICamera
|
||||
{
|
||||
public static Matrix4x4 ViewMatrix { get; } = Matrix4x4.CreateLookAt(
|
||||
Vector3.Zero,
|
||||
new Vector3(0, 0, -1),
|
||||
Vector3.UnitY);
|
||||
|
||||
public static Matrix4x4 ViewProjection { get; } = ViewMatrix
|
||||
* Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1f, 0.1f, 1000f);
|
||||
|
||||
public Matrix4x4 View => ViewMatrix;
|
||||
public Matrix4x4 Projection { get; } =
|
||||
Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1f, 0.1f, 1000f);
|
||||
public float Aspect { get; set; } = 1f;
|
||||
}
|
||||
|
||||
private sealed class RecordingExecutor :
|
||||
IRetailPViewPassExecutor,
|
||||
IRenderFrameEntityPassExecutor,
|
||||
IDisposable
|
||||
{
|
||||
public void AbortFrame() => Operations.Add("abort");
|
||||
|
||||
private readonly ClipFrame _clipFrame = ClipFrame.NoClip();
|
||||
|
||||
public List<string> Operations { get; } = [];
|
||||
public bool HasTransparentShells { get; init; }
|
||||
|
||||
public void BeginFrame() => Operations.Add("begin");
|
||||
public ClipFrameAssembly AssembleClipFrame(
|
||||
PortalVisibilityFrame portalFrame,
|
||||
ClipFrameAssembly reuseAssembly)
|
||||
{
|
||||
Operations.Add("assemble");
|
||||
return ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly);
|
||||
}
|
||||
|
||||
public void AppendLookInClipFrames(
|
||||
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
|
||||
ClipFrameAssembly assembly)
|
||||
{
|
||||
Operations.Add("append-look-in-clips");
|
||||
ClipFrameAssembler.AppendLookInFrames(
|
||||
_clipFrame,
|
||||
lookInFrames,
|
||||
assembly);
|
||||
}
|
||||
|
||||
public void PrepareClipFrame(int terrainUploadCount) =>
|
||||
Operations.Add($"prepare-clip:{terrainUploadCount}");
|
||||
|
||||
public void SetTerrainClip(ReadOnlySpan<Vector4> planes) =>
|
||||
Operations.Add("terrain-clip");
|
||||
|
||||
public void ClearClipRouting() => Operations.Add("clear-routing");
|
||||
public void UseIndoorMembershipOnlyRouting() => Operations.Add("indoor-routing");
|
||||
public void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice) =>
|
||||
Operations.Add($"cell-portal-routing:{cellId:X8}:{slice.Slot}");
|
||||
public void PrepareCellBatches(RetailPViewFrameInput frame, HashSet<uint> visibleCellIds) =>
|
||||
Operations.Add("prepare-cells");
|
||||
public void DrawOpaqueCellShells(HashSet<uint> cellIds) => Operations.Add("opaque-shells");
|
||||
public bool CellHasTransparentShell(uint cellId) => HasTransparentShells;
|
||||
public void DrawTransparentCellShells(HashSet<uint> cellIds) => Operations.Add("transparent-shells");
|
||||
public void DrawTransparentCellShellsOrdered(IReadOnlyList<uint> cellIds) =>
|
||||
Operations.Add("transparent-shells-ordered");
|
||||
public void DrawEntityBucket(
|
||||
RetailPViewFrameInput frame,
|
||||
IReadOnlyList<WorldEntity> entities,
|
||||
HashSet<uint>? visibleCellIds) => Operations.Add("entity-bucket");
|
||||
public void EmitClipRouteProbe(
|
||||
ClipFrameAssembly clipAssembly,
|
||||
ClipViewSlice slice,
|
||||
int sliceIndex) => Operations.Add("clip-probe");
|
||||
public void DrawLandscapeSlice(
|
||||
RetailPViewFrameInput frame,
|
||||
RetailPViewLandscapeSliceContext context)
|
||||
{
|
||||
Operations.Add("landscape-early");
|
||||
if (context.EntityDraw is RenderFrameEntityDrawRequest request)
|
||||
{
|
||||
RenderFrameView view = request.View;
|
||||
DrawEntityRoute(
|
||||
frame.Camera,
|
||||
in view,
|
||||
request.Route,
|
||||
request.RouteIndex,
|
||||
request.CellId,
|
||||
request.TupleLandblockId);
|
||||
}
|
||||
}
|
||||
|
||||
public void DrawLandscapeSliceLate(
|
||||
RetailPViewFrameInput frame,
|
||||
RetailPViewLandscapeLateSliceContext context)
|
||||
{
|
||||
Operations.Add("landscape-late");
|
||||
if (context.EntityDraw is RenderFrameEntityDrawRequest request)
|
||||
{
|
||||
RenderFrameView view = request.View;
|
||||
DrawEntityRoute(
|
||||
frame.Camera,
|
||||
in view,
|
||||
request.Route,
|
||||
request.RouteIndex,
|
||||
request.CellId,
|
||||
request.TupleLandblockId);
|
||||
}
|
||||
}
|
||||
|
||||
public void DrawLandscapeStaticParticles(
|
||||
RetailPViewFrameInput frame,
|
||||
RetailPViewLandscapeStaticParticleContext context) =>
|
||||
Operations.Add("landscape-static-particles");
|
||||
public void DrawLandscapeBuildingShellSlice(
|
||||
RetailPViewFrameInput frame,
|
||||
RetailPViewLandscapeBuildingShellSliceContext context)
|
||||
{
|
||||
Operations.Add("landscape-building-shell");
|
||||
if (context.EntityDraw is RenderFrameEntityDrawRequest request)
|
||||
{
|
||||
RenderFrameView view = request.View;
|
||||
DrawEntityRoute(
|
||||
frame.Camera,
|
||||
in view,
|
||||
request.Route,
|
||||
request.RouteIndex,
|
||||
request.CellId,
|
||||
request.TupleLandblockId);
|
||||
}
|
||||
}
|
||||
public void ClearInteriorDepth() => Operations.Add("interior-depth-clear");
|
||||
public void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("exit-mask");
|
||||
public void DrawLookInPortalPunch(
|
||||
RetailPViewFrameInput frame,
|
||||
RetailPViewCellSliceContext context,
|
||||
int portalIndex) => Operations.Add("look-in-punch");
|
||||
public void DrawUnattachedSceneParticles(
|
||||
RetailPViewFrameInput frame,
|
||||
bool outdoorCells) => Operations.Add(
|
||||
outdoorCells
|
||||
? "unattached-particles-outdoor"
|
||||
: "unattached-particles-interior");
|
||||
public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha");
|
||||
public void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
|
||||
Operations.Add("landscape-alpha-farther");
|
||||
public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles");
|
||||
public void DrawDynamicsParticles(
|
||||
RetailPViewFrameInput frame,
|
||||
IReadOnlySet<uint> ownerIds) =>
|
||||
Operations.Add("dynamics-particles");
|
||||
public void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result) => Operations.Add("diagnostics");
|
||||
|
||||
public void BeginEntityFrame(in RenderFrameView view) =>
|
||||
Operations.Add("entity-frame-begin");
|
||||
|
||||
public bool DrawEntityRoute(
|
||||
ICamera camera,
|
||||
in RenderFrameView view,
|
||||
RenderFrameCandidateRoute route,
|
||||
int routeIndex,
|
||||
uint cellId,
|
||||
uint tupleLandblockId)
|
||||
{
|
||||
Operations.Add(
|
||||
$"entity-route:{route}:{routeIndex}:{cellId:X8}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public void CompleteEntityFrame(in RenderFrameView view) =>
|
||||
Operations.Add("entity-frame-complete");
|
||||
|
||||
public void AbortEntityFrame() =>
|
||||
Operations.Add("entity-frame-abort");
|
||||
|
||||
public void Dispose() => _clipFrame.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -738,15 +738,14 @@ public sealed class WorldSceneRendererTests
|
|||
_calls = calls;
|
||||
// Distinct flood-only vs in-view sets: 0x01010003 is a look-in
|
||||
// cell that is drawn but never part of the main flood.
|
||||
var interiorPartition = new InteriorEntityPartition.Result();
|
||||
_interiorResult = new RetailPViewFrameResult().Reset(
|
||||
new PortalVisibilityFrame(),
|
||||
new ClipFrameAssembly(),
|
||||
[0x01010001u],
|
||||
[0x01010001u, 0x01010003u],
|
||||
RetailPViewRenderer.LegacyDiagnosticCounts(interiorPartition),
|
||||
RetailPViewRenderer.LegacySourceCounts(interiorPartition),
|
||||
interiorPartition);
|
||||
default,
|
||||
default,
|
||||
diagnosticPartition: null);
|
||||
var outdoorPortalFrame = new PortalVisibilityFrame();
|
||||
outdoorPortalFrame.OutsideView.Add(new ViewPolygon(
|
||||
[
|
||||
|
|
@ -759,7 +758,10 @@ public sealed class WorldSceneRendererTests
|
|||
outdoorPortalFrame,
|
||||
ClipFrameAssembler.Assemble(ClipFrame.NoClip(), outdoorPortalFrame),
|
||||
[],
|
||||
new InteriorEntityPartition.Result());
|
||||
[],
|
||||
default,
|
||||
default,
|
||||
diagnosticPartition: null);
|
||||
}
|
||||
|
||||
public IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; } =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue