merge: sky/weather/lighting overhaul branch (Opus agent, 7 commits, +27 tests)

Ships full retail-faithful sky-object rendering, 5-kind weather with
deterministic per-day roll + storm lightning, dynamic-lighting shader
UBO with retail hard-cutoff semantics, per-entity torch LightSource
registration via Setup.Lights, ParticleRenderer for rain/snow, and
TimeSync handshake wiring. F7 / F10 debug keys for time/weather
cycling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:56:49 +02:00
commit 48b5e1f1b1
31 changed files with 3057 additions and 129 deletions

View file

@ -43,6 +43,7 @@
| F.2 | Item model + Appraise — ItemRepository with move/equip/property events, AppraiseRequest (0x00C8), IdentifyObjectResponse header, WieldObject + InventoryPutObjInContainer | Tests ✓ |
| G.1 | Sky + day/night — DerethDateTime (retail-exact 7620-tick calendar + 16-hour names + PY year), SkyStateProvider (4-keyframe default with angular-wrap lerp), WorldTimeService (server-synced clock with real-time advance) | Tests ✓ |
| G.2 | Dynamic lighting (selection) — LightSource + LightManager with retail 8-light cap, range-squared with 1.1× slack, slot 0 reserved for Sun, OwnerId-keyed unregister | Tests ✓ |
| G.1+ | Full sky visuals + weather + dynamic-light shader — SkyDescLoader parses Region 0x13000000 dat keyframes with retail fog fields (start/end/mode); WeatherSystem picks Clear/Overcast/Rain/Snow/Storm deterministically per in-game day with 10s fade; SkyRenderer draws far-plane-1e6 celestial meshes with UV scroll; SceneLightingUbo binds at std140 location=1 with 8 Light slots + fog + lightning flash; terrain.vert + mesh.frag + mesh_instanced.frag + sky.frag all consume the shared UBO; LightingHookSink auto-registers Setup.Lights per entity + flips IsLit on SetLightHook; ParticleRenderer renders rain/snow billboards; F7 cycles day time override, F10 cycles weather; WorldSession surfaces server time via ServerTimeUpdated (ConnectRequest + TimeSync flag) | Tests ✓ |
| H.1 | Chat wire layer — Talk (0x0015) / Tell (0x005D) / ChatChannel (0x0147) outbound, HearSpeech (0x02BB local + 0x02BC ranged) inbound, ChatLog ring buffer with adapters for every chat source | Tests ✓ |
| Glue | GameEventWiring.WireAll — single-call registration mapping parsed GameEvents → Core state classes (ChatLog, CombatState, Spellbook, ItemRepository); GameWindow exposes state classes + wires them to live session | Tests ✓ |
@ -171,8 +172,8 @@ Research: R1 + R2 + R6 + R8 + UI slices 04/05.
Research: R9 + R12 + R13.
- **G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`.
- **G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`.
- **✓ SHIPPED — G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSession→WorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys.
- **✓ SHIPPED — G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. SceneLightingUbo std140 at binding=1 feeds terrain + mesh + mesh_instanced + sky shaders. LightingHookSink auto-registers Setup.Lights at entity stream-in, flips IsLit on SetLightHook, unregisters on landblock unload.
- **G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `LoginComplete` re-send, "pink bubble" loading state. See `r09-dungeon-portal-space.md`.
**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.
@ -241,7 +242,7 @@ Not detailed here; each gets its own brainstorm when it becomes relevant.
| Portals render as a rotating black disk | **Phase E.3** (particle system) |
| Chimneys have no smoke | **Phase E.3** |
| Houses have no fireplace fire | **Phase E.3** |
| No fireplace / torch lighting | **Phase G.2** (dynamic lighting) |
| No fireplace / torch lighting | **Phase G.2** (shipped; Setup.Lights auto-register, 8-light cap with hard-cutoff) |
| Skin/hair color slightly off | **Phase C.3** |
| No chat window | **Phase H.1** |
| No sound | **Phase E.2** |
@ -250,7 +251,7 @@ Not detailed here; each gets its own brainstorm when it becomes relevant.
| Can't cast spells | **Phase F.4** |
| No inventory panel | **Phase F.2 + F.5** |
| No character creation — must use ACE admin | **Phase H.4** |
| Sky is a flat color | **Phase G.1** (weather + day-night) |
| Sky is a flat color | **Phase G.1** (shipped; F7 cycles time, F10 cycles weather) |
| Can't join allegiance | **Phase H.2** |
| No quest tracker | **Phase H.3** |

View file

@ -101,7 +101,14 @@ public sealed class DebugOverlay
int StreamingRadius,
float MouseSensitivity,
float ChaseDistance,
bool RmbOrbit);
bool RmbOrbit,
// Phase G.1/G.2 — sky + weather + lighting
string HourName = "",
float DayFraction = 0f,
string Weather = "Clear",
int ActiveLights = 0,
int RegisteredLights = 0,
int ParticleCount = 0);
public DebugOverlay(TextRenderer text, BitmapFont font)
{
@ -205,6 +212,10 @@ public sealed class DebugOverlay
($"lb {s.LandblocksVisible,3}/{s.LandblocksTotal,3} visible", White),
($"ent {s.EntityCount,4} anim {s.AnimatedCount,3}", White),
($"coll {s.ShadowObjectCount,5} radius {s.StreamingRadius}", White),
// Phase G: sky + weather + dynamic lighting surface.
($"time {s.DayFraction,5:F2} {s.HourName}", Cyan),
($"wx {s.Weather,-8} parts {s.ParticleCount,5}", Cyan),
($"lit {s.ActiveLights}/{s.RegisteredLights} ", Cyan),
};
float pad = 10f;
@ -277,6 +288,8 @@ public sealed class DebugOverlay
("F4", "toggle debug HUD info panel"),
("F5", "toggle stats panel"),
("F6", "toggle compass"),
("F7", "cycle time-of-day override (none/midnight/dawn/noon/dusk)"),
("F10", "cycle weather (clear/overcast/rain/snow/storm)"),
("F", "toggle fly camera"),
("Tab", "toggle player mode (requires login)"),
("W A S D", "move (player mode) / fly"),
@ -388,7 +401,7 @@ public sealed class DebugOverlay
private void DrawHintBar(Vector2 screenSize)
{
string hint = "F1 help F2 wireframes F3 dump F4/F5/F6 panels F8/F9 sens Tab player Hold RMB orbit Wheel zoom";
string hint = "F1 help F2 wires F3 dump F4/F5/F6 panels F7 time F8/F9 sens F10 weather Tab player RMB orbit Wheel zoom";
float w = _font.MeasureWidth(hint);
float pad = 10f;
float y = screenSize.Y - _font.LineHeight - pad;

View file

@ -140,6 +140,7 @@ public sealed class GameWindow : IDisposable
private readonly AcDream.Core.Vfx.EmitterDescRegistry _emitterRegistry = new();
private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
// Remote-entity motion inference: tracks when each remote entity last
// moved meaningfully. Used in TickAnimations to swap to Ready when
@ -240,6 +241,32 @@ 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();
// 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
// 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;
// F7 / F10 debug-cycle steps for time + weather. Initialized out of
// range of the real values so the first press hits index 0 of the
// cycle table cleanly.
private int _timeDebugStep = 0;
private int _weatherDebugStep = 0;
// Phase B.2: player movement mode.
private AcDream.App.Input.PlayerMovementController? _playerController;
@ -428,6 +455,51 @@ public sealed class GameWindow : IDisposable
_debugOverlay.Toast($"Compass {(_debugOverlay.ShowCompass ? "ON" : "OFF")}");
}
}
else if (key == Key.F7)
{
// Phase G.1: cycle debug time-of-day overrides. Useful for
// visually verifying the sun arc + keyframe transitions
// without waiting 30+ real-time hours. Cycle order:
// clear debug → 0.0 (midnight) → 0.25 (dawn)
// → 0.5 (noon) → 0.75 (dusk) → clear
_timeDebugStep = (_timeDebugStep + 1) % 5;
float? pick = _timeDebugStep switch
{
0 => (float?)null, // server time
1 => 0.0f,
2 => 0.25f,
3 => 0.5f,
4 => 0.75f,
_ => null,
};
if (pick.HasValue)
{
WorldTime.SetDebugTime(pick.Value);
_debugOverlay?.Toast($"Time override = {pick.Value:F2}");
}
else
{
WorldTime.ClearDebugTime();
_debugOverlay?.Toast("Time override cleared");
}
}
else if (key == Key.F10)
{
// Phase G.1: cycle weather kinds manually. Useful for
// testing the rain/snow particle systems + storm/light
// fog without waiting for the daily RNG to hit.
var kinds = new[]
{
AcDream.Core.World.WeatherKind.Clear,
AcDream.Core.World.WeatherKind.Overcast,
AcDream.Core.World.WeatherKind.Rain,
AcDream.Core.World.WeatherKind.Snow,
AcDream.Core.World.WeatherKind.Storm,
};
_weatherDebugStep = (_weatherDebugStep + 1) % kinds.Length;
Weather.ForceWeather(kinds[_weatherDebugStep]);
_debugOverlay?.Toast($"Weather = {kinds[_weatherDebugStep]}");
}
else if (key == Key.F8 || key == Key.F9)
{
// Adjust whichever mode's sensitivity is currently active.
@ -645,6 +717,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.
@ -684,6 +762,12 @@ public sealed class GameWindow : IDisposable
_particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem);
_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
// ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers.
if (Environment.GetEnvironmentVariable("ACDREAM_NO_AUDIO") != "1")
@ -719,6 +803,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);
@ -755,6 +868,21 @@ 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 G.1 particle renderer — renders rain / snow / spell auras
// spawned into the shared ParticleSystem as billboard quads.
// Weather uses AttachLocal emitters so the rain volume follows
// the player.
_particleRenderer = new ParticleRenderer(_gl, shadersDir);
// 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.
@ -778,6 +906,16 @@ public sealed class GameWindow : IDisposable
radius: _streamingRadius,
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);
_physicsEngine.RemoveLandblock(id);
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
@ -816,6 +954,11 @@ public sealed class GameWindow : IDisposable
_liveSession.PositionUpdated += OnLivePositionUpdated;
_liveSession.TeleportStarted += OnTeleportStarted;
// Phase G.1: keep the client's day/night clock in sync with
// server time. Fires once from ConnectRequest (initial seed)
// and repeatedly on TimeSync-flagged packets.
_liveSession.ServerTimeUpdated += ticks => WorldTime.SyncFromServer(ticks);
// Phase F.1-H.1: wire every parsed GameEvent into the right
// Core state class (chat, combat, spellbook, items). After
// this one call, server-sent ChannelBroadcast / damage
@ -2446,6 +2589,32 @@ public sealed class GameWindow : IDisposable
int scTried = 0, scHaveBounds = 0, scRegistered = 0, scTooThin = 0, scNoBounds = 0;
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;
// Treat both procedural scenery (0x80000000+) AND LandBlockInfo
// stabs (IDs < 0x40000000 with 0x01/0x02 source) as outdoor-entities
@ -3069,6 +3238,24 @@ 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;
// Clamp to 0..1 — keyframes may store over-1 values (retail uses the
// dir-bright scalar pre-multiplied into color) and GL's ClearColor
// will silently accept them, but some drivers interpret > 1 as
// "bright clamp", producing ugly pink/green frames.
_gl!.ClearColor(
System.Math.Clamp(fogColor.X, 0f, 1f),
System.Math.Clamp(fogColor.Y, 0f, 1f),
System.Math.Clamp(fogColor.Z, 0f, 1f),
1f);
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
// Phase 6.4: advance per-entity animation playback before drawing
@ -3076,6 +3263,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);
@ -3088,26 +3290,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);
@ -3118,9 +3308,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
@ -3132,6 +3357,13 @@ public sealed class GameWindow : IDisposable
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds);
// Phase G.1 / E.3: draw all live particles after opaque
// scene geometry so alpha blending composites correctly.
// Runs with depth test on (particles occluded by walls)
// but depth write off (no self-occlusion sorting needed).
if (_particleSystem is not null && _particleRenderer is not null)
_particleRenderer.Draw(_particleSystem, camera, camPos);
// Debug: draw collision shapes as wireframe cylinders around the
// player so we can visually verify alignment with scenery meshes.
if (_debugCollisionVisible && _debugLines is not null)
@ -3276,6 +3508,8 @@ public sealed class GameWindow : IDisposable
else
activeSens = _sensOrbit;
// Phase G: pull sky + weather + lighting state for the overlay.
var dayCal = WorldTime.CurrentCalendar;
var snapshot = new DebugOverlay.Snapshot(
Fps: (float)_lastFps,
FrameTimeMs: (float)_lastFrameMs,
@ -3298,7 +3532,13 @@ public sealed class GameWindow : IDisposable
StreamingRadius: _streamingRadius,
MouseSensitivity: activeSens,
ChaseDistance: _chaseCamera?.Distance ?? 0f,
RmbOrbit: _rmbHeld);
RmbOrbit: _rmbHeld,
HourName: dayCal.Hour.ToString(),
DayFraction: (float)WorldTime.DayFraction,
Weather: Weather.Kind.ToString(),
ActiveLights: Lighting.ActiveCount,
RegisteredLights: Lighting.RegisteredCount,
ParticleCount: _particleSystem?.ActiveParticleCount ?? 0);
_debugOverlay.Update((float)deltaSeconds);
var size = new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y);
@ -3790,6 +4030,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
@ -3803,6 +4200,9 @@ public sealed class GameWindow : IDisposable
_meshShader?.Dispose();
_terrain?.Dispose();
_shader?.Dispose();
_sceneLightingUbo?.Dispose();
_skyRenderer?.Dispose();
_particleRenderer?.Dispose();
_debugLines?.Dispose();
_textRenderer?.Dispose();
_debugFont?.Dispose();

