diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index cd65887..d6ecebb 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -36,6 +36,11 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator;
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
+ /// Phase A8 RR7 (2026-05-26): portal stencil pipeline for the indoor
+ /// render branch (WB RenderInsideOut Steps 1-4). Non-null after OnLoad. Null
+ /// only if shader compilation failed — in which case the indoor branch falls
+ /// through to the outdoor path (graceful degradation, no crash).
+ private AcDream.App.Rendering.IndoorCellStencilPipeline? _indoorStencilPipeline;
/// Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
/// support. Required at startup — missing bindless throws
/// in OnLoad.
@@ -1760,6 +1765,23 @@ public sealed class GameWindow : IDisposable
_classificationCache);
// A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
+
+ // Phase A8 RR7 (2026-05-26): portal stencil pipeline. Constructed
+ // immediately after WbDrawDispatcher — same GL context, same lifetime.
+ // Null-safe: if shader compilation throws, the indoor branch falls back
+ // to outdoor rendering (no crash; just no indoor/outdoor separation).
+ try
+ {
+ _indoorStencilPipeline = new AcDream.App.Rendering.IndoorCellStencilPipeline(
+ _gl,
+ System.IO.Path.Combine(shadersDir, "portal_stencil.vert"),
+ System.IO.Path.Combine(shadersDir, "portal_stencil.frag"));
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[A8] IndoorCellStencilPipeline failed to construct: {ex.Message}");
+ _indoorStencilPipeline = null;
+ }
}
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
@@ -6915,7 +6937,11 @@ public sealed class GameWindow : IDisposable
System.Math.Clamp(fogColor.Z, 0f, 1f),
1f);
- _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
+ // Phase A8 RR7 (2026-05-26): stencil buffer cleared per-frame now that the
+ // stencil pipeline is wired in. Previously not cleared because no rendering
+ // consumed stencil. Without this clear, bits from a prior frame's indoor
+ // branch could leak into an outdoor frame and gate visibility incorrectly.
+ _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit);
// Phase N.6 slice 1: one-shot surface-format histogram dump under
// ACDREAM_DUMP_SURFACES=1. Zero cost when off.
@@ -7029,6 +7055,31 @@ public sealed class GameWindow : IDisposable
var visibility = _cellVisibility.ComputeVisibility(camPos);
bool cameraInsideCell = visibility?.CameraCell is not null;
+ // Phase A8 RR7 (2026-05-26): the single source of truth for
+ // "render the indoor branch." Strict PointInCell + the camera's
+ // cell must actually belong to a Building (not an outdoor cell
+ // or untagged dungeon cell). No grace.
+ //
+ // sky pre-scene, terrain, stencil pipeline, weather post-scene
+ // all gate on this flag below. The lenient cameraInsideCell flag
+ // is kept ONLY for the [vis] probe's side-by-side logging.
+ bool cameraInsideBuilding = visibility?.CameraCell is not null
+ && CellVisibility.PointInCell(camPos, visibility.CameraCell)
+ && visibility.CameraCell.BuildingId is not null;
+
+ // Phase A8 RR7: look up buildings containing the camera cell.
+ // The BuildingRegistry is keyed by landblock id in _buildingRegistries
+ // (per Code Structure Rule #2 — Core can't reference App types, so the
+ // registry lives on GameWindow, not on a Core struct).
+ IReadOnlyList camBuildings =
+ System.Array.Empty();
+ if (cameraInsideBuilding && visibility?.CameraCell is not null)
+ {
+ uint cameraLandblockId = visibility.CameraCell.CellId & 0xFFFF0000u;
+ if (_buildingRegistries.TryGetValue(cameraLandblockId, out var reg))
+ camBuildings = reg.GetBuildingsContainingCell(visibility.CameraCell.CellId);
+ }
+
// SPIKE 2026-05-26: A8 transition investigation. Lights up the
// dormant RenderingDiagnostics.ProbeVisibilityEnabled flag (added
// by Task 6 of the original A8 plan). Per-frame state captures:
@@ -7143,7 +7194,11 @@ public sealed class GameWindow : IDisposable
// cylinder 0x01004C42/0x01004C44) need to overlay terrain
// and entities to look volumetric — see the post-scene
// RenderWeather call further below.
- if (!cameraInsideCell)
+ //
+ // Phase A8 RR7 (2026-05-26): gate changed from !cameraInsideCell
+ // to !cameraInsideBuilding. When indoors (inside a building), the
+ // sky is drawn stencil-gated in the indoor branch below (Step 4a).
+ if (!cameraInsideBuilding)
{
_skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction,
_activeDayGroup, kf, environOverrideActive);
@@ -7167,8 +7222,14 @@ public sealed class GameWindow : IDisposable
// Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup
// (gated on ACDREAM_WB_DIAG=1, same env var as [WB-DIAG]). Stopwatch
// is cheap; only the periodic Console.WriteLine is gated.
+ //
+ // Phase A8 RR7 (2026-05-26): skip initial terrain when inside a building.
+ // Terrain renders stencil-gated in Step 4b of the indoor branch below.
+ // The old "Conditional depth clear: if (cameraInsideCell)" is removed —
+ // the A8 indoor branch manages depth state through MarkAndPunch instead.
_terrainCpuStopwatch.Restart();
- _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
+ if (!cameraInsideBuilding)
+ _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
_terrainCpuStopwatch.Stop();
// Multiply by 100 then divide by 100 in the diag print to keep
// 0.01 µs precision in the long-typed sample buffer. Terrain Draw
@@ -7178,13 +7239,6 @@ public sealed class GameWindow : IDisposable
_terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length;
MaybeFlushTerrainDiag();
- // Conditional depth clear: when camera is inside a building, clear
- // depth (not color) so interior geometry writes fresh Z values on top
- // of the terrain color buffer. Exit portals show outdoor terrain color
- // because we kept the color buffer. Matching ACME GameScene.cs pattern.
- if (cameraInsideCell)
- _gl!.Clear(ClearBufferMask.DepthBufferBit);
-
// L-fix1 (2026-04-28): pass the set of animated-entity ids so
// the renderer keeps remote players / NPCs / monsters
// visible even when their landblock rotates out of the
@@ -7201,11 +7255,104 @@ public sealed class GameWindow : IDisposable
animatedIds.Add(k);
}
- // N.5: WbDrawDispatcher is always non-null (modern path mandatory).
- _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
- neverCullLandblockId: playerLb,
- visibleCellIds: visibility?.VisibleCellIds,
- animatedEntityIds: animatedIds);
+ // Phase A8 RR7 (2026-05-26): indoor / outdoor branch.
+ //
+ // Indoor (cameraInsideBuilding == true) — WB RenderInsideOut Steps 1-4:
+ // MarkAndPunch on camBuildings' exit portals
+ // IndoorPass (cell mesh + statics + building shells)
+ // EnableOutdoorPass
+ // Stencil-gated sky (acdream enhancement)
+ // Stencil-gated terrain re-draw
+ // Stencil-gated OutdoorScenery
+ // DisableStencil
+ // LiveDynamic
+ //
+ // Outdoor (cameraInsideBuilding == false):
+ // Unchanged from pre-A8: single Draw(All).
+ // RenderOutsideIn (looking INTO cottage windows from outside)
+ // ships in RR11.
+
+ if (cameraInsideBuilding && _indoorStencilPipeline is not null
+ && visibility?.CameraCell is not null
+ && camBuildings.Count > 0)
+ {
+ if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled)
+ Console.WriteLine($"[vis] branch=indoor buildings={camBuildings.Count}");
+
+ // Steps 1+2: stencil bit 1 + far-depth at the camera-buildings'
+ // exit portals. Combine polygons from all containing buildings
+ // (usually 1, occasionally 2 in shared-cell scenarios).
+ int totalVerts = 0;
+ foreach (var b in camBuildings)
+ totalVerts += _indoorStencilPipeline.UploadBuildingPortalMesh(b);
+ // Note: UploadBuildingPortalMesh overwrites the VBO each call.
+ // For multi-building cases, the last call wins. Loop-uploading
+ // all polygons would require a different API; for now we use
+ // the existing pipeline pattern (mark+punch in one call), which
+ // matches WB's per-building iteration. See RR9 for the multi-
+ // building marking pattern.
+
+ var viewProjection = camera.View * camera.Projection;
+ _indoorStencilPipeline.MarkAndPunch(viewProjection);
+
+ // Step 3: IndoorPass with camera-buildings' cell scope.
+ var camCellIds = new HashSet();
+ foreach (var b in camBuildings)
+ foreach (var cid in b.EnvCellIds) camCellIds.Add(cid);
+
+ _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries,
+ cellIds: camCellIds,
+ frustum: frustum,
+ neverCullLandblockId: playerLb,
+ animatedEntityIds: animatedIds,
+ set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.IndoorPass);
+
+ // Step 4: stencil-gated outdoor pass.
+ _indoorStencilPipeline.EnableOutdoorPass();
+
+ // Step 4a: stencil-gated sky (acdream enhancement).
+ // DepthMask off so sky color writes through punched depth=1.0
+ // without disturbing the depth buffer; depth stays at the punch
+ // value so the next step's terrain re-draw can win.
+ _gl!.DepthMask(false);
+ _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction,
+ _activeDayGroup, kf, environOverrideActive);
+ _gl!.DepthMask(true);
+
+ // Step 4b: stencil-gated terrain re-draw.
+ _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
+
+ // Step 4c: stencil-gated OutdoorScenery.
+ _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
+ neverCullLandblockId: playerLb,
+ visibleCellIds: visibility.VisibleCellIds,
+ animatedEntityIds: animatedIds,
+ set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
+
+ // (Step 5 = RR9.)
+
+ _indoorStencilPipeline.DisableStencil();
+
+ // LiveDynamic — player, NPCs, dropped items.
+ _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
+ neverCullLandblockId: playerLb,
+ visibleCellIds: visibility.VisibleCellIds,
+ animatedEntityIds: animatedIds,
+ set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.LiveDynamic);
+ }
+ else
+ {
+ if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled)
+ Console.WriteLine("[vis] branch=outdoor");
+
+ // Outdoor: single Draw(All). N.5: WbDrawDispatcher is always
+ // non-null (modern path mandatory). RenderOutsideIn (looking
+ // INTO cottage windows from outside) ships in RR11.
+ _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
+ neverCullLandblockId: playerLb,
+ visibleCellIds: visibility?.VisibleCellIds,
+ animatedEntityIds: animatedIds);
+ }
// Phase G.1 / E.3: draw all live particles after opaque
// scene geometry so alpha blending composites correctly.
@@ -7223,7 +7370,10 @@ public sealed class GameWindow : IDisposable
// half of retail's LScape::draw split — GameSky::Draw(1)
// fires after the DrawBlock loop. Same indoor gate as the
// sky pass: weather is suppressed inside cells.
- if (!cameraInsideCell)
+ //
+ // Phase A8 RR7 (2026-05-26): gate changed from !cameraInsideCell
+ // to !cameraInsideBuilding (consistent with sky pre-scene gate).
+ if (!cameraInsideBuilding)
{
_skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction,
_activeDayGroup, kf, environOverrideActive);
@@ -10562,6 +10712,7 @@ public sealed class GameWindow : IDisposable
_liveSession = null;
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose();
+ _indoorStencilPipeline?.Dispose(); // Phase A8 RR7
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_samplerCache?.Dispose();
_textureCache?.Dispose();