feat(render): R1 — binary render decision, indoor = per-cell DrawInside only

GameWindow.OnRender: when clipRoot != null, run only InteriorRenderer.DrawInside
(per-cell shells + per-cell objects + live-dynamics); the global entity pass +
global shell pass are no longer issued indoors. Outdoor scenery drawn clipped to
the doorway (after terrain, before the Z-clear). Outdoor root path unchanged.
pvFrame hoisted so the splice reads OrderedVisibleCells; per-frame 3-bucket
partition built on the indoor root. Retail RenderNormalMode @ 0x453aa0.

InteriorRenderer amended with a DrawableCells membership filter (an IsNothingVisible
cell can be in OrderedVisibleCells but absent from CellIdToSlot — iterate for ORDER,
filter for membership; matches the old envCellShellFilter set exactly).

Build green, 174/174 App tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 20:01:53 +02:00
parent 4b75c68ea3
commit c4fd71149a
2 changed files with 72 additions and 26 deletions

View file

@ -166,6 +166,12 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer;
private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum;
// R1 (render redesign): the per-cell DrawInside flood + its per-frame entity partition.
// _interiorRenderer is constructed once both renderers exist; _interiorPartition is rebuilt
// each frame on an indoor root (null on the outdoor root).
private AcDream.App.Rendering.InteriorRenderer? _interiorRenderer;
private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition;
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
// UBO). In U.3 a single ClipFrame.NoClip() instance is created lazily (??=) and
// REUSED across frames — its GL buffers persist; only the cheap CPU-side no-clip
@ -1796,6 +1802,9 @@ public sealed class GameWindow : IDisposable
_envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer(
_gl, _wbMeshAdapter!.MeshManager!, _envCellFrustum);
_envCellRenderer.Initialize(_meshShader!);
// R1: the per-cell DrawInside flood. Both renderers exist here (just constructed).
_interiorRenderer = new AcDream.App.Rendering.InteriorRenderer(_envCellRenderer!, _wbDrawDispatcher!);
}
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
@ -7305,6 +7314,7 @@ public sealed class GameWindow : IDisposable
_clipFrame ??= ClipFrame.NoClip();
var clipRoot = visibility?.CameraCell;
ClipFrameAssembly? clipAssembly = null;
PortalVisibilityFrame? pvFrame = null; // R1: hoisted so the binary decision below reads OrderedVisibleCells
var terrainClipMode = TerrainClipMode.Planes; // overwritten below for indoor root
System.Numerics.Vector4 terrainScissorNdc = default;
HashSet<uint>? envCellShellFilter = null; // drawable visible cells (cellIdToSlot keys)
@ -7313,7 +7323,7 @@ public sealed class GameWindow : IDisposable
// Phase U.4c: side test + distance ordering use the PLAYER position (visRootPos,
// stable inside the cell); projection uses the eye's envCellViewProj (the screen
// view). See the visRootPos rationale at the ComputeVisibility call above.
var pvFrame = PortalVisibilityBuilder.Build(
pvFrame = PortalVisibilityBuilder.Build(
clipRoot,
visRootPos,
id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
@ -7332,6 +7342,12 @@ public sealed class GameWindow : IDisposable
// map's keys; IsNothingVisible cells were excluded by the assembler).
envCellShellFilter = new HashSet<uint>(clipAssembly.CellIdToSlot.Keys);
// R1: partition this frame's entities into per-cell / outdoor / live-dynamic buckets
// for the DrawInside flood + the outdoor-scenery-through-door draw. Keyed by the SAME
// visible-cell set the shells use (cellIdToSlot.Keys).
_interiorPartition = AcDream.App.Rendering.InteriorEntityPartition.Partition(
envCellShellFilter, _worldState.LandblockEntries);
// [vis] probe (ACDREAM_PROBE_VIS=1) — the real PortalVisibilityFrame
// numbers, replacing the old camera-state-only spike. Cell-change
// throttled inside EmitVis so launch.log stays readable under motion.
@ -7368,6 +7384,7 @@ public sealed class GameWindow : IDisposable
_clipFrame.Reset();
_wbDrawDispatcher?.ClearClipRouting();
_envCellRenderer?.SetClipRouting(null);
_interiorPartition = null; // R1: no indoor flood on the outdoor root
}
_clipFrame.UploadShared(_gl);
@ -7512,6 +7529,22 @@ public sealed class GameWindow : IDisposable
animatedIds.Add(k);
}
// R1: outdoor scenery (ParentCellId == null) is part of the landscape seen through the
// doorway (retail LScape::draw draws the exterior, clipped to OutsideView). Drawn here —
// after terrain, BEFORE the Z-clear — only on an indoor root, scoped to the outdoor bucket.
// ResolveEntitySlot routes these (ParentCellId == null) to OutdoorSlot when OutdoorVisible,
// else CULLs them, via the SetClipRouting installed above. visibleCellIds: null ⇒ they pass
// the membership gate (no cell filter) and are gated purely by the clip slot.
if (clipAssembly is not null && _interiorPartition is not null
&& _interiorPartition.Outdoor.Count > 0 && clipAssembly.OutdoorVisible)
{
var sceneryEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero,
(IReadOnlyList<AcDream.Core.World.WorldEntity>)_interiorPartition.Outdoor,
(IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity>?)null);
_wbDrawDispatcher!.Draw(camera, new[] { sceneryEntry }, frustum,
neverCullLandblockId: playerLb, visibleCellIds: null, animatedEntityIds: animatedIds);
}
// ── [Stage 4] conditional doorway Z-clear ───────────────────────────────────
// Retail PView::DrawCells @ pseudo_c:432731: after the landscape (sky + terrain) is drawn
// through the exit portal, RenderDevice->Clear(flag 4 = Z-BUFFER ONLY, NOT color) resets
@ -7527,31 +7560,36 @@ public sealed class GameWindow : IDisposable
if (_zc) _gl.Disable(EnableCap.ScissorTest);
}
// Phase U.4: render the indoor cell SHELLS (walls / floors / ceilings)
// — previously DORMANT (EnvCellRenderer.Render was never called in the
// live loop). Inside the clip bracket so each cell's instances are gated
// to its CellClip slot via the binding=3 map we installed above. Opaque
// pass BEFORE the entity dispatcher (front-to-back, depth writes on);
// Transparent pass AFTER. Filter = the drawable visible cells. Only when
// there's an indoor root (clipAssembly != null) — outdoor frames draw no
// shells. PrepareRenderBatches already ran earlier this frame.
if (clipAssembly is not null && envCellShellFilter is not null)
_envCellRenderer?.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, envCellShellFilter);
// Scene entity draw. N.5: WbDrawDispatcher is always non-null
// (modern path mandatory). Default EntitySet.All — every entity
// walked, gated only by the ParentCellId ∈ visibleCellIds filter.
// Phase U.4: per-instance clip slots come from SetClipRouting above
// (indoor root) or ClearClipRouting (outdoor root → every instance slot 0).
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds);
// Phase U.4: cell shells transparent pass (additive / alpha-blend cell
// surfaces, e.g. stained glass). Still inside the clip bracket.
if (clipAssembly is not null && envCellShellFilter is not null)
_envCellRenderer?.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, envCellShellFilter);
// R1 — the binary render decision (retail RenderNormalMode @ 0x453aa0):
// INDOOR root (clipRoot != null): run ONLY the per-cell DrawInside flood. The global
// entity pass + global shell pass are NOT issued — visibility IS the cull, so the
// outdoor world cannot bleed (it is never iterated; outdoor scenery entered above,
// clipped to the doorway). DrawInside draws per-cell shells (opaque + transparent) +
// per-cell objects + live-dynamics, closest-first over the drawable visible cells.
// OUTDOOR root: the existing global entity pass (no shells, no DrawInside).
if (clipRoot is not null && _interiorRenderer is not null
&& _interiorPartition is not null && envCellShellFilter is not null)
{
var interiorCtx = new AcDream.App.Rendering.InteriorRenderContext
{
OrderedVisibleCells = pvFrame!.OrderedVisibleCells,
DrawableCells = envCellShellFilter,
Partition = _interiorPartition,
Camera = camera,
Frustum = frustum,
PlayerLandblockId = playerLb,
AnimatedEntityIds = animatedIds,
};
_interiorRenderer.DrawInside(interiorCtx);
}
else
{
// Outdoor root: the global entity pass (unchanged).
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds);
}
// Phase U.3: close the world-geometry clip bracket opened above. From here down the
// scene particles, debug lines, and UI use shaders that do NOT write gl_ClipDistance, so

