feat(render): indoor render WORKS — terminating portal flood + every-cell seal + look-in FPS
Checkpoint of the unified retail-faithful indoor render. The two-week HANG/grey is fixed and the interior seals (live-verified by the user). Commits the session render-rewrite foundation together with the fixes that made it functional. - HANG fix: PortalVisibilityBuilder.Build portal flood did not terminate (the faithful ProjectToClip near-side clip drifts per round, defeating the CellView dedup; the BFS had no bound after U.2a removed MaxReprocessPerCell). Fix = drift-tolerant snapped/canonical CellView.Add dedup (PortalView.cs) plus restored MaxReprocessPerCell=16 bounded re-enqueue (PortalVisibilityBuilder.cs). Re-enqueue is kept (load-bearing for late-slice propagation, Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit); only its count is capped. CellViewDedupTests added. - Seal (DrawCells Task 2): RetailPViewRenderer.DrawEnvCellShells draws EVERY visible cell via IndoorDrawPlan.ShellPass (was gated on the ClipFrameAssembler slot filter, leaving slot-less cells grey). - Look-in FPS: GameWindow exterior look-in candidates limited to the player landblock +-1 (was all ~81 loaded LBs iterated every outdoor frame). No behaviour change (far cells were >48m, already culled). Remaining dominant issue = the FLAP at transitions: viewer-cell metastability (render roots at the camera-eye cell, which oscillates outdoor-indoor as the 3rd-person boom drifts across the doorway, confirmed in render-sig). SEPARATE fix, NOT the DrawCells port. Full handoff + flap fix plan + tracked follow-ups (#78 terrain, look-in-from-inside, look-in FPS, L-spotlight): docs/research/2026-06-07-indoor-render-session-handoff.md. Baselines: build 0 err; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bff1955066
commit
1405dd8e90
27 changed files with 3635 additions and 814 deletions
|
|
@ -1,251 +1,224 @@
|
|||
// ClipFrameAssembler.cs
|
||||
//
|
||||
// Phase U.4: assemble a per-frame ClipFrame (the GPU-side shared clip data) +
|
||||
// a cellId→slot map from a PortalVisibilityFrame. This is the CPU policy that
|
||||
// turns the portal-visibility BFS result into the slot indices the mesh shader
|
||||
// (binding=2 CellClip + binding=3 per-instance slot) and the terrain UBO read.
|
||||
// Retail PView assembly policy. PortalVisibilityBuilder produces a retail-like
|
||||
// view graph: one portal_view list per visible cell plus an outside_view list.
|
||||
// This assembler packs each visible polygon as an individual GPU clip slot so
|
||||
// the renderer can draw the exact PView order:
|
||||
//
|
||||
// GL-free: ClipFrame's CPU byte-packing (AppendSlot / SetTerrainClip) runs here;
|
||||
// the GL upload (ClipFrame.UploadShared) happens at the call site. That keeps the
|
||||
// whole slot/gate policy unit-testable without a GPU context — see
|
||||
// ClipFrameAssemblerTests.
|
||||
// outside_view landscape slices
|
||||
// reverse cell_draw_list exit masks
|
||||
// reverse cell_draw_list EnvCell shells
|
||||
// reverse cell_draw_list object lists
|
||||
//
|
||||
// === The slot/gate policy (implemented EXACTLY as the U.4 spec dictates) ======
|
||||
// slot 0 = no-clip (count 0). ALWAYS present (ClipFrame.NoClip seeds it).
|
||||
//
|
||||
// Per visible interior cell (PortalVisibilityFrame.OrderedVisibleCells):
|
||||
// ClipPlaneSet.From(CellView) has THREE Count==0 meanings (see ClipPlaneSet):
|
||||
// • IsNothingVisible ⇒ DO NOT map the cell. Its instances/shell won't draw
|
||||
// (the cull is deliberate — retail culls it too).
|
||||
// • Count > 0 ⇒ append a real planes slot; cellIdToSlot[cell] = slot.
|
||||
// • UseScissorFallback⇒ cellIdToSlot[cell] = 0 (no-clip / over-include).
|
||||
// Per-cell glScissor would break MDI batching, and
|
||||
// over-inclusion is the SAFE direction; counted in
|
||||
// ScissorFallbacks for the probe.
|
||||
//
|
||||
// OutsideView feeds TWO consumers:
|
||||
// • mesh "outdoor slot" (outdoor scenery / building shells drawn while the
|
||||
// camera is indoors): Count>0 ⇒ planes slot (OutdoorSlot); scissor ⇒ slot 0
|
||||
// (no-clip, counted); IsNothingVisible ⇒ OutdoorVisible=false (CULL these
|
||||
// instances — the camera can't see outdoors through any portal chain).
|
||||
// • terrain UBO: Count>0 ⇒ SetTerrainClip(planes); scissor ⇒ TerrainScissor
|
||||
// (the call site sets glScissor around ONLY the terrain draw) + UBO count 0;
|
||||
// IsNothingVisible ⇒ SKIP the terrain draw entirely (THIS is the bleed fix).
|
||||
//
|
||||
// Outdoor root (pvFrame == null) is handled by the caller, not here: terrain
|
||||
// draws normally (UBO count 0, no scissor), every instance is slot 0. The caller
|
||||
// only invokes Assemble when there IS an indoor root.
|
||||
// Slot 0 is always no-clip. A slice whose polygon cannot be represented by the
|
||||
// <=8 plane budget uses slot 0 and its NDC AABB; the renderer uses scissor for
|
||||
// passes that need that fallback. Empty regions are omitted entirely.
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// How the terrain (single OutsideView region) should be drawn this frame.
|
||||
/// How the landscape-through-outside_view pass should be interpreted.
|
||||
/// </summary>
|
||||
public enum TerrainClipMode
|
||||
{
|
||||
/// <summary>OutsideView reduced to convex planes — terrain gated via the UBO
|
||||
/// (<see cref="ClipFrame.SetTerrainClip"/> already applied by the assembler).</summary>
|
||||
/// <summary>All outside_view slices have convex plane clips.</summary>
|
||||
Planes,
|
||||
|
||||
/// <summary>OutsideView exceeded the convex budget — the call site sets a
|
||||
/// glScissor to <see cref="ClipFrameAssembly.TerrainScissorNdcAabb"/> around ONLY
|
||||
/// the terrain draw; the UBO is left at count 0 (ungated).</summary>
|
||||
/// <summary>At least one outside_view slice requires scissor fallback.</summary>
|
||||
Scissor,
|
||||
|
||||
/// <summary>OutsideView is empty (no exit portal visible through any chain) —
|
||||
/// the call site SKIPS the terrain draw entirely. This is the bleed fix: an
|
||||
/// interior with no view outdoors draws no terrain.</summary>
|
||||
/// <summary>No outside_view slice is visible; skip landscape indoors.</summary>
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: the populated
|
||||
/// <see cref="ClipFrame"/> (CPU bytes ready; caller does <c>UploadShared</c>) plus
|
||||
/// the per-instance routing data the renderers + the terrain draw consume.
|
||||
/// One retail portal_view slice mapped to a GPU clip slot. The AABB is retained
|
||||
/// for passes that cannot write gl_ClipDistance and must use scissor.
|
||||
/// </summary>
|
||||
public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes);
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: populated clip buffers
|
||||
/// plus routing data consumed by the render orchestration.
|
||||
/// </summary>
|
||||
public sealed class ClipFrameAssembly
|
||||
{
|
||||
/// <summary>The per-frame clip data. Caller uploads it via
|
||||
/// <see cref="ClipFrame.UploadShared"/> then hands its
|
||||
/// <see cref="ClipFrame.RegionSsbo"/> / <see cref="ClipFrame.TerrainUbo"/> to the
|
||||
/// renderers.</summary>
|
||||
public required ClipFrame Frame { get; init; }
|
||||
|
||||
/// <summary>Maps a visible cell id to its CellClip slot index. A cell that is
|
||||
/// NOT a key (IsNothingVisible, or never visible) must NOT be drawn — its mesh
|
||||
/// instances / shell are culled. A scissor-fallback cell maps to slot 0.</summary>
|
||||
/// <summary>First drawable slice slot per visible cell. Compatibility map
|
||||
/// for renderer APIs that can accept only one slot at a time.</summary>
|
||||
public required Dictionary<uint, int> CellIdToSlot { get; init; }
|
||||
|
||||
/// <summary>Slot for outdoor scenery / building-shell instances (ParentCellId
|
||||
/// == null) while the camera is indoors. Meaningful only when
|
||||
/// <see cref="OutdoorVisible"/> is true. 0 ⇒ no-clip (scissor fallback or trivial).</summary>
|
||||
/// <summary>Slot-only cell slices, retained for older renderer APIs.</summary>
|
||||
public required Dictionary<uint, int[]> CellIdToViewSlots { get; init; }
|
||||
|
||||
/// <summary>Full retail portal_view slices per visible cell.</summary>
|
||||
public required Dictionary<uint, ClipViewSlice[]> CellIdToViewSlices { get; init; }
|
||||
|
||||
/// <summary>Full retail outside_view slices.</summary>
|
||||
public required ClipViewSlice[] OutsideViewSlices { get; init; }
|
||||
|
||||
public required int OutdoorSlot { get; init; }
|
||||
|
||||
/// <summary>False ⇒ the OutsideView is empty; outdoor scenery / shells are
|
||||
/// CULLED this frame (camera sees no outdoors through any portal chain).</summary>
|
||||
public required bool OutdoorVisible { get; init; }
|
||||
|
||||
/// <summary>How to draw terrain (planes already applied to the UBO / scissor /
|
||||
/// skip). See <see cref="TerrainClipMode"/>.</summary>
|
||||
public required TerrainClipMode TerrainMode { get; init; }
|
||||
|
||||
/// <summary>NDC AABB (minX,minY,maxX,maxY) for the terrain glScissor when
|
||||
/// <see cref="TerrainMode"/> is <see cref="TerrainClipMode.Scissor"/>. Unused otherwise.</summary>
|
||||
public required Vector4 TerrainScissorNdcAabb { get; init; }
|
||||
|
||||
/// <summary>True ⇒ the OutsideView (the exit-portal screen region) is meaningfully visible this
|
||||
/// frame — the camera can see outdoors through a portal chain (<see cref="TerrainMode"/> is
|
||||
/// <see cref="TerrainClipMode.Planes"/> or <see cref="TerrainClipMode.Scissor"/>). False ⇒ a
|
||||
/// sealed interior with no exit portal in view (<see cref="TerrainClipMode.Skip"/>). Drives the
|
||||
/// Stage 4 sky/weather draw + the conditional doorway Z-clear. Always false on the outdoor root
|
||||
/// (the caller does not invoke <see cref="ClipFrameAssembler.Assemble"/> there).</summary>
|
||||
public required bool HasOutsideView { get; init; }
|
||||
|
||||
/// <summary>NDC AABB (minX,minY,maxX,maxY) of the OutsideView screen region — the doorway
|
||||
/// opening's bounding box. Computed whenever <see cref="HasOutsideView"/> is true, for BOTH the
|
||||
/// Planes and Scissor terrain modes (unlike <see cref="TerrainScissorNdcAabb"/>, which is valid
|
||||
/// only in Scissor mode). Stage 4 scissors the conditional doorway depth-only Z-clear (retail
|
||||
/// PView::DrawCells:432731) and the sky/weather particle passes to this region. Degenerate
|
||||
/// (<see cref="Vector4.Zero"/>) when <see cref="HasOutsideView"/> is false.</summary>
|
||||
public required Vector4 OutsideViewNdcAabb { get; init; }
|
||||
|
||||
// ---- Probe data (ACDREAM_PROBE_VIS / RenderingDiagnostics.EmitVis) --------
|
||||
|
||||
/// <summary>Plane count the OutsideView reduced to (0 ⇒ scissor or empty).</summary>
|
||||
// Probe data.
|
||||
public required int OutsidePlaneCount { get; init; }
|
||||
|
||||
/// <summary>Per-cell clip-plane count (cell id → plane count) for the probe.
|
||||
/// A scissor-fallback cell records 0 here (it maps to slot 0).</summary>
|
||||
public required Dictionary<uint, int> PerCellPlaneCounts { get; init; }
|
||||
|
||||
/// <summary>Number of regions (cells + OutsideView) that fell back to a scissor
|
||||
/// AABB → no-clip this frame.</summary>
|
||||
public required int ScissorFallbacks { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="ClipFrameAssembly"/> from a <see cref="PortalVisibilityFrame"/>.
|
||||
/// Pure CPU; no GL. The single entry point <see cref="Assemble"/> implements the U.4
|
||||
/// slot/gate policy (file header).
|
||||
/// </summary>
|
||||
public static class ClipFrameAssembler
|
||||
{
|
||||
/// <summary>
|
||||
/// Assemble the per-frame clip data + routing from a portal-visibility frame
|
||||
/// INTO an existing <see cref="ClipFrame"/> — the long-lived GameWindow frame is
|
||||
/// <see cref="ClipFrame.Reset"/>-and-repacked here every frame so its GL buffers
|
||||
/// are reused (no per-frame buffer churn). The returned assembly's
|
||||
/// <see cref="ClipFrameAssembly.Frame"/> is the same instance passed in.
|
||||
/// </summary>
|
||||
public static ClipFrameAssembly Assemble(ClipFrame frame, PortalVisibilityFrame pvFrame)
|
||||
{
|
||||
System.ArgumentNullException.ThrowIfNull(frame);
|
||||
System.ArgumentNullException.ThrowIfNull(pvFrame);
|
||||
|
||||
frame.Reset(); // slot 0 = no-clip
|
||||
frame.Reset();
|
||||
|
||||
var cellIdToSlot = new Dictionary<uint, int>();
|
||||
var cellIdToViewSlots = new Dictionary<uint, int[]>();
|
||||
var cellIdToViewSlices = new Dictionary<uint, ClipViewSlice[]>();
|
||||
var perCellPlaneCounts = new Dictionary<uint, int>();
|
||||
int scissorFallbacks = 0;
|
||||
|
||||
// ── Interior cells ───────────────────────────────────────────────────
|
||||
foreach (uint cellId in pvFrame.OrderedVisibleCells)
|
||||
{
|
||||
if (!pvFrame.CellViews.TryGetValue(cellId, out var view))
|
||||
continue; // defensive — OrderedVisibleCells is derived from CellViews
|
||||
|
||||
var cps = ClipPlaneSet.From(view);
|
||||
|
||||
if (cps.IsNothingVisible)
|
||||
{
|
||||
// Cell culled — do NOT map it; its instances/shell won't draw.
|
||||
continue;
|
||||
|
||||
var slices = new List<ClipViewSlice>(view.Polygons.Count);
|
||||
int maxPlaneCount = 0;
|
||||
|
||||
foreach (var poly in view.Polygons)
|
||||
{
|
||||
var cps = ClipPlaneSet.From(ViewOf(poly));
|
||||
if (cps.IsNothingVisible)
|
||||
continue;
|
||||
|
||||
int slot;
|
||||
Vector4[] planes;
|
||||
if (cps.Count > 0)
|
||||
{
|
||||
planes = ToPlaneSpan(cps);
|
||||
slot = frame.AppendSlot(planes);
|
||||
if (cps.Count > maxPlaneCount)
|
||||
maxPlaneCount = cps.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
planes = System.Array.Empty<Vector4>();
|
||||
slot = 0;
|
||||
scissorFallbacks++;
|
||||
}
|
||||
|
||||
slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
|
||||
}
|
||||
|
||||
if (slices.Count == 0)
|
||||
continue;
|
||||
|
||||
var sliceArray = slices.ToArray();
|
||||
cellIdToViewSlices[cellId] = sliceArray;
|
||||
cellIdToViewSlots[cellId] = ToSlots(sliceArray);
|
||||
cellIdToSlot[cellId] = sliceArray[0].Slot;
|
||||
perCellPlaneCounts[cellId] = maxPlaneCount;
|
||||
}
|
||||
|
||||
var outsideSlicesList = new List<ClipViewSlice>(pvFrame.OutsideView.Polygons.Count);
|
||||
int outsideMaxPlaneCount = 0;
|
||||
bool outsideHasScissorFallback = false;
|
||||
|
||||
foreach (var poly in pvFrame.OutsideView.Polygons)
|
||||
{
|
||||
var cps = ClipPlaneSet.From(ViewOf(poly));
|
||||
if (cps.IsNothingVisible)
|
||||
continue;
|
||||
|
||||
int slot;
|
||||
Vector4[] planes;
|
||||
if (cps.Count > 0)
|
||||
{
|
||||
int slot = frame.AppendSlot(cps);
|
||||
cellIdToSlot[cellId] = slot;
|
||||
perCellPlaneCounts[cellId] = cps.Count;
|
||||
planes = ToPlaneSpan(cps);
|
||||
slot = frame.AppendSlot(planes);
|
||||
if (cps.Count > outsideMaxPlaneCount)
|
||||
outsideMaxPlaneCount = cps.Count;
|
||||
}
|
||||
else // UseScissorFallback (Count == 0, not nothing-visible)
|
||||
else
|
||||
{
|
||||
// Over-include via no-clip (slot 0). Per-cell glScissor would break
|
||||
// MDI batching; over-inclusion is the safe direction for M1.5.
|
||||
cellIdToSlot[cellId] = 0;
|
||||
perCellPlaneCounts[cellId] = 0;
|
||||
planes = System.Array.Empty<Vector4>();
|
||||
slot = 0;
|
||||
outsideHasScissorFallback = true;
|
||||
scissorFallbacks++;
|
||||
}
|
||||
|
||||
outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
|
||||
}
|
||||
|
||||
// ── OutsideView ──────────────────────────────────────────────────────
|
||||
var ov = ClipPlaneSet.From(pvFrame.OutsideView);
|
||||
var outsideViewSlices = outsideSlicesList.ToArray();
|
||||
bool outdoorVisible = outsideViewSlices.Length > 0;
|
||||
int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0;
|
||||
TerrainClipMode terrainMode = !outdoorVisible
|
||||
? TerrainClipMode.Skip
|
||||
: (outsideHasScissorFallback ? TerrainClipMode.Scissor : TerrainClipMode.Planes);
|
||||
|
||||
int outdoorSlot;
|
||||
bool outdoorVisible;
|
||||
TerrainClipMode terrainMode;
|
||||
Vector4 terrainScissor = Vector4.Zero;
|
||||
|
||||
if (ov.IsNothingVisible)
|
||||
{
|
||||
// No outdoors visible through any portal chain.
|
||||
outdoorSlot = 0;
|
||||
outdoorVisible = false; // mesh: CULL outdoor scenery / shells.
|
||||
terrainMode = TerrainClipMode.Skip; // terrain: the bleed fix.
|
||||
}
|
||||
else if (ov.Count > 0)
|
||||
{
|
||||
// Convex planes — gate both the outdoor mesh slot and the terrain UBO.
|
||||
outdoorSlot = frame.AppendSlot(ov);
|
||||
outdoorVisible = true;
|
||||
frame.SetTerrainClip(ToPlaneSpan(ov));
|
||||
terrainMode = TerrainClipMode.Planes;
|
||||
}
|
||||
else // UseScissorFallback
|
||||
{
|
||||
// Mesh: no-clip over-include (slot 0), still visible. Terrain: scissor
|
||||
// around the single terrain batch + UBO ungated (count 0 left as-is).
|
||||
outdoorSlot = 0;
|
||||
outdoorVisible = true;
|
||||
terrainMode = TerrainClipMode.Scissor;
|
||||
terrainScissor = ov.ScissorNdcAabb;
|
||||
scissorFallbacks++;
|
||||
}
|
||||
|
||||
// Stage 4: the doorway screen-space AABB (the OutsideView union bounds), available for
|
||||
// BOTH Planes and Scissor modes — the sky/weather particle scissor + the conditional
|
||||
// doorway Z-clear need it regardless of how the OutsideView reduced to a gate.
|
||||
// TerrainScissorNdcAabb above is only valid in Scissor mode; the OutsideView CellView
|
||||
// always tracks its Min/Max as polygons accumulate, so it is the single source here.
|
||||
bool hasOutsideView = terrainMode != TerrainClipMode.Skip;
|
||||
Vector4 outsideViewNdcAabb = (hasOutsideView && !pvFrame.OutsideView.IsEmpty)
|
||||
Vector4 outsideViewNdcAabb = outdoorVisible
|
||||
? new Vector4(pvFrame.OutsideView.MinX, pvFrame.OutsideView.MinY,
|
||||
pvFrame.OutsideView.MaxX, pvFrame.OutsideView.MaxY)
|
||||
: Vector4.Zero;
|
||||
Vector4 terrainScissor = terrainMode == TerrainClipMode.Scissor
|
||||
? outsideViewNdcAabb
|
||||
: Vector4.Zero;
|
||||
|
||||
return new ClipFrameAssembly
|
||||
{
|
||||
Frame = frame,
|
||||
CellIdToSlot = cellIdToSlot,
|
||||
CellIdToViewSlots = cellIdToViewSlots,
|
||||
CellIdToViewSlices = cellIdToViewSlices,
|
||||
OutsideViewSlices = outsideViewSlices,
|
||||
OutdoorSlot = outdoorSlot,
|
||||
OutdoorVisible = outdoorVisible,
|
||||
TerrainMode = terrainMode,
|
||||
TerrainScissorNdcAabb = terrainScissor,
|
||||
HasOutsideView = hasOutsideView,
|
||||
HasOutsideView = outdoorVisible,
|
||||
OutsideViewNdcAabb = outsideViewNdcAabb,
|
||||
OutsidePlaneCount = ov.Count,
|
||||
OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0,
|
||||
PerCellPlaneCounts = perCellPlaneCounts,
|
||||
ScissorFallbacks = scissorFallbacks,
|
||||
};
|
||||
}
|
||||
|
||||
// Copy a ClipPlaneSet's planes into a heap array for SetTerrainClip's span
|
||||
// parameter (the set exposes IReadOnlyList, not a contiguous span).
|
||||
private static CellView ViewOf(ViewPolygon poly)
|
||||
{
|
||||
var view = new CellView();
|
||||
view.Add(poly);
|
||||
return view;
|
||||
}
|
||||
|
||||
private static Vector4 AabbOf(ViewPolygon poly) =>
|
||||
new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY);
|
||||
|
||||
private static int[] ToSlots(ClipViewSlice[] slices)
|
||||
{
|
||||
var slots = new int[slices.Length];
|
||||
for (int i = 0; i < slices.Length; i++)
|
||||
slots[i] = slices[i].Slot;
|
||||
return slots;
|
||||
}
|
||||
|
||||
private static Vector4[] ToPlaneSpan(ClipPlaneSet set)
|
||||
{
|
||||
int n = set.Count;
|
||||
var planes = new Vector4[n];
|
||||
for (int i = 0; i < n; i++) planes[i] = set.Planes[i];
|
||||
for (int i = 0; i < n; i++)
|
||||
planes[i] = set.Planes[i];
|
||||
return planes;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ public readonly struct ClipPlaneSet
|
|||
// or point — zero screen coverage ⇒ nothing visible. A real portal opening has area far
|
||||
// above this (e.g. the sliver-clip test region is 0.4); only an edge-on projection gets here.
|
||||
private const float MinPolygonArea = 1e-7f;
|
||||
|
||||
private readonly Vector4[] _planes;
|
||||
|
||||
private ClipPlaneSet(Vector4[] planes, bool useScissorFallback, bool isNothingVisible, Vector4 scissorNdcAabb)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,18 +5,10 @@ using AcDream.Core.World;
|
|||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Splits a frame's landblock entities into the three draw buckets the per-cell
|
||||
/// <see cref="InteriorRenderer"/> needs, using the SAME precedence as
|
||||
/// <see cref="Wb.WbDrawDispatcher.ResolveEntitySlot"/>:
|
||||
/// <list type="number">
|
||||
/// <item><b>ServerGuid != 0</b> (player / NPCs / items / doors) ⇒ <see cref="Result.LiveDynamic"/>
|
||||
/// — drawn unclipped (depth only). These have no <c>ParentCellId</c> so they MUST be tested first.</item>
|
||||
/// <item><b>ParentCellId</b> in the visible set ⇒ <see cref="Result.ByCell"/>[cell] — per-cell, portal-clipped.</item>
|
||||
/// <item><b>ParentCellId == null</b> (outdoor scenery / building shell) ⇒ <see cref="Result.Outdoor"/>
|
||||
/// — drawn through the doorway, clipped to OutsideView.</item>
|
||||
/// </list>
|
||||
/// A static whose <c>ParentCellId</c> is NOT in <paramref name="visibleCells"/> is dropped (its cell
|
||||
/// isn't drawn this frame). Entities with no <c>MeshRefs</c> are skipped. Pure; GL-free; unit-tested.
|
||||
/// Splits a frame's landblock entities into the draw buckets used by the
|
||||
/// retail-style DrawInside flood. Indoor ownership wins for live dynamics too:
|
||||
/// a player, NPC, door, or item with a current indoor ParentCellId belongs to
|
||||
/// that cell's portal-clipped object list, not a global overlay pass.
|
||||
/// </summary>
|
||||
public static class InteriorEntityPartition
|
||||
{
|
||||
|
|
@ -40,18 +32,18 @@ public static class InteriorEntityPartition
|
|||
{
|
||||
if (e.MeshRefs.Count == 0) continue;
|
||||
|
||||
if (e.ServerGuid != 0) // live-dynamic — precedence first (no ParentCellId)
|
||||
if (e.ServerGuid != 0)
|
||||
{
|
||||
result.LiveDynamic.Add(e);
|
||||
if (e.ParentCellId is uint liveCell)
|
||||
AddByCellOrOutdoor(e, liveCell, visibleCells, result);
|
||||
else
|
||||
result.LiveDynamic.Add(e);
|
||||
}
|
||||
else if (e.ParentCellId is uint cell)
|
||||
{
|
||||
if (!visibleCells.Contains(cell)) continue; // its cell isn't drawn this frame
|
||||
if (!result.ByCell.TryGetValue(cell, out var list))
|
||||
result.ByCell[cell] = list = new List<WorldEntity>();
|
||||
list.Add(e);
|
||||
AddByCellOrOutdoor(e, cell, visibleCells, result);
|
||||
}
|
||||
else // outdoor scenery / building shell
|
||||
else
|
||||
{
|
||||
result.Outdoor.Add(e);
|
||||
}
|
||||
|
|
@ -59,4 +51,30 @@ public static class InteriorEntityPartition
|
|||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AddByCellOrOutdoor(
|
||||
WorldEntity entity,
|
||||
uint cellId,
|
||||
HashSet<uint> visibleCells,
|
||||
Result result)
|
||||
{
|
||||
if (!IsIndoorCellId(cellId))
|
||||
{
|
||||
result.Outdoor.Add(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!visibleCells.Contains(cellId))
|
||||
return;
|
||||
|
||||
if (!result.ByCell.TryGetValue(cellId, out var list))
|
||||
result.ByCell[cellId] = list = new List<WorldEntity>();
|
||||
list.Add(entity);
|
||||
}
|
||||
|
||||
private static bool IsIndoorCellId(uint cellId)
|
||||
{
|
||||
uint low = cellId & 0xFFFFu;
|
||||
return low >= 0x0100u && low != 0xFFFFu;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ public sealed class InteriorRenderContext
|
|||
/// membership filter; <see cref="OrderedVisibleCells"/> supplies the draw ORDER.</summary>
|
||||
public required IReadOnlySet<uint> DrawableCells { get; init; }
|
||||
|
||||
/// <summary>Per-cell portal_view slots, in the same order retail setup_view(cell, i)
|
||||
/// selects them inside PView::DrawCells.</summary>
|
||||
public required IReadOnlyDictionary<uint, int[]> CellClipSlots { get; init; }
|
||||
|
||||
public required int OutdoorSlot { get; init; }
|
||||
public required bool OutdoorVisible { get; init; }
|
||||
|
||||
/// <summary>The 3-bucket entity split (<see cref="InteriorEntityPartition"/>). Only ByCell +
|
||||
/// LiveDynamic are used here; Outdoor scenery is drawn by the caller's landscape-through-door
|
||||
/// step (clipped to OutsideView).</summary>
|
||||
|
|
@ -34,12 +41,11 @@ public sealed class InteriorRenderContext
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// The per-cell interior render flood — a faithful port of retail PView::DrawCells' per-cell loops
|
||||
/// (decomp 0x5a4840). Iterates the visible cells closest-first; per cell draws the closed shell +
|
||||
/// that cell's static objects (portal-clipped via the clip routing the caller installed), then the
|
||||
/// live-dynamics unclipped, then the transparent shells. The landscape-through-door (sky/terrain/
|
||||
/// scenery) + the conditional Z-clear are the caller's responsibility, run BEFORE this. GL state is
|
||||
/// self-contained inside each renderer (EnvCellRenderer / WbDrawDispatcher set their own).
|
||||
/// The interior render flood, matching retail PView::DrawCells @ 0x005a4840:
|
||||
/// after the caller handles outside_view terrain + the depth-only clear, DrawCells
|
||||
/// walks cell_draw_list from the end back to zero in separate stages: cell shells,
|
||||
/// then each cell's object_list. The transparent shell pass is split out because
|
||||
/// the modern renderer batches opaque/transparent surfaces separately.
|
||||
/// </summary>
|
||||
public sealed class InteriorRenderer
|
||||
{
|
||||
|
|
@ -48,7 +54,6 @@ public sealed class InteriorRenderer
|
|||
|
||||
// Reused single-cell filter set — cleared + repopulated per cell to avoid per-frame allocs.
|
||||
private readonly HashSet<uint> _oneCell = new(1);
|
||||
|
||||
public InteriorRenderer(EnvCellRenderer envCells, WbDrawDispatcher entities)
|
||||
{
|
||||
_envCells = envCells;
|
||||
|
|
@ -57,54 +62,103 @@ public sealed class InteriorRenderer
|
|||
|
||||
public void DrawInside(InteriorRenderContext ctx)
|
||||
{
|
||||
// Loop A — per-cell OPAQUE shell + that cell's static objects (closest-first).
|
||||
foreach (uint cellId in ctx.OrderedVisibleCells)
|
||||
// Retail Loop 2: DrawEnvCell for each drawable cell, farthest-to-nearest
|
||||
// (cell_draw_list[cell_draw_num - 1] down to 0).
|
||||
for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!ctx.DrawableCells.Contains(cellId)) continue; // no clip slot ⇒ assembler culled it
|
||||
uint cellId = ctx.OrderedVisibleCells[i];
|
||||
if (!TryBeginCell(ctx, cellId, out _)) continue;
|
||||
_oneCell.Clear();
|
||||
_oneCell.Add(cellId);
|
||||
ApplyMembershipOnlyRouting();
|
||||
_envCells.Render(WbRenderPass.Opaque, _oneCell);
|
||||
|
||||
if (ctx.Partition.ByCell.TryGetValue(cellId, out var cellEntities) && cellEntities.Count > 0)
|
||||
DrawEntityBucket(ctx, cellEntities, visibleCellIds: _oneCell);
|
||||
}
|
||||
|
||||
// Live-dynamics (player / NPCs): unclipped (serverGuid != 0 → clip slot 0), depth-tested.
|
||||
// Drawn AFTER opaque shells so wall depth occludes them correctly.
|
||||
if (ctx.Partition.LiveDynamic.Count > 0)
|
||||
DrawEntityBucket(ctx, ctx.Partition.LiveDynamic, visibleCellIds: null);
|
||||
|
||||
// Loop B — per-cell TRANSPARENT shells (stained glass / additive cell surfaces).
|
||||
foreach (uint cellId in ctx.OrderedVisibleCells)
|
||||
// Retail Loop 3: Render::PortalList = cell->portal_view; DrawObjCellForDummies(cell).
|
||||
for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!ctx.DrawableCells.Contains(cellId)) continue;
|
||||
uint cellId = ctx.OrderedVisibleCells[i];
|
||||
if (!TryBeginCell(ctx, cellId, out _)) continue;
|
||||
_oneCell.Clear();
|
||||
_oneCell.Add(cellId);
|
||||
if (ctx.Partition.ByCell.TryGetValue(cellId, out var cellEntities) && cellEntities.Count > 0)
|
||||
{
|
||||
ApplyMembershipOnlyRouting();
|
||||
DrawEntityBucket(ctx, cellEntities, visibleCellIds: _oneCell);
|
||||
}
|
||||
}
|
||||
|
||||
// Modern split of DrawEnvCell's transparent/additive batches, same reverse cell order.
|
||||
for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
||||
{
|
||||
uint cellId = ctx.OrderedVisibleCells[i];
|
||||
if (!TryBeginCell(ctx, cellId, out _)) continue;
|
||||
_oneCell.Clear();
|
||||
_oneCell.Add(cellId);
|
||||
ApplyMembershipOnlyRouting();
|
||||
_envCells.Render(WbRenderPass.Transparent, _oneCell);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryBeginCell(InteriorRenderContext ctx, uint cellId, out int[] slots)
|
||||
{
|
||||
if (ctx.DrawableCells.Contains(cellId))
|
||||
{
|
||||
ctx.CellClipSlots.TryGetValue(cellId, out slots!);
|
||||
slots ??= System.Array.Empty<int>();
|
||||
return true;
|
||||
}
|
||||
|
||||
slots = System.Array.Empty<int>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ApplyMembershipOnlyRouting()
|
||||
{
|
||||
// PView membership controls which cell shell/object bucket is visited.
|
||||
// Do not turn the 2D portal view into gl_ClipDistance for indoor meshes:
|
||||
// that slices avatars and shell triangles at stairs/doorways instead of
|
||||
// matching retail's DrawMesh view-check-then-draw behavior.
|
||||
_envCells.SetClipRouting(null);
|
||||
_entities.ClearClipRouting();
|
||||
}
|
||||
|
||||
// Draws one bucket of entities via the existing dispatcher, scoped to a synthetic single-entry
|
||||
// landblock list. visibleCellIds gates which entities pass the cell-membership walk (a single-cell
|
||||
// set for per-cell statics; null for live-dynamics — they pass the gate and resolve to slot 0).
|
||||
// set for per-cell objects; null only for fallback/outdoor buckets where clip-slot routing owns cull).
|
||||
// The clip slot per entity comes from the SetClipRouting the caller installed (cellIdToSlot +
|
||||
// outdoorSlot + outdoorVisible) via ResolveEntitySlot.
|
||||
private void DrawEntityBucket(
|
||||
InteriorRenderContext ctx, IReadOnlyList<WorldEntity> bucket, HashSet<uint>? visibleCellIds)
|
||||
=> DrawEntityBucket(
|
||||
ctx.Camera,
|
||||
ctx.Frustum,
|
||||
ctx.PlayerLandblockId,
|
||||
ctx.AnimatedEntityIds,
|
||||
bucket,
|
||||
visibleCellIds);
|
||||
|
||||
public void DrawEntityBucket(
|
||||
ICamera camera,
|
||||
FrustumPlanes? frustum,
|
||||
uint? playerLandblockId,
|
||||
HashSet<uint>? animatedEntityIds,
|
||||
IReadOnlyList<WorldEntity> bucket,
|
||||
HashSet<uint>? visibleCellIds)
|
||||
{
|
||||
// LandblockId == neverCullLandblockId (PlayerLandblockId) ⇒ the degenerate (zero) AABB is
|
||||
// never landblock-frustum-culled; per-entity AABB culling inside Draw still applies.
|
||||
uint lbId = ctx.PlayerLandblockId ?? 0u;
|
||||
uint lbId = playerLandblockId ?? 0u;
|
||||
var entry = (lbId, Vector3.Zero, Vector3.Zero,
|
||||
(IReadOnlyList<WorldEntity>)bucket,
|
||||
(IReadOnlyDictionary<uint, WorldEntity>?)null);
|
||||
|
||||
_entities.Draw(
|
||||
ctx.Camera,
|
||||
camera,
|
||||
new[] { entry },
|
||||
ctx.Frustum,
|
||||
neverCullLandblockId: ctx.PlayerLandblockId,
|
||||
frustum,
|
||||
neverCullLandblockId: playerLandblockId,
|
||||
visibleCellIds: visibleCellIds,
|
||||
animatedEntityIds: ctx.AnimatedEntityIds);
|
||||
animatedEntityIds: animatedEntityIds);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,7 +120,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
|
|||
ParticleSystem particles,
|
||||
ICamera camera,
|
||||
Vector3 cameraWorldPos,
|
||||
ParticleRenderPass renderPass = ParticleRenderPass.Scene)
|
||||
ParticleRenderPass renderPass = ParticleRenderPass.Scene,
|
||||
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter = null)
|
||||
{
|
||||
if (particles is null || camera is null)
|
||||
return;
|
||||
|
|
@ -128,7 +129,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
|
|||
Matrix4x4.Invert(camera.View, out var 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));
|
||||
var draws = BuildDrawList(particles, cameraWorldPos, renderPass, cameraRight, cameraUp);
|
||||
var draws = BuildDrawList(particles, cameraWorldPos, renderPass, cameraRight, cameraUp, emitterFilter);
|
||||
if (draws.Count == 0)
|
||||
return;
|
||||
draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq));
|
||||
|
|
@ -174,13 +175,16 @@ public sealed unsafe class ParticleRenderer : IDisposable
|
|||
Vector3 cameraWorldPos,
|
||||
ParticleRenderPass renderPass,
|
||||
Vector3 cameraRight,
|
||||
Vector3 cameraUp)
|
||||
Vector3 cameraUp,
|
||||
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter)
|
||||
{
|
||||
var draws = new List<ParticleDraw>(Math.Max(64, particles.ActiveParticleCount));
|
||||
foreach (var (em, idx) in particles.EnumerateLive())
|
||||
{
|
||||
if (em.RenderPass != renderPass)
|
||||
continue;
|
||||
if (emitterFilter is not null && !emitterFilter(em))
|
||||
continue;
|
||||
|
||||
ref var p = ref em.Particles[idx];
|
||||
// `p.Position` is already in world coordinates: AttachLocal
|
||||
|
|
|
|||
|
|
@ -70,6 +70,117 @@ public static class PortalProjection
|
|||
return ndc;
|
||||
}
|
||||
|
||||
/// <summary>Faithful homogeneous projection (retail PrimD3DRender::xformStart + the w=0 clip of
|
||||
/// ACRender::polyClipFinish, decomp 424310 / 702749): transform the portal to clip space and clip
|
||||
/// ONLY the eye plane (w >= <see cref="EyePlaneW"/>), keeping homogeneous coords — NO perspective
|
||||
/// divide, NO frustum side-plane clamp. The screen bound is applied later by <see cref="ClipToRegion"/>
|
||||
/// against the view region (the root region is the full screen), exactly as retail clips the portal
|
||||
/// against the accumulated portal_view rather than fixed side planes. Keeping w means a near/grazing
|
||||
/// portal never collapses to a zero-area edge sliver (the flap) nor blows up under an early divide
|
||||
/// (the void). Returns <3 verts when the portal is entirely behind the eye.</summary>
|
||||
public static Vector4[] ProjectToClip(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj)
|
||||
{
|
||||
if (localPoly == null || localPoly.Count < 3) return System.Array.Empty<Vector4>();
|
||||
|
||||
Matrix4x4 m = cellToWorld * viewProj;
|
||||
var clip = new List<Vector4>(localPoly.Count);
|
||||
foreach (var lp in localPoly)
|
||||
clip.Add(Vector4.Transform(new Vector4(lp, 1f), m));
|
||||
|
||||
// Eye plane ONLY (w >= EyePlaneW), in clip space, homogeneous — no side planes, no divide.
|
||||
// Retail's polyClipFinish clips at w = 0; EyePlaneW is a hair above 0 so the later divide in
|
||||
// ClipToRegion never hits the w = 0 singularity. Everything in front of the eye is kept,
|
||||
// including a portal the camera is standing in (it covers the screen) — the screen bound comes
|
||||
// from ClipToRegion against the view region, not from a near plane here.
|
||||
clip = ClipPlane(clip, v => v.W - EyePlaneW);
|
||||
return clip.Count >= 3 ? clip.ToArray() : System.Array.Empty<Vector4>();
|
||||
}
|
||||
|
||||
/// <summary>Clip a homogeneous (clip-space) portal polygon against an NDC view region
|
||||
/// (CCW convex) with w-aware Sutherland-Hodgman edge tests, then divide the survivors to NDC and
|
||||
/// normalize to CCW. Ports retail ACRender::polyClipFinish's view-region clip (decomp 702749): the
|
||||
/// edge test multiplies through w (which is > 0 after the eye-plane clip) so it never divides a
|
||||
/// near-eye vertex, and the final divide runs only on survivors already bounded to the region —
|
||||
/// stable by construction. Returns <3 verts when the portal does not intersect the region.</summary>
|
||||
public static Vector2[] ClipToRegion(IReadOnlyList<Vector4> subjectClip, IReadOnlyList<Vector2> regionCcwNdc)
|
||||
{
|
||||
if (subjectClip == null || regionCcwNdc == null || subjectClip.Count < 3 || regionCcwNdc.Count < 3)
|
||||
return System.Array.Empty<Vector2>();
|
||||
|
||||
// Homogeneous Sutherland-Hodgman: clip the (w > 0) subject against each CCW edge of the NDC
|
||||
// region. f(P) below is the NDC inside test cross(edge, P_ndc - a) multiplied through P.W,
|
||||
// which is > 0 after the eye-plane clip — so the sign is the NDC sign yet no near-eye vertex
|
||||
// is ever divided (retail polyClipFinish, decomp 702749).
|
||||
var poly = new List<Vector4>(subjectClip);
|
||||
int n = regionCcwNdc.Count;
|
||||
for (int e = 0; e < n; e++)
|
||||
{
|
||||
if (poly.Count < 3) return System.Array.Empty<Vector2>();
|
||||
poly = ClipHomogeneousEdge(poly, regionCcwNdc[e], regionCcwNdc[(e + 1) % n]);
|
||||
}
|
||||
if (poly.Count < 3) return System.Array.Empty<Vector2>();
|
||||
|
||||
// Divide survivors → NDC. They are inside the region now, so |x| ≤ |w| and |y| ≤ |w|: the
|
||||
// divide is bounded by construction (this is why the homogeneous clip avoids the early-divide
|
||||
// blow-up). Normalize to CCW so the result is a valid clip region for the next portal hop.
|
||||
var ndc = new Vector2[poly.Count];
|
||||
for (int i = 0; i < poly.Count; i++)
|
||||
{
|
||||
float w = poly[i].W;
|
||||
ndc[i] = new Vector2(poly[i].X / w, poly[i].Y / w);
|
||||
}
|
||||
EnsureCcw(ndc);
|
||||
return ndc;
|
||||
}
|
||||
|
||||
// One Sutherland-Hodgman half-plane against the directed NDC edge a→b, keeping the CCW-inside
|
||||
// (left) part of a HOMOGENEOUS polygon. Inside test for vertex P (clip space): the NDC cross
|
||||
// product cross(b-a, P/P.W - a) scaled by P.W (> 0): ex·(P.Y - P.W·a.Y) - ey·(P.X - P.W·a.X) ≥ 0.
|
||||
// Crossings interpolate in homogeneous coords (perspective-correct), via the shared Lerp.
|
||||
private static List<Vector4> ClipHomogeneousEdge(List<Vector4> poly, Vector2 a, Vector2 b)
|
||||
{
|
||||
var result = new List<Vector4>(poly.Count + 1);
|
||||
float ex = b.X - a.X, ey = b.Y - a.Y;
|
||||
for (int i = 0; i < poly.Count; i++)
|
||||
{
|
||||
Vector4 cur = poly[i];
|
||||
Vector4 prev = poly[(i + poly.Count - 1) % poly.Count];
|
||||
float dCur = ex * (cur.Y - cur.W * a.Y) - ey * (cur.X - cur.W * a.X);
|
||||
float dPrev = ex * (prev.Y - prev.W * a.Y) - ey * (prev.X - prev.W * a.X);
|
||||
bool curIn = dCur >= 0f;
|
||||
bool prevIn = dPrev >= 0f;
|
||||
|
||||
if (curIn)
|
||||
{
|
||||
if (!prevIn) result.Add(Lerp(prev, cur, dPrev, dCur));
|
||||
result.Add(cur);
|
||||
}
|
||||
else if (prevIn)
|
||||
{
|
||||
result.Add(Lerp(prev, cur, dPrev, dCur));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Reverse vertex order in place if wound clockwise (signed area < 0). Mirrors the builder's
|
||||
// EnsureCcw so a clipped region is always CCW for the next hop's ClipToRegion edge test.
|
||||
private static void EnsureCcw(Vector2[] poly)
|
||||
{
|
||||
float area2 = 0f;
|
||||
for (int i = 0; i < poly.Length; i++)
|
||||
{
|
||||
var p = poly[i]; var q = poly[(i + 1) % poly.Length];
|
||||
area2 += p.X * q.Y - q.X * p.Y;
|
||||
}
|
||||
if (area2 < 0f) System.Array.Reverse(poly);
|
||||
}
|
||||
|
||||
// Eye plane for the homogeneous clip — a hair above retail's w = 0 so the post-region divide in
|
||||
// ClipToRegion never divides by zero. Far closer than any near plane: a portal the eye is standing
|
||||
// in is kept (it covers the screen), so the cell behind it stays visible.
|
||||
private const float EyePlaneW = 1e-4f;
|
||||
|
||||
// Minimum clip-space w (≈ metres in front of the eye) to keep a vertex. Excludes the eye
|
||||
// (w=0) singularity and the ~5 cm right at it (bounding the perspective divide), but is
|
||||
// INTENTIONALLY far closer than the projection's 1.0 m near plane so a doorway the camera is
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
// a cell's clip region is a SET of convex polygons in normalized device coords.
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
|
|
@ -40,6 +41,10 @@ public readonly struct ViewPolygon
|
|||
public sealed class CellView
|
||||
{
|
||||
public readonly List<ViewPolygon> Polygons = new();
|
||||
|
||||
// Canonical (snapped) keys of the polygons in <see cref="Polygons"/>, backing the drift-tolerant
|
||||
// dedup in <see cref="Add"/>. One entry per stored polygon; HashSet membership IS the dedup.
|
||||
private readonly HashSet<string> _polygonKeys = new();
|
||||
public float MinX { get; private set; } = float.MaxValue;
|
||||
public float MinY { get; private set; } = float.MaxValue;
|
||||
public float MaxX { get; private set; } = float.MinValue;
|
||||
|
|
@ -59,13 +64,82 @@ public sealed class CellView
|
|||
return v;
|
||||
}
|
||||
|
||||
public void Add(ViewPolygon p)
|
||||
public bool Add(ViewPolygon p)
|
||||
{
|
||||
if (p.IsEmpty) return;
|
||||
if (p.IsEmpty) return false;
|
||||
|
||||
// Drift-tolerant, rotation-invariant dedup (2026-06-06 hang fix). PortalVisibilityBuilder.Build
|
||||
// re-queues a cell every time its CellView GROWS, so the flood only terminates when Add
|
||||
// recognises a re-clipped region as a duplicate. Across BFS rounds the SAME region returns
|
||||
// float-drifted, vertex-rotated, and/or with a ±1 vertex count (homogeneous Sutherland-Hodgman +
|
||||
// EnsureCcw); the old exact index-by-index match (eps 1e-4) caught none of those, so the region
|
||||
// grew without bound -> O(n^2) CPU-spin hang in this method. We instead key each polygon by its
|
||||
// vertices SNAPPED to a small NDC grid, consecutive snap-duplicates removed, rotated to a
|
||||
// canonical start. The snapped key space is finite, so a monotonically-growing CellView is
|
||||
// bounded and the flood is GUARANTEED to converge. The stored polygon keeps full precision (only
|
||||
// the key is snapped), so downstream clip geometry is unchanged, and the grid (1e-3 NDC ~ sub-
|
||||
// pixel) is far finer than the gap between genuinely distinct openings, so real regions never merge.
|
||||
string? key = CanonicalKey(p.Vertices);
|
||||
if (key is null) return false; // degenerate after snap (< 3 distinct vertices)
|
||||
if (!_polygonKeys.Add(key)) return false; // duplicate region (drift / rotation / count tolerant)
|
||||
|
||||
Polygons.Add(p);
|
||||
if (p.MinX < MinX) MinX = p.MinX;
|
||||
if (p.MinY < MinY) MinY = p.MinY;
|
||||
if (p.MaxX > MaxX) MaxX = p.MaxX;
|
||||
if (p.MaxY > MaxY) MaxY = p.MaxY;
|
||||
return true;
|
||||
}
|
||||
|
||||
// NDC dedup grid. 1e-3 is ~0.5 px at 1080p — finer than the gap between distinct portal openings
|
||||
// (so real regions stay distinct) yet far coarser than the per-round float drift of a re-clipped
|
||||
// region (so a drifted duplicate snaps onto its predecessor). The finite grid is what bounds growth.
|
||||
private const float DedupGridNdc = 1e-3f;
|
||||
|
||||
// Canonical key for a view polygon: vertices snapped to the NDC grid, consecutive snap-duplicates
|
||||
// removed (including wrap-around), then rotated to start at the lexicographically smallest vertex so
|
||||
// a rotated emission of the same cycle yields the same key. Returns null when fewer than 3 distinct
|
||||
// snapped vertices survive (a degenerate sliver, not a real region). Winding is already CCW for every
|
||||
// builder input (ClipToRegion / EnsureCcw), so the cyclic order is canonical without a reversal step.
|
||||
private static string? CanonicalKey(Vector2[]? verts)
|
||||
{
|
||||
if (verts is null || verts.Length < 3) return null;
|
||||
|
||||
var pts = new List<(int X, int Y)>(verts.Length);
|
||||
foreach (var v in verts)
|
||||
{
|
||||
var q = ((int)System.MathF.Round(v.X / DedupGridNdc), (int)System.MathF.Round(v.Y / DedupGridNdc));
|
||||
if (pts.Count == 0 || pts[^1] != q) pts.Add(q);
|
||||
}
|
||||
if (pts.Count >= 2 && pts[^1] == pts[0]) pts.RemoveAt(pts.Count - 1);
|
||||
if (pts.Count < 3) return null;
|
||||
|
||||
int n = pts.Count;
|
||||
int best = 0;
|
||||
for (int s = 1; s < n; s++)
|
||||
if (RotationLess(pts, s, best, n)) best = s;
|
||||
|
||||
var sb = new StringBuilder(n * 10);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var q = pts[(best + i) % n];
|
||||
sb.Append(q.X).Append(',').Append(q.Y).Append(';');
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// True when the rotation of `pts` starting at index a is lexicographically less than the rotation
|
||||
// starting at b (compare X then Y, vertex by vertex around the cycle). Gives a unique canonical
|
||||
// start even when two vertices share the minimum snapped coordinate.
|
||||
private static bool RotationLess(List<(int X, int Y)> pts, int a, int b, int n)
|
||||
{
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var pa = pts[(a + i) % n];
|
||||
var pb = pts[(b + i) % n];
|
||||
if (pa.X != pb.X) return pa.X < pb.X;
|
||||
if (pa.Y != pb.Y) return pa.Y < pb.Y;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,19 @@ public static class PortalVisibilityBuilder
|
|||
{
|
||||
private const float PortalSideEpsilon = 0.01f; // matches CellVisibility.PointInCellEpsilon
|
||||
|
||||
// Bounded re-enqueue cap (restored 2026-06-07). The distance-priority portal flood re-enqueues a
|
||||
// cell whenever its accumulated view GROWS, which is load-bearing — it propagates a late-discovered
|
||||
// portal_view slice to that cell's exit portals (Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit).
|
||||
// But the faithful near-side clip (ProjectToClip) drifts per round, so re-clipping a cell's view yields
|
||||
// ever-smaller distinct sub-regions / drifted near-duplicates the dedup can't always collapse -> the
|
||||
// grow flag never settles and the flood spins forever (the indoor hang). This cap bounds each cell to
|
||||
// at most this many pops, so the flood terminates in <= N*cap pops regardless of drift while still
|
||||
// allowing the few re-processes that legitimate late-slice propagation needs. The old hard cap removed
|
||||
// in U.2a was 4; widened here because ProjectToClip drifts more than the old ProjectToNdc and Option A's
|
||||
// CellView dedup already collapses most spurious growth, so the cap rarely binds. Tune from the visual
|
||||
// gate if an interior view under-includes a slice.
|
||||
private const int MaxReprocessPerCell = 16;
|
||||
|
||||
// TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the
|
||||
// local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell.
|
||||
private static readonly bool s_pvDump =
|
||||
|
|
@ -81,7 +94,11 @@ public static class PortalVisibilityBuilder
|
|||
// the instant a cell is popped). Enqueue-once across the cell set is the hard termination
|
||||
// guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The
|
||||
// camera cell is pre-marked so a portal looping back to it can never re-enqueue it.
|
||||
var seen = new HashSet<uint> { cameraCell.CellId };
|
||||
var queued = new HashSet<uint> { cameraCell.CellId };
|
||||
var drawListed = new HashSet<uint>();
|
||||
var processedViewCounts = new Dictionary<uint, int>();
|
||||
var popCounts = new Dictionary<uint, int>(); // per-cell pop count for the MaxReprocessPerCell cap
|
||||
var trace = PortalBuildTrace.Start(cameraCell, cameraPos);
|
||||
|
||||
bool pvDump = false;
|
||||
if (s_pvDump)
|
||||
|
|
@ -116,46 +133,81 @@ public static class PortalVisibilityBuilder
|
|||
while (todo.Count > 0)
|
||||
{
|
||||
var cell = todo.PopNearest();
|
||||
queued.Remove(cell.CellId);
|
||||
// Bounded re-enqueue (2026-06-07 termination fix): count this pop. The re-enqueue gate below
|
||||
// refuses to re-add a cell already popped MaxReprocessPerCell times, so the flood terminates
|
||||
// even when ProjectToClip drift keeps a view growing forever. Re-enqueue itself is KEPT — it
|
||||
// propagates late-discovered slices to exit portals (see MaxReprocessPerCell); only its count
|
||||
// is capped.
|
||||
popCounts.TryGetValue(cell.CellId, out int popsSoFar);
|
||||
popCounts[cell.CellId] = popsSoFar + 1;
|
||||
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
|
||||
{
|
||||
trace?.Add($"pop cell=0x{cell.CellId:X8} skip=no-view");
|
||||
continue;
|
||||
}
|
||||
|
||||
// `seen` guarantees each cell is inserted into the todo list exactly once, so this single
|
||||
// pop IS the cell's closest-first draw position (retail appends to cell_draw_list once per
|
||||
// pop, 433783) — no per-pop dedup needed, OrderedVisibleCells stays distinct by construction.
|
||||
frame.OrderedVisibleCells.Add(cell.CellId);
|
||||
if (drawListed.Add(cell.CellId))
|
||||
frame.OrderedVisibleCells.Add(cell.CellId);
|
||||
|
||||
processedViewCounts.TryGetValue(cell.CellId, out int processedCount);
|
||||
int endCount = currentView.Polygons.Count;
|
||||
if (processedCount >= endCount)
|
||||
{
|
||||
trace?.Add($"pop cell=0x{cell.CellId:X8} skip=processed processed={processedCount} views={endCount}");
|
||||
continue;
|
||||
}
|
||||
trace?.Add($"pop cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} drawPos={frame.OrderedVisibleCells.Count - 1}");
|
||||
|
||||
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
|
||||
processedViewCounts[cell.CellId] = endCount;
|
||||
|
||||
for (int i = 0; i < cell.Portals.Count; i++)
|
||||
{
|
||||
if (i >= cell.PortalPolygons.Count) continue;
|
||||
var portal = cell.Portals[i];
|
||||
if (i >= cell.PortalPolygons.Count)
|
||||
{
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=no-poly-slot");
|
||||
continue;
|
||||
}
|
||||
var poly = cell.PortalPolygons[i];
|
||||
if (poly == null || poly.Length < 3) continue;
|
||||
if (poly == null || poly.Length < 3)
|
||||
{
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=degenerate-poly len={(poly?.Length ?? -1)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF;
|
||||
bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos);
|
||||
bool sideAllowed = true;
|
||||
|
||||
// Portal-side test: only traverse a portal the camera is on the interior side of
|
||||
// (mirrors CellVisibility.GetVisibleCells + retail's 'seen' flag). Culls back-facing
|
||||
// portals so we never feed a degenerate/wrong-facing projection downstream.
|
||||
if (i < cell.ClipPlanes.Count && !CameraOnInteriorSide(cell, i, cameraPos))
|
||||
if (i < cell.ClipPlanes.Count
|
||||
&& !CameraOnInteriorSide(cell, i, cameraPos)
|
||||
&& !eyeInsideOpening)
|
||||
{
|
||||
sideAllowed = false;
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side eyeIn={eyeInsideOpening}");
|
||||
if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Project to NDC, then normalize to CCW for the CCW-only ScreenPolygonClip
|
||||
// (ProjectToNdc preserves input winding; portal dat polygons may be CW).
|
||||
Vector2[] portalNdc = PortalProjection.ProjectToNdc(poly, cell.WorldTransform, viewProj);
|
||||
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2}) ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F2},{v.Y:F2})"))}]");
|
||||
var clippedRegion = new List<ViewPolygon>();
|
||||
if (portalNdc.Length >= 3)
|
||||
{
|
||||
EnsureCcw(portalNdc);
|
||||
// Intersect the portal opening with every polygon of the current cell's view.
|
||||
foreach (var vp in currentView.Polygons)
|
||||
{
|
||||
var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices);
|
||||
if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped));
|
||||
}
|
||||
}
|
||||
// Retail PView::ClipPortals calls GetClip(..., finish=1): transform to
|
||||
// homogeneous clip space, clip at the eye, then clip against the current
|
||||
// portal_view region before the divide. Do the same here; the old early
|
||||
// ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways.
|
||||
var clippedRegion = ClipPortalAgainstView(
|
||||
poly,
|
||||
cell.WorldTransform,
|
||||
viewProj,
|
||||
activeViewPolygons,
|
||||
out int clipVerts);
|
||||
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})");
|
||||
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
|
||||
|
||||
// R1 void fix (2026-06-05): the projected+clipped region is empty — normally we cull the
|
||||
|
|
@ -171,25 +223,26 @@ public static class PortalVisibilityBuilder
|
|||
if (clippedRegion.Count == 0)
|
||||
{
|
||||
if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos))
|
||||
{
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=clip-empty side={sideAllowed} eyeIn={eyeInsideOpening} clipVerts={clipVerts}");
|
||||
continue; // portal not visible through this chain, and the eye is not standing in it
|
||||
foreach (var vp in currentView.Polygons)
|
||||
}
|
||||
foreach (var vp in activeViewPolygons)
|
||||
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
|
||||
}
|
||||
|
||||
var portal = cell.Portals[i];
|
||||
|
||||
if (portal.OtherCellId == 0xFFFF)
|
||||
{
|
||||
if (pvDump)
|
||||
{
|
||||
Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} clipPolys={clippedRegion.Count}");
|
||||
Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipVerts={clipVerts} clipPolys={clippedRegion.Count}");
|
||||
Console.WriteLine($"[pv-dump] local=[{string.Join(" ", System.Array.ConvertAll(poly, v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"))}]");
|
||||
Console.WriteLine($"[pv-dump] ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F3},{v.Y:F3})"))}]");
|
||||
foreach (var cp in clippedRegion)
|
||||
Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]");
|
||||
}
|
||||
// Exit portal -> outdoors visible through this (clipped) opening.
|
||||
foreach (var cp in clippedRegion) frame.OutsideView.Add(cp);
|
||||
AddRegion(frame.OutsideView, clippedRegion);
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts} eyeIn={eyeInsideOpening}");
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -202,12 +255,17 @@ public static class PortalVisibilityBuilder
|
|||
if (buildingMembership != null && !buildingMembership(neighbourId))
|
||||
{
|
||||
var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId);
|
||||
foreach (var cp in clippedRegion) xview.Add(cp);
|
||||
bool grewCross = AddRegion(xview, clippedRegion);
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} crossBldg polys={clippedRegion.Count} grew={grewCross}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var neighbour = lookup(neighbourId);
|
||||
if (neighbour == null) continue;
|
||||
if (neighbour == null)
|
||||
{
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=lookup-miss polys={clippedRegion.Count}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase U.2b — neighbour-side OtherPortalClip (retail PView::OtherPortalClip
|
||||
// decomp:433524). The portal opening seen from THIS cell may be wider than the
|
||||
|
|
@ -222,12 +280,24 @@ public static class PortalVisibilityBuilder
|
|||
// direct index is what lets a cell with TWO portals to the same neighbour clip each
|
||||
// opening against its OWN reciprocal instead of the first one. Mutates clippedRegion
|
||||
// in place before the union below.
|
||||
var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null;
|
||||
int preReciprocalCount = clippedRegion.Count;
|
||||
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj);
|
||||
if (clippedRegion.Count == 0) continue; // reciprocal opening doesn't overlap → not visible
|
||||
if (clippedRegion.Count == 0)
|
||||
{
|
||||
if (preReciprocalClip is null)
|
||||
{
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=reciprocal-empty pre={preReciprocalCount} otherPortal={portal.OtherPortalId}");
|
||||
continue;
|
||||
}
|
||||
clippedRegion.AddRange(preReciprocalClip);
|
||||
}
|
||||
|
||||
// Union the clipped region into the neighbour's accumulated view.
|
||||
var nview = GetOrCreate(frame.CellViews, neighbourId);
|
||||
foreach (var cp in clippedRegion) nview.Add(cp);
|
||||
bool grew = AddRegion(nview, clippedRegion);
|
||||
bool inserted = false;
|
||||
float dist = float.NaN;
|
||||
|
||||
// Insert the neighbour into the distance-priority list — but ONLY on first discovery
|
||||
// (retail enqueues via InsCellTodoList solely in the ecx_5==0 branch; growth into an
|
||||
|
|
@ -237,11 +307,13 @@ public static class PortalVisibilityBuilder
|
|||
// portal-opening vertex in world space (retail InitCell min-vertex distance,
|
||||
// 432988-433004); derived from the portal geometry, so it works even when the cell's
|
||||
// WorldPosition was never populated.
|
||||
if (seen.Add(neighbourId))
|
||||
if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId))
|
||||
{
|
||||
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
todo.Insert(neighbour, dist);
|
||||
inserted = true;
|
||||
}
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} addCell polys={clippedRegion.Count} clipVerts={clipVerts} recip={preReciprocalCount}->{clippedRegion.Count} grew={grew} queued={inserted} dist={(float.IsNaN(dist) ? "na" : dist.ToString("F2"))}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +324,161 @@ public static class PortalVisibilityBuilder
|
|||
// root cell's per-portal side-test + projection + the frame's exit/visible counts.
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
|
||||
EmitFlapProbe(cameraCell, cameraPos, viewProj, frame);
|
||||
trace?.Emit(frame);
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a portal visibility frame for an OUTDOOR viewer looking into one or more
|
||||
/// outside-facing cell portals. This is the reciprocal of <see cref="Build"/>:
|
||||
/// the seed view is the projected exit-portal opening instead of a full-screen
|
||||
/// camera cell. It keeps the same retail distance-priority traversal and
|
||||
/// neighbour reciprocal clipping once inside the building.
|
||||
/// </summary>
|
||||
public static PortalVisibilityFrame BuildFromExterior(
|
||||
IEnumerable<LoadedCell> candidateCells,
|
||||
Vector3 cameraPos,
|
||||
Func<uint, LoadedCell?> lookup,
|
||||
Matrix4x4 viewProj,
|
||||
float maxSeedDistance = float.PositiveInfinity)
|
||||
{
|
||||
var frame = new PortalVisibilityFrame();
|
||||
var todo = new CellTodoList();
|
||||
var queued = new HashSet<uint>();
|
||||
var drawListed = new HashSet<uint>();
|
||||
var processedViewCounts = new Dictionary<uint, int>();
|
||||
var popCounts = new Dictionary<uint, int>(); // per-cell pop count for the MaxReprocessPerCell cap
|
||||
|
||||
foreach (var cell in candidateCells)
|
||||
{
|
||||
if (cell is null) continue;
|
||||
|
||||
for (int i = 0; i < cell.Portals.Count; i++)
|
||||
{
|
||||
var portal = cell.Portals[i];
|
||||
if (portal.OtherCellId != 0xFFFF)
|
||||
continue;
|
||||
if (i >= cell.PortalPolygons.Count)
|
||||
continue;
|
||||
|
||||
var poly = cell.PortalPolygons[i];
|
||||
if (poly == null || poly.Length < 3)
|
||||
continue;
|
||||
|
||||
// Exterior peering starts from the OUTSIDE face of an exit portal.
|
||||
// If the camera is on the cell-interior side, the normal indoor
|
||||
// DrawInside path owns this portal instead.
|
||||
if (i < cell.ClipPlanes.Count && CameraOnInteriorSide(cell, i, cameraPos))
|
||||
continue;
|
||||
|
||||
float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
if (seedDistance > maxSeedDistance)
|
||||
continue;
|
||||
|
||||
var clippedRegion = ClipPortalAgainstView(
|
||||
poly,
|
||||
cell.WorldTransform,
|
||||
viewProj,
|
||||
FullScreenRegion,
|
||||
out _);
|
||||
|
||||
if (clippedRegion.Count == 0)
|
||||
{
|
||||
if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos))
|
||||
continue;
|
||||
clippedRegion.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone()));
|
||||
}
|
||||
|
||||
var seedView = GetOrCreate(frame.CellViews, cell.CellId);
|
||||
bool grew = AddRegion(seedView, clippedRegion);
|
||||
|
||||
if (grew && queued.Add(cell.CellId))
|
||||
todo.Insert(cell, seedDistance);
|
||||
}
|
||||
}
|
||||
|
||||
while (todo.Count > 0)
|
||||
{
|
||||
var cell = todo.PopNearest();
|
||||
queued.Remove(cell.CellId);
|
||||
// Bounded re-enqueue — see the matching note in Build(). Count this pop; the gate below caps
|
||||
// re-enqueues at MaxReprocessPerCell so the look-in flood terminates under ProjectToClip drift.
|
||||
popCounts.TryGetValue(cell.CellId, out int popsSoFar);
|
||||
popCounts[cell.CellId] = popsSoFar + 1;
|
||||
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
|
||||
continue;
|
||||
|
||||
if (drawListed.Add(cell.CellId))
|
||||
frame.OrderedVisibleCells.Add(cell.CellId);
|
||||
|
||||
processedViewCounts.TryGetValue(cell.CellId, out int processedCount);
|
||||
int endCount = currentView.Polygons.Count;
|
||||
if (processedCount >= endCount)
|
||||
continue;
|
||||
|
||||
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
|
||||
processedViewCounts[cell.CellId] = endCount;
|
||||
uint lbMask = cell.CellId & 0xFFFF0000u;
|
||||
|
||||
for (int i = 0; i < cell.Portals.Count; i++)
|
||||
{
|
||||
if (i >= cell.PortalPolygons.Count)
|
||||
continue;
|
||||
|
||||
var poly = cell.PortalPolygons[i];
|
||||
if (poly == null || poly.Length < 3)
|
||||
continue;
|
||||
|
||||
var portal = cell.Portals[i];
|
||||
if (portal.OtherCellId == 0xFFFF)
|
||||
continue; // already outdoors; exterior terrain was drawn by the caller.
|
||||
|
||||
bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos);
|
||||
if (i < cell.ClipPlanes.Count
|
||||
&& !CameraOnInteriorSide(cell, i, cameraPos)
|
||||
&& !eyeInsideOpening)
|
||||
continue;
|
||||
|
||||
var clippedRegion = ClipPortalAgainstView(
|
||||
poly,
|
||||
cell.WorldTransform,
|
||||
viewProj,
|
||||
activeViewPolygons,
|
||||
out _);
|
||||
|
||||
if (clippedRegion.Count == 0)
|
||||
{
|
||||
if (!eyeInsideOpening)
|
||||
continue;
|
||||
foreach (var vp in activeViewPolygons)
|
||||
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
|
||||
}
|
||||
|
||||
uint neighbourId = lbMask | portal.OtherCellId;
|
||||
var neighbour = lookup(neighbourId);
|
||||
if (neighbour == null)
|
||||
continue;
|
||||
|
||||
var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null;
|
||||
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj);
|
||||
if (clippedRegion.Count == 0)
|
||||
{
|
||||
if (preReciprocalClip is null)
|
||||
continue;
|
||||
clippedRegion.AddRange(preReciprocalClip);
|
||||
}
|
||||
|
||||
var nview = GetOrCreate(frame.CellViews, neighbourId);
|
||||
bool grew = AddRegion(nview, clippedRegion);
|
||||
|
||||
if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId))
|
||||
{
|
||||
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
todo.Insert(neighbour, dist);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
|
@ -260,6 +487,117 @@ public static class PortalVisibilityBuilder
|
|||
private static readonly Vector2[] FullScreenQuad =
|
||||
{ new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
|
||||
|
||||
private static readonly ViewPolygon[] FullScreenRegion =
|
||||
{ new ViewPolygon(FullScreenQuad) };
|
||||
|
||||
private static List<ViewPolygon> ClipPortalAgainstView(
|
||||
Vector3[] localPoly,
|
||||
Matrix4x4 cellToWorld,
|
||||
Matrix4x4 viewProj,
|
||||
IReadOnlyList<ViewPolygon> viewPolygons,
|
||||
out int clipVertexCount)
|
||||
{
|
||||
var portalClip = PortalProjection.ProjectToClip(localPoly, cellToWorld, viewProj);
|
||||
clipVertexCount = portalClip.Length;
|
||||
var clippedRegion = new List<ViewPolygon>();
|
||||
if (portalClip.Length < 3)
|
||||
return clippedRegion;
|
||||
|
||||
foreach (var vp in viewPolygons)
|
||||
{
|
||||
if (vp.IsEmpty)
|
||||
continue;
|
||||
|
||||
var clipped = PortalProjection.ClipToRegion(portalClip, vp.Vertices);
|
||||
if (clipped.Length >= 3)
|
||||
clippedRegion.Add(new ViewPolygon(clipped));
|
||||
}
|
||||
|
||||
return clippedRegion;
|
||||
}
|
||||
|
||||
private const int PortalTraceEmitLimit = 160;
|
||||
private static readonly object s_portalTraceLock = new();
|
||||
private static readonly Dictionary<uint, string> s_portalTraceLastSignature = new();
|
||||
private static int s_portalTraceEmits;
|
||||
|
||||
private sealed class PortalBuildTrace
|
||||
{
|
||||
private readonly uint _rootCellId;
|
||||
private readonly Vector3 _eye;
|
||||
private readonly List<string> _lines = new();
|
||||
|
||||
private PortalBuildTrace(uint rootCellId, Vector3 eye)
|
||||
{
|
||||
_rootCellId = rootCellId;
|
||||
_eye = eye;
|
||||
}
|
||||
|
||||
public static PortalBuildTrace? Start(LoadedCell root, Vector3 eye)
|
||||
{
|
||||
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
|
||||
return null;
|
||||
if (!IsHoltburgIndoorProbeCell(root.CellId))
|
||||
return null;
|
||||
return new PortalBuildTrace(root.CellId, eye);
|
||||
}
|
||||
|
||||
public void Add(string line)
|
||||
{
|
||||
if (_lines.Count < 96)
|
||||
_lines.Add(line);
|
||||
}
|
||||
|
||||
public void Emit(PortalVisibilityFrame frame)
|
||||
{
|
||||
string signature = BuildSignature(frame);
|
||||
lock (s_portalTraceLock)
|
||||
{
|
||||
if (s_portalTraceEmits >= PortalTraceEmitLimit)
|
||||
return;
|
||||
if (s_portalTraceLastSignature.TryGetValue(_rootCellId, out var last) &&
|
||||
string.Equals(last, signature, StringComparison.Ordinal))
|
||||
return;
|
||||
s_portalTraceLastSignature[_rootCellId] = signature;
|
||||
s_portalTraceEmits++;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[pv-trace] root=0x{_rootCellId:X8} eye=({_eye.X:F2},{_eye.Y:F2},{_eye.Z:F2}) {signature}");
|
||||
foreach (var line in _lines)
|
||||
Console.WriteLine("[pv-trace] " + line);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHoltburgIndoorProbeCell(uint cellId)
|
||||
{
|
||||
if ((cellId & 0xFFFF0000u) != 0xA9B40000u)
|
||||
return false;
|
||||
uint low = cellId & 0xFFFFu;
|
||||
return low >= 0x016F && low <= 0x0175;
|
||||
}
|
||||
|
||||
private static string BuildSignature(PortalVisibilityFrame frame)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(160);
|
||||
sb.Append("outPolys=").Append(frame.OutsideView.Polygons.Count);
|
||||
sb.Append(" cells=[");
|
||||
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
|
||||
{
|
||||
if (i != 0) sb.Append(',');
|
||||
sb.Append("0x").Append((frame.OrderedVisibleCells[i] & 0xFFFFu).ToString("X4"));
|
||||
}
|
||||
sb.Append("] views=[");
|
||||
bool first = true;
|
||||
foreach (var kvp in frame.CellViews)
|
||||
{
|
||||
if (!first) sb.Append(',');
|
||||
first = false;
|
||||
sb.Append("0x").Append((kvp.Key & 0xFFFFu).ToString("X4")).Append(':').Append(kvp.Value.Polygons.Count);
|
||||
}
|
||||
sb.Append(']');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// Phase U.4c flap probe. One [flap] line per Build: the root cell's per-portal
|
||||
// signed distance D (eye→portal plane), traverse/cull decision, and NDC projection
|
||||
// vertex count, plus the frame's OutsideView polygon count + visible-cell count.
|
||||
|
|
@ -288,10 +626,10 @@ public static class PortalVisibilityBuilder
|
|||
d = Vector3.Dot(pl.Normal, localEye) + pl.D;
|
||||
side = CameraOnInteriorSide(cameraCell, i, cameraPos);
|
||||
}
|
||||
// Replicate the walk's project → EnsureCcw → Intersect(FullScreen) exactly, so a
|
||||
// portal that PROJECTS (proj>=3) but still fails to ADD its neighbour shows WHY:
|
||||
// clip=0 with ndc inside [-1,1] ⇒ winding/self-intersection degeneracy; clip=0 with
|
||||
// ndc outside [-1,1] ⇒ genuinely off-screen. The ndc coords expose a near-plane bowtie.
|
||||
// Replicate the walk's faithful path exactly (ProjectToClip → ClipToRegion(FullScreen)) so
|
||||
// proj/clip mean the same as production: proj = clip-space verts in front of the eye,
|
||||
// clip = verts surviving the screen-region clip. clip=0 with proj>=3 ⇒ the portal is
|
||||
// genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands.
|
||||
int projN = -1, clipN = -1;
|
||||
string ndcText = "";
|
||||
if (i < cameraCell.PortalPolygons.Count)
|
||||
|
|
@ -299,12 +637,12 @@ public static class PortalVisibilityBuilder
|
|||
var poly = cameraCell.PortalPolygons[i];
|
||||
if (poly != null && poly.Length >= 3)
|
||||
{
|
||||
var ndc = PortalProjection.ProjectToNdc(poly, cameraCell.WorldTransform, viewProj);
|
||||
projN = ndc.Length;
|
||||
if (ndc.Length >= 3)
|
||||
var clip = PortalProjection.ProjectToClip(poly, cameraCell.WorldTransform, viewProj);
|
||||
projN = clip.Length;
|
||||
if (clip.Length >= 3)
|
||||
{
|
||||
EnsureCcw(ndc);
|
||||
clipN = ScreenPolygonClip.Intersect(ndc, FullScreenQuad).Length;
|
||||
var ndc = PortalProjection.ClipToRegion(clip, FullScreenQuad);
|
||||
clipN = ndc.Length;
|
||||
var ns = new System.Text.StringBuilder(48);
|
||||
foreach (var v in ndc) ns.Append('(').Append(v.X.ToString("F1")).Append(',').Append(v.Y.ToString("F1")).Append(')');
|
||||
ndcText = ns.ToString();
|
||||
|
|
@ -376,6 +714,13 @@ public static class PortalVisibilityBuilder
|
|||
|
||||
// Project the reciprocal opening through the NEIGHBOUR's transform (retail positionPush(3,
|
||||
// &other_cell_ptr->pos) at 005a54d2), then normalize winding for the CCW-only clipper.
|
||||
// NOTE: this stays on the divide-then-clip ProjectToNdc path on purpose. The reciprocal is a
|
||||
// back-portal one hop away — never near the eye — so the homogeneous clip buys nothing here,
|
||||
// and ProjectToNdc is float-stable across the BFS re-enqueue rounds. Routing it through
|
||||
// ProjectToClip+ClipToRegion produced per-round float drift that defeated the CellView
|
||||
// SamePolygon dedup, inflating a tight A<->B reciprocal view to ~4x its area
|
||||
// (Build_AppliesReciprocalOtherPortalClip). The near-side clip (ClipPortalAgainstView) IS the
|
||||
// homogeneous path; this secondary tightening is not.
|
||||
Vector2[] reciprocalNdc = PortalProjection.ProjectToNdc(reciprocalPoly, neighbour.WorldTransform, viewProj);
|
||||
if (reciprocalNdc.Length < 3) return; // reciprocal entirely behind camera / degenerate → no-op
|
||||
EnsureCcw(reciprocalNdc);
|
||||
|
|
@ -395,11 +740,27 @@ public static class PortalVisibilityBuilder
|
|||
return v;
|
||||
}
|
||||
|
||||
private static bool AddRegion(CellView view, List<ViewPolygon> region)
|
||||
{
|
||||
bool grew = false;
|
||||
foreach (var poly in region)
|
||||
grew |= view.Add(poly);
|
||||
return grew;
|
||||
}
|
||||
|
||||
// Camera→nearest-vertex distance for a portal polygon, in world space. Mirrors the per-portal
|
||||
// min-distance loop retail runs in PView::InitCell (decomp:432988-433004) to key the todo list:
|
||||
// it walks the portal's vertices, transforms each to world space, and keeps the smallest
|
||||
// straight-line distance to the camera viewpoint. Keying on the portal opening (not the cell
|
||||
// origin) is both retail-faithful and robust to cells whose WorldPosition was never populated.
|
||||
private static List<ViewPolygon> CloneViewPolygons(List<ViewPolygon> source)
|
||||
{
|
||||
var clone = new List<ViewPolygon>(source.Count);
|
||||
foreach (var poly in source)
|
||||
clone.Add(new ViewPolygon((Vector2[])poly.Vertices.Clone()));
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static float NearestPortalVertexDistance(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 cameraPos)
|
||||
{
|
||||
float best = float.MaxValue;
|
||||
|
|
@ -413,10 +774,11 @@ public static class PortalVisibilityBuilder
|
|||
}
|
||||
|
||||
// "Eye standing in the opening": the eye is within this perpendicular distance of a portal's
|
||||
// plane. At the cottage doorway the live capture measured D=0.16 m for the portal the chase
|
||||
// camera was standing in; 0.5 m comfortably covers a doorway-standing eye while excluding portals
|
||||
// the eye is merely facing from across a room (their projection is non-degenerate anyway).
|
||||
private const float EyeStandingPerpDist = 0.5f;
|
||||
// plane. Live captures hit two retail-valid degenerate cases: cottage doorway D=0.16 m and
|
||||
// cellar->stair portal D=1.41 m, both traversable but ProjectToNdc returned zero vertices. We still
|
||||
// require the perpendicular projection to land inside the opening, so side/offscreen portals stay
|
||||
// culled; this only covers active portals whose 2D projection collapses near the chase camera.
|
||||
private const float EyeStandingPerpDist = 1.75f;
|
||||
|
||||
/// <summary>
|
||||
/// True when the camera eye is "standing in" <paramref name="localPoly"/>'s opening: within
|
||||
|
|
|
|||
374
src/AcDream.App/Rendering/RetailPViewRenderer.cs
Normal file
374
src/AcDream.App/Rendering/RetailPViewRenderer.cs
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.World;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// App-layer port of the retail indoor render orchestration:
|
||||
/// SmartBox::RenderNormalMode -> RenderDeviceD3D::DrawInside ->
|
||||
/// PView::DrawInside -> ConstructView -> DrawCells.
|
||||
/// </summary>
|
||||
public sealed class RetailPViewRenderer
|
||||
{
|
||||
private readonly GL _gl;
|
||||
private readonly ClipFrame _clipFrame;
|
||||
private readonly EnvCellRenderer _envCells;
|
||||
private readonly WbDrawDispatcher _entities;
|
||||
|
||||
private static readonly ClipViewSlice NoClipSlice =
|
||||
new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty<Vector4>());
|
||||
|
||||
private readonly HashSet<uint> _oneCell = new(1);
|
||||
private readonly Dictionary<uint, int> _oneCellSlot = new(1);
|
||||
public RetailPViewRenderer(
|
||||
GL gl,
|
||||
ClipFrame clipFrame,
|
||||
EnvCellRenderer envCells,
|
||||
WbDrawDispatcher entities)
|
||||
{
|
||||
_gl = gl;
|
||||
_clipFrame = clipFrame;
|
||||
_envCells = envCells;
|
||||
_entities = entities;
|
||||
}
|
||||
|
||||
public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ctx);
|
||||
|
||||
var pvFrame = PortalVisibilityBuilder.Build(
|
||||
ctx.RootCell,
|
||||
ctx.ViewerEyePos,
|
||||
ctx.CellLookup,
|
||||
ctx.ViewProjection);
|
||||
|
||||
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
|
||||
UploadClipFrame(ctx.SetTerrainClipUbo);
|
||||
|
||||
// R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the
|
||||
// assembler handed a clip-slot. This feeds the Prepare filter + entity partition,
|
||||
// so every visible cell's shell has a prepared batch and seals — killing the grey
|
||||
// (the old clipAssembly.CellIdToSlot.Keys filter silently dropped slot-less cells).
|
||||
// Per-slice trim still applies in DrawEnvCellShells (Task 4 makes it self-contained).
|
||||
var drawableCells = new HashSet<uint>(pvFrame.OrderedVisibleCells);
|
||||
UseIndoorMembershipOnlyRouting();
|
||||
|
||||
_envCells.PrepareRenderBatches(
|
||||
ctx.ViewProjection,
|
||||
ctx.CameraWorldPosition,
|
||||
filter: drawableCells,
|
||||
centerLbX: ctx.RenderCenterLbX,
|
||||
centerLbY: ctx.RenderCenterLbY,
|
||||
renderRadius: ctx.RenderRadius);
|
||||
|
||||
var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries);
|
||||
var result = new RetailPViewFrameResult
|
||||
{
|
||||
PortalFrame = pvFrame,
|
||||
ClipAssembly = clipAssembly,
|
||||
DrawableCells = drawableCells,
|
||||
Partition = partition,
|
||||
};
|
||||
|
||||
ctx.EmitDiagnostics?.Invoke(result);
|
||||
|
||||
DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition);
|
||||
UseIndoorMembershipOnlyRouting();
|
||||
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
|
||||
DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells);
|
||||
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public RetailPViewFrameResult? DrawPortal(RetailPViewPortalDrawContext ctx)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ctx);
|
||||
|
||||
var pvFrame = PortalVisibilityBuilder.BuildFromExterior(
|
||||
ctx.CandidateCells,
|
||||
ctx.ViewerEyePos,
|
||||
ctx.CellLookup,
|
||||
ctx.ViewProjection,
|
||||
ctx.MaxSeedDistance);
|
||||
|
||||
if (pvFrame.OrderedVisibleCells.Count == 0)
|
||||
{
|
||||
RestoreNoClip(ctx.SetTerrainClipUbo);
|
||||
return null;
|
||||
}
|
||||
|
||||
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
|
||||
UploadClipFrame(ctx.SetTerrainClipUbo);
|
||||
|
||||
var drawableCells = new HashSet<uint>(clipAssembly.CellIdToSlot.Keys);
|
||||
UseIndoorMembershipOnlyRouting();
|
||||
|
||||
_envCells.PrepareRenderBatches(
|
||||
ctx.ViewProjection,
|
||||
ctx.CameraWorldPosition,
|
||||
filter: drawableCells,
|
||||
centerLbX: ctx.RenderCenterLbX,
|
||||
centerLbY: ctx.RenderCenterLbY,
|
||||
renderRadius: ctx.RenderRadius);
|
||||
|
||||
var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries);
|
||||
var result = new RetailPViewFrameResult
|
||||
{
|
||||
PortalFrame = pvFrame,
|
||||
ClipAssembly = clipAssembly,
|
||||
DrawableCells = drawableCells,
|
||||
Partition = partition,
|
||||
};
|
||||
|
||||
ctx.EmitDiagnostics?.Invoke(result);
|
||||
|
||||
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
|
||||
DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells);
|
||||
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition);
|
||||
RestoreNoClip(ctx.SetTerrainClipUbo);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void DrawLandscapeThroughOutsideView(
|
||||
RetailPViewDrawContext ctx,
|
||||
ClipFrameAssembly clipAssembly,
|
||||
InteriorEntityPartition.Result partition)
|
||||
{
|
||||
if (clipAssembly.OutsideViewSlices.Length == 0)
|
||||
return;
|
||||
|
||||
foreach (var slice in clipAssembly.OutsideViewSlices)
|
||||
{
|
||||
_clipFrame.SetTerrainClip(slice.Planes);
|
||||
UploadClipFrame(ctx.SetTerrainClipUbo);
|
||||
_entities.SetClipRouting(clipAssembly.CellIdToSlot, slice.Slot, outdoorVisible: true);
|
||||
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, partition.Outdoor));
|
||||
}
|
||||
|
||||
foreach (var slice in clipAssembly.OutsideViewSlices)
|
||||
ctx.ClearDepthSlice?.Invoke(slice);
|
||||
|
||||
UseIndoorMembershipOnlyRouting();
|
||||
}
|
||||
|
||||
private void DrawExitPortalMasks(
|
||||
IRetailPViewCellDrawCallbacks ctx,
|
||||
PortalVisibilityFrame pvFrame,
|
||||
ClipFrameAssembly clipAssembly,
|
||||
HashSet<uint> drawableCells)
|
||||
{
|
||||
if (ctx.DrawExitPortalMasks is null)
|
||||
return;
|
||||
|
||||
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
||||
{
|
||||
uint cellId = pvFrame.OrderedVisibleCells[i];
|
||||
if (!drawableCells.Contains(cellId))
|
||||
continue;
|
||||
|
||||
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
|
||||
ctx.DrawExitPortalMasks(new RetailPViewCellSliceContext(cellId, slice, Array.Empty<WorldEntity>()));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawEnvCellShells(
|
||||
IRetailPViewCellDrawCallbacks ctx,
|
||||
PortalVisibilityFrame pvFrame,
|
||||
ClipFrameAssembly clipAssembly,
|
||||
HashSet<uint> drawableCells) // param kept this task; removed in Task 4
|
||||
{
|
||||
// Retail DrawCells Loop 2: every visible cell's shell, reverse cell_draw_list
|
||||
// (far→near), per portal_view slice. No drawableCells filter — a cell without a
|
||||
// clip-slot falls through GetCellSlicesOrNoClip to NoClipSlice and draws unclipped
|
||||
// (sealed; per-slice trim returns in Task 4).
|
||||
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
|
||||
{
|
||||
uint cellId = entry.CellId;
|
||||
_oneCell.Clear();
|
||||
_oneCell.Add(cellId);
|
||||
|
||||
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
|
||||
{
|
||||
UseShellClipRouting(cellId, slice);
|
||||
_envCells.Render(WbRenderPass.Opaque, _oneCell);
|
||||
_envCells.Render(WbRenderPass.Transparent, _oneCell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawCellObjectLists(
|
||||
IRetailPViewCellDrawContext ctx,
|
||||
PortalVisibilityFrame pvFrame,
|
||||
ClipFrameAssembly clipAssembly,
|
||||
HashSet<uint> drawableCells,
|
||||
InteriorEntityPartition.Result partition)
|
||||
{
|
||||
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
||||
{
|
||||
uint cellId = pvFrame.OrderedVisibleCells[i];
|
||||
if (!drawableCells.Contains(cellId))
|
||||
continue;
|
||||
|
||||
if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0)
|
||||
continue;
|
||||
|
||||
_oneCell.Clear();
|
||||
_oneCell.Add(cellId);
|
||||
|
||||
UseIndoorMembershipOnlyRouting();
|
||||
DrawEntityBucket(ctx, bucket, _oneCell);
|
||||
|
||||
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
|
||||
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, bucket));
|
||||
}
|
||||
}
|
||||
|
||||
private static ClipViewSlice[] GetCellSlicesOrNoClip(
|
||||
ClipFrameAssembly clipAssembly,
|
||||
uint cellId)
|
||||
{
|
||||
if (clipAssembly.CellIdToViewSlices.TryGetValue(cellId, out var slices)
|
||||
&& slices.Length > 0)
|
||||
return slices;
|
||||
|
||||
return new[] { NoClipSlice };
|
||||
}
|
||||
|
||||
private void UseIndoorMembershipOnlyRouting()
|
||||
{
|
||||
// Retail's PView portal views decide which cells/objects are eligible,
|
||||
// but DrawMesh only performs portal-view visibility checks before drawing
|
||||
// the mesh. Feeding those 2D views into gl_ClipDistance slices characters
|
||||
// and cell shells at stair/door boundaries, which retail does not do.
|
||||
_envCells.SetClipRouting(null);
|
||||
_entities.ClearClipRouting();
|
||||
}
|
||||
|
||||
private void UseShellClipRouting(uint cellId, ClipViewSlice slice)
|
||||
{
|
||||
_oneCellSlot.Clear();
|
||||
_oneCellSlot[cellId] = slice.Slot;
|
||||
_envCells.SetClipRouting(_oneCellSlot);
|
||||
_entities.ClearClipRouting();
|
||||
}
|
||||
|
||||
private void DrawEntityBucket(
|
||||
IRetailPViewCellDrawContext ctx,
|
||||
IReadOnlyList<WorldEntity> bucket,
|
||||
HashSet<uint>? visibleCellIds)
|
||||
{
|
||||
uint lbId = ctx.PlayerLandblockId ?? 0u;
|
||||
var entry = (lbId, Vector3.Zero, Vector3.Zero,
|
||||
(IReadOnlyList<WorldEntity>)bucket,
|
||||
(IReadOnlyDictionary<uint, WorldEntity>?)null);
|
||||
|
||||
_entities.Draw(
|
||||
ctx.Camera,
|
||||
new[] { entry },
|
||||
ctx.Frustum,
|
||||
neverCullLandblockId: ctx.PlayerLandblockId,
|
||||
visibleCellIds: visibleCellIds,
|
||||
animatedEntityIds: ctx.AnimatedEntityIds);
|
||||
}
|
||||
|
||||
private void RestoreNoClip(Action<uint> setTerrainClipUbo)
|
||||
{
|
||||
_clipFrame.Reset();
|
||||
UploadClipFrame(setTerrainClipUbo);
|
||||
UseIndoorMembershipOnlyRouting();
|
||||
}
|
||||
|
||||
private void UploadClipFrame(Action<uint> setTerrainClipUbo)
|
||||
{
|
||||
_clipFrame.UploadShared(_gl);
|
||||
_entities.SetClipRegionSsbo(_clipFrame.RegionSsbo);
|
||||
_envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo);
|
||||
setTerrainClipUbo(_clipFrame.TerrainUbo);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IRetailPViewCellDrawCallbacks
|
||||
{
|
||||
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; }
|
||||
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; }
|
||||
}
|
||||
|
||||
public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
|
||||
{
|
||||
public ICamera Camera { get; }
|
||||
public FrustumPlanes? Frustum { get; }
|
||||
public uint? PlayerLandblockId { get; }
|
||||
public HashSet<uint>? AnimatedEntityIds { get; }
|
||||
}
|
||||
|
||||
public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
|
||||
{
|
||||
public required LoadedCell RootCell { get; init; }
|
||||
public required Vector3 ViewerEyePos { get; init; }
|
||||
public required Matrix4x4 ViewProjection { get; init; }
|
||||
public required Func<uint, LoadedCell?> CellLookup { get; init; }
|
||||
public required ICamera Camera { get; init; }
|
||||
public required Vector3 CameraWorldPosition { get; init; }
|
||||
public required FrustumPlanes? Frustum { get; init; }
|
||||
public required uint? PlayerLandblockId { get; init; }
|
||||
public required HashSet<uint>? AnimatedEntityIds { get; init; }
|
||||
public required int RenderCenterLbX { get; init; }
|
||||
public required int RenderCenterLbY { get; init; }
|
||||
public required int RenderRadius { get; init; }
|
||||
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
|
||||
public required Action<uint> SetTerrainClipUbo { get; init; }
|
||||
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
|
||||
public Action<ClipViewSlice>? ClearDepthSlice { get; init; }
|
||||
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
|
||||
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
|
||||
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RetailPViewPortalDrawContext : IRetailPViewCellDrawContext
|
||||
{
|
||||
public required IEnumerable<LoadedCell> CandidateCells { get; init; }
|
||||
public required Vector3 ViewerEyePos { get; init; }
|
||||
public required Matrix4x4 ViewProjection { get; init; }
|
||||
public required Func<uint, LoadedCell?> CellLookup { get; init; }
|
||||
public required ICamera Camera { get; init; }
|
||||
public required Vector3 CameraWorldPosition { get; init; }
|
||||
public required FrustumPlanes? Frustum { get; init; }
|
||||
public required uint? PlayerLandblockId { get; init; }
|
||||
public required HashSet<uint>? AnimatedEntityIds { get; init; }
|
||||
public required int RenderCenterLbX { get; init; }
|
||||
public required int RenderCenterLbY { get; init; }
|
||||
public required int RenderRadius { get; init; }
|
||||
public required float MaxSeedDistance { get; init; }
|
||||
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
|
||||
public required Action<uint> SetTerrainClipUbo { get; init; }
|
||||
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
|
||||
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
|
||||
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RetailPViewFrameResult
|
||||
{
|
||||
public required PortalVisibilityFrame PortalFrame { get; init; }
|
||||
public required ClipFrameAssembly ClipAssembly { get; init; }
|
||||
public required HashSet<uint> DrawableCells { get; init; }
|
||||
public required InteriorEntityPartition.Result Partition { get; init; }
|
||||
}
|
||||
|
||||
public readonly record struct RetailPViewLandscapeSliceContext(
|
||||
ClipViewSlice Slice,
|
||||
IReadOnlyList<WorldEntity> OutdoorEntities);
|
||||
|
||||
public readonly record struct RetailPViewCellSliceContext(
|
||||
uint CellId,
|
||||
ClipViewSlice Slice,
|
||||
IReadOnlyList<WorldEntity> CellEntities);
|
||||
|
|
@ -1291,21 +1291,24 @@ namespace AcDream.App.Rendering.Wb {
|
|||
ct.ThrowIfCancellationRequested();
|
||||
if (poly.VertexIds.Count < 3) continue;
|
||||
|
||||
// Handle Positive Surface
|
||||
if (!poly.Stippling.HasFlag(StipplingType.NoPos)) {
|
||||
AddSurfaceToBatch(poly, poly.PosSurface, false);
|
||||
// Retail D3DPolyRender::ConstructMesh (0x0059dfa0) treats this
|
||||
// DatReaderWriter "CullMode" as CPolygon::sides_type, not as a
|
||||
// GL cull enum: 0 = pos, 1 = pos twice with reversed winding,
|
||||
// 2 = pos + neg surface. The DAT-side NoPos/NoNeg flags still
|
||||
// suppress hidden portal/cap faces before they reach our mesh.
|
||||
bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos);
|
||||
bool hasNeg = !poly.Stippling.HasFlag(StipplingType.NoNeg);
|
||||
|
||||
if (hasPos)
|
||||
AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: false, reverseWinding: false);
|
||||
if (hasPos && poly.SidesType == CullMode.None) {
|
||||
AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: true, reverseWinding: true);
|
||||
}
|
||||
else if (hasNeg && poly.SidesType == CullMode.Clockwise) {
|
||||
AddSurfaceToBatch(poly, poly.NegSurface, useNegUv: true, invertNormal: true, reverseWinding: false);
|
||||
}
|
||||
|
||||
// Handle Negative Surface
|
||||
bool hasNeg = poly.Stippling.HasFlag(StipplingType.Negative) ||
|
||||
poly.Stippling.HasFlag(StipplingType.Both) ||
|
||||
(!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise);
|
||||
|
||||
if (hasNeg) {
|
||||
AddSurfaceToBatch(poly, poly.NegSurface, true);
|
||||
}
|
||||
|
||||
void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool isNeg) {
|
||||
void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool useNegUv, bool invertNormal, bool reverseWinding) {
|
||||
if (surfaceIdx < 0) return;
|
||||
|
||||
uint surfaceId;
|
||||
|
|
@ -1499,7 +1502,17 @@ namespace AcDream.App.Rendering.Wb {
|
|||
|
||||
// Helper for CellStruct vertices
|
||||
bool batchHasWrappingUVs = batch.HasWrappingUVs;
|
||||
BuildCellStructPolygonIndices(poly, cellStruct, UVLookup, vertices, batch.Indices, isNeg, transform, ref batchHasWrappingUVs);
|
||||
BuildCellStructPolygonIndices(
|
||||
poly,
|
||||
cellStruct,
|
||||
UVLookup,
|
||||
vertices,
|
||||
batch.Indices,
|
||||
useNegUv,
|
||||
invertNormal,
|
||||
reverseWinding,
|
||||
transform,
|
||||
ref batchHasWrappingUVs);
|
||||
batch.HasWrappingUVs = batchHasWrappingUVs;
|
||||
}
|
||||
}
|
||||
|
|
@ -1516,8 +1529,10 @@ namespace AcDream.App.Rendering.Wb {
|
|||
}
|
||||
|
||||
private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct,
|
||||
Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup,
|
||||
List<VertexPositionNormalTexture> vertices, List<ushort> indices, bool useNegSurface, Matrix4x4 transform, ref bool hasWrappingUVs) {
|
||||
Dictionary<(ushort vertId, ushort uvIdx, bool invertNormal), ushort> UVLookup,
|
||||
List<VertexPositionNormalTexture> vertices, List<ushort> indices,
|
||||
bool useNegUv, bool invertNormal, bool reverseWinding,
|
||||
Matrix4x4 transform, ref bool hasWrappingUVs) {
|
||||
|
||||
var polyIndices = new List<ushort>();
|
||||
|
||||
|
|
@ -1525,9 +1540,9 @@ namespace AcDream.App.Rendering.Wb {
|
|||
ushort vertId = (ushort)poly.VertexIds[i];
|
||||
ushort uvIdx = 0;
|
||||
|
||||
if (useNegSurface && poly.NegUVIndices != null && i < poly.NegUVIndices.Count)
|
||||
if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count)
|
||||
uvIdx = poly.NegUVIndices[i];
|
||||
else if (!useNegSurface && poly.PosUVIndices != null && i < poly.PosUVIndices.Count)
|
||||
else if (poly.PosUVIndices != null && i < poly.PosUVIndices.Count)
|
||||
uvIdx = poly.PosUVIndices[i];
|
||||
|
||||
if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue;
|
||||
|
|
@ -1536,7 +1551,7 @@ namespace AcDream.App.Rendering.Wb {
|
|||
uvIdx = 0;
|
||||
}
|
||||
|
||||
var key = (vertId, uvIdx, useNegSurface);
|
||||
var key = (vertId, uvIdx, invertNormal);
|
||||
|
||||
if (!hasWrappingUVs) {
|
||||
var uvCheck = vertex.UVs.Count > 0
|
||||
|
|
@ -1553,7 +1568,7 @@ namespace AcDream.App.Rendering.Wb {
|
|||
: Vector2.Zero;
|
||||
|
||||
var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform));
|
||||
if (useNegSurface) {
|
||||
if (invertNormal) {
|
||||
normal = -normal;
|
||||
}
|
||||
|
||||
|
|
@ -1568,18 +1583,18 @@ namespace AcDream.App.Rendering.Wb {
|
|||
polyIndices.Add(idx);
|
||||
}
|
||||
|
||||
if (useNegSurface) {
|
||||
if (reverseWinding) {
|
||||
for (int i = 2; i < polyIndices.Count; i++) {
|
||||
indices.Add(polyIndices[0]);
|
||||
indices.Add(polyIndices[i - 1]);
|
||||
indices.Add(polyIndices[i]);
|
||||
indices.Add(polyIndices[i - 1]);
|
||||
indices.Add(polyIndices[0]);
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (int i = 2; i < polyIndices.Count; i++) {
|
||||
indices.Add(polyIndices[i]);
|
||||
indices.Add(polyIndices[i - 1]);
|
||||
indices.Add(polyIndices[0]);
|
||||
indices.Add(polyIndices[i - 1]);
|
||||
indices.Add(polyIndices[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,8 +144,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// each Draw. When _clipRoutingActive is false (the U.3 path / outdoor root /
|
||||
// no portal frame), every instance maps to slot 0 (no-clip) and no instance is
|
||||
// culled — identical to U.3. When active, each instance's slot is resolved by
|
||||
// ResolveEntitySlot per the U.4 policy (live-dynamic unclipped; cell statics to
|
||||
// their cell slot; outdoor scenery to the OutsideView slot; non-visible culled).
|
||||
// ResolveEntitySlot per the U.4 policy (cell-owned entities to their cell slot;
|
||||
// outdoor-owned entities to OutsideView; non-visible/unresolved indoors culled).
|
||||
private bool _clipRoutingActive;
|
||||
private IReadOnlyDictionary<uint, int>? _cellIdToSlot;
|
||||
private int _outdoorSlot;
|
||||
|
|
@ -310,8 +310,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
/// Phase U.4: install the per-frame clip-slot routing for an INDOOR root.
|
||||
/// Call once per frame BEFORE <see cref="Draw"/> when the camera's root cell is
|
||||
/// non-null; the next <see cref="Draw"/> resolves each instance's binding=3
|
||||
/// clip slot via the U.4 policy (live-dynamic unclipped, cell statics to their
|
||||
/// cell slot, outdoor scenery to the OutsideView slot, non-visible culled).
|
||||
/// clip slot via the U.4 policy (cell-owned entities to their cell slot,
|
||||
/// outdoor-owned entities to OutsideView, non-visible/unresolved indoors culled).
|
||||
/// Pair with <see cref="ClearClipRouting"/> on outdoor-root frames so the
|
||||
/// dispatcher reverts to the U.3 no-clip-everything behavior.
|
||||
/// </summary>
|
||||
|
|
@ -354,12 +354,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
/// Phase U.4: resolve the clip slot for one entity per the slot/gate policy.
|
||||
/// Returns <see cref="ClipSlotCull"/> to drop the entity's instances entirely.
|
||||
/// <list type="bullet">
|
||||
/// <item>ServerGuid != 0 (live dynamic: player / NPC / items / doors) ⇒ slot 0
|
||||
/// (UNCLIPPED — retail draws live-dynamic unclipped; depth only).</item>
|
||||
/// <item>ParentCellId != null (cell static) ⇒ the cell's slot, or CULL when the
|
||||
/// cell isn't in <paramref name="cellIdToSlot"/> (not visible / nothing-visible).</item>
|
||||
/// <item>ParentCellId == null (outdoor scenery / building shell) ⇒ the OutsideView
|
||||
/// slot when <paramref name="outdoorVisible"/>, else CULL.</item>
|
||||
/// <item>Indoor ParentCellId: the cell's slot, or CULL when hidden.</item>
|
||||
/// <item>Outdoor ParentCellId or ParentCellId == null static scenery: the OutsideView slot
|
||||
/// when <paramref name="outdoorVisible"/>, else CULL.</item>
|
||||
/// <item>ServerGuid != 0 with ParentCellId == null: CULL while routing is active.</item>
|
||||
/// </list>
|
||||
/// Only called when <c>_clipRoutingActive</c> (indoor root). On the U.3 / outdoor
|
||||
/// path every instance is slot 0 and nothing is culled — see
|
||||
|
|
@ -385,20 +383,37 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
int outdoorSlot,
|
||||
bool outdoorVisible)
|
||||
{
|
||||
// Live-dynamic entities render unclipped regardless of cell — retail draws
|
||||
// the player / NPCs / dropped items through the depth buffer without portal
|
||||
// clipping. ServerGuid is the live-dynamic marker (0 for dat-hydrated).
|
||||
if (serverGuid != 0)
|
||||
return 0;
|
||||
|
||||
// Live-dynamic entities are not a global indoor overlay. When they
|
||||
// have current cell ownership, route them through the same visible
|
||||
// cell/OutsideView graph as every other object. Parentless live objects
|
||||
// are unresolved indoors, so cull them while clip routing is active.
|
||||
if (parentCellId is uint parentCell)
|
||||
return cellIdToSlot.TryGetValue(parentCell, out int slot) ? slot : ClipSlotCull;
|
||||
{
|
||||
if (IsIndoorCellId(parentCell))
|
||||
{
|
||||
if (!cellIdToSlot.ContainsKey(parentCell))
|
||||
return ClipSlotCull;
|
||||
|
||||
return cellIdToSlot[parentCell];
|
||||
}
|
||||
|
||||
return outdoorVisible ? outdoorSlot : ClipSlotCull;
|
||||
}
|
||||
|
||||
if (serverGuid != 0)
|
||||
return ClipSlotCull;
|
||||
|
||||
// Outdoor scenery / building shell (no ParentCellId). Indoor root: gate to
|
||||
// the OutsideView slot, or cull when nothing outdoors is visible.
|
||||
return outdoorVisible ? outdoorSlot : ClipSlotCull;
|
||||
}
|
||||
|
||||
private static bool IsIndoorCellId(uint cellId)
|
||||
{
|
||||
uint low = cellId & 0xFFFFu;
|
||||
return low >= 0x0100u && low != 0xFFFFu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase U.4: the call-site clip-slot decision for one entity, returning the
|
||||
/// <c>(Slot, Culled)</c> pair the per-entity loop body consumes. Wraps
|
||||
|
|
|
|||
|
|
@ -280,9 +280,9 @@ public static class RenderingDiagnostics
|
|||
/// DrawInside vs the outdoor <c>LScape::draw</c> on <c>is_player_outside</c> — the
|
||||
/// <b>PLAYER's</b> cell (<c>(player->m_position.objcell_id & 0xFFFF) < 0x100</c>,
|
||||
/// <c>SmartBox::is_player_outside</c> 0x451e80) — NOT the camera/viewer cell. When the
|
||||
/// player is inside it then roots the flood at the <b>viewer</b> cell
|
||||
/// (<c>this->viewer_cell</c>). So the inside/outside <i>decision</i> follows the player;
|
||||
/// only the indoor <i>root</i> follows the camera.</para>
|
||||
/// player is inside, acdream roots the portal flood at the player's transition-owned
|
||||
/// physics cell and projects from the camera eye, so the shell around the player remains
|
||||
/// sealed during chase-camera cell transitions.</para>
|
||||
///
|
||||
/// <para>acdream historically branched on the camera cell (a non-null
|
||||
/// <c>visibility.CameraCell</c>). A 3rd-person chase camera lags the player, so when the
|
||||
|
|
@ -292,9 +292,9 @@ public static class RenderingDiagnostics
|
|||
/// only entities (which bypass the gate) showing through. Branching on the player removes it.</para>
|
||||
///
|
||||
/// <param name="playerCellId">The player's current cell id (0 if unresolved → outside).</param>
|
||||
/// <param name="viewerCellResolved">Whether a viewer/camera cell is available to root
|
||||
/// DrawInside at. Indoor render needs both: the player inside AND a cell to root at.</param>
|
||||
/// <param name="renderRootResolved">Whether the player's indoor render root is loaded and
|
||||
/// available to DrawInside.</param>
|
||||
/// </summary>
|
||||
public static bool ShouldRenderIndoor(uint playerCellId, bool viewerCellResolved)
|
||||
=> viewerCellResolved && IsEnvCellId(playerCellId);
|
||||
public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved)
|
||||
=> renderRootResolved && IsEnvCellId(playerCellId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,12 +37,13 @@ public sealed class WorldEntity
|
|||
public PaletteOverride? PaletteOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EnvCell ID that owns this entity (room geometry or static object inside
|
||||
/// EnvCell or outdoor cell ID that owns this entity (room geometry, static
|
||||
/// object, or live object inside/outside a cell).
|
||||
/// the cell). Used by portal visibility to filter interior entities — only
|
||||
/// entities whose ParentCellId appears in the visible set are rendered.
|
||||
/// Null for outdoor entities (stabs, scenery, live server spawns).
|
||||
/// Null for outdoor dat scenery/building stabs or unresolved live entities.
|
||||
/// </summary>
|
||||
public uint? ParentCellId { get; init; }
|
||||
public uint? ParentCellId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when this entity originates from <c>LandBlockInfo.Buildings[]</c>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue