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

@ -7646,6 +7646,7 @@ public sealed class GameWindow : IDisposable
playerLb, playerLb,
animatedIds, animatedIds,
renderSky, renderSky,
renderWeather: playerSeenOutside,
kf, kf,
environOverrideActive), environOverrideActive),
// T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840). // T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840).
@ -7877,6 +7878,25 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Vfx.ParticleRenderPass.Scene); AcDream.Core.Vfx.ParticleRenderPass.Scene);
} }
} }
else if (clipRoot is { IsOutdoorNode: true }
&& _particleSystem is not null && _particleRenderer is not null)
{
// T3 (BR-5): unattached emitters (campfires, ground effects —
// AttachedObjectId == 0) under the OUTDOOR root. The unified
// path's attached emitters draw via the landscape slice + the
// per-cell callbacks; unattached ones had NO pass on
// outdoor-node frames (the unattached-particles-dropped-
// outdoors divergence, adjusted-confirmed). The outdoor root's
// outside view is full-screen (cone pass-all); depth test
// composites them against the world.
sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached";
_particleRenderer.Draw(
_particleSystem,
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
emitter => emitter.AttachedObjectId == 0);
}
// Bug A fix (post-#26 worktree, 2026-04-26): weather sky // Bug A fix (post-#26 worktree, 2026-04-26): weather sky
// Outdoor LScape post-scene weather. Indoor weather through an exit portal is // Outdoor LScape post-scene weather. Indoor weather through an exit portal is
@ -9480,6 +9500,7 @@ public sealed class GameWindow : IDisposable
uint? playerLb, uint? playerLb,
HashSet<uint>? animatedIds, HashSet<uint>? animatedIds,
bool renderSky, bool renderSky,
bool renderWeather,
AcDream.Core.World.SkyKeyframe kf, AcDream.Core.World.SkyKeyframe kf,
bool environOverrideActive) bool environOverrideActive)
{ {
@ -9510,6 +9531,11 @@ public sealed class GameWindow : IDisposable
_terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length; _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length;
MaybeFlushTerrainDiag(); MaybeFlushTerrainDiag();
// T3 (BR-5): entities draw OUTSIDE the clip bracket — retail meshes
// are viewcone-CHECKED, never hard-clipped (Ghidra 0x0054c250); the
// sphere pre-filter already ran in RetailPViewRenderer (OutdoorEntities
// is the per-slice survivor set).
DisableClipDistances();
if (sliceCtx.OutdoorEntities.Count > 0) if (sliceCtx.OutdoorEntities.Count > 0)
{ {
var sceneryEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero, var sceneryEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero,
@ -9539,8 +9565,13 @@ public sealed class GameWindow : IDisposable
&& _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)); && _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId));
} }
// T3 (BR-5): weather gates on the PLAYER being outside, not the viewer
// root — retail draws weather only when is_player_outside (the rain
// cylinder rides the player; an indoor player gets NO rain even while
// looking out a doorway). Closes the rain-through-doorways divergence
// (weather-gate-player-vs-viewer, adjusted-confirmed).
EnableClipDistances(); EnableClipDistances();
if (renderSky) if (renderSky && renderWeather)
{ {
_skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction,
_activeDayGroup, kf, environOverrideActive); _activeDayGroup, kf, environOverrideActive);
@ -9620,8 +9651,12 @@ public sealed class GameWindow : IDisposable
if (_visibleSceneParticleEntityIds.Count == 0) if (_visibleSceneParticleEntityIds.Count == 0)
return; return;
// T3 (BR-5): the scissor-AABB gate is DELETED — retail gates particles
// like meshes (viewcone on the owner; depth does the pixels). The
// CellEntities set is already the cone-surviving owner list, so the
// id-predicate below IS the cone gate; the punch/seal depth discipline
// composites the pixels.
DisableClipDistances(); DisableClipDistances();
bool scissor = BeginDoorwayScissor(true, sliceCtx.Slice.NdcAabb);
_particleRenderer.Draw( _particleRenderer.Draw(
_particleSystem, _particleSystem,
camera, camera,
@ -9629,8 +9664,6 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Vfx.ParticleRenderPass.Scene, AcDream.Core.Vfx.ParticleRenderPass.Scene,
emitter => emitter.AttachedObjectId != 0 emitter => emitter.AttachedObjectId != 0
&& _visibleSceneParticleEntityIds.Contains(emitter.AttachedObjectId)); && _visibleSceneParticleEntityIds.Contains(emitter.AttachedObjectId));
if (scissor)
_gl!.Disable(EnableCap.ScissorTest);
DisableClipDistances(); DisableClipDistances();
} }