View file

@ -11,6 +11,12 @@ public sealed class InteriorRenderContext
/// <summary>Visible cells, closest-first (retail cell_draw_list). From PortalVisibilityFrame.</summary>
public required IReadOnlyList<uint> OrderedVisibleCells { get; init; }
/// <summary>The cells the assembler mapped a clip slot for (ClipFrameAssembly.CellIdToSlot.Keys =
/// the GameWindow envCellShellFilter). A cell may appear in <see cref="OrderedVisibleCells"/> but
/// reduce to IsNothingVisible in the assembler (no slot) — those are skipped. This is the
/// membership filter; <see cref="OrderedVisibleCells"/> supplies the draw ORDER.</summary>
public required IReadOnlySet<uint> DrawableCells { 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>
@ -54,6 +60,7 @@ public sealed class InteriorRenderer
// Loop A — per-cell OPAQUE shell + that cell's static objects (closest-first).
foreach (uint cellId in ctx.OrderedVisibleCells)
{
if (!ctx.DrawableCells.Contains(cellId)) continue; // no clip slot ⇒ assembler culled it
_oneCell.Clear();
_oneCell.Add(cellId);
_envCells.Render(WbRenderPass.Opaque, _oneCell);
@ -70,6 +77,7 @@ public sealed class InteriorRenderer
// Loop B — per-cell TRANSPARENT shells (stained glass / additive cell surfaces).
foreach (uint cellId in ctx.OrderedVisibleCells)
{
if (!ctx.DrawableCells.Contains(cellId)) continue;
_oneCell.Clear();
_oneCell.Add(cellId);
_envCells.Render(WbRenderPass.Transparent, _oneCell);