diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 2b031850..756713a2 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -24,7 +24,7 @@ What does NOT go here:
- 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.
-## #451 — GLFW can dereference another acdream process's private window pointer after cross-process activation
+## #452 — GLFW can dereference another acdream process's private window pointer after cross-process activation
**Status:** IN-PROGRESS — exact root fixed; 100-switch/30-minute dual-client
stress passed 2026-08-27. Graceful exit of both sessions remains before closure.
@@ -49,7 +49,7 @@ AcDream.App.Rendering.GameWindow.Run
```
This is not #422's rare `0xC0000374` heap corruption during graceful process
-exit: #451 happens while both graphical clients are active and reproduces
+exit: #452 happens while both graphical clients are active and reproduces
quickly. It is also not a MossTank/plugin-API, CoreCLR, Vulkan, PAK, or world-
cache failure.
@@ -74,7 +74,7 @@ There is no system-wide hook and no other module is changed. Four focused
tests cover local, foreign, null and unowned HWNDs.
The isolated `app-release24` live gate launched two graphical clients, both
-logged `GLFW foreign-active-window guard installed (#451)`, entered the world,
+logged `GLFW foreign-active-window guard installed (#452)`, entered the world,
and remained responsive through 100 rapid forced cross-process activation
switches — the exact prior trigger — plus a 30-minute combined in-world soak.
The local peer API then passed in both directions: the secondary evaluated the
@@ -96,6 +96,47 @@ Evidence:
**Next:** close only after both `app-release24` sessions exit gracefully; the
activation and sustained in-world portions of the regression gate have passed.
+## #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`
@@ -19032,6 +19073,13 @@ DrawDynamicsParticles only sees dynamics-last cone survivors.
**Gate:** stand inside, look out the doorway at the town portal — the
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
@@ -19072,6 +19120,13 @@ against interiors). The owner-id filter carries over; cell-pass and
dynamics-pass emitters keep their own passes (owners never in the
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
outdoors at the angle that previously erased it.
diff --git a/docs/plans/2026-08-26-mosstank-parity-campaign.md b/docs/plans/2026-08-26-mosstank-parity-campaign.md
index 2ac3576e..c00c08d8 100644
--- a/docs/plans/2026-08-26-mosstank-parity-campaign.md
+++ b/docs/plans/2026-08-26-mosstank-parity-campaign.md
@@ -1,7 +1,7 @@
# MossTank — VTank parity campaign
Date: 2026-08-26
-Status: ACTIVE — MT1 USER-PASSED; MTUI–MT9 functional/API scope complete; connected shelf/shell, accessibility, reconnect, bidirectional peer-expression and two-member fellowship gates passed; #451 root fixed and 30-minute dual-client activation soak passed; hostile/collision gates remain
+Status: ACTIVE — MT1 USER-PASSED; MTUI–MT9 functional/API scope complete; connected shelf/shell, accessibility, reconnect, bidirectional peer-expression and two-member fellowship gates passed; #452 root fixed and 30-minute dual-client activation soak passed; hostile/collision gates remain
Research baseline:
`docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md`
@@ -421,7 +421,7 @@ received `+Acdream`, while the earlier reciprocal gate returned `+Horan` to
the primary; both heartbeat documents contained the expected names, tags,
vitals and positions.
-That broader gate exposed separate client defect #451. First-chance cdb proof
+That broader gate exposed separate client defect #452. First-chance cdb proof
located it in GLFW's Win32 event pump: temporary cross-process input-queue
attachment let `GetActiveWindow` return the other acdream process's HWND;
GLFW's shared `L"GLFW"` property then returned the other process's private
@@ -429,7 +429,7 @@ GLFW's shared `L"GLFW"` property then returned the other process's private
current-process HWND guard at GLFW's own import slot before `glfwInit`; its four
focused tests pass. Two rebuilt graphical clients then entered world, survived
100 rapid forced activation switches—the exact old trigger—and remained
-responsive through a 30-minute combined soak with no native error. Issue #451
+responsive through a 30-minute combined soak with no native error. Issue #452
remains in-progress only until both sessions complete a graceful-exit gate.
The secondary-owned fellowship gate also passed: `+Acdream` created
@@ -446,7 +446,7 @@ Windows hermetic filter passed 15,083 non-network tests; Core.Net then passed
max-parallel combined invocation made Core.Net's timing-sensitive two-percent
packet-loss soak exhaust its wall-clock headroom; the same case and complete
Core.Net lane passed immediately when isolated. No MossTank, plugin API, plugin
-UI, Runtime-owner, or #451 guard test failed.
+UI, Runtime-owner, or #452 guard test failed.
Live hostile discovery checkpoint 2026-08-27: the first surrounded-monster
gate exposed two coupled compatibility defects. Retail's classic `* Lure`
@@ -474,7 +474,7 @@ The authoritative requirement/evidence map is:
| Generic plugin sidepanel; minimizing must not stop plugins | retained `PluginSidePanel`/window-manager tests plus connected hide/restore gate where the hidden buff pass advanced from 91/97 to 77/97 | Proven |
| Modern acdream plugin APIs over canonical owners | additive BCL-only combat, magic, equipment, item, loot, fellowship, enchantment, navigation, object, world-time, login, network, recovery, projectile, selection, storage, command and classifier contracts; 35 focused App/API/UI tests and 16,125 selected Release tests | Proven for the graphical live host; older/no-window implementations explicitly report unavailable and never fabricate success |
| UtilityBelt-compatible expression superset | immutable evaluator tests; all 260 audited public names registered; host-action, object, fellowship, time, login/network, UI, persistence, collection and meta tests | Proven by catalog and semantic family tests; bidirectional two-client network expressions passed live |
-| Lifecycle, reconnect, multi-client stability | same-character reconnect and hidden execution passed; peer expressions and two-member fellowship passed; #451 exact trigger survived 100 focus switches and a 30-minute dual-client soak | Proven through soak; #451 cannot close until both current sessions exit gracefully |
+| Lifecycle, reconnect, multi-client stability | same-character reconnect and hidden execution passed; peer expressions and two-member fellowship passed; #452 exact trigger survived 100 focus switches and a 30-minute dual-client soak | Proven through soak; #452 cannot close until both current sessions exit gracefully |
Open completion gates: (1) hostile physical and offensive-magic behavior against
a live target at valid configured range; (2) visible green/red projectile
diff --git a/src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs b/src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs
index 629ab07d..1caf7415 100644
--- a/src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs
+++ b/src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs
@@ -17,7 +17,7 @@ namespace AcDream.App.Platform;
/// join input queues, however, allowing it to return another acdream process's
/// window. Every GLFW process uses the same property name, so GetPropW
/// then succeeds but returns a pointer meaningful only in the other process.
-/// The next modifier-key read is an access violation (#451).
+/// The next modifier-key read is an access violation (#452).
///
///
/// Patch only GLFW's import-address-table entry for GetActiveWindow.
@@ -98,7 +98,7 @@ internal static unsafe class Win32GlfwActiveWindowGuard
Volatile.Write(ref _installState, 1);
Console.WriteLine(
- "windowing: GLFW foreign-active-window guard installed (#451).");
+ "windowing: GLFW foreign-active-window guard installed (#452).");
}
catch (Exception failure)
{
diff --git a/src/AcDream.App/Rendering/ClipFrameAssembler.cs b/src/AcDream.App/Rendering/ClipFrameAssembler.cs
index 38b72df4..c7b50def 100644
--- a/src/AcDream.App/Rendering/ClipFrameAssembler.cs
+++ b/src/AcDream.App/Rendering/ClipFrameAssembler.cs
@@ -39,6 +39,13 @@ public enum TerrainClipMode
///
public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes);
+///
+/// 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.
+///
+public readonly record struct LookInClipCell(int FrameIndex, uint CellId);
+
///
/// Result of : populated clip buffers
/// plus routing data consumed by the render orchestration.
@@ -57,6 +64,12 @@ public sealed class ClipFrameAssembly
/// Full retail portal_view slices per visible cell.
public Dictionary CellIdToViewSlices { get; } = new();
+ /// First drawable slice slot per nested look-in cell.
+ public Dictionary LookInCellToSlot { get; } = new();
+
+ /// All retail portal_view slices per nested look-in cell.
+ public Dictionary LookInCellToViewSlices { get; } = new();
+
/// Full retail outside_view slices.
public ClipViewSlice[] OutsideViewSlices { get; private set; } = System.Array.Empty();
@@ -93,6 +106,8 @@ public sealed class ClipFrameAssembly
Frame = frame;
foreach (ClipViewSlice[] slices in CellIdToViewSlices.Values)
ReturnSlices(slices);
+ foreach (ClipViewSlice[] slices in LookInCellToViewSlices.Values)
+ ReturnSlices(slices);
foreach (int[] slots in CellIdToViewSlots.Values)
ReturnSlots(slots);
if (OutsideViewSlices.Length != 0)
@@ -101,6 +116,8 @@ public sealed class ClipFrameAssembly
CellIdToSlot.Clear();
CellIdToViewSlots.Clear();
CellIdToViewSlices.Clear();
+ LookInCellToSlot.Clear();
+ LookInCellToViewSlices.Clear();
PerCellPlaneCounts.Clear();
OutsideViewSlices = System.Array.Empty();
SliceScratch.Clear();
@@ -363,6 +380,70 @@ public static class ClipFrameAssembler
return assembly;
}
+ ///
+ /// Appends the cell views used by nested DrawBuilding -> DrawPortal
+ /// PViews to the already assembled frame. Retail installs each nested
+ /// cell's own portal_view before drawing its shell and object list;
+ /// these slots must therefore be published with the main frame before the
+ /// first draw is recorded.
+ ///
+ public static void AppendLookInFrames(
+ ClipFrame frame,
+ IReadOnlyList 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 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();
+ 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) =>
new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY);
diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs b/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs
index cb59ea3e..ab9ec407 100644
--- a/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs
+++ b/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs
@@ -99,7 +99,7 @@ internal sealed class AtmosphericFrameInputState : IAtmosphericWorldFrameSink
_host.DeltaSeconds,
_host.ViewportWidth,
_host.ViewportHeight,
- IsOutdoor: world.Roots.RenderSky && !world.Roots.CameraInsideCell);
+ IsOutdoor: world.Roots.IsAtmosphericallyOutdoor);
_published = true;
}
diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs
index 95f7ee1d..2117d648 100644
--- a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs
+++ b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs
@@ -431,8 +431,7 @@ internal sealed class AtmosphericPostProcessGraph :
var environment = new DirectionalShadowEnvironmentInput(
PackEnabled: true,
PortalOrLoginCoverVisible: foundation.PortalViewportVisible,
- PlayerInsideCell: world.Roots.PlayerInsideCell
- || world.Roots.CameraInsideCell,
+ PlayerInsideCell: world.Roots.PlayerOrCameraInsideEnclosedCell,
source,
foundation.Atmosphere,
ActiveDayGroupMultiplier: Math.Clamp(
@@ -449,7 +448,7 @@ internal sealed class AtmosphericPostProcessGraph :
// or deactivation can never leave a stale exclusion set applied to
// the dispatcher (see FoliageWindExclusions's doc comment).
worldMeshes.FoliageWindExclusions = _foliageWindExclusions;
- bool isOutdoor = world.Roots.RenderSky && !world.Roots.CameraInsideCell;
+ bool isOutdoor = world.Roots.IsAtmosphericallyOutdoor;
AtmosphericFrameBufferBinding shadowAtmosphericFrame =
BuildShadowAtmosphericFrameBinding(frame, foundation.Atmosphere.Kind, isOutdoor);
var input = new DirectionalSunShadowRenderInput(
diff --git a/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs b/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs
index 0bf5fafc..e883456c 100644
--- a/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs
+++ b/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs
@@ -210,8 +210,7 @@ internal class DeclaredFullscreenRenderPackGraph :
var environment = new DirectionalShadowEnvironmentInput(
PackEnabled: true,
PortalOrLoginCoverVisible: foundation.PortalViewportVisible,
- PlayerInsideCell: world.Roots.PlayerInsideCell
- || world.Roots.CameraInsideCell,
+ PlayerInsideCell: world.Roots.PlayerOrCameraInsideEnclosedCell,
source,
foundation.Atmosphere,
ActiveDayGroupMultiplier: Math.Clamp(
diff --git a/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs
index a1ff118e..cd68e206 100644
--- a/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs
+++ b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs
@@ -71,10 +71,8 @@ public sealed unsafe partial class ParticleRenderer
private const uint QuadStrideBytes = 4 * sizeof(float);
- /// Floats per mesh-particle instance: a mat4 plus an RGBA colour.
- internal const int MeshInstanceFloats = 20;
-
- private const uint MeshInstanceStrideBytes = MeshInstanceFloats * sizeof(float);
+ private static readonly uint MeshInstanceStrideBytes =
+ (uint)sizeof(MeshParticleGpuInstance);
///
/// 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,
// so R8G8B8A8's normalized cousin would be wrong in kind. It is one
// 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)));
///
/// 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(5, GpuVertexFormat.Float4, 32, 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)));
///
/// The RHI arm's constructor. No GL context, no Shader, no
@@ -340,22 +340,22 @@ public sealed unsafe partial class ParticleRenderer
while (submission.Kind == ParticleSubmissionKind.Mesh
&& _meshDrawListScratch[submission.DrawIndex].Key == meshKey);
- int neededFloats = _meshRunScratch.Count * MeshInstanceFloats;
- if (_meshInstanceScratch.Length < neededFloats)
- _meshInstanceScratch = new float[neededFloats + 256 * MeshInstanceFloats];
+ int neededInstances = _meshRunScratch.Count;
+ if (_meshInstanceScratch.Length < neededInstances)
+ _meshInstanceScratch = new MeshParticleGpuInstance[neededInstances + 256];
for (int instance = 0; instance < _meshRunScratch.Count; instance++)
{
WriteMeshGpuInstance(
- _meshInstanceScratch,
- instance * MeshInstanceFloats,
+ ref _meshInstanceScratch[instance],
_meshRunScratch[instance]);
}
- GpuRingAllocation instances = WriteVertexRing(
+ GpuRingAllocation instances = WriteVertexRing(
frame,
- _meshInstanceScratch.AsSpan(0, neededFloats));
+ _meshInstanceScratch.AsSpan(0, neededInstances));
DrawMeshBatchRhi(
encoder,
+ frame,
global,
batch,
viewProjection,
@@ -384,7 +384,13 @@ public sealed unsafe partial class ParticleRenderer
GpuRingAllocation ring = WriteVertexRing(
frame,
_instanceScratch.AsSpan(0, instances.Count));
- BindBillboardPipeline(encoder, viewProjection, additive, ring.Buffer, ring.OffsetBytes);
+ BindBillboardPipeline(
+ encoder,
+ frame,
+ viewProjection,
+ additive,
+ ring.Buffer,
+ ring.OffsetBytes);
encoder.DrawIndexed(
(uint)QuadIndices.Length,
(uint)instances.Count,
@@ -401,6 +407,7 @@ public sealed unsafe partial class ParticleRenderer
///
private void BindBillboardPipeline(
IGpuPassEncoder encoder,
+ IGpuFrame frame,
Matrix4x4 viewProjection,
bool additive,
IGpuBuffer instanceBuffer,
@@ -426,10 +433,15 @@ public sealed unsafe partial class ParticleRenderer
encoder.BindVertexBuffer(0, _quadVertexBuffer!, 0);
encoder.BindVertexBuffer(1, instanceBuffer, instanceOffsetBytes);
encoder.BindIndexBuffer(_quadIndexBuffer!, 0, GpuIndexType.UInt32);
+ WorldFrameSectionBinding.BindClipRegions(
+ encoder,
+ _scope!.Sections,
+ frame);
}
private void DrawMeshBatchRhi(
IGpuPassEncoder encoder,
+ IGpuFrame frame,
GlobalMeshBuffer global,
ObjectRenderBatch batch,
Matrix4x4 viewProjection,
@@ -472,6 +484,10 @@ public sealed unsafe partial class ParticleRenderer
"The shared mesh arena has no index store."),
0,
GpuIndexType.UInt16);
+ WorldFrameSectionBinding.BindClipRegions(
+ encoder,
+ _scope!.Sections,
+ frame);
encoder.DrawIndexed(
(uint)batch.IndexCount,
instanceCount,
@@ -520,9 +536,8 @@ public sealed unsafe partial class ParticleRenderer
Array.Resize(ref _preparedInstanceOffsets, count + 256);
if (_instanceScratch.Length < count)
Array.Resize(ref _instanceScratch, count + 256);
- int neededMeshFloats = count * MeshInstanceFloats;
- if (_meshInstanceScratch.Length < neededMeshFloats)
- _meshInstanceScratch = new float[neededMeshFloats + 256 * MeshInstanceFloats];
+ if (_meshInstanceScratch.Length < count)
+ _meshInstanceScratch = new MeshParticleGpuInstance[count + 256];
int billboardCount = 0;
int meshCount = 0;
@@ -541,8 +556,7 @@ public sealed unsafe partial class ParticleRenderer
{
_preparedInstanceOffsets[i] = (uint)meshCount;
WriteMeshGpuInstance(
- _meshInstanceScratch,
- meshCount++ * MeshInstanceFloats,
+ ref _meshInstanceScratch[meshCount++],
deferred.Mesh.Instance);
}
}
@@ -553,9 +567,9 @@ public sealed unsafe partial class ParticleRenderer
_instanceScratch.AsSpan(0, billboardCount)))
: default;
_preparedMeshInstances = meshCount > 0
- ? SectionOf(WriteVertexRing(
+ ? SectionOf(WriteVertexRing(
frame,
- _meshInstanceScratch.AsSpan(0, meshCount * MeshInstanceFloats)))
+ _meshInstanceScratch.AsSpan(0, meshCount)))
: default;
_preparedAlphaCount = count;
}
@@ -591,6 +605,7 @@ public sealed unsafe partial class ParticleRenderer
{
BindBillboardPipeline(
encoder,
+ RequireRhiFrame(),
viewProjection,
key.Additive,
billboards,
@@ -632,6 +647,7 @@ public sealed unsafe partial class ParticleRenderer
{
DrawMeshBatchRhi(
encoder,
+ RequireRhiFrame(),
global,
batch,
meshViewProjection,
diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs
index 63036896..6a963121 100644
--- a/src/AcDream.App/Rendering/ParticleRenderer.cs
+++ b/src/AcDream.App/Rendering/ParticleRenderer.cs
@@ -47,6 +47,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
public readonly uint ColorArgb;
public readonly AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot;
public readonly float DistanceSq;
+ public readonly uint ClipSlot;
public ParticleInstance(
Vector3 position,
@@ -54,7 +55,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
Vector3 axisY,
uint colorArgb,
AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot,
- float distanceSq)
+ float distanceSq,
+ uint clipSlot)
{
Position = position;
AxisX = axisX;
@@ -62,6 +64,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
ColorArgb = colorArgb;
TextureSlot = textureSlot;
DistanceSq = distanceSq;
+ ClipSlot = clipSlot;
}
}
@@ -80,6 +83,16 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
public Vector4 AxisY;
public Vector4 Color;
public uint TextureIndex;
+ public uint ClipSlot;
+ }
+
+ /// Vertex-instance ABI shared with particle_mesh.vert.
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct MeshParticleGpuInstance
+ {
+ public Matrix4x4 Model;
+ public Vector4 Color;
+ public uint ClipSlot;
}
private readonly struct MeshParticleInstance
@@ -87,12 +100,18 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
public readonly Matrix4x4 Model;
public readonly uint ColorArgb;
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;
ColorArgb = colorArgb;
DistanceSq = distanceSq;
+ ClipSlot = clipSlot;
}
}
@@ -124,7 +143,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
internal (int SetCount, long CapacityBytes) DynamicBufferDiagnostics => (0, 0);
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
// (sky pre/post, scene, per-visible-cell, dynamics, unattached passes),
@@ -203,7 +222,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
cameraRight,
cameraUp,
emitterFilter,
- scopedEmitters: null);
+ scopedEmitters: null,
+ clipSlot: 0);
FinishDraw(camera, renderPass);
}
@@ -213,7 +233,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
ParticleRenderPass renderPass,
IReadOnlySet attachedOwnerIds,
bool includeUnattached = false,
- IReadOnlySet? excludedAttachedOwnerIds = null)
+ IReadOnlySet? excludedAttachedOwnerIds = null,
+ uint clipSlot = 0)
{
if (camera is null)
return;
@@ -233,7 +254,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
cameraRight,
cameraUp,
emitterFilter: null,
- _scopedEmitterScratch);
+ _scopedEmitterScratch,
+ clipSlot);
FinishDraw(camera, renderPass);
}
@@ -332,7 +354,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
Vector3 cameraRight,
Vector3 cameraUp,
Func? emitterFilter,
- IReadOnlyList? scopedEmitters)
+ IReadOnlyList? scopedEmitters,
+ uint clipSlot)
{
var draws = _drawListScratch;
draws.Clear();
@@ -348,6 +371,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
cameraWorldPos,
cameraRight,
cameraUp,
+ clipSlot,
ref sequence);
}
return;
@@ -356,7 +380,13 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
foreach (RuntimeParticleEmitter emitter in _particles.EnumerateRenderableEmitters(renderPass))
{
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 cameraRight,
Vector3 cameraUp,
+ uint clipSlot,
ref int sequence)
{
List 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;
if (gfxObjId != 0
&& ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh
- && TryAppendMeshDraws(em, p, gfxObjId, cameraWorldPos, ref sequence))
+ && TryAppendMeshDraws(
+ em,
+ p,
+ gfxObjId,
+ cameraWorldPos,
+ clipSlot,
+ ref sequence))
{
continue;
}
@@ -483,7 +520,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
axisY,
p.ColorArgb,
gfxInfo.TextureSlot,
- distSq)));
+ distSq,
+ clipSlot)));
_submissionScratch.Add(new ParticleSubmission(
ParticleSubmissionKind.Billboard,
drawIndex,
@@ -497,6 +535,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
Particle particle,
uint gfxObjId,
Vector3 cameraWorldPosition,
+ uint clipSlot,
ref int sequence)
{
if (_meshAdapter is null || !MeshParticlesAvailable)
@@ -520,7 +559,11 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
model,
cameraWorldPosition);
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++)
{
@@ -581,35 +624,24 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
TextureIndex = particle.TextureSlot.IsAssigned
? particle.TextureSlot.Index
: NoTextureSlot,
+ ClipSlot = particle.ClipSlot,
};
}
private static void WriteMeshGpuInstance(
- float[] destination,
- int offset,
+ ref MeshParticleGpuInstance destination,
MeshParticleInstance instance)
{
- Matrix4x4 model = instance.Model;
- destination[offset + 0] = model.M11;
- destination[offset + 1] = model.M12;
- destination[offset + 2] = model.M13;
- destination[offset + 3] = model.M14;
- destination[offset + 4] = model.M21;
- destination[offset + 5] = model.M22;
- destination[offset + 6] = model.M23;
- destination[offset + 7] = model.M24;
- 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;
+ destination = new MeshParticleGpuInstance
+ {
+ Model = instance.Model,
+ Color = new Vector4(
+ ((instance.ColorArgb >> 16) & 0xFF) / 255f,
+ ((instance.ColorArgb >> 8) & 0xFF) / 255f,
+ (instance.ColorArgb & 0xFF) / 255f,
+ ((instance.ColorArgb >> 24) & 0xFF) / 255f),
+ ClipSlot = instance.ClipSlot,
+ };
}
private TranslucencyKind ResolveMeshBlend(ObjectRenderBatch batch)
diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
index a9164297..c0ef5f89 100644
--- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
+++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
@@ -26,11 +26,21 @@ public sealed class PortalVisibilityFrame
private int _processedViewCountsUnderusedFrames;
private int _orderedVisibleCellsUnderusedFrames;
private int _todoUnderusedFrames;
+ private int _exteriorSeedPortalsUnderusedFrames;
internal PortalPolygonVertexStore PolygonVertices => _polygonVertices;
internal int PolygonVertexAllocationCount => _polygonVertices.AllocationCount;
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; }
+
/// Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
/// recursively clipped to their portal chain. The cellar-flap fix.
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).
public Dictionary CrossBuildingViews { get; } = new();
+ ///
+ /// 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.
+ ///
+ public List ExteriorSeedPortals { get; } = new();
+
// 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,
// 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 orderedVisibleCellCount = OrderedVisibleCells.Count;
int todoCount = TodoScratch.Count;
+ int exteriorSeedPortalCount = ExteriorSeedPortals.Count;
if (OutsideView.IsRetainable)
OutsideView.Reset();
@@ -121,9 +141,14 @@ public sealed class PortalVisibilityFrame
OutsideView = new CellView();
ReturnCellViews(CellViews);
ReturnCellViews(CrossBuildingViews);
+ for (int index = 0; index < ExteriorSeedPortals.Count; index++)
+ ReturnCellView(ExteriorSeedPortals[index].View);
CellViews.Clear();
OrderedVisibleCells.Clear();
CrossBuildingViews.Clear();
+ ExteriorSeedPortals.Clear();
+ SourceBuildingKey = 0;
+ SourceBuildingLandblockId = 0;
QueuedScratch.Clear();
DrawListedScratch.Clear();
ProcessedViewCountsScratch.Clear();
@@ -152,6 +177,10 @@ public sealed class PortalVisibilityFrame
orderedVisibleCellCount,
ref _orderedVisibleCellsUnderusedFrames);
TrimIfCold(TodoScratch, todoCount, ref _todoUnderusedFrames);
+ TrimIfCold(
+ ExteriorSeedPortals,
+ exteriorSeedPortalCount,
+ ref _exteriorSeedPortalsUnderusedFrames);
}
internal ViewPolygon CopyPolygon(ReadOnlySpan vertices)
@@ -178,11 +207,14 @@ public sealed class PortalVisibilityFrame
private void ReturnCellViews(Dictionary views)
{
foreach (CellView view in views.Values)
- {
- view.Reset();
- if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews)
- _cellViewPool.Push(view);
- }
+ ReturnCellView(view);
+ }
+
+ private void ReturnCellView(CellView view)
+ {
+ view.Reset();
+ if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews)
+ _cellViewPool.Push(view);
}
private static void TrimIfCold(
@@ -243,6 +275,15 @@ public sealed class PortalVisibilityFrame
}
}
+///
+/// One outside-facing portal accepted as an exterior building-view seed.
+/// is the exact installed-view-clipped aperture.
+///
+public readonly record struct ExteriorPortalSeed(
+ uint CellId,
+ int PortalIndex,
+ CellView View);
+
public static class PortalVisibilityBuilder
{
// 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.
if (i < cell.ClipPlanes.Count)
{
- if (CameraOnInteriorSide(cell, i, cameraPos))
- continue;
if (EyeInPlaneOfPortal(cell, i, cameraPos))
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);
@@ -741,6 +799,17 @@ public static class PortalVisibilityBuilder
if (clippedRegion.Count == 0)
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);
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
// 0x005a4b70); building/exterior SEED portals additionally reject in-plane
// 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];
if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
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
diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
index 04708dcb..5b90654a 100644
--- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
+++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
@@ -134,6 +134,8 @@ internal sealed class RetailPViewPassExecutor :
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
private readonly RetailPViewParticleClassifications _particleClassifications = new();
private readonly HashSet _noSceneParticleEntityIds = [];
+ private readonly Dictionary _singleCellClipRouting = new(1);
+ private readonly Dictionary _noCellClipRouting = new(0);
///
/// Borrowed until the next late landscape pass. The outdoor-root post-world
@@ -207,6 +209,8 @@ internal sealed class RetailPViewPassExecutor :
{
List? failures = null;
TryAbort(_frameGlState.RestoreFrameDefaults);
+ TryAbort(() => _envCells.SetClipRouting(null));
+ TryAbort(_entities.ClearClipRouting);
TryAbort(_particleClassifications.BeginFrame);
TryAbort(_noSceneParticleEntityIds.Clear);
if (failures is { Count: > 0 })
@@ -230,6 +234,11 @@ internal sealed class RetailPViewPassExecutor :
ClipFrameAssembly reuseAssembly) =>
ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly);
+ public void AppendLookInClipFrames(
+ IReadOnlyList lookInFrames,
+ ClipFrameAssembly assembly) =>
+ ClipFrameAssembler.AppendLookInFrames(_clipFrame, lookInFrames, assembly);
+
public void PrepareClipFrame(int terrainUploadCount) =>
_surface.PrepareClipFrame(terrainUploadCount);
@@ -246,6 +255,24 @@ internal sealed class RetailPViewPassExecutor :
_entities.ClearClipRouting();
}
+ public void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice)
+ {
+ _singleCellClipRouting.Clear();
+ _singleCellClipRouting.Add(cellId, slice.Slot);
+ _envCells.SetClipRouting(_singleCellClipRouting);
+ // Retail DrawMesh only viewcone-checks an object's sphere under the
+ // installed PortalList and then draws the mesh whole. Hard clipping the
+ // object here slices a stationary player when the chase camera crosses
+ // into the opposite cathedral cell while the player remains behind.
+ _entities.ClearClipRouting();
+ }
+
+ private void UseOutdoorPortalViewRouting(ClipViewSlice slice) =>
+ _entities.SetClipRouting(
+ _noCellClipRouting,
+ outdoorSlot: slice.Slot,
+ outdoorVisible: true);
+
public void PrepareCellBatches(
RetailPViewFrameInput frame,
HashSet visibleCellIds) =>
@@ -376,6 +403,7 @@ internal sealed class RetailPViewPassExecutor :
if (scissor)
_surface.EndScissor();
+ _entities.ClearClipRouting();
DisableClipDistances();
}
@@ -418,8 +446,7 @@ internal sealed class RetailPViewPassExecutor :
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
- if (!frame.RootCell.IsOutdoorNode
- && _particleClassifications.Outdoor.Count > 0
+ if (_particleClassifications.Outdoor.Count > 0
&& _particles is not null
&& _particleRenderer is not null)
{
@@ -427,7 +454,8 @@ internal sealed class RetailPViewPassExecutor :
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
- _particleClassifications.Outdoor);
+ _particleClassifications.Outdoor,
+ clipSlot: (uint)context.Slice.Slot);
}
EnableClipDistances();
@@ -456,6 +484,77 @@ internal sealed class RetailPViewPassExecutor :
if (scissor)
_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?)null);
+ _entities.Draw(
+ frame.Camera,
+ new[] { buildingEntry },
+ frame.Frustum,
+ neverCullLandblockId: frame.PlayerLandblockId,
+ visibleCellIds: null,
+ animatedEntityIds: frame.AnimatedEntityIds);
+ }
+
+ if (scissor)
+ _surface.EndScissor();
+ _entities.ClearClipRouting();
DisableClipDistances();
}
@@ -468,10 +567,13 @@ internal sealed class RetailPViewPassExecutor :
public void DrawLookInPortalPunch(
RetailPViewFrameInput frame,
- RetailPViewCellSliceContext context) =>
- DrawPortalDepthWrite(context, frame, forceFarZ: true);
+ RetailPViewCellSliceContext context,
+ 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)
return;
@@ -482,7 +584,8 @@ internal sealed class RetailPViewPassExecutor :
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
_noSceneParticleEntityIds,
- includeUnattached: true);
+ includeUnattached: true,
+ clipSlot: (uint)slice.Slot);
}
public void FlushLandscapeAlpha() => _alpha.Flush();
@@ -509,7 +612,8 @@ internal sealed class RetailPViewPassExecutor :
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
- visible);
+ visible,
+ clipSlot: (uint)context.Slice.Slot);
DisableClipDistances();
}
@@ -552,7 +656,8 @@ internal sealed class RetailPViewPassExecutor :
private void DrawPortalDepthWrite(
RetailPViewCellSliceContext context,
RetailPViewFrameInput frame,
- bool forceFarZ)
+ bool forceFarZ,
+ int? onlyPortalIndex = null)
{
// Retail D3DPolyRender::DrawPortalPolyInternal @ 0x0059BC90.
// Main interior roots stamp true depth (seal); outdoor and look-in
@@ -566,6 +671,8 @@ internal sealed class RetailPViewPassExecutor :
Span world = stackalloc Vector3[32];
for (int index = 0; index < cell.Portals.Count; index++)
{
+ if (onlyPortalIndex.HasValue && index != onlyPortalIndex.Value)
+ continue;
if (cell.Portals[index].OtherCellId != 0xFFFF)
continue;
if (index >= cell.PortalPolygons.Count)
diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
index d39df8f8..0aa5b947 100644
--- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs
+++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
@@ -50,8 +50,10 @@ public sealed class RetailPViewRenderer
private readonly Stack _lookInFramePool = new();
private readonly HashSet _lookInPrepareScratch = new();
- // #131/#132: the late landscape phase's scene-particle owner survivors
- // (statics + outside-stage dynamics passing the slice cone).
+ // #131/#132: landscape scene-particle owner survivors. With building
+ // 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 _lateParticleOwnerScratch = new();
private readonly HashSet _cellParticleOwnerScratch = new();
private readonly HashSet _dynamicParticleOwnerScratch = new();
@@ -144,6 +146,7 @@ public sealed class RetailPViewRenderer
var clipAssembly = passes.AssembleClipFrame(
pvFrame,
_clipAssemblyScratch);
+ passes.AppendLookInClipFrames(_lookInFrames, clipAssembly);
int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2);
passes.PrepareClipFrame(terrainUploadCount);
@@ -413,6 +416,9 @@ public sealed class RetailPViewRenderer
group, ctx.ViewerEyePos, ctx.Cells.Find, ctx.ViewProjection,
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons,
reuseFrame: frameScratch);
+ LoadedCell sourceCell = group[0];
+ frame.SourceBuildingKey = sourceCell.BuildingId ?? sourceCell.CellId;
+ frame.SourceBuildingLandblockId = sourceCell.CellId & 0xFFFF0000u;
if (frame.OrderedVisibleCells.Count > 0)
_lookInFrames.Add(frame);
else
@@ -454,65 +460,67 @@ public sealed class RetailPViewRenderer
// then draw the flooded cells' shells + statics far→near (the nested
// DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is
// empty by construction — PView ctor draw_landscape=0 — so no recursive
- // landscape/clear/seal). Anything rasterized outside an aperture is
- // repainted by the root's own shells after the depth clear, so over-draw
- // here is color-safe; statics draw whole (the main viewcone has no entry
- // for look-in cells; over-include is the safe direction).
+ // landscape/clear/seal). Retail CEnvCell::setup_view installs every cell's
+ // nested portal_view before DrawEnvCell, while DrawMesh iterates that same
+ // PortalList for cell objects. Preserve that per-slice gate here; drawing a
+ // 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(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result? partition,
+ ViewconeCuller viewcone,
IRenderFrameEntityPassExecutor? frameEntityPasses,
in RenderFrameView frameView)
{
if (_lookInFrames.Count == 0)
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.
- foreach (uint cellId in frame.OrderedVisibleCells)
+ foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals)
{
- if (!frame.CellViews.TryGetValue(cellId, out var view))
- continue;
- foreach (var poly in view.Polygons)
+ foreach (var poly in seed.View.Polygons)
{
var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
continue;
passes.DrawLookInPortalPunch(ctx, new RetailPViewCellSliceContext(
- cellId,
+ seed.CellId,
new ClipViewSlice(
0,
new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY),
cps.PlaneArray),
- NoParticleOwners));
+ NoParticleOwners),
+ seed.PortalIndex);
}
}
- // Pass 2: shells + statics, far→near.
- 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);
-
+ // Pass 2: shells + objects, far→near, once per portal_view slice.
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
- _oneCell.Clear();
- _oneCell.Add(cellId);
- // Opaque shell batched above. Transparent stays per-cell (far→near)
- // for correct compositing; skipped for opaque-only cells.
- if (passes.CellHasTransparentShell(cellId))
- passes.DrawTransparentCellShells(_oneCell);
+ var clipKey = new LookInClipCell(frameIndex, cellId);
+ if (!clipAssembly.LookInCellToViewSlices.TryGetValue(
+ clipKey,
+ out ClipViewSlice[]? cellSlices)
+ || cellSlices.Length == 0)
+ {
+ continue;
+ }
_cellStaticScratch.Clear();
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
// (the #118 lesson). Retail draws a look-in cell's objects
// inside the NESTED DrawCells (DrawObjCellForDummies,
- // pc:432878+), i.e. right here in the landscape stage. Drawn
- // WHOLE like the statics (AP-33's documented over-include).
+ // pc:432878+), i.e. right here in the landscape stage.
// No double-draw: dynamics-last keeps culling them (their
// cell is absent from the main cone), and their emitters ride
// the DrawCellParticles call below, not DrawDynamicsParticles
@@ -541,48 +548,154 @@ public sealed class RetailPViewRenderer
_cellStaticScratch.Add(e);
}
- if (frameEntityPasses is not null)
+ foreach (ClipViewSlice slice in cellSlices)
{
- RenderFrameRouteOwnerSelector.Replace(
- _cellParticleOwnerScratch,
- in frameView,
- RenderFrameCandidateRoute.LookInObject,
- i,
- cellId);
- }
- else
- {
- ReplaceOwnerIds(
- _cellParticleOwnerScratch,
- _cellStaticScratch);
- }
+ int routeIndex = lookInRouteIndex++;
+ passes.UseCellPortalViewRouting(cellId, slice);
+ _oneCell.Clear();
+ _oneCell.Add(cellId);
+ passes.DrawOpaqueCellShells(_oneCell);
+ if (passes.CellHasTransparentShell(cellId))
+ passes.DrawTransparentCellShells(_oneCell);
- if (frameEntityPasses is not null
- || _cellStaticScratch.Count > 0)
- {
- _candidateObserver?.ObservePViewBucket(
- CurrentRenderPViewRoute.LookInObject,
- i,
- cellId,
- _cellStaticScratch);
- DrawEntityRouteOrLegacy(
- ctx,
- passes,
- frameEntityPasses,
- in frameView,
- RenderFrameCandidateRoute.LookInObject,
- i,
- cellId,
- _cellStaticScratch,
- _oneCell);
+ if (frameEntityPasses is not null)
+ {
+ RenderFrameRouteOwnerSelector.Replace(
+ _cellParticleOwnerScratch,
+ in frameView,
+ RenderFrameCandidateRoute.LookInObject,
+ routeIndex,
+ cellId);
+ }
+ else
+ {
+ ReplaceOwnerIds(
+ _cellParticleOwnerScratch,
+ _cellStaticScratch);
+ }
- // The cell-particles pass for look-in cells — retail's
- // nested DrawCells draws objects WITH their emitters.
- foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
+ if (frameEntityPasses is not null
+ || _cellStaticScratch.Count > 0)
+ {
+ _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(
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)
return;
- // #131/#132 (the FlushAlphaList deferral): retail collects ALL alpha
- // draws of the landscape stage and flushes them ONCE after LScape::draw
- // (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent
- // landscape content (portal swirl meshes, flame particles) composites
- // AFTER the building look-ins. Our dispatcher draws translucency inside
- // each Draw call, so the stage is split in TWO phases instead: EARLY =
- // sky + terrain + outdoor STATIC meshes (the look-in punches need their
- // 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.
+ // #131/#132: retail drains the remaining landscape alpha after
+ // LScape::draw (DrawCells pc:432720), while each DrawBuilding is also
+ // an earlier alpha barrier before its portal traversal (pc:427954).
+ // Our dispatcher batches outdoor content, so the stage is split into:
+ // EARLY sky/terrain/static meshes; an optional pre-look-in static-alpha
+ // barrier; building look-ins; then LATE outside-stage dynamics,
+ // remaining particles, and weather; followed by the outer flush.
int probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
@@ -628,6 +736,14 @@ public sealed class RetailPViewRenderer
{
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(
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
// 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).
DrawBuildingLookIns(
ctx,
passes,
clipAssembly,
partition,
+ viewcone,
frameEntityPasses,
in frameView);
@@ -687,8 +869,8 @@ public sealed class RetailPViewRenderer
passes.ClearClipRouting();
_outdoorStaticScratch.Clear(); // late: dynamics survivors
- _lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors
- if (partition is not null)
+ _lateParticleOwnerScratch.Clear(); // late: dynamics, plus statics without look-ins
+ if (!hasBuildingLookIns && partition is not null)
{
foreach (var e in partition.OutdoorStatic)
{
@@ -712,12 +894,19 @@ public sealed class RetailPViewRenderer
}
if (frameEntityPasses is not null)
{
- RenderFrameRouteOwnerSelector.Replace(
- _lateParticleOwnerScratch,
- in frameView,
- RenderFrameCandidateRoute.LandscapeOutdoorStatic,
- probeSliceIndex,
- 0);
+ if (hasBuildingLookIns)
+ {
+ _lateParticleOwnerScratch.Clear();
+ }
+ else
+ {
+ RenderFrameRouteOwnerSelector.Replace(
+ _lateParticleOwnerScratch,
+ in frameView,
+ RenderFrameCandidateRoute.LandscapeOutdoorStatic,
+ probeSliceIndex,
+ 0);
+ }
RenderFrameRouteOwnerSelector.Union(
_lateParticleOwnerScratch,
in frameView,
@@ -753,13 +942,19 @@ public sealed class RetailPViewRenderer
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
// campfires, ground effects anchored at a position) have no owner id
- // to ride any of the id-filtered particle passes. The outdoor root
- // has the dedicated T3 pass for them; an INTERIOR root had NO pass
- // at all. Draw them ONCE per frame (not per slice — alpha particles
- // must not double-draw, the #121 lesson), at the END of the landscape
- // stage: after the clear they would z-fail against the doorway seal.
- if (!ctx.RootCell.IsOutdoorNode)
- passes.DrawUnattachedSceneParticles(ctx);
+ // to ride any of the id-filtered particle passes. Draw once per
+ // installed outside_view for BOTH root kinds, matching retail's
+ // landscape-stage placement and preserving the slot in each deferred
+ // draw. The former outdoor-root post-world tail ran after building
+ // cells and let exterior alpha repaint the cathedral transition.
+ // With no look-ins they drain at the end of the landscape stage; the
+ // 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
// immediately after LScape::draw and before the optional depth clear.
@@ -779,6 +974,58 @@ public sealed class RetailPViewRenderer
passes.UseIndoorMembershipOnlyRouting();
}
+ internal static int LookInBuildingShellRouteIndex(
+ int frameIndex,
+ int outsideSliceCount,
+ int sliceIndex) =>
+ checked((frameIndex * outsideSliceCount) + sliceIndex);
+
+ internal static int FindLookInFrameIndex(
+ uint buildingShellAnchorCellId,
+ IReadOnlyList 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(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
@@ -1180,6 +1427,7 @@ public sealed class RetailPViewRenderer
// T3 scratch lists (render thread only; cleared per use).
private readonly List _outdoorStaticScratch = new();
+ private readonly List _buildingShellScratch = new();
private readonly List _cellStaticScratch = new();
private readonly List _dynamicsScratch = new();
// #118: dynamics assigned to the OUTSIDE stage this frame (interior roots
@@ -1343,10 +1591,14 @@ public interface IRetailPViewPassExecutor
ClipFrameAssembly AssembleClipFrame(
PortalVisibilityFrame portalFrame,
ClipFrameAssembly reuseAssembly);
+ void AppendLookInClipFrames(
+ IReadOnlyList lookInFrames,
+ ClipFrameAssembly assembly);
void PrepareClipFrame(int terrainUploadCount);
void SetTerrainClip(ReadOnlySpan planes);
void ClearClipRouting();
void UseIndoorMembershipOnlyRouting();
+ void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice);
void PrepareCellBatches(
RetailPViewFrameInput frame,
HashSet visibleCellIds);
@@ -1363,11 +1615,22 @@ public interface IRetailPViewPassExecutor
ClipViewSlice slice,
int sliceIndex);
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 ClearInteriorDepth();
void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
- void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
- void DrawUnattachedSceneParticles(RetailPViewFrameInput frame);
+ void DrawLookInPortalPunch(
+ RetailPViewFrameInput frame,
+ RetailPViewCellSliceContext context,
+ int portalIndex);
+ void DrawUnattachedSceneParticles(
+ RetailPViewFrameInput frame,
+ ClipViewSlice slice);
void FlushLandscapeAlpha();
void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet ownerIds);
@@ -1690,9 +1953,27 @@ public readonly record struct RetailPViewLandscapeSliceContext(
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
}
+///
+/// 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.
+///
+public readonly record struct RetailPViewLandscapeStaticParticleContext(
+ ClipViewSlice Slice,
+ IReadOnlySet ParticleOwnerIds);
+
+/// Retail DrawBuilding's ordinary exterior-shell pass, issued after
+/// the same building's portal-only look-in traversal.
+public readonly record struct RetailPViewLandscapeBuildingShellSliceContext(
+ ClipViewSlice Slice,
+ IReadOnlyList BuildingShells)
+{
+ internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
+}
+
/// #131/#132: the late landscape phase's per-slice payload —
-/// outside-stage dynamics to mesh-draw, plus the full scene-particle owner
-/// set (statics + dynamics cone survivors) the attached-emitter filter keys on.
+/// outside-stage dynamics to mesh-draw, plus the particle owners not already
+/// submitted at a pre-building barrier.
public readonly record struct RetailPViewLandscapeLateSliceContext(
ClipViewSlice Slice,
IReadOnlyList Dynamics,
diff --git a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs
index 3b339bd3..506362c2 100644
--- a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs
+++ b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs
@@ -30,6 +30,7 @@ internal readonly record struct CurrentRenderProjectionFingerprint(
internal enum CurrentRenderPViewRoute : byte
{
LandscapeOutdoorStatic,
+ LandscapeBuildingShell,
LandscapeOutsideDynamic,
LookInObject,
CellStatic,
diff --git a/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs
index db928b46..f867a395 100644
--- a/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs
+++ b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs
@@ -13,6 +13,7 @@ internal enum RenderFrameBlendClass : byte
internal enum RenderFrameCandidateRoute : byte
{
LandscapeOutdoorStatic,
+ LandscapeBuildingShell,
LandscapeOutsideDynamic,
LookInObject,
CellStatic,
diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs
index bbd368bd..61f2d3e7 100644
--- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs
+++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs
@@ -992,6 +992,8 @@ internal sealed class RenderScenePViewFrameProductController :
{
CurrentRenderPViewRoute.LandscapeOutdoorStatic =>
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
+ CurrentRenderPViewRoute.LandscapeBuildingShell =>
+ RenderFrameCandidateRoute.LandscapeBuildingShell,
CurrentRenderPViewRoute.LandscapeOutsideDynamic =>
RenderFrameCandidateRoute.LandscapeOutsideDynamic,
CurrentRenderPViewRoute.LookInObject =>
@@ -1157,7 +1159,21 @@ internal sealed class RenderScenePViewFrameBuilder
LoadSceneIndices(input.Scene);
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);
BuildCellStaticRoute(writer, in input);
BuildDynamicLastRoute(writer, in input);
@@ -1279,6 +1295,14 @@ internal sealed class RenderScenePViewFrameBuilder
for (int i = 0; i < _outdoorCount; 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);
if (!input.Viewcone.SphereVisibleInOutsideSlice(
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(
RenderFrameWriter writer,
- in RenderScenePViewBuildInput input)
+ in RenderScenePViewBuildInput input,
+ int frameIndex,
+ ref int routeIndex)
{
- for (int frameIndex = 0;
- frameIndex < input.LookInFrames.Count;
- frameIndex++)
+ PortalVisibilityFrame frame = input.LookInFrames[frameIndex];
+ for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
- PortalVisibilityFrame frame = input.LookInFrames[frameIndex];
- for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
+ uint cellId = frame.OrderedVisibleCells[i];
+ var clipKey = new LookInClipCell(frameIndex, cellId);
+ if (!input.ClipAssembly.LookInCellToViewSlices.TryGetValue(
+ clipKey,
+ out ClipViewSlice[]? slices)
+ || slices.Length == 0)
{
- uint cellId = frame.OrderedVisibleCells[i];
- int count = LoadCell(
- input.Scene,
- cellId,
- includeDynamics: true);
+ continue;
+ }
+
+ int count = LoadCell(
+ input.Scene,
+ cellId,
+ includeDynamics: true);
+ for (int sliceIndex = 0; sliceIndex < slices.Length; sliceIndex++)
+ {
+ int currentRouteIndex = routeIndex++;
if (count == 0)
continue;
@@ -1330,7 +1416,7 @@ internal sealed class RenderScenePViewFrameBuilder
input.AnimatedEntityIds);
writer.AddRouteRange(
RenderFrameCandidateRoute.LookInObject,
- i,
+ currentRouteIndex,
cellId,
_cell.AsSpan(0, count));
}
diff --git a/src/AcDream.App/Rendering/Shaders/particle.vert b/src/AcDream.App/Rendering/Shaders/particle.vert
index 3da9560f..7df885dd 100644
--- a/src/AcDream.App/Rendering/Shaders/particle.vert
+++ b/src/AcDream.App/Rendering/Shaders/particle.vert
@@ -14,6 +14,23 @@ layout(location = 5) in vec4 aColor;
// (ACDREAM_TEXTURE_HANDLE, injected by
// tools/ShaderCompiler/VulkanGlslPreamble.cs).
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;
@@ -35,4 +52,9 @@ void main() {
// stage, which is the only form Vulkan can express.
vTextureIndex = aTextureIndex;
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;
}
diff --git a/src/AcDream.App/Rendering/Shaders/particle_mesh.vert b/src/AcDream.App/Rendering/Shaders/particle_mesh.vert
index 812b2af8..6d6542f1 100644
--- a/src/AcDream.App/Rendering/Shaders/particle_mesh.vert
+++ b/src/AcDream.App/Rendering/Shaders/particle_mesh.vert
@@ -5,6 +5,23 @@ layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
layout(location = 3) in mat4 aModel;
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;
@@ -15,4 +32,9 @@ void main() {
vTexCoord = aTexCoord;
vColor = aColor;
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;
}
diff --git a/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv
index 26fcf5de..9a52c2bf 100644
Binary files a/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv
index 3b32357f..ec6aec3e 100644
Binary files a/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
index 66434e36..3792e997 100644
--- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
+++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
@@ -263,7 +263,7 @@
"stages": [
{
"stage": "vert",
- "sourceSha256": "921c32617708b3931a6304b4697ee96d728077d96f489d6c09eea4c5fe225b63",
+ "sourceSha256": "2bdd7114223164916a87e7d2e6df8a21fdcbd1cad082fafb3102b8a0fd4a2a8b",
"compiled": true
},
{
@@ -279,7 +279,7 @@
"stages": [
{
"stage": "vert",
- "sourceSha256": "19db8757c1a61a2fbec8e56ce89d56b6dd6d66a123cedcdae40915af07d58c0e",
+ "sourceSha256": "582e9be4bca8bd1807d5a2152b211d1d4dd1ed54a6efe4240f52d402e628be75",
"compiled": true
},
{
diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
index bbfac8d7..5426231e 100644
--- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
+++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
@@ -43,6 +43,21 @@ internal readonly record struct WorldRootFrame(
bool PlayerIndoorGate)
{
public bool RenderSky => ViewerRoot is null || RootSeenOutside;
+
+ ///
+ /// 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.
+ ///
+ public bool CameraInsideEnclosedCell => CameraInsideCell && !RootSeenOutside;
+
+ /// Environment gate shared by directional shadows and other
+ /// outdoor-only render-pack effects.
+ public bool PlayerOrCameraInsideEnclosedCell =>
+ PlayerInsideCell || CameraInsideEnclosedCell;
+
+ public bool IsAtmosphericallyOutdoor =>
+ RenderSky && !CameraInsideEnclosedCell;
}
/// Borrowed building scratch, valid only until the next build.
diff --git a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs
index af85081e..fe14a5fc 100644
--- a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs
+++ b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs
@@ -221,17 +221,10 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
return AppendSignature(currentSignature, "global");
}
- if (clipRoot.IsOutdoorNode)
- {
- _particleRenderer.DrawForOwners(
- camera.Camera,
- camera.Position,
- ParticleRenderPass.Scene,
- outdoorOwnerIds,
- includeUnattached: true);
- return AppendSignature(currentSignature, "unattached");
- }
-
+ // Every PView root, including the outdoor sentinel, now submits scene
+ // particles inside LScape::draw. Replaying them here is both a duplicate
+ // and too late: it occurs after nested building cells, allowing exterior
+ // waterfall/foliage alpha to repaint an indoor/outdoor transition.
return currentSignature;
}
diff --git a/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs
index 679d7a1f..d8c624ee 100644
--- a/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs
@@ -128,6 +128,39 @@ public class ClipFrameAssemblerTests
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]
public void Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds()
{
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
index 63ce718f..620a8fb6 100644
--- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
@@ -42,9 +42,12 @@ public sealed class VulkanShaderManifestTests
["mesh_modern.frag.spv"] = "b702b644862aca31ce1fb0677adc5872b39c4ea87f595a89363b44d10f2cc50e",
["mesh_modern.vert.spv"] = "7ca5fb241c4f0248884ba8fa88fbae17a7d5cbc80efe4ac4a9ffd0254012ead8",
["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.vert.spv"] = "f7fe8b203cadcd4d54af5cdbcfd9d5bf733146e10bafa78ca730fb6970db0479",
+ // Same contract for full-mesh particle geometry.
+ ["particle_mesh.vert.spv"] = "b5b3e0f583e00b78b56e297e60e5050013a26b3a74dbec64f6e5b286b751f0fa",
["portal_depth.frag.spv"] = "96755196d4d0da7be4792107557465778be2ebefb5584834cc75bf90ec55a6cc",
["portal_depth.vert.spv"] = "cd113860b7acd6afad3ebcc0a68dd7147f6baae729df51ab360c123588dc3ae2",
// sky.frag re-pinned 2026-08-23: the dome's fog blend lost its
diff --git a/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs b/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs
index 69aaabfb..9d355c3d 100644
--- a/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs
@@ -14,11 +14,15 @@ public sealed class ParticleBindlessInstanceTests
// one TextureIndex (a binding=9 handle-table slot, 4 bytes), so the
// struct shrank by 4 bytes; TextureIndex keeps TextureHandleLow's
// former offset (64 — right after the four vec4 fields).
- Assert.Equal(68, Marshal.SizeOf());
+ Assert.Equal(72, Marshal.SizeOf());
Assert.Equal(
new IntPtr(64),
Marshal.OffsetOf(
nameof(ParticleRenderer.BillboardGpuInstance.TextureIndex)));
+ Assert.Equal(
+ new IntPtr(68),
+ Marshal.OffsetOf(
+ nameof(ParticleRenderer.BillboardGpuInstance.ClipSlot)));
}
[Fact]
@@ -40,6 +44,8 @@ public sealed class ParticleBindlessInstanceTests
// index rather than by a null handle, because Vulkan's descriptor array
// cannot be asked whether an element was ever written.
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("vTextureIndex = aTextureIndex;", vertex);
Assert.Contains("#extension GL_ARB_bindless_texture : require", fragment);
diff --git a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs
index 9a5b3f5c..b8d560ba 100644
--- a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs
@@ -21,6 +21,10 @@ public sealed class RenderScenePViewFrameProductTests
RenderProjectionRecord outdoor = Record(
0x0100_0000_0000_0001,
RenderProjectionClass.OutdoorStatic);
+ RenderProjectionRecord buildingShell = Record(
+ 0x0100_0000_0000_0008,
+ RenderProjectionClass.OutdoorStatic,
+ isBuildingShell: true);
RenderProjectionRecord hidden = Record(
0x0100_0000_0000_0002,
RenderProjectionClass.OutdoorStatic,
@@ -48,6 +52,7 @@ public sealed class RenderScenePViewFrameProductTests
RenderProjectionRecord[] records =
[
outdoor,
+ buildingShell,
hidden,
withdrawn,
shell,
@@ -90,7 +95,7 @@ public sealed class RenderScenePViewFrameProductTests
try
{
Assert.Equal(
- [outdoor.Id, hidden.Id],
+ [outdoor.Id, hidden.Id, buildingShell.Id],
view.OutdoorStaticCandidates.ToArray()
.Select(static item => item.Id));
Assert.Equal(
@@ -101,28 +106,42 @@ public sealed class RenderScenePViewFrameProductTests
[outdoorDynamic.Id, cellDynamic.Id],
view.DynamicCandidates.ToArray()
.Select(static item => item.Id));
- Assert.Equal(5, view.Transforms.Length);
- Assert.Equal(5, view.RouteCandidates.Length);
+ Assert.Equal(6, view.Transforms.Length);
+ Assert.Equal(6, view.RouteCandidates.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(
view.RouteCandidates.ToArray(),
item => item.Id == withdrawn.Id || item.Id == shell.Id);
Assert.Equal(
new RenderFrameDiagnosticCounts(
- OutdoorStaticCandidates: 2,
+ OutdoorStaticCandidates: 3,
CellStaticCandidates: 1,
DynamicCandidates: 2,
- TransformCount: 5,
+ TransformCount: 6,
OpaqueClassificationCount: 0,
AlphaClassificationCount: 0,
LightSetCount: 0,
SelectionPartCount: 0,
- RouteCandidateCount: 5,
- EntityCandidateCount: 5,
- MeshPartCount: 5),
+ RouteCandidateCount: 6,
+ EntityCandidateCount: 6,
+ MeshPartCount: 6),
view.DiagnosticCounts);
- Assert.Equal(5, view.EntityCandidates.Length);
- Assert.Equal(5, view.MeshParts.Length);
+ Assert.Equal(6, view.EntityCandidates.Length);
+ Assert.Equal(6, view.MeshParts.Length);
Assert.Same(portal, view.PortalFrame);
Assert.Same(clip, view.ClipAssembly);
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]
public void Builder_ReusesOrderedIndicesWhileRefreshingDirtyRecords()
{
@@ -501,7 +692,9 @@ public sealed class RenderScenePViewFrameProductTests
uint? parentCell = null,
RenderProjectionFlags flags =
RenderProjectionFlags.Draw
- | RenderProjectionFlags.SpatiallyResident)
+ | RenderProjectionFlags.SpatiallyResident,
+ bool isBuildingShell = false,
+ uint buildingShellAnchorCellId = 0)
{
uint fullCellId = parentCell ?? Landblock;
Matrix4x4 transform = Matrix4x4.Identity;
@@ -531,14 +724,14 @@ public sealed class RenderScenePViewFrameProductTests
SourceId: 1,
ParentCellId: parentCell ?? 0,
EffectCellId: 0,
- BuildingShellAnchorCellId: 0,
+ BuildingShellAnchorCellId: buildingShellAnchorCellId,
TransformFingerprint: default,
GeometryFingerprint: default,
AppearanceFingerprint: default),
new RenderEntityPayload(
[new MeshRef((uint)id, Matrix4x4.Identity)],
PaletteOverride: null,
- IsBuildingShell: false));
+ IsBuildingShell: isBuildingShell));
}
private static WorldEntity Entity(
@@ -578,6 +771,22 @@ public sealed class RenderScenePViewFrameProductTests
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 (
uint LandblockId,
Vector3 AabbMin,
@@ -602,4 +811,15 @@ public sealed class RenderScenePViewFrameProductTests
public LoadedCell? Find(uint cellId) => null;
}
+
+ private sealed class DictionaryCellSource : IRetailPViewCellSource
+ {
+ private readonly Dictionary _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;
+ }
}
diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
index f00a019a..71068650 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
@@ -23,6 +23,7 @@ public sealed class RetailPViewPassExecutorTests
[
"begin",
"assemble",
+ "append-look-in-clips",
"prepare-clip:3",
"indoor-routing",
"prepare-cells",
@@ -33,6 +34,7 @@ public sealed class RetailPViewPassExecutorTests
"terrain-clip",
"clear-routing",
"landscape-late",
+ "unattached-particles",
"landscape-alpha",
"indoor-routing",
"indoor-routing",
@@ -272,6 +274,88 @@ public sealed class RetailPViewPassExecutorTests
executor);
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]
@@ -429,20 +513,21 @@ public sealed class RetailPViewPassExecutorTests
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
{
CellId = vestibuleId,
- BuildingId = 2u,
+ BuildingId = buildingId,
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Portals =
[
new CellPortalInfo(0xFFFF, 0, 0, 0),
- new CellPortalInfo(0x0171, 1, 0, 0),
+ new CellPortalInfo((ushort)(roomId & 0xFFFFu), 1, 0, 0),
],
ClipPlanes =
[
@@ -472,10 +557,17 @@ public sealed class RetailPViewPassExecutorTests
var room = new LoadedCell
{
CellId = roomId,
- BuildingId = 2u,
+ BuildingId = buildingId,
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
- Portals = [new CellPortalInfo(0x0170, 0, 0, 1)],
+ Portals =
+ [
+ new CellPortalInfo(
+ (ushort)(vestibuleId & 0xFFFFu),
+ 0,
+ 0,
+ 1),
+ ],
};
room.PortalPolygons.Add(
[
@@ -490,7 +582,9 @@ public sealed class RetailPViewPassExecutorTests
private static WorldEntity Entity(
uint id,
uint serverGuid = 0,
- uint? parentCellId = null) => new()
+ uint? parentCellId = null,
+ bool isBuildingShell = false,
+ uint? buildingShellAnchorCellId = null) => new()
{
Id = id,
ServerGuid = serverGuid,
@@ -499,6 +593,8 @@ public sealed class RetailPViewPassExecutorTests
Rotation = Quaternion.Identity,
MeshRefs = [new MeshRef(1u, Matrix4x4.Identity)],
ParentCellId = parentCellId,
+ IsBuildingShell = isBuildingShell,
+ BuildingShellAnchorCellId = buildingShellAnchorCellId,
};
private static void AssertAppearsInOrder(string source, params string[] needles)
@@ -566,6 +662,17 @@ public sealed class RetailPViewPassExecutorTests
return ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly);
}
+ public void AppendLookInClipFrames(
+ IReadOnlyList lookInFrames,
+ ClipFrameAssembly assembly)
+ {
+ Operations.Add("append-look-in-clips");
+ ClipFrameAssembler.AppendLookInFrames(
+ _clipFrame,
+ lookInFrames,
+ assembly);
+ }
+
public void PrepareClipFrame(int terrainUploadCount) =>
Operations.Add($"prepare-clip:{terrainUploadCount}");
@@ -574,6 +681,8 @@ public sealed class RetailPViewPassExecutorTests
public void ClearClipRouting() => Operations.Add("clear-routing");
public void UseIndoorMembershipOnlyRouting() => Operations.Add("indoor-routing");
+ public void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice) =>
+ Operations.Add($"cell-portal-routing:{cellId:X8}:{slice.Slot}");
public void PrepareCellBatches(RetailPViewFrameInput frame, HashSet visibleCellIds) =>
Operations.Add("prepare-cells");
public void DrawOpaqueCellShells(HashSet cellIds) => Operations.Add("opaque-shells");
@@ -624,10 +733,37 @@ public sealed class RetailPViewPassExecutorTests
request.TupleLandblockId);
}
}
+
+ public void DrawLandscapeStaticParticles(
+ RetailPViewFrameInput frame,
+ RetailPViewLandscapeStaticParticleContext context) =>
+ Operations.Add("landscape-static-particles");
+ public void DrawLandscapeBuildingShellSlice(
+ RetailPViewFrameInput frame,
+ RetailPViewLandscapeBuildingShellSliceContext context)
+ {
+ Operations.Add("landscape-building-shell");
+ if (context.EntityDraw is RenderFrameEntityDrawRequest request)
+ {
+ RenderFrameView view = request.View;
+ DrawEntityRoute(
+ frame.Camera,
+ in view,
+ request.Route,
+ request.RouteIndex,
+ request.CellId,
+ request.TupleLandblockId);
+ }
+ }
public void ClearInteriorDepth() => Operations.Add("interior-depth-clear");
public void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("exit-mask");
- public void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("look-in-punch");
- public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame) => Operations.Add("unattached-particles");
+ public void DrawLookInPortalPunch(
+ 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 DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles");
public void DrawDynamicsParticles(
diff --git a/tests/AcDream.App.Tests/Rendering/RhiVertexLayoutStrideTests.cs b/tests/AcDream.App.Tests/Rendering/RhiVertexLayoutStrideTests.cs
index 486239bd..6c65f7ad 100644
--- a/tests/AcDream.App.Tests/Rendering/RhiVertexLayoutStrideTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RhiVertexLayoutStrideTests.cs
@@ -105,10 +105,9 @@ public class RhiVertexLayoutStrideTests
layout.StrideOf(0));
Assert.Equal(GpuVertexInputRate.Vertex, layout.InputRateOf(0));
- // Binding 1 is a mat4 model plus an RGBA colour, written as loose floats
- // by WriteMeshGpuInstance.
+ // Binding 1 is the exact mesh-particle record: model, RGBA, clip slot.
Assert.Equal(
- (uint)(ParticleRenderer.MeshInstanceFloats * sizeof(float)),
+ (uint)Unsafe.SizeOf(),
layout.StrideOf(1));
Assert.Equal(GpuVertexInputRate.Instance, layout.InputRateOf(1));
}
diff --git a/tests/AcDream.App.Tests/Rendering/SanctuaryPortalSeamTests.cs b/tests/AcDream.App.Tests/Rendering/SanctuaryPortalSeamTests.cs
new file mode 100644
index 00000000..4152cd92
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/SanctuaryPortalSeamTests.cs
@@ -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;
+
+///
+/// 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.
+///
+[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 Cells(
+ Dictionary cells,
+ uint firstLow,
+ uint lastLow)
+ {
+ var result = new List();
+ 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(dats.Get(Landblock | 0xfffeu));
+ Dictionary cells =
+ Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock);
+ LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out LoadedCell? cell) ? cell : null;
+
+ IReadOnlyList building3 = Cells(cells, 0x0103u, 0x0105u);
+ IReadOnlyList 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 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 opposite,
+ Func 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 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);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs
index 62b92bdf..7bea5c2c 100644
--- a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs
@@ -213,6 +213,9 @@ public sealed class WorldRenderFrameBuilderTests
Assert.False(result.PlayerSeenOutside);
Assert.True(result.RootSeenOutside);
Assert.True(result.RenderSky);
+ Assert.False(result.CameraInsideEnclosedCell);
+ Assert.True(result.PlayerOrCameraInsideEnclosedCell);
+ Assert.True(result.IsAtmosphericallyOutdoor);
Assert.Equal(playerCellId, result.PlayerCellId);
Assert.Equal(viewerCellId, result.ViewerCellId);
Assert.Equal(camera.Position, result.ViewerEyePosition);
@@ -245,6 +248,9 @@ public sealed class WorldRenderFrameBuilderTests
Assert.False(result.PlayerInsideCell);
Assert.False(result.CameraInsideCell);
Assert.True(result.RenderSky);
+ Assert.False(result.CameraInsideEnclosedCell);
+ Assert.False(result.PlayerOrCameraInsideEnclosedCell);
+ Assert.True(result.IsAtmosphericallyOutdoor);
}
[Fact]
diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
index ecc5eefe..a8baa7b9 100644
--- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
@@ -290,7 +290,7 @@ public sealed class WorldSceneRendererTests
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
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.Equal([0xCAFEu], rig.Passes.ParticleOwners);
Assert.DoesNotContain("flat:weather", rig.Calls);
@@ -809,9 +809,7 @@ public sealed class WorldSceneRendererTests
string kind = clipRoot switch
{
null => "global",
- { IsOutdoorNode: true } => currentSignature == "none"
- ? "unattached"
- : currentSignature + "+unattached",
+ { IsOutdoorNode: true } => currentSignature,
_ => currentSignature,
};
calls.Add($"particles:{kind}");
diff --git a/tools/cdb/i451-dual-client-av.cdb b/tools/cdb/i452-dual-client-av.cdb
similarity index 70%
rename from tools/cdb/i451-dual-client-av.cdb
rename to tools/cdb/i452-dual-client-av.cdb
index 08674a33..15e84e8f 100644
--- a/tools/cdb/i451-dual-client-av.cdb
+++ b/tools/cdb/i452-dual-client-av.cdb
@@ -4,7 +4,7 @@ sxd ld
sxd ud
sxd clr
sxd clrn
-sxe -c ".echo ===== #451 FIRST-CHANCE ACCESS VIOLATION =====; .time; .exr -1; .ecxr; r; .echo ===== CURRENT NATIVE STACK =====; kb 100; .echo ===== CURRENT MANAGED STACK =====; .loadby sos coreclr; !clrstack -a; .echo ===== ALL NATIVE THREADS =====; ~*kb 50; .echo ===== RELEVANT MODULES =====; lmvm coreclr; lmvm glfw3; lmvm vulkan-1; lmvm amdvlk64; lm; q" av
-sxe -c ".echo ===== #451 HEAP CORRUPTION =====; .time; .exr -1; .ecxr; r; !analyze -v; ~*kb 50; lm; q" c0000374
-sxe -c ".echo ===== #451 STACK BUFFER OVERRUN =====; .time; .exr -1; .ecxr; r; !analyze -v; ~*kb 50; lm; q" c0000409
+sxe -c ".echo ===== #452 FIRST-CHANCE ACCESS VIOLATION =====; .time; .exr -1; .ecxr; r; .echo ===== CURRENT NATIVE STACK =====; kb 100; .echo ===== CURRENT MANAGED STACK =====; .loadby sos coreclr; !clrstack -a; .echo ===== ALL NATIVE THREADS =====; ~*kb 50; .echo ===== RELEVANT MODULES =====; lmvm coreclr; lmvm glfw3; lmvm vulkan-1; lmvm amdvlk64; lm; q" av
+sxe -c ".echo ===== #452 HEAP CORRUPTION =====; .time; .exr -1; .ecxr; r; !analyze -v; ~*kb 50; lm; q" c0000374
+sxe -c ".echo ===== #452 STACK BUFFER OVERRUN =====; .time; .exr -1; .ecxr; r; !analyze -v; ~*kb 50; lm; q" c0000409
g