diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6df4d44..fde8056 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -166,164 +166,6 @@ 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; - - private void CollectVisiblePortalBuildings( - System.Collections.Generic.List output, - int centerLbX, - int centerLbY, - int radius) - { - output.Clear(); - - foreach (var (landblockId, reg) in _buildingRegistries) - { - int lbX = (int)((landblockId >> 24) & 0xFFu); - int lbY = (int)((landblockId >> 16) & 0xFFu); - if (System.Math.Abs(lbX - centerLbX) > radius || - System.Math.Abs(lbY - centerLbY) > radius) - { - continue; - } - - foreach (var b in reg.All()) - { - if (!b.HasPortalBounds) - continue; - - // WB PortalRenderManager.GetVisibleBuildingPortals frustum-culls - // each building's portal AABB with ignoreNearPlane=true. That - // prevents a doorway/window clipped by the camera near plane from - // dropping out of the portal visibility list. - if (_envCellFrustum is not null && - _envCellFrustum.TestBox(b.PortalBounds, ignoreNearPlane: true) - == AcDream.App.Rendering.Wb.FrustumTestResult.Outside) - { - continue; - } - - output.Add(b); - } - } - } - - private void RenderOutsideInAcdream( - System.Numerics.Matrix4x4 viewProj, - AcDream.App.Rendering.ICamera camera, - AcDream.App.Rendering.FrustumPlanes? frustum, - uint? playerLb, - System.Collections.Generic.HashSet? animatedIds, - System.Collections.Generic.IReadOnlyList visibleBuildings) - { - if (visibleBuildings.Count == 0) - return; - - var gl = _gl!; - var visibleEnvCellIds = new System.Collections.Generic.HashSet(); - foreach (var b in visibleBuildings) - { - foreach (var id in b.EnvCellIds) - visibleEnvCellIds.Add(id); - } - - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) - { - Console.WriteLine( - $"[outside-in] portals={visibleBuildings.Count} cells={visibleEnvCellIds.Count}"); - } - - // WB VisibilityManager.RenderOutsideIn, but fed by the same - // frustum-visible portal list prepared above instead of every loaded - // building. Terrain/scenery are already drawn by the caller; this pass - // opens portal silhouettes, repairs wall depth, then draws EnvCells - // through those silhouettes. WB's outside-in EnvCell render passes a - // null cell filter; the stencil/depth mask is the visibility gate. - gl.Enable(EnableCap.StencilTest); - gl.ClearStencil(0); - gl.Clear(ClearBufferMask.StencilBufferBit); - - // Step 1: mark visible building portals where the exterior depth test - // says the portal surface is actually visible. - gl.Disable(EnableCap.CullFace); - gl.StencilFunc(StencilFunction.Always, 1, 0xFFu); - gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); - gl.StencilMask(0xFFu); - gl.ColorMask(false, false, false, false); - gl.DepthMask(false); - gl.Enable(EnableCap.DepthTest); - gl.DepthFunc(DepthFunction.Less); - foreach (var b in visibleBuildings) - _indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false); - - // Step 2: punch portal depth to the far plane. - gl.StencilFunc(StencilFunction.Equal, 1, 0xFFu); - gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep); - gl.StencilMask(0x00u); - gl.DepthMask(true); - gl.DepthFunc(DepthFunction.Always); - foreach (var b in visibleBuildings) - _indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: true); - - // Step 3: depth-only repair for exterior walls that overlap portal - // silhouettes, matching WB's staticObjectManager depth repair pass. - // In acdream, the dispatcher can target just building shells here; - // walking the full OutdoorScenery set would reprocess every tree and - // outdoor static object only to write depth under portal masks. - gl.Enable(EnableCap.CullFace); - gl.FrontFace(FrontFaceDirection.CW); - gl.DepthFunc(DepthFunction.Less); - _meshShader!.Use(); - _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: visibleEnvCellIds, - animatedEntityIds: null, - set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.BuildingShells); - _a8PerfLastOutsideShellStats = _wbDrawDispatcher.LastDrawStats; - - // Step 4/5: render EnvCells through the repaired stencil mask. - // - // WB EnvCellRenderManager owns BOTH cell geometry and EnvCell static - // objects. A8 split that in acdream: EnvCellRenderer owns only the - // CellStruct mesh, while static objects remain dispatcher WorldEntity - // records with ParentCellId. Mirror WB's combined manager by drawing - // the dispatcher IndoorPass through the same portal stencil and the - // same WB-derived visible cell set used to prepare EnvCellRenderer. - gl.StencilFunc(StencilFunction.Equal, 1, 0xFFu); - gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep); - gl.StencilMask(0x00u); - gl.ColorMask(true, true, true, false); - gl.DepthMask(true); - gl.DepthFunc(DepthFunction.Less); - gl.Disable(EnableCap.Blend); - _meshShader!.Use(); - _envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, filter: null); - - gl.Enable(EnableCap.Blend); - gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - gl.DepthMask(false); - _envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, filter: null); - gl.DepthMask(true); - gl.Disable(EnableCap.Blend); - - _meshShader!.Use(); - _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: visibleEnvCellIds, - animatedEntityIds: null, - set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.IndoorPass); - _a8PerfLastOutsideIndoorStats = _wbDrawDispatcher.LastDrawStats; - - gl.Disable(EnableCap.StencilTest); - gl.StencilMask(0xFFu); - gl.ColorMask(true, true, true, true); - gl.DepthMask(true); - gl.DepthFunc(DepthFunction.Less); - gl.FrontFace(FrontFaceDirection.CW); - } - /// /// Phase 6.4: per-entity animation playback state for entities whose /// MotionTable resolved to a real cycle. The render loop ticks each @@ -1934,14 +1776,6 @@ 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) @@ -7131,56 +6965,8 @@ public sealed class GameWindow : IDisposable private double _perfAccum; private int _perfFrameCount; - private long _a8PerfLastLogTick; - private long _a8PerfFrames; - private long _a8PerfInsideFrames; - private long _a8PerfOutsideInFrames; - private long _a8PerfTickAnimTicks; - private long _a8PerfCollectTicks; - private long _a8PerfEnvPrepareTicks; - private long _a8PerfTerrainTicks; - private long _a8PerfStaticTicks; - private long _a8PerfOutsideInTicks; - private long _a8PerfLiveTicks; - private long _a8PerfInsideOutTicks; - private long _a8PerfInsideLiveTicks; - private int _a8PerfLastPortalBuildings; - private int _a8PerfMaxPortalBuildings; - private int _a8PerfLastPortalCells; - private int _a8PerfMaxPortalCells; - private int _a8PerfLastVisibleLandblocks; - private int _a8PerfLastTotalLandblocks; - private readonly System.Collections.Generic.HashSet _a8PerfCellScratch = new(); - private const int A8PerfGpuRingDepth = 4; - private const int A8PerfGpuPassCount = 6; - private const int A8PerfGpuTerrain = 0; - private const int A8PerfGpuStatic = 1; - private const int A8PerfGpuOutsideIn = 2; - private const int A8PerfGpuLive = 3; - private const int A8PerfGpuInsideOut = 4; - private const int A8PerfGpuInsideLive = 5; - private readonly uint[] _a8PerfGpuQueries = new uint[A8PerfGpuRingDepth * A8PerfGpuPassCount]; - private bool _a8PerfGpuQueriesInitialized; - private int _a8PerfGpuFrameIndex; - private readonly bool[] _a8PerfGpuIssued = new bool[A8PerfGpuRingDepth * A8PerfGpuPassCount]; - private long _a8PerfTerrainGpuNs; - private long _a8PerfStaticGpuNs; - private long _a8PerfOutsideInGpuNs; - private long _a8PerfLiveGpuNs; - private long _a8PerfInsideOutGpuNs; - private long _a8PerfInsideLiveGpuNs; - private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastStaticStats; - private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastLiveStats; - private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastOutsideShellStats; - private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastOutsideIndoorStats; - private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastInsideStats; - private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastInsideLiveStats; - private void OnRender(double deltaSeconds) { - bool a8Perf = A8PerfEnabled(); - int a8GpuSlot = A8PerfBeginGpuFrame(a8Perf); - // Phase G.1: set the clear color from the current sky's fog // tint so the horizon band continues naturally past the // rendered geometry. Fog blends to this color at max distance @@ -7229,10 +7015,8 @@ public sealed class GameWindow : IDisposable // Phase 6.4: advance per-entity animation playback before drawing // so the renderer always sees the up-to-date per-part transforms. - long a8PerfStart = A8PerfStart(a8Perf); if (_animatedEntities.Count > 0) TickAnimations((float)deltaSeconds); - A8PerfStop(a8Perf, ref _a8PerfTickAnimTicks, a8PerfStart); // Phase G.1: weather state machine — deterministic per-day roll // + transitions + lightning flash. @@ -7325,44 +7109,6 @@ 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). - // - // KILL-SWITCH (2026-05-28 PM after 5 failed visual gates): the indoor - // branch (RenderInsideOutAcdream) is broken in ways speculative fixes - // haven't resolved (texture flicker + missing walls + GPU 100%). - // Default behavior reverts to pre-A8 outdoor Draw(All) path. Set - // ACDREAM_A8_INDOOR_BRANCH=1 to re-enable indoor branch for further - // investigation (probe data still emits in that mode). - // The depth-clear-if-inside workaround at line ~7314 is restored when - // the kill-switch is OFF, so pre-A8 visual behavior is preserved. - bool a8IndoorBranchEnabled = string.Equals( - Environment.GetEnvironmentVariable("ACDREAM_A8_INDOOR_BRANCH"), "1", - StringComparison.Ordinal); - bool cameraInsideBuilding = a8IndoorBranchEnabled - && 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(); - var visiblePortalBuildings = new System.Collections.Generic.List(); - System.Collections.Generic.HashSet? envCellPrepareFilter = null; - int visiblePortalCellCount = 0; - - 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); - } - } - // 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: @@ -7401,10 +7147,6 @@ public sealed class GameWindow : IDisposable $"cell=0x{cellId} visN={visCount} {visList}"); } - // Phase A8 Task 9: increment the [draworder] frame counter once per render - // tick. Used by EmitDrawOrderProbe to attribute step transitions to a frame. - _phaseA8DrawOrderFrame++; - // Lighting decisions (sun zeroed, indoor ambient applied) must // track the PLAYER's cell, not the camera's. In third-person // chase mode the camera enters interiors before the player body @@ -7476,84 +7218,27 @@ public sealed class GameWindow : IDisposable // Always called — cheap when no cells loaded, cheap when frustum culls all. var envCellViewProj = camera.View * camera.Projection; _envCellFrustum?.Update(envCellViewProj); - if (a8IndoorBranchEnabled) - { - a8PerfStart = A8PerfStart(a8Perf); - CollectVisiblePortalBuildings( - visiblePortalBuildings, - renderCenterLbX, - renderCenterLbY, - _nearRadius); - - // WB VisibilityManager.PrepareVisibility builds the EnvCell - // set before EnvCellRenderManager.PrepareRenderBatches, then - // RenderOutsideIn calls Render(..., null) against that already- - // narrowed snapshot. Keep that two-stage shape: the stencil is - // the render gate, but the prepared workload remains limited - // to camera-building cells, runtime portal-visible cells, plus - // frustum-visible portal cells. - envCellPrepareFilter = new System.Collections.Generic.HashSet(); - foreach (var b in camBuildings) - foreach (var id in b.EnvCellIds) - envCellPrepareFilter.Add(id); - if (visibility?.VisibleCellIds is not null) - foreach (var id in visibility.VisibleCellIds) - envCellPrepareFilter.Add(id); - foreach (var b in visiblePortalBuildings) - foreach (var id in b.EnvCellIds) - envCellPrepareFilter.Add(id); - visiblePortalCellCount = envCellPrepareFilter.Count; - - if (a8Perf) - { - _a8PerfCellScratch.Clear(); - foreach (var id in envCellPrepareFilter) - _a8PerfCellScratch.Add(id); - } - A8PerfStop(a8Perf, ref _a8PerfCollectTicks, a8PerfStart); - if (cameraInsideBuilding && visibility?.CameraCell is not null) - { - var camCellId = visibility.CameraCell.CellId; - foreach (var b in visiblePortalBuildings) - { - if (!b.EnvCellIds.Contains(camCellId)) - otherBuildings.Add(b); - } - } - } - a8PerfStart = A8PerfStart(a8Perf); _envCellRenderer?.PrepareRenderBatches( envCellViewProj, camPos, - envCellPrepareFilter, + filter: null, centerLbX: renderCenterLbX, centerLbY: renderCenterLbY, renderRadius: _nearRadius); - A8PerfStop(a8Perf, ref _a8PerfEnvPrepareTicks, a8PerfStart); // 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). // - // Phase A8 fix (2026-05-28 visual-gate-#1 follow-up): also - // render sky when cameraInsideBuilding=true — cottages have - // portals to outside, and the user expects sky visible through - // windows + doorways. The blanket `!cameraInsideCell` skip - // was incorrect for cottages (only correct for sealed dungeon - // cells). When inside a building, Step 4's stencil-gated - // outdoor pass will composite terrain + scenery through the - // portal silhouettes; sky needs to be there as the far-depth - // backdrop. WB's pipeline (VisibilityManager.RenderInsideOut) - // assumes the sky pass already ran before stencil setup. - // - // Mirrors retail's LScape::draw at 0x00506330 which calls - // GameSky::Draw(0) (sky pass) BEFORE the landblock DrawBlock - // loop and GameSky::Draw(1) (weather pass) AFTER. The split - // matters because weather meshes (the 815m-tall rain - // cylinder 0x01004C42/0x01004C44) need to overlay terrain - // and entities to look volumetric — see the post-scene - // RenderWeather call further below. - bool renderSky = !cameraInsideCell || cameraInsideBuilding; + // Suppressed inside cells (the camera is in a sealed interior; + // no sky is visible). Mirrors retail's LScape::draw at 0x00506330 + // which calls GameSky::Draw(0) (sky pass) BEFORE the landblock + // DrawBlock loop and GameSky::Draw(1) (weather pass) AFTER. The + // split matters because weather meshes (the 815m-tall rain + // cylinder 0x01004C42/0x01004C44) need to overlay terrain and + // entities to look volumetric — see the post-scene RenderWeather + // call further below. + bool renderSky = !cameraInsideCell; if (renderSky) { _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, @@ -7578,25 +7263,8 @@ 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. - // - // FIX 2026-05-28: skip this terrain pass when cameraInsideBuilding. - // RenderInsideOutAcdream's Step 4 draws terrain stencil-gated to - // portal silhouettes (matching WB VisibilityManager:143). Drawing - // terrain BOTH here (full-screen) AND in Step 4 (stencil-gated) - // doubles GPU work AND causes Z-fighting at cottage walls — the - // lower wall portion (Z=92-94) overlaps terrain Z=92, terrain's - // depth from this pass is already in the depth buffer when Step - // 3 writes the cell mesh. The pre-A8 code papered over this with - // a depth-clear-if-inside workaround which we deleted in Wave 4. - // The proper fix is to NOT draw terrain here when indoor; Step 4 - // is the single, stencil-gated terrain pass. _terrainCpuStopwatch.Restart(); - a8PerfStart = A8PerfStart(a8Perf); - A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuTerrain); - if (!cameraInsideBuilding) - _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); - A8PerfEndGpuQuery(a8Perf, a8GpuSlot); - A8PerfStop(a8Perf, ref _a8PerfTerrainTicks, a8PerfStart); + _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 @@ -7606,13 +7274,6 @@ public sealed class GameWindow : IDisposable _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length; MaybeFlushTerrainDiag(); - // Phase A8 (2026-05-28): the pre-A8 "depth clear when inside" workaround - // is deleted when the indoor branch is enabled. When the indoor branch - // is OFF (default kill-switch state), restore the pre-A8 workaround so - // indoor visuals match pre-A8 behavior. - if (!a8IndoorBranchEnabled && 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 @@ -7629,91 +7290,14 @@ public sealed class GameWindow : IDisposable animatedIds.Add(k); } - // 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) - { - a8PerfStart = A8PerfStart(a8Perf); - A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuInsideOut); - RenderInsideOutAcdream(envCellViewProj, camPos, visibility!.CameraCell!, - camBuildings, otherBuildings, - camera, frustum, playerLb, animatedIds, - visibility?.VisibleCellIds); - A8PerfEndGpuQuery(a8Perf, a8GpuSlot); - A8PerfStop(a8Perf, ref _a8PerfInsideOutTicks, a8PerfStart); - - // Phase A8 fix (2026-05-28 visual-gate-#2 follow-up): LiveDynamic - // entities (player char, NPCs, dropped items, doors) were missing - // inside buildings because RenderInsideOutAcdream only renders the - // IndoorPass + OutdoorScenery partitions; LiveDynamic was implicitly - // excluded. The method's own header comment promised "LiveDynamic - // is drawn last in BOTH branches" but no call existed in the indoor - // path. Wire it here, after RenderInsideOutAcdream returns with - // stencil + state restored to defaults at its cleanup block. Same - // shape as the outdoor branch's Draw(All) for the LiveDynamic - // subset only. - a8PerfStart = A8PerfStart(a8Perf); - A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuInsideLive); - _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: visibility?.VisibleCellIds, - animatedEntityIds: animatedIds, - set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.LiveDynamic); - _a8PerfLastInsideLiveStats = _wbDrawDispatcher.LastDrawStats; - A8PerfEndGpuQuery(a8Perf, a8GpuSlot); - A8PerfStop(a8Perf, ref _a8PerfInsideLiveTicks, a8PerfStart); - } - else - { - // N.5: WbDrawDispatcher is always non-null (modern path mandatory). - if (a8IndoorBranchEnabled) - { - a8PerfStart = A8PerfStart(a8Perf); - A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuStatic); - _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: visibility?.VisibleCellIds, - animatedEntityIds: null, - set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery); - _a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats; - A8PerfEndGpuQuery(a8Perf, a8GpuSlot); - A8PerfStop(a8Perf, ref _a8PerfStaticTicks, a8PerfStart); - - a8PerfStart = A8PerfStart(a8Perf); - A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuOutsideIn); - RenderOutsideInAcdream(envCellViewProj, camera, frustum, playerLb, - animatedIds, visiblePortalBuildings); - A8PerfEndGpuQuery(a8Perf, a8GpuSlot); - A8PerfStop(a8Perf, ref _a8PerfOutsideInTicks, a8PerfStart); - - a8PerfStart = A8PerfStart(a8Perf); - A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuLive); - _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: visibility?.VisibleCellIds, - animatedEntityIds: animatedIds, - set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.LiveDynamic); - _a8PerfLastLiveStats = _wbDrawDispatcher.LastDrawStats; - A8PerfEndGpuQuery(a8Perf, a8GpuSlot); - A8PerfStop(a8Perf, ref _a8PerfLiveTicks, a8PerfStart); - } - else - { - a8PerfStart = A8PerfStart(a8Perf); - A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuStatic); - _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: visibility?.VisibleCellIds, - animatedEntityIds: animatedIds); - _a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats; - A8PerfEndGpuQuery(a8Perf, a8GpuSlot); - A8PerfStop(a8Perf, ref _a8PerfStaticTicks, a8PerfStart); - } - } + // 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: unified gated draw wired in U.4a + _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. @@ -7822,13 +7406,6 @@ public sealed class GameWindow : IDisposable if (AcDream.App.Rendering.FrustumCuller.IsAabbVisible(frustum, entry.AabbMin, entry.AabbMax)) visibleLandblocks++; } - MaybeFlushA8Perf( - a8Perf, - cameraInsideBuilding, - visiblePortalBuildings.Count, - visiblePortalCellCount, - visibleLandblocks, - totalLandblocks); // Phase I.2: refresh per-frame fields that DebugVM closures // can't compute lazily (frustum-derived counters + nearest- @@ -7882,8 +7459,6 @@ public sealed class GameWindow : IDisposable // GL state it touches (blend, scissor, VAO, shader, texture); any // state not in its save-list (e.g. GL_FRAMEBUFFER_SRGB, unused // today) would need manual protection. - A8PerfEndGpuFrame(a8Perf); - if (DevToolsEnabled && _imguiBootstrap is not null && _panelHost is not null) { // Phase I.3 — prefer the live command bus when a live session @@ -11004,610 +10579,6 @@ 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!; - - EmitBuildingsProbe(visibilityCellId: cameraCell.CellId, camBuildings, otherBuildings); - - // Phase A8.F: build the recursively-clipped portal frame from the camera cell. - // OutsideView = exit portals clipped to their portal chain (fixes the cellar flap). - // buildingMembership left null here; Task 8 wires cross-building via CrossBuildingViews. - var portalFrame = AcDream.App.Rendering.PortalVisibilityBuilder.Build( - cameraCell, - camPos, - id => _cellVisibility.TryGetCell(id, out var pc) ? pc : null, - viewProj); - - bool didInsideStencil = !portalFrame.OutsideView.IsEmpty; - if (didInsideStencil) - { - EmitDrawOrderProbe(step: 1, sub: ' '); - _indoorStencilPipeline!.MarkAndPunchNdc(portalFrame.OutsideView.Polygons); - EmitStencilProbe(op: "mark-clipped"); - } - - // Step 3: render the indoor cells visible from the camera cell - // (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); - // A8 cellar-flap provenance (2026-05-28): disabling Step 4 - // terrain removes the green flap, proving terrain is the writer. - // The leak happens because indoor-to-indoor portal cells reached - // by the runtime visibility BFS can be outside the static - // BuildingInfo cell set. Render them in Step 3 so their depth - // blocks Step 4 terrain, while real exterior openings still show - // terrain through the portal mask. - if (visibleCellIds is not null) - foreach (var id in visibleCellIds) - currentEnvCellIds.Add(id); - gl.Disable(EnableCap.Blend); - _envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, currentEnvCellIds); - // TEMP A8.F triage (strip after): opaque wall-render stats BEFORE the transparent loop overwrites them. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeEnvCellEnabled && _a8OpaqueDumped.Add(cameraCell.CellId)) - Console.WriteLine($"[opaque] camCell=0x{cameraCell.CellId:X8} cells={_envCellRenderer.Stats.CellsRendered} tris={_envCellRenderer.Stats.TrianglesDrawn} filterCnt={currentEnvCellIds.Count}"); - - // Phase A8.F (#2): translucent cell geometry clipped to each cell's portal-chain - // region (stencil BIT 2). Opaque cells already clip correctly via depth (left - // untouched above — Q4 fidelity-vs-perf decision). Bit 1 (OutsideView mask) is - // preserved: every bit-2 op below uses StencilMask 0x02 and never clears the - // whole stencil. The camera cell's region is full-screen, so it (and any cell - // without a non-empty region) renders unclipped. - gl.Enable(EnableCap.Blend); - gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - gl.DepthMask(false); - foreach (var cellId in currentEnvCellIds) - { - var oneCell = new System.Collections.Generic.HashSet { cellId }; - if (cellId != cameraCell.CellId - && portalFrame.CellViews.TryGetValue(cellId, out var cv) && !cv.IsEmpty) - { - _indoorStencilPipeline!.MarkRegionBit2(cv.Polygons); - _indoorStencilPipeline.EnableBit2CellPass(); - _meshShader!.Use(); - _envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, oneCell); - gl.Disable(EnableCap.StencilTest); - _indoorStencilPipeline.ResetRegionBit2(cv.Polygons); // clear bit 2 for next cell; bit 1 intact - } - else - { - _meshShader!.Use(); - _envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, oneCell); - } - } - gl.DepthMask(true); - gl.Disable(EnableCap.Blend); - gl.Disable(EnableCap.StencilTest); // ensure clean for Step 4 (which re-enables for bit-1 terrain gate) - } - - // FIX 2026-05-28 (post-third-visual-gate): render IndoorPass entities. - // EnvCellRenderer covers cell GEOMETRY (floor, walls baked into the - // cell's CellStruct). But cottages ALSO have IsBuildingShell entities - // — landblock-baked GfxObjs representing exterior wall slabs that - // aren't part of any cell's CellStruct. Pre-A8 rendered these via - // Dispatcher.Draw(set: IndoorPass); WB's algorithm assumes its - // StaticObjectManager.Render handles them in Step 4. Our EntitySet - // partition puts IsBuildingShell into IndoorPass (not OutdoorScenery), - // so Step 4's `Draw(set: OutdoorScenery)` misses them entirely. - // Result with the missing call: user reports "house missing lots of - // walls" — the cottage's exterior wall slabs aren't drawn. - // - // Render IndoorPass between Step 3 and Step 4. The currentEnvCellIds - // filter now narrows both cell stabs and building shells: shells have - // no ParentCellId, but carry BuildingShellAnchorCellId from - // LandBlockInfo.Buildings[]. Depth-test with DepthFunc.Less so the - // current cottage shell occludes any farther geometry. NO stencil: we - // want the active building shell rendered unconditionally inside the - // camera-building. - if (camBuildings.Count > 0) - { - _meshShader!.Use(); - _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: currentEnvCellIds, - animatedEntityIds: null, - set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.IndoorPass); - _a8PerfLastInsideStats = _wbDrawDispatcher.LastDrawStats; - } - - EmitEnvCellProbe(camBuildings.Count, otherBuildings.Count, currentEnvCellIds.Count); - - // Step 4 (WB VisibilityManager.cs:130-154): terrain + scenery, stencil-gated to the - // recursively-clipped OutsideView (bit 1). - // - // A8.F first-fix (2026-05-29): an EMPTY OutsideView means "no outdoors is visible from - // here," NOT "all outdoors is visible." WB never hits the empty-while-inside case — its - // mask marks the whole building's exit-portal set and is always non-empty when inside — - // so WB safely draws terrain OUTSIDE the `if`. Our recursive-clip builder yields an empty - // mask whenever it finds no visible exit portal (the builder under-production bug, issue - // #102 family). Drawing terrain ungated in that case FLOODS the interior: indoor cell - // geometry is not a reliable full-screen depth-occluder of the terrain heightfield from - // an underground vantage (the prior "depth alone occludes outdoor geometry" assumption is - // false for cellars). So when the mask is empty we draw NO outdoor terrain/scenery — the - // Step-3 walls stay solid. Cost: terrain-through-portal is suppressed until the builder - // yields a non-empty OutsideView. This decouples the flood (Bug A) from the builder (Bug B) - // so each can be fixed and verified independently. - 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). - // acdream's retail/ACME terrain mesh is CCW from the visible top side - // (see terrain_modern.vert's LandblockMesh order comment), while WB's - // editor terrain uses the opposite vertex order under its global CW - // convention. Step 4 enables culling before terrain, so temporarily - // use terrain's own front-face convention or ground disappears through - // indoor portal silhouettes. - gl.FrontFace(FrontFaceDirection.Ccw); - _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); - gl.FrontFace(FrontFaceDirection.CW); - - _meshShader!.Use(); - // Scenery + static objects via dispatcher (WB lines 148-154). - _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: visibleCellIds, // OK - outdoor cells outside the building - animatedEntityIds: null, - set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery); - _a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats; - } - else - { - // Empty OutsideView while inside → no outdoors visible → draw no terrain/scenery. - // The Step-3 walls already occupy the framebuffer with correct depth. - gl.Disable(EnableCap.StencilTest); - EmitDrawOrderProbe(step: 4, sub: 'x'); // 'x' = Step 4 skipped (empty OutsideView) - } - - // Step 5: per-other-building 3-bit stencil pipeline (cross-building - // visibility — wire-in #3). WB VisibilityManager.cs:157-232. - // - // Phase A8.F (Task 8, 2026-05-28): UNGATED. Previously gated behind - // ACDREAM_A8_STEP5 because bit 1 was the old flat all-exit-portals - // mask — feeding the cross-building pass from that over-broad mask let - // unrelated buildings' EnvCells overwrite exterior walls. That risk is - // gone now: bit 1 is the recursively-CLIPPED OutsideView written by - // MarkAndPunchNdc above (Task 6). Step 5a marks bit 2 only WHERE bit 1 - // is set (StencilFunc Equal,3,0x01), so building B's cells render only - // where B's portals overlap our correctly-clipped opening (stencil==3 = - // bit1 AND bit2). In a cellar the stairwell-clipped OutsideView is tiny, - // so stencil==3 is essentially empty and Step 5 does ~nothing → no - // cellar artifacts. Non-overlapping buildings likewise produce empty - // stencil==3, so the frustum/radius-culled `otherBuildings` list is safe - // either way. - // - // The builder's CrossBuildingViews field is intentionally UNUSED: - // cross-building visibility is exit-portal screen-overlap, handled - // entirely by this loop reading the clipped bit 1. (CrossBuildingViews - // is dead output, slated for cleanup in a later task.) - // - // Runs whenever we're inside with a non-empty clipped OutsideView - // (didInsideStencil) and there is at least one other visible building. - 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(); - gl.Disable(EnableCap.Blend); - _envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, b.EnvCellIds); - gl.Enable(EnableCap.Blend); - gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - gl.DepthMask(false); - _envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, b.EnvCellIds); - gl.DepthMask(true); - gl.Disable(EnableCap.Blend); - - // 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); - // FIX 2026-05-28: WB exits with ColorMask(t,t,t,FALSE) — alpha-bit - // off. That breaks subsequent acdream rendering (particles + - // anything that relies on alpha-to-coverage writing alpha). Restore - // full ColorMask before returning to the outer render frame. - // - // Step 5's iteration loop (now always-on when inside + other - // buildings overlap) leaves DepthMask=false / CullFace=enabled / - // ColorMask=(f,f,f,f) on its last iteration (Step 5c re-enables - // CullFace; 5d only touches color/depth/stencil). Restore to - // acdream-default before returning. - gl.ColorMask(true, true, true, true); - gl.DepthMask(true); - gl.DepthFunc(DepthFunction.Less); - gl.Enable(EnableCap.CullFace); - } - - // If no visible exit portal was uploaded, Step 4 is skipped but Step 3 - // still leaves alpha writes disabled. Restore the outer-frame defaults. - gl.ColorMask(true, true, true, true); - gl.DepthMask(true); - gl.DepthFunc(DepthFunction.Less); - gl.Enable(EnableCap.CullFace); - } - - // ── Phase A8 Task 9 (2026-05-28): probe trail for RenderInsideOutAcdream ── - // - // Mandatory diagnostic infrastructure per the post-RR7 process rule - // "no visual-gate launch without probe data first" (handoff doc - // 2026-05-27-a8-rr7-reverted-wb-port-handoff.md). - // - // Gating: - // [envcells] — ACDREAM_PROBE_VIS=1 OR ACDREAM_PROBE_ENVCELL=1 - // [stencil] — ACDREAM_PROBE_VIS=1 - // [draworder] — ACDREAM_PROBE_VIS=1 - // [buildings] — ACDREAM_PROBE_VIS=1 - // - // Acceptance criteria (read these FROM the launch log BEFORE asking - // the user for visual verification): - // - [buildings] camBldgs=[0x...] non-empty when inside a cottage - // - [envcells] cells>=1 tris>=1 filterCnt>=1 for at least one indoor frame - // - [stencil] op=mark verts>0 fires per camera-building - // - [draworder] shows steps 1 → 2 → 3 → 4 per indoor frame - // (and 5{a,b,c,d} whenever inside + a visible other building's - // portals overlap the clipped bit-1 OutsideView) - - private int _phaseA8DrawOrderFrame = 0; - - /// - /// Phase A8 apparatus (2026-05-28): per-step GL state assertion probe. - /// Logs the full relevant GL state at each step boundary of - /// so an operator can read the log - /// offline and compare against WB's expected state (lifted line-by-line - /// from VisibilityManager.cs:73-239). - /// Heavy under ACDREAM_PROBE_VIS=1 (~10 GL queries per call, - /// 5-10 calls per indoor frame), but only enabled when the operator - /// explicitly opts in. The fix-the-bug-first principle keeps this probe - /// dormant by default; turn it on only when the visual is broken and - /// you need evidence. - /// - private void EmitDrawOrderProbe(int step, char sub) - { - if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) return; - var gl = _gl!; - - // Single-int queries. - gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilTest, out int stOn); - gl.GetInteger(Silk.NET.OpenGL.GLEnum.DepthFunc, out int depthFn); - gl.GetBoolean(Silk.NET.OpenGL.GLEnum.DepthWritemask, out var depthMask); - gl.GetInteger(Silk.NET.OpenGL.GLEnum.CullFace, out int cullEnabled); - gl.GetInteger(Silk.NET.OpenGL.GLEnum.CullFaceMode, out int cullMode); - gl.GetInteger(Silk.NET.OpenGL.GLEnum.FrontFace, out int frontFace); - gl.GetInteger(Silk.NET.OpenGL.GLEnum.BlendSrc, out int blendSrc); - gl.GetInteger(Silk.NET.OpenGL.GLEnum.BlendDst, out int blendDst); - gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilFunc, out int sFunc); - gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilRef, out int sRef); - gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilValueMask, out int sValMask); - gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilWritemask, out int sWriteMask); - gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilFail, out int sFail); - gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilPassDepthFail, out int sPdFail); - gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilPassDepthPass, out int sPdPass); - gl.GetInteger(Silk.NET.OpenGL.GLEnum.VertexArrayBinding, out int vao); - gl.GetInteger(Silk.NET.OpenGL.GLEnum.CurrentProgram, out int prog); - - // ColorWritemask is a bool[4]. Silk.NET exposes it via the - // GetBoolean overload with a Span destination — but a tighter - // capture (just whether all four bits are on) is sufficient for the - // diagnostic. Query each component separately via the indexed form. - // GL spec: GL_COLOR_WRITEMASK returns 4 booleans in order R,G,B,A. - Span cwmask = stackalloc bool[4]; - unsafe - { - fixed (bool* p = cwmask) - gl.GetBoolean(Silk.NET.OpenGL.GLEnum.ColorWritemask, p); - } - - string subStr = sub == ' ' ? "" : sub.ToString(); - Console.WriteLine( - $"[draworder] frame={_phaseA8DrawOrderFrame} step={step}{subStr} " + - $"stencil={(stOn != 0 ? "on" : "off")} depthFn=0x{depthFn:X} depthMask={depthMask} " + - $"cull={(cullEnabled != 0 ? "on" : "off")}({(cullMode == (int)Silk.NET.OpenGL.GLEnum.Front ? "front" : cullMode == (int)Silk.NET.OpenGL.GLEnum.Back ? "back" : "f+b")}) " + - $"front={(frontFace == (int)Silk.NET.OpenGL.GLEnum.CW ? "cw" : "ccw")} " + - $"blend=0x{blendSrc:X}/0x{blendDst:X} " + - $"sFunc=0x{sFunc:X}:{sRef}:0x{sValMask:X} " + - $"sOp=0x{sFail:X}/0x{sPdFail:X}/0x{sPdPass:X} sMask=0x{sWriteMask:X} " + - $"cMask=({(cwmask[0] ? 'R' : '-')}{(cwmask[1] ? 'G' : '-')}{(cwmask[2] ? 'B' : '-')}{(cwmask[3] ? 'A' : '-')}) " + - $"vao={vao} prog={prog}"); - } - - private readonly HashSet<(uint cellId, ulong gfxObjId)> _phaseA8AuditLogged = new(); - - // TEMP A8.F triage (strip after): one-shot-per-camCell guard for the [opaque] wall-render probe. - private static readonly System.Collections.Generic.HashSet _a8OpaqueDumped = new(); - - private void EmitEnvCellProbe(int ourBldgs, int otherBldgs, int filterCnt) - { - if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeEnvCellEnabled) return; - var stats = _envCellRenderer?.Stats ?? default; - // Phase A8 apparatus (2026-05-28): also log pool stats. A spike in - // poolTotal or a divergence between hwm and ourBldgs cell counts - // signals pool-management regression — the bug class the audit - // caught. Hwm should track number-of-(cell,gfxObj)-pairs per visible - // landblock and stay roughly stable across stationary-camera frames. - var pool = _envCellRenderer?.GetPoolDiagnostics() ?? (0, 0); - Console.WriteLine( - $"[envcells] cells={stats.CellsRendered} tris={stats.TrianglesDrawn} " + - $"ourBldgs={ourBldgs} otherBldgs={otherBldgs} filterCnt={filterCnt} " + - $"poolTotal={pool.PoolTotal} poolHwm={pool.SnapshotPoolHwm}"); - - // Phase A8 audit (visual-gate-#1 follow-up): one-shot per - // (cellId, gfxObjId) pair dump of cell mesh batch state — to find - // why polys (e.g., floors) might not render. Set ACDREAM_A8_AUDIT=1. - if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_A8_AUDIT"), "1", StringComparison.Ordinal) - && _envCellRenderer is not null) - { - foreach (var line in _envCellRenderer.CollectCellAuditLines(_phaseA8AuditLogged)) - Console.WriteLine(line); - } - } - - private void EmitStencilProbe(string op) - { - if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) return; - if (_indoorStencilPipeline is null) return; - Console.WriteLine( - $"[stencil] op={op} bld=0x{_indoorStencilPipeline.LastStencilBuildingId:X8} " + - $"verts={_indoorStencilPipeline.LastStencilVertexCount}"); - } - - private void EmitBuildingsProbe(uint visibilityCellId, - System.Collections.Generic.List camBldgs, - System.Collections.Generic.List otherBldgs) - { - if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) return; - var ids = string.Join(",", camBldgs.ConvertAll(b => $"0x{b.BuildingId:X}")); - int totalKnown = 0; - foreach (var r in _buildingRegistries.Values) totalKnown += r.Count; - Console.WriteLine( - $"[buildings] camCell=0x{visibilityCellId:X8} " + - $"camBldgs=[{ids}] otherBldgs={otherBldgs.Count} totalKnown={totalKnown}"); - } - - // ──────────────────────────────────────────────────────────────────────────── - - /// 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. - /// Sample buffer is NOT cleared on flush — it's a moving window so the - /// next 5s window already has 256 frames of recent history. - private static bool A8PerfEnabled() - => string.Equals(Environment.GetEnvironmentVariable("ACDREAM_A8_PERF"), "1", StringComparison.Ordinal); - - private static long A8PerfStart(bool enabled) - => enabled ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; - - private static void A8PerfStop(bool enabled, ref long bucket, long startTick) - { - if (!enabled || startTick == 0) return; - bucket += System.Diagnostics.Stopwatch.GetTimestamp() - startTick; - } - - private int A8PerfBeginGpuFrame(bool enabled) - { - if (!enabled || _gl is null) return -1; - - if (!_a8PerfGpuQueriesInitialized) - { - for (int i = 0; i < _a8PerfGpuQueries.Length; i++) - _a8PerfGpuQueries[i] = _gl.GenQuery(); - _a8PerfGpuQueriesInitialized = true; - } - - int slot = _a8PerfGpuFrameIndex % A8PerfGpuRingDepth; - if (_a8PerfGpuFrameIndex >= A8PerfGpuRingDepth) - { - for (int pass = 0; pass < A8PerfGpuPassCount; pass++) - { - int queryIndex = slot * A8PerfGpuPassCount + pass; - if (!_a8PerfGpuIssued[queryIndex]) continue; - uint query = _a8PerfGpuQueries[queryIndex]; - _gl.GetQueryObject(query, QueryObjectParameterName.ResultAvailable, out int available); - if (available == 0) continue; - _gl.GetQueryObject(query, QueryObjectParameterName.Result, out ulong elapsedNs); - A8PerfAccumulateGpu(pass, elapsedNs); - _a8PerfGpuIssued[queryIndex] = false; - } - } - - return slot; - } - - private void A8PerfEndGpuFrame(bool enabled) - { - if (enabled && _a8PerfGpuQueriesInitialized) - _a8PerfGpuFrameIndex++; - } - - private void A8PerfBeginGpuQuery(bool enabled, int slot, int pass) - { - if (!enabled || slot < 0 || _gl is null) return; - int queryIndex = slot * A8PerfGpuPassCount + pass; - _a8PerfGpuIssued[queryIndex] = true; - uint query = _a8PerfGpuQueries[queryIndex]; - _gl.BeginQuery(QueryTarget.TimeElapsed, query); - } - - private void A8PerfEndGpuQuery(bool enabled, int slot) - { - if (!enabled || slot < 0 || _gl is null) return; - _gl.EndQuery(QueryTarget.TimeElapsed); - } - - private void A8PerfAccumulateGpu(int pass, ulong elapsedNs) - { - long ns = elapsedNs > long.MaxValue ? long.MaxValue : (long)elapsedNs; - switch (pass) - { - case A8PerfGpuTerrain: _a8PerfTerrainGpuNs += ns; break; - case A8PerfGpuStatic: _a8PerfStaticGpuNs += ns; break; - case A8PerfGpuOutsideIn: _a8PerfOutsideInGpuNs += ns; break; - case A8PerfGpuLive: _a8PerfLiveGpuNs += ns; break; - case A8PerfGpuInsideOut: _a8PerfInsideOutGpuNs += ns; break; - case A8PerfGpuInsideLive: _a8PerfInsideLiveGpuNs += ns; break; - } - } - - private static string A8DrawStats(AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats stats) - => $"{stats.Draws}d/{stats.CullRuns}r/{stats.Instances}i/{stats.Triangles}t"; - - private void MaybeFlushA8Perf( - bool enabled, - bool cameraInsideBuilding, - int portalBuildings, - int portalCells, - int visibleLandblocks, - int totalLandblocks) - { - if (!enabled) return; - - _a8PerfFrames++; - if (cameraInsideBuilding) _a8PerfInsideFrames++; - if (portalBuildings > 0) _a8PerfOutsideInFrames++; - _a8PerfLastPortalBuildings = portalBuildings; - _a8PerfMaxPortalBuildings = System.Math.Max(_a8PerfMaxPortalBuildings, portalBuildings); - _a8PerfLastPortalCells = portalCells; - _a8PerfMaxPortalCells = System.Math.Max(_a8PerfMaxPortalCells, portalCells); - _a8PerfLastVisibleLandblocks = visibleLandblocks; - _a8PerfLastTotalLandblocks = totalLandblocks; - - long now = Environment.TickCount64; - if (now - _a8PerfLastLogTick <= 3000) return; - - double Ms(long ticks) => _a8PerfFrames == 0 - ? 0.0 - : ticks * 1000.0 / System.Diagnostics.Stopwatch.Frequency / _a8PerfFrames; - double GpuMs(long ns) => _a8PerfFrames == 0 ? 0.0 : ns / 1_000_000.0 / _a8PerfFrames; - - Console.WriteLine(string.Create(System.Globalization.CultureInfo.InvariantCulture, - $"[A8-PERF] frames={_a8PerfFrames} inside={_a8PerfInsideFrames} outsideIn={_a8PerfOutsideInFrames} " + - $"portals={_a8PerfLastPortalBuildings}/{_a8PerfMaxPortalBuildings} cells={_a8PerfLastPortalCells}/{_a8PerfMaxPortalCells} " + - $"lb={_a8PerfLastVisibleLandblocks}/{_a8PerfLastTotalLandblocks} " + - $"avg_ms anim={Ms(_a8PerfTickAnimTicks):F3} collect={Ms(_a8PerfCollectTicks):F3} " + - $"envPrep={Ms(_a8PerfEnvPrepareTicks):F3} terrain={Ms(_a8PerfTerrainTicks):F3} " + - $"static={Ms(_a8PerfStaticTicks):F3} outsideIn={Ms(_a8PerfOutsideInTicks):F3} " + - $"live={Ms(_a8PerfLiveTicks):F3} insideOut={Ms(_a8PerfInsideOutTicks):F3} " + - $"insideLive={Ms(_a8PerfInsideLiveTicks):F3} " + - $"gpu_ms terrain={GpuMs(_a8PerfTerrainGpuNs):F3} static={GpuMs(_a8PerfStaticGpuNs):F3} " + - $"outsideIn={GpuMs(_a8PerfOutsideInGpuNs):F3} live={GpuMs(_a8PerfLiveGpuNs):F3} " + - $"insideOut={GpuMs(_a8PerfInsideOutGpuNs):F3} insideLive={GpuMs(_a8PerfInsideLiveGpuNs):F3} " + - $"draws static={A8DrawStats(_a8PerfLastStaticStats)} live={A8DrawStats(_a8PerfLastLiveStats)} " + - $"outsideShell={A8DrawStats(_a8PerfLastOutsideShellStats)} outsideIndoor={A8DrawStats(_a8PerfLastOutsideIndoorStats)}")); - - _a8PerfFrames = 0; - _a8PerfInsideFrames = 0; - _a8PerfOutsideInFrames = 0; - _a8PerfTickAnimTicks = 0; - _a8PerfCollectTicks = 0; - _a8PerfEnvPrepareTicks = 0; - _a8PerfTerrainTicks = 0; - _a8PerfStaticTicks = 0; - _a8PerfOutsideInTicks = 0; - _a8PerfLiveTicks = 0; - _a8PerfInsideOutTicks = 0; - _a8PerfInsideLiveTicks = 0; - _a8PerfTerrainGpuNs = 0; - _a8PerfStaticGpuNs = 0; - _a8PerfOutsideInGpuNs = 0; - _a8PerfLiveGpuNs = 0; - _a8PerfInsideOutGpuNs = 0; - _a8PerfInsideLiveGpuNs = 0; - _a8PerfMaxPortalBuildings = portalBuildings; - _a8PerfMaxPortalCells = portalCells; - _a8PerfLastLogTick = now; - } - private void MaybeFlushTerrainDiag() { if (!string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal)) @@ -11687,7 +10658,6 @@ 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(); diff --git a/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs b/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs deleted file mode 100644 index 9911a90..0000000 --- a/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs +++ /dev/null @@ -1,792 +0,0 @@ -// IndoorCellStencilPipeline.cs — Phase A8 indoor-cell visibility culling. -// -// Ports WorldBuilder's RenderInsideOut stencil mechanism for acdream's -// modern GL pipeline. Closes issue #78 (outdoor stabs visible through -// indoor walls) and the cellar-stairs artifact (outdoor terrain visible -// inside cottage cellars). -// -// Algorithm (per WB VisibilityManager.cs:73-239): -// Step 1: stencil ref=1 marked everywhere portal polygons cover -// (color/depth writes off, depth=Always, CullFace off). -// Step 2: gl_FragDepth=1.0 punched into stencil=1 regions -// (color off, depth on, depth=Always). -// Step 3: (caller) draw indoor entities with stencil OFF. -// Step 4: (caller) draw terrain + outdoor entities with -// glStencilFunc(Equal, 1, 0x01), stencil read-only. -// -// Retail equivalent: PView::DrawCells at -// docs/research/named-retail/acclient_2013_pseudo_c.txt:432709 uses -// screen-space polygon-clip scissor instead of stencil. Same observable -// behavior; the stencil approach matches modern GL pipeline conventions. - -using System; -using System.Collections.Generic; -using System.Numerics; -using Silk.NET.OpenGL; - -namespace AcDream.App.Rendering; - -/// -/// Pure-math triangle-fan generation from a list of cells with -/// . Extracted as a static class so -/// the vertex-list construction is unit-testable without a GL context. -/// -public static class PortalMeshBuilder -{ - /// - /// Builds a flat triangle-fan vertex array in world space from every - /// exit portal ( == 0xFFFF) on - /// every cell in . Inner portals are skipped - /// — they don't open to outdoors, so stencil-marking them would let - /// outdoor geometry bleed into adjacent rooms (incorrect). - /// - public static Vector3[] BuildTriangles( - IReadOnlyCollection cells, - Vector3? cameraWorldPosition = null) - { - // Pre-count to size the output exactly. - int triCount = 0; - foreach (var cell in cells) - { - for (int p = 0; p < cell.Portals.Count; p++) - { - if (cell.Portals[p].OtherCellId != 0xFFFF) continue; - if (!ExitPortalPassesCameraSide(cell, p, cameraWorldPosition)) continue; - if (p >= cell.PortalPolygons.Count) continue; - var poly = cell.PortalPolygons[p]; - if (poly.Length < 3) continue; - triCount += (poly.Length - 2) * 3; - } - } - - if (triCount == 0) return Array.Empty(); - - var output = new Vector3[triCount]; - int outIdx = 0; - foreach (var cell in cells) - { - var xform = cell.WorldTransform; - for (int p = 0; p < cell.Portals.Count; p++) - { - if (cell.Portals[p].OtherCellId != 0xFFFF) continue; - if (!ExitPortalPassesCameraSide(cell, p, cameraWorldPosition)) continue; - if (p >= cell.PortalPolygons.Count) continue; - var poly = cell.PortalPolygons[p]; - if (poly.Length < 3) continue; - - // Triangle-fan from vertex 0. - var v0 = Vector3.Transform(poly[0], xform); - for (int i = 1; i < poly.Length - 1; i++) - { - output[outIdx++] = v0; - output[outIdx++] = Vector3.Transform(poly[i], xform); - output[outIdx++] = Vector3.Transform(poly[i + 1], xform); - } - } - } - - return output; - } - - private static bool ExitPortalPassesCameraSide( - LoadedCell cell, - int portalIndex, - Vector3? cameraWorldPosition) - { - if (cameraWorldPosition is not Vector3 camera) - return true; - if (portalIndex >= cell.ClipPlanes.Count) - return true; - - var plane = cell.ClipPlanes[portalIndex]; - if (plane.Normal.LengthSquared() < 1e-8f) - return true; - - var localCamera = Vector3.Transform(camera, cell.InverseWorldTransform); - float dot = Vector3.Dot(plane.Normal, localCamera) + plane.D; - - return plane.InsideSide == 0 - ? dot >= -0.01f - : dot <= 0.01f; - } - -} - -/// -/// GL pipeline owner. Holds the portal stencil shader and a dynamic -/// VBO/VAO for per-frame portal triangle uploads. Public methods are -/// called in the per-frame render path when the camera is inside an -/// EnvCell. When outside, this object is dormant — no GL work. -/// -public sealed unsafe class IndoorCellStencilPipeline : IDisposable -{ - private readonly GL _gl; - private readonly Shader _shader; - private readonly uint _vao; - private readonly uint _vbo; - private int _vboCapacityVerts; - private int _lastVertexCount; - private readonly int _uViewProjectionLoc; - private readonly int _uWriteFarDepthLoc; - - public IndoorCellStencilPipeline(GL gl, string vertPath, string fragPath) - { - _gl = gl; - _shader = new Shader(gl, vertPath, fragPath); - _uViewProjectionLoc = _gl.GetUniformLocation(_shader.Program, "uViewProjection"); - _uWriteFarDepthLoc = _gl.GetUniformLocation(_shader.Program, "uWriteFarDepth"); - - _vao = _gl.GenVertexArray(); - _vbo = _gl.GenBuffer(); - AllocateVbo(1024); - ConfigureVao(); - } - - /// - /// Builds the per-frame portal triangle array from - /// and uploads it to . Returns the vertex count - /// (0 means no exit portals — caller should skip stencil setup entirely). - /// - public int UploadPortalMesh( - IReadOnlyCollection cells, - Vector3? cameraWorldPosition = null) - { - var verts = PortalMeshBuilder.BuildTriangles(cells, cameraWorldPosition); - _lastVertexCount = verts.Length; - if (_lastVertexCount == 0) return 0; - - if (_lastVertexCount > _vboCapacityVerts) - { - _vboCapacityVerts = Math.Max(_lastVertexCount * 2, 1024); - AllocateVbo(_vboCapacityVerts); - } - - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); - fixed (Vector3* p = verts) - { - _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, - (nuint)(_lastVertexCount * sizeof(Vector3)), p); - } - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); - return _lastVertexCount; - } - - /// - /// Draws the portal mesh most recently uploaded by . - /// The caller owns stencil/depth/color/cull state, matching - /// . - /// - public void DrawUploadedPortalMesh( - Matrix4x4 viewProjection, - bool writeFarDepth, - bool enableDepthClamp = true) - { - if (_lastVertexCount == 0) - { - LastStencilVertexCount = 0; - LastStencilWasFarPunch = writeFarDepth; - LastStencilBuildingId = 0; - return; - } - - if (enableDepthClamp) - _gl.Enable(EnableCap.DepthClamp); - - _shader.Use(); - var vp = viewProjection; - _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp); - _gl.Uniform1(_uWriteFarDepthLoc, writeFarDepth ? 1 : 0); - - _gl.BindVertexArray(_vao); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_lastVertexCount); - _gl.BindVertexArray(0); - - if (enableDepthClamp) - _gl.Disable(EnableCap.DepthClamp); - - LastStencilVertexCount = _lastVertexCount; - LastStencilWasFarPunch = writeFarDepth; - LastStencilBuildingId = 0; - } - - /// - /// Steps 1+2 of WB's RenderInsideOut: mark stencil ref=1 wherever - /// portal polygons cover, then write gl_FragDepth=1.0 into those - /// regions. Leaves the GL state set up for the caller to invoke - /// . - /// - public void MarkAndPunch(Matrix4x4 viewProjection) - { - if (_lastVertexCount == 0) return; - - _gl.Enable(EnableCap.StencilTest); - _gl.Enable(EnableCap.DepthTest); // idempotent if already on; makes MarkAndPunch self-contained - _gl.ClearStencil(0); - _gl.Clear(ClearBufferMask.StencilBufferBit); - - // Step 1: stencil mark. - _gl.ColorMask(false, false, false, false); - _gl.DepthMask(false); - _gl.DepthFunc(DepthFunction.Always); - _gl.Disable(EnableCap.CullFace); - _gl.Enable(EnableCap.DepthClamp); // portal polys can be at any Z - _gl.StencilFunc(StencilFunction.Always, 1, 0xFFu); - _gl.StencilMask(0x01u); - _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); - - _shader.Use(); - var vp = viewProjection; - _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp); - _gl.Uniform1(_uWriteFarDepthLoc, 0); - - _gl.BindVertexArray(_vao); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_lastVertexCount); - - // Step 2: far-depth punch. - _gl.ColorMask(false, false, false, false); - _gl.DepthMask(true); - _gl.DepthFunc(DepthFunction.Always); - _gl.StencilFunc(StencilFunction.Equal, 1, 0x01u); - _gl.StencilMask(0x00u); // read-only - _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep); - - _gl.Uniform1(_uWriteFarDepthLoc, 1); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_lastVertexCount); - - _gl.BindVertexArray(0); - _gl.Disable(EnableCap.DepthClamp); - _gl.Enable(EnableCap.CullFace); - - // Leave a clean state for the indoor-entities pass: stencil - // disabled, color+depth on, depth=Less, no cull change. - _gl.ColorMask(true, true, true, true); - _gl.DepthFunc(DepthFunction.Less); - _gl.Disable(EnableCap.StencilTest); - } - - /// - /// Phase A8.F: mark a pre-projected NDC clip region into stencil bit 1 and far-depth-punch it. - /// Replaces the flat world-space exit-portal path (PortalMeshBuilder.BuildTriangles) with the - /// recursively-clipped region from PortalVisibilityBuilder.OutsideView. Polygons are triangulated - /// (fan) and uploaded as NDC verts (z=0) drawn with an identity view-projection (already clip-space). - /// GL state on exit matches MarkAndPunch: stencil disabled, color+depth on, depth=Less. - /// - public void MarkAndPunchNdc(System.Collections.Generic.IReadOnlyList region) - { - // Triangulate the region (fan per convex polygon) into NDC Vector3 (z=0). - int triVerts = 0; - foreach (var p in region) if (!p.IsEmpty) triVerts += (p.Vertices.Length - 2) * 3; - if (triVerts == 0) { _lastVertexCount = 0; LastStencilVertexCount = 0; LastStencilBuildingId = 0; return; } - - var verts = new Vector3[triVerts]; - int idx = 0; - foreach (var p in region) - { - if (p.IsEmpty) continue; - var v0 = new Vector3(p.Vertices[0], 0f); - for (int i = 1; i < p.Vertices.Length - 1; i++) - { - verts[idx++] = v0; - verts[idx++] = new Vector3(p.Vertices[i], 0f); - verts[idx++] = new Vector3(p.Vertices[i + 1], 0f); - } - } - - if (triVerts > _vboCapacityVerts) AllocateVbo(Math.Max(triVerts * 2, 1024)); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); - fixed (Vector3* p = verts) - _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(triVerts * sizeof(Vector3)), p); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); - _lastVertexCount = triVerts; - - // Phase A8.F: expose the clipped-mask vert count to the [stencil] probe so the Task 9 - // visual-gate evidence reflects the recursively-clipped OutsideView (building id 0 = N/A). - LastStencilVertexCount = triVerts; - LastStencilWasFarPunch = true; - LastStencilBuildingId = 0; - - // Same GL state machine as MarkAndPunch, but identity VP (verts are already NDC). - var identity = Matrix4x4.Identity; - - _gl.Enable(EnableCap.StencilTest); - _gl.Enable(EnableCap.DepthTest); - _gl.ClearStencil(0); - _gl.Clear(ClearBufferMask.StencilBufferBit); - - // Step 1: mark bit 1. - // No DepthClamp here (unlike MarkAndPunch): MarkAndPunch draws world-space portal - // polygons that can fall outside [near,far]; these verts are already NDC with z=0, - // always within the depth range, so there is nothing to clamp. - _gl.ColorMask(false, false, false, false); - _gl.DepthMask(false); - _gl.DepthFunc(DepthFunction.Always); - _gl.Disable(EnableCap.CullFace); - _gl.StencilFunc(StencilFunction.Always, 1, 0xFFu); - _gl.StencilMask(0x01u); - _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); - _shader.Use(); - _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&identity); - _gl.Uniform1(_uWriteFarDepthLoc, 0); - _gl.BindVertexArray(_vao); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)triVerts); - - // Step 2: far-depth punch where bit 1 is set. - _gl.DepthMask(true); - _gl.DepthFunc(DepthFunction.Always); - _gl.StencilFunc(StencilFunction.Equal, 1, 0x01u); - _gl.StencilMask(0x00u); - _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep); - _gl.Uniform1(_uWriteFarDepthLoc, 1); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)triVerts); - _gl.BindVertexArray(0); - - // Clean state for the indoor-entities pass (matches MarkAndPunch exit). - _gl.Enable(EnableCap.CullFace); - _gl.ColorMask(true, true, true, true); - _gl.DepthFunc(DepthFunction.Less); - _gl.Disable(EnableCap.StencilTest); - } - - /// - /// Phase A8.F (#2): mark stencil BIT 2 (only) wherever the NDC region covers, preserving bit 1. - /// Color/depth writes off. Enables the stencil test and leaves it enabled. Used to clip a single - /// cell's translucent geometry to its portal-chain screen region without disturbing the bit-1 - /// OutsideView mask. Pair with EnableBit2CellPass (render) then ResetRegionBit2 (clear). - /// - public void MarkRegionBit2(System.Collections.Generic.IReadOnlyList region) - => DrawRegionBit2(region, setBit: true); - - /// Phase A8.F (#2): clear stencil BIT 2 (only) wherever the NDC region covers, preserving - /// bit 1. Call after rendering a cell so the next cell starts with bit 2 == 0 in this region. - public void ResetRegionBit2(System.Collections.Generic.IReadOnlyList region) - => DrawRegionBit2(region, setBit: false); - - /// Phase A8.F (#2): set render state to draw geometry only where bit 2 is set (read-only - /// stencil). Call between MarkRegionBit2 and the cell's draw. - public void EnableBit2CellPass() - { - _gl.Enable(EnableCap.StencilTest); - _gl.StencilFunc(StencilFunction.Equal, 0x02, 0x02u); // bit 2 set - _gl.StencilMask(0x00u); // read-only — do not touch bit 1 or bit 2 - _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep); - // Make the per-cell transparent render state explicit (don't inherit DrawRegionBit2's - // DepthFunc.Always): depth-test Less, depth-write off (translucent). Self-describing so - // the clip pass can't silently regress if a sibling helper's exit state changes. - _gl.DepthFunc(DepthFunction.Less); - _gl.DepthMask(false); - } - - // Triangulate the NDC region (fan, z=0) and draw it writing only bit 2 (set or clear). - // StencilMask 0x02 guarantees bit 1 is never modified. Color/depth writes off. - private void DrawRegionBit2(System.Collections.Generic.IReadOnlyList region, bool setBit) - { - int triVerts = 0; - foreach (var p in region) if (!p.IsEmpty) triVerts += (p.Vertices.Length - 2) * 3; - if (triVerts == 0) return; - - var verts = new Vector3[triVerts]; - int idx = 0; - foreach (var p in region) - { - if (p.IsEmpty) continue; - var v0 = new Vector3(p.Vertices[0], 0f); - for (int i = 1; i < p.Vertices.Length - 1; i++) - { - verts[idx++] = v0; - verts[idx++] = new Vector3(p.Vertices[i], 0f); - verts[idx++] = new Vector3(p.Vertices[i + 1], 0f); - } - } - - if (triVerts > _vboCapacityVerts) AllocateVbo(Math.Max(triVerts * 2, 1024)); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); - fixed (Vector3* p = verts) - _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(triVerts * sizeof(Vector3)), p); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); - - var identity = Matrix4x4.Identity; - _gl.Enable(EnableCap.StencilTest); - _gl.ColorMask(false, false, false, false); - _gl.DepthMask(false); - _gl.DepthFunc(DepthFunction.Always); - _gl.Disable(EnableCap.CullFace); - _gl.StencilMask(0x02u); // ONLY bit 2 — bit 1 preserved - _gl.StencilFunc(StencilFunction.Always, setBit ? 0x02 : 0x00, 0xFFu); - _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); - _shader.Use(); - _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&identity); - _gl.Uniform1(_uWriteFarDepthLoc, 0); - _gl.BindVertexArray(_vao); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)triVerts); - _gl.BindVertexArray(0); - _gl.Enable(EnableCap.CullFace); - _gl.ColorMask(true, true, true, false); // match the indoor pass convention (alpha-write off) - // Restore the loop's depth-test: do NOT leak DepthFunc.Always into the cell render, the - // unclipped else-branch, the IndoorPass shells, or Step 4. (Opus review C1/C2.) - _gl.DepthFunc(DepthFunction.Less); - } - - /// - /// Step 4 of WB's RenderInsideOut: enable stencil read-only with - /// ref=1, so subsequent terrain + outdoor entity draws are gated - /// to portal silhouette regions only. - /// - public void EnableOutdoorPass() - { - if (_lastVertexCount == 0) return; - _gl.Enable(EnableCap.StencilTest); - _gl.StencilFunc(StencilFunction.Equal, 1, 0x01u); - _gl.StencilMask(0x00u); - _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep); - } - - /// - /// Restores stencil-off state. Call after all outdoor passes complete - /// so subsequent rendering (particles, sky, UI) is unaffected. - /// - public void DisableStencil() - { - _gl.Disable(EnableCap.StencilTest); - _gl.StencilMask(0xFFu); // restore default write mask - } - - // ------------------------------------------------------------------------- - // Phase A8 RR6 (2026-05-26): Step 5 — 3-bit stencil mode + occlusion-query - // helpers. These methods are called by RR7 (Steps 1-4 wire-in) and RR9 - // (Step 5 cross-building pass). They assume the VBO was already uploaded - // by UploadBuildingPortalMesh for the specific building being processed. - // ------------------------------------------------------------------------- - - /// - /// Phase A8 RR6: Step 5a — mark stencil bit 2 at portal silhouettes WHERE - /// bit 1 is already set. After this call, pixels at the intersection of our - /// building's portals (bit 1) and the other building's portals (bit 2) will - /// have stencil == 3. Subsequent renders - /// into those pixels only. - /// - /// GL state on entry: stencil test enabled (left by prior MarkAndPunch - /// or EnableOutdoorPass); depth test on. DepthFunc.Lequal set here. - /// - /// Mirrors WB VisibilityManager.cs:186-189. - /// - public void MarkBuildingBit2(Matrix4x4 viewProjection, int buildingPortalVertexCount) - { - // WB:186 StencilFunc.Equal(3, 0x01) — match where bit 1 is set - // WB:187 StencilOp(Keep, Keep, Replace) - // WB:188 StencilMask 0x02 — only write to bit 2 - // WB:189 ColorMask off; DepthMask off; DepthFunc.Lequal; Disable CullFace - _gl.StencilFunc(StencilFunction.Equal, 3, 0x01u); - _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); - _gl.StencilMask(0x02u); - _gl.ColorMask(false, false, false, false); - _gl.DepthMask(false); - _gl.Enable(EnableCap.DepthTest); - _gl.DepthFunc(DepthFunction.Lequal); - _gl.Disable(EnableCap.CullFace); - - _shader.Use(); - var vp = viewProjection; - _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp); - _gl.Uniform1(_uWriteFarDepthLoc, 0); // color/depth writes off anyway - - _gl.BindVertexArray(_vao); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)buildingPortalVertexCount); - _gl.BindVertexArray(0); - } - - /// - /// Phase A8 RR6: Step 5b — punch depth=1.0 where stencil == 3 (intersection - /// of our portal silhouette and the other building's portal silhouette). Clears - /// interior-wall depth written during Step 3 so the other building's cells win - /// depth when rendered in . - /// - /// GL state on entry: stencil enabled; StencilMask 0x02 from MarkBuildingBit2. - /// This method sets StencilMask 0x00 (read-only), DepthMask on, DepthFunc.Always. - /// - /// Mirrors WB VisibilityManager.cs:201-205. - /// - public void PunchDepthAtStencil3(Matrix4x4 viewProjection, int buildingPortalVertexCount) - { - // WB:201 StencilFunc.Equal(3, 0x03) — match intersection pixels - // WB:202 StencilMask 0x00 — read-only stencil - // WB:203 DepthMask on; DepthFunc.Always - // WB:204-205 draw portal triangles with uWriteFarDepth=1 - _gl.StencilFunc(StencilFunction.Equal, 3, 0x03u); - _gl.StencilMask(0x00u); - _gl.DepthMask(true); - _gl.DepthFunc(DepthFunction.Always); - - _shader.Use(); - var vp = viewProjection; - _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp); - _gl.Uniform1(_uWriteFarDepthLoc, 1); // write gl_FragDepth = 1.0 - - _gl.BindVertexArray(_vao); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)buildingPortalVertexCount); - _gl.BindVertexArray(0); - } - - /// - /// Phase A8 RR6: Step 5c — set render state to draw the other-building's - /// EnvCells where stencil == 3. Does not issue draw calls; caller renders - /// the cells immediately after. - /// - /// GL state on entry: stencil func still Equal(3, 0x03) from - /// PunchDepthAtStencil3. This method re-enables color and CullFace, sets - /// DepthFunc.Less for the geometry pass. - /// - /// Mirrors WB VisibilityManager.cs:210-212. - /// - public void EnableOtherBuildingPass() - { - // WB:210 ColorMask true; WB:211 DepthFunc.Less; WB:212 Enable CullFace - // Stencil func stays Equal(3, 0x03) from PunchDepthAtStencil3. - _gl.ColorMask(true, true, true, false); - _gl.DepthFunc(DepthFunction.Less); - _gl.Enable(EnableCap.CullFace); - } - - /// - /// Phase A8 RR6: Step 5d — reset bit 2 to zero so the next other-building - /// iteration starts fresh. Re-draws this building's portal triangles with a - /// ref value of 1 (bit 2 = 0) to overwrite bit 2 back to 0 in stencil. - /// - /// GL state on entry: color/depth writes may still be on from - /// EnableOtherBuildingPass. This method disables them and rewrites bit 2. - /// - /// Mirrors WB VisibilityManager.cs:222-228. - /// - public void ResetBit2(Matrix4x4 viewProjection, int buildingPortalVertexCount) - { - // WB:222 ColorMask off; DepthMask off - // WB:223 StencilMask 0x02 - // WB:224 StencilFunc.Always(1, 0x02) → ref=1 AND mask=0x02 → writes 0 to bit 2 - // because ref & writeMask = 1 & 0x02 = 0 (bit 2 of 1 is 0) - // WB:225-226 StencilOp Replace - // WB:227-228 draw portal triangles - _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); - - _shader.Use(); - var vp = viewProjection; - _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp); - _gl.Uniform1(_uWriteFarDepthLoc, 0); - - _gl.BindVertexArray(_vao); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)buildingPortalVertexCount); - _gl.BindVertexArray(0); - } - - // ------------------------------------------------------------------------- - // Occlusion-query helpers (Phase A8 RR6). Wrap raw GL query calls with - // the same asynchronous non-stalling pattern used by WB VisibilityManager - // (lines 173-184 and 267-287). All three Building.QueryId/QueryStarted/ - // WasVisible fields are written by the caller (RR9) around these helpers. - // ------------------------------------------------------------------------- - - /// - /// Phase A8 RR6: lazily allocate a GL query object handle. The - /// is written on first call and reused thereafter. Callers pass - /// ref building.QueryId. Mirrors WB VisibilityManager.cs:173 - /// (if (building.QueryId != 0) guard pattern — we create the id before - /// that guard is first needed). - /// - public uint EnsureOcclusionQueryId(ref uint slot) - { - if (slot == 0) slot = _gl.GenQuery(); - return slot; - } - - /// - /// Phase A8 RR6: non-blocking read of the previous frame's occlusion query - /// result. Returns false immediately if the result is not yet available (no - /// CPU stall). Sets to true if at least one - /// sample passed. - /// - /// Mirrors WB VisibilityManager.cs:174-180 and 272-278. - /// - public bool TryReadOcclusionResult(uint queryId, out bool anyPassed) - { - anyPassed = false; - if (queryId == 0) return false; - _gl.GetQueryObject(queryId, QueryObjectParameterName.ResultAvailable, out int available); - if (available == 0) return false; - _gl.GetQueryObject(queryId, QueryObjectParameterName.Result, out int samplesPassed); - anyPassed = samplesPassed > 0; - return true; - } - - /// - /// Phase A8 RR6: begin a samples-passed occlusion query. Caller is responsible - /// for setting building.QueryStarted = true afterward. - /// Mirrors WB VisibilityManager.cs:182 and 279. - /// - public void BeginOcclusionQuery(uint queryId) => - _gl.BeginQuery(QueryTarget.SamplesPassed, queryId); - - /// - /// Phase A8 RR6: end a samples-passed occlusion query. - /// Mirrors WB VisibilityManager.cs:195 and 286. - /// - public void EndOcclusionQuery() => - _gl.EndQuery(QueryTarget.SamplesPassed); - - // ------------------------------------------------------------------------- - // Per-building portal mesh upload (Phase A8 RR6 — S3). - // ------------------------------------------------------------------------- - - /// - /// Phase A8 RR6: upload a Building's pre-computed world-space exit portal - /// polygons as a triangle fan. Mirrors but - /// operates on - /// instead of per-cell portal lists. Called once per other-building per frame - /// (Step 5 loop in RR9) before the MarkBuildingBit2 / PunchDepthAtStencil3 / - /// EnableOtherBuildingPass / ResetBit2 sequence. - /// - /// Vertex count uploaded (always a multiple of 3; 0 if no polygons). - public int UploadBuildingPortalMesh(AcDream.App.Rendering.Wb.Building building) - { - // Pre-count vertices so we allocate exactly. - int triVertCount = 0; - foreach (var poly in building.ExitPortalPolygons) - { - if (poly.Length < 3) continue; - triVertCount += (poly.Length - 2) * 3; - } - - if (triVertCount == 0) - { - _lastVertexCount = 0; - return 0; - } - - if (triVertCount > _vboCapacityVerts) - AllocateVbo(Math.Max(triVertCount * 2, 1024)); - - // Triangle-fan triangulation — matches UploadPortalMesh / WB PortalRenderManager.cs:537-543. - var verts = new Vector3[triVertCount]; - int idx = 0; - foreach (var poly in building.ExitPortalPolygons) - { - if (poly.Length < 3) continue; - var v0 = poly[0]; - for (int i = 1; i < poly.Length - 1; i++) - { - verts[idx++] = v0; - verts[idx++] = poly[i]; - verts[idx++] = poly[i + 1]; - } - } - - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); - fixed (Vector3* p = verts) - { - _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, - (nuint)(idx * sizeof(Vector3)), p); - } - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); - - _lastVertexCount = idx; - return idx; - } - - /// - /// Phase A8 (2026-05-28): low-level building-portal stencil draw. Mirrors WB - /// PortalRenderManager.RenderBuildingStencilMask at - /// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:471-484. - /// - /// Uploads the building's exit-portal mesh to our shared VBO and draws - /// it with the portal_stencil shader. Does NOT set or restore any - /// surrounding GL state — caller is responsible (stencil func, - /// depth mask, color mask, cull face, etc.) per WB - /// VisibilityManager.RenderInsideOut Steps 1/2/5a/5b/5d - /// expectations. - /// - /// Note: enables/disables GL_DEPTH_CLAMP around the draw - /// because portal polygons can extend beyond the camera's near/far range. - /// This is symmetric — no state leakage. - /// - /// The building whose exit portal polygons to draw. - /// Camera view-projection matrix. - /// When true, the fragment shader writes - /// gl_FragDepth = 1.0 (WB Step 2 / Step 5b "punch" semantic). - /// When false, default depth is written (WB Step 1 / Step 5a "mark" - /// semantic). - public void RenderBuildingStencilMask(AcDream.App.Rendering.Wb.Building building, Matrix4x4 viewProjection, bool writeFarDepth) - { - int vertexCount = UploadBuildingPortalMesh(building); - if (vertexCount == 0) - { - LastStencilVertexCount = 0; - LastStencilWasFarPunch = writeFarDepth; - LastStencilBuildingId = building.BuildingId; - return; - } - - _gl.Enable(EnableCap.DepthClamp); - - _shader.Use(); - var vp = viewProjection; - _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp); - _gl.Uniform1(_uWriteFarDepthLoc, writeFarDepth ? 1 : 0); - - _gl.BindVertexArray(_vao); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)vertexCount); - _gl.BindVertexArray(0); - - _gl.Disable(EnableCap.DepthClamp); - - LastStencilVertexCount = vertexCount; - LastStencilWasFarPunch = writeFarDepth; - LastStencilBuildingId = building.BuildingId; - } - - // ------------------------------------------------------------------------- - // Probe data (Phase A8 — read by the [stencil] probe emitter in GameWindow). - // ------------------------------------------------------------------------- - - /// Phase A8 RR9: vertex count of the most recent - /// draw. 0 if the building had no portals. - public int LastStencilVertexCount { get; private set; } - - /// Phase A8 RR9: true iff the most recent - /// draw was a far-depth punch (Step 2). - public bool LastStencilWasFarPunch { get; private set; } - - /// Phase A8 RR9: building id of the most recent - /// draw. - public uint LastStencilBuildingId { get; private set; } - - public void Dispose() - { - _shader.Dispose(); - _gl.DeleteVertexArray(_vao); - _gl.DeleteBuffer(_vbo); - } - - // Safe to call mid-session after ConfigureVao — the VAO bakes the - // VBO association at VertexAttribPointer time, so reallocating the - // VBO with new size does NOT require re-running ConfigureVao. - private void AllocateVbo(int capacityVerts) - { - _vboCapacityVerts = capacityVerts; - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); - _gl.BufferData(BufferTargetARB.ArrayBuffer, - (nuint)(capacityVerts * sizeof(Vector3)), - null, BufferUsageARB.DynamicDraw); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); - } - - private void ConfigureVao() - { - _gl.BindVertexArray(_vao); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); - _gl.EnableVertexAttribArray(0); - _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, - (uint)sizeof(Vector3), (void*)0); - _gl.BindVertexArray(0); - } -} diff --git a/src/AcDream.App/Rendering/Shaders/portal_stencil.frag b/src/AcDream.App/Rendering/Shaders/portal_stencil.frag deleted file mode 100644 index 7ff1582..0000000 --- a/src/AcDream.App/Rendering/Shaders/portal_stencil.frag +++ /dev/null @@ -1,25 +0,0 @@ -#version 430 core -// -// Phase A8 — portal stencil mark + far-depth punch. -// -// uWriteFarDepth = 0 → pass through gl_FragCoord.z (used for the -// stencil mark pass; depth mask is off anyway). -// uWriteFarDepth != 0 → write gl_FragDepth = 1.0 (the far-depth punch -// pass; depth mask is on, color is off). -// -// Matches WorldBuilder's PortalStencil.frag at -// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/PortalStencil.frag - -uniform int uWriteFarDepth; - -void main() -{ - if (uWriteFarDepth != 0) - { - gl_FragDepth = 1.0; - } - else - { - gl_FragDepth = gl_FragCoord.z; - } -} diff --git a/src/AcDream.App/Rendering/Shaders/portal_stencil.vert b/src/AcDream.App/Rendering/Shaders/portal_stencil.vert deleted file mode 100644 index 54f71cb..0000000 --- a/src/AcDream.App/Rendering/Shaders/portal_stencil.vert +++ /dev/null @@ -1,25 +0,0 @@ -#version 430 core -// -// Phase A8 - portal stencil mark + far-depth punch. -// -// Position is in WORLD space (pipeline transforms cell-local portal -// polygon vertices through cell.WorldTransform on the CPU before -// uploading to the VBO). Output is clip space via uViewProjection. - -layout(location = 0) in vec3 aPosition; - -uniform mat4 uViewProjection; - -void main() -{ - vec4 pos = uViewProjection * vec4(aPosition, 1.0); - - // Match WorldBuilder's PortalStencil.vert: keep portal polygons stable - // when the chase camera straddles an exit portal plane. Without this, - // near-zero clip W can explode the screen-space portal mask and let the - // Step 4 terrain pass punch into indoor floor/wall pixels for a frame. - if (abs(pos.w) < 0.001) - pos.w = pos.w < 0.0 ? -0.001 : 0.001; - - gl_Position = pos; -} diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 2e4acb6..af84b25 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -62,42 +62,21 @@ namespace AcDream.App.Rendering.Wb; public sealed unsafe class WbDrawDispatcher : IDisposable { /// - /// Phase A8 — which subset of entities to walk in a single Draw call. - /// Used to split the indoor-cell visibility pipeline into three passes - /// when the camera is inside an EnvCell. + /// Which subset of entities to walk in a single Draw call. /// - /// Taxonomy reference: docs/research/2026-05-26-a8-entity-taxonomy.md. + /// Phase U.1 (2026-05-30): the indoor/outdoor two-pipe split (IndoorPass / + /// OutdoorScenery / BuildingShells / LiveDynamic) was deleted along with the + /// inside-out render machinery. is the sole remaining + /// member; the unified retail-faithful pass (Phase U) draws every entity in + /// one path. The set: parameter is retained on the Draw overloads so + /// the unified pass can re-introduce partitioning later without re-threading + /// the call sites. /// public enum EntitySet { - /// Pre-A8 behavior: every entity walked, gated only by - /// the existing ParentCellId ∈ visibleCellIds filter. - /// Used when the camera is OUTSIDE any EnvCell. + /// Every entity walked, gated only by the existing + /// ParentCellId ∈ visibleCellIds filter. All, - - /// Cell mesh + cell statics ( - /// non-null) PLUS building shell stabs ( - /// true) whose - /// belongs to the active building cell set. Live-dynamic - /// (ServerGuid != 0) is excluded; it flows through - /// . - IndoorPass, - - /// Outdoor/top-level stabs (ParentCellId == null), - /// including building shells. Drawn stencil-gated to portal - /// silhouettes when the camera is inside. Live-dynamic excluded. - OutdoorScenery, - - /// Top-level building shell stabs only, optionally scoped by - /// . Used for - /// portal depth repair without walking the full outdoor scenery set. - BuildingShells, - - /// Server-spawned dynamic entities (ServerGuid != 0): - /// player, NPCs, monsters, dropped items, animated and idle doors. - /// Drawn last with stencil disabled so they're depth-tested against - /// everything else but not stencil-clipped. - LiveDynamic, } private readonly GL _gl; @@ -576,13 +555,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable probeState, set); - if (set == EntitySet.IndoorPass && RenderingDiagnostics.ProbeVisibilityEnabled) - { - Console.WriteLine( - $"[indoor-shells] anchorPass={walkResult.BuildingShellAnchorPass} " + - $"anchorReject={walkResult.BuildingShellAnchorReject} walked={walkResult.EntitiesWalked}"); - } - // Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple // per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of // a given entity are contiguous. We accumulate ALL of an entity's @@ -1507,82 +1479,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } /// - /// Phase A8 — entity-taxonomy-aware membership test for the three-way - /// EntitySet partition. See for the doctrine. + /// Entity-set membership test. Phase U.1 (2026-05-30): with the + /// two-pipe partition deleted, the sole + /// member matches every entity. Retained as a seam for the unified + /// pass to re-introduce partitioning. /// - private static bool EntityMatchesSet(WorldEntity entity, EntitySet set) - { - if (set == EntitySet.All) return true; - - bool isLiveDynamic = entity.ServerGuid != 0; - if (set == EntitySet.LiveDynamic) return isLiveDynamic; - if (isLiveDynamic) return false; // IndoorPass/OutdoorScenery exclude live-dynamic - - bool isIndoor = entity.ParentCellId.HasValue || entity.IsBuildingShell; - if (set == EntitySet.IndoorPass) return isIndoor; - if (set == EntitySet.OutdoorScenery) return !entity.ParentCellId.HasValue; - if (set == EntitySet.BuildingShells) return entity.IsBuildingShell; - - throw new InvalidOperationException($"Unhandled EntitySet value: {set}"); - } - - /// - /// Phase A8 test helper: runs the EntitySet partition + visibleCellIds - /// gate against an in-memory entity list, returning the IDs that - /// survive both filters. Exists so the partition logic is unit-testable - /// without requiring a GL context or landblock-entries machinery. - /// - public static List WalkEntitiesForTest( - IReadOnlyList entities, - HashSet? visibleCellIds, - EntitySet set) - { - var output = new List(); - foreach (var entity in entities) - { - if (!EntityMatchesSet(entity, set)) continue; - if (entity.MeshRefs.Count == 0) continue; - - if (!EntityPassesVisibleCellGate(entity, visibleCellIds, set)) continue; - - output.Add(entity.Id); - } - return output; - } - - /// - /// Phase A8 RR5 (2026-05-26): pure-data walk for the explicit cellIds - /// overload. Used by RR7's IndoorPass to render only the camera-buildings' - /// cells (instead of the visibility-derived set). - /// - /// Indoor entities (ParentCellId set) gated by membership in - /// . Building shells are gated by - /// BuildingShellAnchorCellId membership in the same cell set. Outdoor - /// scenery is excluded by the EntitySet partition (no cell-list gate - /// needed — EntityMatchesSet handles it). - /// - public static List WalkEntitiesForTestByCellIds( - IEnumerable entities, - IReadOnlyCollection cellIds, - EntitySet set) - { - var result = new List(); - foreach (var entity in entities) - { - if (!EntityMatchesSet(entity, set)) continue; - if (entity.MeshRefs.Count == 0) continue; - if (entity.ParentCellId.HasValue && !cellIds.Contains(entity.ParentCellId.Value)) - continue; - if (IsShellScopedSet(set) && entity.IsBuildingShell) - { - if (entity.BuildingShellAnchorCellId is not uint anchorCellId || - !cellIds.Contains(anchorCellId)) - continue; - } - result.Add(entity.Id); - } - return result; - } + private static bool EntityMatchesSet(WorldEntity entity, EntitySet set) => true; private static bool EntityPassesVisibleCellGate( WorldEntity entity, @@ -1604,8 +1506,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable return true; } - private static bool IsShellScopedSet(EntitySet set) => - set == EntitySet.IndoorPass || set == EntitySet.BuildingShells; + // Phase U.1 (2026-05-30): the shell-scoped sets (IndoorPass / BuildingShells) + // were deleted with the two-pipe machinery. EntitySet.All is never shell-scoped. + private static bool IsShellScopedSet(EntitySet set) => false; public void Dispose() { diff --git a/tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs b/tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs deleted file mode 100644 index 5b5dd9f..0000000 --- a/tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs +++ /dev/null @@ -1,232 +0,0 @@ -// Phase A8 — portal mesh triangle-fan generation tests. -// -// Pure-math coverage of PortalMeshBuilder.BuildTriangles — the part of -// IndoorCellStencilPipeline that converts a list of LoadedCell with -// PortalPolygons + WorldTransform into a flat Vector3[] of triangles -// in world space. The GL/upload portion is exercised at runtime only. - -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.Rendering; -using Xunit; - -namespace AcDream.App.Tests.Rendering; - -public class IndoorCellStencilPipelineTests -{ - [Fact] - public void BuildTriangles_NoCells_ReturnsEmpty() - { - var verts = PortalMeshBuilder.BuildTriangles(new List()); - Assert.Empty(verts); - } - - [Fact] - public void BuildTriangles_SkipsInnerPortals() - { - // Two portals on one cell: one exit (OtherCellId=0xFFFF, should be - // included), one inner (OtherCellId=0x0102, should be skipped). - var cell = new LoadedCell - { - WorldTransform = Matrix4x4.Identity, - Portals = new() - { - new CellPortalInfo(0xFFFF, 100, 0), // exit — included - new CellPortalInfo(0x0102, 101, 0), // inner — skipped - }, - ClipPlanes = new() { default, default }, - PortalPolygons = new() - { - new[] - { - new Vector3(0, 0, 0), - new Vector3(1, 0, 0), - new Vector3(1, 1, 0), - }, - new[] - { - new Vector3(10, 0, 0), - new Vector3(11, 0, 0), - new Vector3(11, 1, 0), - }, - }, - }; - - var verts = PortalMeshBuilder.BuildTriangles(new List { cell }); - - // Only the exit polygon (3 verts → 1 triangle → 3 vertices). - Assert.Equal(3, verts.Length); - Assert.Equal(new Vector3(0, 0, 0), verts[0]); - Assert.Equal(new Vector3(1, 0, 0), verts[1]); - Assert.Equal(new Vector3(1, 1, 0), verts[2]); - } - - [Fact] - public void BuildTriangles_OnlyIncludesProvidedVisibleCells() - { - // The render path now feeds BuildTriangles from the portal traversal's - // visible cells, not every cell in the building. A hidden room's exit - // portal must not punch outdoor terrain into the current view. - var visibleInnerCell = new LoadedCell - { - WorldTransform = Matrix4x4.Identity, - Portals = new() { new CellPortalInfo(0x0102, 100, 0) }, - ClipPlanes = new() { default }, - PortalPolygons = new() - { - new[] - { - new Vector3(0, 0, 0), - new Vector3(1, 0, 0), - new Vector3(1, 1, 0), - }, - }, - }; - - var hiddenExitCell = new LoadedCell - { - WorldTransform = Matrix4x4.Identity, - Portals = new() { new CellPortalInfo(0xFFFF, 101, 0) }, - ClipPlanes = new() { default }, - PortalPolygons = new() - { - new[] - { - new Vector3(10, 0, 0), - new Vector3(11, 0, 0), - new Vector3(11, 1, 0), - }, - }, - }; - - var visibleOnly = PortalMeshBuilder.BuildTriangles(new List { visibleInnerCell }); - var allBuildingCells = PortalMeshBuilder.BuildTriangles(new List { visibleInnerCell, hiddenExitCell }); - - Assert.Empty(visibleOnly); - Assert.Equal(3, allBuildingCells.Length); - Assert.Equal(new Vector3(10, 0, 0), allBuildingCells[0]); - } - - [Fact] - public void BuildTriangles_CameraSideFilterSkipsExitPortalsBehindCamera() - { - var cell = new LoadedCell - { - WorldTransform = Matrix4x4.Identity, - InverseWorldTransform = Matrix4x4.Identity, - Portals = new() { new CellPortalInfo(0xFFFF, 100, 0) }, - ClipPlanes = new() - { - new PortalClipPlane - { - Normal = Vector3.UnitX, - D = 0f, - InsideSide = 0, - }, - }, - PortalPolygons = new() - { - new[] - { - new Vector3(0, 0, 0), - new Vector3(0, 1, 0), - new Vector3(0, 0, 1), - }, - }, - }; - - var visible = PortalMeshBuilder.BuildTriangles(new List { cell }, new Vector3(1, 0, 0)); - var rejected = PortalMeshBuilder.BuildTriangles(new List { cell }, new Vector3(-1, 0, 0)); - - Assert.Equal(3, visible.Length); - Assert.Empty(rejected); - } - - [Fact] - public void BuildTriangles_TriangulatesAsFan() - { - // 4-vertex quad → fan = 2 triangles → 6 vertices. - // Quad: (0,0,0), (1,0,0), (1,1,0), (0,1,0). - // Fan from vertex 0: (0,1,2), (0,2,3). - var cell = new LoadedCell - { - WorldTransform = Matrix4x4.Identity, - Portals = new() { new CellPortalInfo(0xFFFF, 100, 0) }, - ClipPlanes = new() { default }, - PortalPolygons = new() - { - new[] - { - new Vector3(0, 0, 0), - new Vector3(1, 0, 0), - new Vector3(1, 1, 0), - new Vector3(0, 1, 0), - }, - }, - }; - - var verts = PortalMeshBuilder.BuildTriangles(new List { cell }); - - Assert.Equal(6, verts.Length); - // Triangle 1: (0,1,2) - Assert.Equal(new Vector3(0, 0, 0), verts[0]); - Assert.Equal(new Vector3(1, 0, 0), verts[1]); - Assert.Equal(new Vector3(1, 1, 0), verts[2]); - // Triangle 2: (0,2,3) - Assert.Equal(new Vector3(0, 0, 0), verts[3]); - Assert.Equal(new Vector3(1, 1, 0), verts[4]); - Assert.Equal(new Vector3(0, 1, 0), verts[5]); - } - - [Fact] - public void BuildTriangles_AppliesWorldTransform() - { - // Identity cell-local triangle, translated by WorldTransform. - var translate = Matrix4x4.CreateTranslation(new Vector3(100, 200, 300)); - var cell = new LoadedCell - { - WorldTransform = translate, - Portals = new() { new CellPortalInfo(0xFFFF, 100, 0) }, - ClipPlanes = new() { default }, - PortalPolygons = new() - { - new[] - { - new Vector3(0, 0, 0), - new Vector3(1, 0, 0), - new Vector3(0, 1, 0), - }, - }, - }; - - var verts = PortalMeshBuilder.BuildTriangles(new List { cell }); - - Assert.Equal(3, verts.Length); - Assert.Equal(new Vector3(100, 200, 300), verts[0]); - Assert.Equal(new Vector3(101, 200, 300), verts[1]); - Assert.Equal(new Vector3(100, 201, 300), verts[2]); - } - - [Fact] - public void BuildTriangles_SkipsEmptyOrDegeneratePolygons() - { - var cell = new LoadedCell - { - WorldTransform = Matrix4x4.Identity, - Portals = new() - { - new CellPortalInfo(0xFFFF, 100, 0), - new CellPortalInfo(0xFFFF, 101, 0), - }, - ClipPlanes = new() { default, default }, - PortalPolygons = new() - { - System.Array.Empty(), // empty - new[] { new Vector3(0, 0, 0), new Vector3(1, 0, 0) }, // degenerate (2 verts) - }, - }; - - var verts = PortalMeshBuilder.BuildTriangles(new List { cell }); - Assert.Empty(verts); - } -} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs deleted file mode 100644 index 64406ab..0000000 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -// Phase A8 RR5 — verify WbDrawDispatcher.WalkEntitiesForTestByCellIds, -// the pure-data companion to the new Draw(cellIds:) production overload. -// -// Semantics: indoor entities (ParentCellId.HasValue) are gated by explicit -// membership in cellIds. Building shells (IsBuildingShell) pass only when their -// BuildingShellAnchorCellId belongs to the same cell set. -// Outdoor scenery (no ParentCellId, not a shell) is excluded by EntitySet.IndoorPass. - -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.Rendering.Wb; -using AcDream.Core.World; -using Xunit; - -namespace AcDream.Core.Tests.Rendering.Wb; - -public class WbDrawDispatcherCellIdsOverloadTests -{ - private static WorldEntity CellEnt(uint id, uint cellId) => new() - { - Id = id, - SourceGfxObjOrSetupId = 0x01000001u, - ParentCellId = cellId, - MeshRefs = new List { new(0x01000001u, Matrix4x4.Identity) }, - Position = Vector3.Zero, - Rotation = Quaternion.Identity, - }; - - private static WorldEntity OutdoorScenery(uint id) => new() - { - Id = id, - SourceGfxObjOrSetupId = 0x01000001u, - ParentCellId = null, - IsBuildingShell = false, - MeshRefs = new List { new(0x01000001u, Matrix4x4.Identity) }, - Position = Vector3.Zero, - Rotation = Quaternion.Identity, - }; - - private static WorldEntity BuildingShell(uint id, uint? anchorCellId) => new() - { - Id = id, - SourceGfxObjOrSetupId = 0x02000001u, - ParentCellId = null, - IsBuildingShell = true, - BuildingShellAnchorCellId = anchorCellId, - MeshRefs = new List { new(0x01000001u, Matrix4x4.Identity) }, - Position = Vector3.Zero, - Rotation = Quaternion.Identity, - }; - - [Fact] - public void WalkEntitiesByCellIds_IncludesOnlyEntitiesInListedCells() - { - var entities = new List - { - CellEnt(0x40000001u, 0xA9B40150u), // in listed cells - CellEnt(0x40000002u, 0xA9B40151u), // in listed cells - CellEnt(0x40000003u, 0xA9B40999u), // OUT — not in list - BuildingShell(0xC0000001u, 0xA9B40150u), // in listed building cells - BuildingShell(0xC0000003u, 0xA9B40999u), // OUT — another building shell - OutdoorScenery(0xC0000002u), // OUT — not a shell, not in cell list - }; - var cellIds = new HashSet { 0xA9B40150u, 0xA9B40151u }; - - var result = WbDrawDispatcher.WalkEntitiesForTestByCellIds( - entities, cellIds, set: WbDrawDispatcher.EntitySet.IndoorPass); - - Assert.Equal(3, result.Count); - Assert.Contains(0x40000001u, result); - Assert.Contains(0x40000002u, result); - Assert.Contains(0xC0000001u, result); - Assert.DoesNotContain(0x40000003u, result); - Assert.DoesNotContain(0xC0000003u, result); - Assert.DoesNotContain(0xC0000002u, result); - } - - [Fact] - public void WalkEntitiesByCellIds_EmptyCellList_ExcludesBuildingShells() - { - var entities = new List - { - CellEnt(0x40000001u, 0xA9B40150u), - BuildingShell(0xC0000001u, 0xA9B40150u), - }; - - var result = WbDrawDispatcher.WalkEntitiesForTestByCellIds( - entities, new HashSet(), set: WbDrawDispatcher.EntitySet.IndoorPass); - - Assert.Empty(result); - } -} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs deleted file mode 100644 index ded923f..0000000 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs +++ /dev/null @@ -1,282 +0,0 @@ -// Phase A8 — verify the WbDrawDispatcher EntitySet partition (taxonomy-aware). -// -// The pure-data WalkEntitiesForTest helper iterates a flat entity list and -// returns the IDs that survive the EntitySet filter + visibleCellIds gate. -// -// EntitySet.IndoorPass — ParentCellId.HasValue OR IsBuildingShell, -// and NOT live-dynamic (ServerGuid == 0). -// Building shells are gated by their dat anchor; -// live-dynamic flows through LiveDynamic instead. -// EntitySet.OutdoorScenery — ParentCellId == null AND not live-dynamic. -// Includes building shells for exterior/depth repair passes. -// EntitySet.BuildingShells — IsBuildingShell only, gated by dat anchor when -// visibleCellIds are supplied. -// EntitySet.LiveDynamic — ServerGuid != 0 (player, NPCs, dropped items, -// idle doors after animation). Drawn last with -// stencil disabled. -// EntitySet.All — pre-A8 behavior (visibleCellIds gates indoor; -// outdoor entities pass through). - -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.Rendering.Wb; -using AcDream.Core.World; -using Xunit; - -namespace AcDream.Core.Tests.Rendering.Wb; - -public class WbDrawDispatcherEntitySetTests -{ - private static WorldEntity CellEnt(uint id, uint cellId) => new() - { - Id = id, - SourceGfxObjOrSetupId = 0x01000001u, - ParentCellId = cellId, - MeshRefs = new List { new() { GfxObjId = 0x01000001u } }, - Position = Vector3.Zero, - Rotation = Quaternion.Identity, - }; - - private static WorldEntity OutdoorScenery(uint id) => new() - { - Id = id, - SourceGfxObjOrSetupId = 0x01000001u, - ParentCellId = null, - IsBuildingShell = false, - MeshRefs = new List { new() { GfxObjId = 0x01000001u } }, - Position = Vector3.Zero, - Rotation = Quaternion.Identity, - }; - - private static WorldEntity BuildingShell(uint id, uint? anchorCellId = null) => new() - { - Id = id, - SourceGfxObjOrSetupId = 0x02000001u, - ParentCellId = null, - IsBuildingShell = true, - BuildingShellAnchorCellId = anchorCellId, - MeshRefs = new List { new() { GfxObjId = 0x01000001u } }, - Position = Vector3.Zero, - Rotation = Quaternion.Identity, - }; - - private static WorldEntity LiveDynamic(uint id, uint serverGuid) => new() - { - Id = id, - SourceGfxObjOrSetupId = 0x02000001u, - ServerGuid = serverGuid, - ParentCellId = null, - IsBuildingShell = false, - MeshRefs = new List { new() { GfxObjId = 0x01000001u } }, - Position = Vector3.Zero, - Rotation = Quaternion.Identity, - }; - - [Fact] - public void IndoorPass_IncludesCellEntities() - { - var entities = new List - { - CellEnt(0x10000001, 0xA9B40143), - OutdoorScenery(0x10000002), - CellEnt(0x10000003, 0xA9B40144), - }; - - var visible = new HashSet { 0xA9B40143u, 0xA9B40144u }; - var result = WbDrawDispatcher.WalkEntitiesForTest( - entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass); - - Assert.Equal(2, result.Count); - Assert.Contains(0x10000001u, result); - Assert.Contains(0x10000003u, result); - Assert.DoesNotContain(0x10000002u, result); - } - - [Fact] - public void IndoorPass_IncludesBuildingShells_WhenAnchorCellIsVisible() - { - var entities = new List - { - BuildingShell(0xC0000001, 0xA9B40143u), // cottage wall - OutdoorScenery(0xC0000002), // tree - CellEnt(0x40000001, 0xA9B40143), - }; - - var visible = new HashSet { 0xA9B40143u }; - var result = WbDrawDispatcher.WalkEntitiesForTest( - entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass); - - Assert.Equal(2, result.Count); - Assert.Contains(0xC0000001u, result); // building shell included - Assert.Contains(0x40000001u, result); // cell entity included - Assert.DoesNotContain(0xC0000002u, result); // tree excluded - } - - [Fact] - public void IndoorPass_WithNullCellFilter_UsesEntitySetOnly() - { - var entities = new List - { - BuildingShell(0xC0000001, 0xA9B40143u), - CellEnt(0x40000001, 0xA9B40143), - CellEnt(0x40000002, 0xA9B40199), - OutdoorScenery(0xC0000002), - LiveDynamic(0x10000001, serverGuid: 0x50000123u), - }; - - var result = WbDrawDispatcher.WalkEntitiesForTest( - entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.IndoorPass); - - Assert.Equal(3, result.Count); - Assert.Contains(0xC0000001u, result); - Assert.Contains(0x40000001u, result); - Assert.Contains(0x40000002u, result); - Assert.DoesNotContain(0xC0000002u, result); - Assert.DoesNotContain(0x10000001u, result); - } - - [Fact] - public void IndoorPass_ExcludesBuildingShells_WhenAnchorCellIsNotVisible() - { - var entities = new List - { - BuildingShell(0xC0000001, 0xA9B40150u), - CellEnt(0x40000001, 0xA9B40143), - }; - - var visible = new HashSet { 0xA9B40143u }; - var result = WbDrawDispatcher.WalkEntitiesForTest( - entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass); - - Assert.Single(result); - Assert.Contains(0x40000001u, result); - Assert.DoesNotContain(0xC0000001u, result); - } - - [Fact] - public void IndoorPass_ExcludesLiveDynamic() - { - var entities = new List - { - CellEnt(0x40000001, 0xA9B40143), - LiveDynamic(0x10000001, serverGuid: 0x50000123u), - }; - - var visible = new HashSet { 0xA9B40143u }; - var result = WbDrawDispatcher.WalkEntitiesForTest( - entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass); - - Assert.Single(result); - Assert.Contains(0x40000001u, result); - Assert.DoesNotContain(0x10000001u, result); // live-dynamic excluded - } - - [Fact] - public void OutdoorScenery_IncludesBuildingShells() - { - var entities = new List - { - BuildingShell(0xC0000001), // cottage wall — included - OutdoorScenery(0xC0000002), // tree — included - CellEnt(0x40000001, 0xA9B40143), // cell — excluded - }; - - var result = WbDrawDispatcher.WalkEntitiesForTest( - entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.OutdoorScenery); - - Assert.Equal(2, result.Count); - Assert.Contains(0xC0000002u, result); - Assert.Contains(0xC0000001u, result); - Assert.DoesNotContain(0x40000001u, result); - } - - [Fact] - public void OutdoorScenery_ExcludesLiveDynamic() - { - var entities = new List - { - OutdoorScenery(0xC0000001), - LiveDynamic(0x10000001, serverGuid: 0x50000123u), - }; - - var result = WbDrawDispatcher.WalkEntitiesForTest( - entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.OutdoorScenery); - - Assert.Single(result); - Assert.Contains(0xC0000001u, result); - Assert.DoesNotContain(0x10000001u, result); - } - - [Fact] - public void BuildingShells_IncludesOnlyAnchoredShells() - { - var entities = new List - { - BuildingShell(0xC0000001, 0xA9B40143u), - BuildingShell(0xC0000002, 0xA9B40999u), - OutdoorScenery(0xC0000003), - CellEnt(0x40000001, 0xA9B40143), - LiveDynamic(0x10000001, serverGuid: 0x50000123u), - }; - - var visible = new HashSet { 0xA9B40143u }; - var result = WbDrawDispatcher.WalkEntitiesForTest( - entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.BuildingShells); - - Assert.Single(result); - Assert.Contains(0xC0000001u, result); - Assert.DoesNotContain(0xC0000002u, result); - Assert.DoesNotContain(0xC0000003u, result); - Assert.DoesNotContain(0x40000001u, result); - Assert.DoesNotContain(0x10000001u, result); - } - - [Fact] - public void LiveDynamic_IncludesOnlyServerSpawned() - { - var entities = new List - { - OutdoorScenery(0xC0000001), - BuildingShell(0xC0000002), - CellEnt(0x40000001, 0xA9B40143), - LiveDynamic(0x10000001, serverGuid: 0x50000123u), - LiveDynamic(0x10000002, serverGuid: 0x50000456u), - }; - - var result = WbDrawDispatcher.WalkEntitiesForTest( - entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.LiveDynamic); - - Assert.Equal(2, result.Count); - Assert.Contains(0x10000001u, result); - Assert.Contains(0x10000002u, result); - Assert.DoesNotContain(0xC0000001u, result); - Assert.DoesNotContain(0xC0000002u, result); - Assert.DoesNotContain(0x40000001u, result); - } - - [Fact] - public void All_MatchesPreA8Behavior() - { - var entities = new List - { - CellEnt(0x40000001, 0xA9B40143), - OutdoorScenery(0xC0000001), - BuildingShell(0xC0000002), - LiveDynamic(0x10000001, serverGuid: 0x50000123u), - CellEnt(0x40000002, 0xA9B40999), // not in visibleCellIds - }; - - var visible = new HashSet { 0xA9B40143u }; - var result = WbDrawDispatcher.WalkEntitiesForTest( - entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.All); - - // Pre-A8: visibleCellIds gates indoor entities only; outdoor entities - // (regardless of building/scenery/live-dynamic) pass through. - Assert.Equal(4, result.Count); - Assert.Contains(0x40000001u, result); - Assert.Contains(0xC0000001u, result); - Assert.Contains(0xC0000002u, result); - Assert.Contains(0x10000001u, result); - Assert.DoesNotContain(0x40000002u, result); - } -}