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>
This commit is contained in:
Erik 2026-04-19 10:39:48 +02:00
parent 0df1c5b4a6
commit 9957070cab
15 changed files with 1255 additions and 91 deletions

View file

@ -0,0 +1,121 @@
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);
}
}