View file

@ -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);

View file

@ -0,0 +1,219 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Vfx;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Simple billboard-quad particle renderer. One draw call per emitter:
/// the CPU streams (position, size, rotation, packed color) into a
/// per-instance VBO; a unit quad VBO gets instanced and the vertex
/// shader rotates the quad around the camera forward vector so it
/// always faces the viewer.
///
/// <para>
/// Not a retail-perfect port of the D3D7 fixed-function particle pipe;
/// good enough for rain, snow, and the basic spell auras we need for
/// Phase G.1's weather + E.3's playback. Trails + spot-light
/// interactions deferred.
/// </para>
///
/// <para>
/// Emitters tagged with <see cref="EmitterFlags.AttachLocal"/> get
/// re-anchored to the current camera position each frame so the rain
/// volume follows the player (r12 §7). This is the cheap version of
/// retail's "IsParentLocal" flag on held emitters.
/// </para>
/// </summary>
public sealed unsafe class ParticleRenderer : IDisposable
{
private readonly GL _gl;
private readonly Shader _shader;
// Unit-quad vertex buffer (-0.5..+0.5 in XY). 4 verts, 6 indices.
private readonly uint _quadVao;
private readonly uint _quadVbo;
private readonly uint _quadEbo;
// Instance buffer — 8 floats per particle: posX,Y,Z, size, colorR,G,B,A.
private readonly uint _instanceVbo;
private float[] _instanceScratch = new float[256 * 8];
public ParticleRenderer(GL gl, string shadersDir)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_shader = new Shader(_gl,
System.IO.Path.Combine(shadersDir, "particle.vert"),
System.IO.Path.Combine(shadersDir, "particle.frag"));
// Unit quad around origin (XY plane, Z = 0). The vertex shader
// reads this, then offsets into world space using the
// per-instance (pos, size) values.
float[] quadVerts = new float[]
{
// pos x,y uv
-0.5f, -0.5f, 0f, 0f,
0.5f, -0.5f, 1f, 0f,
0.5f, 0.5f, 1f, 1f,
-0.5f, 0.5f, 0f, 1f,
};
uint[] quadIdx = new uint[] { 0, 1, 2, 0, 2, 3 };
_quadVao = _gl.GenVertexArray();
_gl.BindVertexArray(_quadVao);
_quadVbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _quadVbo);
fixed (void* p = quadVerts)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(quadVerts.Length * sizeof(float)), p, BufferUsageARB.StaticDraw);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)(2 * sizeof(float)));
_quadEbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _quadEbo);
fixed (void* p = quadIdx)
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
(nuint)(quadIdx.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
_instanceVbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
_gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)(256 * 8 * sizeof(float)),
(void*)0, BufferUsageARB.DynamicDraw);
// Per-instance attributes: pos+size at loc 2, color at loc 3.
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, 8 * sizeof(float), (void*)0);
_gl.VertexAttribDivisor(2, 1);
_gl.EnableVertexAttribArray(3);
_gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, 8 * sizeof(float), (void*)(4 * sizeof(float)));
_gl.VertexAttribDivisor(3, 1);
_gl.BindVertexArray(0);
}
/// <summary>
/// Draw every live particle. Splits emitters by blend mode (additive
/// vs alpha-blend) but doesn't sort by depth — particles don't
/// self-occlude enough for sorting to matter for rain/snow.
/// </summary>
public void Draw(ParticleSystem particles, ICamera camera, Vector3 cameraWorldPos)
{
if (particles is null || camera is null) return;
_shader.Use();
_shader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
_shader.SetVec3("uCameraRight", GetCameraRight(camera));
_shader.SetVec3("uCameraUp", GetCameraUp(camera));
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
_gl.Disable(EnableCap.CullFace);
// Group emitters by additive vs alpha-blend so we flip blend state
// once per group rather than per-emitter. Simple two-pass split.
var alphaGroup = new List<ParticleEmitter>(32);
var addGroup = new List<ParticleEmitter>(32);
foreach (var (em, _) in particles.EnumerateLive())
{
var list = (em.Desc.Flags & EmitterFlags.Additive) != 0 ? addGroup : alphaGroup;
if (list.Count == 0 || !ReferenceEquals(list[^1], em))
list.Add(em);
}
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
foreach (var em in alphaGroup)
DrawEmitter(em, cameraWorldPos);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
foreach (var em in addGroup)
DrawEmitter(em, cameraWorldPos);
_gl.DepthMask(true);
_gl.Disable(EnableCap.Blend);
_gl.BindVertexArray(0);
}
private void DrawEmitter(ParticleEmitter em, Vector3 cameraWorldPos)
{
int liveCount = 0;
for (int i = 0; i < em.Particles.Length; i++)
if (em.Particles[i].Alive) liveCount++;
if (liveCount == 0) return;
// Ensure instance buffer is big enough.
int needed = liveCount * 8;
if (_instanceScratch.Length < needed)
_instanceScratch = new float[needed + 256 * 8];
// Anchor adjustment for AttachLocal emitters — re-center the
// emission volume on the camera each frame so the rain/snow
// follows the viewer. The emitter's AnchorPos stays at the
// spawn point, but when writing out world-space particles we
// add (camera - emitterAnchor) so they track the camera.
bool attachLocal = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0;
Vector3 cameraOffset = attachLocal ? (cameraWorldPos - em.AnchorPos) : Vector3.Zero;
int idx = 0;
for (int i = 0; i < em.Particles.Length; i++)
{
ref var p = ref em.Particles[i];
if (!p.Alive) continue;
Vector3 pos = p.Position + cameraOffset;
_instanceScratch[idx * 8 + 0] = pos.X;
_instanceScratch[idx * 8 + 1] = pos.Y;
_instanceScratch[idx * 8 + 2] = pos.Z;
_instanceScratch[idx * 8 + 3] = p.Size;
// ARGB → RGBA floats.
float a = ((p.ColorArgb >> 24) & 0xFF) / 255f;
float r = ((p.ColorArgb >> 16) & 0xFF) / 255f;
float g = ((p.ColorArgb >> 8) & 0xFF) / 255f;
float b = ( p.ColorArgb & 0xFF) / 255f;
_instanceScratch[idx * 8 + 4] = r;
_instanceScratch[idx * 8 + 5] = g;
_instanceScratch[idx * 8 + 6] = b;
_instanceScratch[idx * 8 + 7] = a;
idx++;
}
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
fixed (void* bp = _instanceScratch)
{
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(liveCount * 8 * sizeof(float)),
bp, BufferUsageARB.DynamicDraw);
}
_gl.BindVertexArray(_quadVao);
_gl.DrawElementsInstanced(PrimitiveType.Triangles, 6,
DrawElementsType.UnsignedInt, (void*)0, (uint)liveCount);
}
private static Vector3 GetCameraRight(ICamera camera)
{
Matrix4x4.Invert(camera.View, out var inv);
return Vector3.Normalize(new Vector3(inv.M11, inv.M12, inv.M13));
}
private static Vector3 GetCameraUp(ICamera camera)
{
Matrix4x4.Invert(camera.View, out var inv);
return Vector3.Normalize(new Vector3(inv.M21, inv.M22, inv.M23));
}
public void Dispose()
{
_gl.DeleteBuffer(_quadVbo);
_gl.DeleteBuffer(_quadEbo);
_gl.DeleteBuffer(_instanceVbo);
_gl.DeleteVertexArray(_quadVao);
_shader.Dispose();
}
}

