fix #451: stabilize portal seam rendering
This commit is contained in:
parent
f6fe0f2a4f
commit
1d2f2f738f
29 changed files with 1650 additions and 239 deletions
|
|
@ -24,6 +24,48 @@ What does NOT go here:
|
||||||
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
||||||
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
||||||
|
|
||||||
|
## #451 — Sanctuary cathedral portal seam leaks exterior world and particles
|
||||||
|
|
||||||
|
**Status:** DONE — OWNER-ACCEPTED 2026-08-27 ("Looks good! Gate pass now!").
|
||||||
|
**Component:** Vulkan PView / nested building look-ins / landscape alpha ordering.
|
||||||
|
|
||||||
|
At the open-air Sanctuary cathedral seam between `0xF4180104` and
|
||||||
|
`0xF4180106`, small player or chase-camera movements could make cathedral
|
||||||
|
halves, their shadows, or floor textures flap; expose the world background,
|
||||||
|
trees, a nearby building, and waterfall/steam particles through opaque
|
||||||
|
geometry; or clip the local player in half. Camera zoom alone reproduced the
|
||||||
|
failure. The final particle repro was
|
||||||
|
`0xF4180104 [31.111177 57.648911 169.804993]`.
|
||||||
|
|
||||||
|
This was a renderer contract failure, not bad Sanctuary data. The correction:
|
||||||
|
|
||||||
|
- classifies exterior building seeds with retail's `F_EPSILON` instead of the
|
||||||
|
ordinary 1 cm EnvCell traversal tolerance and retains the exact accepted
|
||||||
|
`CBldPortal` aperture;
|
||||||
|
- appends every nested look-in cell's own `portal_view` to the frame clip
|
||||||
|
buffer, punches only the accepted seed portal, and clips shells plus static
|
||||||
|
and particle alpha to that view;
|
||||||
|
- pairs each look-in with only its own exterior building shell and reproduces
|
||||||
|
retail's per-building alpha barriers;
|
||||||
|
- keeps dynamic objects whole after the CPU PortalList sphere test, matching
|
||||||
|
retail `DrawMesh` and preventing the player-slicing regression;
|
||||||
|
- submits attached and ownerless exterior particles inside `LScape::draw` for
|
||||||
|
every PView root, eliminating the late post-world replay that let waterfall
|
||||||
|
alpha repaint already-drawn cathedral cells; and
|
||||||
|
- treats authored `SeenOutside` open-air cells as atmospheric outdoor cells so
|
||||||
|
sky/shadow activation no longer flips merely because the camera acquired an
|
||||||
|
EnvCell root.
|
||||||
|
|
||||||
|
The fix is renderer-wide: no Sanctuary coordinate or asset special case is
|
||||||
|
present in production. Installed-DAT regressions retain the reported camera
|
||||||
|
handoff, lateral transition, and exact steam-seam views. The temporary live
|
||||||
|
emitter/shell trace was removed at closeout.
|
||||||
|
|
||||||
|
**Acceptance:** owner swept both cells, moved across the seam, and zoomed the
|
||||||
|
camera through the former trigger positions. Cathedral/world flapping, shadow
|
||||||
|
toggle, player clipping, exterior geometry, and waterfall/particle bleed were
|
||||||
|
absent in the accepted build.
|
||||||
|
|
||||||
## #450 — Fast character re-entry after logout can remain in portal space at `lb 0/0`
|
## #450 — Fast character re-entry after logout can remain in portal space at `lb 0/0`
|
||||||
|
|
||||||
**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate.
|
**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate.
|
||||||
|
|
@ -18959,6 +19001,13 @@ DrawDynamicsParticles only sees dynamics-last cone survivors.
|
||||||
**Gate:** stand inside, look out the doorway at the town portal — the
|
**Gate:** stand inside, look out the doorway at the town portal — the
|
||||||
swirl renders through the door.
|
swirl renders through the door.
|
||||||
|
|
||||||
|
**2026-08-27 ordering correction (#451):** the old "once per frame after the
|
||||||
|
look-ins" placement was sufficient for this gate but did not preserve the
|
||||||
|
installed `outside_view` and could repaint an already-drawn open-air building.
|
||||||
|
Ownerless emitters now submit once per outside-view slice with that slice's
|
||||||
|
clip slot. When building look-ins exist they enter the pre-building alpha
|
||||||
|
barrier; otherwise they drain at the end of `LScape::draw`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## #132 — Candle flame disappears when the through-opening background is behind it
|
## #132 — Candle flame disappears when the through-opening background is behind it
|
||||||
|
|
@ -18999,6 +19048,13 @@ against interiors). The owner-id filter carries over; cell-pass and
|
||||||
dynamics-pass emitters keep their own passes (owners never in the
|
dynamics-pass emitters keep their own passes (owners never in the
|
||||||
outdoor-static set → no double-draw).
|
outdoor-static set → no double-draw).
|
||||||
|
|
||||||
|
**2026-08-27 correction (#451):** the post-frame placement fixed this narrow
|
||||||
|
flame-overpaint case but was too late for nested open-air cathedral cells: an
|
||||||
|
exterior waterfall could repaint their completed opaque floor. Outdoor-static
|
||||||
|
and ownerless particles now submit inside `LScape::draw` under the exact
|
||||||
|
outside-view clip slot, with retail's pre-building/per-building alpha barriers;
|
||||||
|
the post-world PView replay is deleted.
|
||||||
|
|
||||||
**Gate:** both sides — indoors with the opening behind the candle, and
|
**Gate:** both sides — indoors with the opening behind the candle, and
|
||||||
outdoors at the angle that previously erased it.
|
outdoors at the angle that previously erased it.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,13 @@ public enum TerrainClipMode
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes);
|
public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies one cell inside one nested building look-in. The same EnvCell can
|
||||||
|
/// be reached by more than one building PView, so a cell id alone is not a
|
||||||
|
/// sufficient routing key.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct LookInClipCell(int FrameIndex, uint CellId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: populated clip buffers
|
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: populated clip buffers
|
||||||
/// plus routing data consumed by the render orchestration.
|
/// plus routing data consumed by the render orchestration.
|
||||||
|
|
@ -57,6 +64,12 @@ public sealed class ClipFrameAssembly
|
||||||
/// <summary>Full retail portal_view slices per visible cell.</summary>
|
/// <summary>Full retail portal_view slices per visible cell.</summary>
|
||||||
public Dictionary<uint, ClipViewSlice[]> CellIdToViewSlices { get; } = new();
|
public Dictionary<uint, ClipViewSlice[]> CellIdToViewSlices { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>First drawable slice slot per nested look-in cell.</summary>
|
||||||
|
public Dictionary<LookInClipCell, int> LookInCellToSlot { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>All retail portal_view slices per nested look-in cell.</summary>
|
||||||
|
public Dictionary<LookInClipCell, ClipViewSlice[]> LookInCellToViewSlices { get; } = new();
|
||||||
|
|
||||||
/// <summary>Full retail outside_view slices.</summary>
|
/// <summary>Full retail outside_view slices.</summary>
|
||||||
public ClipViewSlice[] OutsideViewSlices { get; private set; } = System.Array.Empty<ClipViewSlice>();
|
public ClipViewSlice[] OutsideViewSlices { get; private set; } = System.Array.Empty<ClipViewSlice>();
|
||||||
|
|
||||||
|
|
@ -93,6 +106,8 @@ public sealed class ClipFrameAssembly
|
||||||
Frame = frame;
|
Frame = frame;
|
||||||
foreach (ClipViewSlice[] slices in CellIdToViewSlices.Values)
|
foreach (ClipViewSlice[] slices in CellIdToViewSlices.Values)
|
||||||
ReturnSlices(slices);
|
ReturnSlices(slices);
|
||||||
|
foreach (ClipViewSlice[] slices in LookInCellToViewSlices.Values)
|
||||||
|
ReturnSlices(slices);
|
||||||
foreach (int[] slots in CellIdToViewSlots.Values)
|
foreach (int[] slots in CellIdToViewSlots.Values)
|
||||||
ReturnSlots(slots);
|
ReturnSlots(slots);
|
||||||
if (OutsideViewSlices.Length != 0)
|
if (OutsideViewSlices.Length != 0)
|
||||||
|
|
@ -101,6 +116,8 @@ public sealed class ClipFrameAssembly
|
||||||
CellIdToSlot.Clear();
|
CellIdToSlot.Clear();
|
||||||
CellIdToViewSlots.Clear();
|
CellIdToViewSlots.Clear();
|
||||||
CellIdToViewSlices.Clear();
|
CellIdToViewSlices.Clear();
|
||||||
|
LookInCellToSlot.Clear();
|
||||||
|
LookInCellToViewSlices.Clear();
|
||||||
PerCellPlaneCounts.Clear();
|
PerCellPlaneCounts.Clear();
|
||||||
OutsideViewSlices = System.Array.Empty<ClipViewSlice>();
|
OutsideViewSlices = System.Array.Empty<ClipViewSlice>();
|
||||||
SliceScratch.Clear();
|
SliceScratch.Clear();
|
||||||
|
|
@ -363,6 +380,70 @@ public static class ClipFrameAssembler
|
||||||
return assembly;
|
return assembly;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends the cell views used by nested <c>DrawBuilding -> DrawPortal</c>
|
||||||
|
/// PViews to the already assembled frame. Retail installs each nested
|
||||||
|
/// cell's own <c>portal_view</c> before drawing its shell and object list;
|
||||||
|
/// these slots must therefore be published with the main frame before the
|
||||||
|
/// first draw is recorded.
|
||||||
|
/// </summary>
|
||||||
|
public static void AppendLookInFrames(
|
||||||
|
ClipFrame frame,
|
||||||
|
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
|
||||||
|
ClipFrameAssembly assembly)
|
||||||
|
{
|
||||||
|
System.ArgumentNullException.ThrowIfNull(frame);
|
||||||
|
System.ArgumentNullException.ThrowIfNull(lookInFrames);
|
||||||
|
System.ArgumentNullException.ThrowIfNull(assembly);
|
||||||
|
if (!ReferenceEquals(frame, assembly.Frame))
|
||||||
|
throw new System.ArgumentException(
|
||||||
|
"The look-in slots must be appended to the assembly's clip frame.",
|
||||||
|
nameof(frame));
|
||||||
|
|
||||||
|
for (int frameIndex = 0; frameIndex < lookInFrames.Count; frameIndex++)
|
||||||
|
{
|
||||||
|
PortalVisibilityFrame lookIn = lookInFrames[frameIndex];
|
||||||
|
foreach (uint cellId in lookIn.OrderedVisibleCells)
|
||||||
|
{
|
||||||
|
if (!lookIn.CellViews.TryGetValue(cellId, out CellView? view))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
List<ClipViewSlice> slices = assembly.SliceScratch;
|
||||||
|
slices.Clear();
|
||||||
|
foreach (ViewPolygon poly in view.Polygons)
|
||||||
|
{
|
||||||
|
ClipPlaneSet cps = ClipPlaneSet.From(poly);
|
||||||
|
if (cps.IsNothingVisible)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int slot;
|
||||||
|
Vector4[] planes;
|
||||||
|
if (cps.Count > 0)
|
||||||
|
{
|
||||||
|
planes = cps.PlaneArray;
|
||||||
|
slot = frame.AppendSlot(planes);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
planes = System.Array.Empty<Vector4>();
|
||||||
|
slot = 0;
|
||||||
|
assembly.ScissorFallbacks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slices.Count == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
ClipViewSlice[] packed = assembly.CopySlices(slices);
|
||||||
|
var key = new LookInClipCell(frameIndex, cellId);
|
||||||
|
assembly.LookInCellToViewSlices.Add(key, packed);
|
||||||
|
assembly.LookInCellToSlot.Add(key, packed[0].Slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static Vector4 AabbOf(ViewPolygon poly) =>
|
private static Vector4 AabbOf(ViewPolygon poly) =>
|
||||||
new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY);
|
new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ internal sealed class AtmosphericFrameInputState : IAtmosphericWorldFrameSink
|
||||||
_host.DeltaSeconds,
|
_host.DeltaSeconds,
|
||||||
_host.ViewportWidth,
|
_host.ViewportWidth,
|
||||||
_host.ViewportHeight,
|
_host.ViewportHeight,
|
||||||
IsOutdoor: world.Roots.RenderSky && !world.Roots.CameraInsideCell);
|
IsOutdoor: world.Roots.IsAtmosphericallyOutdoor);
|
||||||
_published = true;
|
_published = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -431,8 +431,7 @@ internal sealed class AtmosphericPostProcessGraph :
|
||||||
var environment = new DirectionalShadowEnvironmentInput(
|
var environment = new DirectionalShadowEnvironmentInput(
|
||||||
PackEnabled: true,
|
PackEnabled: true,
|
||||||
PortalOrLoginCoverVisible: foundation.PortalViewportVisible,
|
PortalOrLoginCoverVisible: foundation.PortalViewportVisible,
|
||||||
PlayerInsideCell: world.Roots.PlayerInsideCell
|
PlayerInsideCell: world.Roots.PlayerOrCameraInsideEnclosedCell,
|
||||||
|| world.Roots.CameraInsideCell,
|
|
||||||
source,
|
source,
|
||||||
foundation.Atmosphere,
|
foundation.Atmosphere,
|
||||||
ActiveDayGroupMultiplier: Math.Clamp(
|
ActiveDayGroupMultiplier: Math.Clamp(
|
||||||
|
|
@ -449,7 +448,7 @@ internal sealed class AtmosphericPostProcessGraph :
|
||||||
// or deactivation can never leave a stale exclusion set applied to
|
// or deactivation can never leave a stale exclusion set applied to
|
||||||
// the dispatcher (see FoliageWindExclusions's doc comment).
|
// the dispatcher (see FoliageWindExclusions's doc comment).
|
||||||
worldMeshes.FoliageWindExclusions = _foliageWindExclusions;
|
worldMeshes.FoliageWindExclusions = _foliageWindExclusions;
|
||||||
bool isOutdoor = world.Roots.RenderSky && !world.Roots.CameraInsideCell;
|
bool isOutdoor = world.Roots.IsAtmosphericallyOutdoor;
|
||||||
AtmosphericFrameBufferBinding shadowAtmosphericFrame =
|
AtmosphericFrameBufferBinding shadowAtmosphericFrame =
|
||||||
BuildShadowAtmosphericFrameBinding(frame, foundation.Atmosphere.Kind, isOutdoor);
|
BuildShadowAtmosphericFrameBinding(frame, foundation.Atmosphere.Kind, isOutdoor);
|
||||||
var input = new DirectionalSunShadowRenderInput(
|
var input = new DirectionalSunShadowRenderInput(
|
||||||
|
|
|
||||||
|
|
@ -210,8 +210,7 @@ internal class DeclaredFullscreenRenderPackGraph :
|
||||||
var environment = new DirectionalShadowEnvironmentInput(
|
var environment = new DirectionalShadowEnvironmentInput(
|
||||||
PackEnabled: true,
|
PackEnabled: true,
|
||||||
PortalOrLoginCoverVisible: foundation.PortalViewportVisible,
|
PortalOrLoginCoverVisible: foundation.PortalViewportVisible,
|
||||||
PlayerInsideCell: world.Roots.PlayerInsideCell
|
PlayerInsideCell: world.Roots.PlayerOrCameraInsideEnclosedCell,
|
||||||
|| world.Roots.CameraInsideCell,
|
|
||||||
source,
|
source,
|
||||||
foundation.Atmosphere,
|
foundation.Atmosphere,
|
||||||
ActiveDayGroupMultiplier: Math.Clamp(
|
ActiveDayGroupMultiplier: Math.Clamp(
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,8 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
|
|
||||||
private const uint QuadStrideBytes = 4 * sizeof(float);
|
private const uint QuadStrideBytes = 4 * sizeof(float);
|
||||||
|
|
||||||
/// <summary>Floats per mesh-particle instance: a <c>mat4</c> plus an RGBA colour.</summary>
|
private static readonly uint MeshInstanceStrideBytes =
|
||||||
internal const int MeshInstanceFloats = 20;
|
(uint)sizeof(MeshParticleGpuInstance);
|
||||||
|
|
||||||
private const uint MeshInstanceStrideBytes = MeshInstanceFloats * sizeof(float);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The billboard layout: the shared unit quad at vertex rate, and one
|
/// The billboard layout: the shared unit quad at vertex rate, and one
|
||||||
|
|
@ -103,7 +101,8 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
// Location 6 is `in uint aTextureIndex` — an INTEGER shader input,
|
// Location 6 is `in uint aTextureIndex` — an INTEGER shader input,
|
||||||
// so R8G8B8A8's normalized cousin would be wrong in kind. It is one
|
// so R8G8B8A8's normalized cousin would be wrong in kind. It is one
|
||||||
// 32-bit unsigned value; Float1 would reinterpret its bits.
|
// 32-bit unsigned value; Float1 would reinterpret its bits.
|
||||||
new GpuVertexAttribute(6, GpuVertexFormat.UInt1, 64, Binding: 1)));
|
new GpuVertexAttribute(6, GpuVertexFormat.UInt1, 64, Binding: 1),
|
||||||
|
new GpuVertexAttribute(7, GpuVertexFormat.UInt1, 68, Binding: 1)));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The mesh-particle layout: the shared world-mesh vertex at vertex rate,
|
/// The mesh-particle layout: the shared world-mesh vertex at vertex rate,
|
||||||
|
|
@ -126,7 +125,8 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
new GpuVertexAttribute(4, GpuVertexFormat.Float4, 16, Binding: 1),
|
new GpuVertexAttribute(4, GpuVertexFormat.Float4, 16, Binding: 1),
|
||||||
new GpuVertexAttribute(5, GpuVertexFormat.Float4, 32, Binding: 1),
|
new GpuVertexAttribute(5, GpuVertexFormat.Float4, 32, Binding: 1),
|
||||||
new GpuVertexAttribute(6, GpuVertexFormat.Float4, 48, Binding: 1),
|
new GpuVertexAttribute(6, GpuVertexFormat.Float4, 48, Binding: 1),
|
||||||
new GpuVertexAttribute(7, GpuVertexFormat.Float4, 64, Binding: 1)));
|
new GpuVertexAttribute(7, GpuVertexFormat.Float4, 64, Binding: 1),
|
||||||
|
new GpuVertexAttribute(8, GpuVertexFormat.UInt1, 80, Binding: 1)));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The RHI arm's constructor. No GL context, no <c>Shader</c>, no
|
/// The RHI arm's constructor. No GL context, no <c>Shader</c>, no
|
||||||
|
|
@ -340,22 +340,22 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
while (submission.Kind == ParticleSubmissionKind.Mesh
|
while (submission.Kind == ParticleSubmissionKind.Mesh
|
||||||
&& _meshDrawListScratch[submission.DrawIndex].Key == meshKey);
|
&& _meshDrawListScratch[submission.DrawIndex].Key == meshKey);
|
||||||
|
|
||||||
int neededFloats = _meshRunScratch.Count * MeshInstanceFloats;
|
int neededInstances = _meshRunScratch.Count;
|
||||||
if (_meshInstanceScratch.Length < neededFloats)
|
if (_meshInstanceScratch.Length < neededInstances)
|
||||||
_meshInstanceScratch = new float[neededFloats + 256 * MeshInstanceFloats];
|
_meshInstanceScratch = new MeshParticleGpuInstance[neededInstances + 256];
|
||||||
for (int instance = 0; instance < _meshRunScratch.Count; instance++)
|
for (int instance = 0; instance < _meshRunScratch.Count; instance++)
|
||||||
{
|
{
|
||||||
WriteMeshGpuInstance(
|
WriteMeshGpuInstance(
|
||||||
_meshInstanceScratch,
|
ref _meshInstanceScratch[instance],
|
||||||
instance * MeshInstanceFloats,
|
|
||||||
_meshRunScratch[instance]);
|
_meshRunScratch[instance]);
|
||||||
}
|
}
|
||||||
|
|
||||||
GpuRingAllocation instances = WriteVertexRing<float>(
|
GpuRingAllocation instances = WriteVertexRing<MeshParticleGpuInstance>(
|
||||||
frame,
|
frame,
|
||||||
_meshInstanceScratch.AsSpan(0, neededFloats));
|
_meshInstanceScratch.AsSpan(0, neededInstances));
|
||||||
DrawMeshBatchRhi(
|
DrawMeshBatchRhi(
|
||||||
encoder,
|
encoder,
|
||||||
|
frame,
|
||||||
global,
|
global,
|
||||||
batch,
|
batch,
|
||||||
viewProjection,
|
viewProjection,
|
||||||
|
|
@ -384,7 +384,13 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
GpuRingAllocation ring = WriteVertexRing<BillboardGpuInstance>(
|
GpuRingAllocation ring = WriteVertexRing<BillboardGpuInstance>(
|
||||||
frame,
|
frame,
|
||||||
_instanceScratch.AsSpan(0, instances.Count));
|
_instanceScratch.AsSpan(0, instances.Count));
|
||||||
BindBillboardPipeline(encoder, viewProjection, additive, ring.Buffer, ring.OffsetBytes);
|
BindBillboardPipeline(
|
||||||
|
encoder,
|
||||||
|
frame,
|
||||||
|
viewProjection,
|
||||||
|
additive,
|
||||||
|
ring.Buffer,
|
||||||
|
ring.OffsetBytes);
|
||||||
encoder.DrawIndexed(
|
encoder.DrawIndexed(
|
||||||
(uint)QuadIndices.Length,
|
(uint)QuadIndices.Length,
|
||||||
(uint)instances.Count,
|
(uint)instances.Count,
|
||||||
|
|
@ -401,6 +407,7 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void BindBillboardPipeline(
|
private void BindBillboardPipeline(
|
||||||
IGpuPassEncoder encoder,
|
IGpuPassEncoder encoder,
|
||||||
|
IGpuFrame frame,
|
||||||
Matrix4x4 viewProjection,
|
Matrix4x4 viewProjection,
|
||||||
bool additive,
|
bool additive,
|
||||||
IGpuBuffer instanceBuffer,
|
IGpuBuffer instanceBuffer,
|
||||||
|
|
@ -426,10 +433,15 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
encoder.BindVertexBuffer(0, _quadVertexBuffer!, 0);
|
encoder.BindVertexBuffer(0, _quadVertexBuffer!, 0);
|
||||||
encoder.BindVertexBuffer(1, instanceBuffer, instanceOffsetBytes);
|
encoder.BindVertexBuffer(1, instanceBuffer, instanceOffsetBytes);
|
||||||
encoder.BindIndexBuffer(_quadIndexBuffer!, 0, GpuIndexType.UInt32);
|
encoder.BindIndexBuffer(_quadIndexBuffer!, 0, GpuIndexType.UInt32);
|
||||||
|
WorldFrameSectionBinding.BindClipRegions(
|
||||||
|
encoder,
|
||||||
|
_scope!.Sections,
|
||||||
|
frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawMeshBatchRhi(
|
private void DrawMeshBatchRhi(
|
||||||
IGpuPassEncoder encoder,
|
IGpuPassEncoder encoder,
|
||||||
|
IGpuFrame frame,
|
||||||
GlobalMeshBuffer global,
|
GlobalMeshBuffer global,
|
||||||
ObjectRenderBatch batch,
|
ObjectRenderBatch batch,
|
||||||
Matrix4x4 viewProjection,
|
Matrix4x4 viewProjection,
|
||||||
|
|
@ -472,6 +484,10 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
"The shared mesh arena has no index store."),
|
"The shared mesh arena has no index store."),
|
||||||
0,
|
0,
|
||||||
GpuIndexType.UInt16);
|
GpuIndexType.UInt16);
|
||||||
|
WorldFrameSectionBinding.BindClipRegions(
|
||||||
|
encoder,
|
||||||
|
_scope!.Sections,
|
||||||
|
frame);
|
||||||
encoder.DrawIndexed(
|
encoder.DrawIndexed(
|
||||||
(uint)batch.IndexCount,
|
(uint)batch.IndexCount,
|
||||||
instanceCount,
|
instanceCount,
|
||||||
|
|
@ -520,9 +536,8 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
Array.Resize(ref _preparedInstanceOffsets, count + 256);
|
Array.Resize(ref _preparedInstanceOffsets, count + 256);
|
||||||
if (_instanceScratch.Length < count)
|
if (_instanceScratch.Length < count)
|
||||||
Array.Resize(ref _instanceScratch, count + 256);
|
Array.Resize(ref _instanceScratch, count + 256);
|
||||||
int neededMeshFloats = count * MeshInstanceFloats;
|
if (_meshInstanceScratch.Length < count)
|
||||||
if (_meshInstanceScratch.Length < neededMeshFloats)
|
_meshInstanceScratch = new MeshParticleGpuInstance[count + 256];
|
||||||
_meshInstanceScratch = new float[neededMeshFloats + 256 * MeshInstanceFloats];
|
|
||||||
|
|
||||||
int billboardCount = 0;
|
int billboardCount = 0;
|
||||||
int meshCount = 0;
|
int meshCount = 0;
|
||||||
|
|
@ -541,8 +556,7 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
{
|
{
|
||||||
_preparedInstanceOffsets[i] = (uint)meshCount;
|
_preparedInstanceOffsets[i] = (uint)meshCount;
|
||||||
WriteMeshGpuInstance(
|
WriteMeshGpuInstance(
|
||||||
_meshInstanceScratch,
|
ref _meshInstanceScratch[meshCount++],
|
||||||
meshCount++ * MeshInstanceFloats,
|
|
||||||
deferred.Mesh.Instance);
|
deferred.Mesh.Instance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -553,9 +567,9 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
_instanceScratch.AsSpan(0, billboardCount)))
|
_instanceScratch.AsSpan(0, billboardCount)))
|
||||||
: default;
|
: default;
|
||||||
_preparedMeshInstances = meshCount > 0
|
_preparedMeshInstances = meshCount > 0
|
||||||
? SectionOf(WriteVertexRing<float>(
|
? SectionOf(WriteVertexRing<MeshParticleGpuInstance>(
|
||||||
frame,
|
frame,
|
||||||
_meshInstanceScratch.AsSpan(0, meshCount * MeshInstanceFloats)))
|
_meshInstanceScratch.AsSpan(0, meshCount)))
|
||||||
: default;
|
: default;
|
||||||
_preparedAlphaCount = count;
|
_preparedAlphaCount = count;
|
||||||
}
|
}
|
||||||
|
|
@ -591,6 +605,7 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
{
|
{
|
||||||
BindBillboardPipeline(
|
BindBillboardPipeline(
|
||||||
encoder,
|
encoder,
|
||||||
|
RequireRhiFrame(),
|
||||||
viewProjection,
|
viewProjection,
|
||||||
key.Additive,
|
key.Additive,
|
||||||
billboards,
|
billboards,
|
||||||
|
|
@ -632,6 +647,7 @@ public sealed unsafe partial class ParticleRenderer
|
||||||
{
|
{
|
||||||
DrawMeshBatchRhi(
|
DrawMeshBatchRhi(
|
||||||
encoder,
|
encoder,
|
||||||
|
RequireRhiFrame(),
|
||||||
global,
|
global,
|
||||||
batch,
|
batch,
|
||||||
meshViewProjection,
|
meshViewProjection,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
public readonly uint ColorArgb;
|
public readonly uint ColorArgb;
|
||||||
public readonly AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot;
|
public readonly AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot;
|
||||||
public readonly float DistanceSq;
|
public readonly float DistanceSq;
|
||||||
|
public readonly uint ClipSlot;
|
||||||
|
|
||||||
public ParticleInstance(
|
public ParticleInstance(
|
||||||
Vector3 position,
|
Vector3 position,
|
||||||
|
|
@ -54,7 +55,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
Vector3 axisY,
|
Vector3 axisY,
|
||||||
uint colorArgb,
|
uint colorArgb,
|
||||||
AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot,
|
AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot,
|
||||||
float distanceSq)
|
float distanceSq,
|
||||||
|
uint clipSlot)
|
||||||
{
|
{
|
||||||
Position = position;
|
Position = position;
|
||||||
AxisX = axisX;
|
AxisX = axisX;
|
||||||
|
|
@ -62,6 +64,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
ColorArgb = colorArgb;
|
ColorArgb = colorArgb;
|
||||||
TextureSlot = textureSlot;
|
TextureSlot = textureSlot;
|
||||||
DistanceSq = distanceSq;
|
DistanceSq = distanceSq;
|
||||||
|
ClipSlot = clipSlot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +83,16 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
public Vector4 AxisY;
|
public Vector4 AxisY;
|
||||||
public Vector4 Color;
|
public Vector4 Color;
|
||||||
public uint TextureIndex;
|
public uint TextureIndex;
|
||||||
|
public uint ClipSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Vertex-instance ABI shared with particle_mesh.vert.</summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct MeshParticleGpuInstance
|
||||||
|
{
|
||||||
|
public Matrix4x4 Model;
|
||||||
|
public Vector4 Color;
|
||||||
|
public uint ClipSlot;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly struct MeshParticleInstance
|
private readonly struct MeshParticleInstance
|
||||||
|
|
@ -87,12 +100,18 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
public readonly Matrix4x4 Model;
|
public readonly Matrix4x4 Model;
|
||||||
public readonly uint ColorArgb;
|
public readonly uint ColorArgb;
|
||||||
public readonly float DistanceSq;
|
public readonly float DistanceSq;
|
||||||
|
public readonly uint ClipSlot;
|
||||||
|
|
||||||
public MeshParticleInstance(Matrix4x4 model, uint colorArgb, float distanceSq)
|
public MeshParticleInstance(
|
||||||
|
Matrix4x4 model,
|
||||||
|
uint colorArgb,
|
||||||
|
float distanceSq,
|
||||||
|
uint clipSlot)
|
||||||
{
|
{
|
||||||
Model = model;
|
Model = model;
|
||||||
ColorArgb = colorArgb;
|
ColorArgb = colorArgb;
|
||||||
DistanceSq = distanceSq;
|
DistanceSq = distanceSq;
|
||||||
|
ClipSlot = clipSlot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,7 +143,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
internal (int SetCount, long CapacityBytes) DynamicBufferDiagnostics => (0, 0);
|
internal (int SetCount, long CapacityBytes) DynamicBufferDiagnostics => (0, 0);
|
||||||
|
|
||||||
private BillboardGpuInstance[] _instanceScratch = new BillboardGpuInstance[256];
|
private BillboardGpuInstance[] _instanceScratch = new BillboardGpuInstance[256];
|
||||||
private float[] _meshInstanceScratch = new float[256 * 20];
|
private MeshParticleGpuInstance[] _meshInstanceScratch = new MeshParticleGpuInstance[256];
|
||||||
|
|
||||||
// MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame
|
// MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame
|
||||||
// (sky pre/post, scene, per-visible-cell, dynamics, unattached passes),
|
// (sky pre/post, scene, per-visible-cell, dynamics, unattached passes),
|
||||||
|
|
@ -203,7 +222,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
cameraRight,
|
cameraRight,
|
||||||
cameraUp,
|
cameraUp,
|
||||||
emitterFilter,
|
emitterFilter,
|
||||||
scopedEmitters: null);
|
scopedEmitters: null,
|
||||||
|
clipSlot: 0);
|
||||||
FinishDraw(camera, renderPass);
|
FinishDraw(camera, renderPass);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,7 +233,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
ParticleRenderPass renderPass,
|
ParticleRenderPass renderPass,
|
||||||
IReadOnlySet<uint> attachedOwnerIds,
|
IReadOnlySet<uint> attachedOwnerIds,
|
||||||
bool includeUnattached = false,
|
bool includeUnattached = false,
|
||||||
IReadOnlySet<uint>? excludedAttachedOwnerIds = null)
|
IReadOnlySet<uint>? excludedAttachedOwnerIds = null,
|
||||||
|
uint clipSlot = 0)
|
||||||
{
|
{
|
||||||
if (camera is null)
|
if (camera is null)
|
||||||
return;
|
return;
|
||||||
|
|
@ -233,7 +254,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
cameraRight,
|
cameraRight,
|
||||||
cameraUp,
|
cameraUp,
|
||||||
emitterFilter: null,
|
emitterFilter: null,
|
||||||
_scopedEmitterScratch);
|
_scopedEmitterScratch,
|
||||||
|
clipSlot);
|
||||||
FinishDraw(camera, renderPass);
|
FinishDraw(camera, renderPass);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,7 +354,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
Vector3 cameraRight,
|
Vector3 cameraRight,
|
||||||
Vector3 cameraUp,
|
Vector3 cameraUp,
|
||||||
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter,
|
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter,
|
||||||
IReadOnlyList<RuntimeParticleEmitter>? scopedEmitters)
|
IReadOnlyList<RuntimeParticleEmitter>? scopedEmitters,
|
||||||
|
uint clipSlot)
|
||||||
{
|
{
|
||||||
var draws = _drawListScratch;
|
var draws = _drawListScratch;
|
||||||
draws.Clear();
|
draws.Clear();
|
||||||
|
|
@ -348,6 +371,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
cameraWorldPos,
|
cameraWorldPos,
|
||||||
cameraRight,
|
cameraRight,
|
||||||
cameraUp,
|
cameraUp,
|
||||||
|
clipSlot,
|
||||||
ref sequence);
|
ref sequence);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -356,7 +380,13 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
foreach (RuntimeParticleEmitter emitter in _particles.EnumerateRenderableEmitters(renderPass))
|
foreach (RuntimeParticleEmitter emitter in _particles.EnumerateRenderableEmitters(renderPass))
|
||||||
{
|
{
|
||||||
if (emitterFilter is null || emitterFilter(emitter))
|
if (emitterFilter is null || emitterFilter(emitter))
|
||||||
AppendEmitterDraws(emitter, cameraWorldPos, cameraRight, cameraUp, ref sequence);
|
AppendEmitterDraws(
|
||||||
|
emitter,
|
||||||
|
cameraWorldPos,
|
||||||
|
cameraRight,
|
||||||
|
cameraUp,
|
||||||
|
clipSlot,
|
||||||
|
ref sequence);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -365,6 +395,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
Vector3 cameraWorldPos,
|
Vector3 cameraWorldPos,
|
||||||
Vector3 cameraRight,
|
Vector3 cameraRight,
|
||||||
Vector3 cameraUp,
|
Vector3 cameraUp,
|
||||||
|
uint clipSlot,
|
||||||
ref int sequence)
|
ref int sequence)
|
||||||
{
|
{
|
||||||
List<ParticleDraw> draws = _drawListScratch;
|
List<ParticleDraw> draws = _drawListScratch;
|
||||||
|
|
@ -385,7 +416,13 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId;
|
uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId;
|
||||||
if (gfxObjId != 0
|
if (gfxObjId != 0
|
||||||
&& ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh
|
&& ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh
|
||||||
&& TryAppendMeshDraws(em, p, gfxObjId, cameraWorldPos, ref sequence))
|
&& TryAppendMeshDraws(
|
||||||
|
em,
|
||||||
|
p,
|
||||||
|
gfxObjId,
|
||||||
|
cameraWorldPos,
|
||||||
|
clipSlot,
|
||||||
|
ref sequence))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -483,7 +520,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
axisY,
|
axisY,
|
||||||
p.ColorArgb,
|
p.ColorArgb,
|
||||||
gfxInfo.TextureSlot,
|
gfxInfo.TextureSlot,
|
||||||
distSq)));
|
distSq,
|
||||||
|
clipSlot)));
|
||||||
_submissionScratch.Add(new ParticleSubmission(
|
_submissionScratch.Add(new ParticleSubmission(
|
||||||
ParticleSubmissionKind.Billboard,
|
ParticleSubmissionKind.Billboard,
|
||||||
drawIndex,
|
drawIndex,
|
||||||
|
|
@ -497,6 +535,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
Particle particle,
|
Particle particle,
|
||||||
uint gfxObjId,
|
uint gfxObjId,
|
||||||
Vector3 cameraWorldPosition,
|
Vector3 cameraWorldPosition,
|
||||||
|
uint clipSlot,
|
||||||
ref int sequence)
|
ref int sequence)
|
||||||
{
|
{
|
||||||
if (_meshAdapter is null || !MeshParticlesAvailable)
|
if (_meshAdapter is null || !MeshParticlesAvailable)
|
||||||
|
|
@ -520,7 +559,11 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
model,
|
model,
|
||||||
cameraWorldPosition);
|
cameraWorldPosition);
|
||||||
float distanceSq = viewerDistance * viewerDistance;
|
float distanceSq = viewerDistance * viewerDistance;
|
||||||
var instance = new MeshParticleInstance(model, particle.ColorArgb, distanceSq);
|
var instance = new MeshParticleInstance(
|
||||||
|
model,
|
||||||
|
particle.ColorArgb,
|
||||||
|
distanceSq,
|
||||||
|
clipSlot);
|
||||||
|
|
||||||
for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++)
|
for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++)
|
||||||
{
|
{
|
||||||
|
|
@ -581,35 +624,24 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
TextureIndex = particle.TextureSlot.IsAssigned
|
TextureIndex = particle.TextureSlot.IsAssigned
|
||||||
? particle.TextureSlot.Index
|
? particle.TextureSlot.Index
|
||||||
: NoTextureSlot,
|
: NoTextureSlot,
|
||||||
|
ClipSlot = particle.ClipSlot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void WriteMeshGpuInstance(
|
private static void WriteMeshGpuInstance(
|
||||||
float[] destination,
|
ref MeshParticleGpuInstance destination,
|
||||||
int offset,
|
|
||||||
MeshParticleInstance instance)
|
MeshParticleInstance instance)
|
||||||
{
|
{
|
||||||
Matrix4x4 model = instance.Model;
|
destination = new MeshParticleGpuInstance
|
||||||
destination[offset + 0] = model.M11;
|
{
|
||||||
destination[offset + 1] = model.M12;
|
Model = instance.Model,
|
||||||
destination[offset + 2] = model.M13;
|
Color = new Vector4(
|
||||||
destination[offset + 3] = model.M14;
|
((instance.ColorArgb >> 16) & 0xFF) / 255f,
|
||||||
destination[offset + 4] = model.M21;
|
((instance.ColorArgb >> 8) & 0xFF) / 255f,
|
||||||
destination[offset + 5] = model.M22;
|
(instance.ColorArgb & 0xFF) / 255f,
|
||||||
destination[offset + 6] = model.M23;
|
((instance.ColorArgb >> 24) & 0xFF) / 255f),
|
||||||
destination[offset + 7] = model.M24;
|
ClipSlot = instance.ClipSlot,
|
||||||
destination[offset + 8] = model.M31;
|
};
|
||||||
destination[offset + 9] = model.M32;
|
|
||||||
destination[offset + 10] = model.M33;
|
|
||||||
destination[offset + 11] = model.M34;
|
|
||||||
destination[offset + 12] = model.M41;
|
|
||||||
destination[offset + 13] = model.M42;
|
|
||||||
destination[offset + 14] = model.M43;
|
|
||||||
destination[offset + 15] = model.M44;
|
|
||||||
destination[offset + 16] = ((instance.ColorArgb >> 16) & 0xFF) / 255f;
|
|
||||||
destination[offset + 17] = ((instance.ColorArgb >> 8) & 0xFF) / 255f;
|
|
||||||
destination[offset + 18] = (instance.ColorArgb & 0xFF) / 255f;
|
|
||||||
destination[offset + 19] = ((instance.ColorArgb >> 24) & 0xFF) / 255f;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private TranslucencyKind ResolveMeshBlend(ObjectRenderBatch batch)
|
private TranslucencyKind ResolveMeshBlend(ObjectRenderBatch batch)
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,21 @@ public sealed class PortalVisibilityFrame
|
||||||
private int _processedViewCountsUnderusedFrames;
|
private int _processedViewCountsUnderusedFrames;
|
||||||
private int _orderedVisibleCellsUnderusedFrames;
|
private int _orderedVisibleCellsUnderusedFrames;
|
||||||
private int _todoUnderusedFrames;
|
private int _todoUnderusedFrames;
|
||||||
|
private int _exteriorSeedPortalsUnderusedFrames;
|
||||||
|
|
||||||
internal PortalPolygonVertexStore PolygonVertices => _polygonVertices;
|
internal PortalPolygonVertexStore PolygonVertices => _polygonVertices;
|
||||||
internal int PolygonVertexAllocationCount => _polygonVertices.AllocationCount;
|
internal int PolygonVertexAllocationCount => _polygonVertices.AllocationCount;
|
||||||
internal int RetainedPolygonVertexArrayCount => _polygonVertices.RetainedArrayCount;
|
internal int RetainedPolygonVertexArrayCount => _polygonVertices.RetainedArrayCount;
|
||||||
|
|
||||||
|
// Interior-root look-ins are constructed one building at a time. Keep
|
||||||
|
// the source identity on the retained frame so the landscape pass can
|
||||||
|
// pair retail's portal-only traversal with that building's own exterior
|
||||||
|
// shell instead of repainting every nearby shell after every look-in.
|
||||||
|
// Building ids are publication-local, so the landblock is part of the
|
||||||
|
// identity. An unstamped building uses its seed cell id as the key.
|
||||||
|
internal uint SourceBuildingKey { get; set; }
|
||||||
|
internal uint SourceBuildingLandblockId { get; set; }
|
||||||
|
|
||||||
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
|
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
|
||||||
/// recursively clipped to their portal chain. The cellar-flap fix.</summary>
|
/// recursively clipped to their portal chain. The cellar-flap fix.</summary>
|
||||||
public CellView OutsideView { get; private set; } = new();
|
public CellView OutsideView { get; private set; } = new();
|
||||||
|
|
@ -49,6 +59,15 @@ public sealed class PortalVisibilityFrame
|
||||||
/// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5).</summary>
|
/// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5).</summary>
|
||||||
public Dictionary<uint, CellView> CrossBuildingViews { get; } = new();
|
public Dictionary<uint, CellView> CrossBuildingViews { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exact outside-facing building portals that successfully seeded this
|
||||||
|
/// exterior construction, together with the clipped view region produced
|
||||||
|
/// by that portal. Retail's DrawBuilding pass punches these CBldPortal
|
||||||
|
/// apertures themselves; it does not infer them later from every exit on a
|
||||||
|
/// cell reached by the resulting flood.
|
||||||
|
/// </summary>
|
||||||
|
public List<ExteriorPortalSeed> ExteriorSeedPortals { get; } = new();
|
||||||
|
|
||||||
// Build scratch belongs to the frame so a caller that reuses a frame also reuses the large
|
// Build scratch belongs to the frame so a caller that reuses a frame also reuses the large
|
||||||
// hash tables and the 128-entry convergence trace. This is especially important outdoors,
|
// hash tables and the 128-entry convergence trace. This is especially important outdoors,
|
||||||
// where retail runs one small ConstructView flood per nearby building every frame.
|
// where retail runs one small ConstructView flood per nearby building every frame.
|
||||||
|
|
@ -114,6 +133,7 @@ public sealed class PortalVisibilityFrame
|
||||||
int processedViewCount = ProcessedViewCountsScratch.Count;
|
int processedViewCount = ProcessedViewCountsScratch.Count;
|
||||||
int orderedVisibleCellCount = OrderedVisibleCells.Count;
|
int orderedVisibleCellCount = OrderedVisibleCells.Count;
|
||||||
int todoCount = TodoScratch.Count;
|
int todoCount = TodoScratch.Count;
|
||||||
|
int exteriorSeedPortalCount = ExteriorSeedPortals.Count;
|
||||||
|
|
||||||
if (OutsideView.IsRetainable)
|
if (OutsideView.IsRetainable)
|
||||||
OutsideView.Reset();
|
OutsideView.Reset();
|
||||||
|
|
@ -121,9 +141,14 @@ public sealed class PortalVisibilityFrame
|
||||||
OutsideView = new CellView();
|
OutsideView = new CellView();
|
||||||
ReturnCellViews(CellViews);
|
ReturnCellViews(CellViews);
|
||||||
ReturnCellViews(CrossBuildingViews);
|
ReturnCellViews(CrossBuildingViews);
|
||||||
|
for (int index = 0; index < ExteriorSeedPortals.Count; index++)
|
||||||
|
ReturnCellView(ExteriorSeedPortals[index].View);
|
||||||
CellViews.Clear();
|
CellViews.Clear();
|
||||||
OrderedVisibleCells.Clear();
|
OrderedVisibleCells.Clear();
|
||||||
CrossBuildingViews.Clear();
|
CrossBuildingViews.Clear();
|
||||||
|
ExteriorSeedPortals.Clear();
|
||||||
|
SourceBuildingKey = 0;
|
||||||
|
SourceBuildingLandblockId = 0;
|
||||||
QueuedScratch.Clear();
|
QueuedScratch.Clear();
|
||||||
DrawListedScratch.Clear();
|
DrawListedScratch.Clear();
|
||||||
ProcessedViewCountsScratch.Clear();
|
ProcessedViewCountsScratch.Clear();
|
||||||
|
|
@ -152,6 +177,10 @@ public sealed class PortalVisibilityFrame
|
||||||
orderedVisibleCellCount,
|
orderedVisibleCellCount,
|
||||||
ref _orderedVisibleCellsUnderusedFrames);
|
ref _orderedVisibleCellsUnderusedFrames);
|
||||||
TrimIfCold(TodoScratch, todoCount, ref _todoUnderusedFrames);
|
TrimIfCold(TodoScratch, todoCount, ref _todoUnderusedFrames);
|
||||||
|
TrimIfCold(
|
||||||
|
ExteriorSeedPortals,
|
||||||
|
exteriorSeedPortalCount,
|
||||||
|
ref _exteriorSeedPortalsUnderusedFrames);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal ViewPolygon CopyPolygon(ReadOnlySpan<Vector2> vertices)
|
internal ViewPolygon CopyPolygon(ReadOnlySpan<Vector2> vertices)
|
||||||
|
|
@ -178,11 +207,14 @@ public sealed class PortalVisibilityFrame
|
||||||
private void ReturnCellViews(Dictionary<uint, CellView> views)
|
private void ReturnCellViews(Dictionary<uint, CellView> views)
|
||||||
{
|
{
|
||||||
foreach (CellView view in views.Values)
|
foreach (CellView view in views.Values)
|
||||||
{
|
ReturnCellView(view);
|
||||||
view.Reset();
|
}
|
||||||
if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews)
|
|
||||||
_cellViewPool.Push(view);
|
private void ReturnCellView(CellView view)
|
||||||
}
|
{
|
||||||
|
view.Reset();
|
||||||
|
if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews)
|
||||||
|
_cellViewPool.Push(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void TrimIfCold<TKey, TValue>(
|
private static void TrimIfCold<TKey, TValue>(
|
||||||
|
|
@ -243,6 +275,15 @@ public sealed class PortalVisibilityFrame
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One outside-facing portal accepted as an exterior building-view seed.
|
||||||
|
/// <paramref name="View"/> is the exact installed-view-clipped aperture.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct ExteriorPortalSeed(
|
||||||
|
uint CellId,
|
||||||
|
int PortalIndex,
|
||||||
|
CellView View);
|
||||||
|
|
||||||
public static class PortalVisibilityBuilder
|
public static class PortalVisibilityBuilder
|
||||||
{
|
{
|
||||||
// Side-classification epsilon. Retail's is F_EPSILON = 0.000199999995
|
// Side-classification epsilon. Retail's is F_EPSILON = 0.000199999995
|
||||||
|
|
@ -710,10 +751,27 @@ public static class PortalVisibilityBuilder
|
||||||
// ever built from a knife-edge aperture.
|
// ever built from a knife-edge aperture.
|
||||||
if (i < cell.ClipPlanes.Count)
|
if (i < cell.ClipPlanes.Count)
|
||||||
{
|
{
|
||||||
if (CameraOnInteriorSide(cell, i, cameraPos))
|
|
||||||
continue;
|
|
||||||
if (EyeInPlaneOfPortal(cell, i, cameraPos))
|
if (EyeInPlaneOfPortal(cell, i, cameraPos))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
// Do NOT reuse the ordinary EnvCell traversal tolerance
|
||||||
|
// here. PortalSideEpsilon deliberately admits a 1 cm
|
||||||
|
// stale-root margin, but retail ConstructView(CBldPortal)
|
||||||
|
// classifies the seed with Sidedness's exact F_EPSILON
|
||||||
|
// (0.0002 m). At Sanctuary the 0104 and 0106 exterior
|
||||||
|
// planes coincide; the chase eye can sit ~4 mm outside the
|
||||||
|
// opposite cathedral half while its EnvCell root remains
|
||||||
|
// 0104. Applying the 1 cm margin calls that eye "inside"
|
||||||
|
// 0106 and drops the entire look-in flood, exposing the
|
||||||
|
// landscape/waterfall through half the cathedral.
|
||||||
|
if (CameraOnInteriorSide(
|
||||||
|
cell,
|
||||||
|
i,
|
||||||
|
cameraPos,
|
||||||
|
SeedInPlaneEpsilon))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||||
|
|
@ -741,6 +799,17 @@ public static class PortalVisibilityBuilder
|
||||||
if (clippedRegion.Count == 0)
|
if (clippedRegion.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
// Preserve the exact CBldPortal that produced this view. The
|
||||||
|
// renderer must punch only accepted seed apertures; iterating
|
||||||
|
// every OtherCellId==0xFFFF portal on the reached cell turns a
|
||||||
|
// single accepted opening into unrelated far-depth holes.
|
||||||
|
var seedPortalView = frame.RentCellView();
|
||||||
|
AddRegion(seedPortalView, clippedRegion);
|
||||||
|
frame.ExteriorSeedPortals.Add(new ExteriorPortalSeed(
|
||||||
|
cell.CellId,
|
||||||
|
i,
|
||||||
|
seedPortalView));
|
||||||
|
|
||||||
var seedView = GetOrCreate(frame, frame.CellViews, cell.CellId);
|
var seedView = GetOrCreate(frame, frame.CellViews, cell.CellId);
|
||||||
bool grew = AddRegion(seedView, clippedRegion);
|
bool grew = AddRegion(seedView, clippedRegion);
|
||||||
|
|
||||||
|
|
@ -1068,13 +1137,17 @@ public static class PortalVisibilityBuilder
|
||||||
// InitCell leaves the in-plane case a CANDIDATE for cell portals (Ghidra
|
// InitCell leaves the in-plane case a CANDIDATE for cell portals (Ghidra
|
||||||
// 0x005a4b70); building/exterior SEED portals additionally reject in-plane
|
// 0x005a4b70); building/exterior SEED portals additionally reject in-plane
|
||||||
// via EyeInPlaneOfPortal (retail ConstructView(CBldPortal) IN_PLANE → 0).
|
// via EyeInPlaneOfPortal (retail ConstructView(CBldPortal) IN_PLANE → 0).
|
||||||
private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos)
|
private static bool CameraOnInteriorSide(
|
||||||
|
LoadedCell cell,
|
||||||
|
int portalIndex,
|
||||||
|
Vector3 cameraPos,
|
||||||
|
float epsilon = PortalSideEpsilon)
|
||||||
{
|
{
|
||||||
var plane = cell.ClipPlanes[portalIndex];
|
var plane = cell.ClipPlanes[portalIndex];
|
||||||
if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow
|
if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow
|
||||||
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
|
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
|
||||||
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
|
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
|
||||||
return plane.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon;
|
return plane.InsideSide == 0 ? dot >= -epsilon : dot <= epsilon;
|
||||||
}
|
}
|
||||||
|
|
||||||
// T2 (BR-4): retail ConstructView(CBldPortal)'s Sidedness IN_PLANE reject
|
// T2 (BR-4): retail ConstructView(CBldPortal)'s Sidedness IN_PLANE reject
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,8 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
|
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
|
||||||
private readonly RetailPViewParticleClassifications _particleClassifications = new();
|
private readonly RetailPViewParticleClassifications _particleClassifications = new();
|
||||||
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
|
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
|
||||||
|
private readonly Dictionary<uint, int> _singleCellClipRouting = new(1);
|
||||||
|
private readonly Dictionary<uint, int> _noCellClipRouting = new(0);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Borrowed until the next late landscape pass. The outdoor-root post-world
|
/// Borrowed until the next late landscape pass. The outdoor-root post-world
|
||||||
|
|
@ -207,6 +209,8 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
{
|
{
|
||||||
List<Exception>? failures = null;
|
List<Exception>? failures = null;
|
||||||
TryAbort(_frameGlState.RestoreFrameDefaults);
|
TryAbort(_frameGlState.RestoreFrameDefaults);
|
||||||
|
TryAbort(() => _envCells.SetClipRouting(null));
|
||||||
|
TryAbort(_entities.ClearClipRouting);
|
||||||
TryAbort(_particleClassifications.BeginFrame);
|
TryAbort(_particleClassifications.BeginFrame);
|
||||||
TryAbort(_noSceneParticleEntityIds.Clear);
|
TryAbort(_noSceneParticleEntityIds.Clear);
|
||||||
if (failures is { Count: > 0 })
|
if (failures is { Count: > 0 })
|
||||||
|
|
@ -230,6 +234,11 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
ClipFrameAssembly reuseAssembly) =>
|
ClipFrameAssembly reuseAssembly) =>
|
||||||
ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly);
|
ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly);
|
||||||
|
|
||||||
|
public void AppendLookInClipFrames(
|
||||||
|
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
|
||||||
|
ClipFrameAssembly assembly) =>
|
||||||
|
ClipFrameAssembler.AppendLookInFrames(_clipFrame, lookInFrames, assembly);
|
||||||
|
|
||||||
public void PrepareClipFrame(int terrainUploadCount) =>
|
public void PrepareClipFrame(int terrainUploadCount) =>
|
||||||
_surface.PrepareClipFrame(terrainUploadCount);
|
_surface.PrepareClipFrame(terrainUploadCount);
|
||||||
|
|
||||||
|
|
@ -246,6 +255,24 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
_entities.ClearClipRouting();
|
_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(
|
public void PrepareCellBatches(
|
||||||
RetailPViewFrameInput frame,
|
RetailPViewFrameInput frame,
|
||||||
HashSet<uint> visibleCellIds) =>
|
HashSet<uint> visibleCellIds) =>
|
||||||
|
|
@ -376,6 +403,7 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
|
|
||||||
if (scissor)
|
if (scissor)
|
||||||
_surface.EndScissor();
|
_surface.EndScissor();
|
||||||
|
_entities.ClearClipRouting();
|
||||||
DisableClipDistances();
|
DisableClipDistances();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,8 +446,7 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
|
|
||||||
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
|
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
|
||||||
|
|
||||||
if (!frame.RootCell.IsOutdoorNode
|
if (_particleClassifications.Outdoor.Count > 0
|
||||||
&& _particleClassifications.Outdoor.Count > 0
|
|
||||||
&& _particles is not null
|
&& _particles is not null
|
||||||
&& _particleRenderer is not null)
|
&& _particleRenderer is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -427,7 +454,8 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
frame.Camera,
|
frame.Camera,
|
||||||
frame.CameraWorldPosition,
|
frame.CameraWorldPosition,
|
||||||
ParticleRenderPass.Scene,
|
ParticleRenderPass.Scene,
|
||||||
_particleClassifications.Outdoor);
|
_particleClassifications.Outdoor,
|
||||||
|
clipSlot: (uint)context.Slice.Slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
EnableClipDistances();
|
EnableClipDistances();
|
||||||
|
|
@ -456,6 +484,77 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
|
|
||||||
if (scissor)
|
if (scissor)
|
||||||
_surface.EndScissor();
|
_surface.EndScissor();
|
||||||
|
_entities.ClearClipRouting();
|
||||||
|
DisableClipDistances();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DrawLandscapeStaticParticles(
|
||||||
|
RetailPViewFrameInput frame,
|
||||||
|
RetailPViewLandscapeStaticParticleContext context)
|
||||||
|
{
|
||||||
|
bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb);
|
||||||
|
_surface.BindTerrainClip();
|
||||||
|
DisableClipDistances();
|
||||||
|
|
||||||
|
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
|
||||||
|
if (_particleClassifications.Outdoor.Count > 0
|
||||||
|
&& _particles is not null
|
||||||
|
&& _particleRenderer is not null)
|
||||||
|
{
|
||||||
|
_particleRenderer.DrawForOwners(
|
||||||
|
frame.Camera,
|
||||||
|
frame.CameraWorldPosition,
|
||||||
|
ParticleRenderPass.Scene,
|
||||||
|
_particleClassifications.Outdoor,
|
||||||
|
clipSlot: (uint)context.Slice.Slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scissor)
|
||||||
|
_surface.EndScissor();
|
||||||
|
_entities.ClearClipRouting();
|
||||||
|
DisableClipDistances();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DrawLandscapeBuildingShellSlice(
|
||||||
|
RetailPViewFrameInput frame,
|
||||||
|
RetailPViewLandscapeBuildingShellSliceContext context)
|
||||||
|
{
|
||||||
|
UseOutdoorPortalViewRouting(context.Slice);
|
||||||
|
bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb);
|
||||||
|
_surface.BindTerrainClip();
|
||||||
|
DisableClipDistances();
|
||||||
|
|
||||||
|
if (context.EntityDraw is RenderFrameEntityDrawRequest request)
|
||||||
|
{
|
||||||
|
RenderFrameView drawView = request.View;
|
||||||
|
_entities.DrawPackedProductionRoute(
|
||||||
|
frame.Camera,
|
||||||
|
in drawView,
|
||||||
|
request.Route,
|
||||||
|
request.RouteIndex,
|
||||||
|
request.CellId,
|
||||||
|
request.TupleLandblockId);
|
||||||
|
}
|
||||||
|
else 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();
|
DisableClipDistances();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -468,10 +567,13 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
|
|
||||||
public void DrawLookInPortalPunch(
|
public void DrawLookInPortalPunch(
|
||||||
RetailPViewFrameInput frame,
|
RetailPViewFrameInput frame,
|
||||||
RetailPViewCellSliceContext context) =>
|
RetailPViewCellSliceContext context,
|
||||||
DrawPortalDepthWrite(context, frame, forceFarZ: true);
|
int portalIndex) =>
|
||||||
|
DrawPortalDepthWrite(context, frame, forceFarZ: true, portalIndex);
|
||||||
|
|
||||||
public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame)
|
public void DrawUnattachedSceneParticles(
|
||||||
|
RetailPViewFrameInput frame,
|
||||||
|
ClipViewSlice slice)
|
||||||
{
|
{
|
||||||
if (_particles is null || _particleRenderer is null)
|
if (_particles is null || _particleRenderer is null)
|
||||||
return;
|
return;
|
||||||
|
|
@ -482,7 +584,8 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
frame.CameraWorldPosition,
|
frame.CameraWorldPosition,
|
||||||
ParticleRenderPass.Scene,
|
ParticleRenderPass.Scene,
|
||||||
_noSceneParticleEntityIds,
|
_noSceneParticleEntityIds,
|
||||||
includeUnattached: true);
|
includeUnattached: true,
|
||||||
|
clipSlot: (uint)slice.Slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void FlushLandscapeAlpha() => _alpha.Flush();
|
public void FlushLandscapeAlpha() => _alpha.Flush();
|
||||||
|
|
@ -509,7 +612,8 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
frame.Camera,
|
frame.Camera,
|
||||||
frame.CameraWorldPosition,
|
frame.CameraWorldPosition,
|
||||||
ParticleRenderPass.Scene,
|
ParticleRenderPass.Scene,
|
||||||
visible);
|
visible,
|
||||||
|
clipSlot: (uint)context.Slice.Slot);
|
||||||
DisableClipDistances();
|
DisableClipDistances();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -552,7 +656,8 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
private void DrawPortalDepthWrite(
|
private void DrawPortalDepthWrite(
|
||||||
RetailPViewCellSliceContext context,
|
RetailPViewCellSliceContext context,
|
||||||
RetailPViewFrameInput frame,
|
RetailPViewFrameInput frame,
|
||||||
bool forceFarZ)
|
bool forceFarZ,
|
||||||
|
int? onlyPortalIndex = null)
|
||||||
{
|
{
|
||||||
// Retail D3DPolyRender::DrawPortalPolyInternal @ 0x0059BC90.
|
// Retail D3DPolyRender::DrawPortalPolyInternal @ 0x0059BC90.
|
||||||
// Main interior roots stamp true depth (seal); outdoor and look-in
|
// Main interior roots stamp true depth (seal); outdoor and look-in
|
||||||
|
|
@ -566,6 +671,8 @@ internal sealed class RetailPViewPassExecutor :
|
||||||
Span<Vector3> world = stackalloc Vector3[32];
|
Span<Vector3> world = stackalloc Vector3[32];
|
||||||
for (int index = 0; index < cell.Portals.Count; index++)
|
for (int index = 0; index < cell.Portals.Count; index++)
|
||||||
{
|
{
|
||||||
|
if (onlyPortalIndex.HasValue && index != onlyPortalIndex.Value)
|
||||||
|
continue;
|
||||||
if (cell.Portals[index].OtherCellId != 0xFFFF)
|
if (cell.Portals[index].OtherCellId != 0xFFFF)
|
||||||
continue;
|
continue;
|
||||||
if (index >= cell.PortalPolygons.Count)
|
if (index >= cell.PortalPolygons.Count)
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,10 @@ public sealed class RetailPViewRenderer
|
||||||
private readonly Stack<PortalVisibilityFrame> _lookInFramePool = new();
|
private readonly Stack<PortalVisibilityFrame> _lookInFramePool = new();
|
||||||
private readonly HashSet<uint> _lookInPrepareScratch = new();
|
private readonly HashSet<uint> _lookInPrepareScratch = new();
|
||||||
|
|
||||||
// #131/#132: the late landscape phase's scene-particle owner survivors
|
// #131/#132: landscape scene-particle owner survivors. With building
|
||||||
// (statics + outside-stage dynamics passing the slice cone).
|
// look-ins, static owners use the pre-building alpha barrier and the late
|
||||||
|
// phase contains only outside-stage dynamics; otherwise the late phase
|
||||||
|
// carries both sets.
|
||||||
private readonly HashSet<uint> _lateParticleOwnerScratch = new();
|
private readonly HashSet<uint> _lateParticleOwnerScratch = new();
|
||||||
private readonly HashSet<uint> _cellParticleOwnerScratch = new();
|
private readonly HashSet<uint> _cellParticleOwnerScratch = new();
|
||||||
private readonly HashSet<uint> _dynamicParticleOwnerScratch = new();
|
private readonly HashSet<uint> _dynamicParticleOwnerScratch = new();
|
||||||
|
|
@ -144,6 +146,7 @@ public sealed class RetailPViewRenderer
|
||||||
var clipAssembly = passes.AssembleClipFrame(
|
var clipAssembly = passes.AssembleClipFrame(
|
||||||
pvFrame,
|
pvFrame,
|
||||||
_clipAssemblyScratch);
|
_clipAssemblyScratch);
|
||||||
|
passes.AppendLookInClipFrames(_lookInFrames, clipAssembly);
|
||||||
int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2);
|
int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2);
|
||||||
passes.PrepareClipFrame(terrainUploadCount);
|
passes.PrepareClipFrame(terrainUploadCount);
|
||||||
|
|
||||||
|
|
@ -413,6 +416,9 @@ public sealed class RetailPViewRenderer
|
||||||
group, ctx.ViewerEyePos, ctx.Cells.Find, ctx.ViewProjection,
|
group, ctx.ViewerEyePos, ctx.Cells.Find, ctx.ViewProjection,
|
||||||
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons,
|
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons,
|
||||||
reuseFrame: frameScratch);
|
reuseFrame: frameScratch);
|
||||||
|
LoadedCell sourceCell = group[0];
|
||||||
|
frame.SourceBuildingKey = sourceCell.BuildingId ?? sourceCell.CellId;
|
||||||
|
frame.SourceBuildingLandblockId = sourceCell.CellId & 0xFFFF0000u;
|
||||||
if (frame.OrderedVisibleCells.Count > 0)
|
if (frame.OrderedVisibleCells.Count > 0)
|
||||||
_lookInFrames.Add(frame);
|
_lookInFrames.Add(frame);
|
||||||
else
|
else
|
||||||
|
|
@ -454,65 +460,67 @@ public sealed class RetailPViewRenderer
|
||||||
// then draw the flooded cells' shells + statics far→near (the nested
|
// then draw the flooded cells' shells + statics far→near (the nested
|
||||||
// DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is
|
// DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is
|
||||||
// empty by construction — PView ctor draw_landscape=0 — so no recursive
|
// empty by construction — PView ctor draw_landscape=0 — so no recursive
|
||||||
// landscape/clear/seal). Anything rasterized outside an aperture is
|
// landscape/clear/seal). Retail CEnvCell::setup_view installs every cell's
|
||||||
// repainted by the root's own shells after the depth clear, so over-draw
|
// nested portal_view before DrawEnvCell, while DrawMesh iterates that same
|
||||||
// here is color-safe; statics draw whole (the main viewcone has no entry
|
// PortalList for cell objects. Preserve that per-slice gate here; drawing a
|
||||||
// for look-in cells; over-include is the safe direction).
|
// nested cell whole lets its floor, details, and emitters escape the authored
|
||||||
|
// aperture even when the outer depth choreography is otherwise correct.
|
||||||
private void DrawBuildingLookIns(
|
private void DrawBuildingLookIns(
|
||||||
RetailPViewFrameInput ctx,
|
RetailPViewFrameInput ctx,
|
||||||
IRetailPViewPassExecutor passes,
|
IRetailPViewPassExecutor passes,
|
||||||
ClipFrameAssembly clipAssembly,
|
ClipFrameAssembly clipAssembly,
|
||||||
InteriorEntityPartition.Result? partition,
|
InteriorEntityPartition.Result? partition,
|
||||||
|
ViewconeCuller viewcone,
|
||||||
IRenderFrameEntityPassExecutor? frameEntityPasses,
|
IRenderFrameEntityPassExecutor? frameEntityPasses,
|
||||||
in RenderFrameView frameView)
|
in RenderFrameView frameView)
|
||||||
{
|
{
|
||||||
if (_lookInFrames.Count == 0)
|
if (_lookInFrames.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
foreach (var frame in _lookInFrames)
|
int outsideSliceCount = clipAssembly.OutsideViewSlices.Length;
|
||||||
|
int lookInRouteIndex = 0;
|
||||||
|
for (int frameIndex = 0; frameIndex < _lookInFrames.Count; frameIndex++)
|
||||||
{
|
{
|
||||||
|
PortalVisibilityFrame frame = _lookInFrames[frameIndex];
|
||||||
|
|
||||||
|
// Retail enters DrawBuilding once per building and drains every
|
||||||
|
// alpha submission accumulated by the preceding building before
|
||||||
|
// punching the next building's portals. The first building uses
|
||||||
|
// the pre-look-in barrier in DrawLandscapeThroughOutsideView.
|
||||||
|
if (frameIndex > 0)
|
||||||
|
passes.FlushLandscapeAlpha();
|
||||||
|
|
||||||
// Pass 1: far-Z punch every aperture of this building.
|
// Pass 1: far-Z punch every aperture of this building.
|
||||||
foreach (uint cellId in frame.OrderedVisibleCells)
|
foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals)
|
||||||
{
|
{
|
||||||
if (!frame.CellViews.TryGetValue(cellId, out var view))
|
foreach (var poly in seed.View.Polygons)
|
||||||
continue;
|
|
||||||
foreach (var poly in view.Polygons)
|
|
||||||
{
|
{
|
||||||
var cps = ClipPlaneSet.From(poly);
|
var cps = ClipPlaneSet.From(poly);
|
||||||
if (cps.IsNothingVisible)
|
if (cps.IsNothingVisible)
|
||||||
continue;
|
continue;
|
||||||
passes.DrawLookInPortalPunch(ctx, new RetailPViewCellSliceContext(
|
passes.DrawLookInPortalPunch(ctx, new RetailPViewCellSliceContext(
|
||||||
cellId,
|
seed.CellId,
|
||||||
new ClipViewSlice(
|
new ClipViewSlice(
|
||||||
0,
|
0,
|
||||||
new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY),
|
new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY),
|
||||||
cps.PlaneArray),
|
cps.PlaneArray),
|
||||||
NoParticleOwners));
|
NoParticleOwners),
|
||||||
|
seed.PortalIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 2: shells + statics, far→near.
|
// Pass 2: shells + objects, far→near, once per portal_view slice.
|
||||||
passes.UseIndoorMembershipOnlyRouting();
|
|
||||||
|
|
||||||
// Opaque shells batched per building into ONE Render (this building's
|
|
||||||
// aperture punches above already ran; z-buffer handles order and
|
|
||||||
// lighting is per-instance CellId-keyed) — was one heavy per-frame
|
|
||||||
// Render per cell. Per-cell entity/particle work stays in the loop.
|
|
||||||
_shellBatch.Clear();
|
|
||||||
foreach (uint cid in frame.OrderedVisibleCells)
|
|
||||||
_shellBatch.Add(cid);
|
|
||||||
if (_shellBatch.Count > 0)
|
|
||||||
passes.DrawOpaqueCellShells(_shellBatch);
|
|
||||||
|
|
||||||
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
uint cellId = frame.OrderedVisibleCells[i];
|
uint cellId = frame.OrderedVisibleCells[i];
|
||||||
_oneCell.Clear();
|
var clipKey = new LookInClipCell(frameIndex, cellId);
|
||||||
_oneCell.Add(cellId);
|
if (!clipAssembly.LookInCellToViewSlices.TryGetValue(
|
||||||
// Opaque shell batched above. Transparent stays per-cell (far→near)
|
clipKey,
|
||||||
// for correct compositing; skipped for opaque-only cells.
|
out ClipViewSlice[]? cellSlices)
|
||||||
if (passes.CellHasTransparentShell(cellId))
|
|| cellSlices.Length == 0)
|
||||||
passes.DrawTransparentCellShells(_oneCell);
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
_cellStaticScratch.Clear();
|
_cellStaticScratch.Clear();
|
||||||
if (partition is not null
|
if (partition is not null
|
||||||
|
|
@ -528,8 +536,7 @@ public sealed class RetailPViewRenderer
|
||||||
// post-clear they would z-fail against the root's seal anyway
|
// post-clear they would z-fail against the root's seal anyway
|
||||||
// (the #118 lesson). Retail draws a look-in cell's objects
|
// (the #118 lesson). Retail draws a look-in cell's objects
|
||||||
// inside the NESTED DrawCells (DrawObjCellForDummies,
|
// inside the NESTED DrawCells (DrawObjCellForDummies,
|
||||||
// pc:432878+), i.e. right here in the landscape stage. Drawn
|
// pc:432878+), i.e. right here in the landscape stage.
|
||||||
// WHOLE like the statics (AP-33's documented over-include).
|
|
||||||
// No double-draw: dynamics-last keeps culling them (their
|
// No double-draw: dynamics-last keeps culling them (their
|
||||||
// cell is absent from the main cone), and their emitters ride
|
// cell is absent from the main cone), and their emitters ride
|
||||||
// the DrawCellParticles call below, not DrawDynamicsParticles
|
// the DrawCellParticles call below, not DrawDynamicsParticles
|
||||||
|
|
@ -541,48 +548,154 @@ public sealed class RetailPViewRenderer
|
||||||
_cellStaticScratch.Add(e);
|
_cellStaticScratch.Add(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frameEntityPasses is not null)
|
foreach (ClipViewSlice slice in cellSlices)
|
||||||
{
|
{
|
||||||
RenderFrameRouteOwnerSelector.Replace(
|
int routeIndex = lookInRouteIndex++;
|
||||||
_cellParticleOwnerScratch,
|
passes.UseCellPortalViewRouting(cellId, slice);
|
||||||
in frameView,
|
_oneCell.Clear();
|
||||||
RenderFrameCandidateRoute.LookInObject,
|
_oneCell.Add(cellId);
|
||||||
i,
|
passes.DrawOpaqueCellShells(_oneCell);
|
||||||
cellId);
|
if (passes.CellHasTransparentShell(cellId))
|
||||||
}
|
passes.DrawTransparentCellShells(_oneCell);
|
||||||
else
|
|
||||||
{
|
|
||||||
ReplaceOwnerIds(
|
|
||||||
_cellParticleOwnerScratch,
|
|
||||||
_cellStaticScratch);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (frameEntityPasses is not null
|
if (frameEntityPasses is not null)
|
||||||
|| _cellStaticScratch.Count > 0)
|
{
|
||||||
{
|
RenderFrameRouteOwnerSelector.Replace(
|
||||||
_candidateObserver?.ObservePViewBucket(
|
_cellParticleOwnerScratch,
|
||||||
CurrentRenderPViewRoute.LookInObject,
|
in frameView,
|
||||||
i,
|
RenderFrameCandidateRoute.LookInObject,
|
||||||
cellId,
|
routeIndex,
|
||||||
_cellStaticScratch);
|
cellId);
|
||||||
DrawEntityRouteOrLegacy(
|
}
|
||||||
ctx,
|
else
|
||||||
passes,
|
{
|
||||||
frameEntityPasses,
|
ReplaceOwnerIds(
|
||||||
in frameView,
|
_cellParticleOwnerScratch,
|
||||||
RenderFrameCandidateRoute.LookInObject,
|
_cellStaticScratch);
|
||||||
i,
|
}
|
||||||
cellId,
|
|
||||||
_cellStaticScratch,
|
|
||||||
_oneCell);
|
|
||||||
|
|
||||||
// The cell-particles pass for look-in cells — retail's
|
if (frameEntityPasses is not null
|
||||||
// nested DrawCells draws objects WITH their emitters.
|
|| _cellStaticScratch.Count > 0)
|
||||||
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
|
{
|
||||||
|
_candidateObserver?.ObservePViewBucket(
|
||||||
|
CurrentRenderPViewRoute.LookInObject,
|
||||||
|
routeIndex,
|
||||||
|
cellId,
|
||||||
|
_cellStaticScratch);
|
||||||
|
DrawEntityRouteOrLegacy(
|
||||||
|
ctx,
|
||||||
|
passes,
|
||||||
|
frameEntityPasses,
|
||||||
|
in frameView,
|
||||||
|
RenderFrameCandidateRoute.LookInObject,
|
||||||
|
routeIndex,
|
||||||
|
cellId,
|
||||||
|
_cellStaticScratch,
|
||||||
|
_oneCell);
|
||||||
|
|
||||||
|
// The nested DrawCells object pass includes emitters and
|
||||||
|
// retains the exact setup_view clip until alpha playback.
|
||||||
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
|
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
|
||||||
cellId, slice, _cellParticleOwnerScratch));
|
cellId, slice, _cellParticleOwnerScratch));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ordinary exterior building shell is clipped by the outer
|
||||||
|
// outside_view, not by the nested cell PortalList.
|
||||||
|
passes.UseIndoorMembershipOnlyRouting();
|
||||||
|
|
||||||
|
// Retail's ordinary shell pass immediately follows this same
|
||||||
|
// building's portal-only pass. Pair by the shell's authored
|
||||||
|
// anchor EnvCell; never let an unrelated building repaint a
|
||||||
|
// look-in merely because both happen to be nearby.
|
||||||
|
int sliceIndex = 0;
|
||||||
|
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
|
||||||
|
{
|
||||||
|
int shellRouteIndex = LookInBuildingShellRouteIndex(
|
||||||
|
frameIndex,
|
||||||
|
outsideSliceCount,
|
||||||
|
sliceIndex);
|
||||||
|
_buildingShellScratch.Clear();
|
||||||
|
if (partition is not null)
|
||||||
|
{
|
||||||
|
foreach (WorldEntity entity in partition.OutdoorStatic)
|
||||||
|
{
|
||||||
|
if (!entity.IsBuildingShell
|
||||||
|
|| FindLookInFrameIndex(
|
||||||
|
entity.BuildingShellAnchorCellId ?? 0,
|
||||||
|
_lookInFrames,
|
||||||
|
ctx.Cells) != frameIndex)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
EntitySphere(entity, out Vector3 center, out float radius);
|
||||||
|
if (viewcone.SphereVisibleInOutsideSlice(
|
||||||
|
sliceIndex,
|
||||||
|
center,
|
||||||
|
radius))
|
||||||
|
{
|
||||||
|
_buildingShellScratch.Add(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_candidateObserver?.ObservePViewBucket(
|
||||||
|
CurrentRenderPViewRoute.LandscapeBuildingShell,
|
||||||
|
shellRouteIndex,
|
||||||
|
0,
|
||||||
|
_buildingShellScratch);
|
||||||
|
bool hasPackedShell = frameEntityPasses is not null
|
||||||
|
&& HasExactRoute(
|
||||||
|
in frameView,
|
||||||
|
RenderFrameCandidateRoute.LandscapeBuildingShell,
|
||||||
|
shellRouteIndex,
|
||||||
|
0);
|
||||||
|
if (hasPackedShell || _buildingShellScratch.Count > 0)
|
||||||
|
{
|
||||||
|
RenderFrameEntityDrawRequest? shellDraw =
|
||||||
|
frameEntityPasses is null
|
||||||
|
? null
|
||||||
|
: new RenderFrameEntityDrawRequest(
|
||||||
|
frameView,
|
||||||
|
RenderFrameCandidateRoute.LandscapeBuildingShell,
|
||||||
|
shellRouteIndex,
|
||||||
|
0,
|
||||||
|
ctx.PlayerLandblockId ?? 0);
|
||||||
|
passes.DrawLandscapeBuildingShellSlice(
|
||||||
|
ctx,
|
||||||
|
new RetailPViewLandscapeBuildingShellSliceContext(
|
||||||
|
slice,
|
||||||
|
_buildingShellScratch)
|
||||||
|
{
|
||||||
|
EntityDraw = shellDraw,
|
||||||
|
});
|
||||||
|
|
||||||
|
_lateParticleOwnerScratch.Clear();
|
||||||
|
if (frameEntityPasses is not null)
|
||||||
|
{
|
||||||
|
RenderFrameRouteOwnerSelector.Replace(
|
||||||
|
_lateParticleOwnerScratch,
|
||||||
|
in frameView,
|
||||||
|
RenderFrameCandidateRoute.LandscapeBuildingShell,
|
||||||
|
shellRouteIndex,
|
||||||
|
0);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ReplaceOwnerIds(
|
||||||
|
_lateParticleOwnerScratch,
|
||||||
|
_buildingShellScratch);
|
||||||
|
}
|
||||||
|
passes.DrawLandscapeStaticParticles(
|
||||||
|
ctx,
|
||||||
|
new RetailPViewLandscapeStaticParticleContext(
|
||||||
|
slice,
|
||||||
|
_lateParticleOwnerScratch));
|
||||||
|
}
|
||||||
|
sliceIndex++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -598,18 +711,13 @@ public sealed class RetailPViewRenderer
|
||||||
if (clipAssembly.OutsideViewSlices.Length == 0)
|
if (clipAssembly.OutsideViewSlices.Length == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// #131/#132 (the FlushAlphaList deferral): retail collects ALL alpha
|
// #131/#132: retail drains the remaining landscape alpha after
|
||||||
// draws of the landscape stage and flushes them ONCE after LScape::draw
|
// LScape::draw (DrawCells pc:432720), while each DrawBuilding is also
|
||||||
// (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent
|
// an earlier alpha barrier before its portal traversal (pc:427954).
|
||||||
// landscape content (portal swirl meshes, flame particles) composites
|
// Our dispatcher batches outdoor content, so the stage is split into:
|
||||||
// AFTER the building look-ins. Our dispatcher draws translucency inside
|
// EARLY sky/terrain/static meshes; an optional pre-look-in static-alpha
|
||||||
// each Draw call, so the stage is split in TWO phases instead: EARLY =
|
// barrier; building look-ins; then LATE outside-stage dynamics,
|
||||||
// sky + terrain + outdoor STATIC meshes (the look-in punches need their
|
// remaining particles, and weather; followed by the outer flush.
|
||||||
// depth to mark against, the #117 lesson); then the look-ins; then
|
|
||||||
// LATE = outside-stage dynamics' meshes + ALL scene particles +
|
|
||||||
// weather. Content drawn early and overlapped by a look-in aperture
|
|
||||||
// was otherwise overpainted by the far interior (translucents write no
|
|
||||||
// depth to protect themselves) — the portal-swirl/candle-flame class.
|
|
||||||
int probeSliceIndex = 0;
|
int probeSliceIndex = 0;
|
||||||
foreach (var slice in clipAssembly.OutsideViewSlices)
|
foreach (var slice in clipAssembly.OutsideViewSlices)
|
||||||
{
|
{
|
||||||
|
|
@ -628,6 +736,14 @@ public sealed class RetailPViewRenderer
|
||||||
{
|
{
|
||||||
foreach (var e in partition.OutdoorStatic)
|
foreach (var e in partition.OutdoorStatic)
|
||||||
{
|
{
|
||||||
|
if (e.IsBuildingShell
|
||||||
|
&& FindLookInFrameIndex(
|
||||||
|
e.BuildingShellAnchorCellId ?? 0,
|
||||||
|
_lookInFrames,
|
||||||
|
ctx.Cells) >= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
EntitySphere(e, out var c, out float r);
|
EntitySphere(e, out var c, out float r);
|
||||||
if (viewcone.SphereVisibleInOutsideSlice(
|
if (viewcone.SphereVisibleInOutsideSlice(
|
||||||
probeSliceIndex,
|
probeSliceIndex,
|
||||||
|
|
@ -663,15 +779,81 @@ public sealed class RetailPViewRenderer
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retail DrawBuilding flushes every alpha submission accumulated before
|
||||||
|
// the building immediately before its portal-only traversal
|
||||||
|
// (RenderDeviceD3D::DrawBuilding pc:427954-427956). That barrier is
|
||||||
|
// essential at open-air seams: foliage and static emitters encountered
|
||||||
|
// before the building must not be flushed after the look-in cell floor
|
||||||
|
// and repaint it. Our outdoor statics are one retained batch rather than
|
||||||
|
// retail's BSP-by-building walk, so use one barrier before the first
|
||||||
|
// look-in; DrawBuildingLookIns adds the corresponding barrier between
|
||||||
|
// each later building pair. Submit the early static owners' particles
|
||||||
|
// into the same alpha queue first; their mesh alpha was already
|
||||||
|
// submitted by the EARLY entity route above.
|
||||||
|
bool hasBuildingLookIns = _lookInFrames.Count > 0;
|
||||||
|
if (hasBuildingLookIns)
|
||||||
|
{
|
||||||
|
int barrierSliceIndex = 0;
|
||||||
|
foreach (var slice in clipAssembly.OutsideViewSlices)
|
||||||
|
{
|
||||||
|
// Ownerless outdoor emitters cannot ride an entity route. Retail
|
||||||
|
// draws their meshes once for every installed outside_view;
|
||||||
|
// retain that slot through deferred alpha playback.
|
||||||
|
passes.DrawUnattachedSceneParticles(ctx, slice);
|
||||||
|
|
||||||
|
_lateParticleOwnerScratch.Clear();
|
||||||
|
if (partition is not null)
|
||||||
|
{
|
||||||
|
foreach (var e in partition.OutdoorStatic)
|
||||||
|
{
|
||||||
|
if (e.IsBuildingShell
|
||||||
|
&& FindLookInFrameIndex(
|
||||||
|
e.BuildingShellAnchorCellId ?? 0,
|
||||||
|
_lookInFrames,
|
||||||
|
ctx.Cells) >= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
EntitySphere(e, out var c, out float r);
|
||||||
|
if (viewcone.SphereVisibleInOutsideSlice(
|
||||||
|
barrierSliceIndex,
|
||||||
|
c,
|
||||||
|
r))
|
||||||
|
{
|
||||||
|
_lateParticleOwnerScratch.Add(e.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (frameEntityPasses is not null)
|
||||||
|
{
|
||||||
|
RenderFrameRouteOwnerSelector.Replace(
|
||||||
|
_lateParticleOwnerScratch,
|
||||||
|
in frameView,
|
||||||
|
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
|
||||||
|
barrierSliceIndex,
|
||||||
|
0);
|
||||||
|
}
|
||||||
|
|
||||||
|
passes.DrawLandscapeStaticParticles(
|
||||||
|
ctx,
|
||||||
|
new RetailPViewLandscapeStaticParticleContext(
|
||||||
|
slice,
|
||||||
|
_lateParticleOwnerScratch));
|
||||||
|
barrierSliceIndex++;
|
||||||
|
}
|
||||||
|
passes.FlushLandscapeAlpha();
|
||||||
|
}
|
||||||
|
|
||||||
// #124: far-building look-ins draw HERE — still inside the landscape
|
// #124: far-building look-ins draw HERE — still inside the landscape
|
||||||
// stage (their punches mark against the terrain/exterior depth just
|
// stage (their punches mark against the terrain/exterior depth just
|
||||||
// drawn), strictly BEFORE the depth clear + seals below, matching
|
// drawn), strictly BEFORE the outer depth clear + seals below, matching
|
||||||
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
|
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
|
||||||
DrawBuildingLookIns(
|
DrawBuildingLookIns(
|
||||||
ctx,
|
ctx,
|
||||||
passes,
|
passes,
|
||||||
clipAssembly,
|
clipAssembly,
|
||||||
partition,
|
partition,
|
||||||
|
viewcone,
|
||||||
frameEntityPasses,
|
frameEntityPasses,
|
||||||
in frameView);
|
in frameView);
|
||||||
|
|
||||||
|
|
@ -687,8 +869,8 @@ public sealed class RetailPViewRenderer
|
||||||
passes.ClearClipRouting();
|
passes.ClearClipRouting();
|
||||||
|
|
||||||
_outdoorStaticScratch.Clear(); // late: dynamics survivors
|
_outdoorStaticScratch.Clear(); // late: dynamics survivors
|
||||||
_lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors
|
_lateParticleOwnerScratch.Clear(); // late: dynamics, plus statics without look-ins
|
||||||
if (partition is not null)
|
if (!hasBuildingLookIns && partition is not null)
|
||||||
{
|
{
|
||||||
foreach (var e in partition.OutdoorStatic)
|
foreach (var e in partition.OutdoorStatic)
|
||||||
{
|
{
|
||||||
|
|
@ -712,12 +894,19 @@ public sealed class RetailPViewRenderer
|
||||||
}
|
}
|
||||||
if (frameEntityPasses is not null)
|
if (frameEntityPasses is not null)
|
||||||
{
|
{
|
||||||
RenderFrameRouteOwnerSelector.Replace(
|
if (hasBuildingLookIns)
|
||||||
_lateParticleOwnerScratch,
|
{
|
||||||
in frameView,
|
_lateParticleOwnerScratch.Clear();
|
||||||
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
|
}
|
||||||
probeSliceIndex,
|
else
|
||||||
0);
|
{
|
||||||
|
RenderFrameRouteOwnerSelector.Replace(
|
||||||
|
_lateParticleOwnerScratch,
|
||||||
|
in frameView,
|
||||||
|
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
|
||||||
|
probeSliceIndex,
|
||||||
|
0);
|
||||||
|
}
|
||||||
RenderFrameRouteOwnerSelector.Union(
|
RenderFrameRouteOwnerSelector.Union(
|
||||||
_lateParticleOwnerScratch,
|
_lateParticleOwnerScratch,
|
||||||
in frameView,
|
in frameView,
|
||||||
|
|
@ -753,13 +942,19 @@ public sealed class RetailPViewRenderer
|
||||||
|
|
||||||
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
|
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
|
||||||
// campfires, ground effects anchored at a position) have no owner id
|
// campfires, ground effects anchored at a position) have no owner id
|
||||||
// to ride any of the id-filtered particle passes. The outdoor root
|
// to ride any of the id-filtered particle passes. Draw once per
|
||||||
// has the dedicated T3 pass for them; an INTERIOR root had NO pass
|
// installed outside_view for BOTH root kinds, matching retail's
|
||||||
// at all. Draw them ONCE per frame (not per slice — alpha particles
|
// landscape-stage placement and preserving the slot in each deferred
|
||||||
// must not double-draw, the #121 lesson), at the END of the landscape
|
// draw. The former outdoor-root post-world tail ran after building
|
||||||
// stage: after the clear they would z-fail against the doorway seal.
|
// cells and let exterior alpha repaint the cathedral transition.
|
||||||
if (!ctx.RootCell.IsOutdoorNode)
|
// With no look-ins they drain at the end of the landscape stage; the
|
||||||
passes.DrawUnattachedSceneParticles(ctx);
|
// look-in path submits them at its pre-building barrier so later opaque
|
||||||
|
// cell floors can cover them.
|
||||||
|
if (!hasBuildingLookIns)
|
||||||
|
{
|
||||||
|
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
|
||||||
|
passes.DrawUnattachedSceneParticles(ctx, slice);
|
||||||
|
}
|
||||||
|
|
||||||
// Retail PView::DrawCells 0x005A4872 drains the landscape alpha list
|
// Retail PView::DrawCells 0x005A4872 drains the landscape alpha list
|
||||||
// immediately after LScape::draw and before the optional depth clear.
|
// immediately after LScape::draw and before the optional depth clear.
|
||||||
|
|
@ -779,6 +974,58 @@ public sealed class RetailPViewRenderer
|
||||||
passes.UseIndoorMembershipOnlyRouting();
|
passes.UseIndoorMembershipOnlyRouting();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static int LookInBuildingShellRouteIndex(
|
||||||
|
int frameIndex,
|
||||||
|
int outsideSliceCount,
|
||||||
|
int sliceIndex) =>
|
||||||
|
checked((frameIndex * outsideSliceCount) + sliceIndex);
|
||||||
|
|
||||||
|
internal static int FindLookInFrameIndex(
|
||||||
|
uint buildingShellAnchorCellId,
|
||||||
|
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
|
||||||
|
IRetailPViewCellSource cells)
|
||||||
|
{
|
||||||
|
if (buildingShellAnchorCellId == 0)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
LoadedCell? anchorCell = cells.Find(buildingShellAnchorCellId);
|
||||||
|
if (anchorCell is null)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
uint buildingKey = anchorCell.BuildingId ?? anchorCell.CellId;
|
||||||
|
uint landblockId = anchorCell.CellId & 0xFFFF0000u;
|
||||||
|
for (int frameIndex = 0; frameIndex < lookInFrames.Count; frameIndex++)
|
||||||
|
{
|
||||||
|
PortalVisibilityFrame frame = lookInFrames[frameIndex];
|
||||||
|
if (frame.SourceBuildingKey == buildingKey
|
||||||
|
&& frame.SourceBuildingLandblockId == landblockId)
|
||||||
|
{
|
||||||
|
return frameIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasExactRoute(
|
||||||
|
in RenderFrameView view,
|
||||||
|
RenderFrameCandidateRoute route,
|
||||||
|
int routeIndex,
|
||||||
|
uint cellId)
|
||||||
|
{
|
||||||
|
foreach (RenderFrameCandidateRange range in view.RouteRanges)
|
||||||
|
{
|
||||||
|
if (range.Route == route
|
||||||
|
&& range.RouteIndex == routeIndex
|
||||||
|
&& range.CellId == cellId
|
||||||
|
&& range.Count > 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private void DrawExitPortalMasks(
|
private void DrawExitPortalMasks(
|
||||||
RetailPViewFrameInput ctx,
|
RetailPViewFrameInput ctx,
|
||||||
IRetailPViewPassExecutor passes,
|
IRetailPViewPassExecutor passes,
|
||||||
|
|
@ -1180,6 +1427,7 @@ public sealed class RetailPViewRenderer
|
||||||
|
|
||||||
// T3 scratch lists (render thread only; cleared per use).
|
// T3 scratch lists (render thread only; cleared per use).
|
||||||
private readonly List<WorldEntity> _outdoorStaticScratch = new();
|
private readonly List<WorldEntity> _outdoorStaticScratch = new();
|
||||||
|
private readonly List<WorldEntity> _buildingShellScratch = new();
|
||||||
private readonly List<WorldEntity> _cellStaticScratch = new();
|
private readonly List<WorldEntity> _cellStaticScratch = new();
|
||||||
private readonly List<WorldEntity> _dynamicsScratch = new();
|
private readonly List<WorldEntity> _dynamicsScratch = new();
|
||||||
// #118: dynamics assigned to the OUTSIDE stage this frame (interior roots
|
// #118: dynamics assigned to the OUTSIDE stage this frame (interior roots
|
||||||
|
|
@ -1343,10 +1591,14 @@ public interface IRetailPViewPassExecutor
|
||||||
ClipFrameAssembly AssembleClipFrame(
|
ClipFrameAssembly AssembleClipFrame(
|
||||||
PortalVisibilityFrame portalFrame,
|
PortalVisibilityFrame portalFrame,
|
||||||
ClipFrameAssembly reuseAssembly);
|
ClipFrameAssembly reuseAssembly);
|
||||||
|
void AppendLookInClipFrames(
|
||||||
|
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
|
||||||
|
ClipFrameAssembly assembly);
|
||||||
void PrepareClipFrame(int terrainUploadCount);
|
void PrepareClipFrame(int terrainUploadCount);
|
||||||
void SetTerrainClip(ReadOnlySpan<Vector4> planes);
|
void SetTerrainClip(ReadOnlySpan<Vector4> planes);
|
||||||
void ClearClipRouting();
|
void ClearClipRouting();
|
||||||
void UseIndoorMembershipOnlyRouting();
|
void UseIndoorMembershipOnlyRouting();
|
||||||
|
void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice);
|
||||||
void PrepareCellBatches(
|
void PrepareCellBatches(
|
||||||
RetailPViewFrameInput frame,
|
RetailPViewFrameInput frame,
|
||||||
HashSet<uint> visibleCellIds);
|
HashSet<uint> visibleCellIds);
|
||||||
|
|
@ -1363,11 +1615,22 @@ public interface IRetailPViewPassExecutor
|
||||||
ClipViewSlice slice,
|
ClipViewSlice slice,
|
||||||
int sliceIndex);
|
int sliceIndex);
|
||||||
void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context);
|
void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context);
|
||||||
|
void DrawLandscapeStaticParticles(
|
||||||
|
RetailPViewFrameInput frame,
|
||||||
|
RetailPViewLandscapeStaticParticleContext context);
|
||||||
|
void DrawLandscapeBuildingShellSlice(
|
||||||
|
RetailPViewFrameInput frame,
|
||||||
|
RetailPViewLandscapeBuildingShellSliceContext context);
|
||||||
void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context);
|
void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context);
|
||||||
void ClearInteriorDepth();
|
void ClearInteriorDepth();
|
||||||
void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
|
void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
|
||||||
void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
|
void DrawLookInPortalPunch(
|
||||||
void DrawUnattachedSceneParticles(RetailPViewFrameInput frame);
|
RetailPViewFrameInput frame,
|
||||||
|
RetailPViewCellSliceContext context,
|
||||||
|
int portalIndex);
|
||||||
|
void DrawUnattachedSceneParticles(
|
||||||
|
RetailPViewFrameInput frame,
|
||||||
|
ClipViewSlice slice);
|
||||||
void FlushLandscapeAlpha();
|
void FlushLandscapeAlpha();
|
||||||
void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
|
void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
|
||||||
void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet<uint> ownerIds);
|
void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet<uint> ownerIds);
|
||||||
|
|
@ -1690,9 +1953,27 @@ public readonly record struct RetailPViewLandscapeSliceContext(
|
||||||
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
|
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outdoor-static emitters submitted at retail's pre-building alpha barrier.
|
||||||
|
/// Mesh alpha for the same owners is already queued by the early landscape
|
||||||
|
/// entity route.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct RetailPViewLandscapeStaticParticleContext(
|
||||||
|
ClipViewSlice Slice,
|
||||||
|
IReadOnlySet<uint> ParticleOwnerIds);
|
||||||
|
|
||||||
|
/// <summary>Retail DrawBuilding's ordinary exterior-shell pass, issued after
|
||||||
|
/// the same building's portal-only look-in traversal.</summary>
|
||||||
|
public readonly record struct RetailPViewLandscapeBuildingShellSliceContext(
|
||||||
|
ClipViewSlice Slice,
|
||||||
|
IReadOnlyList<WorldEntity> BuildingShells)
|
||||||
|
{
|
||||||
|
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>#131/#132: the late landscape phase's per-slice payload —
|
/// <summary>#131/#132: the late landscape phase's per-slice payload —
|
||||||
/// outside-stage dynamics to mesh-draw, plus the full scene-particle owner
|
/// outside-stage dynamics to mesh-draw, plus the particle owners not already
|
||||||
/// set (statics + dynamics cone survivors) the attached-emitter filter keys on.</summary>
|
/// submitted at a pre-building barrier.</summary>
|
||||||
public readonly record struct RetailPViewLandscapeLateSliceContext(
|
public readonly record struct RetailPViewLandscapeLateSliceContext(
|
||||||
ClipViewSlice Slice,
|
ClipViewSlice Slice,
|
||||||
IReadOnlyList<WorldEntity> Dynamics,
|
IReadOnlyList<WorldEntity> Dynamics,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ internal readonly record struct CurrentRenderProjectionFingerprint(
|
||||||
internal enum CurrentRenderPViewRoute : byte
|
internal enum CurrentRenderPViewRoute : byte
|
||||||
{
|
{
|
||||||
LandscapeOutdoorStatic,
|
LandscapeOutdoorStatic,
|
||||||
|
LandscapeBuildingShell,
|
||||||
LandscapeOutsideDynamic,
|
LandscapeOutsideDynamic,
|
||||||
LookInObject,
|
LookInObject,
|
||||||
CellStatic,
|
CellStatic,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ internal enum RenderFrameBlendClass : byte
|
||||||
internal enum RenderFrameCandidateRoute : byte
|
internal enum RenderFrameCandidateRoute : byte
|
||||||
{
|
{
|
||||||
LandscapeOutdoorStatic,
|
LandscapeOutdoorStatic,
|
||||||
|
LandscapeBuildingShell,
|
||||||
LandscapeOutsideDynamic,
|
LandscapeOutsideDynamic,
|
||||||
LookInObject,
|
LookInObject,
|
||||||
CellStatic,
|
CellStatic,
|
||||||
|
|
|
||||||
|
|
@ -992,6 +992,8 @@ internal sealed class RenderScenePViewFrameProductController :
|
||||||
{
|
{
|
||||||
CurrentRenderPViewRoute.LandscapeOutdoorStatic =>
|
CurrentRenderPViewRoute.LandscapeOutdoorStatic =>
|
||||||
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
|
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
|
||||||
|
CurrentRenderPViewRoute.LandscapeBuildingShell =>
|
||||||
|
RenderFrameCandidateRoute.LandscapeBuildingShell,
|
||||||
CurrentRenderPViewRoute.LandscapeOutsideDynamic =>
|
CurrentRenderPViewRoute.LandscapeOutsideDynamic =>
|
||||||
RenderFrameCandidateRoute.LandscapeOutsideDynamic,
|
RenderFrameCandidateRoute.LandscapeOutsideDynamic,
|
||||||
CurrentRenderPViewRoute.LookInObject =>
|
CurrentRenderPViewRoute.LookInObject =>
|
||||||
|
|
@ -1157,7 +1159,21 @@ internal sealed class RenderScenePViewFrameBuilder
|
||||||
LoadSceneIndices(input.Scene);
|
LoadSceneIndices(input.Scene);
|
||||||
|
|
||||||
BuildOutdoorRoutes(writer, in input);
|
BuildOutdoorRoutes(writer, in input);
|
||||||
BuildLookInRoutes(writer, in input);
|
int lookInRouteIndex = 0;
|
||||||
|
for (int frameIndex = 0;
|
||||||
|
frameIndex < input.LookInFrames.Count;
|
||||||
|
frameIndex++)
|
||||||
|
{
|
||||||
|
BuildLookInRoutes(
|
||||||
|
writer,
|
||||||
|
in input,
|
||||||
|
frameIndex,
|
||||||
|
ref lookInRouteIndex);
|
||||||
|
BuildLookInBuildingShellRoutes(
|
||||||
|
writer,
|
||||||
|
in input,
|
||||||
|
frameIndex);
|
||||||
|
}
|
||||||
BuildOutsideDynamicRoutes(writer, in input);
|
BuildOutsideDynamicRoutes(writer, in input);
|
||||||
BuildCellStaticRoute(writer, in input);
|
BuildCellStaticRoute(writer, in input);
|
||||||
BuildDynamicLastRoute(writer, in input);
|
BuildDynamicLastRoute(writer, in input);
|
||||||
|
|
@ -1279,6 +1295,14 @@ internal sealed class RenderScenePViewFrameBuilder
|
||||||
for (int i = 0; i < _outdoorCount; i++)
|
for (int i = 0; i < _outdoorCount; i++)
|
||||||
{
|
{
|
||||||
RenderProjectionRecord record = _outdoor[i];
|
RenderProjectionRecord record = _outdoor[i];
|
||||||
|
if (record.EntityPayload.IsBuildingShell
|
||||||
|
&& RetailPViewRenderer.FindLookInFrameIndex(
|
||||||
|
record.Source.BuildingShellAnchorCellId,
|
||||||
|
input.LookInFrames,
|
||||||
|
input.Cells) >= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
Sphere(in record, out Vector3 center, out float radius);
|
Sphere(in record, out Vector3 center, out float radius);
|
||||||
if (!input.Viewcone.SphereVisibleInOutsideSlice(
|
if (!input.Viewcone.SphereVisibleInOutsideSlice(
|
||||||
sliceIndex,
|
sliceIndex,
|
||||||
|
|
@ -1304,22 +1328,84 @@ internal sealed class RenderScenePViewFrameBuilder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void BuildLookInBuildingShellRoutes(
|
||||||
|
RenderFrameWriter writer,
|
||||||
|
in RenderScenePViewBuildInput input,
|
||||||
|
int frameIndex)
|
||||||
|
{
|
||||||
|
int sliceCount = input.ClipAssembly.OutsideViewSlices.Length;
|
||||||
|
for (int sliceIndex = 0; sliceIndex < sliceCount; sliceIndex++)
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
EnsureCapacity(ref _survivors, _outdoorCount);
|
||||||
|
for (int i = 0; i < _outdoorCount; i++)
|
||||||
|
{
|
||||||
|
RenderProjectionRecord record = _outdoor[i];
|
||||||
|
if (!record.EntityPayload.IsBuildingShell
|
||||||
|
|| RetailPViewRenderer.FindLookInFrameIndex(
|
||||||
|
record.Source.BuildingShellAnchorCellId,
|
||||||
|
input.LookInFrames,
|
||||||
|
input.Cells) != frameIndex)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sphere(in record, out Vector3 center, out float radius);
|
||||||
|
if (!input.Viewcone.SphereVisibleInOutsideSlice(
|
||||||
|
sliceIndex,
|
||||||
|
in center,
|
||||||
|
radius))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_survivors[count++] = record;
|
||||||
|
writer.AddOutdoor(in record);
|
||||||
|
AddProjection(
|
||||||
|
writer,
|
||||||
|
in record,
|
||||||
|
input.AnimatedEntityIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
int routeIndex =
|
||||||
|
RetailPViewRenderer.LookInBuildingShellRouteIndex(
|
||||||
|
frameIndex,
|
||||||
|
sliceCount,
|
||||||
|
sliceIndex);
|
||||||
|
writer.AddRouteRange(
|
||||||
|
RenderFrameCandidateRoute.LandscapeBuildingShell,
|
||||||
|
routeIndex,
|
||||||
|
0,
|
||||||
|
_survivors.AsSpan(0, count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void BuildLookInRoutes(
|
private void BuildLookInRoutes(
|
||||||
RenderFrameWriter writer,
|
RenderFrameWriter writer,
|
||||||
in RenderScenePViewBuildInput input)
|
in RenderScenePViewBuildInput input,
|
||||||
|
int frameIndex,
|
||||||
|
ref int routeIndex)
|
||||||
{
|
{
|
||||||
for (int frameIndex = 0;
|
PortalVisibilityFrame frame = input.LookInFrames[frameIndex];
|
||||||
frameIndex < input.LookInFrames.Count;
|
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
||||||
frameIndex++)
|
|
||||||
{
|
{
|
||||||
PortalVisibilityFrame frame = input.LookInFrames[frameIndex];
|
uint cellId = frame.OrderedVisibleCells[i];
|
||||||
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
var clipKey = new LookInClipCell(frameIndex, cellId);
|
||||||
|
if (!input.ClipAssembly.LookInCellToViewSlices.TryGetValue(
|
||||||
|
clipKey,
|
||||||
|
out ClipViewSlice[]? slices)
|
||||||
|
|| slices.Length == 0)
|
||||||
{
|
{
|
||||||
uint cellId = frame.OrderedVisibleCells[i];
|
continue;
|
||||||
int count = LoadCell(
|
}
|
||||||
input.Scene,
|
|
||||||
cellId,
|
int count = LoadCell(
|
||||||
includeDynamics: true);
|
input.Scene,
|
||||||
|
cellId,
|
||||||
|
includeDynamics: true);
|
||||||
|
for (int sliceIndex = 0; sliceIndex < slices.Length; sliceIndex++)
|
||||||
|
{
|
||||||
|
int currentRouteIndex = routeIndex++;
|
||||||
if (count == 0)
|
if (count == 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
|
@ -1330,7 +1416,7 @@ internal sealed class RenderScenePViewFrameBuilder
|
||||||
input.AnimatedEntityIds);
|
input.AnimatedEntityIds);
|
||||||
writer.AddRouteRange(
|
writer.AddRouteRange(
|
||||||
RenderFrameCandidateRoute.LookInObject,
|
RenderFrameCandidateRoute.LookInObject,
|
||||||
i,
|
currentRouteIndex,
|
||||||
cellId,
|
cellId,
|
||||||
_cell.AsSpan(0, count));
|
_cell.AsSpan(0, count));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,23 @@ layout(location = 5) in vec4 aColor;
|
||||||
// (ACDREAM_TEXTURE_HANDLE, injected by
|
// (ACDREAM_TEXTURE_HANDLE, injected by
|
||||||
// tools/ShaderCompiler/VulkanGlslPreamble.cs).
|
// tools/ShaderCompiler/VulkanGlslPreamble.cs).
|
||||||
layout(location = 6) in uint aTextureIndex;
|
layout(location = 6) in uint aTextureIndex;
|
||||||
|
layout(location = 7) in uint aClipSlot;
|
||||||
|
|
||||||
|
struct CellClip {
|
||||||
|
uint count;
|
||||||
|
uint _p0;
|
||||||
|
uint _p1;
|
||||||
|
uint _p2;
|
||||||
|
vec4 planes[8];
|
||||||
|
};
|
||||||
|
layout(std430, binding = 2) readonly buffer ClipRegionBuf {
|
||||||
|
CellClip clipRegions[];
|
||||||
|
};
|
||||||
|
|
||||||
|
out gl_PerVertex {
|
||||||
|
vec4 gl_Position;
|
||||||
|
float gl_ClipDistance[8];
|
||||||
|
};
|
||||||
|
|
||||||
uniform mat4 uViewProjection;
|
uniform mat4 uViewProjection;
|
||||||
|
|
||||||
|
|
@ -35,4 +52,9 @@ void main() {
|
||||||
// stage, which is the only form Vulkan can express.
|
// stage, which is the only form Vulkan can express.
|
||||||
vTextureIndex = aTextureIndex;
|
vTextureIndex = aTextureIndex;
|
||||||
gl_Position = uViewProjection * vec4(world, 1.0);
|
gl_Position = uViewProjection * vec4(world, 1.0);
|
||||||
|
CellClip clip = clipRegions[aClipSlot];
|
||||||
|
for (uint i = 0u; i < clip.count; ++i)
|
||||||
|
gl_ClipDistance[i] = dot(clip.planes[i], gl_Position);
|
||||||
|
for (uint i = clip.count; i < 8u; ++i)
|
||||||
|
gl_ClipDistance[i] = 1.0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,23 @@ layout(location = 1) in vec3 aNormal;
|
||||||
layout(location = 2) in vec2 aTexCoord;
|
layout(location = 2) in vec2 aTexCoord;
|
||||||
layout(location = 3) in mat4 aModel;
|
layout(location = 3) in mat4 aModel;
|
||||||
layout(location = 7) in vec4 aColor;
|
layout(location = 7) in vec4 aColor;
|
||||||
|
layout(location = 8) in uint aClipSlot;
|
||||||
|
|
||||||
|
struct CellClip {
|
||||||
|
uint count;
|
||||||
|
uint _p0;
|
||||||
|
uint _p1;
|
||||||
|
uint _p2;
|
||||||
|
vec4 planes[8];
|
||||||
|
};
|
||||||
|
layout(std430, binding = 2) readonly buffer ClipRegionBuf {
|
||||||
|
CellClip clipRegions[];
|
||||||
|
};
|
||||||
|
|
||||||
|
out gl_PerVertex {
|
||||||
|
vec4 gl_Position;
|
||||||
|
float gl_ClipDistance[8];
|
||||||
|
};
|
||||||
|
|
||||||
uniform mat4 uViewProjection;
|
uniform mat4 uViewProjection;
|
||||||
|
|
||||||
|
|
@ -15,4 +32,9 @@ void main() {
|
||||||
vTexCoord = aTexCoord;
|
vTexCoord = aTexCoord;
|
||||||
vColor = aColor;
|
vColor = aColor;
|
||||||
gl_Position = uViewProjection * aModel * vec4(aPosition, 1.0);
|
gl_Position = uViewProjection * aModel * vec4(aPosition, 1.0);
|
||||||
|
CellClip clip = clipRegions[aClipSlot];
|
||||||
|
for (uint i = 0u; i < clip.count; ++i)
|
||||||
|
gl_ClipDistance[i] = dot(clip.planes[i], gl_Position);
|
||||||
|
for (uint i = clip.count; i < 8u; ++i)
|
||||||
|
gl_ClipDistance[i] = 1.0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -263,7 +263,7 @@
|
||||||
"stages": [
|
"stages": [
|
||||||
{
|
{
|
||||||
"stage": "vert",
|
"stage": "vert",
|
||||||
"sourceSha256": "921c32617708b3931a6304b4697ee96d728077d96f489d6c09eea4c5fe225b63",
|
"sourceSha256": "2bdd7114223164916a87e7d2e6df8a21fdcbd1cad082fafb3102b8a0fd4a2a8b",
|
||||||
"compiled": true
|
"compiled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -279,7 +279,7 @@
|
||||||
"stages": [
|
"stages": [
|
||||||
{
|
{
|
||||||
"stage": "vert",
|
"stage": "vert",
|
||||||
"sourceSha256": "19db8757c1a61a2fbec8e56ce89d56b6dd6d66a123cedcdae40915af07d58c0e",
|
"sourceSha256": "582e9be4bca8bd1807d5a2152b211d1d4dd1ed54a6efe4240f52d402e628be75",
|
||||||
"compiled": true
|
"compiled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,21 @@ internal readonly record struct WorldRootFrame(
|
||||||
bool PlayerIndoorGate)
|
bool PlayerIndoorGate)
|
||||||
{
|
{
|
||||||
public bool RenderSky => ViewerRoot is null || RootSeenOutside;
|
public bool RenderSky => ViewerRoot is null || RootSeenOutside;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The camera has an EnvCell root whose authored visibility does not reach
|
||||||
|
/// outdoors. SeenOutside cells are open-air presentation cells, not an
|
||||||
|
/// indoor environment gate merely because they have portal geometry.
|
||||||
|
/// </summary>
|
||||||
|
public bool CameraInsideEnclosedCell => CameraInsideCell && !RootSeenOutside;
|
||||||
|
|
||||||
|
/// <summary>Environment gate shared by directional shadows and other
|
||||||
|
/// outdoor-only render-pack effects.</summary>
|
||||||
|
public bool PlayerOrCameraInsideEnclosedCell =>
|
||||||
|
PlayerInsideCell || CameraInsideEnclosedCell;
|
||||||
|
|
||||||
|
public bool IsAtmosphericallyOutdoor =>
|
||||||
|
RenderSky && !CameraInsideEnclosedCell;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Borrowed building scratch, valid only until the next build.</summary>
|
/// <summary>Borrowed building scratch, valid only until the next build.</summary>
|
||||||
|
|
|
||||||
|
|
@ -221,17 +221,10 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
|
||||||
return AppendSignature(currentSignature, "global");
|
return AppendSignature(currentSignature, "global");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clipRoot.IsOutdoorNode)
|
// Every PView root, including the outdoor sentinel, now submits scene
|
||||||
{
|
// particles inside LScape::draw. Replaying them here is both a duplicate
|
||||||
_particleRenderer.DrawForOwners(
|
// and too late: it occurs after nested building cells, allowing exterior
|
||||||
camera.Camera,
|
// waterfall/foliage alpha to repaint an indoor/outdoor transition.
|
||||||
camera.Position,
|
|
||||||
ParticleRenderPass.Scene,
|
|
||||||
outdoorOwnerIds,
|
|
||||||
includeUnattached: true);
|
|
||||||
return AppendSignature(currentSignature, "unattached");
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentSignature;
|
return currentSignature;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,39 @@ public class ClipFrameAssemblerTests
|
||||||
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
|
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AppendLookInFrames_PacksEveryNestedCellViewWithoutReplacingMainRoutes()
|
||||||
|
{
|
||||||
|
const uint mainCell = 0xA9B40100;
|
||||||
|
const uint lookInCell = 0xA9B40106;
|
||||||
|
var main = new PortalVisibilityFrame();
|
||||||
|
main.CellViews[mainCell] = ViewOf(Square(0f, 0f, 0.8f));
|
||||||
|
main.OrderedVisibleCells.Add(mainCell);
|
||||||
|
main.OutsideView.Add(Square(0f, 0f, 0.7f));
|
||||||
|
|
||||||
|
var nested = new PortalVisibilityFrame();
|
||||||
|
nested.CellViews[lookInCell] = ViewOf(
|
||||||
|
Square(-0.35f, 0f, 0.15f),
|
||||||
|
Square(0.35f, 0f, 0.15f));
|
||||||
|
nested.OrderedVisibleCells.Add(lookInCell);
|
||||||
|
|
||||||
|
using ClipFrame frame = ClipFrame.NoClip();
|
||||||
|
ClipFrameAssembly assembly = ClipFrameAssembler.Assemble(frame, main);
|
||||||
|
int mainSlot = assembly.CellIdToSlot[mainCell];
|
||||||
|
int slotsBeforeLookIn = frame.SlotCount;
|
||||||
|
|
||||||
|
ClipFrameAssembler.AppendLookInFrames(frame, [nested], assembly);
|
||||||
|
|
||||||
|
var key = new LookInClipCell(0, lookInCell);
|
||||||
|
ClipViewSlice[] slices = assembly.LookInCellToViewSlices[key];
|
||||||
|
Assert.Equal(2, slices.Length);
|
||||||
|
Assert.All(slices, slice => Assert.True(slice.Slot >= slotsBeforeLookIn));
|
||||||
|
Assert.NotEqual(slices[0].Slot, slices[1].Slot);
|
||||||
|
Assert.Equal(slices[0].Slot, assembly.LookInCellToSlot[key]);
|
||||||
|
Assert.Equal(mainSlot, assembly.CellIdToSlot[mainCell]);
|
||||||
|
Assert.DoesNotContain(lookInCell, assembly.CellIdToSlot.Keys);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds()
|
public void Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,12 @@ public sealed class VulkanShaderManifestTests
|
||||||
["mesh_modern.frag.spv"] = "b702b644862aca31ce1fb0677adc5872b39c4ea87f595a89363b44d10f2cc50e",
|
["mesh_modern.frag.spv"] = "b702b644862aca31ce1fb0677adc5872b39c4ea87f595a89363b44d10f2cc50e",
|
||||||
["mesh_modern.vert.spv"] = "7ca5fb241c4f0248884ba8fa88fbae17a7d5cbc80efe4ac4a9ffd0254012ead8",
|
["mesh_modern.vert.spv"] = "7ca5fb241c4f0248884ba8fa88fbae17a7d5cbc80efe4ac4a9ffd0254012ead8",
|
||||||
["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b",
|
["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b",
|
||||||
["particle.vert.spv"] = "ed79461ab347bf17edaca714bbbbfabead8192e059c760578ca3a1a01409799e",
|
// Re-pinned 2026-08-27: portal-view clip slots now travel with
|
||||||
|
// deferred billboard particles, matching retail PortalList draws.
|
||||||
|
["particle.vert.spv"] = "bf0f6b7b26a6b237e4abb2973b9959a38338868fd8304b3863f593ad56d61c35",
|
||||||
["particle_mesh.frag.spv"] = "7696b1dc0613b5a724c55df465173f613ae047da9675895b149b7c71b009cc7c",
|
["particle_mesh.frag.spv"] = "7696b1dc0613b5a724c55df465173f613ae047da9675895b149b7c71b009cc7c",
|
||||||
["particle_mesh.vert.spv"] = "f7fe8b203cadcd4d54af5cdbcfd9d5bf733146e10bafa78ca730fb6970db0479",
|
// Same contract for full-mesh particle geometry.
|
||||||
|
["particle_mesh.vert.spv"] = "b5b3e0f583e00b78b56e297e60e5050013a26b3a74dbec64f6e5b286b751f0fa",
|
||||||
["portal_depth.frag.spv"] = "96755196d4d0da7be4792107557465778be2ebefb5584834cc75bf90ec55a6cc",
|
["portal_depth.frag.spv"] = "96755196d4d0da7be4792107557465778be2ebefb5584834cc75bf90ec55a6cc",
|
||||||
["portal_depth.vert.spv"] = "cd113860b7acd6afad3ebcc0a68dd7147f6baae729df51ab360c123588dc3ae2",
|
["portal_depth.vert.spv"] = "cd113860b7acd6afad3ebcc0a68dd7147f6baae729df51ab360c123588dc3ae2",
|
||||||
// sky.frag re-pinned 2026-08-23: the dome's fog blend lost its
|
// sky.frag re-pinned 2026-08-23: the dome's fog blend lost its
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,15 @@ public sealed class ParticleBindlessInstanceTests
|
||||||
// one TextureIndex (a binding=9 handle-table slot, 4 bytes), so the
|
// one TextureIndex (a binding=9 handle-table slot, 4 bytes), so the
|
||||||
// struct shrank by 4 bytes; TextureIndex keeps TextureHandleLow's
|
// struct shrank by 4 bytes; TextureIndex keeps TextureHandleLow's
|
||||||
// former offset (64 — right after the four vec4 fields).
|
// former offset (64 — right after the four vec4 fields).
|
||||||
Assert.Equal(68, Marshal.SizeOf<ParticleRenderer.BillboardGpuInstance>());
|
Assert.Equal(72, Marshal.SizeOf<ParticleRenderer.BillboardGpuInstance>());
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
new IntPtr(64),
|
new IntPtr(64),
|
||||||
Marshal.OffsetOf<ParticleRenderer.BillboardGpuInstance>(
|
Marshal.OffsetOf<ParticleRenderer.BillboardGpuInstance>(
|
||||||
nameof(ParticleRenderer.BillboardGpuInstance.TextureIndex)));
|
nameof(ParticleRenderer.BillboardGpuInstance.TextureIndex)));
|
||||||
|
Assert.Equal(
|
||||||
|
new IntPtr(68),
|
||||||
|
Marshal.OffsetOf<ParticleRenderer.BillboardGpuInstance>(
|
||||||
|
nameof(ParticleRenderer.BillboardGpuInstance.ClipSlot)));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -40,6 +44,8 @@ public sealed class ParticleBindlessInstanceTests
|
||||||
// index rather than by a null handle, because Vulkan's descriptor array
|
// index rather than by a null handle, because Vulkan's descriptor array
|
||||||
// cannot be asked whether an element was ever written.
|
// cannot be asked whether an element was ever written.
|
||||||
Assert.Contains("layout(location = 6) in uint aTextureIndex;", vertex);
|
Assert.Contains("layout(location = 6) in uint aTextureIndex;", vertex);
|
||||||
|
Assert.Contains("layout(location = 7) in uint aClipSlot;", vertex);
|
||||||
|
Assert.Contains("clipRegions[aClipSlot]", vertex);
|
||||||
Assert.Contains("flat out uint vTextureIndex;", vertex);
|
Assert.Contains("flat out uint vTextureIndex;", vertex);
|
||||||
Assert.Contains("vTextureIndex = aTextureIndex;", vertex);
|
Assert.Contains("vTextureIndex = aTextureIndex;", vertex);
|
||||||
Assert.Contains("#extension GL_ARB_bindless_texture : require", fragment);
|
Assert.Contains("#extension GL_ARB_bindless_texture : require", fragment);
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ public sealed class RenderScenePViewFrameProductTests
|
||||||
RenderProjectionRecord outdoor = Record(
|
RenderProjectionRecord outdoor = Record(
|
||||||
0x0100_0000_0000_0001,
|
0x0100_0000_0000_0001,
|
||||||
RenderProjectionClass.OutdoorStatic);
|
RenderProjectionClass.OutdoorStatic);
|
||||||
|
RenderProjectionRecord buildingShell = Record(
|
||||||
|
0x0100_0000_0000_0008,
|
||||||
|
RenderProjectionClass.OutdoorStatic,
|
||||||
|
isBuildingShell: true);
|
||||||
RenderProjectionRecord hidden = Record(
|
RenderProjectionRecord hidden = Record(
|
||||||
0x0100_0000_0000_0002,
|
0x0100_0000_0000_0002,
|
||||||
RenderProjectionClass.OutdoorStatic,
|
RenderProjectionClass.OutdoorStatic,
|
||||||
|
|
@ -48,6 +52,7 @@ public sealed class RenderScenePViewFrameProductTests
|
||||||
RenderProjectionRecord[] records =
|
RenderProjectionRecord[] records =
|
||||||
[
|
[
|
||||||
outdoor,
|
outdoor,
|
||||||
|
buildingShell,
|
||||||
hidden,
|
hidden,
|
||||||
withdrawn,
|
withdrawn,
|
||||||
shell,
|
shell,
|
||||||
|
|
@ -90,7 +95,7 @@ public sealed class RenderScenePViewFrameProductTests
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
[outdoor.Id, hidden.Id],
|
[outdoor.Id, hidden.Id, buildingShell.Id],
|
||||||
view.OutdoorStaticCandidates.ToArray()
|
view.OutdoorStaticCandidates.ToArray()
|
||||||
.Select(static item => item.Id));
|
.Select(static item => item.Id));
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
|
|
@ -101,28 +106,42 @@ public sealed class RenderScenePViewFrameProductTests
|
||||||
[outdoorDynamic.Id, cellDynamic.Id],
|
[outdoorDynamic.Id, cellDynamic.Id],
|
||||||
view.DynamicCandidates.ToArray()
|
view.DynamicCandidates.ToArray()
|
||||||
.Select(static item => item.Id));
|
.Select(static item => item.Id));
|
||||||
Assert.Equal(5, view.Transforms.Length);
|
Assert.Equal(6, view.Transforms.Length);
|
||||||
Assert.Equal(5, view.RouteCandidates.Length);
|
Assert.Equal(6, view.RouteCandidates.Length);
|
||||||
Assert.Equal(3, view.RouteRanges.Length);
|
Assert.Equal(3, view.RouteRanges.Length);
|
||||||
|
RenderFrameCandidateRange outdoorRange = Assert.Single(
|
||||||
|
view.RouteRanges.ToArray(),
|
||||||
|
range => range.Route
|
||||||
|
== RenderFrameCandidateRoute.LandscapeOutdoorStatic);
|
||||||
|
Assert.Equal(3, outdoorRange.Count);
|
||||||
|
Assert.Contains(
|
||||||
|
view.RouteCandidates.Slice(
|
||||||
|
outdoorRange.Offset,
|
||||||
|
outdoorRange.Count).ToArray(),
|
||||||
|
item => item.Id == buildingShell.Id);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
view.RouteRanges.ToArray(),
|
||||||
|
range => range.Route
|
||||||
|
== RenderFrameCandidateRoute.LandscapeBuildingShell);
|
||||||
Assert.DoesNotContain(
|
Assert.DoesNotContain(
|
||||||
view.RouteCandidates.ToArray(),
|
view.RouteCandidates.ToArray(),
|
||||||
item => item.Id == withdrawn.Id || item.Id == shell.Id);
|
item => item.Id == withdrawn.Id || item.Id == shell.Id);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
new RenderFrameDiagnosticCounts(
|
new RenderFrameDiagnosticCounts(
|
||||||
OutdoorStaticCandidates: 2,
|
OutdoorStaticCandidates: 3,
|
||||||
CellStaticCandidates: 1,
|
CellStaticCandidates: 1,
|
||||||
DynamicCandidates: 2,
|
DynamicCandidates: 2,
|
||||||
TransformCount: 5,
|
TransformCount: 6,
|
||||||
OpaqueClassificationCount: 0,
|
OpaqueClassificationCount: 0,
|
||||||
AlphaClassificationCount: 0,
|
AlphaClassificationCount: 0,
|
||||||
LightSetCount: 0,
|
LightSetCount: 0,
|
||||||
SelectionPartCount: 0,
|
SelectionPartCount: 0,
|
||||||
RouteCandidateCount: 5,
|
RouteCandidateCount: 6,
|
||||||
EntityCandidateCount: 5,
|
EntityCandidateCount: 6,
|
||||||
MeshPartCount: 5),
|
MeshPartCount: 6),
|
||||||
view.DiagnosticCounts);
|
view.DiagnosticCounts);
|
||||||
Assert.Equal(5, view.EntityCandidates.Length);
|
Assert.Equal(6, view.EntityCandidates.Length);
|
||||||
Assert.Equal(5, view.MeshParts.Length);
|
Assert.Equal(6, view.MeshParts.Length);
|
||||||
Assert.Same(portal, view.PortalFrame);
|
Assert.Same(portal, view.PortalFrame);
|
||||||
Assert.Same(clip, view.ClipAssembly);
|
Assert.Same(clip, view.ClipAssembly);
|
||||||
Assert.Equal(digest, view.SourceDigest);
|
Assert.Equal(digest, view.SourceDigest);
|
||||||
|
|
@ -133,6 +152,178 @@ public sealed class RenderScenePViewFrameProductTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Builder_OrdersMultiSliceBuildingShellsAfterLookIns()
|
||||||
|
{
|
||||||
|
using var scene = new ArchRenderScene(Generation);
|
||||||
|
RenderProjectionRecord outdoor = Record(
|
||||||
|
0x0100_0000_0000_0101,
|
||||||
|
RenderProjectionClass.OutdoorStatic);
|
||||||
|
RenderProjectionRecord buildingShell = Record(
|
||||||
|
0x0100_0000_0000_0102,
|
||||||
|
RenderProjectionClass.OutdoorStatic,
|
||||||
|
isBuildingShell: true,
|
||||||
|
buildingShellAnchorCellId: Cell);
|
||||||
|
RenderProjectionRecord lookInObject = Record(
|
||||||
|
0x0100_0000_0000_0103,
|
||||||
|
RenderProjectionClass.IndoorCellStatic,
|
||||||
|
parentCell: Cell);
|
||||||
|
scene.Apply(
|
||||||
|
[
|
||||||
|
RenderProjectionDelta.Register(Generation, 1, outdoor),
|
||||||
|
RenderProjectionDelta.Register(Generation, 2, buildingShell),
|
||||||
|
RenderProjectionDelta.Register(Generation, 3, lookInObject),
|
||||||
|
]);
|
||||||
|
|
||||||
|
PortalVisibilityFrame portal = Portal(Cell);
|
||||||
|
PortalVisibilityFrame lookIn = Portal(Cell);
|
||||||
|
lookIn.SourceBuildingKey = 2u;
|
||||||
|
lookIn.SourceBuildingLandblockId = Cell & 0xFFFF0000u;
|
||||||
|
var anchorCell = new LoadedCell
|
||||||
|
{
|
||||||
|
CellId = Cell,
|
||||||
|
BuildingId = 2u,
|
||||||
|
};
|
||||||
|
ClipFrameAssembly clip = TwoSliceClip(Cell);
|
||||||
|
clip.LookInCellToViewSlices[new LookInClipCell(0, Cell)] =
|
||||||
|
[clip.CellIdToViewSlices[Cell][0]];
|
||||||
|
ViewconeCuller viewcone =
|
||||||
|
ViewconeCuller.Build(clip, Matrix4x4.Identity);
|
||||||
|
var exchange = new RenderFrameExchange();
|
||||||
|
var builder = new RenderScenePViewFrameBuilder();
|
||||||
|
RenderSceneDigest digest =
|
||||||
|
scene.BuildDigest(new RenderSceneDigestBuffer());
|
||||||
|
var input = new RenderScenePViewBuildInput(
|
||||||
|
scene.OpenQuery(),
|
||||||
|
digest,
|
||||||
|
portal,
|
||||||
|
clip,
|
||||||
|
viewcone,
|
||||||
|
[lookIn],
|
||||||
|
[Cell],
|
||||||
|
new DictionaryCellSource(anchorCell),
|
||||||
|
[],
|
||||||
|
RootIsOutdoor: true);
|
||||||
|
|
||||||
|
builder.Build(exchange, frameSequence: 1, in input);
|
||||||
|
RenderFrameView view = exchange.BorrowLatest(Generation, 1);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
(RenderFrameCandidateRoute.LandscapeOutdoorStatic, 0, 0u),
|
||||||
|
(RenderFrameCandidateRoute.LandscapeOutdoorStatic, 1, 0u),
|
||||||
|
(RenderFrameCandidateRoute.LookInObject, 0, Cell),
|
||||||
|
(RenderFrameCandidateRoute.LandscapeBuildingShell, 0, 0u),
|
||||||
|
(RenderFrameCandidateRoute.LandscapeBuildingShell, 1, 0u),
|
||||||
|
(RenderFrameCandidateRoute.CellStatic, 0, 0u),
|
||||||
|
],
|
||||||
|
view.RouteRanges.ToArray().Select(static range =>
|
||||||
|
(range.Route, range.RouteIndex, range.CellId)));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
exchange.Release(in view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Builder_InterleavesEachLookInWithItsOwnPackedBuildingShellRoutes()
|
||||||
|
{
|
||||||
|
const uint secondCell = 0xA9B40180u;
|
||||||
|
using var scene = new ArchRenderScene(Generation);
|
||||||
|
RenderProjectionRecord firstShell = Record(
|
||||||
|
0x0100_0000_0000_0201,
|
||||||
|
RenderProjectionClass.OutdoorStatic,
|
||||||
|
isBuildingShell: true,
|
||||||
|
buildingShellAnchorCellId: Cell);
|
||||||
|
RenderProjectionRecord secondShell = Record(
|
||||||
|
0x0100_0000_0000_0202,
|
||||||
|
RenderProjectionClass.OutdoorStatic,
|
||||||
|
isBuildingShell: true,
|
||||||
|
buildingShellAnchorCellId: secondCell);
|
||||||
|
RenderProjectionRecord firstObject = Record(
|
||||||
|
0x0100_0000_0000_0203,
|
||||||
|
RenderProjectionClass.IndoorCellStatic,
|
||||||
|
parentCell: Cell);
|
||||||
|
RenderProjectionRecord secondObject = Record(
|
||||||
|
0x0100_0000_0000_0204,
|
||||||
|
RenderProjectionClass.IndoorCellStatic,
|
||||||
|
parentCell: secondCell);
|
||||||
|
scene.Apply(
|
||||||
|
[
|
||||||
|
RenderProjectionDelta.Register(Generation, 1, firstShell),
|
||||||
|
RenderProjectionDelta.Register(Generation, 2, secondShell),
|
||||||
|
RenderProjectionDelta.Register(Generation, 3, firstObject),
|
||||||
|
RenderProjectionDelta.Register(Generation, 4, secondObject),
|
||||||
|
]);
|
||||||
|
|
||||||
|
PortalVisibilityFrame portal = Portal(Cell);
|
||||||
|
PortalVisibilityFrame firstLookIn = Portal(Cell);
|
||||||
|
firstLookIn.SourceBuildingKey = 2u;
|
||||||
|
firstLookIn.SourceBuildingLandblockId = Cell & 0xFFFF0000u;
|
||||||
|
PortalVisibilityFrame secondLookIn = Portal(secondCell);
|
||||||
|
secondLookIn.SourceBuildingKey = 3u;
|
||||||
|
secondLookIn.SourceBuildingLandblockId = secondCell & 0xFFFF0000u;
|
||||||
|
var firstAnchor = new LoadedCell
|
||||||
|
{
|
||||||
|
CellId = Cell,
|
||||||
|
BuildingId = 2u,
|
||||||
|
};
|
||||||
|
var secondAnchor = new LoadedCell
|
||||||
|
{
|
||||||
|
CellId = secondCell,
|
||||||
|
BuildingId = 3u,
|
||||||
|
};
|
||||||
|
ClipFrameAssembly clip = TwoSliceClip(Cell);
|
||||||
|
clip.LookInCellToViewSlices[new LookInClipCell(0, Cell)] =
|
||||||
|
[clip.CellIdToViewSlices[Cell][0]];
|
||||||
|
clip.LookInCellToViewSlices[new LookInClipCell(1, secondCell)] =
|
||||||
|
[clip.CellIdToViewSlices[Cell][0]];
|
||||||
|
ViewconeCuller viewcone =
|
||||||
|
ViewconeCuller.Build(clip, Matrix4x4.Identity);
|
||||||
|
var exchange = new RenderFrameExchange();
|
||||||
|
var builder = new RenderScenePViewFrameBuilder();
|
||||||
|
RenderSceneDigest digest =
|
||||||
|
scene.BuildDigest(new RenderSceneDigestBuffer());
|
||||||
|
var input = new RenderScenePViewBuildInput(
|
||||||
|
scene.OpenQuery(),
|
||||||
|
digest,
|
||||||
|
portal,
|
||||||
|
clip,
|
||||||
|
viewcone,
|
||||||
|
[firstLookIn, secondLookIn],
|
||||||
|
[Cell],
|
||||||
|
new DictionaryCellSource(firstAnchor, secondAnchor),
|
||||||
|
[],
|
||||||
|
RootIsOutdoor: true);
|
||||||
|
|
||||||
|
builder.Build(exchange, frameSequence: 1, in input);
|
||||||
|
RenderFrameView view = exchange.BorrowLatest(Generation, 1);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
(RenderFrameCandidateRoute.LookInObject, 0, Cell),
|
||||||
|
(RenderFrameCandidateRoute.LandscapeBuildingShell, 0, 0u),
|
||||||
|
(RenderFrameCandidateRoute.LandscapeBuildingShell, 1, 0u),
|
||||||
|
(RenderFrameCandidateRoute.LookInObject, 1, secondCell),
|
||||||
|
(RenderFrameCandidateRoute.LandscapeBuildingShell, 2, 0u),
|
||||||
|
(RenderFrameCandidateRoute.LandscapeBuildingShell, 3, 0u),
|
||||||
|
],
|
||||||
|
view.RouteRanges.ToArray()
|
||||||
|
.Where(static range =>
|
||||||
|
range.Route is RenderFrameCandidateRoute.LookInObject
|
||||||
|
or RenderFrameCandidateRoute.LandscapeBuildingShell)
|
||||||
|
.Select(static range =>
|
||||||
|
(range.Route, range.RouteIndex, range.CellId)));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
exchange.Release(in view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Builder_ReusesOrderedIndicesWhileRefreshingDirtyRecords()
|
public void Builder_ReusesOrderedIndicesWhileRefreshingDirtyRecords()
|
||||||
{
|
{
|
||||||
|
|
@ -501,7 +692,9 @@ public sealed class RenderScenePViewFrameProductTests
|
||||||
uint? parentCell = null,
|
uint? parentCell = null,
|
||||||
RenderProjectionFlags flags =
|
RenderProjectionFlags flags =
|
||||||
RenderProjectionFlags.Draw
|
RenderProjectionFlags.Draw
|
||||||
| RenderProjectionFlags.SpatiallyResident)
|
| RenderProjectionFlags.SpatiallyResident,
|
||||||
|
bool isBuildingShell = false,
|
||||||
|
uint buildingShellAnchorCellId = 0)
|
||||||
{
|
{
|
||||||
uint fullCellId = parentCell ?? Landblock;
|
uint fullCellId = parentCell ?? Landblock;
|
||||||
Matrix4x4 transform = Matrix4x4.Identity;
|
Matrix4x4 transform = Matrix4x4.Identity;
|
||||||
|
|
@ -531,14 +724,14 @@ public sealed class RenderScenePViewFrameProductTests
|
||||||
SourceId: 1,
|
SourceId: 1,
|
||||||
ParentCellId: parentCell ?? 0,
|
ParentCellId: parentCell ?? 0,
|
||||||
EffectCellId: 0,
|
EffectCellId: 0,
|
||||||
BuildingShellAnchorCellId: 0,
|
BuildingShellAnchorCellId: buildingShellAnchorCellId,
|
||||||
TransformFingerprint: default,
|
TransformFingerprint: default,
|
||||||
GeometryFingerprint: default,
|
GeometryFingerprint: default,
|
||||||
AppearanceFingerprint: default),
|
AppearanceFingerprint: default),
|
||||||
new RenderEntityPayload(
|
new RenderEntityPayload(
|
||||||
[new MeshRef((uint)id, Matrix4x4.Identity)],
|
[new MeshRef((uint)id, Matrix4x4.Identity)],
|
||||||
PaletteOverride: null,
|
PaletteOverride: null,
|
||||||
IsBuildingShell: false));
|
IsBuildingShell: isBuildingShell));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WorldEntity Entity(
|
private static WorldEntity Entity(
|
||||||
|
|
@ -578,6 +771,22 @@ public sealed class RenderScenePViewFrameProductTests
|
||||||
return assembly;
|
return assembly;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ClipFrameAssembly TwoSliceClip(uint cellId)
|
||||||
|
{
|
||||||
|
var first = new ClipViewSlice(
|
||||||
|
Slot: 0,
|
||||||
|
NdcAabb: new Vector4(-1, -1, 0, 1),
|
||||||
|
Planes: []);
|
||||||
|
var second = new ClipViewSlice(
|
||||||
|
Slot: 1,
|
||||||
|
NdcAabb: new Vector4(0, -1, 1, 1),
|
||||||
|
Planes: []);
|
||||||
|
var assembly = new ClipFrameAssembly();
|
||||||
|
assembly.SetOutsideViewSlices([first, second]);
|
||||||
|
assembly.CellIdToViewSlices[cellId] = [first];
|
||||||
|
return assembly;
|
||||||
|
}
|
||||||
|
|
||||||
private static (
|
private static (
|
||||||
uint LandblockId,
|
uint LandblockId,
|
||||||
Vector3 AabbMin,
|
Vector3 AabbMin,
|
||||||
|
|
@ -602,4 +811,15 @@ public sealed class RenderScenePViewFrameProductTests
|
||||||
|
|
||||||
public LoadedCell? Find(uint cellId) => null;
|
public LoadedCell? Find(uint cellId) => null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class DictionaryCellSource : IRetailPViewCellSource
|
||||||
|
{
|
||||||
|
private readonly Dictionary<uint, LoadedCell> _cells;
|
||||||
|
|
||||||
|
public DictionaryCellSource(params LoadedCell[] cells) =>
|
||||||
|
_cells = cells.ToDictionary(static cell => cell.CellId);
|
||||||
|
|
||||||
|
public LoadedCell? Find(uint cellId) =>
|
||||||
|
_cells.TryGetValue(cellId, out LoadedCell? cell) ? cell : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
[
|
[
|
||||||
"begin",
|
"begin",
|
||||||
"assemble",
|
"assemble",
|
||||||
|
"append-look-in-clips",
|
||||||
"prepare-clip:3",
|
"prepare-clip:3",
|
||||||
"indoor-routing",
|
"indoor-routing",
|
||||||
"prepare-cells",
|
"prepare-cells",
|
||||||
|
|
@ -33,6 +34,7 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
"terrain-clip",
|
"terrain-clip",
|
||||||
"clear-routing",
|
"clear-routing",
|
||||||
"landscape-late",
|
"landscape-late",
|
||||||
|
"unattached-particles",
|
||||||
"landscape-alpha",
|
"landscape-alpha",
|
||||||
"indoor-routing",
|
"indoor-routing",
|
||||||
"indoor-routing",
|
"indoor-routing",
|
||||||
|
|
@ -272,6 +274,88 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
executor);
|
executor);
|
||||||
|
|
||||||
Assert.Contains("look-in-punch", executor.Operations);
|
Assert.Contains("look-in-punch", executor.Operations);
|
||||||
|
AssertAppearsInOrder(
|
||||||
|
string.Join('|', executor.Operations),
|
||||||
|
"landscape-early",
|
||||||
|
"unattached-particles",
|
||||||
|
"landscape-static-particles",
|
||||||
|
"landscape-alpha",
|
||||||
|
"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",
|
||||||
|
"look-in-punch",
|
||||||
|
"landscape-building-shell");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -429,20 +513,21 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
return cell;
|
return cell;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LoadedCell[] NearbyTwoCellBuilding()
|
private static LoadedCell[] NearbyTwoCellBuilding(
|
||||||
|
uint vestibuleId = 0xA9B40170u,
|
||||||
|
uint roomId = 0xA9B40171u,
|
||||||
|
uint buildingId = 2u)
|
||||||
{
|
{
|
||||||
const uint vestibuleId = 0xA9B40170u;
|
|
||||||
const uint roomId = 0xA9B40171u;
|
|
||||||
var vestibule = new LoadedCell
|
var vestibule = new LoadedCell
|
||||||
{
|
{
|
||||||
CellId = vestibuleId,
|
CellId = vestibuleId,
|
||||||
BuildingId = 2u,
|
BuildingId = buildingId,
|
||||||
WorldTransform = Matrix4x4.Identity,
|
WorldTransform = Matrix4x4.Identity,
|
||||||
InverseWorldTransform = Matrix4x4.Identity,
|
InverseWorldTransform = Matrix4x4.Identity,
|
||||||
Portals =
|
Portals =
|
||||||
[
|
[
|
||||||
new CellPortalInfo(0xFFFF, 0, 0, 0),
|
new CellPortalInfo(0xFFFF, 0, 0, 0),
|
||||||
new CellPortalInfo(0x0171, 1, 0, 0),
|
new CellPortalInfo((ushort)(roomId & 0xFFFFu), 1, 0, 0),
|
||||||
],
|
],
|
||||||
ClipPlanes =
|
ClipPlanes =
|
||||||
[
|
[
|
||||||
|
|
@ -472,10 +557,17 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
var room = new LoadedCell
|
var room = new LoadedCell
|
||||||
{
|
{
|
||||||
CellId = roomId,
|
CellId = roomId,
|
||||||
BuildingId = 2u,
|
BuildingId = buildingId,
|
||||||
WorldTransform = Matrix4x4.Identity,
|
WorldTransform = Matrix4x4.Identity,
|
||||||
InverseWorldTransform = Matrix4x4.Identity,
|
InverseWorldTransform = Matrix4x4.Identity,
|
||||||
Portals = [new CellPortalInfo(0x0170, 0, 0, 1)],
|
Portals =
|
||||||
|
[
|
||||||
|
new CellPortalInfo(
|
||||||
|
(ushort)(vestibuleId & 0xFFFFu),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
room.PortalPolygons.Add(
|
room.PortalPolygons.Add(
|
||||||
[
|
[
|
||||||
|
|
@ -490,7 +582,9 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
private static WorldEntity Entity(
|
private static WorldEntity Entity(
|
||||||
uint id,
|
uint id,
|
||||||
uint serverGuid = 0,
|
uint serverGuid = 0,
|
||||||
uint? parentCellId = null) => new()
|
uint? parentCellId = null,
|
||||||
|
bool isBuildingShell = false,
|
||||||
|
uint? buildingShellAnchorCellId = null) => new()
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
ServerGuid = serverGuid,
|
ServerGuid = serverGuid,
|
||||||
|
|
@ -499,6 +593,8 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
Rotation = Quaternion.Identity,
|
Rotation = Quaternion.Identity,
|
||||||
MeshRefs = [new MeshRef(1u, Matrix4x4.Identity)],
|
MeshRefs = [new MeshRef(1u, Matrix4x4.Identity)],
|
||||||
ParentCellId = parentCellId,
|
ParentCellId = parentCellId,
|
||||||
|
IsBuildingShell = isBuildingShell,
|
||||||
|
BuildingShellAnchorCellId = buildingShellAnchorCellId,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static void AssertAppearsInOrder(string source, params string[] needles)
|
private static void AssertAppearsInOrder(string source, params string[] needles)
|
||||||
|
|
@ -566,6 +662,17 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
return ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly);
|
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) =>
|
public void PrepareClipFrame(int terrainUploadCount) =>
|
||||||
Operations.Add($"prepare-clip:{terrainUploadCount}");
|
Operations.Add($"prepare-clip:{terrainUploadCount}");
|
||||||
|
|
||||||
|
|
@ -574,6 +681,8 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
|
|
||||||
public void ClearClipRouting() => Operations.Add("clear-routing");
|
public void ClearClipRouting() => Operations.Add("clear-routing");
|
||||||
public void UseIndoorMembershipOnlyRouting() => Operations.Add("indoor-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) =>
|
public void PrepareCellBatches(RetailPViewFrameInput frame, HashSet<uint> visibleCellIds) =>
|
||||||
Operations.Add("prepare-cells");
|
Operations.Add("prepare-cells");
|
||||||
public void DrawOpaqueCellShells(HashSet<uint> cellIds) => Operations.Add("opaque-shells");
|
public void DrawOpaqueCellShells(HashSet<uint> cellIds) => Operations.Add("opaque-shells");
|
||||||
|
|
@ -624,10 +733,37 @@ public sealed class RetailPViewPassExecutorTests
|
||||||
request.TupleLandblockId);
|
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 ClearInteriorDepth() => Operations.Add("interior-depth-clear");
|
||||||
public void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("exit-mask");
|
public void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("exit-mask");
|
||||||
public void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("look-in-punch");
|
public void DrawLookInPortalPunch(
|
||||||
public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame) => Operations.Add("unattached-particles");
|
RetailPViewFrameInput frame,
|
||||||
|
RetailPViewCellSliceContext context,
|
||||||
|
int portalIndex) => Operations.Add("look-in-punch");
|
||||||
|
public void DrawUnattachedSceneParticles(
|
||||||
|
RetailPViewFrameInput frame,
|
||||||
|
ClipViewSlice slice) => Operations.Add("unattached-particles");
|
||||||
public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha");
|
public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha");
|
||||||
public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles");
|
public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles");
|
||||||
public void DrawDynamicsParticles(
|
public void DrawDynamicsParticles(
|
||||||
|
|
|
||||||
|
|
@ -105,10 +105,9 @@ public class RhiVertexLayoutStrideTests
|
||||||
layout.StrideOf(0));
|
layout.StrideOf(0));
|
||||||
Assert.Equal(GpuVertexInputRate.Vertex, layout.InputRateOf(0));
|
Assert.Equal(GpuVertexInputRate.Vertex, layout.InputRateOf(0));
|
||||||
|
|
||||||
// Binding 1 is a mat4 model plus an RGBA colour, written as loose floats
|
// Binding 1 is the exact mesh-particle record: model, RGBA, clip slot.
|
||||||
// by WriteMeshGpuInstance.
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
(uint)(ParticleRenderer.MeshInstanceFloats * sizeof(float)),
|
(uint)Unsafe.SizeOf<ParticleRenderer.MeshParticleGpuInstance>(),
|
||||||
layout.StrideOf(1));
|
layout.StrideOf(1));
|
||||||
Assert.Equal(GpuVertexInputRate.Instance, layout.InputRateOf(1));
|
Assert.Equal(GpuVertexInputRate.Instance, layout.InputRateOf(1));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
226
tests/AcDream.App.Tests/Rendering/SanctuaryPortalSeamTests.cs
Normal file
226
tests/AcDream.App.Tests/Rendering/SanctuaryPortalSeamTests.cs
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sanctuary cathedral seam captured 2026-08-27. The stationary player is in
|
||||||
|
/// F4180104 while chase-camera zoom crosses the coincident outside portals at
|
||||||
|
/// world Y=48 and the viewer root switches between two separate buildings:
|
||||||
|
/// B3={0103,0104,0105} and B4={0106..0111}. The opposite cathedral half must
|
||||||
|
/// remain available through the interior-root building look-in on both sides.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Lane", "InstalledDat")]
|
||||||
|
public sealed class SanctuaryPortalSeamTests
|
||||||
|
{
|
||||||
|
private const uint Landblock = 0xF4180000u;
|
||||||
|
private const uint Cell0104 = Landblock | 0x0104u;
|
||||||
|
private const uint Cell0106 = Landblock | 0x0106u;
|
||||||
|
|
||||||
|
private static Matrix4x4 ViewProjection(Vector3 eye)
|
||||||
|
{
|
||||||
|
// Derived from the two exact flap-sweep points. Extending their zoom
|
||||||
|
// ray reaches the stable chase target at the player's head.
|
||||||
|
var target = new Vector3(36.033f, 49.638f, 171.353f);
|
||||||
|
var view = Matrix4x4.CreateLookAt(eye, target, Vector3.UnitZ);
|
||||||
|
var projection = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||||
|
1.2f, 893f / 522f, 1f, 5000f);
|
||||||
|
return view * projection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<LoadedCell> Cells(
|
||||||
|
Dictionary<uint, LoadedCell> cells,
|
||||||
|
uint firstLow,
|
||||||
|
uint lastLow)
|
||||||
|
{
|
||||||
|
var result = new List<LoadedCell>();
|
||||||
|
for (uint low = firstLow; low <= lastLow; low++)
|
||||||
|
result.Add(cells[Landblock | low]);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CapturedZoomHandoff_BothRootsBuildTheExactOppositeCathedralSeed()
|
||||||
|
{
|
||||||
|
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||||
|
if (datDir is null)
|
||||||
|
{
|
||||||
|
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
LandBlockInfo info = Assert.IsType<LandBlockInfo>(dats.Get<LandBlockInfo>(Landblock | 0xfffeu));
|
||||||
|
Dictionary<uint, LoadedCell> cells =
|
||||||
|
Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock);
|
||||||
|
LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out LoadedCell? cell) ? cell : null;
|
||||||
|
|
||||||
|
IReadOnlyList<LoadedCell> building3 = Cells(cells, 0x0103u, 0x0105u);
|
||||||
|
IReadOnlyList<LoadedCell> building4 = Cells(cells, 0x0106u, 0x0111u);
|
||||||
|
Assert.Equal(0x01001FB2u, info.Buildings[3].ModelId);
|
||||||
|
Assert.Equal(0x01001FB3u, info.Buildings[4].ModelId);
|
||||||
|
|
||||||
|
var captures = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Name = "near/root0104",
|
||||||
|
Eye = new Vector3(32.742317f, 48.034306f, 172.447845f),
|
||||||
|
Root = cells[Cell0104],
|
||||||
|
Opposite = building4,
|
||||||
|
ExpectedOppositeCell = Cell0106,
|
||||||
|
ExpectedSeedPortal = 2,
|
||||||
|
OppositeBuildingIndex = 4,
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Name = "far/root0106",
|
||||||
|
Eye = new Vector3(32.308865f, 47.823200f, 172.592255f),
|
||||||
|
Root = cells[Cell0106],
|
||||||
|
Opposite = building3,
|
||||||
|
ExpectedOppositeCell = Cell0104,
|
||||||
|
ExpectedSeedPortal = 0,
|
||||||
|
OppositeBuildingIndex = 3,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var capture in captures)
|
||||||
|
{
|
||||||
|
Matrix4x4 viewProjection = ViewProjection(capture.Eye);
|
||||||
|
PortalVisibilityFrame main = PortalVisibilityBuilder.Build(
|
||||||
|
capture.Root, capture.Eye, Lookup, viewProjection);
|
||||||
|
PortalVisibilityFrame lookIn = PortalVisibilityBuilder.ConstructViewBuilding(
|
||||||
|
capture.Opposite,
|
||||||
|
capture.Eye,
|
||||||
|
Lookup,
|
||||||
|
viewProjection,
|
||||||
|
maxSeedDistance: float.PositiveInfinity,
|
||||||
|
seedRegion: main.OutsideView.Polygons);
|
||||||
|
|
||||||
|
Assert.Contains(capture.ExpectedOppositeCell, lookIn.OrderedVisibleCells);
|
||||||
|
Assert.True(
|
||||||
|
lookIn.CellViews.TryGetValue(capture.ExpectedOppositeCell, out CellView? view)
|
||||||
|
&& view.Polygons.Count > 0,
|
||||||
|
$"{capture.Name} must retain a clipped aperture for the opposite cathedral half");
|
||||||
|
ExteriorPortalSeed seed = Assert.Single(lookIn.ExteriorSeedPortals);
|
||||||
|
Assert.Equal(capture.ExpectedOppositeCell, seed.CellId);
|
||||||
|
Assert.Equal(capture.ExpectedSeedPortal, seed.PortalIndex);
|
||||||
|
Assert.NotEmpty(seed.View.Polygons);
|
||||||
|
|
||||||
|
using ClipFrame clipFrame = ClipFrame.NoClip();
|
||||||
|
ClipFrameAssembly assembly = ClipFrameAssembler.Assemble(
|
||||||
|
clipFrame,
|
||||||
|
main);
|
||||||
|
ClipFrameAssembler.AppendLookInFrames(
|
||||||
|
clipFrame,
|
||||||
|
[lookIn],
|
||||||
|
assembly);
|
||||||
|
ClipViewSlice[] nestedSlices =
|
||||||
|
assembly.LookInCellToViewSlices[
|
||||||
|
new LookInClipCell(0, capture.ExpectedOppositeCell)];
|
||||||
|
Assert.Equal(view.Polygons.Count, nestedSlices.Length);
|
||||||
|
Assert.All(nestedSlices, slice => Assert.True(slice.Slot > 0));
|
||||||
|
|
||||||
|
Assert.Contains(
|
||||||
|
info.Buildings[capture.OppositeBuildingIndex].Portals,
|
||||||
|
portal => portal.OtherCellId == (capture.ExpectedOppositeCell & 0xffffu)
|
||||||
|
&& portal.OtherPortalId == capture.ExpectedSeedPortal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReportedLateralTransition_BothRootsKeepTheOppositeFacadePortal()
|
||||||
|
{
|
||||||
|
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||||
|
if (datDir is null)
|
||||||
|
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
Dictionary<uint, LoadedCell> cells =
|
||||||
|
Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock);
|
||||||
|
LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out LoadedCell? cell) ? cell : null;
|
||||||
|
|
||||||
|
static (PortalVisibilityFrame Main, PortalVisibilityFrame LookIn) Build(
|
||||||
|
Vector3 eye,
|
||||||
|
Vector3 target,
|
||||||
|
LoadedCell root,
|
||||||
|
IReadOnlyList<LoadedCell> opposite,
|
||||||
|
Func<uint, LoadedCell?> lookup)
|
||||||
|
{
|
||||||
|
Matrix4x4 viewProjection = Matrix4x4.CreateLookAt(eye, target, Vector3.UnitZ)
|
||||||
|
* Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 1.6f, 0.1f, 5000f);
|
||||||
|
PortalVisibilityFrame main = PortalVisibilityBuilder.Build(
|
||||||
|
root, eye, lookup, viewProjection);
|
||||||
|
PortalVisibilityFrame lookIn = PortalVisibilityBuilder.ConstructViewBuilding(
|
||||||
|
opposite, eye, lookup, viewProjection, seedRegion: main.OutsideView.Polygons);
|
||||||
|
return (main, lookIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
(PortalVisibilityFrame Main, PortalVisibilityFrame LookIn) from0106 = Build(
|
||||||
|
new Vector3(31.189594f, 46.280170f, 171.783646f),
|
||||||
|
new Vector3(32.787731f, 46.275948f, 171.354993f),
|
||||||
|
cells[Cell0106],
|
||||||
|
Cells(cells, 0x0103u, 0x0105u),
|
||||||
|
Lookup);
|
||||||
|
(PortalVisibilityFrame Main, PortalVisibilityFrame LookIn) from0104 = Build(
|
||||||
|
new Vector3(31.198704f, 49.733287f, 171.783646f),
|
||||||
|
new Vector3(32.796841f, 49.729065f, 171.354993f),
|
||||||
|
cells[Cell0104],
|
||||||
|
Cells(cells, 0x0106u, 0x0111u),
|
||||||
|
Lookup);
|
||||||
|
|
||||||
|
Assert.Equal([Cell0106, Landblock | 0x010Fu], from0106.Main.OrderedVisibleCells);
|
||||||
|
Assert.Equal([Cell0104], from0106.LookIn.OrderedVisibleCells);
|
||||||
|
ExteriorPortalSeed seed0104 = Assert.Single(from0106.LookIn.ExteriorSeedPortals);
|
||||||
|
Assert.Equal(Cell0104, seed0104.CellId);
|
||||||
|
Assert.Equal(0, seed0104.PortalIndex);
|
||||||
|
Assert.NotEmpty(seed0104.View.Polygons);
|
||||||
|
|
||||||
|
Assert.Equal([Cell0104], from0104.Main.OrderedVisibleCells);
|
||||||
|
Assert.Equal([Cell0106, Landblock | 0x010Fu], from0104.LookIn.OrderedVisibleCells);
|
||||||
|
ExteriorPortalSeed seed0106 = Assert.Single(from0104.LookIn.ExteriorSeedPortals);
|
||||||
|
Assert.Equal(Cell0106, seed0106.CellId);
|
||||||
|
Assert.Equal(2, seed0106.PortalIndex);
|
||||||
|
Assert.NotEmpty(seed0106.View.Polygons);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReportedExactSteamSeam_Root0104KeepsTheOppositeCathedralPortal()
|
||||||
|
{
|
||||||
|
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||||
|
if (datDir is null)
|
||||||
|
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
Dictionary<uint, LoadedCell> cells =
|
||||||
|
Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock);
|
||||||
|
LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out LoadedCell? cell) ? cell : null;
|
||||||
|
|
||||||
|
// Live ACDREAM_PROBE_CELL capture for the user-reported stationary
|
||||||
|
// frame at player [32.792290, 48.000618, 169.804993]. The chase eye is
|
||||||
|
// only ~4 mm across the coincident 0104/0106 exterior portal plane.
|
||||||
|
var eye = new Vector3(31.189665f, 48.004852f, 171.784988f);
|
||||||
|
var target = new Vector3(32.792290f, 48.000618f, 171.354993f);
|
||||||
|
Matrix4x4 viewProjection = Matrix4x4.CreateLookAt(eye, target, Vector3.UnitZ)
|
||||||
|
* Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1555f / 1019f, 1f, 5000f);
|
||||||
|
|
||||||
|
PortalVisibilityFrame main = PortalVisibilityBuilder.Build(
|
||||||
|
cells[Cell0104], eye, Lookup, viewProjection);
|
||||||
|
PortalVisibilityFrame lookIn = PortalVisibilityBuilder.ConstructViewBuilding(
|
||||||
|
Cells(cells, 0x0106u, 0x0111u),
|
||||||
|
eye,
|
||||||
|
Lookup,
|
||||||
|
viewProjection,
|
||||||
|
seedRegion: main.OutsideView.Polygons);
|
||||||
|
|
||||||
|
Assert.Contains(Cell0106, lookIn.OrderedVisibleCells);
|
||||||
|
ExteriorPortalSeed seed = Assert.Single(lookIn.ExteriorSeedPortals);
|
||||||
|
Assert.Equal(Cell0106, seed.CellId);
|
||||||
|
Assert.Equal(2, seed.PortalIndex);
|
||||||
|
Assert.NotEmpty(seed.View.Polygons);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -213,6 +213,9 @@ public sealed class WorldRenderFrameBuilderTests
|
||||||
Assert.False(result.PlayerSeenOutside);
|
Assert.False(result.PlayerSeenOutside);
|
||||||
Assert.True(result.RootSeenOutside);
|
Assert.True(result.RootSeenOutside);
|
||||||
Assert.True(result.RenderSky);
|
Assert.True(result.RenderSky);
|
||||||
|
Assert.False(result.CameraInsideEnclosedCell);
|
||||||
|
Assert.True(result.PlayerOrCameraInsideEnclosedCell);
|
||||||
|
Assert.True(result.IsAtmosphericallyOutdoor);
|
||||||
Assert.Equal(playerCellId, result.PlayerCellId);
|
Assert.Equal(playerCellId, result.PlayerCellId);
|
||||||
Assert.Equal(viewerCellId, result.ViewerCellId);
|
Assert.Equal(viewerCellId, result.ViewerCellId);
|
||||||
Assert.Equal(camera.Position, result.ViewerEyePosition);
|
Assert.Equal(camera.Position, result.ViewerEyePosition);
|
||||||
|
|
@ -245,6 +248,9 @@ public sealed class WorldRenderFrameBuilderTests
|
||||||
Assert.False(result.PlayerInsideCell);
|
Assert.False(result.PlayerInsideCell);
|
||||||
Assert.False(result.CameraInsideCell);
|
Assert.False(result.CameraInsideCell);
|
||||||
Assert.True(result.RenderSky);
|
Assert.True(result.RenderSky);
|
||||||
|
Assert.False(result.CameraInsideEnclosedCell);
|
||||||
|
Assert.False(result.PlayerOrCameraInsideEnclosedCell);
|
||||||
|
Assert.True(result.IsAtmosphericallyOutdoor);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -290,7 +290,7 @@ public sealed class WorldSceneRendererTests
|
||||||
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
|
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
|
||||||
|
|
||||||
Assert.True(result.NormalWorldDrawn);
|
Assert.True(result.NormalWorldDrawn);
|
||||||
Assert.Contains("particles:pviewScoped+unattached", rig.Calls);
|
Assert.Contains("particles:pviewScoped", rig.Calls);
|
||||||
Assert.Same(rig.PView.OutdoorSceneParticleEntityIds, rig.Passes.ParticleOwners);
|
Assert.Same(rig.PView.OutdoorSceneParticleEntityIds, rig.Passes.ParticleOwners);
|
||||||
Assert.Equal([0xCAFEu], rig.Passes.ParticleOwners);
|
Assert.Equal([0xCAFEu], rig.Passes.ParticleOwners);
|
||||||
Assert.DoesNotContain("flat:weather", rig.Calls);
|
Assert.DoesNotContain("flat:weather", rig.Calls);
|
||||||
|
|
@ -809,9 +809,7 @@ public sealed class WorldSceneRendererTests
|
||||||
string kind = clipRoot switch
|
string kind = clipRoot switch
|
||||||
{
|
{
|
||||||
null => "global",
|
null => "global",
|
||||||
{ IsOutdoorNode: true } => currentSignature == "none"
|
{ IsOutdoorNode: true } => currentSignature,
|
||||||
? "unattached"
|
|
||||||
: currentSignature + "+unattached",
|
|
||||||
_ => currentSignature,
|
_ => currentSignature,
|
||||||
};
|
};
|
||||||
calls.Add($"particles:{kind}");
|
calls.Add($"particles:{kind}");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue