From f9a644a366f01dc1833fcc8c87b7691af7f3f1cb Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 27 May 2026 15:13:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Phase=20A8=20Wave=204=20?= =?UTF-8?q?=E2=80=94=20RenderInsideOutAcdream=20byte-for-byte=20WB=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six surgical edits in GameWindow.cs (+275 LOC): 1. _indoorStencilPipeline field + ctor init (line 172 + 1788). Uses the portal_stencil.{vert,frag} shaders. Disposed at line 10595. 2. Strict cameraInsideBuilding gate (line 7079-7097): visibility.CameraCell PointInCell + BuildingId != null. camBuildings + otherBuildings lists populated from _buildingRegistries.GetBuildingsContainingCell / .All(). 3. envCellViewProj compute + _envCellFrustum.Update + _envCellRenderer .PrepareRenderBatches (line 7192) — once per frame, before sky. 4. Frame clear now includes StencilBufferBit (line 6947) so stencil starts at 0 each frame. RR7 missed this. 5. Old "depth clear when inside" workaround (was lines 7210-7215) DELETED. Replaced with one-line marker pointing at RenderInsideOutAcdream. 6. Indoor-vs-outdoor branch (line 7284-7298): on cameraInsideBuilding, call RenderInsideOutAcdream. Otherwise, existing Dispatcher.Draw(set: All). The outdoor path retains pre-A8 behavior exactly. 7. RenderInsideOutAcdream method (line 10587-10761): byte-for-byte port of WB VisibilityManager.RenderInsideOut at references/WorldBuilder/.../VisibilityManager.cs:73-239. Substitutions: portalManager.RenderBuildingStencilMask -> _indoorStencilPipeline.RenderBuildingStencilMask envCellManager.Render(pass, filter) -> _envCellRenderer.Render(pass, filter) terrainManager.Render(...) -> _terrain?.Draw(camera, frustum, neverCullLb) sceneryManager + staticObjectManager -> _wbDrawDispatcher.Draw(set: OutdoorScenery) sceneryShader.Bind() -> _meshShader.Use() Step 1 + 2 (camera-building portals stencil mark + far-depth punch). Step 3 (cells of camera-buildings, opaque + transparent). Step 4 (stencil-gated terrain + scenery). Step 5 (cross-building visibility via 3-bit stencil + occlusion query). 8. Four EmitXxxProbe stub methods (Task 9 fills them with real output). LiveDynamic (player + NPCs + dropped items) is NOT YET drawn separately; Task 9 follow-up may add the LiveDynamic dispatch call after stencil disable. Pre-A8 behavior had no separate LiveDynamic pass either — dynamic entities flow through Dispatcher.Draw(All) on the outdoor path. Subagent deviation from spec: `camera` parameter typed as AcDream.App.Rendering.ICamera (the actual type GameWindow uses) rather than AcDream.Core.Rendering.Camera (which doesn't exist). Build green. 82/82 App.Tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 287 +++++++++++++++++++++++- 1 file changed, 275 insertions(+), 12 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 4de48ed..e7589c9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -166,6 +166,11 @@ public sealed class GameWindow : IDisposable private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer; private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum; + // Phase A8 (2026-05-28): portal-stencil pipeline — Steps 1+2+5a-d of WB's + // RenderInsideOut algorithm. Lazily-created portal VBO; caller sets GL state + // around each RenderBuildingStencilMask call. + private AcDream.App.Rendering.IndoorCellStencilPipeline? _indoorStencilPipeline; + /// /// Phase 6.4: per-entity animation playback state for entities whose /// MotionTable resolved to a real cycle. The render loop ticks each @@ -1776,6 +1781,14 @@ public sealed class GameWindow : IDisposable _envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer( _gl, _wbMeshAdapter!.MeshManager!, _envCellFrustum); _envCellRenderer.Initialize(_meshShader!); + + // Phase A8 (2026-05-28): portal-stencil pipeline. Uses its own dedicated + // shader (portal_stencil.vert / portal_stencil.frag) that only writes + // stencil and optionally gl_FragDepth = 1.0 (far-depth punch semantic). + _indoorStencilPipeline = new AcDream.App.Rendering.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) @@ -6944,7 +6957,7 @@ public sealed class GameWindow : IDisposable System.Math.Clamp(fogColor.Z, 0f, 1f), 1f); - _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); + _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. @@ -7058,6 +7071,35 @@ public sealed class GameWindow : IDisposable var visibility = _cellVisibility.ComputeVisibility(camPos); bool cameraInsideCell = visibility?.CameraCell is not null; + // Phase A8 (2026-05-28): strict cameraInsideBuilding gate. Requires the + // camera cell to actually be a Building (BuildingId != null per RR4). The + // existing cameraInsideCell flag stays — it's still used by the sky / + // weather suppression code paths which gate on "any indoor cell" (including + // dungeon cells without BuildingId). + bool cameraInsideBuilding = + visibility?.CameraCell is not null + && AcDream.App.Rendering.CellVisibility.PointInCell(camPos, visibility.CameraCell) + && visibility.CameraCell.BuildingId is not null; + + var camBuildings = new System.Collections.Generic.List(); + var otherBuildings = new System.Collections.Generic.List(); + + if (cameraInsideBuilding) + { + uint lbId = visibility!.CameraCell!.CellId & 0xFFFF0000u; + if (_buildingRegistries.TryGetValue(lbId, out var reg)) + { + foreach (var b in reg.GetBuildingsContainingCell(visibility.CameraCell.CellId)) + camBuildings.Add(b); + } + + var camCellId = visibility!.CameraCell!.CellId; + foreach (var rr in _buildingRegistries.Values) + foreach (var b in rr.All()) + if (!b.EnvCellIds.Contains(camCellId)) + otherBuildings.Add(b); + } + // 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: @@ -7160,6 +7202,12 @@ public sealed class GameWindow : IDisposable playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); } + // Phase A8: prepare EnvCellRenderer's per-frame visibility snapshot. + // Always called — cheap when no cells loaded, cheap when frustum culls all. + var envCellViewProj = camera.View * camera.Projection; + _envCellFrustum?.Update(envCellViewProj); + _envCellRenderer?.PrepareRenderBatches(envCellViewProj, camPos); + // Phase G.1: sky renderer — draws the far-plane-infinity // celestial meshes FIRST so the rest of the scene z-tests // on top of them (depth mask off, no depth writes). Skipped @@ -7207,12 +7255,9 @@ 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); + // Phase A8 (2026-05-28): the pre-A8 "depth clear when inside" workaround + // is deleted. RenderInsideOutAcdream below handles indoor visibility via + // stencil-gated portals instead. // L-fix1 (2026-04-28): pass the set of animated-entity ids so // the renderer keeps remote players / NPCs / monsters @@ -7230,11 +7275,27 @@ 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 (2026-05-28): indoor-vs-outdoor render branch. The outdoor + // branch keeps pre-A8 behavior (single Draw(set: All)). The indoor branch + // runs WB's RenderInsideOut algorithm byte-for-byte via RenderInsideOutAcdream. + // LiveDynamic (player + NPCs + dropped items) is drawn last in BOTH branches + // after stencil is disabled, so dynamic entities depth-test against everything + // but aren't stencil-clipped. + if (cameraInsideBuilding) + { + RenderInsideOutAcdream(envCellViewProj, camPos, visibility!.CameraCell!, + camBuildings, otherBuildings, + camera, frustum, playerLb, animatedIds, + visibility?.VisibleCellIds); + } + else + { + // N.5: WbDrawDispatcher is always non-null (modern path mandatory). + _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. @@ -10509,6 +10570,207 @@ public sealed class GameWindow : IDisposable } } + // ── Phase A8 (2026-05-28): RenderInsideOutAcdream + probe stubs ──────────── + + /// + /// Phase A8 (2026-05-28): port of WB's VisibilityManager.RenderInsideOut + /// at references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239. + /// Byte-for-byte port with these substitutions: + /// portalManager.RenderBuildingStencilMask → _indoorStencilPipeline.RenderBuildingStencilMask + /// envCellManager.Render(pass, filter) → _envCellRenderer.Render(pass, filter) + /// terrainManager.Render(...) → _terrain?.Draw(camera, frustum, neverCullLandblockId) + /// sceneryManager + staticObjectManager → folded into _wbDrawDispatcher.Draw(set: OutdoorScenery) + /// sceneryShader.Bind() → _meshShader.Use() + /// state.ShowScenery / ShowStaticObjects → always true (game client; no editor toggles) + /// state.EnableTransparencyPass → true (we want translucent geometry) + /// + private void RenderInsideOutAcdream( + System.Numerics.Matrix4x4 viewProj, + System.Numerics.Vector3 camPos, + AcDream.App.Rendering.LoadedCell cameraCell, + System.Collections.Generic.List camBuildings, + System.Collections.Generic.List otherBuildings, + AcDream.App.Rendering.ICamera camera, + AcDream.App.Rendering.FrustumPlanes? frustum, + uint? playerLb, + System.Collections.Generic.HashSet? animatedIds, + System.Collections.Generic.HashSet? visibleCellIds) + { + var gl = _gl!; + bool didInsideStencil = false; + + EmitBuildingsProbe(visibilityCellId: cameraCell.CellId, camBuildings, otherBuildings); + + // Steps 1+2: stencil bit 1 + far-depth punch at camera-buildings' portals. + if (camBuildings.Count > 0) + { + didInsideStencil = true; + gl.Enable(EnableCap.StencilTest); + gl.ClearStencil(0); + gl.Clear(ClearBufferMask.StencilBufferBit); + + // Step 1: stencil bit 1 at our buildings' portals. + // WB VisibilityManager.cs:86-94 + gl.Disable(EnableCap.CullFace); + gl.StencilFunc(StencilFunction.Always, 1, 0xFFu); + gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); + gl.StencilMask(0x01u); + gl.ColorMask(false, false, false, false); + gl.DepthMask(false); + gl.Enable(EnableCap.DepthTest); + gl.DepthFunc(DepthFunction.Always); + + EmitDrawOrderProbe(step: 1, sub: ' '); + foreach (var b in camBuildings) + { + _indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false); + EmitStencilProbe(op: "mark"); + } + + // Step 2: punch depth at portals. + // WB VisibilityManager.cs:99-104 + gl.DepthMask(true); + gl.DepthFunc(DepthFunction.Always); + + EmitDrawOrderProbe(step: 2, sub: ' '); + foreach (var b in camBuildings) + { + _indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: true); + EmitStencilProbe(op: "punch"); + } + } + + // Step 3: render camera-buildings' cells (stencil off, DepthFunc.Less). + // WB VisibilityManager.cs:107-127 + gl.ColorMask(true, true, true, false); + gl.DepthMask(true); + gl.Disable(EnableCap.StencilTest); + gl.DepthFunc(DepthFunction.Less); + _meshShader!.Use(); + + EmitDrawOrderProbe(step: 3, sub: ' '); + var currentEnvCellIds = new System.Collections.Generic.HashSet(); + if (camBuildings.Count > 0) + { + foreach (var b in camBuildings) + foreach (var id in b.EnvCellIds) currentEnvCellIds.Add(id); + _envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, currentEnvCellIds); + + // Transparency pass. + gl.DepthMask(false); + _envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, currentEnvCellIds); + gl.DepthMask(true); + } + + EmitEnvCellProbe(camBuildings.Count, otherBuildings.Count, currentEnvCellIds.Count); + + // Step 4: stencil-gated outdoor (terrain + scenery + static objects). + // WB VisibilityManager.cs:130-154 + if (didInsideStencil) + { + gl.Enable(EnableCap.StencilTest); + gl.StencilFunc(StencilFunction.Equal, 1, 0x01u); + gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep); + gl.StencilMask(0x00u); + gl.ColorMask(true, true, true, false); + gl.DepthMask(true); + gl.Enable(EnableCap.CullFace); + gl.DepthFunc(DepthFunction.Less); + } + + EmitDrawOrderProbe(step: 4, sub: ' '); + // Terrain (WB line 143). + _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); + + _meshShader!.Use(); + // Scenery + static objects via dispatcher (WB lines 148-154). + _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: visibleCellIds, // OK — outdoor cells outside the building + animatedEntityIds: animatedIds, + set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery); + + // Step 5: per-other-building 3-bit stencil pipeline. + // WB VisibilityManager.cs:157-232 + if (didInsideStencil && otherBuildings.Count > 0) + { + gl.Enable(EnableCap.StencilTest); + gl.ColorMask(false, false, false, false); + gl.DepthMask(false); + gl.DepthFunc(DepthFunction.Lequal); + + foreach (var b in otherBuildings) + { + // Occlusion-query read-back (prev-frame async). + _indoorStencilPipeline!.EnsureOcclusionQueryId(ref b.QueryId); + if (b.QueryStarted && + _indoorStencilPipeline.TryReadOcclusionResult(b.QueryId, out bool anyPassed)) + { + b.WasVisible = anyPassed; + } + _indoorStencilPipeline.BeginOcclusionQuery(b.QueryId); + b.QueryStarted = true; + + // Step 5a: mark bit 2 (Ref=3, Mask=0x02). WB VisibilityManager.cs:186-192 + EmitDrawOrderProbe(step: 5, sub: 'a'); + gl.StencilFunc(StencilFunction.Equal, 3, 0x01u); + gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); + gl.StencilMask(0x02u); + gl.Disable(EnableCap.CullFace); + _indoorStencilPipeline.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false); + _indoorStencilPipeline.EndOcclusionQuery(); + + // Step 5b: clear depth at stencil==3. WB VisibilityManager.cs:201-205 + EmitDrawOrderProbe(step: 5, sub: 'b'); + gl.StencilFunc(StencilFunction.Equal, 3, 0x03u); + gl.StencilMask(0x00u); + gl.DepthMask(true); + gl.DepthFunc(DepthFunction.Always); + _indoorStencilPipeline.RenderBuildingStencilMask(b, viewProj, writeFarDepth: true); + + // Step 5c: render this building's cells where stencil==3. WB VisibilityManager.cs:210-220 + EmitDrawOrderProbe(step: 5, sub: 'c'); + gl.ColorMask(true, true, true, false); + gl.DepthFunc(DepthFunction.Less); + gl.Enable(EnableCap.CullFace); + _meshShader!.Use(); + _envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, b.EnvCellIds); + gl.DepthMask(false); + _envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, b.EnvCellIds); + gl.DepthMask(true); + + // Step 5d: reset bit 2 (Ref=1, Mask=0x02) for next iteration. WB VisibilityManager.cs:222-228 + EmitDrawOrderProbe(step: 5, sub: 'd'); + gl.ColorMask(false, false, false, false); + gl.DepthMask(false); + gl.StencilMask(0x02u); + gl.StencilFunc(StencilFunction.Always, 1, 0x02u); + gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); + _indoorStencilPipeline.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false); + } + gl.DepthFunc(DepthFunction.Less); + } + + // Cleanup (WB VisibilityManager.cs:234-238). + if (didInsideStencil) + { + gl.Disable(EnableCap.StencilTest); + gl.StencilMask(0xFFu); + gl.ColorMask(true, true, true, false); + } + } + + // ── Phase A8 probe stubs (Task 9 wires these up with real output) ──────── + + private void EmitDrawOrderProbe(int step, char sub) { /* Task 9 */ } + private void EmitEnvCellProbe(int ourBldgs, int otherBldgs, int filterCnt) { /* Task 9 */ } + private void EmitStencilProbe(string op) { /* Task 9 */ } + private void EmitBuildingsProbe(uint visibilityCellId, + System.Collections.Generic.List camBldgs, + System.Collections.Generic.List otherBldgs) { /* Task 9 */ } + + // ──────────────────────────────────────────────────────────────────────────── + /// Phase N.5b: emits [TERRAIN-DIAG] once per ~5s under /// ACDREAM_WB_DIAG=1. Mirrors WbDrawDispatcher.MaybeFlushDiag: /// rolling 256-sample buffer of microseconds, median + p95 reported. @@ -10593,6 +10855,7 @@ public sealed class GameWindow : IDisposable _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context _wbDrawDispatcher?.Dispose(); _envCellRenderer?.Dispose(); // Phase A8 + _indoorStencilPipeline?.Dispose(); // Phase A8 (2026-05-28) _skyRenderer?.Dispose(); // depends on sampler cache; dispose first _samplerCache?.Dispose(); _textureCache?.Dispose();