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

@ -162,6 +162,23 @@ public sealed class GameWindow : IDisposable
new AcDream.Core.World.WorldTimeService(
AcDream.Core.World.SkyStateProvider.Default());
public readonly AcDream.Core.Lighting.LightManager Lighting = new();
public readonly AcDream.Core.World.WeatherSystem Weather = new();
// Phase G.1 sky renderer + shared UBO. Created once the GL context
// exists in OnLoad; shared across every other renderer via
// binding = 1 so terrain/mesh/instanced/sky all read the same
// sun / ambient / fog / flash data per frame.
private AcDream.App.Rendering.SceneLightingUboBinding? _sceneLightingUbo;
private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer;
private AcDream.Core.World.LoadedSkyDesc? _loadedSkyDesc;
// Current rain/snow emitter handles — spawned on weather-kind change
// and stopped when the kind leaves Rain/Snow. Non-zero == active.
private int _rainEmitterHandle;
private int _snowEmitterHandle;
private AcDream.Core.World.WeatherKind _lastWeatherKind =
AcDream.Core.World.WeatherKind.Clear;
private double _weatherAccum;
// Phase B.2: player movement mode.
private AcDream.App.Input.PlayerMovementController? _playerController;
@ -566,6 +583,12 @@ public sealed class GameWindow : IDisposable
Path.Combine(shadersDir, "mesh_instanced.vert"),
Path.Combine(shadersDir, "mesh_instanced.frag"));
// Phase G.1/G.2: shared scene-lighting UBO. Stays bound at
// binding=1 for the lifetime of the process — every shader that
// declares `layout(std140, binding = 1) uniform SceneLighting`
// reads from this without further intervention.
_sceneLightingUbo = new SceneLightingUboBinding(_gl);
_debugLines = new DebugLineRenderer(_gl, shadersDir);
// Debug HUD: load a system monospace font and set up the text overlay.
@ -640,6 +663,35 @@ public sealed class GameWindow : IDisposable
if (heightTable is null || heightTable.Length < 256)
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");
// Phase G.1: parse the full sky descriptor (day groups, keyframes,
// celestial mesh layers) and swap WorldTime's provider over to the
// dat-backed keyframes. The stub default provider is only used if
// the Region lacks HasSkyInfo.
if (region is not null)
{
_loadedSkyDesc = AcDream.Core.World.SkyDescLoader.LoadFromRegion(region);
if (_loadedSkyDesc is not null)
{
WorldTime.SetProvider(_loadedSkyDesc.BuildDefaultProvider());
WorldTime.TickSize = _loadedSkyDesc.TickSize > 0 ? _loadedSkyDesc.TickSize : 1.0;
Console.WriteLine(
$"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " +
$"TickSize={_loadedSkyDesc.TickSize}, LightTickSize={_loadedSkyDesc.LightTickSize}");
if (_loadedSkyDesc.DefaultDayGroup is not null)
{
Console.WriteLine(
$"sky: default group '{_loadedSkyDesc.DefaultDayGroup.Name}' has " +
$"{_loadedSkyDesc.DefaultDayGroup.SkyObjects.Count} sky objects, " +
$"{_loadedSkyDesc.DefaultDayGroup.SkyTimes.Count} keyframes");
}
}
}
// Seed WorldTime to noon so outdoor scenes aren't pitch-black before
// the server sends its first TimeSync packet (offline rendering in
// particular never receives one).
WorldTime.SyncFromServer(AcDream.Core.World.DerethDateTime.DayTicks * 0.5);
// Build the terrain atlas once from the Region dat.
var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats);
@ -676,6 +728,15 @@ public sealed class GameWindow : IDisposable
_textureCache = new TextureCache(_gl, _dats);
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache);
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
// with depth writes off + far plane 1e6 so celestial meshes
// never clip. Shares the TextureCache with the static pipeline.
var skyShader = new Shader(_gl,
Path.Combine(shadersDir, "sky.vert"),
Path.Combine(shadersDir, "sky.frag"));
_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(
_gl, _dats, skyShader, _textureCache);
// Phase A.1: replace the one-shot 3×3 preload with a streaming controller.
// Parse runtime radius from environment (default 2 → 5×5 window).
// Values outside [0, 8] fall back to the field default of 2.
@ -2863,6 +2924,16 @@ public sealed class GameWindow : IDisposable
private void OnRender(double deltaSeconds)
{
// Phase G.1: set the clear color from the current sky's fog
// tint so the horizon band continues naturally past the
// rendered geometry. Fog blends to this color at max distance
// so there's no visible seam. Updated each frame from the
// interpolated keyframe.
var kf = WorldTime.CurrentSky;
var atmo = Weather.Snapshot(in kf);
var fogColor = atmo.FogColor;
_gl!.ClearColor(fogColor.X, fogColor.Y, fogColor.Z, 1f);
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
// Phase 6.4: advance per-entity animation playback before drawing
@ -2870,6 +2941,21 @@ public sealed class GameWindow : IDisposable
if (_animatedEntities.Count > 0)
TickAnimations((float)deltaSeconds);
// Phase G.1: weather state machine — deterministic per-day roll
// + transitions + lightning flash.
var cal = WorldTime.CurrentCalendar;
int dayIndex = cal.Year * (AcDream.Core.World.DerethDateTime.DaysInAMonth *
AcDream.Core.World.DerethDateTime.MonthsInAYear)
+ (int)cal.Month * AcDream.Core.World.DerethDateTime.DaysInAMonth
+ (cal.Day - 1);
Weather.Tick(nowSeconds: _weatherAccum, dayIndex: dayIndex, dtSeconds: (float)deltaSeconds);
_weatherAccum += deltaSeconds;
// Update the rain/snow particle emitters when the weather kind
// changes. Keep the emitters fed by the ParticleSystem tick so
// visuals stay alive frame-over-frame.
UpdateWeatherParticles(atmo);
// Phase E.3: advance live particle emitters AFTER animation tick
// so emitters spawned by hooks fired this frame get integrated.
_particleSystem?.Tick((float)deltaSeconds);
@ -2882,26 +2968,14 @@ public sealed class GameWindow : IDisposable
var camera = _cameraController.Active;
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection);
// Never cull the landblock the player is currently on.
uint? playerLb = null;
if (_playerMode && _playerController is not null)
{
var pp = _playerController.Position;
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
}
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
// Step 4: portal visibility — determine which interior cells to render.
// Extract camera world position from the inverse of the view matrix.
// Extract camera world position from the inverse of the view
// matrix — needed by the scene-lighting UBO (for fog distance)
// and by the sky renderer (for the camera-centered sky dome).
System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43);
// Phase E.2 audio: update listener pose so 3D sounds pan/attenuate
// correctly relative to where we're looking. Fwd = -Z of the view
// matrix (OpenGL convention), up = +Y. Both live in the inverse
// view matrix's basis vectors.
// correctly relative to where we're looking.
if (_audioEngine is not null && _audioEngine.IsAvailable)
{
var fwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33);
@ -2912,9 +2986,44 @@ public sealed class GameWindow : IDisposable
up.X, up.Y, up.Z);
}
// Step 4: portal visibility — compute BEFORE the UBO upload so
// the indoor flag drives the sun's intensity to zero for
// dungeons (r13 §13.7).
var visibility = _cellVisibility.ComputeVisibility(camPos);
bool cameraInsideCell = visibility?.CameraCell is not null;
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload
// the scene-lighting UBO once per frame. Every shader that
// consumes binding=1 reads the same data for the rest of the
// frame — terrain, static mesh, instanced mesh, sky.
UpdateSunFromSky(kf, cameraInsideCell);
Lighting.Tick(camPos);
var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build(
Lighting, in atmo, camPos, (float)WorldTime.DayFraction);
_sceneLightingUbo?.Upload(ubo);
// Never cull the landblock the player is currently on.
uint? playerLb = null;
if (_playerMode && _playerController is not null)
{
var pp = _playerController.Position;
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
}
// Phase G.1: sky renderer — draws the far-plane-infinity
// celestial meshes FIRST so the rest of the scene z-tests
// on top of them (depth mask off, no depth writes). Skipped
// when indoors; dungeons fully block sky visibility.
if (!cameraInsideCell)
{
_skyRenderer?.Render(camera, camPos, (float)WorldTime.DayFraction,
_loadedSkyDesc?.DefaultDayGroup);
}
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
// Conditional depth clear: when camera is inside a building, clear
// depth (not color) so interior geometry writes fresh Z values on top
// of the terrain color buffer. Exit portals show outdoor terrain color
@ -3409,6 +3518,163 @@ public sealed class GameWindow : IDisposable
ae.CurrFrame = ae.LowFrame;
}
/// <summary>
/// Derive the current sun (directional light, slot 0 of the UBO)
/// from the interpolated <see cref="AcDream.Core.World.SkyKeyframe"/>,
/// plus the cell ambient. Indoor cells force the sun intensity to
/// zero (r13 §13.7) and substitute a fixed dungeon-tone ambient.
/// </summary>
private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool cameraInsideCell)
{
// Sun direction: points FROM the sun TOWARDS the world. Our
// shader does dot(N, -forward) so a positive N·L means the
// surface faces the sun.
var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf);
if (cameraInsideCell)
{
// Dungeon default per r13 §3 — warm-dark ambient, no sun.
Lighting.Sun = new AcDream.Core.Lighting.LightSource
{
Kind = AcDream.Core.Lighting.LightKind.Directional,
WorldForward = sunToWorld,
ColorLinear = System.Numerics.Vector3.Zero,
Intensity = 0f,
Range = 1f,
};
Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState(
AmbientColor: new System.Numerics.Vector3(0.10f, 0.09f, 0.08f),
SunColor: System.Numerics.Vector3.Zero,
SunDirection: sunToWorld);
}
else
{
// Outdoor: full keyframe sun + ambient; colors are already
// pre-multiplied by DirBright / AmbBright inside
// SkyDescLoader so we feed them straight into the UBO.
Lighting.Sun = new AcDream.Core.Lighting.LightSource
{
Kind = AcDream.Core.Lighting.LightKind.Directional,
WorldForward = sunToWorld,
ColorLinear = kf.SunColor,
Intensity = 1f,
Range = 1f,
};
Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState(
AmbientColor: kf.AmbientColor,
SunColor: kf.SunColor,
SunDirection: sunToWorld);
}
}
/// <summary>
/// Keep the rain/snow camera-anchored emitters aligned with the
/// current weather state. Spawns on entry, stops on exit, with no
/// per-frame churn while the state is stable. Emitters are camera-
/// local (<see cref="AcDream.Core.Vfx.EmitterFlags.AttachLocal"/>)
/// so walking never leaves the rain volume (r12 §7).
/// </summary>
private void UpdateWeatherParticles(in AcDream.Core.World.AtmosphereSnapshot atmo)
{
if (_particleSystem is null) return;
if (atmo.Kind == _lastWeatherKind) return; // no change
// Stop any existing emitters first.
if (_rainEmitterHandle != 0)
{
_particleSystem.StopEmitter(_rainEmitterHandle, fadeOut: true);
_rainEmitterHandle = 0;
}
if (_snowEmitterHandle != 0)
{
_particleSystem.StopEmitter(_snowEmitterHandle, fadeOut: true);
_snowEmitterHandle = 0;
}
// Anchor at camera world position; AttachLocal keeps it moving.
var anchor = System.Numerics.Vector3.Zero;
if (_cameraController is not null)
{
System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var inv);
anchor = new System.Numerics.Vector3(inv.M41, inv.M42, inv.M43);
}
switch (atmo.Kind)
{
case AcDream.Core.World.WeatherKind.Rain:
case AcDream.Core.World.WeatherKind.Storm:
_rainEmitterHandle = _particleSystem.SpawnEmitter(
BuildRainDesc(), anchor);
break;
case AcDream.Core.World.WeatherKind.Snow:
_snowEmitterHandle = _particleSystem.SpawnEmitter(
BuildSnowDesc(), anchor);
break;
}
_lastWeatherKind = atmo.Kind;
}
/// <summary>
/// Rain emitter tuned per r12 §7: streaks falling at ~50 m/s with
/// a slight wind bias, 500 drops/sec, 2000 max alive, 1.2s life so
/// drops cover the ~60m fall at terminal velocity.
/// </summary>
private static AcDream.Core.Vfx.EmitterDesc BuildRainDesc() => new()
{
DatId = 0xFFFF_0001u, // synthetic id
Type = AcDream.Core.Vfx.ParticleType.LocalVelocity,
Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal |
AcDream.Core.Vfx.EmitterFlags.Billboard,
EmitRate = 500f,
MaxParticles = 2000,
LifetimeMin = 1.0f,
LifetimeMax = 1.4f,
OffsetDir = new System.Numerics.Vector3(0, 0, 1),
MinOffset = 0f,
MaxOffset = 50f,
SpawnDiskRadius = 15f,
InitialVelocity = new System.Numerics.Vector3(0.5f, 0f, -50f),
VelocityJitter = 2f,
Gravity = System.Numerics.Vector3.Zero,
StartColorArgb = 0x40B0C0E0u,
EndColorArgb = 0x20B0C0E0u,
StartAlpha = 0.3f,
EndAlpha = 0f,
StartSize = 0.05f,
EndSize = 0.05f,
};
/// <summary>
/// Snow emitter tuned per r12 §8: slow fall at ~2 m/s, tumbling
/// sideways drift, small billboards, 100 flakes/sec, long lifespan.
/// </summary>
private static AcDream.Core.Vfx.EmitterDesc BuildSnowDesc() => new()
{
DatId = 0xFFFF_0002u,
Type = AcDream.Core.Vfx.ParticleType.LocalVelocity,
Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal |
AcDream.Core.Vfx.EmitterFlags.Billboard,
EmitRate = 100f,
MaxParticles = 1000,
LifetimeMin = 4f,
LifetimeMax = 8f,
OffsetDir = new System.Numerics.Vector3(0, 0, 1),
MinOffset = 0f,
MaxOffset = 30f,
SpawnDiskRadius = 15f,
InitialVelocity = new System.Numerics.Vector3(0.3f, 0.2f, -2f),
VelocityJitter = 0.8f,
Gravity = System.Numerics.Vector3.Zero,
StartColorArgb = 0xE0FFFFFFu,
EndColorArgb = 0x80FFFFFFu,
StartAlpha = 0.85f,
EndAlpha = 0.3f,
StartSize = 0.08f,
EndSize = 0.06f,
};
private void OnClosing()
{
// Phase A.1: join the streamer worker thread before tearing down GL
@ -3422,6 +3688,8 @@ public sealed class GameWindow : IDisposable
_meshShader?.Dispose();
_terrain?.Dispose();
_shader?.Dispose();
_sceneLightingUbo?.Dispose();
_skyRenderer?.Dispose();
_debugLines?.Dispose();
_textRenderer?.Dispose();
_debugFont?.Dispose();