fix #451: stabilize portal seam rendering
All checks were successful
CI / linux-portable (push) Successful in 3m32s
CI / windows-gate (push) Successful in 6m55s
CI / release (push) Successful in 2m12s

This commit is contained in:
Erik 2026-08-27 14:30:21 +02:00
parent f6fe0f2a4f
commit 1d2f2f738f
29 changed files with 1650 additions and 239 deletions

View file

@ -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.

View file

@ -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 -&gt; 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);

View file

@ -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;
} }

View file

@ -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(

View file

@ -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(

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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));
} }

View file

@ -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;
} }

View file

@ -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;
} }

View file

@ -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
}, },
{ {

View file

@ -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>

View file

@ -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;
} }

View file

@ -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()
{ {

View file

@ -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

View file

@ -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);

View file

@ -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;
}
} }

View file

@ -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(

View file

@ -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));
} }

View 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);
}
}

View file

@ -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]

View file

@ -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}");