fix(lighting): trigger indoor ambient on PLAYER cell, not camera cell

User report: third-person chase camera enters interiors before the
player body does, so the camera-based cameraInsideCell flag was
flipping the scene to indoor lighting prematurely (ambient drops to
0.2 white before the player has actually crossed the doorway).

Retail keys lighting off the PLAYER's cell. CellManager::ChangePosition
@ 0x004559B0 reads CObjCell::seen_outside on the player's current
cell — never on the camera. Match that semantics.

- CellVisibility.IsInsideAnyCell(Vector3): new non-caching brute-force
  scan that's safe to call alongside ComputeVisibility(cameraPos)
  without thrashing the camera cell cache.
- GameWindow render loop: derive playerInsideCell from the player's
  Position when in player mode, otherwise fall back to cameraInsideCell
  (orbit/fly debug camera).
- UpdateSunFromSky now takes playerInsideCell. The sky-render and
  depth-buffer-clear decisions still use cameraInsideCell — those are
  legitimately camera-POV concerns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-19 10:38:48 +02:00
parent a54cd7bef6
commit 1024ba34e0
2 changed files with 34 additions and 3 deletions

View file

@ -330,6 +330,21 @@ public sealed class CellVisibility
local.Z <= cell.LocalBoundsMax.Z + PointInCellEpsilon; local.Z <= cell.LocalBoundsMax.Z + PointInCellEpsilon;
} }
/// <summary>
/// Brute-force scan of every loaded cell to test whether
/// <paramref name="worldPoint"/> is inside any of them. Does not touch
/// the camera cache (<see cref="_lastCameraCell"/>), so this is safe
/// to call alongside <see cref="ComputeVisibility"/> in the same frame
/// for a different position (e.g. player position when the camera is
/// in third-person chase mode).
/// </summary>
public bool IsInsideAnyCell(Vector3 worldPoint)
{
foreach (var cell in _cellLookup.Values)
if (PointInCell(worldPoint, cell)) return true;
return false;
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// GetVisibleCells (BFS) // GetVisibleCells (BFS)
// ------------------------------------------------------------------ // ------------------------------------------------------------------

View file

@ -6848,6 +6848,19 @@ public sealed class GameWindow : IDisposable
var visibility = _cellVisibility.ComputeVisibility(camPos); var visibility = _cellVisibility.ComputeVisibility(camPos);
bool cameraInsideCell = visibility?.CameraCell is not null; bool cameraInsideCell = visibility?.CameraCell is not null;
// 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;
// Phase C.1: tick retail PhysicsScript particle hooks. Named // Phase C.1: tick retail PhysicsScript particle hooks. Named
// retail decomp confirms SkyObject.PesObjectId is copied by // retail decomp confirms SkyObject.PesObjectId is copied by
// SkyDesc::GetSky but ignored by GameSky, so the sky-PES path is // SkyDesc::GetSky but ignored by GameSky, so the sky-PES path is
@ -6861,7 +6874,7 @@ public sealed class GameWindow : IDisposable
// the scene-lighting UBO once per frame. Every shader that // the scene-lighting UBO once per frame. Every shader that
// consumes binding=1 reads the same data for the rest of the // consumes binding=1 reads the same data for the rest of the
// frame — terrain, static mesh, instanced mesh, sky. // frame — terrain, static mesh, instanced mesh, sky.
UpdateSunFromSky(kf, cameraInsideCell); UpdateSunFromSky(kf, playerInsideCell);
Lighting.Tick(camPos); Lighting.Tick(camPos);
var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build(
Lighting, in atmo, camPos, (float)WorldTime.DayFraction); Lighting, in atmo, camPos, (float)WorldTime.DayFraction);
@ -8326,15 +8339,18 @@ public sealed class GameWindow : IDisposable
/// Indoor brightness then comes from per-cell point lights /// Indoor brightness then comes from per-cell point lights
/// (Setup.Lights on the cell's static objects, registered through /// (Setup.Lights on the cell's static objects, registered through
/// <see cref="AcDream.Core.Lighting.LightingHookSink"/>). /// <see cref="AcDream.Core.Lighting.LightingHookSink"/>).
/// The trigger is the PLAYER's cell, not the camera's — third-person
/// chase camera enters interiors before the player body does, and
/// retail keys lighting off the player position.
/// </summary> /// </summary>
private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool cameraInsideCell) private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool playerInsideCell)
{ {
// Sun direction: points FROM the sun TOWARDS the world. Our // Sun direction: points FROM the sun TOWARDS the world. Our
// shader does dot(N, -forward) so a positive N·L means the // shader does dot(N, -forward) so a positive N·L means the
// surface faces the sun. // surface faces the sun.
var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf); var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf);
if (cameraInsideCell) if (playerInsideCell)
{ {
// Indoor default — retail's flat 0.2 neutral ambient, sun // Indoor default — retail's flat 0.2 neutral ambient, sun
// zeroed. See xref to retail decomp in the doc comment above. // zeroed. See xref to retail decomp in the doc comment above.