acdream/tests/AcDream.Core.Tests/Lighting/SceneLightingUboTests.cs
Erik 9957070cab feat(render): Phase G.1/G.2 — SceneLighting UBO + sky renderer + shader integration
Wire the existing LightManager + WorldTimeService state into visible
rendering. Every draw call (terrain, static mesh, instanced mesh, sky)
now shares one SceneLighting UBO at binding=1 carrying:
  - 8 Light slots (Directional / Point / Spot, retail hard-cutoff)
  - Ambient RGB + active light count
  - Fog start/end/mode + color + lightning flash scalar
  - Camera world position + day fraction

The CPU side (SceneLightingUbo in Core.Lighting) is a POD struct that
gets BufferSubData'd once per frame from GameWindow.OnRender. Shaders
read the block via `layout(std140, binding = 1) uniform SceneLighting`
— no per-program uniform uploads.

Shader changes:
  - mesh.frag + mesh_instanced.frag accumulate 8 dynamic lights per
    fragment using the retail no-attenuation hard-cutoff model
    (r13 §10.2 / §13.1). Sun reads slot 0; spots use hard cos-cone test.
    Additive lightning flash + linear fog layered on top. Saturate
    clamps per-channel to 1.0.
  - terrain.vert bakes AdjustPlanes sun+ambient per vertex using the
    retail MIN_FACTOR = 0.08 ambient floor (r13 §7). terrain.frag adds
    fog + flash on top of the baked vertex color.
  - mesh.vert + mesh_instanced.vert emit vWorldPos so the fragment
    stage can do per-pixel lighting against world-space positions.
  - New sky.vert / sky.frag pair — unlit, scroll-UV, camera-centered,
    with its own 0.1..1e6 far plane. Ports WorldBuilder's skybox.

SkyRenderer (new file in App/Rendering/Sky/) ports WorldBuilder's
SkyboxRenderManager verbatim for the C# idiom: zeroed view translation,
dedicated projection, depth mask off, iterate each visible SkyObject
in the day group, apply arc transform (Z rot for heading + Y rot for
arc sweep), feed TexVelocityX/Y as a scrolling UV offset, apply
per-keyframe SkyObjectReplace overrides (mesh swap + transparency +
luminosity) for overcast / dusk cloud variants.

GameWindow integration:
  - OnLoad parses Region (0x13000000) into LoadedSkyDesc and hot-swaps
    WorldTime's provider to the dat-accurate keyframes. Seeds to noon
    for offline rendering. Creates the SceneLightingUboBinding and the
    SkyRenderer.
  - OnRender: set clear color from atmosphere fog, tick WeatherSystem,
    spawn/stop rain/snow camera-local emitters on kind change, feed
    sun to LightManager (zero intensity indoors — r13 §13.7), tick
    LightManager against viewer pos, build + upload the UBO, draw
    sky before terrain, draw terrain + static + instanced using the
    shared UBO.

5 new UBO packing tests (struct sizes, slot population, 8-light cap,
directional slot 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:39:48 +02:00

121 lines
3.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.Core.Lighting;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.Lighting;
public sealed class SceneLightingUboTests
{
[Fact]
public void UboLight_StructSize_Is64Bytes()
{
// std140 mandates 4× vec4 = 64 bytes. If this drifts the shader
// will read garbage.
Assert.Equal(64, Marshal.SizeOf<UboLight>());
}
[Fact]
public void SceneLightingUbo_StructSize_MatchesConstant()
{
Assert.Equal(SceneLightingUbo.SizeInBytes, Marshal.SizeOf<SceneLightingUbo>());
}
[Fact]
public void Build_PacksActiveLightsIntoSlotsInOrder()
{
var lights = new LightManager();
lights.Register(new LightSource
{
Kind = LightKind.Point,
WorldPosition = new Vector3(1, 2, 3),
ColorLinear = new Vector3(1f, 0.5f, 0.25f),
Intensity = 0.8f,
Range = 6f,
});
lights.Tick(Vector3.Zero);
var atmo = new AtmosphereSnapshot(
Kind: WeatherKind.Clear,
Intensity: 1f,
FogColor: new Vector3(0.7f, 0.8f, 0.9f),
FogStart: 100f,
FogEnd: 400f,
FogMode: FogMode.Linear,
LightningFlash: 0f,
Override: EnvironOverride.None);
var ubo = SceneLightingUbo.Build(lights, in atmo, new Vector3(10, 20, 30), 0.5f);
// Light 0 is the slot we populated.
Assert.Equal(1f, ubo.Light0.PosAndKind.X);
Assert.Equal(2f, ubo.Light0.PosAndKind.Y);
Assert.Equal(3f, ubo.Light0.PosAndKind.Z);
Assert.Equal((float)(int)LightKind.Point, ubo.Light0.PosAndKind.W);
Assert.Equal(6f, ubo.Light0.DirAndRange.W);
Assert.Equal(0.8f, ubo.Light0.ColorAndIntensity.W);
// Unused slots should be zero-packed.
Assert.Equal(0f, ubo.Light1.DirAndRange.W);
// Active count lives in uCellAmbient.w.
Assert.Equal(1f, ubo.CellAmbient.W);
// Fog params passed through.
Assert.Equal(100f, ubo.FogParams.X);
Assert.Equal(400f, ubo.FogParams.Y);
Assert.Equal(0f, ubo.FogParams.Z); // no flash
Assert.Equal((float)(int)FogMode.Linear, ubo.FogParams.W);
// Camera + day fraction.
Assert.Equal(10f, ubo.CameraAndTime.X);
Assert.Equal(0.5f, ubo.CameraAndTime.W);
}
[Fact]
public void Build_ClampsAtEightLights()
{
var lights = new LightManager();
// Register 20; the active list caps at 8.
for (int i = 0; i < 20; i++)
{
lights.Register(new LightSource
{
Kind = LightKind.Point,
WorldPosition = new Vector3(i, 0, 0),
Range = 200f, // all in range
});
}
lights.Tick(Vector3.Zero);
var atmo = new AtmosphereSnapshot(
WeatherKind.Clear, 1f, Vector3.Zero, 0, 0, FogMode.Off, 0f, EnvironOverride.None);
var ubo = SceneLightingUbo.Build(lights, in atmo, Vector3.Zero, 0f);
// Slot 7 populated (8th light), active count = 8.
Assert.Equal(8f, ubo.CellAmbient.W);
Assert.NotEqual(0f, ubo.Light7.DirAndRange.W);
}
[Fact]
public void Build_WithSun_SlotZeroIsDirectional()
{
var lights = new LightManager();
lights.Sun = new LightSource
{
Kind = LightKind.Directional,
WorldForward = new Vector3(0, 0, -1),
ColorLinear = new Vector3(1f, 0.9f, 0.8f),
Intensity = 1.2f,
};
lights.Tick(Vector3.Zero);
var atmo = new AtmosphereSnapshot(
WeatherKind.Clear, 1f, Vector3.Zero, 0, 0, FogMode.Off, 0f, EnvironOverride.None);
var ubo = SceneLightingUbo.Build(lights, in atmo, Vector3.Zero, 0f);
Assert.Equal((float)(int)LightKind.Directional, ubo.Light0.PosAndKind.W);
Assert.Equal(1.2f, ubo.Light0.ColorAndIntensity.W);
}
}