feat(render): Stage 3 T3.2 — seen_outside terrain/sky gate per CellManager::ChangePosition

Port retail CellManager::ChangePosition @ 0x004559B0 (pseudo_c:94649) landscape
policy. Three changes in GameWindow.OnRender:
1. Extract rootSeenOutside = physicsRoot?.SeenOutside ?? true after
   ComputeVisibilityFromRoot (outdoor null root → always seen_outside=true).
2. Replace IsInsideAnyCell AABB scan with seen_outside-derived predicate:
   playerInsideCell = cameraInsideCell && !rootSeenOutside.
   Semantics: sun zeroed only in sealed interior (dungeon); building interiors
   with seen_outside keep the sun (sky visible through door).
3. renderSky = !cameraInsideCell || rootSeenOutside (Stage 3 gate, interim:
   sky draws full-screen in building interiors until Stage 4 clips to doorway).
4. Weather gate updated to follow renderSky (seen_outside policy).

Retail anchors: CellManager::ChangePosition 0x004559B0 (landscape/sun policy),
SmartBox::RenderNormalMode 0x00453aa0 (sky gate per seen_outside).

NOTE: Interim regression — sky renders full-screen indoors for seen_outside
cells until Stage 4 wires OutsideView clip. Expected per EXECUTION POLICY.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 15:37:00 +02:00
parent 6a1fbbd44e
commit 352086042e

View file

@ -7166,6 +7166,16 @@ public sealed class GameWindow : IDisposable
var visibility = _cellVisibility.ComputeVisibilityFromRoot(physicsRoot, visRootPos);
bool cameraInsideCell = visibility?.CameraCell is not null;
// Stage 3 (2026-06-02): extract seen_outside from the PVS root cell.
// Retail CellManager::ChangePosition @ 0x004559B0 (pseudo_c:94649):
// "if (seen_outside || keep_lscape_loaded) keep landscape + terrain
// else LScape::release_all (dungeon)"
// Outdoor root (physicsRoot==null) → always seen_outside=true.
// Building interior with exit portal → seen_outside=true (sky/terrain kept live;
// clipped to doorway in Stage 4).
// Pure dungeon (no exit portal reachable) → seen_outside=false (sky suppressed).
bool rootSeenOutside = physicsRoot?.SeenOutside ?? true;
// Phase U.4 (2026-05-30): the [vis] probe moved DOWN to the unified
// gated-draw block (after envCellViewProj exists) where it can report
// the real PortalVisibilityFrame — OutsideView polygon/plane counts and
@ -7173,18 +7183,14 @@ public sealed class GameWindow : IDisposable
// of the old camera-state-only spike. See the U.4 ClipFrame assembly
// below (gated on ACDREAM_PROBE_VIS=1, cell-change-throttled).
// 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
// does, so a camera-based trigger flips the scene to indoor
// lighting prematurely. Retail's CellManager::ChangePosition
// @ 0x004559B0 reads CObjCell::seen_outside on the player's
// current cell — that's the semantics we want here. When the
// player isn't in player mode (orbit / fly debug camera) we
// fall back to the camera trigger.
bool playerInsideCell = (_playerMode && _playerController is not null)
? _cellVisibility.IsInsideAnyCell(_playerController.Position)
: cameraInsideCell;
// Stage 3 (2026-06-02): replace the IsInsideAnyCell AABB scan with the
// seen_outside-derived predicate. Retail CellManager::ChangePosition (0x004559B0)
// gates sun/lighting off seen_outside on the player's current cell, NOT off an
// independent AABB containment scan. playerInsideCell = true (kill sunlight) only
// when the player is inside a SEALED interior (seen_outside=false = dungeon).
// Building interiors with seen_outside=true keep the sun (sky visible through door).
// When not in player mode (orbit/fly debug camera) we fall back to cameraInsideCell.
bool playerInsideCell = cameraInsideCell && !rootSeenOutside;
// Phase C.1: tick retail PhysicsScript particle hooks. Named
// retail decomp confirms SkyObject.PesObjectId is copied by
@ -7264,7 +7270,14 @@ public sealed class GameWindow : IDisposable
// cylinder 0x01004C42/0x01004C44) need to overlay terrain and
// entities to look volumetric — see the post-scene RenderWeather
// call further below.
bool renderSky = !cameraInsideCell;
// Stage 3 (2026-06-02): sky gate uses seen_outside per retail RenderNormalMode:92649.
// Outdoor root (cameraInsideCell=false): always render sky.
// Building interior (cameraInsideCell=true, rootSeenOutside=true): render sky —
// it draws full-screen here until Stage 4 clips it to the doorway via OutsideView.
// Sealed dungeon (cameraInsideCell=true, rootSeenOutside=false): no sky.
// NOTE: interim regression until Stage 4 — sky draws full-screen in building interiors.
// This is expected per the EXECUTION POLICY; do NOT add a workaround gate.
bool renderSky = !cameraInsideCell || rootSeenOutside;
if (renderSky)
{
_skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction,
@ -7502,8 +7515,10 @@ public sealed class GameWindow : IDisposable
// instead of being painted over by them. This is the second
// half of retail's LScape::draw split — GameSky::Draw(1)
// fires after the DrawBlock loop. Same indoor gate as the
// sky pass: weather is suppressed inside cells.
if (!cameraInsideCell)
// sky pass: weather follows renderSky (seen_outside policy,
// Stage 3: suppressed in sealed dungeons, visible in building
// interiors through exit portals, always visible outdoors).
if (renderSky)
{
_skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction,
_activeDayGroup, kf, environOverrideActive);