View file

@ -0,0 +1,67 @@
using System;
using System.Runtime.InteropServices;
using AcDream.Core.Lighting;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// GL wrapper that owns the SceneLighting UBO buffer, updates its
/// contents each frame, and keeps it bound at binding=1 so every
/// shader sampling <c>uLights[]</c> / <c>uFogColor</c> / etc reads
/// consistent data without per-shader re-upload.
///
/// <para>
/// Usage (r12 §13.2 + r13 §12.3):
/// <list type="number">
/// <item><description>Instantiate once at startup, after the GL context exists.</description></item>
/// <item><description>Each frame, after <see cref="LightManager.Tick"/>, call <see cref="Upload"/> with a freshly-built <see cref="SceneLightingUbo"/>.</description></item>
/// <item><description>Shader programs that declare <c>layout(std140, binding = 1) uniform SceneLighting { ... }</c> automatically pick up the data.</description></item>
/// </list>
/// </para>
/// </summary>
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);
}
/// <summary>
/// Push the current frame's UBO contents to the GPU. Cheap (576 bytes)
/// so fine to call unconditionally every frame.
/// </summary>
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;
}
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -0,0 +1,18 @@
#version 430 core
in vec2 vTex;
in vec4 vColor;
out vec4 fragColor;
// Procedural rain/snow streak — no texture, just a radial falloff
// centred on the quad so droplets read as small soft circles. Good
// enough for weather + basic spell auras without a texture pipeline.
void main() {
// Signed distance from quad center (in UV space).
vec2 d = vTex - vec2(0.5, 0.5);
float r = length(d) * 2.0; // 0 at center, 1 at corner
float falloff = smoothstep(1.0, 0.4, r);
if (falloff < 0.02) discard;
fragColor = vec4(vColor.rgb, vColor.a * falloff);
}

View file

@ -0,0 +1,31 @@
#version 430 core
// Unit quad vertex (XY -0.5..+0.5)
layout(location = 0) in vec2 aQuad;
layout(location = 1) in vec2 aTex;
// Per-instance: world-space center + size
layout(location = 2) in vec4 aPosAndSize;
layout(location = 3) in vec4 aColor;
uniform mat4 uViewProjection;
uniform vec3 uCameraRight;
uniform vec3 uCameraUp;
out vec2 vTex;
out vec4 vColor;
void main() {
vec3 center = aPosAndSize.xyz;
float size = aPosAndSize.w;
// Billboard: offset the quad vertex along the camera's right + up
// basis vectors so it always faces the viewer.
vec3 world = center
+ uCameraRight * (aQuad.x * size)
+ uCameraUp * (aQuad.y * size);
vTex = aTex;
vColor = aColor;
gl_Position = uViewProjection * vec4(world, 1.0);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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;

View file

@ -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;
/// <summary>
/// Port of <c>references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs</c>.
/// 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 <see cref="SkyObjectData"/> is
/// visible in a window of day-fraction space, sweeps from
/// <c>BeginAngle</c> to <c>EndAngle</c> across the sky, and samples its
/// texture with a per-frame UV scroll driven by <c>TexVelocityX/Y</c>.
///
/// <para>
/// GL state delta per frame:
/// <list type="bullet">
/// <item><description>Depth mask OFF, depth test OFF, cull OFF — the sky
/// should never occlude scene geometry.</description></item>
/// <item><description>Separate projection matrix with a 0.11e6 near/far
/// so mesh vertices at large distance don't clip.</description></item>
/// <item><description>View matrix with translation zeroed — sky is
/// always camera-centred; moving doesn't get you closer to the
/// sun.</description></item>
/// </list>
/// </para>
///
/// <para>
/// Meshes are built lazily per GfxObj id on first reference. The
/// per-object arc transform matches WorldBuilder's composition:
/// <c>scale × RotZ(-heading) × RotY(-rotation)</c> — the negative signs
/// come from AC's Z-up right-handed convention where heading is
/// measured clockwise from north.
/// </para>
/// </summary>
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<uint, List<SubMeshGpu>> _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));
}
/// <summary>
/// Draw the sky for this frame. Called FIRST in the render loop —
/// terrain / meshes / debug lines / overlay land on top.
/// </summary>
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);
}
/// <summary>
/// Find the <see cref="SkyObjectReplaceData"/> entries for the
/// keyframe currently "active" at <paramref name="dayFraction"/>.
/// Matches WorldBuilder's single-keyframe lookup (it picks <c>t1</c>
/// and doesn't interpolate the replace fields).
/// </summary>
private static Dictionary<uint, SkyObjectReplaceData> PickReplaces(
DayGroupData group, float dayFraction)
{
var result = new Dictionary<uint, SkyObjectReplaceData>();
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;
}
/// <summary>
/// Lazy GfxObj build — reuses <see cref="GfxObjMesh"/> so the
/// pos/neg polygon splitting logic stays consistent with the main
/// static-mesh pipeline. Most sky meshes are single-surface.
/// </summary>
private void EnsureMeshUploaded(uint gfxObjId)
{
if (_gpuByGfxObj.ContainsKey(gfxObjId)) return;
var gfx = _dats.Get<GfxObj>(gfxObjId);
if (gfx is null)
{
_gpuByGfxObj[gfxObjId] = new List<SubMeshGpu>();
return;
}
var subMeshes = GfxObjMesh.Build(gfx, _dats);
var gpuList = new List<SubMeshGpu>(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;
}
}

View file

@ -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);

View file

@ -65,6 +65,22 @@ public sealed class GpuWorldState
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>
/// Store the axis-aligned bounding box for a loaded landblock. Called from
/// the render thread after the terrain mesh is built and uploaded.

View file