View file

@ -104,12 +104,17 @@ public sealed class RetailPViewRenderer
// from the punch/seal depth writes + the z-buffer, and the dynamics- // 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 // last order is what makes the punch safe (the first BR-2 attempt
// punched after dynamics and erased the player, reverted 88be519). // 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(); UseIndoorMembershipOnlyRouting();
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
DrawEnvCellShells(pvFrame); DrawEnvCellShells(pvFrame);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition); DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone);
DrawDynamicsLast(ctx, partition); DrawDynamicsLast(ctx, partition, viewcone);
return result; return result;
} }
@ -228,9 +233,10 @@ public sealed class RetailPViewRenderer
// drawn here: they belong exclusively to the frame's single last // drawn here: they belong exclusively to the frame's single last
// entity pass (the outdoor root's DrawDynamicsLast), which prevents // entity pass (the outdoor root's DrawDynamicsLast), which prevents
// double-draws of entities inside looked-into buildings. // double-draws of entities inside looked-into buildings.
var viewcone = ViewconeCuller.Build(clipAssembly, ctx.ViewProjection);
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
DrawEnvCellShells(pvFrame); DrawEnvCellShells(pvFrame);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition); DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone);
RestoreNoClip(ctx.SetTerrainClipUbo); RestoreNoClip(ctx.SetTerrainClipUbo);
return result; return result;
@ -239,7 +245,8 @@ public sealed class RetailPViewRenderer
private void DrawLandscapeThroughOutsideView( private void DrawLandscapeThroughOutsideView(
RetailPViewDrawContext ctx, RetailPViewDrawContext ctx,
ClipFrameAssembly clipAssembly, ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result partition) InteriorEntityPartition.Result partition,
ViewconeCuller viewcone)
{ {
if (clipAssembly.OutsideViewSlices.Length == 0) if (clipAssembly.OutsideViewSlices.Length == 0)
return; return;
@ -249,11 +256,24 @@ public sealed class RetailPViewRenderer
{ {
_clipFrame.SetTerrainClip(slice.Planes); _clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo); 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) if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled)
EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex); 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++; 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 // 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 — // (retail draws objects per cell AFTER cells and viewcone-culls them —
// PView::DrawCells epilogue Ghidra 0x005a4840; the sphere-vs-view cull is // PView::DrawCells epilogue Ghidra 0x005a4840; the sphere-vs-view cull is
// T3). Drawing dynamics last is what makes the aperture punch safe. // 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( private void DrawDynamicsLast(
IRetailPViewCellDrawContext ctx, IRetailPViewCellDrawContext ctx,
InteriorEntityPartition.Result partition) InteriorEntityPartition.Result partition,
ViewconeCuller viewcone)
{ {
if (partition.Dynamics.Count == 0) if (partition.Dynamics.Count == 0)
return; 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(); UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, partition.Dynamics, visibleCellIds: null); DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null);
} }
private void DrawCellObjectLists( private void DrawCellObjectLists(
@ -418,13 +461,17 @@ public sealed class RetailPViewRenderer
PortalVisibilityFrame pvFrame, PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly, ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells, HashSet<uint> drawableCells,
InteriorEntityPartition.Result partition) InteriorEntityPartition.Result partition,
ViewconeCuller viewcone)
{ {
// T1: per-cell STATIC object lists only (dat-baked 0x40 statics) — // T1: per-cell STATIC object lists only (dat-baked 0x40 statics) —
// dynamics moved to DrawDynamicsLast. Far→near with the cells, after // dynamics moved to DrawDynamicsLast. Far→near with the cells, after
// the shells (retail DrawCells epilogue: PortalList = cell's views → // the shells (retail DrawCells epilogue: PortalList = cell's views →
// DrawObjCell, Ghidra 0x005a4840). Unclipped; per-view sphere culling // DrawObjCell, Ghidra 0x005a4840). T3 (BR-5): each static's sphere is
// (viewconeCheck) is T3. // 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--) for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{ {
uint cellId = pvFrame.OrderedVisibleCells[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) if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0)
continue; continue;
_oneCell.Clear(); _cellStaticScratch.Clear();
_oneCell.Add(cellId); 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 + // BR-2 phantom-site probe (T3-updated): post-viewcone survivors.
// un-viewcone'd until T3 — log the per-cell exposure.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled) if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled)
EmitPhantomObjsProbe(cellId, bucket.Count); EmitPhantomObjsProbe(cellId, _cellStaticScratch.Count);
UseIndoorMembershipOnlyRouting(); if (_cellStaticScratch.Count > 0)
DrawEntityBucket(ctx, bucket, _oneCell); {
_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)) 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 // BR-2 phantom-site probe state: print-on-change per cell so the log stays
// diffable while the condition persists. Throwaway apparatus — strip when // diffable while the condition persists. Throwaway apparatus — strip when
// the #113 phantom residual closes. (The [phantom-shell] half died with // the #113 phantom residual closes. (The [phantom-shell] half died with

