feat(lighting): Phase G.2 — Setup.Lights + SetLightHook wiring

Register dat-defined LightInfos as runtime LightSources when entities
stream in. Every Setup (0x02xxxxxx) with a non-empty Lights dictionary
gets its per-part lights pulled via LightInfoLoader, which converts
the local Frame + ColorARGB + Intensity + Falloff + ConeAngle fields
into world-space LightSource records owned by the entity id.

Wire the LightingHookSink into the animation-hook router so retail's
SetLightHook animations (ignite-torch, extinguish-lamp) flip the
matching LightSource.IsLit latches. One hook may own multiple lights
(lamp-posts with two LightInfo entries) — the sink maintains an
owner-indexed map so all get toggled together.

Unregister on landblock unload: the streaming controller's
removeTerrain callback grabs the loaded landblock's entity list (new
GpuWorldState.TryGetLandblock helper) and drops every owner from the
sink before the entities disappear — otherwise walking across
landblocks accumulates stale LightSources.

9 new tests (LightingHookSink routing + LightInfoLoader conversion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:46:49 +02:00
parent 9618c66813
commit 7b9a66c9ea
5 changed files with 350 additions and 0 deletions

View file

@ -164,6 +164,9 @@ public sealed class GameWindow : IDisposable
AcDream.Core.World.SkyStateProvider.Default()); AcDream.Core.World.SkyStateProvider.Default());
public readonly AcDream.Core.Lighting.LightManager Lighting = new(); public readonly AcDream.Core.Lighting.LightManager Lighting = new();
public readonly AcDream.Core.World.WeatherSystem Weather = new(); public readonly AcDream.Core.World.WeatherSystem Weather = new();
// Wired into the hook router in OnLoad so SetLightHook fires
// from the animation pipeline flip the matching LightSource.IsLit.
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
// Phase G.1 sky renderer + shared UBO. Created once the GL context // Phase G.1 sky renderer + shared UBO. Created once the GL context
// exists in OnLoad; shared across every other renderer via // exists in OnLoad; shared across every other renderer via
@ -629,6 +632,12 @@ public sealed class GameWindow : IDisposable
_particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem); _particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem);
_hookRouter.Register(_particleSink); _hookRouter.Register(_particleSink);
// Phase G.2 lighting hooks: SetLightHook flips IsLit on
// owner-tagged lights so ignite-torch animations light up,
// extinguish-torch animations go dark.
_lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting);
_hookRouter.Register(_lightingSink);
// Phase E.2 audio: init OpenAL + hook sink. Suppressible via // Phase E.2 audio: init OpenAL + hook sink. Suppressible via
// ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers. // ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers.
if (Environment.GetEnvironmentVariable("ACDREAM_NO_AUDIO") != "1") if (Environment.GetEnvironmentVariable("ACDREAM_NO_AUDIO") != "1")
@ -767,6 +776,16 @@ public sealed class GameWindow : IDisposable
radius: _streamingRadius, radius: _streamingRadius,
removeTerrain: id => removeTerrain: id =>
{ {
// Phase G.2: release any LightSources attached to entities
// in this landblock before their records disappear from
// _worldState — otherwise the LightManager accumulates
// stale entries for every walk across a landblock boundary.
if (_lightingSink is not null &&
_worldState.TryGetLandblock(id, out var lb))
{
foreach (var ent in lb!.Entities)
_lightingSink.UnregisterOwner(ent.Id);
}
_terrain?.RemoveLandblock(id); _terrain?.RemoveLandblock(id);
_physicsEngine.RemoveLandblock(id); _physicsEngine.RemoveLandblock(id);
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
@ -2308,6 +2327,32 @@ public sealed class GameWindow : IDisposable
int scTried = 0, scHaveBounds = 0, scRegistered = 0, scTooThin = 0, scNoBounds = 0; int scTried = 0, scHaveBounds = 0, scRegistered = 0, scTooThin = 0, scNoBounds = 0;
foreach (var entity in lb.Entities) foreach (var entity in lb.Entities)
{ {
// Phase G.2: if the entity's Setup has baked-in LightInfos,
// register them with the LightManager so torches, braziers,
// and lifestones cast real light on nearby geometry. Hooked
// via the LightingHookSink so per-entity owner tracking +
// SetLightHook IsLit toggles all go through one codepath.
// Only applies to Setup-sourced entities (0x02xxxxxx) — raw
// GfxObjs don't carry Lights dictionaries.
if (_lightingSink is not null && _dats is not null)
{
uint src = entity.SourceGfxObjOrSetupId;
if ((src & 0xFF000000u) == 0x02000000u)
{
var datSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(src);
if (datSetup is not null && datSetup.Lights.Count > 0)
{
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
datSetup,
ownerId: entity.Id,
entityPosition: entity.Position,
entityRotation: entity.Rotation);
foreach (var ls in loaded)
_lightingSink.RegisterOwnedLight(ls);
}
}
}
int entityBsp = 0, entityCyl = 0; int entityBsp = 0, entityCyl = 0;
// Treat both procedural scenery (0x80000000+) AND LandBlockInfo // Treat both procedural scenery (0x80000000+) AND LandBlockInfo
// stabs (IDs < 0x40000000 with 0x01/0x02 source) as outdoor-entities // stabs (IDs < 0x40000000 with 0x01/0x02 source) as outdoor-entities

View file

@ -65,6 +65,22 @@ public sealed class GpuWorldState
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId); public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
/// <summary>
/// Try to grab the loaded record for a landblock — useful for callers
/// that need to enumerate entities before the landblock is dropped
/// (e.g. unregistering dynamic lights on a RemoveLandblock).
/// </summary>
public bool TryGetLandblock(uint landblockId, out LoadedLandblock? lb)
{
if (_loaded.TryGetValue(landblockId, out var found))
{
lb = found;
return true;
}
lb = null;
return false;
}
/// <summary> /// <summary>
/// Store the axis-aligned bounding box for a loaded landblock. Called from /// Store the axis-aligned bounding box for a loaded landblock. Called from
/// the render thread after the terrain mesh is built and uploaded. /// the render thread after the terrain mesh is built and uploaded.

View file

@ -0,0 +1,92 @@
using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Lighting;
/// <summary>
/// Converts a <see cref="Setup"/>'s <c>Lights</c> dictionary (dat-level
/// <see cref="LightInfo"/> records) into runtime <see cref="LightSource"/>
/// instances the <see cref="LightManager"/> can consume.
///
/// <para>
/// Retail <see cref="LightInfo"/> fields (r13 §1):
/// <list type="bullet">
/// <item><description><c>ViewSpaceLocation</c>: local Frame relative to the owning part.</description></item>
/// <item><description><c>Color</c>: packed ARGB. Alpha is ignored; channels go through <c>/255</c>.</description></item>
/// <item><description><c>Intensity</c>: multiplies color for final diffuse.</description></item>
/// <item><description><c>Falloff</c>: world metres — acts as the <see cref="LightSource.Range"/> hard cutoff.</description></item>
/// <item><description><c>ConeAngle</c>: radians; 0 = point, &gt;0 = spot cone.</description></item>
/// </list>
/// </para>
/// </summary>
public static class LightInfoLoader
{
/// <summary>
/// Extract all lights from a Setup, positioned in the entity's
/// world frame (via <paramref name="entityPosition"/> +
/// <paramref name="entityRotation"/>). The dat's per-light Frame is
/// treated as a local offset relative to the entity root; acdream
/// doesn't yet transform through the animated part chain (retail's
/// hand-held torches), so held lights render at the entity root
/// until the animation hook layer handles per-part placement.
/// </summary>
public static IReadOnlyList<LightSource> Load(
Setup setup,
uint ownerId,
Vector3 entityPosition,
Quaternion entityRotation)
{
var results = new List<LightSource>();
if (setup?.Lights is null || setup.Lights.Count == 0) return results;
foreach (var kvp in setup.Lights)
{
var info = kvp.Value;
if (info is null) continue;
// Local Frame offset into world space.
Vector3 localOffset = Vector3.Zero;
Quaternion localRot = Quaternion.Identity;
if (info.ViewSpaceLocation is not null)
{
localOffset = new Vector3(
info.ViewSpaceLocation.Origin.X,
info.ViewSpaceLocation.Origin.Y,
info.ViewSpaceLocation.Origin.Z);
localRot = new Quaternion(
info.ViewSpaceLocation.Orientation.X,
info.ViewSpaceLocation.Orientation.Y,
info.ViewSpaceLocation.Orientation.Z,
info.ViewSpaceLocation.Orientation.W);
}
// Transform local offset into world space via the entity's
// rotation + translation. No per-part chain yet — held
// torches track the entity's root for now.
Vector3 worldPos = entityPosition + Vector3.Transform(localOffset, entityRotation);
Quaternion worldRot = entityRotation * localRot;
Vector3 forward = Vector3.Transform(Vector3.UnitY, worldRot);
var light = new LightSource
{
Kind = info.ConeAngle > 0f ? LightKind.Spot : LightKind.Point,
WorldPosition = worldPos,
WorldForward = forward,
ColorLinear = new Vector3(
(info.Color?.Red ?? 255) / 255f,
(info.Color?.Green ?? 255) / 255f,
(info.Color?.Blue ?? 255) / 255f),
Intensity = info.Intensity,
Range = info.Falloff,
ConeAngle = info.ConeAngle,
OwnerId = ownerId,
IsLit = true,
};
results.Add(light);
}
return results;
}
}

View file

@ -0,0 +1,78 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
namespace AcDream.Core.Lighting;
/// <summary>
/// Routes <see cref="SetLightHook"/> animation hooks to the
/// <see cref="LightManager"/> — when a torch lights / extinguishes via
/// an animation frame, flip the corresponding
/// <see cref="LightSource.IsLit"/> latch. Per r13 §2 the hook is AC's
/// way of saying "this Setup's baked-in LightInfo is now active".
///
/// <para>
/// Registration: at entity spawn time the caller walks the Setup's
/// <c>Lights</c> dictionary and registers a <see cref="LightSource"/>
/// per <c>LightInfo</c>, tagging it with the owning entity id. When a
/// hook fires later, we look up every light tagged to that owner and
/// flip them all together (retail's SetLightHook is a per-setup
/// boolean, not per-light).
/// </para>
/// </summary>
public sealed class LightingHookSink : IAnimationHookSink
{
private readonly LightManager _lights;
// Index owner → the set of LightSource instances they registered.
// Maintained lazily — populated on first RegisterLight for that owner.
private readonly Dictionary<uint, List<LightSource>> _byOwner = new();
public LightingHookSink(LightManager lights)
{
_lights = lights ?? throw new System.ArgumentNullException(nameof(lights));
}
/// <summary>
/// Register a light with the manager + track it by owner so later
/// SetLightHook / Unregister calls can reach it.
/// </summary>
public void RegisterOwnedLight(LightSource light)
{
System.ArgumentNullException.ThrowIfNull(light);
_lights.Register(light);
if (!_byOwner.TryGetValue(light.OwnerId, out var list))
{
list = new List<LightSource>();
_byOwner[light.OwnerId] = list;
}
list.Add(light);
}
/// <summary>Drop every light tagged to this owner (despawn / unload).</summary>
public void UnregisterOwner(uint ownerId)
{
if (!_byOwner.TryGetValue(ownerId, out var list)) return;
foreach (var l in list) _lights.Unregister(l);
_byOwner.Remove(ownerId);
}
/// <summary>
/// Get the set of registered lights for an owner — exposed so
/// callers can reposition them (torch on hand follows hand part).
/// </summary>
public IReadOnlyList<LightSource>? GetOwnedLights(uint ownerId)
{
return _byOwner.TryGetValue(ownerId, out var list) ? list : null;
}
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
{
if (hook is not SetLightHook slh) return;
if (!_byOwner.TryGetValue(entityId, out var list)) return;
foreach (var light in list)
light.IsLit = slh.LightsOn;
}
}

View file

@ -0,0 +1,119 @@
using System.Numerics;
using AcDream.Core.Lighting;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Lighting;
public sealed class LightingHookSinkTests
{
[Fact]
public void SetLightHook_FlipsOwnedLights()
{
var mgr = new LightManager();
var sink = new LightingHookSink(mgr);
var light1 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true };
var light2 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true };
var other = new LightSource { Kind = LightKind.Point, OwnerId = 99, IsLit = true };
sink.RegisterOwnedLight(light1);
sink.RegisterOwnedLight(light2);
sink.RegisterOwnedLight(other);
var hook = new SetLightHook { LightsOn = false };
sink.OnHook(entityId: 42, entityWorldPosition: Vector3.Zero, hook: hook);
Assert.False(light1.IsLit);
Assert.False(light2.IsLit);
Assert.True(other.IsLit); // owner 99 untouched
}
[Fact]
public void UnregisterOwner_RemovesAllOwnedLights()
{
var mgr = new LightManager();
var sink = new LightingHookSink(mgr);
sink.RegisterOwnedLight(new LightSource { OwnerId = 7 });
sink.RegisterOwnedLight(new LightSource { OwnerId = 7 });
Assert.Equal(2, mgr.RegisteredCount);
sink.UnregisterOwner(7);
Assert.Equal(0, mgr.RegisteredCount);
}
[Fact]
public void UnrelatedHook_Ignored()
{
var mgr = new LightManager();
var sink = new LightingHookSink(mgr);
var light = new LightSource { OwnerId = 1, IsLit = true };
sink.RegisterOwnedLight(light);
// Should not crash or change state for non-SetLight hooks.
var noise = new SoundHook();
sink.OnHook(entityId: 1, entityWorldPosition: Vector3.Zero, hook: noise);
Assert.True(light.IsLit);
}
}
public sealed class LightInfoLoaderTests
{
[Fact]
public void Load_EmptyLights_ReturnsEmpty()
{
var setup = new DatReaderWriter.DBObjs.Setup();
var result = LightInfoLoader.Load(setup, 1u, Vector3.Zero, Quaternion.Identity);
Assert.Empty(result);
}
[Fact]
public void Load_PointLight_ProducesCorrectSource()
{
var setup = new DatReaderWriter.DBObjs.Setup();
setup.Lights[0] = new LightInfo
{
ViewSpaceLocation = new Frame
{
Origin = new Vector3(1, 2, 3),
Orientation = Quaternion.Identity,
},
Color = new ColorARGB { Red = 255, Green = 200, Blue = 50, Alpha = 255 },
Intensity = 0.8f,
Falloff = 8f,
ConeAngle = 0f, // point
};
var result = LightInfoLoader.Load(setup, ownerId: 77,
entityPosition: new Vector3(100, 200, 300),
entityRotation: Quaternion.Identity);
Assert.Single(result);
var light = result[0];
Assert.Equal(LightKind.Point, light.Kind);
Assert.Equal(77u, light.OwnerId);
Assert.Equal(8f, light.Range);
Assert.Equal(0.8f, light.Intensity);
Assert.Equal(new Vector3(101, 202, 303), light.WorldPosition);
Assert.InRange(light.ColorLinear.X, 0.99f, 1.01f);
}
[Fact]
public void Load_NonZeroConeAngle_ProducesSpot()
{
var setup = new DatReaderWriter.DBObjs.Setup();
setup.Lights[0] = new LightInfo
{
ViewSpaceLocation = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity },
Color = new ColorARGB { Red = 255, Green = 255, Blue = 255, Alpha = 255 },
Intensity = 1f,
Falloff = 5f,
ConeAngle = 0.5f,
};
var result = LightInfoLoader.Load(setup, ownerId: 1, entityPosition: Vector3.Zero, entityRotation: Quaternion.Identity);
Assert.Equal(LightKind.Spot, result[0].Kind);
Assert.Equal(0.5f, result[0].ConeAngle);
}
}