diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index b7a7d41..722c8a2 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -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;
}
+ ///
+ /// Derive the current sun (directional light, slot 0 of the UBO)
+ /// from the interpolated ,
+ /// plus the cell ambient. Indoor cells force the sun intensity to
+ /// zero (r13 §13.7) and substitute a fixed dungeon-tone ambient.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// 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 ()
+ /// so walking never leaves the rain volume (r12 §7).
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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,
+ };
+
+ ///
+ /// Snow emitter tuned per r12 §8: slow fall at ~2 m/s, tumbling
+ /// sideways drift, small billboards, 100 flakes/sec, long lifespan.
+ ///
+ 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();
diff --git a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs
index 0034e50..18a67ae 100644
--- a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs
+++ b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs
@@ -159,10 +159,10 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
var vp = camera.View * camera.Projection;
_shader.SetMatrix4("uViewProjection", vp);
- // Lighting uniforms matching ACME StaticObject.vert.
- var lightDir = Vector3.Normalize(new Vector3(0.5f, 0.3f, -0.3f));
- _shader.SetVec3("uLightDirection", lightDir);
- _shader.SetFloat("uAmbientIntensity", 0.45f);
+ // Phase G: lighting + ambient + fog are owned by the
+ // SceneLighting UBO (binding=1) uploaded once per frame by
+ // GameWindow. The instanced mesh fragment shader reads it
+ // directly — no per-draw uniform uploads needed.
// ── Collect and group instances ───────────────────────────────────────
CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds);
diff --git a/src/AcDream.App/Rendering/SceneLightingUboBinding.cs b/src/AcDream.App/Rendering/SceneLightingUboBinding.cs
new file mode 100644
index 0000000..92f7e8d
--- /dev/null
+++ b/src/AcDream.App/Rendering/SceneLightingUboBinding.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Runtime.InteropServices;
+using AcDream.Core.Lighting;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Rendering;
+
+///
+/// GL wrapper that owns the SceneLighting UBO buffer, updates its
+/// contents each frame, and keeps it bound at binding=1 so every
+/// shader sampling uLights[] / uFogColor / etc reads
+/// consistent data without per-shader re-upload.
+///
+///
+/// Usage (r12 §13.2 + r13 §12.3):
+///
+/// - Instantiate once at startup, after the GL context exists.
+/// - Each frame, after , call with a freshly-built .
+/// - Shader programs that declare layout(std140, binding = 1) uniform SceneLighting { ... } automatically pick up the data.
+///
+///
+///
+public sealed unsafe class SceneLightingUboBinding : IDisposable
+{
+ private readonly GL _gl;
+ private readonly uint _ubo;
+ private bool _disposed;
+
+ public SceneLightingUboBinding(GL gl)
+ {
+ _gl = gl ?? throw new ArgumentNullException(nameof(gl));
+ _ubo = _gl.GenBuffer();
+ _gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo);
+ // Pre-allocate with the final size; BufferSubData each frame.
+ _gl.BufferData(
+ BufferTargetARB.UniformBuffer,
+ (nuint)SceneLightingUbo.SizeInBytes,
+ (void*)0,
+ BufferUsageARB.DynamicDraw);
+ _gl.BindBuffer(BufferTargetARB.UniformBuffer, 0);
+
+ // Bind the buffer to the chosen binding point exactly once — shaders
+ // that declare this binding in their layout block will read from it
+ // on every draw without further intervention.
+ _gl.BindBufferBase(BufferTargetARB.UniformBuffer,
+ SceneLightingUbo.BindingPoint, _ubo);
+ }
+
+ ///
+ /// Push the current frame's UBO contents to the GPU. Cheap (576 bytes)
+ /// so fine to call unconditionally every frame.
+ ///
+ public void Upload(SceneLightingUbo data)
+ {
+ _gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo);
+ _gl.BufferSubData(BufferTargetARB.UniformBuffer,
+ (nint)0, (nuint)SceneLightingUbo.SizeInBytes, &data);
+ _gl.BindBuffer(BufferTargetARB.UniformBuffer, 0);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _gl.DeleteBuffer(_ubo);
+ _disposed = true;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Shaders/mesh.frag b/src/AcDream.App/Rendering/Shaders/mesh.frag
index f8570ac..94cc31f 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh.frag
+++ b/src/AcDream.App/Rendering/Shaders/mesh.frag
@@ -1,6 +1,7 @@
#version 430 core
in vec2 vTex;
in vec3 vWorldNormal;
+in vec3 vWorldPos;
out vec4 fragColor;
uniform sampler2D uDiffuse;
@@ -11,35 +12,114 @@ uniform sampler2D uDiffuse;
// 2 = AlphaBlend — GL blending handles compositing; do NOT discard
// 3 = Additive — GL additive blending; do NOT discard
// 4 = InvAlpha — GL inverted-alpha blending; do NOT discard
-//
-// Only ClipMap uses the alpha-discard path. AlphaBlend/Additive/InvAlpha
-// rely entirely on the GL blend stage — discarding low-alpha fragments
-// would make semi-transparent surfaces (portals, glows) fully invisible.
uniform int uTranslucencyKind;
-// Phase 3a: simple directional lighting. A single sun direction + ambient term
-// gives scenery and building faces enough differentiation to read as 3D instead
-// of looking like paper cutouts. Hardcoded for now; a later phase can route
-// light parameters through uniforms driven by the game's time-of-day.
-// Sun direction tuned after Phase 3a verification: (0.4,0.3,0.8) was too
-// vertical — roofs and ground both landed near peak brightness and only
-// walls dropped, so the contrast was hard to read through textures. More
-// oblique + lower ambient + higher diffuse = contrast ratio ~3.3x.
-const vec3 SUN_DIR = normalize(vec3(0.5, 0.4, 0.6));
-const float AMBIENT = 0.25;
-const float DIFFUSE = 0.75;
+// ─────────────────────────────────────────────────────────────
+// Phase G.1+G.2: shared scene-lighting UBO (binding = 1).
+//
+// Layout mirrors SceneLightingUbo in C#:
+// struct Light {
+// vec4 posAndKind; xyz = world pos, w = kind (0=dir,1=point,2=spot)
+// vec4 dirAndRange; xyz = forward, w = range (metres, hard cutoff)
+// vec4 colorAndIntensity; xyz = RGB linear, w = intensity
+// vec4 coneAngleEtc; x = cone (rad), yzw = reserved
+// };
+// layout(std140, binding = 1) uniform SceneLighting {
+// Light uLights[8];
+// vec4 uCellAmbient; xyz = ambient RGB, w = active count
+// vec4 uFogParams; x = start, y = end, z = flash, w = mode
+// vec4 uFogColor; xyz = color
+// vec4 uCameraAndTime; xyz = camera pos, w = day fraction
+// };
+// ─────────────────────────────────────────────────────────────
+struct Light {
+ vec4 posAndKind;
+ vec4 dirAndRange;
+ vec4 colorAndIntensity;
+ vec4 coneAngleEtc;
+};
+layout(std140, binding = 1) uniform SceneLighting {
+ Light uLights[8];
+ vec4 uCellAmbient;
+ vec4 uFogParams;
+ vec4 uFogColor;
+ vec4 uCameraAndTime;
+};
+
+// Retail hard-cutoff lighting equation (r13 §10.2). No distance
+// attenuation inside Range; hard edge at Range; spotlights use a
+// binary cos-cone test. This is deliberate — the retail "bubble of
+// light" look relies on crisp boundaries.
+vec3 accumulateLights(vec3 N, vec3 worldPos) {
+ vec3 lit = uCellAmbient.xyz;
+ int active = int(uCellAmbient.w);
+ for (int i = 0; i < 8; ++i) {
+ if (i >= active) break;
+
+ int kind = int(uLights[i].posAndKind.w);
+ vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;
+
+ if (kind == 0) {
+ // Directional: "forward" is the light's direction vector
+ // pointing INTO the scene. N·(-forward) = light-facing.
+ vec3 Ldir = -uLights[i].dirAndRange.xyz;
+ float ndl = max(0.0, dot(N, Ldir));
+ lit += Lcol * ndl;
+ } else {
+ // Point / spot: falloff is a HARD bubble at Range.
+ vec3 toL = uLights[i].posAndKind.xyz - worldPos;
+ float d = length(toL);
+ float range = uLights[i].dirAndRange.w;
+ if (d < range && range > 1e-3) {
+ vec3 Ldir = toL / max(d, 1e-4);
+ float ndl = max(0.0, dot(N, Ldir));
+ float atten = 1.0; // retail: no attenuation inside Range
+ if (kind == 2) {
+ // Spotlight: hard-edged cos-cone test.
+ float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
+ float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
+ atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
+ }
+ lit += Lcol * ndl * atten;
+ }
+ }
+ }
+ return lit;
+}
+
+// Linear fog (r12 §5.1): mode 1 = LINEAR, 0 = off, others reserved.
+vec3 applyFog(vec3 lit, vec3 worldPos) {
+ int mode = int(uFogParams.w);
+ if (mode == 0) return lit;
+ float d = length(worldPos - uCameraAndTime.xyz);
+ float fogStart = uFogParams.x;
+ float fogEnd = uFogParams.y;
+ float span = max(1e-3, fogEnd - fogStart);
+ float fog = clamp((d - fogStart) / span, 0.0, 1.0);
+ return mix(lit, uFogColor.xyz, fog);
+}
void main() {
vec4 sampled = texture(uDiffuse, vTex);
// Alpha cutout only for clip-map surfaces (doors, windows, vegetation).
- // Blended surface types (AlphaBlend, Additive, InvAlpha) must NOT
- // discard here — that would make every semi-transparent pixel invisible
- // before the blend stage even runs.
if (uTranslucencyKind == 1 && sampled.a < 0.5) discard;
vec3 N = normalize(vWorldNormal);
- float ndotl = max(dot(N, SUN_DIR), 0.0);
- float lighting = AMBIENT + DIFFUSE * ndotl;
- fragColor = vec4(sampled.rgb * lighting, sampled.a);
+ vec3 lit = accumulateLights(N, vWorldPos);
+
+ // Lightning flash (r12 §9) — additive cold-white pulse layered on top
+ // of diffuse lighting.
+ float flash = uFogParams.z;
+ lit += flash * vec3(0.6, 0.6, 0.75);
+
+ // Clamp per-channel to 1.0 — matches retail (r13 §13.1).
+ lit = min(lit, vec3(1.0));
+
+ vec3 rgb = sampled.rgb * lit;
+
+ // Atmospheric fog — applied after lighting.
+ rgb = applyFog(rgb, vWorldPos);
+
+ fragColor = vec4(rgb, sampled.a);
}
diff --git a/src/AcDream.App/Rendering/Shaders/mesh.vert b/src/AcDream.App/Rendering/Shaders/mesh.vert
index 509ee49..8f9134f 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh.vert
+++ b/src/AcDream.App/Rendering/Shaders/mesh.vert
@@ -9,6 +9,7 @@ uniform mat4 uProjection;
out vec2 vTex;
out vec3 vWorldNormal;
+out vec3 vWorldPos;
void main() {
vTex = aTex;
@@ -17,5 +18,7 @@ void main() {
// scale would require the inverse transpose; we accept that as a Phase 3+
// concern.
vWorldNormal = normalize(mat3(uModel) * aNormal);
- gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
+ vec4 world = uModel * vec4(aPos, 1.0);
+ vWorldPos = world.xyz;
+ gl_Position = uProjection * uView * world;
}
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_instanced.frag b/src/AcDream.App/Rendering/Shaders/mesh_instanced.frag
index 0ad961b..6199aa4 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh_instanced.frag
+++ b/src/AcDream.App/Rendering/Shaders/mesh_instanced.frag
@@ -2,7 +2,7 @@
in vec2 vTex;
in vec3 vWorldNormal;
-in float vLightingFactor;
+in vec3 vWorldPos;
out vec4 fragColor;
@@ -18,14 +18,81 @@ uniform sampler2D uDiffuse;
// 4 = InvAlpha — GL inverted-alpha blending; do NOT discard
uniform int uTranslucencyKind;
+// Phase G.1+G.2: shared scene-lighting UBO (see mesh.frag for layout docs).
+struct Light {
+ vec4 posAndKind;
+ vec4 dirAndRange;
+ vec4 colorAndIntensity;
+ vec4 coneAngleEtc;
+};
+layout(std140, binding = 1) uniform SceneLighting {
+ Light uLights[8];
+ vec4 uCellAmbient;
+ vec4 uFogParams;
+ vec4 uFogColor;
+ vec4 uCameraAndTime;
+};
+
+vec3 accumulateLights(vec3 N, vec3 worldPos) {
+ vec3 lit = uCellAmbient.xyz;
+ int active = int(uCellAmbient.w);
+ for (int i = 0; i < 8; ++i) {
+ if (i >= active) break;
+
+ int kind = int(uLights[i].posAndKind.w);
+ vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;
+
+ if (kind == 0) {
+ vec3 Ldir = -uLights[i].dirAndRange.xyz;
+ float ndl = max(0.0, dot(N, Ldir));
+ lit += Lcol * ndl;
+ } else {
+ vec3 toL = uLights[i].posAndKind.xyz - worldPos;
+ float d = length(toL);
+ float range = uLights[i].dirAndRange.w;
+ if (d < range && range > 1e-3) {
+ vec3 Ldir = toL / max(d, 1e-4);
+ float ndl = max(0.0, dot(N, Ldir));
+ float atten = 1.0;
+ if (kind == 2) {
+ float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
+ float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
+ atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
+ }
+ lit += Lcol * ndl * atten;
+ }
+ }
+ }
+ return lit;
+}
+
+vec3 applyFog(vec3 lit, vec3 worldPos) {
+ int mode = int(uFogParams.w);
+ if (mode == 0) return lit;
+ float d = length(worldPos - uCameraAndTime.xyz);
+ float fogStart = uFogParams.x;
+ float fogEnd = uFogParams.y;
+ float span = max(1e-3, fogEnd - fogStart);
+ float fog = clamp((d - fogStart) / span, 0.0, 1.0);
+ return mix(lit, uFogColor.xyz, fog);
+}
+
void main() {
vec4 color = texture(uDiffuse, vTex);
// Alpha cutout only for clip-map surfaces (doors, windows, vegetation).
- // Blended surface types must NOT discard here — that kills every
- // semi-transparent pixel before the blend stage runs.
if (uTranslucencyKind == 1 && color.a < 0.5) discard;
- // Apply pre-computed Lambert + ambient lighting factor from the vertex shader.
- fragColor = vec4(color.rgb * vLightingFactor, color.a);
+ vec3 N = normalize(vWorldNormal);
+ vec3 lit = accumulateLights(N, vWorldPos);
+
+ // Lightning flash — additive scene bump.
+ lit += uFogParams.z * vec3(0.6, 0.6, 0.75);
+
+ // Retail clamp per-channel to 1.0 (r13 §13.1).
+ lit = min(lit, vec3(1.0));
+
+ vec3 rgb = color.rgb * lit;
+ rgb = applyFog(rgb, vWorldPos);
+ fragColor = vec4(rgb, color.a);
}
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_instanced.vert b/src/AcDream.App/Rendering/Shaders/mesh_instanced.vert
index e9c6896..a2f3893 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh_instanced.vert
+++ b/src/AcDream.App/Rendering/Shaders/mesh_instanced.vert
@@ -16,12 +16,10 @@ layout(location = 5) in vec4 aInstanceRow2;
layout(location = 6) in vec4 aInstanceRow3;
uniform mat4 uViewProjection;
-uniform vec3 uLightDirection; // world-space light direction (points FROM sun, matching ACME)
-uniform float uAmbientIntensity;
out vec2 vTex;
out vec3 vWorldNormal;
-out float vLightingFactor;
+out vec3 vWorldPos;
void main() {
// Reconstruct the per-instance model matrix from its four row vectors.
@@ -30,11 +28,8 @@ void main() {
vec4 worldPos = model * vec4(aPosition, 1.0);
gl_Position = uViewProjection * worldPos;
+ vWorldPos = worldPos.xyz;
// Transform normal into world space.
vWorldNormal = normalize(mat3(model) * aNormal);
vTex = aTexCoord;
-
- // Lambert + ambient matching ACME StaticObject.vert:
- // LightingFactor = max(dot(Normal, -uLightDirection), 0.0) + uAmbientIntensity;
- vLightingFactor = max(dot(vWorldNormal, -uLightDirection), 0.0) + uAmbientIntensity;
}
diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag
new file mode 100644
index 0000000..8945781
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/sky.frag
@@ -0,0 +1,51 @@
+#version 430 core
+// Sky mesh fragment shader — sample the object's diffuse texture with
+// the scrolled UVs from the vertex stage. Unlit: sky meshes ARE the
+// gradient (r12 §2.2), not a surface lit by the sun.
+//
+// The per-keyframe replace override can dim the mesh (Transparent) or
+// brighten it (Luminosity); those two floats arrive as uTransparency /
+// uLuminosity uniforms.
+
+in vec2 vTex;
+out vec4 fragColor;
+
+uniform sampler2D uDiffuse;
+uniform float uTransparency; // 0 = fully visible, 1 = invisible
+uniform float uLuminosity; // 1 = normal, >1 makes the mesh glow
+uniform vec4 uTint; // per-object color tint (default white)
+
+// Shared SceneLighting UBO — we only need the fog parameters to let the
+// horizon band of the sky blend smoothly into the scene's fog color at
+// the far edge, and the lightning flash to give storms their signature
+// strobe.
+struct Light {
+ vec4 posAndKind;
+ vec4 dirAndRange;
+ vec4 colorAndIntensity;
+ vec4 coneAngleEtc;
+};
+layout(std140, binding = 1) uniform SceneLighting {
+ Light uLights[8];
+ vec4 uCellAmbient;
+ vec4 uFogParams;
+ vec4 uFogColor;
+ vec4 uCameraAndTime;
+};
+
+void main() {
+ vec4 sampled = texture(uDiffuse, vTex);
+
+ // Apply tint + luminosity. Retail's SkyObjReplace.Luminosity can push
+ // above 1 to make the sun mesh brighter than its texture; r12 §2.3.
+ vec3 rgb = sampled.rgb * uTint.rgb * uLuminosity;
+
+ // Lightning additive bump — makes the sky itself flash during storms.
+ rgb += uFogParams.z * vec3(0.5, 0.5, 0.55);
+
+ rgb = min(rgb, vec3(1.2)); // soft clamp to let luminosity over-bright mildly
+
+ float a = sampled.a * (1.0 - uTransparency) * uTint.a;
+ if (a < 0.01) discard;
+ fragColor = vec4(rgb, a);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/sky.vert b/src/AcDream.App/Rendering/Shaders/sky.vert
new file mode 100644
index 0000000..35df9ca
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/sky.vert
@@ -0,0 +1,22 @@
+#version 430 core
+// Sky mesh vertex shader — each celestial object is a GfxObj mesh
+// (sun billboard, cloud sheet, moon, star dome) rendered at large
+// distance with depth writes disabled. The view matrix has its
+// translation zeroed so the sky stays camera-centered; the projection
+// matrix has a huge far plane so 1e6-metre-away sky meshes never clip.
+
+layout(location = 0) in vec3 aPos;
+layout(location = 1) in vec3 aNormal;
+layout(location = 2) in vec2 aTex;
+
+uniform mat4 uModel; // per-object arc transform (r12 §2.1)
+uniform mat4 uSkyView; // camera view with M41..M43 = 0
+uniform mat4 uSkyProjection; // near=0.1, far=1e6
+uniform vec2 uUvScroll; // cumulative TexVelocityX/Y * time
+
+out vec2 vTex;
+
+void main() {
+ vTex = aTex + uUvScroll;
+ gl_Position = uSkyProjection * uSkyView * uModel * vec4(aPos, 1.0);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/terrain.frag b/src/AcDream.App/Rendering/Shaders/terrain.frag
index f615929..479939d 100644
--- a/src/AcDream.App/Rendering/Shaders/terrain.frag
+++ b/src/AcDream.App/Rendering/Shaders/terrain.frag
@@ -1,12 +1,14 @@
#version 430 core
// Per-cell terrain blending (Phase 3c.4) — ported from WorldBuilder's
// Landscape.frag, trimmed of editor-specific features (grid, brush,
-// walkable-slope highlighting) and with Phase 3a/3b directional lighting
-// layered on at the end.
+// walkable-slope highlighting). Phase G extends this with the shared
+// SceneLighting UBO driving per-vertex sun bake + fragment-stage fog
+// + lightning flash.
in vec2 vBaseUV;
in vec3 vWorldNormal;
-in float vLightingFactor;
+in vec3 vWorldPos;
+in vec3 vLightingRGB;
in vec4 vOverlay0;
in vec4 vOverlay1;
in vec4 vOverlay2;
@@ -18,24 +20,34 @@ out vec4 fragColor;
uniform sampler2DArray uTerrain; // 33+ layers — TerrainAtlas.GlTexture
uniform sampler2DArray uAlpha; // 8+ layers — TerrainAtlas.GlAlphaTexture
-uniform float xAmbient; // ambient intensity (matching ACME Landscape.frag)
+
+// Shared scene-lighting UBO — fog + flash are consumed here; the per-vertex
+// AdjustPlanes bake already incorporated sun + ambient.
+struct Light {
+ vec4 posAndKind;
+ vec4 dirAndRange;
+ vec4 colorAndIntensity;
+ vec4 coneAngleEtc;
+};
+layout(std140, binding = 1) uniform SceneLighting {
+ Light uLights[8];
+ vec4 uCellAmbient;
+ vec4 uFogParams;
+ vec4 uFogColor;
+ vec4 uCameraAndTime;
+};
// Per-texture tiling repeat count across a cell. WorldBuilder uses
// uTexTiling[36] uploaded from the dats; we default to 1.0 (one tile per
-// cell, 8 tiles across a landblock) until we wire the array. The previous
-// Phase 2b/3 single-layer path tiled at ~2 per cell, so the world may read
-// slightly coarser at 1.0 — tunable here if it looks wrong.
+// cell, 8 tiles across a landblock).
const float TILE = 1.0;
-// Three-layer alpha-weighted composite. Each terrain overlay layer
-// contributes based on its own alpha mask; missing layers (h == 0) collapse
-// to transparent. Lifted verbatim from WorldBuilder's Landscape.frag.
+// Three-layer alpha-weighted composite.
vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) {
float a0 = h0 == 0.0 ? 1.0 : t0.a;
float a1 = h1 == 0.0 ? 1.0 : t1.a;
float a2 = h2 == 0.0 ? 1.0 : t2.a;
float aR = 1.0 - (a0 * a1 * a2);
- // avoid divide-by-zero when all three overlays are absent
float aRsafe = max(aR, 1e-6);
a0 = 1.0 - a0;
a1 = 1.0 - a1;
@@ -82,7 +94,6 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
result = texture(uTerrain, vec3(baseUV * TILE, pRoad0.z));
if (pRoad0.w >= 0.0) {
vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w));
- // Roads use inverted alpha (the mask stores NON-road coverage).
result.a = 1.0 - a0.a;
if (h1 > 0.0 && pRoad1.w >= 0.0) {
vec4 a1 = texture(uAlpha, vec3(pRoad1.xy, pRoad1.w));
@@ -93,9 +104,18 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
return result;
}
+vec3 applyFog(vec3 lit, vec3 worldPos) {
+ int mode = int(uFogParams.w);
+ if (mode == 0) return lit;
+ float d = length(worldPos - uCameraAndTime.xyz);
+ float fogStart = uFogParams.x;
+ float fogEnd = uFogParams.y;
+ float span = max(1e-3, fogEnd - fogStart);
+ float fog = clamp((d - fogStart) / span, 0.0, 1.0);
+ return mix(lit, uFogColor.xyz, fog);
+}
+
void main() {
- // Base color: if there's no base layer (sentinel -1) just render black
- // (shouldn't happen in valid data).
vec4 baseColor = vec4(0.0);
if (vBaseTexIdx >= 0.0) {
baseColor = texture(uTerrain, vec3(vBaseUV * TILE, vBaseTexIdx));
@@ -115,9 +135,15 @@ void main() {
vec3 roadMasked = roads.rgb * roads.a;
vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0);
- // Lighting matching ACME Landscape.frag:
- // litColor = finalColor * (saturate(vLightingFactor) + xAmbient);
- vec3 litColor = rgb * (clamp(vLightingFactor, 0.0, 1.0) + xAmbient);
+ // Apply the per-vertex baked sun+ambient.
+ vec3 lit = rgb * min(vLightingRGB, vec3(1.0));
- fragColor = vec4(litColor, 1.0);
+ // Lightning flash — additive.
+ float flash = uFogParams.z;
+ lit += flash * vec3(0.6, 0.6, 0.75);
+
+ // Atmospheric fog.
+ lit = applyFog(lit, vWorldPos);
+
+ fragColor = vec4(lit, 1.0);
}
diff --git a/src/AcDream.App/Rendering/Shaders/terrain.vert b/src/AcDream.App/Rendering/Shaders/terrain.vert
index c72295d..4b77642 100644
--- a/src/AcDream.App/Rendering/Shaders/terrain.vert
+++ b/src/AcDream.App/Rendering/Shaders/terrain.vert
@@ -8,11 +8,28 @@ layout(location = 5) in uvec4 aPacked3; // bits: rot fields + splitDir (see bel
uniform mat4 uView;
uniform mat4 uProjection;
-uniform vec3 xLightDirection; // world-space sun direction (matching ACME Landscape.vert)
+
+// Phase G.1+G.2: sky/scene UBO. Terrain reads uLights[0] for the sun
+// (slot 0 is reserved) plus uCellAmbient for outdoor ambient; the fog
+// fields are consumed by the fragment stage.
+struct Light {
+ vec4 posAndKind;
+ vec4 dirAndRange;
+ vec4 colorAndIntensity;
+ vec4 coneAngleEtc;
+};
+layout(std140, binding = 1) uniform SceneLighting {
+ Light uLights[8];
+ vec4 uCellAmbient;
+ vec4 uFogParams;
+ vec4 uFogColor;
+ vec4 uCameraAndTime;
+};
out vec2 vBaseUV;
out vec3 vWorldNormal;
-out float vLightingFactor;
+out vec3 vWorldPos;
+out vec3 vLightingRGB; // pre-computed sun+ambient contribution for retail-style AdjustPlanes bake
// Per-layer "UV.xy in cell-local 0..1 space, tex index .z, alpha index .w".
// Negative .z means "layer not present, skip it in the fragment shader."
out vec4 vOverlay0;
@@ -22,6 +39,11 @@ out vec4 vRoad0;
out vec4 vRoad1;
flat out float vBaseTexIdx;
+// Retail's "ambient floor" constant from the decompiled AdjustPlanes
+// path (r13 §7, DAT_00796344). Even a back-lit vertex sees at least
+// this fraction of the sun color — NOT additive with ambient.
+const float MIN_FACTOR = 0.08;
+
// Port of WorldBuilder's Landscape.vert unpackOverlayLayer: sentinel-check
// 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's
// 90° rotation count.
@@ -56,13 +78,6 @@ void main() {
// gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a
// specific order for each split direction; the table below must stay
// in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches.
- //
- // Corner labels: 0=BL (low x/y), 1=BR (high x, low y),
- // 2=TR (high x/y), 3=TL (low x, high y).
- // WorldBuilder assigns cell-local UV per corner:
- // 0 → (0, 1) 1 → (1, 1) 2 → (1, 0) 3 → (0, 0)
- // (the v axis is flipped vs. geometric convention — harmless, just a
- // texture-space choice).
int vIdx = gl_VertexID % 6;
int corner = 0;
if (splitDir == 0u) {
@@ -90,12 +105,20 @@ void main() {
else baseUV = vec2(0.0, 0.0);
vBaseUV = baseUV;
- // Vertices are baked in world space; normals need no model transform.
+ vWorldPos = aPos;
vWorldNormal = normalize(aNormal);
- // Lambert diffuse term matching ACME Landscape.vert:
- // vLightingFactor = max(0.0, dot(vNormal, -normalize(xLightDirection)));
- vLightingFactor = max(0.0, dot(vWorldNormal, -normalize(xLightDirection)));
+ // Retail AdjustPlanes bake (r13 §7):
+ // L = max(N · -sunDir, MIN_FACTOR)
+ // vertex.color = sun_color * L + ambient_color
+ //
+ // Slot 0 of the UBO is the sun (directional). We read its forward
+ // vector and pre-multiplied color, apply the ambient floor, layer
+ // in the scene ambient separately.
+ vec3 sunDir = uLights[0].dirAndRange.xyz;
+ vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w;
+ float L = max(dot(vWorldNormal, -sunDir), MIN_FACTOR);
+ vLightingRGB = sunCol * L + uCellAmbient.xyz;
float baseTex = float(aPacked0.x);
if (baseTex >= 254.0) baseTex = -1.0;
diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
new file mode 100644
index 0000000..ba7a58b
--- /dev/null
+++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
@@ -0,0 +1,292 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using AcDream.Core.Meshing;
+using AcDream.Core.Terrain;
+using AcDream.Core.World;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Rendering.Sky;
+
+///
+/// Port of references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs.
+/// Draws the retail sky as a stack of independent celestial meshes (the
+/// "it's not a dome" insight from r12 §2) rather than a cube/sphere
+/// with a gradient texture. Each is
+/// visible in a window of day-fraction space, sweeps from
+/// BeginAngle to EndAngle across the sky, and samples its
+/// texture with a per-frame UV scroll driven by TexVelocityX/Y.
+///
+///
+/// GL state delta per frame:
+///
+/// - Depth mask OFF, depth test OFF, cull OFF — the sky
+/// should never occlude scene geometry.
+/// - Separate projection matrix with a 0.1–1e6 near/far
+/// so mesh vertices at large distance don't clip.
+/// - View matrix with translation zeroed — sky is
+/// always camera-centred; moving doesn't get you closer to the
+/// sun.
+///
+///
+///
+///
+/// Meshes are built lazily per GfxObj id on first reference. The
+/// per-object arc transform matches WorldBuilder's composition:
+/// scale × RotZ(-heading) × RotY(-rotation) — the negative signs
+/// come from AC's Z-up right-handed convention where heading is
+/// measured clockwise from north.
+///
+///
+public sealed unsafe class SkyRenderer : IDisposable
+{
+ private readonly GL _gl;
+ private readonly DatCollection _dats;
+ private readonly Shader _shader;
+ private readonly TextureCache _textures;
+
+ // Lazily-built GPU resources per sky-GfxObj.
+ private readonly Dictionary> _gpuByGfxObj = new();
+
+ // When did we start running — used to accumulate TexVelocityX/Y over
+ // real time (independent of the day-fraction clock).
+ private readonly DateTime _startedAt = DateTime.UtcNow;
+
+ // Configurable render distance — retail uses ~1e6; anything larger
+ // than the scene far plane works.
+ public float Near { get; set; } = 0.1f;
+ public float Far { get; set; } = 1_000_000f;
+
+ public SkyRenderer(GL gl, DatCollection dats, Shader shader, TextureCache textures)
+ {
+ _gl = gl ?? throw new ArgumentNullException(nameof(gl));
+ _dats = dats ?? throw new ArgumentNullException(nameof(dats));
+ _shader = shader ?? throw new ArgumentNullException(nameof(shader));
+ _textures = textures ?? throw new ArgumentNullException(nameof(textures));
+ }
+
+ ///
+ /// Draw the sky for this frame. Called FIRST in the render loop —
+ /// terrain / meshes / debug lines / overlay land on top.
+ ///
+ public void Render(
+ ICamera camera,
+ Vector3 cameraWorldPos,
+ float dayFraction,
+ DayGroupData? group)
+ {
+ if (group is null || group.SkyObjects.Count == 0) return;
+
+ // Build a sky projection with a huge far plane so 1e6m-distant
+ // celestial meshes don't clip. The FOV is cargo-culted from the
+ // camera's projection — see WorldBuilder's implementation.
+ float fovY = MathF.PI / 3f; // 60° — matches FlyCamera/ChaseCamera
+ float aspect = camera.Aspect;
+ if (aspect <= 0f) aspect = 16f / 9f;
+ var skyProj = Matrix4x4.CreatePerspectiveFieldOfView(fovY, aspect, Near, Far);
+
+ // View with translation zeroed — keeps the sky at camera origin
+ // regardless of camera position in the world.
+ var skyView = camera.View;
+ skyView.M41 = 0f;
+ skyView.M42 = 0f;
+ skyView.M43 = 0f;
+
+ _shader.Use();
+ _shader.SetMatrix4("uSkyView", skyView);
+ _shader.SetMatrix4("uSkyProjection", skyProj);
+
+ // Save + override GL state.
+ _gl.DepthMask(false);
+ _gl.Disable(EnableCap.DepthTest);
+ _gl.Disable(EnableCap.CullFace);
+ _gl.Enable(EnableCap.Blend);
+ _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
+
+ // Look up the keyframe's override list so we can apply
+ // SkyObjReplace (r12 §2.3): per-keyframe GfxObj swaps + rotation
+ // override + transparency fade + luminosity cap.
+ var replaces = PickReplaces(group, dayFraction);
+
+ float secondsSinceStart = (float)(DateTime.UtcNow - _startedAt).TotalSeconds;
+
+ for (int i = 0; i < group.SkyObjects.Count; i++)
+ {
+ var obj = group.SkyObjects[i];
+ if (!obj.IsVisible(dayFraction)) continue;
+
+ // Apply per-keyframe replace overrides.
+ uint gfxObjId = obj.GfxObjId;
+ float headingDeg = 0f;
+ float transparent = 0f;
+ float luminosity = 1f;
+ if (replaces.TryGetValue((uint)i, out var rep))
+ {
+ if (rep.GfxObjId != 0) gfxObjId = rep.GfxObjId;
+ if (rep.Rotate != 0f) headingDeg = rep.Rotate;
+ transparent = Math.Clamp(rep.Transparent, 0f, 1f);
+ if (rep.Luminosity > 0f) luminosity = rep.Luminosity;
+ if (rep.MaxBright > 0f) luminosity = MathF.Min(luminosity, rep.MaxBright);
+ }
+ if (gfxObjId == 0) continue;
+
+ // Current arc angle across the sky.
+ float rotationDeg = obj.CurrentAngle(dayFraction);
+ float headingRad = headingDeg * (MathF.PI / 180f);
+ float rotationRad = rotationDeg * (MathF.PI / 180f);
+
+ // Matches WorldBuilder's composition for a Z-up right-handed
+ // frame with heading measured clockwise from north.
+ var model = Matrix4x4.CreateScale(1.0f)
+ * Matrix4x4.CreateRotationZ(-headingRad)
+ * Matrix4x4.CreateRotationY(-rotationRad);
+
+ _shader.SetMatrix4("uModel", model);
+
+ // UV scroll accumulates real-time × velocity. Wrap to [0, 1]
+ // so long-running sessions don't accumulate float precision
+ // loss in the fragment UV.
+ float uOffset = (obj.TexVelocityX * secondsSinceStart) % 1f;
+ float vOffset = (obj.TexVelocityY * secondsSinceStart) % 1f;
+ _shader.SetVec2("uUvScroll", new Vector2(uOffset, vOffset));
+ _shader.SetFloat("uTransparency", transparent);
+ _shader.SetFloat("uLuminosity", luminosity);
+ _shader.SetVec4("uTint", Vector4.One);
+
+ EnsureMeshUploaded(gfxObjId);
+ if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes)) continue;
+
+ foreach (var sub in subMeshes)
+ {
+ uint tex = _textures.GetOrUpload(sub.SurfaceId);
+ _gl.ActiveTexture(TextureUnit.Texture0);
+ _gl.BindTexture(TextureTarget.Texture2D, tex);
+ _gl.BindVertexArray(sub.Vao);
+ _gl.DrawElements(PrimitiveType.Triangles,
+ (uint)sub.IndexCount,
+ DrawElementsType.UnsignedInt,
+ (void*)0);
+ }
+ }
+
+ // Restore GL state expected by the rest of the pipeline.
+ _gl.Disable(EnableCap.Blend);
+ _gl.DepthMask(true);
+ _gl.Enable(EnableCap.DepthTest);
+ _gl.BindVertexArray(0);
+ }
+
+ ///
+ /// Find the entries for the
+ /// keyframe currently "active" at .
+ /// Matches WorldBuilder's single-keyframe lookup (it picks t1
+ /// and doesn't interpolate the replace fields).
+ ///
+ private static Dictionary PickReplaces(
+ DayGroupData group, float dayFraction)
+ {
+ var result = new Dictionary();
+ var times = group.SkyTimes;
+ if (times.Count == 0) return result;
+
+ // Pick k1 = last keyframe with Begin <= dayFraction.
+ DatSkyKeyframeData k1 = times[^1];
+ for (int i = 0; i < times.Count; i++)
+ {
+ if (times[i].Keyframe.Begin <= dayFraction)
+ k1 = times[i];
+ else
+ break;
+ }
+
+ foreach (var r in k1.Replaces)
+ result[r.ObjectIndex] = r;
+
+ return result;
+ }
+
+ ///
+ /// Lazy GfxObj build — reuses so the
+ /// pos/neg polygon splitting logic stays consistent with the main
+ /// static-mesh pipeline. Most sky meshes are single-surface.
+ ///
+ private void EnsureMeshUploaded(uint gfxObjId)
+ {
+ if (_gpuByGfxObj.ContainsKey(gfxObjId)) return;
+
+ var gfx = _dats.Get(gfxObjId);
+ if (gfx is null)
+ {
+ _gpuByGfxObj[gfxObjId] = new List();
+ return;
+ }
+
+ var subMeshes = GfxObjMesh.Build(gfx, _dats);
+ var gpuList = new List(subMeshes.Count);
+ foreach (var sm in subMeshes)
+ gpuList.Add(UploadSubMesh(sm));
+ _gpuByGfxObj[gfxObjId] = gpuList;
+ }
+
+ private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm)
+ {
+ uint vao = _gl.GenVertexArray();
+ _gl.BindVertexArray(vao);
+
+ uint vbo = _gl.GenBuffer();
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
+ fixed (void* p = sm.Vertices)
+ _gl.BufferData(BufferTargetARB.ArrayBuffer,
+ (nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
+
+ uint ebo = _gl.GenBuffer();
+ _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
+ fixed (void* p = sm.Indices)
+ _gl.BufferData(BufferTargetARB.ElementArrayBuffer,
+ (nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
+
+ uint stride = (uint)sizeof(Vertex);
+ _gl.EnableVertexAttribArray(0);
+ _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
+ _gl.EnableVertexAttribArray(1);
+ _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
+ _gl.EnableVertexAttribArray(2);
+ _gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
+
+ _gl.BindVertexArray(0);
+ return new SubMeshGpu
+ {
+ Vao = vao,
+ Vbo = vbo,
+ Ebo = ebo,
+ IndexCount = sm.Indices.Length,
+ SurfaceId = sm.SurfaceId,
+ };
+ }
+
+ public void Dispose()
+ {
+ foreach (var subs in _gpuByGfxObj.Values)
+ {
+ foreach (var sub in subs)
+ {
+ _gl.DeleteBuffer(sub.Vbo);
+ _gl.DeleteBuffer(sub.Ebo);
+ _gl.DeleteVertexArray(sub.Vao);
+ }
+ }
+ _gpuByGfxObj.Clear();
+ }
+
+ private sealed class SubMeshGpu
+ {
+ public uint Vao;
+ public uint Vbo;
+ public uint Ebo;
+ public int IndexCount;
+ public uint SurfaceId;
+ }
+}
diff --git a/src/AcDream.App/Rendering/TerrainChunkRenderer.cs b/src/AcDream.App/Rendering/TerrainChunkRenderer.cs
index ba619d9..cd2df6a 100644
--- a/src/AcDream.App/Rendering/TerrainChunkRenderer.cs
+++ b/src/AcDream.App/Rendering/TerrainChunkRenderer.cs
@@ -218,12 +218,11 @@ public sealed unsafe class TerrainChunkRenderer : IDisposable
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
- // Lighting uniforms matching ACME Landscape.vert/frag.
- // LightDirection (0.5, 0.3, -0.3) from ACME GameScene.cs:238.
- // AmbientLightIntensity 0.45 from ACME LandscapeEditorSettings.cs:108.
- var lightDir = Vector3.Normalize(new Vector3(0.5f, 0.3f, -0.3f));
- _shader.SetVec3("xLightDirection", lightDir);
- _shader.SetFloat("xAmbient", 0.45f);
+ // Phase G: light direction + ambient + fog come from the shared
+ // SceneLighting UBO (binding=1) uploaded by GameWindow once per
+ // frame. Terrain bakes per-vertex AdjustPlanes lighting (r13 §7)
+ // from the UBO's slot-0 sun + uCellAmbient, then the fragment
+ // stage adds fog + lightning flash. No per-program uniforms here.
// Terrain atlas on unit 0, alpha atlas on unit 1.
_gl.ActiveTexture(TextureUnit.Texture0);
diff --git a/src/AcDream.Core/Lighting/SceneLightingUbo.cs b/src/AcDream.Core/Lighting/SceneLightingUbo.cs
new file mode 100644
index 0000000..85d721f
--- /dev/null
+++ b/src/AcDream.Core/Lighting/SceneLightingUbo.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Numerics;
+using System.Runtime.InteropServices;
+using AcDream.Core.World;
+
+namespace AcDream.Core.Lighting;
+
+///
+/// GPU-facing scene-lighting UBO layout. Matches the GLSL block in
+/// mesh.frag / mesh_instanced.vert / terrain.vert
+/// bound at binding=1. std140-compliant — each vec4 member
+/// lives on a 16-byte boundary, arrays of vec4 pack contiguously,
+/// and no pad elements are required because the struct's fields are
+/// already 16-byte-aligned.
+///
+///
+/// Layout (r13 §12.3 extended with R12 §13.2 sun+fog):
+///
+/// struct Light {
+/// vec4 posAndKind; // xyz = world pos, w = kind (0=dir, 1=point, 2=spot)
+/// vec4 dirAndRange; // xyz = forward, w = range (metres, hard cutoff)
+/// vec4 colorAndIntensity; // xyz = RGB linear, w = intensity scalar
+/// vec4 coneAngleEtc; // x = cone (rad), y=unused, z=unused, w=unused
+/// };
+///
+/// layout(std140, binding = 1) uniform SceneLighting {
+/// Light uLights[8]; // 8 * 64 bytes = 512 bytes
+/// vec4 uCellAmbient; // xyz = ambient RGB, w = active light count
+/// vec4 uFogParams; // x = fogStart, y = fogEnd, z = lightningFlash, w = fogMode
+/// vec4 uFogColor; // xyz = fog RGB, w = unused
+/// vec4 uCameraAndTime; // xyz = camera world pos, w = day fraction (debug / sky shader)
+/// };
+///
+///
+///
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public struct UboLight
+{
+ public Vector4 PosAndKind;
+ public Vector4 DirAndRange;
+ public Vector4 ColorAndIntensity;
+ public Vector4 ConeAngleEtc;
+
+ /// Pack a into UBO-ready bytes.
+ public static UboLight FromSource(LightSource ls)
+ {
+ return new UboLight
+ {
+ PosAndKind = new Vector4(ls.WorldPosition, (float)(int)ls.Kind),
+ DirAndRange = new Vector4(ls.WorldForward, ls.Range),
+ ColorAndIntensity = new Vector4(ls.ColorLinear, ls.Intensity),
+ ConeAngleEtc = new Vector4(ls.ConeAngle, 0f, 0f, 0f),
+ };
+ }
+
+ /// Packed "zero" light — stored in unused UBO slots so shaders
+ /// don't read garbage. dirAndRange.w = 0 disables the light
+ /// even if the active-count sentinel is wrong.
+ public static UboLight Empty => new()
+ {
+ PosAndKind = Vector4.Zero,
+ DirAndRange = Vector4.Zero,
+ ColorAndIntensity = Vector4.Zero,
+ ConeAngleEtc = Vector4.Zero,
+ };
+}
+
+///
+/// Full CPU-side scene-lighting UBO buffer. One per frame; lives on the
+/// render thread. The GL-side wrapper (SceneLightingUboBinding
+/// in AcDream.App) uploads this to binding=1 once per frame.
+///
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public struct SceneLightingUbo
+{
+ // 8 lights × 64 bytes = 512 bytes
+ public UboLight Light0;
+ public UboLight Light1;
+ public UboLight Light2;
+ public UboLight Light3;
+ public UboLight Light4;
+ public UboLight Light5;
+ public UboLight Light6;
+ public UboLight Light7;
+
+ public Vector4 CellAmbient; // xyz = ambient RGB, w = active count
+ public Vector4 FogParams; // x = fogStart, y = fogEnd, z = flash, w = fogMode
+ public Vector4 FogColor; // xyz = color, w = reserved
+ public Vector4 CameraAndTime; // xyz = camera pos, w = day fraction
+
+ public const int SizeInBytes = 8 * 64 + 4 * 16; // 576
+ public const int BindingPoint = 1;
+
+ ///
+ /// Build the full per-frame UBO payload from:
+ ///
+ /// - An already-ticked .
+ /// - The current (sky + weather).
+ /// - The current camera world position (sky shader needs it, fog shader needs it).
+ /// - The current day fraction (sky shader needs it for scrolling clouds).
+ ///
+ ///
+ public static SceneLightingUbo Build(
+ LightManager lights,
+ in AtmosphereSnapshot atmo,
+ Vector3 cameraWorldPos,
+ float dayFraction)
+ {
+ ArgumentNullException.ThrowIfNull(lights);
+
+ var ubo = new SceneLightingUbo();
+
+ // Pack up to 8 lights. Empty slots stay zero.
+ var active = lights.Active;
+ int count = active.Length;
+ if (count > 8) count = 8;
+ for (int i = 0; i < 8; i++)
+ {
+ var packed = (i < count && active[i] is not null)
+ ? UboLight.FromSource(active[i]!)
+ : UboLight.Empty;
+ SetLightAt(ref ubo, i, packed);
+ }
+
+ ubo.CellAmbient = new Vector4(lights.CurrentAmbient.AmbientColor, count);
+ ubo.FogParams = new Vector4(
+ atmo.FogStart,
+ atmo.FogEnd,
+ atmo.LightningFlash,
+ (float)(int)atmo.FogMode);
+ ubo.FogColor = new Vector4(atmo.FogColor, 0f);
+ ubo.CameraAndTime = new Vector4(cameraWorldPos, dayFraction);
+ return ubo;
+ }
+
+ private static void SetLightAt(ref SceneLightingUbo ubo, int i, in UboLight v)
+ {
+ switch (i)
+ {
+ case 0: ubo.Light0 = v; break;
+ case 1: ubo.Light1 = v; break;
+ case 2: ubo.Light2 = v; break;
+ case 3: ubo.Light3 = v; break;
+ case 4: ubo.Light4 = v; break;
+ case 5: ubo.Light5 = v; break;
+ case 6: ubo.Light6 = v; break;
+ case 7: ubo.Light7 = v; break;
+ }
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Lighting/SceneLightingUboTests.cs b/tests/AcDream.Core.Tests/Lighting/SceneLightingUboTests.cs
new file mode 100644
index 0000000..e79a281
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Lighting/SceneLightingUboTests.cs
@@ -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());
+ }
+
+ [Fact]
+ public void SceneLightingUbo_StructSize_MatchesConstant()
+ {
+ Assert.Equal(SceneLightingUbo.SizeInBytes, Marshal.SizeOf());
+ }
+
+ [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);
+ }
+}