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