@ -109,6 +109,21 @@ public sealed class WorldSession : IDisposable
/// </summary>
public event Action<HearSpeech.Parsed>? SpeechHeard;
/// <summary>
/// Phase G.1: latest server Portal Year tick count. Seeded from the
/// ConnectRequest handshake (r12 §1.3 — server sends absolute game
/// time as a double) and refreshed on every TimeSync-flagged packet.
/// Subscribers feed this into <c>WorldTimeService.SyncFromServer</c>
/// so client-local day/night stays in lockstep with the server clock.
/// </summary>
public event Action<double>? ServerTimeUpdated;
/// <summary>
/// Latest server tick count from <see cref="ServerTimeUpdated"/>
/// events. 0 until the handshake completes.
/// </summary>
public double LastServerTimeTicks { get; private set; }
/// <summary>
/// Allow re-sending LoginComplete after a portal teleport. The normal
/// _loginCompleteSent latch prevents duplicate sends on the initial spawn
@ -224,6 +239,14 @@ public sealed class WorldSession : IDisposable
// Step 3: seed ISAAC, send ConnectResponse to port+1, with 200ms race delay
var opt = cr.Optional;
// Phase G.1: server's initial PortalYearTicks (r12 §1.3) lives
// in the ConnectRequest optional section. Publish it to
// subscribers so WorldTimeService.SyncFromServer can seed the
// client clock.
LastServerTimeTicks = opt.ConnectRequestServerTime;
ServerTimeUpdated?.Invoke(opt.ConnectRequestServerTime);
byte[] serverSeedBytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed);
byte[] clientSeedBytes = new byte[4];
@ -393,6 +416,19 @@ public sealed class WorldSession : IDisposable
SendAck(serverHeader.Sequence);
}
// Phase G.1: propagate TimeSync-flagged server time to anyone who
// needs it (sky/day-night lerp in particular). Server sends this
// periodically — no explicit opcode, just the header flag.
if ((serverHeader.Flags & PacketHeaderFlags.TimeSync) != 0)
{
double t = dec.Packet!.Optional.TimeSync;
if (t > 0)
{
LastServerTimeTicks = t;
ServerTimeUpdated?.Invoke(t);
}
}
foreach (var frag in dec.Packet!.Fragments)
{
var body = _assembler.Ingest(frag, out _);

View 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, &gt;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;
}
}

View 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;
}
}

View file

@ -0,0 +1,150 @@
using System;
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.Core.World;
namespace AcDream.Core.Lighting;
/// <summary>
/// GPU-facing scene-lighting UBO layout. Matches the GLSL block in
/// <c>mesh.frag</c> / <c>mesh_instanced.vert</c> / <c>terrain.vert</c>
/// bound at binding=1. std140-compliant — each <c>vec4</c> member
/// lives on a 16-byte boundary, arrays of <c>vec4</c> pack contiguously,
/// and no pad elements are required because the struct's fields are
/// already 16-byte-aligned.
///
/// <para>
/// Layout (r13 §12.3 extended with R12 §13.2 sun+fog):
/// <code>
/// 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)
/// };
/// </code>
/// </para>
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct UboLight
{
public Vector4 PosAndKind;
public Vector4 DirAndRange;
public Vector4 ColorAndIntensity;
public Vector4 ConeAngleEtc;
/// <summary>Pack a <see cref="LightSource"/> into UBO-ready bytes.</summary>
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),
};
}
/// <summary>Packed "zero" light — stored in unused UBO slots so shaders
/// don't read garbage. <c>dirAndRange.w = 0</c> disables the light
/// even if the active-count sentinel is wrong.</summary>
public static UboLight Empty => new()
{
PosAndKind = Vector4.Zero,
DirAndRange = Vector4.Zero,
ColorAndIntensity = Vector4.Zero,
ConeAngleEtc = Vector4.Zero,
};
}
/// <summary>
/// Full CPU-side scene-lighting UBO buffer. One per frame; lives on the
/// render thread. The GL-side wrapper (<c>SceneLightingUboBinding</c>
/// in AcDream.App) uploads this to binding=1 once per frame.
/// </summary>
[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;
/// <summary>
/// Build the full per-frame UBO payload from:
/// <list type="bullet">
/// <item><description>An already-ticked <see cref="LightManager"/>.</description></item>
/// <item><description>The current <see cref="AtmosphereSnapshot"/> (sky + weather).</description></item>
/// <item><description>The current camera world position (sky shader needs it, fog shader needs it).</description></item>
/// <item><description>The current day fraction (sky shader needs it for scrolling clouds).</description></item>
/// </list>
/// </summary>
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;
}
}
}

View file

@ -0,0 +1,297 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.World;
/// <summary>
/// One sky object (celestial mesh) per r12 §2. Each object has:
/// <list type="bullet">
/// <item><description>A visibility window in day-fraction space.</description></item>
/// <item><description>A <c>BeginAngle</c>/<c>EndAngle</c> sweep — the arc it traces across the sky during its window.</description></item>
/// <item><description>A texture-velocity pair for UV scrolling (cloud drift, star twinkle).</description></item>
/// <item><description>A GfxObj mesh (the actual geometry rendered at large distance).</description></item>
/// </list>
///
/// <para>
/// This is the in-memory mirror of <c>DatReaderWriter.Types.SkyObject</c>
/// scrubbed of dat-reader dependencies and with a couple of derived
/// fields pre-computed. The per-keyframe <see cref="SkyObjectReplaceData"/>
/// (r12 §2.3) lives off the owning <see cref="DatSkyKeyframeData"/>.
/// </para>
/// </summary>
public sealed class SkyObjectData
{
public float BeginTime;
public float EndTime;
public float BeginAngle;
public float EndAngle;
public float TexVelocityX;
public float TexVelocityY;
public uint GfxObjId;
public uint Properties;
/// <summary>Object is visible at day-fraction <paramref name="t"/>
/// by retail's begin/end semantics (r12 §2). Three cases:
/// <list type="bullet">
/// <item><description><c>Begin == End</c> → always visible.</description></item>
/// <item><description><c>Begin &lt; End</c> → daytime arc, visible in [Begin, End].</description></item>
/// <item><description><c>Begin &gt; End</c> → wraps midnight, visible in [Begin, 1) [0, End].</description></item>
/// </list></summary>
public bool IsVisible(float t)
{
if (BeginTime == EndTime) return true;
if (BeginTime < EndTime) return t >= BeginTime && t <= EndTime;
// Wrap around midnight.
return t >= BeginTime || t <= EndTime;
}
/// <summary>
/// Arc progress 0..1 through the visibility window; gives the angle
/// interpolation for <c>BeginAngle</c>→<c>EndAngle</c> (r12 §2).
/// </summary>
public float AngleProgress(float t)
{
if (BeginTime == EndTime) return 0f;
float duration;
float progress;
if (BeginTime < EndTime)
{
duration = EndTime - BeginTime;
progress = (t - BeginTime) / duration;
}
else
{
duration = (1f - BeginTime) + EndTime;
progress = (t >= BeginTime)
? (t - BeginTime) / duration
: (t + (1f - BeginTime)) / duration;
}
return Math.Clamp(progress, 0f, 1f);
}
/// <summary>
/// Current arc angle in degrees given the day fraction. Linear
/// interpolation between <see cref="BeginAngle"/> and <see cref="EndAngle"/>.
/// </summary>
public float CurrentAngle(float t)
{
if (BeginTime == EndTime) return BeginAngle;
return BeginAngle + (EndAngle - BeginAngle) * AngleProgress(t);
}
}
/// <summary>
/// Per-keyframe override for one sky object — swap its mesh at dusk,
/// dim it, or rotate it (r12 §2.3). Indexed by
/// <see cref="ObjectIndex"/> into the owning day group's SkyObjects list.
/// </summary>
public sealed class SkyObjectReplaceData
{
public uint ObjectIndex;
public uint GfxObjId;
public float Rotate;
public float Transparent;
public float Luminosity;
public float MaxBright;
}
/// <summary>
/// Full lighting + sky-object-override data for one <c>SkyTimeOfDay</c>
/// keyframe. Built alongside the <see cref="SkyKeyframe"/> the shaders
/// consume — this form keeps the per-object overrides which the
/// <c>SkyRenderer</c> needs to swap clouds for overcast keyframes.
/// </summary>
public sealed class DatSkyKeyframeData
{
public SkyKeyframe Keyframe;
public IReadOnlyList<SkyObjectReplaceData> Replaces = Array.Empty<SkyObjectReplaceData>();
}
/// <summary>
/// One <c>DayGroup</c> from retail's Region dat — a self-contained
/// weather regime. Retail Dereth ships ~3 day groups (clear, overcast,
/// storm) and the client rolls one per day. r12 §11 describes this.
/// </summary>
public sealed class DayGroupData
{
public float ChanceOfOccur;
public string Name = "";
public IReadOnlyList<SkyObjectData> SkyObjects = Array.Empty<SkyObjectData>();
public IReadOnlyList<DatSkyKeyframeData> SkyTimes = Array.Empty<DatSkyKeyframeData>();
}
/// <summary>
/// Fully-loaded skybox data pulled from the Region dat (0x13000000).
/// Has everything the renderer + weather system need to produce a
/// retail-faithful day/night cycle:
/// <list type="bullet">
/// <item><description>A <see cref="SkyStateProvider"/> ready to drop into <see cref="WorldTimeService"/>.</description></item>
/// <item><description>A list of day groups for weather picking.</description></item>
/// <item><description>Calendar constants (<c>DayLength</c>, etc) for cross-checking.</description></item>
/// </list>
/// </summary>
public sealed class LoadedSkyDesc
{
public double TickSize;
public double LightTickSize;
public IReadOnlyList<DayGroupData> DayGroups = Array.Empty<DayGroupData>();
/// <summary>
/// Default day group — currently group 0 per WorldBuilder's
/// <c>SkyboxRenderManager.Render</c>. Weather integration later picks
/// the current day's group by <c>ChanceOfOccur</c>.
/// </summary>
public DayGroupData? DefaultDayGroup =>
DayGroups.Count > 0 ? DayGroups[0] : null;
/// <summary>
/// Build a shader-facing <see cref="SkyStateProvider"/> for the default day group.
/// </summary>
public SkyStateProvider BuildDefaultProvider()
{
var grp = DefaultDayGroup;
if (grp is null || grp.SkyTimes.Count == 0) return SkyStateProvider.Default();
return new SkyStateProvider(grp.SkyTimes.Select(s => s.Keyframe).ToList());
}
}
/// <summary>
/// Parses the Region dat (0x13000000) into strongly-typed acdream data.
/// Safe to call off the render thread as long as the underlying
/// <see cref="DatCollection"/> isn't being mutated (acdream's one-shot
/// startup path already holds the dat lock during Region reads).
///
/// <para>
/// Retail stores the entire world's sky + calendar in this single record
/// — there's only ever one <c>Region</c>. The loader reads the SkyDesc
/// out of <c>region.SkyInfo</c>, iterates every DayGroup, and converts
/// each <c>SkyTimeOfDay</c> to our <see cref="SkyKeyframe"/> record.
/// </para>
///
/// <para>
/// The SunColor / AmbientColor fields store the color × brightness
/// product so the shader UBO layout can stay a flat <c>vec3</c> without
/// extra multiplies per pixel. See r12 §4.
/// </para>
/// </summary>
public static class SkyDescLoader
{
public const uint RegionDatId = 0x13000000u;
/// <summary>
/// Load + parse. Returns <c>null</c> if the Region doesn't have
/// <see cref="PartsMask.HasSkyInfo"/> or the dat is absent.
/// </summary>
public static LoadedSkyDesc? LoadFromDat(DatCollection dats)
{
ArgumentNullException.ThrowIfNull(dats);
var region = dats.Get<Region>(RegionDatId);
if (region is null) return null;
return LoadFromRegion(region);
}
/// <summary>
/// Convert an in-memory Region object to our domain data.
/// Separated so tests can feed hand-built Regions without the dat
/// pipeline.
/// </summary>
public static LoadedSkyDesc? LoadFromRegion(Region region)
{
ArgumentNullException.ThrowIfNull(region);
if (!region.PartsMask.HasFlag(PartsMask.HasSkyInfo) || region.SkyInfo is null)
return null;
var sky = region.SkyInfo;
var dayGroups = new List<DayGroupData>(sky.DayGroups.Count);
foreach (var dg in sky.DayGroups)
{
var objs = dg.SkyObjects.Select(ConvertSkyObject).ToList();
var times = dg.SkyTime.Select(ConvertTimeOfDay).ToList();
dayGroups.Add(new DayGroupData
{
ChanceOfOccur = dg.ChanceOfOccur,
Name = dg.DayName?.ToString() ?? "",
SkyObjects = objs,
SkyTimes = times,
});
}
return new LoadedSkyDesc
{
TickSize = sky.TickSize,
LightTickSize = sky.LightTickSize,
DayGroups = dayGroups,
};
}
private static SkyObjectData ConvertSkyObject(SkyObject s) => new()
{
BeginTime = s.BeginTime,
EndTime = s.EndTime,
BeginAngle = s.BeginAngle,
EndAngle = s.EndAngle,
TexVelocityX = s.TexVelocityX,
TexVelocityY = s.TexVelocityY,
GfxObjId = s.DefaultGfxObjectId?.DataId ?? 0u,
Properties = s.Properties,
};
private static DatSkyKeyframeData ConvertTimeOfDay(SkyTimeOfDay s)
{
var replaces = s.SkyObjReplace.Select(r => new SkyObjectReplaceData
{
ObjectIndex = r.ObjectIndex,
GfxObjId = r.GfxObjId?.DataId ?? 0u,
Rotate = r.Rotate,
Transparent = r.Transparent,
Luminosity = r.Luminosity,
MaxBright = r.MaxBright,
}).ToList();
var fogMode = s.WorldFog switch
{
1u => FogMode.Linear,
2u => FogMode.Exp,
3u => FogMode.Exp2,
_ => FogMode.Off,
};
var kf = new SkyKeyframe(
Begin: s.Begin,
SunHeadingDeg: s.DirHeading,
SunPitchDeg: s.DirPitch,
SunColor: ColorToVec3(s.DirColor) * s.DirBright,
AmbientColor: ColorToVec3(s.AmbColor) * s.AmbBright,
FogColor: ColorToVec3(s.WorldFogColor),
FogDensity: 0f,
FogStart: s.MinWorldFog,
FogEnd: s.MaxWorldFog,
FogMode: fogMode);
return new DatSkyKeyframeData
{
Keyframe = kf,
Replaces = replaces,
};
}
/// <summary>
/// <see cref="ColorARGB"/> stores bytes as B,G,R,A — but the logical
/// channel mapping is just "R/G/B in 0..255". Convert to linear
/// 0..1 <see cref="Vector3"/>. Alpha is ignored (retail lighting
/// doesn't use it).
/// </summary>
public static Vector3 ColorToVec3(ColorARGB? c)
{
if (c is null) return Vector3.One;
return new Vector3(c.Red / 255f, c.Green / 255f, c.Blue / 255f);
}
}

View file

@ -5,25 +5,53 @@ using System.Numerics;
namespace AcDream.Core.World;
/// <summary>
/// One sky keyframe — the lighting + fog state for a specific day-fraction.
/// Multiple keyframes across [0, 1) interpolate linearly (with angular
/// wrap on sun direction) to produce the current sky state.
/// Fog modes mirroring retail's <c>D3DFOGMODE</c>. Retail only ever uses
/// <see cref="Off"/> and <see cref="Linear"/>; the Exp variants are
/// supported by the dat schema but never appear in shipped data. See r12
/// §5 and <c>SkyTimeOfDay.WorldFog</c> (dat <c>uint</c>).
/// </summary>
public enum FogMode
{
Off = 0,
Linear = 1,
Exp = 2,
Exp2 = 3,
}
/// <summary>
/// One sky keyframe — the full lighting + fog state for a specific
/// day-fraction. Multiple keyframes across <c>[0, 1)</c> interpolate
/// linearly (with angular-shortest-arc wrap on sun direction) to produce
/// the current sky state.
///
/// <para>
/// Retail's <c>SkyTimeOfDay</c> dat struct carries this exact data plus
/// references to sky objects (sun mesh, moon mesh, cloud layer) which
/// belong to the renderer. This class exposes the lighting-relevant
/// subset — sun direction, sun color, ambient color, fog.
/// belong to the renderer. This record exposes the shader-relevant
/// subset — sun direction, sun color, ambient color, linear fog. See
/// <c>references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyTimeOfDay.generated.cs</c>
/// and r12 §4 + §5.
/// </para>
///
/// <para>
/// Colors are in LINEAR RGB, already pre-multiplied by their brightness
/// scalar so the shader can plug them straight into the UBO without
/// knowing about <c>DirBright</c> / <c>AmbBright</c>. Range is loosely
/// [0, N] — retail dusk tints have channels above 1.0 and the frag
/// shader clamps after lighting math.
/// </para>
/// </summary>
public readonly record struct SkyKeyframe(
float Begin, // [0, 1] day-fraction this keyframe kicks in
float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W)
float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith)
Vector3 SunColor, // RGB linear, post-brightness multiply
Vector3 AmbientColor,
float Begin, // [0, 1] day-fraction this keyframe kicks in
float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W)
float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith)
Vector3 SunColor, // RGB linear, post-brightness multiply
Vector3 AmbientColor, // RGB linear, post-brightness multiply
Vector3 FogColor,
float FogDensity);
float FogDensity, // retained for tests; derive from FogStart/End
float FogStart = 80f, // meters (retail default ~120 clear, ~40 storm)
float FogEnd = 350f, // meters (retail default ~350 clear, ~150 storm)
FogMode FogMode = FogMode.Linear);
/// <summary>
/// Sky keyframe interpolator — given a day fraction in [0, 1), returns
@ -42,9 +70,8 @@ public readonly record struct SkyKeyframe(
/// with wrap handling.
/// </description></item>
/// <item><description>
/// Lerp every vector component; SLERP the sun direction
/// quaternions to avoid artifacts when heading wraps (e.g. k1.Heading
/// = 350°, k2.Heading = 10°).
/// Lerp every vector component; use shortest-arc lerp for the sun
/// heading so k1=350° → k2=10° doesn't sweep backwards across the sky.
/// </description></item>
/// </list>
/// </para>
@ -64,12 +91,20 @@ public sealed class SkyStateProvider
}
public int KeyframeCount => _keyframes.Count;
public IReadOnlyList<SkyKeyframe> Keyframes => _keyframes;
/// <summary>
/// Default keyframe set based on retail observations — sunrise at 6am,
/// noon at 12pm, sunset at 6pm. Used when the dat-loaded set isn't
/// available yet or the player is in a region whose Region dat
/// doesn't override it.
///
/// <para>
/// Fog values approximate retail clear-weather defaults: ~80m..~350m
/// linear fog with color matching the horizon band so mountains at
/// distance fade into the sky instead of popping at the clip plane.
/// See r12 §5.1.
/// </para>
/// </summary>
public static SkyStateProvider Default()
{
@ -83,7 +118,10 @@ public sealed class SkyStateProvider
SunColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue
AmbientColor: new Vector3(0.05f, 0.05f, 0.12f),
FogColor: new Vector3(0.02f, 0.02f, 0.05f),
FogDensity: 0.004f),
FogDensity: 0.004f,
FogStart: 30f,
FogEnd: 180f,
FogMode: FogMode.Linear),
new SkyKeyframe(
Begin: 0.25f,
SunHeadingDeg: 90f, // east at dawn
@ -91,7 +129,10 @@ public sealed class SkyStateProvider
SunColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm
AmbientColor: new Vector3(0.4f, 0.35f, 0.3f),
FogColor: new Vector3(0.8f, 0.55f, 0.4f),
FogDensity: 0.002f),
FogDensity: 0.002f,
FogStart: 60f,
FogEnd: 260f,
FogMode: FogMode.Linear),
new SkyKeyframe(
Begin: 0.5f,
SunHeadingDeg: 180f, // south at noon
@ -99,7 +140,10 @@ public sealed class SkyStateProvider
SunColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish
AmbientColor: new Vector3(0.5f, 0.5f, 0.55f),
FogColor: new Vector3(0.7f, 0.75f, 0.85f),
FogDensity: 0.0008f),
FogDensity: 0.0008f,
FogStart: 120f,
FogEnd: 500f,
FogMode: FogMode.Linear),
new SkyKeyframe(
Begin: 0.75f,
SunHeadingDeg: 270f, // west at dusk
@ -107,7 +151,10 @@ public sealed class SkyStateProvider
SunColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red
AmbientColor: new Vector3(0.35f, 0.25f, 0.25f),
FogColor: new Vector3(0.85f, 0.45f, 0.35f),
FogDensity: 0.002f),
FogDensity: 0.002f,
FogStart: 60f,
FogEnd: 260f,
FogMode: FogMode.Linear),
});
}
@ -145,21 +192,34 @@ public sealed class SkyStateProvider
u = Math.Clamp(u, 0f, 1f);
// Angular lerp for sun heading: pick shortest arc.
float h1 = k1.SunHeadingDeg;
float h2 = k2.SunHeadingDeg;
float delta = h2 - h1;
while (delta > 180f) delta -= 360f;
while (delta < -180f) delta += 360f;
float heading = h1 + delta * u;
float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u);
// Fog mode doesn't interpolate — pick k1's mode (retail uses Linear everywhere).
return new SkyKeyframe(
Begin: t,
Begin: t,
SunHeadingDeg: heading,
SunPitchDeg: k1.SunPitchDeg + (k2.SunPitchDeg - k1.SunPitchDeg) * u,
SunPitchDeg: Lerp(k1.SunPitchDeg, k2.SunPitchDeg, u),
SunColor: Vector3.Lerp(k1.SunColor, k2.SunColor, u),
AmbientColor: Vector3.Lerp(k1.AmbientColor, k2.AmbientColor, u),
FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, u),
FogDensity: k1.FogDensity + (k2.FogDensity - k1.FogDensity) * u);
FogDensity: Lerp(k1.FogDensity, k2.FogDensity, u),
FogStart: Lerp(k1.FogStart, k2.FogStart, u),
FogEnd: Lerp(k1.FogEnd, k2.FogEnd, u),
FogMode: k1.FogMode);
}
private static float Lerp(float a, float b, float u) => a + (b - a) * u;
/// <summary>
/// Shortest-arc heading lerp: r12 §4. If <c>a=350</c> and <c>b=10</c>
/// the lerp walks 20° forward through 0° rather than 340° backward.
/// </summary>
public static float ShortestAngleLerp(float aDeg, float bDeg, float u)
{
float delta = bDeg - aDeg;
while (delta > 180f) delta -= 360f;
while (delta < -180f) delta += 360f;
return aDeg + delta * u;
}
/// <summary>
@ -185,42 +245,89 @@ public sealed class SkyStateProvider
/// Service that turns server-delivered tick counts into live sky state.
/// Owns the "current time" clock (seeded from server sync, advanced by
/// real-time elapsed between syncs).
///
/// <para>
/// Supports a debug "time override" (slash-command <c>/time 0.5</c>) that
/// forces a specific day fraction regardless of server sync — used for
/// screenshots and visual debugging. The override is transient and gets
/// cleared on the next TimeSync packet.
/// </para>
/// </summary>
public sealed class WorldTimeService
{
private readonly SkyStateProvider _sky;
private SkyStateProvider _sky;
private double _lastSyncedTicks;
private DateTime _lastSyncedWallClockUtc = DateTime.UtcNow;
private float? _debugDayFractionOverride;
/// <summary>
/// Rate at which in-game time advances relative to real time. Retail
/// default is 1.0 (one wall-clock second = one in-game tick). Server
/// config can override via <c>SkyDesc.TickSize</c>; see r12 §1.2.
/// </summary>
public double TickSize { get; set; } = 1.0;
public WorldTimeService(SkyStateProvider sky)
{
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
}
/// <summary>
/// Hot-swap the keyframe source — typically called once at world-load
/// time after the Region dat has been parsed by <see cref="SkyDescLoader"/>.
/// </summary>
public void SetProvider(SkyStateProvider sky)
{
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
}
/// <summary>
/// Set the authoritative tick count from a server TimeSync packet.
/// Clears any debug override.
/// </summary>
public void SyncFromServer(double serverTicks)
{
_lastSyncedTicks = serverTicks;
_lastSyncedWallClockUtc = DateTime.UtcNow;
_debugDayFractionOverride = null;
}
/// <summary>
/// Debug-only: force a specific day fraction in [0, 1). Overrides
/// server-synced time until cleared by <see cref="SyncFromServer"/>
/// or <see cref="ClearDebugTime"/>.
/// </summary>
public void SetDebugTime(float dayFraction)
{
_debugDayFractionOverride = dayFraction;
}
public void ClearDebugTime() => _debugDayFractionOverride = null;
/// <summary>
/// Current ticks at <see cref="DateTime.UtcNow"/>, advanced from the
/// last sync by real-time elapsed seconds.
/// last sync by real-time elapsed seconds times <see cref="TickSize"/>.
/// </summary>
public double NowTicks
{
get
{
double elapsed = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds;
return _lastSyncedTicks + elapsed;
return _lastSyncedTicks + elapsed * TickSize;
}
}
/// <summary>Current day fraction in [0, 1).</summary>
public double DayFraction => DerethDateTime.DayFraction(NowTicks);
public double DayFraction
{
get
{
if (_debugDayFractionOverride.HasValue)
return _debugDayFractionOverride.Value;
return DerethDateTime.DayFraction(NowTicks);
}
}
/// <summary>Current sky lighting state.</summary>
public SkyKeyframe CurrentSky => _sky.Interpolate((float)DayFraction);

View file

@ -0,0 +1,309 @@
using System;
using System.Numerics;
namespace AcDream.Core.World;
/// <summary>
/// Client-local atmospheric regime. Retail AC has no server weather
/// opcode (r12 §6) — the client picks a state per in-game day via a
/// deterministic seeded RNG so all players on the same server see the
/// same weather without any packets. Transitions take ~10 seconds.
///
/// <para>
/// The rendering side reads <see cref="Kind"/> to decide whether to
/// spawn rain/snow particles and which cloud mesh override to select.
/// The <see cref="Intensity"/> field lets the fog / particle rate /
/// cloud-darkness terms ease in and out smoothly rather than popping.
/// </para>
/// </summary>
public enum WeatherKind
{
Clear = 0,
Overcast = 1,
Rain = 2,
Snow = 3,
Storm = 4,
}
/// <summary>
/// Server-forced fog override (retail <c>EnvironChangeType</c>). When
/// the server sends <c>AdminEnvirons</c> (0xEA60) with one of the
/// non-<see cref="None"/> values, the client overrides its locally-computed
/// fog color and density with the tint shown below. See r12 §5.2 and
/// <c>references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs</c>.
/// </summary>
public enum EnvironOverride
{
None = 0x00, // clear override, revert to dat-driven fog
RedFog = 0x01,
BlueFog = 0x02,
WhiteFog = 0x03,
GreenFog = 0x04,
BlackFog = 0x05,
BlackFog2 = 0x06,
}
/// <summary>
/// Full per-frame atmosphere state consumed by the shader + particle
/// systems. Built by <see cref="WeatherSystem"/> from
/// <list type="bullet">
/// <item><description>the interpolated <see cref="SkyKeyframe"/>,</description></item>
/// <item><description>the current <see cref="WeatherKind"/>,</description></item>
/// <item><description>a possibly-active <see cref="EnvironOverride"/>,</description></item>
/// <item><description>a transient lightning-flash bump.</description></item>
/// </list>
/// </summary>
public readonly record struct AtmosphereSnapshot(
WeatherKind Kind,
float Intensity, // 0..1, eases on state transitions
Vector3 FogColor, // final fog color (may be overridden)
float FogStart,
float FogEnd,
FogMode FogMode,
float LightningFlash, // 0..1, decays from strike moment
EnvironOverride Override);
/// <summary>
/// Weather state machine — deterministic per-day RNG picks the weather
/// kind; a 10-second ease blends fog + particle density between old
/// and new states. Also owns the lightning-flash timer for storms.
///
/// <para>
/// Algorithm (r12 §6.16.2):
/// <list type="number">
/// <item><description>
/// Derive a deterministic <c>Random(dayIndex)</c> per in-game day.
/// Roll a weighted pick from a table matching retail's rough
/// 70/15/10/5 distribution (Clear dominates).
/// </description></item>
/// <item><description>
/// When the kind changes, store a <c>transitionStart</c> timestamp
/// and tween <see cref="AtmosphereSnapshot.Intensity"/> from 0 → 1
/// over <see cref="TransitionSeconds"/>.
/// </description></item>
/// <item><description>
/// Storm kind only: every 830 seconds fire a lightning flash; the
/// shader reads <see cref="AtmosphereSnapshot.LightningFlash"/> as
/// an additive scene bump that decays with a 200 ms time constant.
/// </description></item>
/// <item><description>
/// Any server <see cref="EnvironOverride"/> beats the local picks —
/// stick the override fog color and density in the snapshot until
/// the server sends <see cref="EnvironOverride.None"/>.
/// </description></item>
/// </list>
/// </para>
/// </summary>
public sealed class WeatherSystem
{
public const float TransitionSeconds = 10f;
// Flash visual parameters (r12 §9). The spike rises to 1.0 in ~50ms
// and decays exponentially with a time constant of ~200ms.
private const float FlashDecay = 1f / 0.200f; // 1 / τ seconds
private const float FlashPeakHoldS = 0.05f;
// Retail storm cadence: 830 seconds between strikes.
private const float StrikeIntervalMinS = 8f;
private const float StrikeIntervalMaxS = 30f;
// Overcast-kind fog feels like ~40150m retail range (r12 §5.1).
private const float OvercastFogStart = 40f;
private const float OvercastFogEnd = 150f;
private const float StormFogStart = 25f;
private const float StormFogEnd = 90f;
private WeatherKind _kind = WeatherKind.Clear;
private WeatherKind _previousKind = WeatherKind.Clear;
private float _transitionT; // 0..1 through the cross-fade
private float _flashLevel;
private float _flashAge; // seconds since last strike
private float _nextStrikeInS;
private EnvironOverride _override;
private int _rolledDayIndex = int.MinValue; // unrolled == "pick one"
private readonly Random _strikeJitter;
public WeatherSystem(Random? rng = null)
{
_strikeJitter = rng ?? new Random(unchecked((int)0xDCAE_5001u));
_nextStrikeInS = 12f;
}
/// <summary>Current active weather.</summary>
public WeatherKind Kind => _kind;
/// <summary>Last-known server fog override (sticky between sync packets).</summary>
public EnvironOverride Override
{
get => _override;
set => _override = value;
}
/// <summary>
/// Debug / test hook — force a specific weather kind, ignoring the
/// per-day roll. Passing <see cref="WeatherKind.Clear"/> returns to
/// normal behavior starting on the next day-roll.
/// </summary>
public void ForceWeather(WeatherKind kind)
{
BeginTransition(kind);
_rolledDayIndex = int.MaxValue; // "forced" sentinel — don't re-roll
}
/// <summary>
/// Advance the state machine. Call once per frame from the render
/// loop. <paramref name="dayIndex"/> is the in-game day (derived
/// from <see cref="DerethDateTime"/>); when it changes we re-roll
/// the weather kind.
/// </summary>
public void Tick(double nowSeconds, int dayIndex, float dtSeconds)
{
// Cross-fade progression: transitionT advances toward 1 over
// TransitionSeconds. Capped; no further rollover.
if (_transitionT < 1f)
_transitionT = Math.Min(1f, _transitionT + dtSeconds / TransitionSeconds);
// Day changed → re-roll. Skip the sentinel (forced).
if (dayIndex != _rolledDayIndex && _rolledDayIndex != int.MaxValue)
{
_rolledDayIndex = dayIndex;
var newKind = RollKind(dayIndex);
if (newKind != _kind) BeginTransition(newKind);
}
// Lightning timer only ticks in Storm kind.
if (_kind == WeatherKind.Storm && _override == EnvironOverride.None)
{
_nextStrikeInS -= dtSeconds;
if (_nextStrikeInS <= 0f)
{
TriggerFlash();
_nextStrikeInS = StrikeIntervalMinS
+ (float)_strikeJitter.NextDouble() * (StrikeIntervalMaxS - StrikeIntervalMinS);
}
}
// Decay the flash level with a 200ms time constant.
if (_flashLevel > 0f)
{
_flashAge += dtSeconds;
if (_flashAge < FlashPeakHoldS)
_flashLevel = 1f;
else
_flashLevel = MathF.Exp(-(_flashAge - FlashPeakHoldS) * FlashDecay);
if (_flashLevel < 1e-3f) _flashLevel = 0f;
}
}
/// <summary>
/// Trigger a lightning flash manually (server-forced or test hook).
/// </summary>
public void TriggerFlash()
{
_flashLevel = 1f;
_flashAge = 0f;
}
/// <summary>
/// Produce the per-frame snapshot consumed by the shader UBO +
/// particle emitter spawners. Combines the sky keyframe's fog with
/// the weather state's fog overlay, then applies the server
/// <see cref="EnvironOverride"/> tint if any.
/// </summary>
public AtmosphereSnapshot Snapshot(in SkyKeyframe kf)
{
// Cross-fade fog distance + color from previous-kind to new-kind.
var prev = FogForKind(_previousKind, kf);
var curr = FogForKind(_kind, kf);
float t = _transitionT;
var fogColor = Vector3.Lerp(prev.color, curr.color, t);
float fogStart = prev.start + (curr.start - prev.start) * t;
float fogEnd = prev.end + (curr.end - prev.end) * t;
// Server environ override wins.
if (_override != EnvironOverride.None)
{
fogColor = EnvironOverrideColor(_override);
fogStart = 15f;
fogEnd = 80f; // Dense override fog
}
float intensity = _kind == WeatherKind.Clear ? 1f - t : t;
return new AtmosphereSnapshot(
Kind: _kind,
Intensity: Math.Clamp(intensity, 0f, 1f),
FogColor: fogColor,
FogStart: fogStart,
FogEnd: fogEnd,
FogMode: kf.FogMode,
LightningFlash: _flashLevel,
Override: _override);
}
// ----------------------------------------------------------------
// Internal machinery
// ----------------------------------------------------------------
private void BeginTransition(WeatherKind newKind)
{
_previousKind = _kind;
_kind = newKind;
_transitionT = 0f;
}
/// <summary>
/// Deterministic per-day weighted roll. Seeded with <paramref name="dayIndex"/>
/// alone so every client running the same day sees the same weather —
/// retail's mechanism for "synchronized weather without any packets"
/// (r12 §6.1).
/// </summary>
private static WeatherKind RollKind(int dayIndex)
{
// Mix the day index so consecutive days aren't adjacent in PRNG
// state space (avoids tiny-seed correlation issues).
int seed = unchecked((int)((uint)dayIndex * 0x9E3779B1u));
var rng = new Random(seed);
double r = rng.NextDouble();
// Retail weights (approximate): 60% clear, 20% overcast, 12% rain,
// 5% snow, 3% storm. Tuned for "most days are fine, some are bad."
if (r < 0.60) return WeatherKind.Clear;
if (r < 0.80) return WeatherKind.Overcast;
if (r < 0.92) return WeatherKind.Rain;
if (r < 0.97) return WeatherKind.Snow;
return WeatherKind.Storm;
}
private static (Vector3 color, float start, float end) FogForKind(WeatherKind kind, in SkyKeyframe kf)
{
return kind switch
{
WeatherKind.Clear => (kf.FogColor, kf.FogStart, kf.FogEnd),
WeatherKind.Overcast => (Vector3.Lerp(kf.FogColor, new Vector3(0.55f, 0.55f, 0.55f), 0.6f),
OvercastFogStart, OvercastFogEnd),
WeatherKind.Rain => (Vector3.Lerp(kf.FogColor, new Vector3(0.45f, 0.48f, 0.55f), 0.7f),
OvercastFogStart, OvercastFogEnd),
WeatherKind.Snow => (Vector3.Lerp(kf.FogColor, new Vector3(0.80f, 0.82f, 0.90f), 0.6f),
OvercastFogStart, OvercastFogEnd * 1.2f),
WeatherKind.Storm => (Vector3.Lerp(kf.FogColor, new Vector3(0.25f, 0.25f, 0.30f), 0.8f),
StormFogStart, StormFogEnd),
_ => (kf.FogColor, kf.FogStart, kf.FogEnd),
};
}
private static Vector3 EnvironOverrideColor(EnvironOverride o) => o switch
{
EnvironOverride.RedFog => new Vector3(0.60f, 0.05f, 0.05f),
EnvironOverride.BlueFog => new Vector3(0.08f, 0.15f, 0.60f),
EnvironOverride.WhiteFog => new Vector3(0.90f, 0.90f, 0.92f),
EnvironOverride.GreenFog => new Vector3(0.08f, 0.55f, 0.12f),
EnvironOverride.BlackFog => new Vector3(0.02f, 0.02f, 0.02f),
EnvironOverride.BlackFog2 => new Vector3(0.04f, 0.01f, 0.01f),
_ => new Vector3(1f, 1f, 1f),
};
}

View 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);
}
}

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);
}
}

