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:
parent
88f3ce1fa0
commit
a6aec8c32f
3 changed files with 268 additions and 24 deletions
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
135
src/AcDream.App/Rendering/ViewconeCuller.cs
Normal file
135
src/AcDream.App/Rendering/ViewconeCuller.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue