T3 (BR-5): viewconeCheck port - per-view sphere culling for statics/dynamics/particles, weather player-gate, unattached outdoor emitters

Ports Render::viewconeCheck (Ghidra 0x0054c250): meshes are sphere-CULLED
per portal view, never hard-clipped. NEW ViewconeCuller lifts each
slice's <=8 clip-space half-planes to world-space eye-edge planes (the
view_vertex.plane analog, acclient.h:32483 - one matrix fold: L = VP
rows . P) and tests bounding spheres from the entity's cached AABB (the
dispatcher's own cull bounds source).

Gating now matches retail's shape end to end:
- Per-cell STATICS: sphere vs THEIR CELL's views - the statics-through-
  walls fix (the cottage phantom staircase's actual draw path: a static
  outside every view of its cell no longer paints through the wall).
- DYNAMICS (last pass): sphere vs their cell's views; outdoor/unresolved
  vs the outside views (pass-all under the outdoor root). A dynamic in a
  non-flooded room culls HERE - retail never reaches an object whose cell
  is not in the draw list; the partition still routes it so the CULL is
  what drops it, retail's shape exactly.
- OutdoorStatic (landscape pass): pre-filtered per outside slice; the
  per-slice entity gl_ClipDistance routing is DELETED (entities draw
  outside the clip bracket; terrain/sky keep their plane clip).
- PARTICLES: the scissor-AABB gate is DELETED; emitters gate through
  their cone-surviving owners (candle-flames-through-walls fix).
- WEATHER: gated on the PLAYER being outside (retail is_player_outside -
  an indoor player gets no rain even looking out a doorway). Closes
  weather-gate-player-vs-viewer.
- UNATTACHED emitters (campfires) get their missing outdoor-root pass
  (closes unattached-particles-dropped-outdoors).