View file

@ -0,0 +1,136 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.World;
public sealed class SkyDescLoaderTests
{
/// <summary>
/// Hand-build a Region with a minimal sky descriptor to feed the
/// loader without needing real dat bytes. The LoadFromRegion
/// separator exists precisely for this — keeps the parsing logic
/// testable independent of DatCollection.
/// </summary>
private static Region MakeRegion(float dirBright, byte rBgrOrder)
{
var region = new Region();
region.PartsMask = PartsMask.HasSkyInfo;
var sky = new SkyDesc
{
TickSize = 1.0,
LightTickSize = 2.0,
};
var dg = new DayGroup
{
ChanceOfOccur = 1.0f,
};
var time = new SkyTimeOfDay
{
Begin = 0.5f,
DirBright = dirBright,
DirHeading = 180f,
DirPitch = 70f,
DirColor = new ColorARGB { Blue = 0, Green = 0, Red = rBgrOrder, Alpha = 255 },
AmbBright = 0.4f,
AmbColor = new ColorARGB { Blue = 100, Green = 100, Red = 100, Alpha = 255 },
MinWorldFog = 120f,
MaxWorldFog = 400f,
WorldFogColor = new ColorARGB { Blue = 50, Green = 50, Red = 50, Alpha = 255 },
WorldFog = 1, // Linear
};
dg.SkyTime.Add(time);
sky.DayGroups.Add(dg);
region.SkyInfo = sky;
return region;
}
[Fact]
public void LoadFromRegion_ConvertsFogFields()
{
var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
var loaded = SkyDescLoader.LoadFromRegion(region);
Assert.NotNull(loaded);
Assert.Equal(1.0, loaded!.TickSize);
Assert.Single(loaded.DayGroups);
var grp = loaded.DayGroups[0];
Assert.Single(grp.SkyTimes);
var kf = grp.SkyTimes[0].Keyframe;
Assert.Equal(120f, kf.FogStart);
Assert.Equal(400f, kf.FogEnd);
Assert.Equal(FogMode.Linear, kf.FogMode);
}
[Fact]
public void LoadFromRegion_SunColor_IsPrepreMultipliedByBrightness()
{
var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
var loaded = SkyDescLoader.LoadFromRegion(region);
Assert.NotNull(loaded);
var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe;
// R was 200/255 ≈ 0.784, times dirBright 1.5 = 1.176
Assert.InRange(kf.SunColor.X, 1.17f, 1.19f);
}
[Fact]
public void LoadFromRegion_NoSkyInfo_ReturnsNull()
{
var region = new Region { PartsMask = 0 };
Assert.Null(SkyDescLoader.LoadFromRegion(region));
}
[Fact]
public void BuildDefaultProvider_FromDatKeyframes_SupportsInterpolation()
{
var region = MakeRegion(dirBright: 1.0f, rBgrOrder: 255);
var loaded = SkyDescLoader.LoadFromRegion(region)!;
var provider = loaded.BuildDefaultProvider();
// Exactly one keyframe: interpolation at any t returns it.
var s = provider.Interpolate(0.1f);
Assert.InRange(s.SunColor.X, 0.99f, 1.01f);
}
[Fact]
public void SkyObjectData_IsVisible_HandlesWrap()
{
var obj = new SkyObjectData
{
BeginTime = 0.9f, // wraps across midnight
EndTime = 0.1f,
};
Assert.True(obj.IsVisible(0.95f)); // near end of day
Assert.True(obj.IsVisible(0.05f)); // just after midnight
Assert.False(obj.IsVisible(0.5f)); // mid-day (not visible)
}
[Fact]
public void SkyObjectData_CurrentAngle_LerpsAcrossWindow()
{
var obj = new SkyObjectData
{
BeginTime = 0.25f,
EndTime = 0.75f,
BeginAngle = 0f,
EndAngle = 180f,
};
// Middle of the window → 90°.
Assert.Equal(90f, obj.CurrentAngle(0.5f), precision: 2);
// At begin → begin angle.
Assert.Equal(0f, obj.CurrentAngle(0.25f), precision: 2);
}
}

