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 <noreply@anthropic.com>
This commit is contained in:
parent
f3a03efc81
commit
0d6cd5c06e
3 changed files with 108 additions and 0 deletions
|
|
@ -161,6 +161,10 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
private readonly List<MeshParticleInstance> _meshRunScratch = new(64);
|
private readonly List<MeshParticleInstance> _meshRunScratch = new(64);
|
||||||
private readonly List<ParticleSubmission> _submissionScratch = new(128);
|
private readonly List<ParticleSubmission> _submissionScratch = new(128);
|
||||||
private readonly List<RuntimeParticleEmitter> _scopedEmitterScratch = new(64);
|
private readonly List<RuntimeParticleEmitter> _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<DeferredParticleDraw> _deferredAlpha = new(128);
|
private readonly List<DeferredParticleDraw> _deferredAlpha = new(128);
|
||||||
private DeferredParticleDraw[] _preparedAlpha = new DeferredParticleDraw[256];
|
private DeferredParticleDraw[] _preparedAlpha = new DeferredParticleDraw[256];
|
||||||
private uint[] _preparedInstanceOffsets = new uint[256];
|
private uint[] _preparedInstanceOffsets = new uint[256];
|
||||||
|
|
@ -247,6 +251,22 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
|
||||||
_scopedEmitterScratch,
|
_scopedEmitterScratch,
|
||||||
excludedAttachedOwnerIds,
|
excludedAttachedOwnerIds,
|
||||||
unattachedCellScope);
|
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);
|
Matrix4x4.Invert(camera.View, out Matrix4x4 invView);
|
||||||
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
|
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
|
||||||
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
|
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,18 @@ public sealed class RetailPViewRenderer
|
||||||
// shine-through, fourth surface).
|
// shine-through, fourth surface).
|
||||||
private readonly HashSet<uint> _preClearParticleOwnerScratch = new();
|
private readonly HashSet<uint> _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<uint> _preClearCellStaticOwnerScratch = new();
|
||||||
|
|
||||||
// MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/
|
// MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/
|
||||||
// Dynamics), reused across frames instead of `new`ing a Result (a Dictionary
|
// Dynamics), reused across frames instead of `new`ing a Result (a Dictionary
|
||||||
// + 2 Lists, plus one List<WorldEntity> per visible cell) every DrawInside
|
// + 2 Lists, plus one List<WorldEntity> per visible cell) every DrawInside
|
||||||
|
|
@ -354,9 +366,15 @@ public sealed class RetailPViewRenderer
|
||||||
walkExecutor!, ctx, clipAssembly, clearInteriorDepth, drawExitSeals);
|
walkExecutor!, ctx, clipAssembly, clearInteriorDepth, drawExitSeals);
|
||||||
walkDriver = new Walk.WalkFrameDriver(walkExecutor!.Dispatcher, leafRenderer, _walkWorldData);
|
walkDriver = new Walk.WalkFrameDriver(walkExecutor!.Dispatcher, leafRenderer, _walkWorldData);
|
||||||
|
|
||||||
|
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled)
|
||||||
|
{
|
||||||
|
AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame =
|
||||||
|
_probeWalkRootFrame % 90 == 0;
|
||||||
|
}
|
||||||
walkDriver.Collect(
|
walkDriver.Collect(
|
||||||
_frameWalk, ctx.ViewerCellId, walkCameraCell, walkLandscape, walkContext,
|
_frameWalk, ctx.ViewerCellId, walkCameraCell, walkLandscape, walkContext,
|
||||||
ctx.ViewProjection, ctx.CameraWorldPosition);
|
ctx.ViewProjection, ctx.CameraWorldPosition);
|
||||||
|
AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame = false;
|
||||||
|
|
||||||
// FW4 slice 1: an interior root's terrain/sky/punch clip slices
|
// FW4 slice 1: an interior root's terrain/sky/punch clip slices
|
||||||
// come from THE WALK'S OWN outside_view — retail's one
|
// 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)
|
if (walkActive)
|
||||||
{
|
{
|
||||||
// Campaign FW3.2b-2: the walk owns every static draw —
|
// 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
|
// BEFORE the pre-clear drain so they composite against
|
||||||
// still-true landscape depth, never the seals.
|
// still-true landscape depth, never the seals.
|
||||||
SubmitWalkLandscapeStaticParticles(ctx, walkExecutor!, capturedDriver);
|
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();
|
passes.UseIndoorMembershipOnlyRouting();
|
||||||
DrawLandscapeDynamicsPhase(
|
DrawLandscapeDynamicsPhase(
|
||||||
ctx,
|
ctx,
|
||||||
|
|
@ -1403,6 +1463,10 @@ public sealed class RetailPViewRenderer
|
||||||
continue;
|
continue;
|
||||||
UnionRecordOwners(_walkWorldData!.GetCellStatics(cellId), _cellParticleOwnerScratch);
|
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)
|
if (_cellParticleOwnerScratch.Count > 0)
|
||||||
{
|
{
|
||||||
passes.DrawCellParticles(
|
passes.DrawCellParticles(
|
||||||
|
|
@ -2617,6 +2681,16 @@ public sealed class RetailPViewRenderer
|
||||||
if (cell is null)
|
if (cell is null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
return SphereStraddlesExitPlane(cell, sphereCenter, sphereRadius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
internal static bool SphereStraddlesExitPlane(
|
||||||
|
LoadedCell cell, Vector3 sphereCenter, float sphereRadius)
|
||||||
|
{
|
||||||
var localC = Vector3.Transform(sphereCenter, cell.InverseWorldTransform);
|
var localC = Vector3.Transform(sphereCenter, cell.InverseWorldTransform);
|
||||||
int n = Math.Min(cell.Portals.Count, cell.ClipPlanes.Count);
|
int n = Math.Min(cell.Portals.Count, cell.ClipPlanes.Count);
|
||||||
for (int i = 0; i < n; i++)
|
for (int i = 0; i < n; i++)
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,14 @@ public sealed class WalkPView
|
||||||
flags.InView = true; // portal polygon faces the viewer
|
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)
|
if (flags.InView)
|
||||||
|
|
@ -201,6 +209,12 @@ public sealed class WalkPView
|
||||||
cell, portal.PortalSide,
|
cell, portal.PortalSide,
|
||||||
cell.PortalPolygons[portal.PolygonIndex],
|
cell.PortalPolygons[portal.PolygonIndex],
|
||||||
doClip: true, ctx, _clipScratch);
|
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 (n == 0) continue;
|
||||||
|
|
||||||
if (portal.OtherCellId == 0xFFFFFFFFu)
|
if (portal.OtherCellId == 0xFFFFFFFFu)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue