fix(lighting): indoor interiors now lit — landblock-key bug + viewer light + weenie fixtures
The whole "indoor interiors read dark/flat vs retail" saga was ONE root-cause bug: EnvCellRenderer.GetCellLightSet derived the landblock key as `cellId & 0xFFFF0000` (0xXXYY0000), but landblocks are keyed by the streaming id 0xXXYYFFFF. The lookup missed for EVERY cell, so SelectForObject never ran and every EnvCell wall received ZERO point lights — torches, lanterns, the viewer light, all of it. Confirmed by a [cell-light] probe: inBounds=False selected=0 across 1M+ cell draws; after the fix inBounds=True selected=3-4. User-confirmed the interiors now look like retail. Three faithful additions that were blocked by the key bug (and only show now): - Viewer light (LightManager.UpdateViewerLight): retail's SmartBox::set_viewer (0x00452c40) adds a white fill light at the player every frame via add_dynamic_light — the dominant interior fill (no sun indoors). acdream had NO dynamic lights at all. Params from the cdb capture: intensity 2.25, falloff 10, white, offset (0,0,2). Indoor-only via the AP-43 gate. - Weenie fixture lights (OnLiveEntitySpawnedLocked): server-spawned lanterns/ braziers carry Setup.Lights but the dat-static registration never saw CreateObject entities. Register on spawn; unregister on despawn (UnregisterOwner made unconditional). Register row AP-44. - IndoorObjectReceivesTorches now excludes the 0xFFFF landblock marker (it is not an EnvCell) — fixes WbDrawDispatcherIndoorFlagTests.LandblockId_OutdoorFlag0 (a #142 verification miss). Divergence register: AP-44 (weenie light spawn-position, no movement tracking), AP-47 (acdream's 128-light/camera-independent cap keeps interiors always-lit vs retail's 40-nearest-to-player budget that pops in on approach — intentional, user-preferred). Investigation: docs/research/2026-06-20-indoor-torch-lantern-lighting-investigation.md Core 1505 / App 476 green. Visual gate: user-confirmed "looks like retail now." Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ef5049fd58
commit
0d8b827721
6 changed files with 185 additions and 7 deletions
|
|
@ -2708,7 +2708,7 @@ public sealed class GameWindow : IDisposable
|
|||
// For a respawn, drop the previous rendering state here before we
|
||||
// build the new one. `_entitiesByServerGuid` is the canonical map,
|
||||
// its value is the live WorldEntity we need to dispose.
|
||||
RemoveLiveEntityByServerGuid(spawn.Guid, logDelete: false);
|
||||
RemoveLiveEntityByServerGuid(spawn.Guid);
|
||||
|
||||
// When requested, log every spawn that arrives so we can inventory what the server
|
||||
// sends (including the ones we can't render yet). The Name field
|
||||
|
|
@ -3261,6 +3261,34 @@ public sealed class GameWindow : IDisposable
|
|||
if (liveBounds.TryGet(out var liveBMin, out var liveBMax))
|
||||
entity.SetLocalBounds(liveBMin, liveBMax);
|
||||
|
||||
// A7 indoor lighting: server-spawned weenie fixtures (lanterns,
|
||||
// braziers, glowing items) carry their light in Setup.Lights exactly
|
||||
// like dat-baked statics, but the static registration in
|
||||
// ApplyLoadedTerrainLocked never sees CreateObject entities — so an
|
||||
// interior's lanterns cast no light and the room reads dark. Register
|
||||
// them here, mirroring that path (GameWindow.cs ~6742). Owned by
|
||||
// entity.Id, so RemoveLiveEntityByServerGuid's UnregisterOwner tears
|
||||
// them down on despawn/respawn. Retail registers object-borne lights
|
||||
// regardless of static-vs-dynamic origin (insert_light 0x0054d1b0).
|
||||
// The light is placed at the spawn frame and does NOT follow a moving
|
||||
// light-bearer yet (register row AP-44) — fine for stationary fixtures,
|
||||
// which is the common case. _dats is non-null here (checked above).
|
||||
if (_lightingSink is not null
|
||||
&& (entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
var liteSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(entity.SourceGfxObjOrSetupId);
|
||||
if (liteSetup is not null && liteSetup.Lights.Count > 0)
|
||||
{
|
||||
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
|
||||
liteSetup,
|
||||
ownerId: entity.Id,
|
||||
entityPosition: entity.Position,
|
||||
entityRotation: entity.Rotation);
|
||||
foreach (var ls in loaded)
|
||||
_lightingSink.RegisterOwnedLight(ls);
|
||||
}
|
||||
}
|
||||
|
||||
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
||||
Id: entity.Id,
|
||||
SourceId: entity.SourceGfxObjOrSetupId,
|
||||
|
|
@ -3540,7 +3568,7 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
|
||||
{
|
||||
if (RemoveLiveEntityByServerGuid(delete.Guid, logDelete: true)
|
||||
if (RemoveLiveEntityByServerGuid(delete.Guid)
|
||||
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
{
|
||||
Console.WriteLine(
|
||||
|
|
@ -3722,7 +3750,7 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete)
|
||||
private bool RemoveLiveEntityByServerGuid(uint serverGuid)
|
||||
{
|
||||
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
|
||||
return false;
|
||||
|
|
@ -3742,8 +3770,12 @@ public sealed class GameWindow : IDisposable
|
|||
if (_selectedGuid == serverGuid)
|
||||
SelectedGuid = null;
|
||||
|
||||
if (logDelete)
|
||||
_lightingSink?.UnregisterOwner(existingEntity.Id);
|
||||
// A7 indoor lighting: release this entity's owned lights on EVERY
|
||||
// removal, including the respawn-dedup path (former logDelete=false).
|
||||
// A respawned weenie fixture would otherwise leak its old light set and
|
||||
// double-register the new one. (Was gated on logDelete — harmless only
|
||||
// while live weenies registered no lights, which is no longer true.)
|
||||
_lightingSink?.UnregisterOwner(existingEntity.Id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -8120,6 +8152,12 @@ public sealed class GameWindow : IDisposable
|
|||
// consumes binding=1 reads the same data for the rest of the
|
||||
// frame — terrain, static mesh, instanced mesh, sky.
|
||||
UpdateSunFromSky(kf, playerInsideCell);
|
||||
// A7 indoor lighting: position retail's viewer fill light at the player
|
||||
// (SmartBox::set_viewer 0x00452c40) before the snapshot is built. It is
|
||||
// the primary interior fill (no sun indoors) and is indoor-only via the
|
||||
// AP-43 gate. playerViewPos is the player render position (or camPos when
|
||||
// there is no player), matching retail's player/viewer branch.
|
||||
Lighting.UpdateViewerLight(playerViewPos);
|
||||
Lighting.Tick(camPos);
|
||||
|
||||
// Fix B (A7 #3): build this frame's point-light snapshot and hand it to
|
||||
|
|
|
|||
|
|
@ -1049,8 +1049,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
System.Array.Fill(set, -1);
|
||||
|
||||
var snap = _pointSnapshot;
|
||||
// Landblocks are keyed by the streaming landblock id 0xXXYYFFFF
|
||||
// (GameWindow: (x<<24)|(y<<16)|0xFFFF), NOT 0xXXYY0000 — so the landblock
|
||||
// key is (cellId & 0xFFFF0000) | 0xFFFF. The old `cellId & 0xFFFF0000` key
|
||||
// (0xXXYY0000) NEVER matched a registered landblock, so this lookup always
|
||||
// missed: SelectForObject never ran and every EnvCell wall received ZERO
|
||||
// point lights (the entire "indoor torches/lanterns don't light the room"
|
||||
// bug — confirmed by the [cell-light] probe: inBounds=False for every cell).
|
||||
if (snap is { Count: > 0 } &&
|
||||
_landblocks.TryGetValue(cellId & 0xFFFF0000u, out var lb) &&
|
||||
_landblocks.TryGetValue((cellId & 0xFFFF0000u) | 0xFFFFu, out var lb) &&
|
||||
lb.EnvCellBounds.TryGetValue(cellId, out var b))
|
||||
{
|
||||
Vector3 center = (b.Min + b.Max) * 0.5f;
|
||||
|
|
|
|||
|
|
@ -2107,7 +2107,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
/// <c>Render::useSunlight == 0</c>, which is true only in the indoor draw stage.
|
||||
/// </summary>
|
||||
internal static bool IndoorObjectReceivesTorches(uint? parentCellId)
|
||||
=> parentCellId.HasValue && (parentCellId.Value & 0xFFFFu) >= 0x0100u;
|
||||
=> parentCellId.HasValue
|
||||
&& (parentCellId.Value & 0xFFFFu) >= 0x0100u
|
||||
&& (parentCellId.Value & 0xFFFFu) != 0xFFFFu; // 0xFFFF = landblock marker, not an EnvCell → outdoor
|
||||
|
||||
/// <summary>
|
||||
/// Fix B: append the current entity's 8-slot light set to a group's
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ public sealed class LightManager
|
|||
private readonly List<LightSource> _all = new();
|
||||
private readonly LightSource?[] _active = new LightSource?[MaxActiveLights];
|
||||
private int _activeCount;
|
||||
private LightSource? _viewerLight; // retail SmartBox::viewer_light (see UpdateViewerLight)
|
||||
|
||||
/// <summary>Current cell ambient state applied to everything.</summary>
|
||||
public CellAmbientState CurrentAmbient { get; set; }
|
||||
|
|
@ -86,6 +87,7 @@ public sealed class LightManager
|
|||
_all.Clear();
|
||||
Array.Clear(_active);
|
||||
_activeCount = 0;
|
||||
_viewerLight = null; // re-created + re-registered by the next UpdateViewerLight
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -212,6 +214,58 @@ public sealed class LightManager
|
|||
}
|
||||
}
|
||||
|
||||
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────
|
||||
// Retail adds a white fill light pinned to the player EVERY frame via
|
||||
// Render::add_dynamic_light. It is the dominant INTERIOR fill: the outdoor
|
||||
// stage runs useSunlightSet(1) (sun only — dynamics are NOT enabled), but the
|
||||
// interior stage runs minimize_envcell_lighting, which enables the dynamic
|
||||
// lights, so EnvCell walls + indoor objects are lit by this viewer light while
|
||||
// the player is inside. acdream registered NO dynamic lights at all, so
|
||||
// interiors had only flat ambient and read dark/cool vs retail's lit rooms.
|
||||
//
|
||||
// It rides the existing point-light path: registered in _all ⇒ included in
|
||||
// BuildPointLightSnapshot ⇒ selected by SelectForObject for nearby cells /
|
||||
// objects. The AP-43 indoor gate (WbDrawDispatcher.IndoorObjectReceivesTorches)
|
||||
// already restricts per-object point lights to EnvCell-parented objects, and
|
||||
// only EnvCellRenderer selects per-cell lights, so the viewer light lights
|
||||
// ONLY indoor draws — matching retail's interior-stage-only dynamic enable.
|
||||
// Terrain has no point-light path, so it is unaffected.
|
||||
//
|
||||
// Params from the live cdb capture (reference_retail_ambient_values.md):
|
||||
// intensity 2.25, falloff 10, colour white, offset (0,0,2) above the player
|
||||
// (SmartBox::set_viewer player branch). Dynamic lights use rangeAdjust 1.5
|
||||
// (config_hardware_light 0x0059ad30) ⇒ Range = 10 × 1.5 = 15 m.
|
||||
public const float ViewerLightIntensity = 2.25f; // GRV SmartBox.ViewerLightIntensity
|
||||
public const float ViewerLightFalloff = 10f; // GRV SmartBox.ViewerLightFalloff
|
||||
private const uint ViewerLightOwnerId = 0xFFFFFFFFu; // sentinel; never an entity id
|
||||
|
||||
/// <summary>
|
||||
/// Reposition the always-on viewer fill light at the player (offset +2 m up),
|
||||
/// registering it on first call. Call once per frame BEFORE
|
||||
/// <see cref="BuildPointLightSnapshot"/> / <see cref="Tick"/>. Mirrors retail's
|
||||
/// per-frame <c>SmartBox::set_viewer</c> add_dynamic_light; here the light lives
|
||||
/// in <c>_all</c> and is repositioned so the existing snapshot + per-object
|
||||
/// selection light the cell around the player. Indoor-only via the AP-43 gate
|
||||
/// (see the note above).
|
||||
/// </summary>
|
||||
public void UpdateViewerLight(Vector3 playerWorldPos)
|
||||
{
|
||||
if (_viewerLight is null)
|
||||
{
|
||||
_viewerLight = new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
ColorLinear = Vector3.One, // white (1,1,1)
|
||||
Intensity = ViewerLightIntensity,
|
||||
Range = ViewerLightFalloff * 1.5f, // dynamic rangeAdjust 1.5
|
||||
OwnerId = ViewerLightOwnerId,
|
||||
IsLit = true,
|
||||
};
|
||||
_all.Add(_viewerLight);
|
||||
}
|
||||
_viewerLight.WorldPosition = playerWorldPos + new Vector3(0f, 0f, 2f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select up to <see cref="MaxLightsPerObject"/> point/spot lights from
|
||||
/// <paramref name="snapshot"/> that reach the object sphere
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue