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:
parent
9618c66813
commit
7b9a66c9ea
5 changed files with 350 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
92
src/AcDream.Core/Lighting/LightInfoLoader.cs
Normal file
92
src/AcDream.Core/Lighting/LightInfoLoader.cs
Normal 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, >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;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/AcDream.Core/Lighting/LightingHookSink.cs
Normal file
78
src/AcDream.Core/Lighting/LightingHookSink.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs
Normal file
119
tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue