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 ✓ | | 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.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.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 ✓ | | 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 ✓ | | 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. 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`. - **✓ 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.
- **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.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`. - **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. **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) | | Portals render as a rotating black disk | **Phase E.3** (particle system) |
| Chimneys have no smoke | **Phase E.3** | | Chimneys have no smoke | **Phase E.3** |
| Houses have no fireplace fire | **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** | | Skin/hair color slightly off | **Phase C.3** |
| No chat window | **Phase H.1** | | No chat window | **Phase H.1** |
| No sound | **Phase E.2** | | 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** | | Can't cast spells | **Phase F.4** |
| No inventory panel | **Phase F.2 + F.5** | | No inventory panel | **Phase F.2 + F.5** |
| No character creation — must use ACE admin | **Phase H.4** | | 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** | | Can't join allegiance | **Phase H.2** |
| No quest tracker | **Phase H.3** | | No quest tracker | **Phase H.3** |

View file

@ -101,7 +101,14 @@ public sealed class DebugOverlay
int StreamingRadius, int StreamingRadius,
float MouseSensitivity, float MouseSensitivity,
float ChaseDistance, 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) public DebugOverlay(TextRenderer text, BitmapFont font)
{ {
@ -205,6 +212,10 @@ public sealed class DebugOverlay
($"lb {s.LandblocksVisible,3}/{s.LandblocksTotal,3} visible", White), ($"lb {s.LandblocksVisible,3}/{s.LandblocksTotal,3} visible", White),
($"ent {s.EntityCount,4} anim {s.AnimatedCount,3}", White), ($"ent {s.EntityCount,4} anim {s.AnimatedCount,3}", White),
($"coll {s.ShadowObjectCount,5} radius {s.StreamingRadius}", 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; float pad = 10f;
@ -277,6 +288,8 @@ public sealed class DebugOverlay
("F4", "toggle debug HUD info panel"), ("F4", "toggle debug HUD info panel"),
("F5", "toggle stats panel"), ("F5", "toggle stats panel"),
("F6", "toggle compass"), ("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"), ("F", "toggle fly camera"),
("Tab", "toggle player mode (requires login)"), ("Tab", "toggle player mode (requires login)"),
("W A S D", "move (player mode) / fly"), ("W A S D", "move (player mode) / fly"),
@ -388,7 +401,7 @@ public sealed class DebugOverlay
private void DrawHintBar(Vector2 screenSize) 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 w = _font.MeasureWidth(hint);
float pad = 10f; float pad = 10f;
float y = screenSize.Y - _font.LineHeight - pad; 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 readonly AcDream.Core.Vfx.EmitterDescRegistry _emitterRegistry = new();
private AcDream.Core.Vfx.ParticleSystem? _particleSystem; private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
private AcDream.Core.Vfx.ParticleHookSink? _particleSink; private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
// Remote-entity motion inference: tracks when each remote entity last // Remote-entity motion inference: tracks when each remote entity last
// moved meaningfully. Used in TickAnimations to swap to Ready when // moved meaningfully. Used in TickAnimations to swap to Ready when
@ -240,6 +241,32 @@ public sealed class GameWindow : IDisposable
new AcDream.Core.World.WorldTimeService( new AcDream.Core.World.WorldTimeService(
AcDream.Core.World.SkyStateProvider.Default()); AcDream.Core.World.SkyStateProvider.Default());
public readonly AcDream.Core.Lighting.LightManager Lighting = new(); 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. // Phase B.2: player movement mode.
private AcDream.App.Input.PlayerMovementController? _playerController; private AcDream.App.Input.PlayerMovementController? _playerController;
@ -428,6 +455,51 @@ public sealed class GameWindow : IDisposable
_debugOverlay.Toast($"Compass {(_debugOverlay.ShowCompass ? "ON" : "OFF")}"); _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) else if (key == Key.F8 || key == Key.F9)
{ {
// Adjust whichever mode's sensitivity is currently active. // 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.vert"),
Path.Combine(shadersDir, "mesh_instanced.frag")); 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); _debugLines = new DebugLineRenderer(_gl, shadersDir);
// Debug HUD: load a system monospace font and set up the text overlay. // 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); _particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem);
_hookRouter.Register(_particleSink); _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 // Phase E.2 audio: init OpenAL + hook sink. Suppressible via
// ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers. // ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers.
if (Environment.GetEnvironmentVariable("ACDREAM_NO_AUDIO") != "1") if (Environment.GetEnvironmentVariable("ACDREAM_NO_AUDIO") != "1")
@ -719,6 +803,35 @@ public sealed class GameWindow : IDisposable
if (heightTable is null || heightTable.Length < 256) if (heightTable is null || heightTable.Length < 256)
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated"); 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. // Build the terrain atlas once from the Region dat.
var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats); var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats);
@ -755,6 +868,21 @@ public sealed class GameWindow : IDisposable
_textureCache = new TextureCache(_gl, _dats); _textureCache = new TextureCache(_gl, _dats);
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache); _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. // Phase A.1: replace the one-shot 3×3 preload with a streaming controller.
// Parse runtime radius from environment (default 2 → 5×5 window). // Parse runtime radius from environment (default 2 → 5×5 window).
// Values outside [0, 8] fall back to the field default of 2. // Values outside [0, 8] fall back to the field default of 2.
@ -778,6 +906,16 @@ public sealed class GameWindow : IDisposable
radius: _streamingRadius, radius: _streamingRadius,
removeTerrain: id => 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); _terrain?.RemoveLandblock(id);
_physicsEngine.RemoveLandblock(id); _physicsEngine.RemoveLandblock(id);
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
@ -816,6 +954,11 @@ public sealed class GameWindow : IDisposable
_liveSession.PositionUpdated += OnLivePositionUpdated; _liveSession.PositionUpdated += OnLivePositionUpdated;
_liveSession.TeleportStarted += OnTeleportStarted; _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 // Phase F.1-H.1: wire every parsed GameEvent into the right
// Core state class (chat, combat, spellbook, items). After // Core state class (chat, combat, spellbook, items). After
// this one call, server-sent ChannelBroadcast / damage // 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; int scTried = 0, scHaveBounds = 0, scRegistered = 0, scTooThin = 0, scNoBounds = 0;
foreach (var entity in lb.Entities) 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; int entityBsp = 0, entityCyl = 0;
// Treat both procedural scenery (0x80000000+) AND LandBlockInfo // Treat both procedural scenery (0x80000000+) AND LandBlockInfo
// stabs (IDs < 0x40000000 with 0x01/0x02 source) as outdoor-entities // stabs (IDs < 0x40000000 with 0x01/0x02 source) as outdoor-entities
@ -3069,6 +3238,24 @@ public sealed class GameWindow : IDisposable
private void OnRender(double deltaSeconds) 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); _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
// Phase 6.4: advance per-entity animation playback before drawing // Phase 6.4: advance per-entity animation playback before drawing
@ -3076,6 +3263,21 @@ public sealed class GameWindow : IDisposable
if (_animatedEntities.Count > 0) if (_animatedEntities.Count > 0)
TickAnimations((float)deltaSeconds); 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 // Phase E.3: advance live particle emitters AFTER animation tick
// so emitters spawned by hooks fired this frame get integrated. // so emitters spawned by hooks fired this frame get integrated.
_particleSystem?.Tick((float)deltaSeconds); _particleSystem?.Tick((float)deltaSeconds);
@ -3088,26 +3290,14 @@ public sealed class GameWindow : IDisposable
var camera = _cameraController.Active; var camera = _cameraController.Active;
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection); var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection);
// Never cull the landblock the player is currently on. // Extract camera world position from the inverse of the view
uint? playerLb = null; // matrix — needed by the scene-lighting UBO (for fog distance)
if (_playerMode && _playerController is not null) // and by the sky renderer (for the camera-centered sky dome).
{
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.
System.Numerics.Matrix4x4.Invert(camera.View, out var invView); System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43); var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43);
// Phase E.2 audio: update listener pose so 3D sounds pan/attenuate // Phase E.2 audio: update listener pose so 3D sounds pan/attenuate
// correctly relative to where we're looking. Fwd = -Z of the view // correctly relative to where we're looking.
// matrix (OpenGL convention), up = +Y. Both live in the inverse
// view matrix's basis vectors.
if (_audioEngine is not null && _audioEngine.IsAvailable) if (_audioEngine is not null && _audioEngine.IsAvailable)
{ {
var fwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33); 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); 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); var visibility = _cellVisibility.ComputeVisibility(camPos);
bool cameraInsideCell = visibility?.CameraCell is not null; 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 // Conditional depth clear: when camera is inside a building, clear
// depth (not color) so interior geometry writes fresh Z values on top // depth (not color) so interior geometry writes fresh Z values on top
// of the terrain color buffer. Exit portals show outdoor terrain color // of the terrain color buffer. Exit portals show outdoor terrain color
@ -3132,6 +3357,13 @@ public sealed class GameWindow : IDisposable
neverCullLandblockId: playerLb, neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds); 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 // Debug: draw collision shapes as wireframe cylinders around the
// player so we can visually verify alignment with scenery meshes. // player so we can visually verify alignment with scenery meshes.
if (_debugCollisionVisible && _debugLines is not null) if (_debugCollisionVisible && _debugLines is not null)
@ -3276,6 +3508,8 @@ public sealed class GameWindow : IDisposable
else else
activeSens = _sensOrbit; activeSens = _sensOrbit;
// Phase G: pull sky + weather + lighting state for the overlay.
var dayCal = WorldTime.CurrentCalendar;
var snapshot = new DebugOverlay.Snapshot( var snapshot = new DebugOverlay.Snapshot(
Fps: (float)_lastFps, Fps: (float)_lastFps,
FrameTimeMs: (float)_lastFrameMs, FrameTimeMs: (float)_lastFrameMs,
@ -3298,7 +3532,13 @@ public sealed class GameWindow : IDisposable
StreamingRadius: _streamingRadius, StreamingRadius: _streamingRadius,
MouseSensitivity: activeSens, MouseSensitivity: activeSens,
ChaseDistance: _chaseCamera?.Distance ?? 0f, 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); _debugOverlay.Update((float)deltaSeconds);
var size = new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y); 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; 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() private void OnClosing()
{ {
// Phase A.1: join the streamer worker thread before tearing down GL // Phase A.1: join the streamer worker thread before tearing down GL
@ -3803,6 +4200,9 @@ public sealed class GameWindow : IDisposable
_meshShader?.Dispose(); _meshShader?.Dispose();
_terrain?.Dispose(); _terrain?.Dispose();
_shader?.Dispose(); _shader?.Dispose();
_sceneLightingUbo?.Dispose();
_skyRenderer?.Dispose();
_particleRenderer?.Dispose();
_debugLines?.Dispose(); _debugLines?.Dispose();
_textRenderer?.Dispose(); _textRenderer?.Dispose();
_debugFont?.Dispose(); _debugFont?.Dispose();

View file

@ -159,10 +159,10 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
var vp = camera.View * camera.Projection; var vp = camera.View * camera.Projection;
_shader.SetMatrix4("uViewProjection", vp); _shader.SetMatrix4("uViewProjection", vp);
// Lighting uniforms matching ACME StaticObject.vert. // Phase G: lighting + ambient + fog are owned by the
var lightDir = Vector3.Normalize(new Vector3(0.5f, 0.3f, -0.3f)); // SceneLighting UBO (binding=1) uploaded once per frame by
_shader.SetVec3("uLightDirection", lightDir); // GameWindow. The instanced mesh fragment shader reads it
_shader.SetFloat("uAmbientIntensity", 0.45f); // directly — no per-draw uniform uploads needed.
// ── Collect and group instances ─────────────────────────────────────── // ── Collect and group instances ───────────────────────────────────────
CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds); 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 #version 430 core
in vec2 vTex; in vec2 vTex;
in vec3 vWorldNormal; in vec3 vWorldNormal;
in vec3 vWorldPos;
out vec4 fragColor; out vec4 fragColor;
uniform sampler2D uDiffuse; uniform sampler2D uDiffuse;
@ -11,35 +12,114 @@ uniform sampler2D uDiffuse;
// 2 = AlphaBlend — GL blending handles compositing; do NOT discard // 2 = AlphaBlend — GL blending handles compositing; do NOT discard
// 3 = Additive — GL additive blending; do NOT discard // 3 = Additive — GL additive blending; do NOT discard
// 4 = InvAlpha — GL inverted-alpha 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; 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 // Phase G.1+G.2: shared scene-lighting UBO (binding = 1).
// 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. // Layout mirrors SceneLightingUbo in C#:
// Sun direction tuned after Phase 3a verification: (0.4,0.3,0.8) was too // struct Light {
// vertical — roofs and ground both landed near peak brightness and only // vec4 posAndKind; xyz = world pos, w = kind (0=dir,1=point,2=spot)
// walls dropped, so the contrast was hard to read through textures. More // vec4 dirAndRange; xyz = forward, w = range (metres, hard cutoff)
// oblique + lower ambient + higher diffuse = contrast ratio ~3.3x. // vec4 colorAndIntensity; xyz = RGB linear, w = intensity
const vec3 SUN_DIR = normalize(vec3(0.5, 0.4, 0.6)); // vec4 coneAngleEtc; x = cone (rad), yzw = reserved
const float AMBIENT = 0.25; // };
const float DIFFUSE = 0.75; // 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() { void main() {
vec4 sampled = texture(uDiffuse, vTex); vec4 sampled = texture(uDiffuse, vTex);
// Alpha cutout only for clip-map surfaces (doors, windows, vegetation). // 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; if (uTranslucencyKind == 1 && sampled.a < 0.5) discard;
vec3 N = normalize(vWorldNormal); vec3 N = normalize(vWorldNormal);
float ndotl = max(dot(N, SUN_DIR), 0.0); vec3 lit = accumulateLights(N, vWorldPos);
float lighting = AMBIENT + DIFFUSE * ndotl;
fragColor = vec4(sampled.rgb * lighting, sampled.a); // 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 vec2 vTex;
out vec3 vWorldNormal; out vec3 vWorldNormal;
out vec3 vWorldPos;
void main() { void main() {
vTex = aTex; vTex = aTex;
@ -17,5 +18,7 @@ void main() {
// scale would require the inverse transpose; we accept that as a Phase 3+ // scale would require the inverse transpose; we accept that as a Phase 3+
// concern. // concern.
vWorldNormal = normalize(mat3(uModel) * aNormal); 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 vec2 vTex;
in vec3 vWorldNormal; in vec3 vWorldNormal;
in float vLightingFactor; in vec3 vWorldPos;
out vec4 fragColor; out vec4 fragColor;
@ -18,14 +18,81 @@ uniform sampler2D uDiffuse;
// 4 = InvAlpha — GL inverted-alpha blending; do NOT discard // 4 = InvAlpha — GL inverted-alpha blending; do NOT discard
uniform int uTranslucencyKind; 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() { void main() {
vec4 color = texture(uDiffuse, vTex); vec4 color = texture(uDiffuse, vTex);
// Alpha cutout only for clip-map surfaces (doors, windows, vegetation). // 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; if (uTranslucencyKind == 1 && color.a < 0.5) discard;
// Apply pre-computed Lambert + ambient lighting factor from the vertex shader. vec3 N = normalize(vWorldNormal);
fragColor = vec4(color.rgb * vLightingFactor, color.a); 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; layout(location = 6) in vec4 aInstanceRow3;
uniform mat4 uViewProjection; uniform mat4 uViewProjection;
uniform vec3 uLightDirection; // world-space light direction (points FROM sun, matching ACME)
uniform float uAmbientIntensity;
out vec2 vTex; out vec2 vTex;
out vec3 vWorldNormal; out vec3 vWorldNormal;
out float vLightingFactor; out vec3 vWorldPos;
void main() { void main() {
// Reconstruct the per-instance model matrix from its four row vectors. // Reconstruct the per-instance model matrix from its four row vectors.
@ -30,11 +28,8 @@ void main() {
vec4 worldPos = model * vec4(aPosition, 1.0); vec4 worldPos = model * vec4(aPosition, 1.0);
gl_Position = uViewProjection * worldPos; gl_Position = uViewProjection * worldPos;
vWorldPos = worldPos.xyz;
// Transform normal into world space. // Transform normal into world space.
vWorldNormal = normalize(mat3(model) * aNormal); vWorldNormal = normalize(mat3(model) * aNormal);
vTex = aTexCoord; 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 #version 430 core
// Per-cell terrain blending (Phase 3c.4) — ported from WorldBuilder's // Per-cell terrain blending (Phase 3c.4) — ported from WorldBuilder's
// Landscape.frag, trimmed of editor-specific features (grid, brush, // Landscape.frag, trimmed of editor-specific features (grid, brush,
// walkable-slope highlighting) and with Phase 3a/3b directional lighting // walkable-slope highlighting). Phase G extends this with the shared
// layered on at the end. // SceneLighting UBO driving per-vertex sun bake + fragment-stage fog
// + lightning flash.
in vec2 vBaseUV; in vec2 vBaseUV;
in vec3 vWorldNormal; in vec3 vWorldNormal;
in float vLightingFactor; in vec3 vWorldPos;
in vec3 vLightingRGB;
in vec4 vOverlay0; in vec4 vOverlay0;
in vec4 vOverlay1; in vec4 vOverlay1;
in vec4 vOverlay2; in vec4 vOverlay2;
@ -18,24 +20,34 @@ out vec4 fragColor;
uniform sampler2DArray uTerrain; // 33+ layers — TerrainAtlas.GlTexture uniform sampler2DArray uTerrain; // 33+ layers — TerrainAtlas.GlTexture
uniform sampler2DArray uAlpha; // 8+ layers — TerrainAtlas.GlAlphaTexture 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 // Per-texture tiling repeat count across a cell. WorldBuilder uses
// uTexTiling[36] uploaded from the dats; we default to 1.0 (one tile per // 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 // cell, 8 tiles across a landblock).
// 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.
const float TILE = 1.0; const float TILE = 1.0;
// Three-layer alpha-weighted composite. Each terrain overlay layer // Three-layer alpha-weighted composite.
// contributes based on its own alpha mask; missing layers (h == 0) collapse
// to transparent. Lifted verbatim from WorldBuilder's Landscape.frag.
vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) { vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) {
float a0 = h0 == 0.0 ? 1.0 : t0.a; float a0 = h0 == 0.0 ? 1.0 : t0.a;
float a1 = h1 == 0.0 ? 1.0 : t1.a; float a1 = h1 == 0.0 ? 1.0 : t1.a;
float a2 = h2 == 0.0 ? 1.0 : t2.a; float a2 = h2 == 0.0 ? 1.0 : t2.a;
float aR = 1.0 - (a0 * a1 * a2); float aR = 1.0 - (a0 * a1 * a2);
// avoid divide-by-zero when all three overlays are absent
float aRsafe = max(aR, 1e-6); float aRsafe = max(aR, 1e-6);
a0 = 1.0 - a0; a0 = 1.0 - a0;
a1 = 1.0 - a1; a1 = 1.0 - a1;
@ -82,7 +94,6 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
result = texture(uTerrain, vec3(baseUV * TILE, pRoad0.z)); result = texture(uTerrain, vec3(baseUV * TILE, pRoad0.z));
if (pRoad0.w >= 0.0) { if (pRoad0.w >= 0.0) {
vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w)); 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; result.a = 1.0 - a0.a;
if (h1 > 0.0 && pRoad1.w >= 0.0) { if (h1 > 0.0 && pRoad1.w >= 0.0) {
vec4 a1 = texture(uAlpha, vec3(pRoad1.xy, pRoad1.w)); vec4 a1 = texture(uAlpha, vec3(pRoad1.xy, pRoad1.w));
@ -93,9 +104,18 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
return result; 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() { 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); vec4 baseColor = vec4(0.0);
if (vBaseTexIdx >= 0.0) { if (vBaseTexIdx >= 0.0) {
baseColor = texture(uTerrain, vec3(vBaseUV * TILE, vBaseTexIdx)); baseColor = texture(uTerrain, vec3(vBaseUV * TILE, vBaseTexIdx));
@ -115,9 +135,15 @@ void main() {
vec3 roadMasked = roads.rgb * roads.a; vec3 roadMasked = roads.rgb * roads.a;
vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0); vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0);
// Lighting matching ACME Landscape.frag: // Apply the per-vertex baked sun+ambient.
// litColor = finalColor * (saturate(vLightingFactor) + xAmbient); vec3 lit = rgb * min(vLightingRGB, vec3(1.0));
vec3 litColor = rgb * (clamp(vLightingFactor, 0.0, 1.0) + xAmbient);
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 uView;
uniform mat4 uProjection; 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 vec2 vBaseUV;
out vec3 vWorldNormal; 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". // 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." // Negative .z means "layer not present, skip it in the fragment shader."
out vec4 vOverlay0; out vec4 vOverlay0;
@ -22,6 +39,11 @@ out vec4 vRoad0;
out vec4 vRoad1; out vec4 vRoad1;
flat out float vBaseTexIdx; 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 // Port of WorldBuilder's Landscape.vert unpackOverlayLayer: sentinel-check
// 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's // 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's
// 90° rotation count. // 90° rotation count.
@ -56,13 +78,6 @@ void main() {
// gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a // gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a
// specific order for each split direction; the table below must stay // specific order for each split direction; the table below must stay
// in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches. // 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 vIdx = gl_VertexID % 6;
int corner = 0; int corner = 0;
if (splitDir == 0u) { if (splitDir == 0u) {
@ -90,12 +105,20 @@ void main() {
else baseUV = vec2(0.0, 0.0); else baseUV = vec2(0.0, 0.0);
vBaseUV = baseUV; vBaseUV = baseUV;
// Vertices are baked in world space; normals need no model transform. vWorldPos = aPos;
vWorldNormal = normalize(aNormal); vWorldNormal = normalize(aNormal);
// Lambert diffuse term matching ACME Landscape.vert: // Retail AdjustPlanes bake (r13 §7):
// vLightingFactor = max(0.0, dot(vNormal, -normalize(xLightDirection))); // L = max(N · -sunDir, MIN_FACTOR)
vLightingFactor = max(0.0, dot(vWorldNormal, -normalize(xLightDirection))); // 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); float baseTex = float(aPacked0.x);
if (baseTex >= 254.0) baseTex = -1.0; 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("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection); _shader.SetMatrix4("uProjection", camera.Projection);
// Lighting uniforms matching ACME Landscape.vert/frag. // Phase G: light direction + ambient + fog come from the shared
// LightDirection (0.5, 0.3, -0.3) from ACME GameScene.cs:238. // SceneLighting UBO (binding=1) uploaded by GameWindow once per
// AmbientLightIntensity 0.45 from ACME LandscapeEditorSettings.cs:108. // frame. Terrain bakes per-vertex AdjustPlanes lighting (r13 §7)
var lightDir = Vector3.Normalize(new Vector3(0.5f, 0.3f, -0.3f)); // from the UBO's slot-0 sun + uCellAmbient, then the fragment
_shader.SetVec3("xLightDirection", lightDir); // stage adds fog + lightning flash. No per-program uniforms here.
_shader.SetFloat("xAmbient", 0.45f);
// Terrain atlas on unit 0, alpha atlas on unit 1. // Terrain atlas on unit 0, alpha atlas on unit 1.
_gl.ActiveTexture(TextureUnit.Texture0); _gl.ActiveTexture(TextureUnit.Texture0);

View file

@ -65,6 +65,22 @@ public sealed class GpuWorldState
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId); 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> /// <summary>
/// Store the axis-aligned bounding box for a loaded landblock. Called from /// Store the axis-aligned bounding box for a loaded landblock. Called from
/// the render thread after the terrain mesh is built and uploaded. /// the render thread after the terrain mesh is built and uploaded.

View file

@ -109,6 +109,21 @@ public sealed class WorldSession : IDisposable
/// </summary> /// </summary>
public event Action<HearSpeech.Parsed>? SpeechHeard; 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> /// <summary>
/// Allow re-sending LoginComplete after a portal teleport. The normal /// Allow re-sending LoginComplete after a portal teleport. The normal
/// _loginCompleteSent latch prevents duplicate sends on the initial spawn /// _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 // Step 3: seed ISAAC, send ConnectResponse to port+1, with 200ms race delay
var opt = cr.Optional; 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]; byte[] serverSeedBytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed); BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed);
byte[] clientSeedBytes = new byte[4]; byte[] clientSeedBytes = new byte[4];
@ -393,6 +416,19 @@ public sealed class WorldSession : IDisposable
SendAck(serverHeader.Sequence); 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) foreach (var frag in dec.Packet!.Fragments)
{ {
var body = _assembler.Ingest(frag, out _); 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; namespace AcDream.Core.World;
/// <summary> /// <summary>
/// One sky keyframe — the lighting + fog state for a specific day-fraction. /// Fog modes mirroring retail's <c>D3DFOGMODE</c>. Retail only ever uses
/// Multiple keyframes across [0, 1) interpolate linearly (with angular /// <see cref="Off"/> and <see cref="Linear"/>; the Exp variants are
/// wrap on sun direction) to produce the current sky state. /// 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> /// <para>
/// Retail's <c>SkyTimeOfDay</c> dat struct carries this exact data plus /// Retail's <c>SkyTimeOfDay</c> dat struct carries this exact data plus
/// references to sky objects (sun mesh, moon mesh, cloud layer) which /// references to sky objects (sun mesh, moon mesh, cloud layer) which
/// belong to the renderer. This class exposes the lighting-relevant /// belong to the renderer. This record exposes the shader-relevant
/// subset — sun direction, sun color, ambient color, fog. /// 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> /// </para>
/// </summary> /// </summary>
public readonly record struct SkyKeyframe( public readonly record struct SkyKeyframe(
float Begin, // [0, 1] day-fraction this keyframe kicks in float Begin, // [0, 1] day-fraction this keyframe kicks in
float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W) float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W)
float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith) float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith)
Vector3 SunColor, // RGB linear, post-brightness multiply Vector3 SunColor, // RGB linear, post-brightness multiply
Vector3 AmbientColor, Vector3 AmbientColor, // RGB linear, post-brightness multiply
Vector3 FogColor, 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> /// <summary>
/// Sky keyframe interpolator — given a day fraction in [0, 1), returns /// Sky keyframe interpolator — given a day fraction in [0, 1), returns
@ -42,9 +70,8 @@ public readonly record struct SkyKeyframe(
/// with wrap handling. /// with wrap handling.
/// </description></item> /// </description></item>
/// <item><description> /// <item><description>
/// Lerp every vector component; SLERP the sun direction /// Lerp every vector component; use shortest-arc lerp for the sun
/// quaternions to avoid artifacts when heading wraps (e.g. k1.Heading /// heading so k1=350° → k2=10° doesn't sweep backwards across the sky.
/// = 350°, k2.Heading = 10°).
/// </description></item> /// </description></item>
/// </list> /// </list>
/// </para> /// </para>
@ -64,12 +91,20 @@ public sealed class SkyStateProvider
} }
public int KeyframeCount => _keyframes.Count; public int KeyframeCount => _keyframes.Count;
public IReadOnlyList<SkyKeyframe> Keyframes => _keyframes;
/// <summary> /// <summary>
/// Default keyframe set based on retail observations — sunrise at 6am, /// Default keyframe set based on retail observations — sunrise at 6am,
/// noon at 12pm, sunset at 6pm. Used when the dat-loaded set isn't /// 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 /// available yet or the player is in a region whose Region dat
/// doesn't override it. /// 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> /// </summary>
public static SkyStateProvider Default() public static SkyStateProvider Default()
{ {
@ -83,7 +118,10 @@ public sealed class SkyStateProvider
SunColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue SunColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue
AmbientColor: new Vector3(0.05f, 0.05f, 0.12f), AmbientColor: new Vector3(0.05f, 0.05f, 0.12f),
FogColor: new Vector3(0.02f, 0.02f, 0.05f), FogColor: new Vector3(0.02f, 0.02f, 0.05f),
FogDensity: 0.004f), FogDensity: 0.004f,
FogStart: 30f,
FogEnd: 180f,
FogMode: FogMode.Linear),
new SkyKeyframe( new SkyKeyframe(
Begin: 0.25f, Begin: 0.25f,
SunHeadingDeg: 90f, // east at dawn SunHeadingDeg: 90f, // east at dawn
@ -91,7 +129,10 @@ public sealed class SkyStateProvider
SunColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm SunColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm
AmbientColor: new Vector3(0.4f, 0.35f, 0.3f), AmbientColor: new Vector3(0.4f, 0.35f, 0.3f),
FogColor: new Vector3(0.8f, 0.55f, 0.4f), FogColor: new Vector3(0.8f, 0.55f, 0.4f),
FogDensity: 0.002f), FogDensity: 0.002f,
FogStart: 60f,
FogEnd: 260f,
FogMode: FogMode.Linear),
new SkyKeyframe( new SkyKeyframe(
Begin: 0.5f, Begin: 0.5f,
SunHeadingDeg: 180f, // south at noon 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 SunColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish
AmbientColor: new Vector3(0.5f, 0.5f, 0.55f), AmbientColor: new Vector3(0.5f, 0.5f, 0.55f),
FogColor: new Vector3(0.7f, 0.75f, 0.85f), FogColor: new Vector3(0.7f, 0.75f, 0.85f),
FogDensity: 0.0008f), FogDensity: 0.0008f,
FogStart: 120f,
FogEnd: 500f,
FogMode: FogMode.Linear),
new SkyKeyframe( new SkyKeyframe(
Begin: 0.75f, Begin: 0.75f,
SunHeadingDeg: 270f, // west at dusk SunHeadingDeg: 270f, // west at dusk
@ -107,7 +151,10 @@ public sealed class SkyStateProvider
SunColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red SunColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red
AmbientColor: new Vector3(0.35f, 0.25f, 0.25f), AmbientColor: new Vector3(0.35f, 0.25f, 0.25f),
FogColor: new Vector3(0.85f, 0.45f, 0.35f), 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); u = Math.Clamp(u, 0f, 1f);
// Angular lerp for sun heading: pick shortest arc. // Angular lerp for sun heading: pick shortest arc.
float h1 = k1.SunHeadingDeg; float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u);
float h2 = k2.SunHeadingDeg;
float delta = h2 - h1;
while (delta > 180f) delta -= 360f;
while (delta < -180f) delta += 360f;
float heading = h1 + delta * u;
// Fog mode doesn't interpolate — pick k1's mode (retail uses Linear everywhere).
return new SkyKeyframe( return new SkyKeyframe(
Begin: t, Begin: t,
SunHeadingDeg: heading, 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), SunColor: Vector3.Lerp(k1.SunColor, k2.SunColor, u),
AmbientColor: Vector3.Lerp(k1.AmbientColor, k2.AmbientColor, u), AmbientColor: Vector3.Lerp(k1.AmbientColor, k2.AmbientColor, u),
FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, 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> /// <summary>
@ -185,42 +245,89 @@ public sealed class SkyStateProvider
/// Service that turns server-delivered tick counts into live sky state. /// Service that turns server-delivered tick counts into live sky state.
/// Owns the "current time" clock (seeded from server sync, advanced by /// Owns the "current time" clock (seeded from server sync, advanced by
/// real-time elapsed between syncs). /// 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> /// </summary>
public sealed class WorldTimeService public sealed class WorldTimeService
{ {
private readonly SkyStateProvider _sky; private SkyStateProvider _sky;
private double _lastSyncedTicks; private double _lastSyncedTicks;
private DateTime _lastSyncedWallClockUtc = DateTime.UtcNow; 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) public WorldTimeService(SkyStateProvider sky)
{ {
_sky = sky ?? throw new ArgumentNullException(nameof(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> /// <summary>
/// Set the authoritative tick count from a server TimeSync packet. /// Set the authoritative tick count from a server TimeSync packet.
/// Clears any debug override.
/// </summary> /// </summary>
public void SyncFromServer(double serverTicks) public void SyncFromServer(double serverTicks)
{ {
_lastSyncedTicks = serverTicks; _lastSyncedTicks = serverTicks;
_lastSyncedWallClockUtc = DateTime.UtcNow; _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> /// <summary>
/// Current ticks at <see cref="DateTime.UtcNow"/>, advanced from the /// 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> /// </summary>
public double NowTicks public double NowTicks
{ {
get get
{ {
double elapsed = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds; double elapsed = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds;
return _lastSyncedTicks + elapsed; return _lastSyncedTicks + elapsed * TickSize;
} }
} }
/// <summary>Current day fraction in [0, 1).</summary> /// <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> /// <summary>Current sky lighting state.</summary>
public SkyKeyframe CurrentSky => _sky.Interpolate((float)DayFraction); 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);
}
}