feat(render): Phase A8 R3 — wire stencil pipeline into render frame (WB order)

Replaces the pre-A8 single dispatcher call with the WB RenderInsideOut
order when cameraInsideCell:

  1. Terrain draws normally (color + depth)
  2. depth-clear-if-inside (depth = 1.0 globally)
  3. MarkAndPunch — stencil bit 1 at camera's-own-cell exit portals
  4. IndoorPass — cell mesh + cell statics + building shells, stencil OFF
  5. EnableOutdoorPass + re-draw terrain + OutdoorScenery, stencil-gated
  6. DisableStencil + LiveDynamic, depth-test only

Outdoor (cameraInsideCell == false) path unchanged: single Draw(set: All).

Step 5 (WB's 3-stencil-bit cross-cell-portal pipeline) is DEFERRED — we
mark only the camera's own cell's exit portals via [visibility.CameraCell],
not the BFS-extended VisibleCellIds. Trade-off documented in
docs/research/2026-05-26-a8-entity-taxonomy.md §"open questions".

Adds IndoorCellStencilPipeline field + ctor wiring + Dispose. Field types
the partition consumers from R2; the ParentCellId / IsBuildingShell /
ServerGuid distinctions are now consumed at runtime.

Visual verification at cottage interior / cottage cellar / inn interior /
dungeon is R4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-26 11:46:45 +02:00
parent 55f26f2a9c
commit 60f07bc21b

View file

@ -36,6 +36,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter; private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator; private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator;
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher; private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
private IndoorCellStencilPipeline? _indoorStencilPipeline;
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters /// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
/// support. Required at startup — missing bindless throws /// support. Required at startup — missing bindless throws
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary> /// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
@ -1752,6 +1753,15 @@ public sealed class GameWindow : IDisposable
_classificationCache); _classificationCache);
// A.5 T22.5: apply A2C gate from quality preset. // A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
// Phase A8 R3 — indoor visibility culling pipeline. Owns the
// portal_stencil shader pair and a dynamic VBO for per-frame
// portal triangle uploads. Stencil work runs only when the
// camera is inside an EnvCell; outside, the object is dormant.
_indoorStencilPipeline = new IndoorCellStencilPipeline(
_gl,
Path.Combine(shadersDir, "portal_stencil.vert"),
Path.Combine(shadersDir, "portal_stencil.frag"));
} }
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
@ -7119,14 +7129,10 @@ public sealed class GameWindow : IDisposable
if (cameraInsideCell) if (cameraInsideCell)
_gl!.Clear(ClearBufferMask.DepthBufferBit); _gl!.Clear(ClearBufferMask.DepthBufferBit);
// L-fix1 (2026-04-28): pass the set of animated-entity ids so // L-fix1 (2026-04-28): animated-entity id set. Required by both
// the renderer keeps remote players / NPCs / monsters // the cameraInsideCell branch (to route them to LiveDynamic pass)
// visible even when their landblock rotates out of the // and the outdoor path (where it preserves visibility across
// frustum. Without this, other characters wink in/out as // landblock frustum culling).
// the camera turns. The set is rebuilt per-frame from
// _animatedEntities — it's small (<100 entities typically)
// so HashSet allocation is cheap. Static scenery still
// respects landblock-level cull.
HashSet<uint>? animatedIds = null; HashSet<uint>? animatedIds = null;
if (_animatedEntities.Count > 0) if (_animatedEntities.Count > 0)
{ {
@ -7135,11 +7141,75 @@ public sealed class GameWindow : IDisposable
animatedIds.Add(k); animatedIds.Add(k);
} }
// N.5: WbDrawDispatcher is always non-null (modern path mandatory). if (cameraInsideCell && _indoorStencilPipeline is not null
&& visibility?.CameraCell is not null)
{
// Phase A8 R3 — WB RenderInsideOut order.
//
// 1. Terrain has already drawn (color + depth) at line ~7104.
// 2. depth-clear-if-inside has already cleared depth to 1.0
// (above this branch at line ~7118). The MarkAndPunch
// below is a no-op against that baseline — left in for
// symmetry with WB's reference pipeline and to handle
// the unusual case where depth-clear is later dropped.
// 3. MarkAndPunch — stencil bit 1 at the camera's-own-cell
// exit portals. Step 5 (cross-cell-portal visibility via
// 3-stencil-bit pipeline) is DEFERRED — we mark ONLY the
// camera's own cell's portals, not the BFS-extended
// VisibleCellIds. Trade-off: cross-cell visibility loss
// (rare visually); correctness in the common case (no
// see-through-wall to far-side portal openings).
var cameraCells = new[] { visibility.CameraCell };
_indoorStencilPipeline.UploadPortalMesh(cameraCells);
var viewProjection = camera.View * camera.Projection;
_indoorStencilPipeline.MarkAndPunch(viewProjection);
// 4. IndoorPass — cell mesh + cell statics + building shells
// (R1's IsBuildingShell flag drives the partition).
// Stencil OFF (MarkAndPunch's cleanup restored that).
// Depth test normal; building shells write the wall depth
// that protects the indoor from outdoor visibility.
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility.VisibleCellIds,
animatedEntityIds: animatedIds,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.IndoorPass);
// 5. Stencil-gated outdoor pass.
_indoorStencilPipeline.EnableOutdoorPass();
// 5a. Re-draw terrain — at portal-silhouette pixels only,
// terrain Z (with the f48c74a -0.01 nudge) wins over the
// punched 1.0 depth. Color writes through window.
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
// 5b. Outdoor scenery — same stencil gating.
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility.VisibleCellIds,
animatedEntityIds: animatedIds,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
// 6. Stencil OFF — live dynamic entities draw freely with
// depth test only (no stencil clipping).
_indoorStencilPipeline.DisableStencil();
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility.VisibleCellIds,
animatedEntityIds: animatedIds,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.LiveDynamic);
}
else
{
// Outdoor path — unchanged from pre-A8: single dispatcher call
// walks every entity with default EntitySet.All partition.
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb, neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds, visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds); animatedEntityIds: animatedIds);
}
// Phase G.1 / E.3: draw all live particles after opaque // Phase G.1 / E.3: draw all live particles after opaque
// scene geometry so alpha blending composites correctly. // scene geometry so alpha blending composites correctly.
@ -10495,6 +10565,7 @@ public sealed class GameWindow : IDisposable
_liveSession = null; _liveSession = null;
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose(); _wbDrawDispatcher?.Dispose();
_indoorStencilPipeline?.Dispose();
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first _skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_samplerCache?.Dispose(); _samplerCache?.Dispose();
_textureCache?.Dispose(); _textureCache?.Dispose();