Suites: App 226 green (flood gates included), Core baseline unchanged
(1398 + 4 pre-existing #99-era).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-11 12:56:48 +02:00
parent 88f3ce1fa0
commit a6aec8c32f
3 changed files with 268 additions and 24 deletions

View file

@ -104,12 +104,17 @@ public sealed class RetailPViewRenderer
// from the punch/seal depth writes + the z-buffer, and the dynamics-
// last order is what makes the punch safe (the first BR-2 attempt
// punched after dynamics and erased the player, reverted 88be519).
DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition);
// T3 (BR-5): retail viewconeCheck — meshes are sphere-CULLED per view,
// never clipped (Ghidra 0x0054c250). Built once per frame from the
// assembled slices + this frame's view-projection.
var viewcone = ViewconeCuller.Build(clipAssembly, ctx.ViewProjection);
DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition, viewcone);
UseIndoorMembershipOnlyRouting();
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
DrawEnvCellShells(pvFrame);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition);
DrawDynamicsLast(ctx, partition);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone);
DrawDynamicsLast(ctx, partition, viewcone);
return result;
}
@ -228,9 +233,10 @@ public sealed class RetailPViewRenderer
// drawn here: they belong exclusively to the frame's single last
// entity pass (the outdoor root's DrawDynamicsLast), which prevents
// double-draws of entities inside looked-into buildings.
var viewcone = ViewconeCuller.Build(clipAssembly, ctx.ViewProjection);
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
DrawEnvCellShells(pvFrame);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone);
RestoreNoClip(ctx.SetTerrainClipUbo);
return result;
@ -239,7 +245,8 @@ public sealed class RetailPViewRenderer
private void DrawLandscapeThroughOutsideView(
RetailPViewDrawContext ctx,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result partition)
InteriorEntityPartition.Result partition,
ViewconeCuller viewcone)
{
if (clipAssembly.OutsideViewSlices.Length == 0)
return;
@ -249,11 +256,24 @@ public sealed class RetailPViewRenderer
{
_clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo);
_entities.SetClipRouting(clipAssembly.CellIdToSlot, slice.Slot, outdoorVisible: true);
// T3 (BR-5): entities are never hard-clipped — retail viewcone-
// CHECKS each mesh's sphere against the view (Ghidra 0x0054c250)
// and draws it whole. The old per-slice entity clip routing
// (gl_ClipDistance via SetClipRouting) is replaced by the sphere
// pre-filter below; terrain/sky keep their per-slice plane clip.
_entities.ClearClipRouting();
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled)
EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex);
_outdoorStaticScratch.Clear();
foreach (var e in partition.OutdoorStatic)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
_outdoorStaticScratch.Add(e);
}
probeSliceIndex++;
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, partition.OutdoorStatic));
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
}
// T1: retail clears the FULL depth buffer ONCE between the outside
@ -402,15 +422,38 @@ public sealed class RetailPViewRenderer
// (retail draws objects per cell AFTER cells and viewcone-culls them —
// PView::DrawCells epilogue Ghidra 0x005a4840; the sphere-vs-view cull is
// T3). Drawing dynamics last is what makes the aperture punch safe.
// T3 (BR-5): each dynamic is viewcone-culled like retail — sphere vs its
// cell's views; outdoor/unresolved vs the outside views (pass-all under
// the outdoor root's full-screen outside view). A dynamic in a NON-flooded
// room culls HERE — retail never reaches an object whose cell is not in
// the draw list; the partition keeps routing it so the CULL (not the
// visibility set) drops it, exactly retail's shape.
private void DrawDynamicsLast(
IRetailPViewCellDrawContext ctx,
InteriorEntityPartition.Result partition)
InteriorEntityPartition.Result partition,
ViewconeCuller viewcone)
{
if (partition.Dynamics.Count == 0)
return;
_dynamicsScratch.Clear();
foreach (var e in partition.Dynamics)
{
EntitySphere(e, out var c, out float r);
bool indoor = e.ParentCellId is uint cell
&& (cell & 0xFFFFu) >= 0x0100u && (cell & 0xFFFFu) != 0xFFFFu;
bool visible = indoor
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
: viewcone.SphereVisibleOutside(c, r);
if (visible)
_dynamicsScratch.Add(e);
}
if (_dynamicsScratch.Count == 0)
return;
UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, partition.Dynamics, visibleCellIds: null);
DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null);
}
private void DrawCellObjectLists(
@ -418,13 +461,17 @@ public sealed class RetailPViewRenderer
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells,
InteriorEntityPartition.Result partition)
InteriorEntityPartition.Result partition,
ViewconeCuller viewcone)
{
// T1: per-cell STATIC object lists only (dat-baked 0x40 statics) —
// dynamics moved to DrawDynamicsLast. Far→near with the cells, after
// the shells (retail DrawCells epilogue: PortalList = cell's views →
// DrawObjCell, Ghidra 0x005a4840). Unclipped; per-view sphere culling
// (viewconeCheck) is T3.
// DrawObjCell, Ghidra 0x005a4840). T3 (BR-5): each static's sphere is
// tested against ITS CELL's views (retail viewconeCheck) — the
// statics-through-walls fix: a static whose sphere is outside every
// view of its cell no longer paints through the wall (the cottage
// phantom staircase's draw path).
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = pvFrame.OrderedVisibleCells[i];
@ -434,22 +481,51 @@ public sealed class RetailPViewRenderer
if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0)
continue;
_oneCell.Clear();
_oneCell.Add(cellId);
_cellStaticScratch.Clear();
foreach (var e in bucket)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInCell(cellId, c, r))
_cellStaticScratch.Add(e);
}
// BR-2 phantom-site probe: static buckets draw unclipped +
// un-viewcone'd until T3 — log the per-cell exposure.
// BR-2 phantom-site probe (T3-updated): post-viewcone survivors.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled)
EmitPhantomObjsProbe(cellId, bucket.Count);
EmitPhantomObjsProbe(cellId, _cellStaticScratch.Count);
UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, bucket, _oneCell);
if (_cellStaticScratch.Count > 0)
{
_oneCell.Clear();
_oneCell.Add(cellId);
UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, _cellStaticScratch, _oneCell);
}
// T3 (BR-5): particles gate through the SAME viewcone as their
// owners — the callback receives the cone-surviving entity set, so
// an emitter attached to a culled static no longer draws through
// the wall (the candle-flames-through-walls fix). Consumed
// synchronously within this iteration (scratch list reuse).
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, bucket));
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, _cellStaticScratch));
}
}
// T3 scratch lists (render thread only; cleared per use).
private readonly List<WorldEntity> _outdoorStaticScratch = new();
private readonly List<WorldEntity> _cellStaticScratch = new();
private readonly List<WorldEntity> _dynamicsScratch = new();
// Conservative bounding sphere from the entity's cached AABB — the same
// bounds source the dispatcher's frustum cull uses.
private static void EntitySphere(WorldEntity e, out Vector3 center, out float radius)
{
if (e.AabbDirty)
e.RefreshAabb();
center = (e.AabbMin + e.AabbMax) * 0.5f;
radius = (e.AabbMax - e.AabbMin).Length() * 0.5f;
}
// BR-2 phantom-site probe state: print-on-change per cell so the log stays
// diffable while the condition persists. Throwaway apparatus — strip when
// the #113 phantom residual closes. (The [phantom-shell] half died with