From 0d6cd5c06e889ffbc0dba6e9bc6b2e28bf7e9589 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 30 Aug 2026 19:58:04 +0200 Subject: [PATCH] fix(render): FW4 slice 5 - straddling cell statics'' particles emit pre-clear The cathedral falls'' true identity, pinned by the emitter/owner dumps and the owner''s retail-vs-ACE oracle (retail renders them perfectly): they are EnvCell STAB-LIST statics (interior-cell dat objects, ids minted by InteriorEntityIdAllocator) whose geometry hangs out over the lake. Our cell-statics route submitted every interior flood cell''s static particle owners POST-clear ("walls own depth") - true for content that stays inside, but a straddler''s outside half meets seal/cleared depth and splats across the vista. Retail''s mechanism is the outdoor shadow-cell draw: an object overlapping outdoor landcells draws during LScape::draw (DrawBlock pc:430056-430064, once per overlapped shadow cell), so its particles submit in the landscape scope and drain at the pre-clear boundary flush over true terrain depth; the cell''s own turn skips them (drawn-once). This slice ports that rule for particles: after Collect, the interior flood cells'' static records are classified by the shared exit-plane straddle test (extracted as SphereStraddlesExitPlane from the #118 dynamic split); straddlers'' owners submit in the pre-clear closure and the post-replay cell-owners union subtracts them, so every owner emits exactly once. Also adds the [walk-emit] probe dump that falsified the unattached-route theory. Hermetic 6,762/0. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/Rendering/ParticleRenderer.cs | 20 +++++ .../Rendering/RetailPViewRenderer.cs | 74 +++++++++++++++++++ src/AcDream.App/Rendering/Walk/WalkPView.cs | 14 ++++ 3 files changed, 108 insertions(+) diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 75f60578..694955e2 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -161,6 +161,10 @@ public sealed unsafe partial class ParticleRenderer : IDisposable private readonly List _meshRunScratch = new(64); private readonly List _submissionScratch = new(128); private readonly List _scopedEmitterScratch = new(64); + + // ACDREAM_PROBE_WALK_ROOT companion (throwaway): rate limiter for the + // [walk-emit] interior-unattached emitter dump. + private uint _probeEmitterDumpCounter; private readonly List _deferredAlpha = new(128); private DeferredParticleDraw[] _preparedAlpha = new DeferredParticleDraw[256]; private uint[] _preparedInstanceOffsets = new uint[256]; @@ -247,6 +251,22 @@ public sealed unsafe partial class ParticleRenderer : IDisposable _scopedEmitterScratch, excludedAttachedOwnerIds, unattachedCellScope); + // ACDREAM_PROBE_WALK_ROOT companion (throwaway): identify WHICH + // emitters ride the interior-unattached route (the falls hunt) — + // owner cell, anchor position, attachment. + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled + && unattachedCellScope == UnattachedEmitterCellScope.InteriorCells + && ++_probeEmitterDumpCounter % 120 == 0) + { + foreach (var em in _scopedEmitterScratch) + { + Console.WriteLine( + $"[walk-emit] cell={em.OwnerCellId:x8} attached={em.AttachedObjectId:x} " + + $"anchor=({em.AnchorPos.X:F1},{em.AnchorPos.Y:F1},{em.AnchorPos.Z:F1}) " + + $"active={em.ActiveCount} " + + $"phase={AcDream.Core.Rendering.RenderingDiagnostics.WalkRootPhase}"); + } + } Matrix4x4.Invert(camera.View, out Matrix4x4 invView); Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13)); Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23)); diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index f50776a4..077955e8 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -81,6 +81,18 @@ public sealed class RetailPViewRenderer // shine-through, fourth surface). private readonly HashSet _preClearParticleOwnerScratch = new(); + // FW4 slice 5: interior flood-cell STATICS whose bounds straddle their + // cell's exit-portal plane (the cathedral falls — EnvCell stab-list + // objects hanging out over the lake). Retail draws such an object + // during LScape::draw via the outdoor shadow-cell lists (DrawBlock + // pc:430056-430064, once per overlapped shadow cell), so its particles + // submit in the landscape scope and drain at the pre-clear boundary + // flush over true terrain depth; the cell's own post-clear turn skips + // them (drawn-once). Computed after Collect from the walk's flood; + // submitted by the pre-clear closure; subtracted from the post-replay + // cell-owners union. + private readonly HashSet _preClearCellStaticOwnerScratch = new(); + // MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/ // Dynamics), reused across frames instead of `new`ing a Result (a Dictionary // + 2 Lists, plus one List per visible cell) every DrawInside @@ -354,9 +366,15 @@ public sealed class RetailPViewRenderer walkExecutor!, ctx, clipAssembly, clearInteriorDepth, drawExitSeals); walkDriver = new Walk.WalkFrameDriver(walkExecutor!.Dispatcher, leafRenderer, _walkWorldData); + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled) + { + AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame = + _probeWalkRootFrame % 90 == 0; + } walkDriver.Collect( _frameWalk, ctx.ViewerCellId, walkCameraCell, walkLandscape, walkContext, ctx.ViewProjection, ctx.CameraWorldPosition); + AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame = false; // FW4 slice 1: an interior root's terrain/sky/punch clip slices // come from THE WALK'S OWN outside_view — retail's one @@ -576,6 +594,37 @@ public sealed class RetailPViewRenderer } } + // FW4 slice 5: classify the interior flood cells' STATICS by the + // exit-plane straddle test — a straddler's particles (the + // cathedral falls: EnvCell stab objects hanging over the lake) + // submit pre-clear like retail's outdoor shadow-cell draw; see + // _preClearCellStaticOwnerScratch's field comment. GetCellStatics + // is per-frame cached, so this also pre-warms the post-replay + // cell-owners pass. + _preClearCellStaticOwnerScratch.Clear(); + if (walkActive && !ctx.RootCell.IsOutdoorNode) + { + foreach (uint cellId in walkDriver!.VisitedCells) + { + if ((cellId & 0xFFFFu) < 0x100u || _lookInCellIds.Contains(cellId)) + continue; + LoadedCell? staticCell = ctx.Cells.Find(cellId); + if (staticCell is null) + continue; + Walk.WalkFrameStaticRecords cellRecords = + _walkWorldData!.GetCellStatics(cellId); + foreach (RenderProjectionRecord record in cellRecords.Records) + { + if (record.Source.LocalEntityId == 0) + continue; + Vector3 c = (record.Bounds.Minimum + record.Bounds.Maximum) * 0.5f; + float r = (record.Bounds.Maximum - record.Bounds.Minimum).Length() * 0.5f; + if (SphereStraddlesExitPlane(staticCell, c, r)) + _preClearCellStaticOwnerScratch.Add(record.Source.LocalEntityId); + } + } + } + if (walkActive) { // Campaign FW3.2b-2: the walk owns every static draw — @@ -608,6 +657,17 @@ public sealed class RetailPViewRenderer // BEFORE the pre-clear drain so they composite against // still-true landscape depth, never the seals. SubmitWalkLandscapeStaticParticles(ctx, walkExecutor!, capturedDriver); + // FW4 slice 5: straddling flood-cell statics' emitters + // (the falls) — retail's shadow-cell landscape draw. + if (_preClearCellStaticOwnerScratch.Count > 0) + { + _preClearParticleOwnerScratch.UnionWith( + _preClearCellStaticOwnerScratch); + passes.DrawLandscapeStaticParticles( + ctx, + new RetailPViewLandscapeStaticParticleContext( + _preClearCellStaticOwnerScratch)); + } passes.UseIndoorMembershipOnlyRouting(); DrawLandscapeDynamicsPhase( ctx, @@ -1403,6 +1463,10 @@ public sealed class RetailPViewRenderer continue; UnionRecordOwners(_walkWorldData!.GetCellStatics(cellId), _cellParticleOwnerScratch); } + // FW4 slice 5: exit-plane straddlers (the falls) already emitted at + // the pre-clear boundary — retail's shadow-cell landscape draw; + // every owner emits exactly once. + _cellParticleOwnerScratch.ExceptWith(_preClearCellStaticOwnerScratch); if (_cellParticleOwnerScratch.Count > 0) { passes.DrawCellParticles( @@ -2617,6 +2681,16 @@ public sealed class RetailPViewRenderer if (cell is null) return false; + return SphereStraddlesExitPlane(cell, sphereCenter, sphereRadius); + } + + /// The exit-plane straddle test shared by the #118 dynamic + /// stage split and the FW4 slice-5 cell-static particle split — retail + /// draws a straddling object once per overlapped outdoor shadow cell + /// inside LScape::draw (DrawBlock pc:430056-430064). + internal static bool SphereStraddlesExitPlane( + LoadedCell cell, Vector3 sphereCenter, float sphereRadius) + { var localC = Vector3.Transform(sphereCenter, cell.InverseWorldTransform); int n = Math.Min(cell.Portals.Count, cell.ClipPlanes.Count); for (int i = 0; i < n; i++) diff --git a/src/AcDream.App/Rendering/Walk/WalkPView.cs b/src/AcDream.App/Rendering/Walk/WalkPView.cs index 3e5f7984..faeb9859 100644 --- a/src/AcDream.App/Rendering/Walk/WalkPView.cs +++ b/src/AcDream.App/Rendering/Walk/WalkPView.cs @@ -119,6 +119,14 @@ public sealed class WalkPView flags.InView = true; // portal polygon faces the viewer } } + if (AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame) + { + Console.WriteLine( + $"[walk-portal] init cell={cell.CellId:x8} i={i} " + + $"dest={cell.Portals[i].OtherCellId:x8} d={d:F3} " + + $"pside={cell.Portals[i].PortalSide} " + + $"inview={(flags.InView ? 1 : 0)}"); + } } if (flags.InView) @@ -201,6 +209,12 @@ public sealed class WalkPView cell, portal.PortalSide, cell.PortalPolygons[portal.PolygonIndex], doClip: true, ctx, _clipScratch); + if (AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame) + { + Console.WriteLine( + $"[walk-portal] clip cell={cell.CellId:x8} view={i} j={j} " + + $"dest={portal.OtherCellId:x8} n={n}"); + } if (n == 0) continue; if (portal.OtherCellId == 0xFFFFFFFFu)