diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 5c5e8932..93f1e58f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7319,7 +7319,19 @@ public sealed class GameWindow : IDisposable // (mesh SSBO) + binding=2 (terrain UBO); each renderer re-binds its // binding=2 defensively from the ids we hand it. _clipFrame ??= ClipFrame.NoClip(); - var clipRoot = visibility?.CameraCell; + // Retail RenderNormalMode (0x453aa0:92665) branches inside/outside on is_player_outside + // — the PLAYER's cell (0x451e80), NOT the camera cell — then roots DrawInside at the + // VIEWER cell (this->viewer_cell) when inside. The 3rd-person chase camera LAGS the + // player, so keying the branch off the camera (the old `visibility?.CameraCell`) made + // the camera lingering in a doorway AFTER the player had stepped outside take the + // DrawInside path rooted at the threshold cell, where the exit-portal flood degenerates + // → terrain Skipped + sparse shells → grey world with only entities showing through. + // Branch on the player; keep the viewer cell as the indoor root (handoff invariant). + uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u; + var clipRoot = AcDream.Core.Rendering.RenderingDiagnostics.ShouldRenderIndoor( + playerCellId, visibility?.CameraCell is not null) + ? visibility!.CameraCell + : null; ClipFrameAssembly? clipAssembly = null; PortalVisibilityFrame? pvFrame = null; // R1: hoisted so the binary decision below reads OrderedVisibleCells var terrainClipMode = TerrainClipMode.Planes; // overwritten below for indoor root diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 408b4842..e44a920f 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -272,4 +272,29 @@ public static class RenderingDiagnostics /// in the 8×8 landblock grid (0x0001–0x0040). /// public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u; + + /// + /// The top-level render branch: should this frame run the indoor (DrawInside) path? + /// + /// Retail SmartBox::RenderNormalMode (0x453aa0, pc:92665) branches + /// DrawInside vs the outdoor LScape::draw on is_player_outside — the + /// PLAYER's cell ((player->m_position.objcell_id & 0xFFFF) < 0x100, + /// SmartBox::is_player_outside 0x451e80) — NOT the camera/viewer cell. When the + /// player is inside it then roots the flood at the viewer cell + /// (this->viewer_cell). So the inside/outside decision follows the player; + /// only the indoor root follows the camera. + /// + /// acdream historically branched on the camera cell (a non-null + /// visibility.CameraCell). A 3rd-person chase camera lags the player, so when the + /// player had already stepped outside but the camera still sat in the doorway, the camera + /// branch wrongly chose DrawInside rooted at the doorway cell, where the exit-portal flood + /// degenerates → the whole static world (terrain + shells) gated off → grey screen with + /// only entities (which bypass the gate) showing through. Branching on the player removes it. + /// + /// The player's current cell id (0 if unresolved → outside). + /// Whether a viewer/camera cell is available to root + /// DrawInside at. Indoor render needs both: the player inside AND a cell to root at. + /// + public static bool ShouldRenderIndoor(uint playerCellId, bool viewerCellResolved) + => viewerCellResolved && IsEnvCellId(playerCellId); } diff --git a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs index f490b366..9fd9232f 100644 --- a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs @@ -89,4 +89,47 @@ public sealed class RenderingDiagnosticsTests { Assert.Equal(expected, RenderingDiagnostics.IsEnvCellId(id)); } + + // ── Render inside/outside branch (retail RenderNormalMode is_player_outside) ── + // The top-level render branch decides DrawInside vs DrawOutside. Retail + // (SmartBox::RenderNormalMode 0x453aa0:92665) keys it on is_player_outside (the + // PLAYER's cell, 0x451e80), NOT the camera cell. acdream previously branched on the + // camera cell, so a chase camera lagging in a doorway while the player was already + // outside took the DrawInside path and degenerated to a grey world + entities showing + // through walls. These pin the player-keyed branch (DrawInside root stays the viewer cell). + + [Fact] + public void ShouldRenderIndoor_PlayerOutside_CameraInside_ReturnsFalse() + { + // THE doorway-grey regression: the player stepped onto a landcell (0x...0031) but the + // chase camera still resolves an interior EnvCell. Branch on the PLAYER → outdoor. + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, viewerCellResolved: true)); + } + + [Fact] + public void ShouldRenderIndoor_PlayerInside_CameraInside_ReturnsTrue() + { + Assert.True(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, viewerCellResolved: true)); + } + + [Fact] + public void ShouldRenderIndoor_PlayerInside_NoViewerCell_ReturnsFalse() + { + // Opposite lag (camera pulled outside while the player is inside): no viewer cell to + // root DrawInside at → outdoor. Defensive; matches prior null-CameraCell behavior. + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, viewerCellResolved: false)); + } + + [Fact] + public void ShouldRenderIndoor_PlayerOutside_CameraOutside_ReturnsFalse() + { + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, viewerCellResolved: false)); + } + + [Fact] + public void ShouldRenderIndoor_UnknownPlayerCell_TreatedAsOutside_ReturnsFalse() + { + // playerCellId == 0 (unresolved) → treat as outside (safe default: outdoor render). + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0u, viewerCellResolved: true)); + } }