View file

@ -0,0 +1,135 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// T3 (BR-5): the port of retail's <c>Render::viewconeCheck</c> (Ghidra
/// 0x0054c250) — meshes (characters, statics, emitters) are CULLED per portal
/// view by a bounding-sphere test against the view's edge planes, never
/// clipped. Retail stores each view vertex with its 3D eye-edge plane
/// (<c>view_vertex { Vec2D pt; Plane plane }</c>, acclient.h:32483) and tests
/// the object's drawing sphere against the installed view's plane set;
/// OUTSIDE → skipped (RenderDeviceD3D::DrawMesh per-view loop pc:429290-429310,
/// and the DrawCells per-cell object epilogue, Ghidra 0x005a4840).
///
/// <para>Our views are clip-space half-planes (≤8 per slice,
/// <see cref="ClipPlaneSet"/> output: (nx,ny,0,d) satisfied when
/// nx·Cx + ny·Cy + d·Cw ≥ 0 for clip-space C). Lifting one to world space —
/// the view_vertex.plane analog, a plane through the EYE and the view edge —
/// is one matrix fold: with row-vector convention (System.Numerics),
/// C = world·VP, so C·P = world·(VP·P); L = VP·P (rows of VP dotted with P)
/// is the world-space homogeneous half-plane. Sphere-vs-half-plane keeps the
/// sphere when L.xyz·c + L.w ≥ r·|L.xyz| (not entirely outside).</para>
///
/// <para>A sphere is visible through a SLICE when it is not entirely outside
/// any of the slice's planes (convex region); visible for a CELL when any of
/// the cell's slices passes. A slice with zero planes is pass-all (the
/// NoClipSlice / full-screen outdoor case). A cell with no views culls — in
/// retail an object whose cell is not in the draw list is simply never
/// reached.</para>
/// </summary>
public sealed class ViewconeCuller
{
private readonly Dictionary<uint, Vector4[][]> _cellPlanes = new();
private Vector4[][] _outsidePlanes = Array.Empty<Vector4[]>();
/// <summary>True when the outside view is a full-screen pass-all (the
/// synthetic outdoor root) — every outside-test passes.</summary>
public bool OutsideIsFullScreen { get; private set; }
public static ViewconeCuller Build(ClipFrameAssembly assembly, in Matrix4x4 viewProjection)
{
ArgumentNullException.ThrowIfNull(assembly);
var culler = new ViewconeCuller();
foreach (var (cellId, slices) in assembly.CellIdToViewSlices)
{
var lifted = new Vector4[slices.Length][];
for (int s = 0; s < slices.Length; s++)
lifted[s] = LiftPlanes(slices[s].Planes, viewProjection);
culler._cellPlanes[cellId] = lifted;
}
var outside = assembly.OutsideViewSlices;
var outsideLifted = new Vector4[outside.Length][];
bool fullScreen = false;
for (int s = 0; s < outside.Length; s++)
{
outsideLifted[s] = LiftPlanes(outside[s].Planes, viewProjection);
if (outside[s].Planes.Length == 0)
fullScreen = true;
}
culler._outsidePlanes = outsideLifted;
culler.OutsideIsFullScreen = fullScreen;
return culler;
}
private static Vector4[] LiftPlanes(Vector4[] clipPlanes, in Matrix4x4 m)
{
if (clipPlanes.Length == 0)
return Array.Empty<Vector4>();
var world = new Vector4[clipPlanes.Length];
for (int i = 0; i < clipPlanes.Length; i++)
{
var p = clipPlanes[i];
world[i] = new Vector4(
m.M11 * p.X + m.M12 * p.Y + m.M13 * p.Z + m.M14 * p.W,
m.M21 * p.X + m.M22 * p.Y + m.M23 * p.Z + m.M24 * p.W,
m.M31 * p.X + m.M32 * p.Y + m.M33 * p.Z + m.M34 * p.W,
m.M41 * p.X + m.M42 * p.Y + m.M43 * p.Z + m.M44 * p.W);
}
return world;
}
private static bool SphereInsidePlanes(Vector4[] planes, in Vector3 center, float radius)
{
for (int i = 0; i < planes.Length; i++)
{
var l = planes[i];
float nLen = MathF.Sqrt(l.X * l.X + l.Y * l.Y + l.Z * l.Z);
if (nLen < 1e-12f)
continue; // degenerate plane — no constraint
float dist = l.X * center.X + l.Y * center.Y + l.Z * center.Z + l.W;
if (dist < -radius * nLen)
return false; // entirely outside this edge plane
}
return true;
}
/// <summary>Sphere-vs-the-cell's-views: visible when any slice passes.
/// A cell with no views culls (not in the draw list ⇒ never reached in
/// retail). A zero-plane slice is pass-all.</summary>
public bool SphereVisibleInCell(uint cellId, in Vector3 center, float radius)
{
if (!_cellPlanes.TryGetValue(cellId, out var slices))
return false;
for (int s = 0; s < slices.Length; s++)
if (SphereInsidePlanes(slices[s], center, radius))
return true;
return false;
}
/// <summary>Sphere-vs-the-outside-views (objects in outdoor space seen
/// from an interior root through doorways; pass-all under the outdoor
/// root's full-screen outside view).</summary>
public bool SphereVisibleOutside(in Vector3 center, float radius)
{
if (OutsideIsFullScreen)
return true;
for (int s = 0; s < _outsidePlanes.Length; s++)
if (SphereInsidePlanes(_outsidePlanes[s], center, radius))
return true;
return false;
}
/// <summary>Sphere vs ONE outside slice (the landscape pass draws per
/// slice; its statics pre-filter tests against exactly that slice).</summary>
public bool SphereVisibleInOutsideSlice(int sliceIndex, in Vector3 center, float radius)
{
if ((uint)sliceIndex >= (uint)_outsidePlanes.Length)
return false;
return SphereInsidePlanes(_outsidePlanes[sliceIndex], center, radius);
}
}