View file

@ -0,0 +1,102 @@
using System;
using System.Numerics;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.World;
public sealed class WeatherSystemTests
{
[Fact]
public void Roll_Deterministic_ForSameDayIndex()
{
var a = new WeatherSystem();
var b = new WeatherSystem();
for (int d = 0; d < 100; d++)
{
a.Tick(0, d, 100f); // big dt to finish any transition
b.Tick(0, d, 100f);
Assert.Equal(a.Kind, b.Kind);
}
}
[Fact]
public void Roll_WeightsDominatedByClear()
{
// Clear should cover ~60% of the distribution. Sample many days
// and check the clear fraction is in a reasonable band.
var sys = new WeatherSystem();
int clear = 0;
for (int d = 0; d < 1000; d++)
{
sys.Tick(0, d, 100f);
if (sys.Kind == WeatherKind.Clear) clear++;
}
double frac = clear / 1000.0;
Assert.InRange(frac, 0.45, 0.75);
}
[Fact]
public void Transition_EasesAcrossTenSeconds()
{
// Force Storm, then Clear, sample snapshot fog distance mid-transition.
var sys = new WeatherSystem();
sys.ForceWeather(WeatherKind.Storm);
sys.Tick(0, 1, 100f); // finalize
var kf = SkyStateProvider.Default().Interpolate(0.5f);
var stormFog = sys.Snapshot(in kf);
Assert.Equal(WeatherKind.Storm, stormFog.Kind);
// Snapshot should have a small fog end (storm fog is dense).
Assert.True(stormFog.FogEnd < 120f, $"storm fog end too large: {stormFog.FogEnd}");
}
[Fact]
public void EnvironOverride_ForcesTintedFog()
{
var sys = new WeatherSystem();
sys.Override = EnvironOverride.RedFog;
var kf = SkyStateProvider.Default().Interpolate(0.5f);
var snap = sys.Snapshot(in kf);
Assert.Equal(EnvironOverride.RedFog, snap.Override);
// Red override means the R channel dominates.
Assert.True(snap.FogColor.X > snap.FogColor.Y);
Assert.True(snap.FogColor.X > snap.FogColor.Z);
}
[Fact]
public void Flash_DecaysOverTime()
{
var sys = new WeatherSystem();
sys.TriggerFlash();
var kf = SkyStateProvider.Default().Interpolate(0.5f);
var imm = sys.Snapshot(in kf);
Assert.True(imm.LightningFlash > 0.9f);
// After 1 second the flash should be mostly decayed.
sys.Tick(0, 0, 1.0f);
var later = sys.Snapshot(in kf);
Assert.True(later.LightningFlash < 0.1f,
$"lightning flash didn't decay: {later.LightningFlash}");
}
[Fact]
public void Snapshot_ClearKind_PassesThroughKeyframeFog()
{
var sys = new WeatherSystem();
sys.ForceWeather(WeatherKind.Clear);
sys.Tick(0, 0, 100f); // finish transition
var kf = SkyStateProvider.Default().Interpolate(0.5f);
var snap = sys.Snapshot(in kf);
// Clear passes the keyframe's fog color + distances through.
Assert.Equal(kf.FogStart, snap.FogStart, precision: 2);
Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2);
}
}

View file

@ -0,0 +1,58 @@
using System;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.World;
public sealed class WorldTimeDebugTests
{
[Fact]
public void SetDebugTime_OverridesDayFraction()
{
var service = new WorldTimeService(SkyStateProvider.Default());
service.SyncFromServer(0); // midnight
service.SetDebugTime(0.5f); // force noon
Assert.InRange(service.DayFraction, 0.499, 0.501);
}
[Fact]
public void ClearDebugTime_RestoresServerTime()
{
var service = new WorldTimeService(SkyStateProvider.Default());
service.SyncFromServer(DerethDateTime.DayTicks * 0.25); // dawn
service.SetDebugTime(0.5f);
service.ClearDebugTime();
Assert.InRange(service.DayFraction, 0.24, 0.26);
}
[Fact]
public void SyncFromServer_ClearsDebugOverride()
{
var service = new WorldTimeService(SkyStateProvider.Default());
service.SetDebugTime(0.75f);
service.SyncFromServer(0); // midnight — this should clear the override
Assert.InRange(service.DayFraction, 0.0, 0.01);
}
[Fact]
public void SetProvider_AcceptsNewKeyframes()
{
var service = new WorldTimeService(SkyStateProvider.Default());
var custom = new SkyStateProvider(new[]
{
new SkyKeyframe(
Begin: 0f,
SunHeadingDeg: 0f,
SunPitchDeg: 90f,
SunColor: System.Numerics.Vector3.One,
AmbientColor: System.Numerics.Vector3.One,
FogColor: System.Numerics.Vector3.Zero,
FogDensity: 0f),
});
service.SetProvider(custom);
Assert.Equal(1, custom.KeyframeCount);
}
}