diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index c94df04..4b56a6e 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -43,6 +43,7 @@ | F.2 | Item model + Appraise — ItemRepository with move/equip/property events, AppraiseRequest (0x00C8), IdentifyObjectResponse header, WieldObject + InventoryPutObjInContainer | Tests ✓ | | G.1 | Sky + day/night — DerethDateTime (retail-exact 7620-tick calendar + 16-hour names + PY year), SkyStateProvider (4-keyframe default with angular-wrap lerp), WorldTimeService (server-synced clock with real-time advance) | Tests ✓ | | G.2 | Dynamic lighting (selection) — LightSource + LightManager with retail 8-light cap, range-squared with 1.1× slack, slot 0 reserved for Sun, OwnerId-keyed unregister | Tests ✓ | +| G.1+ | Full sky visuals + weather + dynamic-light shader — SkyDescLoader parses Region 0x13000000 dat keyframes with retail fog fields (start/end/mode); WeatherSystem picks Clear/Overcast/Rain/Snow/Storm deterministically per in-game day with 10s fade; SkyRenderer draws far-plane-1e6 celestial meshes with UV scroll; SceneLightingUbo binds at std140 location=1 with 8 Light slots + fog + lightning flash; terrain.vert + mesh.frag + mesh_instanced.frag + sky.frag all consume the shared UBO; LightingHookSink auto-registers Setup.Lights per entity + flips IsLit on SetLightHook; ParticleRenderer renders rain/snow billboards; F7 cycles day time override, F10 cycles weather; WorldSession surfaces server time via ServerTimeUpdated (ConnectRequest + TimeSync flag) | Tests ✓ | | H.1 | Chat wire layer — Talk (0x0015) / Tell (0x005D) / ChatChannel (0x0147) outbound, HearSpeech (0x02BB local + 0x02BC ranged) inbound, ChatLog ring buffer with adapters for every chat source | Tests ✓ | | Glue | GameEventWiring.WireAll — single-call registration mapping parsed GameEvents → Core state classes (ChatLog, CombatState, Spellbook, ItemRepository); GameWindow exposes state classes + wires them to live session | Tests ✓ | @@ -171,8 +172,8 @@ Research: R1 + R2 + R6 + R8 + UI slices 04/05. Research: R9 + R12 + R13. -- **G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. -- **G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. +- **✓ SHIPPED — G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSession→WorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys. +- **✓ SHIPPED — G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. SceneLightingUbo std140 at binding=1 feeds terrain + mesh + mesh_instanced + sky shaders. LightingHookSink auto-registers Setup.Lights at entity stream-in, flips IsLit on SetLightHook, unregisters on landblock unload. - **G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `LoginComplete` re-send, "pink bubble" loading state. See `r09-dungeon-portal-space.md`. **Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight. @@ -241,7 +242,7 @@ Not detailed here; each gets its own brainstorm when it becomes relevant. | Portals render as a rotating black disk | **Phase E.3** (particle system) | | Chimneys have no smoke | **Phase E.3** | | Houses have no fireplace fire | **Phase E.3** | -| No fireplace / torch lighting | **Phase G.2** (dynamic lighting) | +| No fireplace / torch lighting | **Phase G.2** (shipped; Setup.Lights auto-register, 8-light cap with hard-cutoff) | | Skin/hair color slightly off | **Phase C.3** | | No chat window | **Phase H.1** | | No sound | **Phase E.2** | @@ -250,7 +251,7 @@ Not detailed here; each gets its own brainstorm when it becomes relevant. | Can't cast spells | **Phase F.4** | | No inventory panel | **Phase F.2 + F.5** | | No character creation — must use ACE admin | **Phase H.4** | -| Sky is a flat color | **Phase G.1** (weather + day-night) | +| Sky is a flat color | **Phase G.1** (shipped; F7 cycles time, F10 cycles weather) | | Can't join allegiance | **Phase H.2** | | No quest tracker | **Phase H.3** | diff --git a/src/AcDream.App/Rendering/DebugOverlay.cs b/src/AcDream.App/Rendering/DebugOverlay.cs index 57ab741..db2ba2c 100644 --- a/src/AcDream.App/Rendering/DebugOverlay.cs +++ b/src/AcDream.App/Rendering/DebugOverlay.cs @@ -101,7 +101,14 @@ public sealed class DebugOverlay int StreamingRadius, float MouseSensitivity, float ChaseDistance, - bool RmbOrbit); + bool RmbOrbit, + // Phase G.1/G.2 — sky + weather + lighting + string HourName = "", + float DayFraction = 0f, + string Weather = "Clear", + int ActiveLights = 0, + int RegisteredLights = 0, + int ParticleCount = 0); public DebugOverlay(TextRenderer text, BitmapFont font) { @@ -205,6 +212,10 @@ public sealed class DebugOverlay ($"lb {s.LandblocksVisible,3}/{s.LandblocksTotal,3} visible", White), ($"ent {s.EntityCount,4} anim {s.AnimatedCount,3}", White), ($"coll {s.ShadowObjectCount,5} radius {s.StreamingRadius}", White), + // Phase G: sky + weather + dynamic lighting surface. + ($"time {s.DayFraction,5:F2} {s.HourName}", Cyan), + ($"wx {s.Weather,-8} parts {s.ParticleCount,5}", Cyan), + ($"lit {s.ActiveLights}/{s.RegisteredLights} ", Cyan), }; float pad = 10f; @@ -277,6 +288,8 @@ public sealed class DebugOverlay ("F4", "toggle debug HUD info panel"), ("F5", "toggle stats panel"), ("F6", "toggle compass"), + ("F7", "cycle time-of-day override (none/midnight/dawn/noon/dusk)"), + ("F10", "cycle weather (clear/overcast/rain/snow/storm)"), ("F", "toggle fly camera"), ("Tab", "toggle player mode (requires login)"), ("W A S D", "move (player mode) / fly"), @@ -388,7 +401,7 @@ public sealed class DebugOverlay private void DrawHintBar(Vector2 screenSize) { - string hint = "F1 help F2 wireframes F3 dump F4/F5/F6 panels F8/F9 sens Tab player Hold RMB orbit Wheel zoom"; + string hint = "F1 help F2 wires F3 dump F4/F5/F6 panels F7 time F8/F9 sens F10 weather Tab player RMB orbit Wheel zoom"; float w = _font.MeasureWidth(hint); float pad = 10f; float y = screenSize.Y - _font.LineHeight - pad; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 656ff93..540d0e2 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -140,6 +140,7 @@ public sealed class GameWindow : IDisposable private readonly AcDream.Core.Vfx.EmitterDescRegistry _emitterRegistry = new(); private AcDream.Core.Vfx.ParticleSystem? _particleSystem; private AcDream.Core.Vfx.ParticleHookSink? _particleSink; + private AcDream.App.Rendering.ParticleRenderer? _particleRenderer; // Remote-entity motion inference: tracks when each remote entity last // moved meaningfully. Used in TickAnimations to swap to Ready when @@ -240,6 +241,32 @@ public sealed class GameWindow : IDisposable new AcDream.Core.World.WorldTimeService( AcDream.Core.World.SkyStateProvider.Default()); public readonly AcDream.Core.Lighting.LightManager Lighting = new(); + public readonly AcDream.Core.World.WeatherSystem Weather = new(); + // Wired into the hook router in OnLoad so SetLightHook fires + // from the animation pipeline flip the matching LightSource.IsLit. + private AcDream.Core.Lighting.LightingHookSink? _lightingSink; + + // Phase G.1 sky renderer + shared UBO. Created once the GL context + // exists in OnLoad; shared across every other renderer via + // binding = 1 so terrain/mesh/instanced/sky all read the same + // sun / ambient / fog / flash data per frame. + private AcDream.App.Rendering.SceneLightingUboBinding? _sceneLightingUbo; + private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer; + private AcDream.Core.World.LoadedSkyDesc? _loadedSkyDesc; + + // Current rain/snow emitter handles — spawned on weather-kind change + // and stopped when the kind leaves Rain/Snow. Non-zero == active. + private int _rainEmitterHandle; + private int _snowEmitterHandle; + private AcDream.Core.World.WeatherKind _lastWeatherKind = + AcDream.Core.World.WeatherKind.Clear; + private double _weatherAccum; + + // F7 / F10 debug-cycle steps for time + weather. Initialized out of + // range of the real values so the first press hits index 0 of the + // cycle table cleanly. + private int _timeDebugStep = 0; + private int _weatherDebugStep = 0; // Phase B.2: player movement mode. private AcDream.App.Input.PlayerMovementController? _playerController; @@ -428,6 +455,51 @@ public sealed class GameWindow : IDisposable _debugOverlay.Toast($"Compass {(_debugOverlay.ShowCompass ? "ON" : "OFF")}"); } } + else if (key == Key.F7) + { + // Phase G.1: cycle debug time-of-day overrides. Useful for + // visually verifying the sun arc + keyframe transitions + // without waiting 30+ real-time hours. Cycle order: + // clear debug → 0.0 (midnight) → 0.25 (dawn) + // → 0.5 (noon) → 0.75 (dusk) → clear + _timeDebugStep = (_timeDebugStep + 1) % 5; + float? pick = _timeDebugStep switch + { + 0 => (float?)null, // server time + 1 => 0.0f, + 2 => 0.25f, + 3 => 0.5f, + 4 => 0.75f, + _ => null, + }; + if (pick.HasValue) + { + WorldTime.SetDebugTime(pick.Value); + _debugOverlay?.Toast($"Time override = {pick.Value:F2}"); + } + else + { + WorldTime.ClearDebugTime(); + _debugOverlay?.Toast("Time override cleared"); + } + } + else if (key == Key.F10) + { + // Phase G.1: cycle weather kinds manually. Useful for + // testing the rain/snow particle systems + storm/light + // fog without waiting for the daily RNG to hit. + var kinds = new[] + { + AcDream.Core.World.WeatherKind.Clear, + AcDream.Core.World.WeatherKind.Overcast, + AcDream.Core.World.WeatherKind.Rain, + AcDream.Core.World.WeatherKind.Snow, + AcDream.Core.World.WeatherKind.Storm, + }; + _weatherDebugStep = (_weatherDebugStep + 1) % kinds.Length; + Weather.ForceWeather(kinds[_weatherDebugStep]); + _debugOverlay?.Toast($"Weather = {kinds[_weatherDebugStep]}"); + } else if (key == Key.F8 || key == Key.F9) { // Adjust whichever mode's sensitivity is currently active. @@ -645,6 +717,12 @@ public sealed class GameWindow : IDisposable Path.Combine(shadersDir, "mesh_instanced.vert"), Path.Combine(shadersDir, "mesh_instanced.frag")); + // Phase G.1/G.2: shared scene-lighting UBO. Stays bound at + // binding=1 for the lifetime of the process — every shader that + // declares `layout(std140, binding = 1) uniform SceneLighting` + // reads from this without further intervention. + _sceneLightingUbo = new SceneLightingUboBinding(_gl); + _debugLines = new DebugLineRenderer(_gl, shadersDir); // Debug HUD: load a system monospace font and set up the text overlay. @@ -684,6 +762,12 @@ public sealed class GameWindow : IDisposable _particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem); _hookRouter.Register(_particleSink); + // Phase G.2 lighting hooks: SetLightHook flips IsLit on + // owner-tagged lights so ignite-torch animations light up, + // extinguish-torch animations go dark. + _lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting); + _hookRouter.Register(_lightingSink); + // Phase E.2 audio: init OpenAL + hook sink. Suppressible via // ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers. if (Environment.GetEnvironmentVariable("ACDREAM_NO_AUDIO") != "1") @@ -719,6 +803,35 @@ public sealed class GameWindow : IDisposable if (heightTable is null || heightTable.Length < 256) throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated"); + // Phase G.1: parse the full sky descriptor (day groups, keyframes, + // celestial mesh layers) and swap WorldTime's provider over to the + // dat-backed keyframes. The stub default provider is only used if + // the Region lacks HasSkyInfo. + if (region is not null) + { + _loadedSkyDesc = AcDream.Core.World.SkyDescLoader.LoadFromRegion(region); + if (_loadedSkyDesc is not null) + { + WorldTime.SetProvider(_loadedSkyDesc.BuildDefaultProvider()); + WorldTime.TickSize = _loadedSkyDesc.TickSize > 0 ? _loadedSkyDesc.TickSize : 1.0; + Console.WriteLine( + $"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " + + $"TickSize={_loadedSkyDesc.TickSize}, LightTickSize={_loadedSkyDesc.LightTickSize}"); + if (_loadedSkyDesc.DefaultDayGroup is not null) + { + Console.WriteLine( + $"sky: default group '{_loadedSkyDesc.DefaultDayGroup.Name}' has " + + $"{_loadedSkyDesc.DefaultDayGroup.SkyObjects.Count} sky objects, " + + $"{_loadedSkyDesc.DefaultDayGroup.SkyTimes.Count} keyframes"); + } + } + } + + // Seed WorldTime to noon so outdoor scenes aren't pitch-black before + // the server sends its first TimeSync packet (offline rendering in + // particular never receives one). + WorldTime.SyncFromServer(AcDream.Core.World.DerethDateTime.DayTicks * 0.5); + // Build the terrain atlas once from the Region dat. var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats); @@ -755,6 +868,21 @@ public sealed class GameWindow : IDisposable _textureCache = new TextureCache(_gl, _dats); _staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache); + // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) + // with depth writes off + far plane 1e6 so celestial meshes + // never clip. Shares the TextureCache with the static pipeline. + var skyShader = new Shader(_gl, + Path.Combine(shadersDir, "sky.vert"), + Path.Combine(shadersDir, "sky.frag")); + _skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer( + _gl, _dats, skyShader, _textureCache); + + // Phase G.1 particle renderer — renders rain / snow / spell auras + // spawned into the shared ParticleSystem as billboard quads. + // Weather uses AttachLocal emitters so the rain volume follows + // the player. + _particleRenderer = new ParticleRenderer(_gl, shadersDir); + // Phase A.1: replace the one-shot 3×3 preload with a streaming controller. // Parse runtime radius from environment (default 2 → 5×5 window). // Values outside [0, 8] fall back to the field default of 2. @@ -778,6 +906,16 @@ public sealed class GameWindow : IDisposable radius: _streamingRadius, removeTerrain: id => { + // Phase G.2: release any LightSources attached to entities + // in this landblock before their records disappear from + // _worldState — otherwise the LightManager accumulates + // stale entries for every walk across a landblock boundary. + if (_lightingSink is not null && + _worldState.TryGetLandblock(id, out var lb)) + { + foreach (var ent in lb!.Entities) + _lightingSink.UnregisterOwner(ent.Id); + } _terrain?.RemoveLandblock(id); _physicsEngine.RemoveLandblock(id); _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); @@ -816,6 +954,11 @@ public sealed class GameWindow : IDisposable _liveSession.PositionUpdated += OnLivePositionUpdated; _liveSession.TeleportStarted += OnTeleportStarted; + // Phase G.1: keep the client's day/night clock in sync with + // server time. Fires once from ConnectRequest (initial seed) + // and repeatedly on TimeSync-flagged packets. + _liveSession.ServerTimeUpdated += ticks => WorldTime.SyncFromServer(ticks); + // Phase F.1-H.1: wire every parsed GameEvent into the right // Core state class (chat, combat, spellbook, items). After // this one call, server-sent ChannelBroadcast / damage @@ -2446,6 +2589,32 @@ public sealed class GameWindow : IDisposable int scTried = 0, scHaveBounds = 0, scRegistered = 0, scTooThin = 0, scNoBounds = 0; foreach (var entity in lb.Entities) { + // Phase G.2: if the entity's Setup has baked-in LightInfos, + // register them with the LightManager so torches, braziers, + // and lifestones cast real light on nearby geometry. Hooked + // via the LightingHookSink so per-entity owner tracking + + // SetLightHook IsLit toggles all go through one codepath. + // Only applies to Setup-sourced entities (0x02xxxxxx) — raw + // GfxObjs don't carry Lights dictionaries. + if (_lightingSink is not null && _dats is not null) + { + uint src = entity.SourceGfxObjOrSetupId; + if ((src & 0xFF000000u) == 0x02000000u) + { + var datSetup = _dats.Get(src); + if (datSetup is not null && datSetup.Lights.Count > 0) + { + var loaded = AcDream.Core.Lighting.LightInfoLoader.Load( + datSetup, + ownerId: entity.Id, + entityPosition: entity.Position, + entityRotation: entity.Rotation); + foreach (var ls in loaded) + _lightingSink.RegisterOwnedLight(ls); + } + } + } + int entityBsp = 0, entityCyl = 0; // Treat both procedural scenery (0x80000000+) AND LandBlockInfo // stabs (IDs < 0x40000000 with 0x01/0x02 source) as outdoor-entities @@ -3069,6 +3238,24 @@ public sealed class GameWindow : IDisposable private void OnRender(double deltaSeconds) { + // Phase G.1: set the clear color from the current sky's fog + // tint so the horizon band continues naturally past the + // rendered geometry. Fog blends to this color at max distance + // so there's no visible seam. Updated each frame from the + // interpolated keyframe. + var kf = WorldTime.CurrentSky; + var atmo = Weather.Snapshot(in kf); + var fogColor = atmo.FogColor; + // Clamp to 0..1 — keyframes may store over-1 values (retail uses the + // dir-bright scalar pre-multiplied into color) and GL's ClearColor + // will silently accept them, but some drivers interpret > 1 as + // "bright clamp", producing ugly pink/green frames. + _gl!.ClearColor( + System.Math.Clamp(fogColor.X, 0f, 1f), + System.Math.Clamp(fogColor.Y, 0f, 1f), + System.Math.Clamp(fogColor.Z, 0f, 1f), + 1f); + _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); // Phase 6.4: advance per-entity animation playback before drawing @@ -3076,6 +3263,21 @@ public sealed class GameWindow : IDisposable if (_animatedEntities.Count > 0) TickAnimations((float)deltaSeconds); + // Phase G.1: weather state machine — deterministic per-day roll + // + transitions + lightning flash. + var cal = WorldTime.CurrentCalendar; + int dayIndex = cal.Year * (AcDream.Core.World.DerethDateTime.DaysInAMonth * + AcDream.Core.World.DerethDateTime.MonthsInAYear) + + (int)cal.Month * AcDream.Core.World.DerethDateTime.DaysInAMonth + + (cal.Day - 1); + Weather.Tick(nowSeconds: _weatherAccum, dayIndex: dayIndex, dtSeconds: (float)deltaSeconds); + _weatherAccum += deltaSeconds; + + // Update the rain/snow particle emitters when the weather kind + // changes. Keep the emitters fed by the ParticleSystem tick so + // visuals stay alive frame-over-frame. + UpdateWeatherParticles(atmo); + // Phase E.3: advance live particle emitters AFTER animation tick // so emitters spawned by hooks fired this frame get integrated. _particleSystem?.Tick((float)deltaSeconds); @@ -3088,26 +3290,14 @@ public sealed class GameWindow : IDisposable var camera = _cameraController.Active; var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection); - // Never cull the landblock the player is currently on. - uint? playerLb = null; - if (_playerMode && _playerController is not null) - { - var pp = _playerController.Position; - int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); - int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); - playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); - } - _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); - - // Step 4: portal visibility — determine which interior cells to render. - // Extract camera world position from the inverse of the view matrix. + // Extract camera world position from the inverse of the view + // matrix — needed by the scene-lighting UBO (for fog distance) + // and by the sky renderer (for the camera-centered sky dome). System.Numerics.Matrix4x4.Invert(camera.View, out var invView); var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43); // Phase E.2 audio: update listener pose so 3D sounds pan/attenuate - // correctly relative to where we're looking. Fwd = -Z of the view - // matrix (OpenGL convention), up = +Y. Both live in the inverse - // view matrix's basis vectors. + // correctly relative to where we're looking. if (_audioEngine is not null && _audioEngine.IsAvailable) { var fwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33); @@ -3118,9 +3308,44 @@ public sealed class GameWindow : IDisposable up.X, up.Y, up.Z); } + // Step 4: portal visibility — compute BEFORE the UBO upload so + // the indoor flag drives the sun's intensity to zero for + // dungeons (r13 §13.7). var visibility = _cellVisibility.ComputeVisibility(camPos); bool cameraInsideCell = visibility?.CameraCell is not null; + // Phase G.1/G.2: feed the sun, tick LightManager, build + upload + // the scene-lighting UBO once per frame. Every shader that + // consumes binding=1 reads the same data for the rest of the + // frame — terrain, static mesh, instanced mesh, sky. + UpdateSunFromSky(kf, cameraInsideCell); + Lighting.Tick(camPos); + var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( + Lighting, in atmo, camPos, (float)WorldTime.DayFraction); + _sceneLightingUbo?.Upload(ubo); + + // Never cull the landblock the player is currently on. + uint? playerLb = null; + if (_playerMode && _playerController is not null) + { + var pp = _playerController.Position; + int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); + int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); + playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); + } + + // Phase G.1: sky renderer — draws the far-plane-infinity + // celestial meshes FIRST so the rest of the scene z-tests + // on top of them (depth mask off, no depth writes). Skipped + // when indoors; dungeons fully block sky visibility. + if (!cameraInsideCell) + { + _skyRenderer?.Render(camera, camPos, (float)WorldTime.DayFraction, + _loadedSkyDesc?.DefaultDayGroup); + } + + _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); + // Conditional depth clear: when camera is inside a building, clear // depth (not color) so interior geometry writes fresh Z values on top // of the terrain color buffer. Exit portals show outdoor terrain color @@ -3132,6 +3357,13 @@ public sealed class GameWindow : IDisposable neverCullLandblockId: playerLb, visibleCellIds: visibility?.VisibleCellIds); + // Phase G.1 / E.3: draw all live particles after opaque + // scene geometry so alpha blending composites correctly. + // Runs with depth test on (particles occluded by walls) + // but depth write off (no self-occlusion sorting needed). + if (_particleSystem is not null && _particleRenderer is not null) + _particleRenderer.Draw(_particleSystem, camera, camPos); + // Debug: draw collision shapes as wireframe cylinders around the // player so we can visually verify alignment with scenery meshes. if (_debugCollisionVisible && _debugLines is not null) @@ -3276,6 +3508,8 @@ public sealed class GameWindow : IDisposable else activeSens = _sensOrbit; + // Phase G: pull sky + weather + lighting state for the overlay. + var dayCal = WorldTime.CurrentCalendar; var snapshot = new DebugOverlay.Snapshot( Fps: (float)_lastFps, FrameTimeMs: (float)_lastFrameMs, @@ -3298,7 +3532,13 @@ public sealed class GameWindow : IDisposable StreamingRadius: _streamingRadius, MouseSensitivity: activeSens, ChaseDistance: _chaseCamera?.Distance ?? 0f, - RmbOrbit: _rmbHeld); + RmbOrbit: _rmbHeld, + HourName: dayCal.Hour.ToString(), + DayFraction: (float)WorldTime.DayFraction, + Weather: Weather.Kind.ToString(), + ActiveLights: Lighting.ActiveCount, + RegisteredLights: Lighting.RegisteredCount, + ParticleCount: _particleSystem?.ActiveParticleCount ?? 0); _debugOverlay.Update((float)deltaSeconds); var size = new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y); @@ -3790,6 +4030,163 @@ public sealed class GameWindow : IDisposable ae.CurrFrame = ae.LowFrame; } + /// + /// Derive the current sun (directional light, slot 0 of the UBO) + /// from the interpolated , + /// plus the cell ambient. Indoor cells force the sun intensity to + /// zero (r13 §13.7) and substitute a fixed dungeon-tone ambient. + /// + private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool cameraInsideCell) + { + // Sun direction: points FROM the sun TOWARDS the world. Our + // shader does dot(N, -forward) so a positive N·L means the + // surface faces the sun. + var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf); + + if (cameraInsideCell) + { + // Dungeon default per r13 §3 — warm-dark ambient, no sun. + Lighting.Sun = new AcDream.Core.Lighting.LightSource + { + Kind = AcDream.Core.Lighting.LightKind.Directional, + WorldForward = sunToWorld, + ColorLinear = System.Numerics.Vector3.Zero, + Intensity = 0f, + Range = 1f, + }; + Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState( + AmbientColor: new System.Numerics.Vector3(0.10f, 0.09f, 0.08f), + SunColor: System.Numerics.Vector3.Zero, + SunDirection: sunToWorld); + } + else + { + // Outdoor: full keyframe sun + ambient; colors are already + // pre-multiplied by DirBright / AmbBright inside + // SkyDescLoader so we feed them straight into the UBO. + Lighting.Sun = new AcDream.Core.Lighting.LightSource + { + Kind = AcDream.Core.Lighting.LightKind.Directional, + WorldForward = sunToWorld, + ColorLinear = kf.SunColor, + Intensity = 1f, + Range = 1f, + }; + Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState( + AmbientColor: kf.AmbientColor, + SunColor: kf.SunColor, + SunDirection: sunToWorld); + } + } + + /// + /// Keep the rain/snow camera-anchored emitters aligned with the + /// current weather state. Spawns on entry, stops on exit, with no + /// per-frame churn while the state is stable. Emitters are camera- + /// local () + /// so walking never leaves the rain volume (r12 §7). + /// + private void UpdateWeatherParticles(in AcDream.Core.World.AtmosphereSnapshot atmo) + { + if (_particleSystem is null) return; + + if (atmo.Kind == _lastWeatherKind) return; // no change + + // Stop any existing emitters first. + if (_rainEmitterHandle != 0) + { + _particleSystem.StopEmitter(_rainEmitterHandle, fadeOut: true); + _rainEmitterHandle = 0; + } + if (_snowEmitterHandle != 0) + { + _particleSystem.StopEmitter(_snowEmitterHandle, fadeOut: true); + _snowEmitterHandle = 0; + } + + // Anchor at camera world position; AttachLocal keeps it moving. + var anchor = System.Numerics.Vector3.Zero; + if (_cameraController is not null) + { + System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var inv); + anchor = new System.Numerics.Vector3(inv.M41, inv.M42, inv.M43); + } + + switch (atmo.Kind) + { + case AcDream.Core.World.WeatherKind.Rain: + case AcDream.Core.World.WeatherKind.Storm: + _rainEmitterHandle = _particleSystem.SpawnEmitter( + BuildRainDesc(), anchor); + break; + case AcDream.Core.World.WeatherKind.Snow: + _snowEmitterHandle = _particleSystem.SpawnEmitter( + BuildSnowDesc(), anchor); + break; + } + + _lastWeatherKind = atmo.Kind; + } + + /// + /// Rain emitter tuned per r12 §7: streaks falling at ~50 m/s with + /// a slight wind bias, 500 drops/sec, 2000 max alive, 1.2s life so + /// drops cover the ~60m fall at terminal velocity. + /// + private static AcDream.Core.Vfx.EmitterDesc BuildRainDesc() => new() + { + DatId = 0xFFFF_0001u, // synthetic id + Type = AcDream.Core.Vfx.ParticleType.LocalVelocity, + Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal | + AcDream.Core.Vfx.EmitterFlags.Billboard, + EmitRate = 500f, + MaxParticles = 2000, + LifetimeMin = 1.0f, + LifetimeMax = 1.4f, + OffsetDir = new System.Numerics.Vector3(0, 0, 1), + MinOffset = 0f, + MaxOffset = 50f, + SpawnDiskRadius = 15f, + InitialVelocity = new System.Numerics.Vector3(0.5f, 0f, -50f), + VelocityJitter = 2f, + Gravity = System.Numerics.Vector3.Zero, + StartColorArgb = 0x40B0C0E0u, + EndColorArgb = 0x20B0C0E0u, + StartAlpha = 0.3f, + EndAlpha = 0f, + StartSize = 0.05f, + EndSize = 0.05f, + }; + + /// + /// Snow emitter tuned per r12 §8: slow fall at ~2 m/s, tumbling + /// sideways drift, small billboards, 100 flakes/sec, long lifespan. + /// + private static AcDream.Core.Vfx.EmitterDesc BuildSnowDesc() => new() + { + DatId = 0xFFFF_0002u, + Type = AcDream.Core.Vfx.ParticleType.LocalVelocity, + Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal | + AcDream.Core.Vfx.EmitterFlags.Billboard, + EmitRate = 100f, + MaxParticles = 1000, + LifetimeMin = 4f, + LifetimeMax = 8f, + OffsetDir = new System.Numerics.Vector3(0, 0, 1), + MinOffset = 0f, + MaxOffset = 30f, + SpawnDiskRadius = 15f, + InitialVelocity = new System.Numerics.Vector3(0.3f, 0.2f, -2f), + VelocityJitter = 0.8f, + Gravity = System.Numerics.Vector3.Zero, + StartColorArgb = 0xE0FFFFFFu, + EndColorArgb = 0x80FFFFFFu, + StartAlpha = 0.85f, + EndAlpha = 0.3f, + StartSize = 0.08f, + EndSize = 0.06f, + }; + private void OnClosing() { // Phase A.1: join the streamer worker thread before tearing down GL @@ -3803,6 +4200,9 @@ public sealed class GameWindow : IDisposable _meshShader?.Dispose(); _terrain?.Dispose(); _shader?.Dispose(); + _sceneLightingUbo?.Dispose(); + _skyRenderer?.Dispose(); + _particleRenderer?.Dispose(); _debugLines?.Dispose(); _textRenderer?.Dispose(); _debugFont?.Dispose(); diff --git a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs index 0034e50..18a67ae 100644 --- a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs +++ b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs @@ -159,10 +159,10 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable var vp = camera.View * camera.Projection; _shader.SetMatrix4("uViewProjection", vp); - // Lighting uniforms matching ACME StaticObject.vert. - var lightDir = Vector3.Normalize(new Vector3(0.5f, 0.3f, -0.3f)); - _shader.SetVec3("uLightDirection", lightDir); - _shader.SetFloat("uAmbientIntensity", 0.45f); + // Phase G: lighting + ambient + fog are owned by the + // SceneLighting UBO (binding=1) uploaded once per frame by + // GameWindow. The instanced mesh fragment shader reads it + // directly — no per-draw uniform uploads needed. // ── Collect and group instances ─────────────────────────────────────── CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds); diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs new file mode 100644 index 0000000..7128694 --- /dev/null +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -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; + +/// +/// 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. +/// +/// +/// 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. +/// +/// +/// +/// Emitters tagged with 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. +/// +/// +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); + } + + /// + /// 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. + /// + 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(32); + var addGroup = new List(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(); + } +} diff --git a/src/AcDream.App/Rendering/SceneLightingUboBinding.cs b/src/AcDream.App/Rendering/SceneLightingUboBinding.cs new file mode 100644 index 0000000..92f7e8d --- /dev/null +++ b/src/AcDream.App/Rendering/SceneLightingUboBinding.cs @@ -0,0 +1,67 @@ +using System; +using System.Runtime.InteropServices; +using AcDream.Core.Lighting; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// GL wrapper that owns the SceneLighting UBO buffer, updates its +/// contents each frame, and keeps it bound at binding=1 so every +/// shader sampling uLights[] / uFogColor / etc reads +/// consistent data without per-shader re-upload. +/// +/// +/// Usage (r12 §13.2 + r13 §12.3): +/// +/// Instantiate once at startup, after the GL context exists. +/// Each frame, after , call with a freshly-built . +/// Shader programs that declare layout(std140, binding = 1) uniform SceneLighting { ... } automatically pick up the data. +/// +/// +/// +public sealed unsafe class SceneLightingUboBinding : IDisposable +{ + private readonly GL _gl; + private readonly uint _ubo; + private bool _disposed; + + public SceneLightingUboBinding(GL gl) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _ubo = _gl.GenBuffer(); + _gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo); + // Pre-allocate with the final size; BufferSubData each frame. + _gl.BufferData( + BufferTargetARB.UniformBuffer, + (nuint)SceneLightingUbo.SizeInBytes, + (void*)0, + BufferUsageARB.DynamicDraw); + _gl.BindBuffer(BufferTargetARB.UniformBuffer, 0); + + // Bind the buffer to the chosen binding point exactly once — shaders + // that declare this binding in their layout block will read from it + // on every draw without further intervention. + _gl.BindBufferBase(BufferTargetARB.UniformBuffer, + SceneLightingUbo.BindingPoint, _ubo); + } + + /// + /// Push the current frame's UBO contents to the GPU. Cheap (576 bytes) + /// so fine to call unconditionally every frame. + /// + public void Upload(SceneLightingUbo data) + { + _gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo); + _gl.BufferSubData(BufferTargetARB.UniformBuffer, + (nint)0, (nuint)SceneLightingUbo.SizeInBytes, &data); + _gl.BindBuffer(BufferTargetARB.UniformBuffer, 0); + } + + public void Dispose() + { + if (_disposed) return; + _gl.DeleteBuffer(_ubo); + _disposed = true; + } +} diff --git a/src/AcDream.App/Rendering/Shaders/mesh.frag b/src/AcDream.App/Rendering/Shaders/mesh.frag index f8570ac..94cc31f 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh.frag @@ -1,6 +1,7 @@ #version 430 core in vec2 vTex; in vec3 vWorldNormal; +in vec3 vWorldPos; out vec4 fragColor; uniform sampler2D uDiffuse; @@ -11,35 +12,114 @@ uniform sampler2D uDiffuse; // 2 = AlphaBlend — GL blending handles compositing; do NOT discard // 3 = Additive — GL additive blending; do NOT discard // 4 = InvAlpha — GL inverted-alpha blending; do NOT discard -// -// Only ClipMap uses the alpha-discard path. AlphaBlend/Additive/InvAlpha -// rely entirely on the GL blend stage — discarding low-alpha fragments -// would make semi-transparent surfaces (portals, glows) fully invisible. uniform int uTranslucencyKind; -// Phase 3a: simple directional lighting. A single sun direction + ambient term -// gives scenery and building faces enough differentiation to read as 3D instead -// of looking like paper cutouts. Hardcoded for now; a later phase can route -// light parameters through uniforms driven by the game's time-of-day. -// Sun direction tuned after Phase 3a verification: (0.4,0.3,0.8) was too -// vertical — roofs and ground both landed near peak brightness and only -// walls dropped, so the contrast was hard to read through textures. More -// oblique + lower ambient + higher diffuse = contrast ratio ~3.3x. -const vec3 SUN_DIR = normalize(vec3(0.5, 0.4, 0.6)); -const float AMBIENT = 0.25; -const float DIFFUSE = 0.75; +// ───────────────────────────────────────────────────────────── +// Phase G.1+G.2: shared scene-lighting UBO (binding = 1). +// +// Layout mirrors SceneLightingUbo in C#: +// struct Light { +// vec4 posAndKind; xyz = world pos, w = kind (0=dir,1=point,2=spot) +// vec4 dirAndRange; xyz = forward, w = range (metres, hard cutoff) +// vec4 colorAndIntensity; xyz = RGB linear, w = intensity +// vec4 coneAngleEtc; x = cone (rad), yzw = reserved +// }; +// layout(std140, binding = 1) uniform SceneLighting { +// Light uLights[8]; +// vec4 uCellAmbient; xyz = ambient RGB, w = active count +// vec4 uFogParams; x = start, y = end, z = flash, w = mode +// vec4 uFogColor; xyz = color +// vec4 uCameraAndTime; xyz = camera pos, w = day fraction +// }; +// ───────────────────────────────────────────────────────────── +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; + +// Retail hard-cutoff lighting equation (r13 §10.2). No distance +// attenuation inside Range; hard edge at Range; spotlights use a +// binary cos-cone test. This is deliberate — the retail "bubble of +// light" look relies on crisp boundaries. +vec3 accumulateLights(vec3 N, vec3 worldPos) { + vec3 lit = uCellAmbient.xyz; + int active = int(uCellAmbient.w); + for (int i = 0; i < 8; ++i) { + if (i >= active) break; + + int kind = int(uLights[i].posAndKind.w); + vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w; + + if (kind == 0) { + // Directional: "forward" is the light's direction vector + // pointing INTO the scene. N·(-forward) = light-facing. + vec3 Ldir = -uLights[i].dirAndRange.xyz; + float ndl = max(0.0, dot(N, Ldir)); + lit += Lcol * ndl; + } else { + // Point / spot: falloff is a HARD bubble at Range. + vec3 toL = uLights[i].posAndKind.xyz - worldPos; + float d = length(toL); + float range = uLights[i].dirAndRange.w; + if (d < range && range > 1e-3) { + vec3 Ldir = toL / max(d, 1e-4); + float ndl = max(0.0, dot(N, Ldir)); + float atten = 1.0; // retail: no attenuation inside Range + if (kind == 2) { + // Spotlight: hard-edged cos-cone test. + float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); + float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); + atten *= (cos_l > cos_edge) ? 1.0 : 0.0; + } + lit += Lcol * ndl * atten; + } + } + } + return lit; +} + +// Linear fog (r12 §5.1): mode 1 = LINEAR, 0 = off, others reserved. +vec3 applyFog(vec3 lit, vec3 worldPos) { + int mode = int(uFogParams.w); + if (mode == 0) return lit; + float d = length(worldPos - uCameraAndTime.xyz); + float fogStart = uFogParams.x; + float fogEnd = uFogParams.y; + float span = max(1e-3, fogEnd - fogStart); + float fog = clamp((d - fogStart) / span, 0.0, 1.0); + return mix(lit, uFogColor.xyz, fog); +} void main() { vec4 sampled = texture(uDiffuse, vTex); // Alpha cutout only for clip-map surfaces (doors, windows, vegetation). - // Blended surface types (AlphaBlend, Additive, InvAlpha) must NOT - // discard here — that would make every semi-transparent pixel invisible - // before the blend stage even runs. if (uTranslucencyKind == 1 && sampled.a < 0.5) discard; vec3 N = normalize(vWorldNormal); - float ndotl = max(dot(N, SUN_DIR), 0.0); - float lighting = AMBIENT + DIFFUSE * ndotl; - fragColor = vec4(sampled.rgb * lighting, sampled.a); + vec3 lit = accumulateLights(N, vWorldPos); + + // Lightning flash (r12 §9) — additive cold-white pulse layered on top + // of diffuse lighting. + float flash = uFogParams.z; + lit += flash * vec3(0.6, 0.6, 0.75); + + // Clamp per-channel to 1.0 — matches retail (r13 §13.1). + lit = min(lit, vec3(1.0)); + + vec3 rgb = sampled.rgb * lit; + + // Atmospheric fog — applied after lighting. + rgb = applyFog(rgb, vWorldPos); + + fragColor = vec4(rgb, sampled.a); } diff --git a/src/AcDream.App/Rendering/Shaders/mesh.vert b/src/AcDream.App/Rendering/Shaders/mesh.vert index 509ee49..8f9134f 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh.vert @@ -9,6 +9,7 @@ uniform mat4 uProjection; out vec2 vTex; out vec3 vWorldNormal; +out vec3 vWorldPos; void main() { vTex = aTex; @@ -17,5 +18,7 @@ void main() { // scale would require the inverse transpose; we accept that as a Phase 3+ // concern. vWorldNormal = normalize(mat3(uModel) * aNormal); - gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0); + vec4 world = uModel * vec4(aPos, 1.0); + vWorldPos = world.xyz; + gl_Position = uProjection * uView * world; } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_instanced.frag b/src/AcDream.App/Rendering/Shaders/mesh_instanced.frag index 0ad961b..6199aa4 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_instanced.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_instanced.frag @@ -2,7 +2,7 @@ in vec2 vTex; in vec3 vWorldNormal; -in float vLightingFactor; +in vec3 vWorldPos; out vec4 fragColor; @@ -18,14 +18,81 @@ uniform sampler2D uDiffuse; // 4 = InvAlpha — GL inverted-alpha blending; do NOT discard uniform int uTranslucencyKind; +// Phase G.1+G.2: shared scene-lighting UBO (see mesh.frag for layout docs). +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; + +vec3 accumulateLights(vec3 N, vec3 worldPos) { + vec3 lit = uCellAmbient.xyz; + int active = int(uCellAmbient.w); + for (int i = 0; i < 8; ++i) { + if (i >= active) break; + + int kind = int(uLights[i].posAndKind.w); + vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w; + + if (kind == 0) { + vec3 Ldir = -uLights[i].dirAndRange.xyz; + float ndl = max(0.0, dot(N, Ldir)); + lit += Lcol * ndl; + } else { + vec3 toL = uLights[i].posAndKind.xyz - worldPos; + float d = length(toL); + float range = uLights[i].dirAndRange.w; + if (d < range && range > 1e-3) { + vec3 Ldir = toL / max(d, 1e-4); + float ndl = max(0.0, dot(N, Ldir)); + float atten = 1.0; + if (kind == 2) { + float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); + float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); + atten *= (cos_l > cos_edge) ? 1.0 : 0.0; + } + lit += Lcol * ndl * atten; + } + } + } + return lit; +} + +vec3 applyFog(vec3 lit, vec3 worldPos) { + int mode = int(uFogParams.w); + if (mode == 0) return lit; + float d = length(worldPos - uCameraAndTime.xyz); + float fogStart = uFogParams.x; + float fogEnd = uFogParams.y; + float span = max(1e-3, fogEnd - fogStart); + float fog = clamp((d - fogStart) / span, 0.0, 1.0); + return mix(lit, uFogColor.xyz, fog); +} + void main() { vec4 color = texture(uDiffuse, vTex); // Alpha cutout only for clip-map surfaces (doors, windows, vegetation). - // Blended surface types must NOT discard here — that kills every - // semi-transparent pixel before the blend stage runs. if (uTranslucencyKind == 1 && color.a < 0.5) discard; - // Apply pre-computed Lambert + ambient lighting factor from the vertex shader. - fragColor = vec4(color.rgb * vLightingFactor, color.a); + vec3 N = normalize(vWorldNormal); + vec3 lit = accumulateLights(N, vWorldPos); + + // Lightning flash — additive scene bump. + lit += uFogParams.z * vec3(0.6, 0.6, 0.75); + + // Retail clamp per-channel to 1.0 (r13 §13.1). + lit = min(lit, vec3(1.0)); + + vec3 rgb = color.rgb * lit; + rgb = applyFog(rgb, vWorldPos); + fragColor = vec4(rgb, color.a); } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_instanced.vert b/src/AcDream.App/Rendering/Shaders/mesh_instanced.vert index e9c6896..a2f3893 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_instanced.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_instanced.vert @@ -16,12 +16,10 @@ layout(location = 5) in vec4 aInstanceRow2; layout(location = 6) in vec4 aInstanceRow3; uniform mat4 uViewProjection; -uniform vec3 uLightDirection; // world-space light direction (points FROM sun, matching ACME) -uniform float uAmbientIntensity; out vec2 vTex; out vec3 vWorldNormal; -out float vLightingFactor; +out vec3 vWorldPos; void main() { // Reconstruct the per-instance model matrix from its four row vectors. @@ -30,11 +28,8 @@ void main() { vec4 worldPos = model * vec4(aPosition, 1.0); gl_Position = uViewProjection * worldPos; + vWorldPos = worldPos.xyz; // Transform normal into world space. vWorldNormal = normalize(mat3(model) * aNormal); vTex = aTexCoord; - - // Lambert + ambient matching ACME StaticObject.vert: - // LightingFactor = max(dot(Normal, -uLightDirection), 0.0) + uAmbientIntensity; - vLightingFactor = max(dot(vWorldNormal, -uLightDirection), 0.0) + uAmbientIntensity; } diff --git a/src/AcDream.App/Rendering/Shaders/particle.frag b/src/AcDream.App/Rendering/Shaders/particle.frag new file mode 100644 index 0000000..4633285 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/particle.frag @@ -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); +} diff --git a/src/AcDream.App/Rendering/Shaders/particle.vert b/src/AcDream.App/Rendering/Shaders/particle.vert new file mode 100644 index 0000000..7b26dbf --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/particle.vert @@ -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); +} diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag new file mode 100644 index 0000000..8945781 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -0,0 +1,51 @@ +#version 430 core +// Sky mesh fragment shader — sample the object's diffuse texture with +// the scrolled UVs from the vertex stage. Unlit: sky meshes ARE the +// gradient (r12 §2.2), not a surface lit by the sun. +// +// The per-keyframe replace override can dim the mesh (Transparent) or +// brighten it (Luminosity); those two floats arrive as uTransparency / +// uLuminosity uniforms. + +in vec2 vTex; +out vec4 fragColor; + +uniform sampler2D uDiffuse; +uniform float uTransparency; // 0 = fully visible, 1 = invisible +uniform float uLuminosity; // 1 = normal, >1 makes the mesh glow +uniform vec4 uTint; // per-object color tint (default white) + +// Shared SceneLighting UBO — we only need the fog parameters to let the +// horizon band of the sky blend smoothly into the scene's fog color at +// the far edge, and the lightning flash to give storms their signature +// strobe. +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; + +void main() { + vec4 sampled = texture(uDiffuse, vTex); + + // Apply tint + luminosity. Retail's SkyObjReplace.Luminosity can push + // above 1 to make the sun mesh brighter than its texture; r12 §2.3. + vec3 rgb = sampled.rgb * uTint.rgb * uLuminosity; + + // Lightning additive bump — makes the sky itself flash during storms. + rgb += uFogParams.z * vec3(0.5, 0.5, 0.55); + + rgb = min(rgb, vec3(1.2)); // soft clamp to let luminosity over-bright mildly + + float a = sampled.a * (1.0 - uTransparency) * uTint.a; + if (a < 0.01) discard; + fragColor = vec4(rgb, a); +} diff --git a/src/AcDream.App/Rendering/Shaders/sky.vert b/src/AcDream.App/Rendering/Shaders/sky.vert new file mode 100644 index 0000000..35df9ca --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/sky.vert @@ -0,0 +1,22 @@ +#version 430 core +// Sky mesh vertex shader — each celestial object is a GfxObj mesh +// (sun billboard, cloud sheet, moon, star dome) rendered at large +// distance with depth writes disabled. The view matrix has its +// translation zeroed so the sky stays camera-centered; the projection +// matrix has a huge far plane so 1e6-metre-away sky meshes never clip. + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTex; + +uniform mat4 uModel; // per-object arc transform (r12 §2.1) +uniform mat4 uSkyView; // camera view with M41..M43 = 0 +uniform mat4 uSkyProjection; // near=0.1, far=1e6 +uniform vec2 uUvScroll; // cumulative TexVelocityX/Y * time + +out vec2 vTex; + +void main() { + vTex = aTex + uUvScroll; + gl_Position = uSkyProjection * uSkyView * uModel * vec4(aPos, 1.0); +} diff --git a/src/AcDream.App/Rendering/Shaders/terrain.frag b/src/AcDream.App/Rendering/Shaders/terrain.frag index f615929..479939d 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain.frag +++ b/src/AcDream.App/Rendering/Shaders/terrain.frag @@ -1,12 +1,14 @@ #version 430 core // Per-cell terrain blending (Phase 3c.4) — ported from WorldBuilder's // Landscape.frag, trimmed of editor-specific features (grid, brush, -// walkable-slope highlighting) and with Phase 3a/3b directional lighting -// layered on at the end. +// walkable-slope highlighting). Phase G extends this with the shared +// SceneLighting UBO driving per-vertex sun bake + fragment-stage fog +// + lightning flash. in vec2 vBaseUV; in vec3 vWorldNormal; -in float vLightingFactor; +in vec3 vWorldPos; +in vec3 vLightingRGB; in vec4 vOverlay0; in vec4 vOverlay1; in vec4 vOverlay2; @@ -18,24 +20,34 @@ out vec4 fragColor; uniform sampler2DArray uTerrain; // 33+ layers — TerrainAtlas.GlTexture uniform sampler2DArray uAlpha; // 8+ layers — TerrainAtlas.GlAlphaTexture -uniform float xAmbient; // ambient intensity (matching ACME Landscape.frag) + +// Shared scene-lighting UBO — fog + flash are consumed here; the per-vertex +// AdjustPlanes bake already incorporated sun + ambient. +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; // Per-texture tiling repeat count across a cell. WorldBuilder uses // uTexTiling[36] uploaded from the dats; we default to 1.0 (one tile per -// cell, 8 tiles across a landblock) until we wire the array. The previous -// Phase 2b/3 single-layer path tiled at ~2 per cell, so the world may read -// slightly coarser at 1.0 — tunable here if it looks wrong. +// cell, 8 tiles across a landblock). const float TILE = 1.0; -// Three-layer alpha-weighted composite. Each terrain overlay layer -// contributes based on its own alpha mask; missing layers (h == 0) collapse -// to transparent. Lifted verbatim from WorldBuilder's Landscape.frag. +// Three-layer alpha-weighted composite. vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) { float a0 = h0 == 0.0 ? 1.0 : t0.a; float a1 = h1 == 0.0 ? 1.0 : t1.a; float a2 = h2 == 0.0 ? 1.0 : t2.a; float aR = 1.0 - (a0 * a1 * a2); - // avoid divide-by-zero when all three overlays are absent float aRsafe = max(aR, 1e-6); a0 = 1.0 - a0; a1 = 1.0 - a1; @@ -82,7 +94,6 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) { result = texture(uTerrain, vec3(baseUV * TILE, pRoad0.z)); if (pRoad0.w >= 0.0) { vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w)); - // Roads use inverted alpha (the mask stores NON-road coverage). result.a = 1.0 - a0.a; if (h1 > 0.0 && pRoad1.w >= 0.0) { vec4 a1 = texture(uAlpha, vec3(pRoad1.xy, pRoad1.w)); @@ -93,9 +104,18 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) { return result; } +vec3 applyFog(vec3 lit, vec3 worldPos) { + int mode = int(uFogParams.w); + if (mode == 0) return lit; + float d = length(worldPos - uCameraAndTime.xyz); + float fogStart = uFogParams.x; + float fogEnd = uFogParams.y; + float span = max(1e-3, fogEnd - fogStart); + float fog = clamp((d - fogStart) / span, 0.0, 1.0); + return mix(lit, uFogColor.xyz, fog); +} + void main() { - // Base color: if there's no base layer (sentinel -1) just render black - // (shouldn't happen in valid data). vec4 baseColor = vec4(0.0); if (vBaseTexIdx >= 0.0) { baseColor = texture(uTerrain, vec3(vBaseUV * TILE, vBaseTexIdx)); @@ -115,9 +135,15 @@ void main() { vec3 roadMasked = roads.rgb * roads.a; vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0); - // Lighting matching ACME Landscape.frag: - // litColor = finalColor * (saturate(vLightingFactor) + xAmbient); - vec3 litColor = rgb * (clamp(vLightingFactor, 0.0, 1.0) + xAmbient); + // Apply the per-vertex baked sun+ambient. + vec3 lit = rgb * min(vLightingRGB, vec3(1.0)); - fragColor = vec4(litColor, 1.0); + // Lightning flash — additive. + float flash = uFogParams.z; + lit += flash * vec3(0.6, 0.6, 0.75); + + // Atmospheric fog. + lit = applyFog(lit, vWorldPos); + + fragColor = vec4(lit, 1.0); } diff --git a/src/AcDream.App/Rendering/Shaders/terrain.vert b/src/AcDream.App/Rendering/Shaders/terrain.vert index c72295d..4b77642 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain.vert +++ b/src/AcDream.App/Rendering/Shaders/terrain.vert @@ -8,11 +8,28 @@ layout(location = 5) in uvec4 aPacked3; // bits: rot fields + splitDir (see bel uniform mat4 uView; uniform mat4 uProjection; -uniform vec3 xLightDirection; // world-space sun direction (matching ACME Landscape.vert) + +// Phase G.1+G.2: sky/scene UBO. Terrain reads uLights[0] for the sun +// (slot 0 is reserved) plus uCellAmbient for outdoor ambient; the fog +// fields are consumed by the fragment stage. +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; out vec2 vBaseUV; out vec3 vWorldNormal; -out float vLightingFactor; +out vec3 vWorldPos; +out vec3 vLightingRGB; // pre-computed sun+ambient contribution for retail-style AdjustPlanes bake // Per-layer "UV.xy in cell-local 0..1 space, tex index .z, alpha index .w". // Negative .z means "layer not present, skip it in the fragment shader." out vec4 vOverlay0; @@ -22,6 +39,11 @@ out vec4 vRoad0; out vec4 vRoad1; flat out float vBaseTexIdx; +// Retail's "ambient floor" constant from the decompiled AdjustPlanes +// path (r13 §7, DAT_00796344). Even a back-lit vertex sees at least +// this fraction of the sun color — NOT additive with ambient. +const float MIN_FACTOR = 0.08; + // Port of WorldBuilder's Landscape.vert unpackOverlayLayer: sentinel-check // 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's // 90° rotation count. @@ -56,13 +78,6 @@ void main() { // gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a // specific order for each split direction; the table below must stay // in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches. - // - // Corner labels: 0=BL (low x/y), 1=BR (high x, low y), - // 2=TR (high x/y), 3=TL (low x, high y). - // WorldBuilder assigns cell-local UV per corner: - // 0 → (0, 1) 1 → (1, 1) 2 → (1, 0) 3 → (0, 0) - // (the v axis is flipped vs. geometric convention — harmless, just a - // texture-space choice). int vIdx = gl_VertexID % 6; int corner = 0; if (splitDir == 0u) { @@ -90,12 +105,20 @@ void main() { else baseUV = vec2(0.0, 0.0); vBaseUV = baseUV; - // Vertices are baked in world space; normals need no model transform. + vWorldPos = aPos; vWorldNormal = normalize(aNormal); - // Lambert diffuse term matching ACME Landscape.vert: - // vLightingFactor = max(0.0, dot(vNormal, -normalize(xLightDirection))); - vLightingFactor = max(0.0, dot(vWorldNormal, -normalize(xLightDirection))); + // Retail AdjustPlanes bake (r13 §7): + // L = max(N · -sunDir, MIN_FACTOR) + // vertex.color = sun_color * L + ambient_color + // + // Slot 0 of the UBO is the sun (directional). We read its forward + // vector and pre-multiplied color, apply the ambient floor, layer + // in the scene ambient separately. + vec3 sunDir = uLights[0].dirAndRange.xyz; + vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w; + float L = max(dot(vWorldNormal, -sunDir), MIN_FACTOR); + vLightingRGB = sunCol * L + uCellAmbient.xyz; float baseTex = float(aPacked0.x); if (baseTex >= 254.0) baseTex = -1.0; diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs new file mode 100644 index 0000000..ba7a58b --- /dev/null +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.Core.Meshing; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Sky; + +/// +/// Port of references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs. +/// Draws the retail sky as a stack of independent celestial meshes (the +/// "it's not a dome" insight from r12 §2) rather than a cube/sphere +/// with a gradient texture. Each is +/// visible in a window of day-fraction space, sweeps from +/// BeginAngle to EndAngle across the sky, and samples its +/// texture with a per-frame UV scroll driven by TexVelocityX/Y. +/// +/// +/// GL state delta per frame: +/// +/// Depth mask OFF, depth test OFF, cull OFF — the sky +/// should never occlude scene geometry. +/// Separate projection matrix with a 0.1–1e6 near/far +/// so mesh vertices at large distance don't clip. +/// View matrix with translation zeroed — sky is +/// always camera-centred; moving doesn't get you closer to the +/// sun. +/// +/// +/// +/// +/// Meshes are built lazily per GfxObj id on first reference. The +/// per-object arc transform matches WorldBuilder's composition: +/// scale × RotZ(-heading) × RotY(-rotation) — the negative signs +/// come from AC's Z-up right-handed convention where heading is +/// measured clockwise from north. +/// +/// +public sealed unsafe class SkyRenderer : IDisposable +{ + private readonly GL _gl; + private readonly DatCollection _dats; + private readonly Shader _shader; + private readonly TextureCache _textures; + + // Lazily-built GPU resources per sky-GfxObj. + private readonly Dictionary> _gpuByGfxObj = new(); + + // When did we start running — used to accumulate TexVelocityX/Y over + // real time (independent of the day-fraction clock). + private readonly DateTime _startedAt = DateTime.UtcNow; + + // Configurable render distance — retail uses ~1e6; anything larger + // than the scene far plane works. + public float Near { get; set; } = 0.1f; + public float Far { get; set; } = 1_000_000f; + + public SkyRenderer(GL gl, DatCollection dats, Shader shader, TextureCache textures) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _shader = shader ?? throw new ArgumentNullException(nameof(shader)); + _textures = textures ?? throw new ArgumentNullException(nameof(textures)); + } + + /// + /// Draw the sky for this frame. Called FIRST in the render loop — + /// terrain / meshes / debug lines / overlay land on top. + /// + public void Render( + ICamera camera, + Vector3 cameraWorldPos, + float dayFraction, + DayGroupData? group) + { + if (group is null || group.SkyObjects.Count == 0) return; + + // Build a sky projection with a huge far plane so 1e6m-distant + // celestial meshes don't clip. The FOV is cargo-culted from the + // camera's projection — see WorldBuilder's implementation. + float fovY = MathF.PI / 3f; // 60° — matches FlyCamera/ChaseCamera + float aspect = camera.Aspect; + if (aspect <= 0f) aspect = 16f / 9f; + var skyProj = Matrix4x4.CreatePerspectiveFieldOfView(fovY, aspect, Near, Far); + + // View with translation zeroed — keeps the sky at camera origin + // regardless of camera position in the world. + var skyView = camera.View; + skyView.M41 = 0f; + skyView.M42 = 0f; + skyView.M43 = 0f; + + _shader.Use(); + _shader.SetMatrix4("uSkyView", skyView); + _shader.SetMatrix4("uSkyProjection", skyProj); + + // Save + override GL state. + _gl.DepthMask(false); + _gl.Disable(EnableCap.DepthTest); + _gl.Disable(EnableCap.CullFace); + _gl.Enable(EnableCap.Blend); + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); + + // Look up the keyframe's override list so we can apply + // SkyObjReplace (r12 §2.3): per-keyframe GfxObj swaps + rotation + // override + transparency fade + luminosity cap. + var replaces = PickReplaces(group, dayFraction); + + float secondsSinceStart = (float)(DateTime.UtcNow - _startedAt).TotalSeconds; + + for (int i = 0; i < group.SkyObjects.Count; i++) + { + var obj = group.SkyObjects[i]; + if (!obj.IsVisible(dayFraction)) continue; + + // Apply per-keyframe replace overrides. + uint gfxObjId = obj.GfxObjId; + float headingDeg = 0f; + float transparent = 0f; + float luminosity = 1f; + if (replaces.TryGetValue((uint)i, out var rep)) + { + if (rep.GfxObjId != 0) gfxObjId = rep.GfxObjId; + if (rep.Rotate != 0f) headingDeg = rep.Rotate; + transparent = Math.Clamp(rep.Transparent, 0f, 1f); + if (rep.Luminosity > 0f) luminosity = rep.Luminosity; + if (rep.MaxBright > 0f) luminosity = MathF.Min(luminosity, rep.MaxBright); + } + if (gfxObjId == 0) continue; + + // Current arc angle across the sky. + float rotationDeg = obj.CurrentAngle(dayFraction); + float headingRad = headingDeg * (MathF.PI / 180f); + float rotationRad = rotationDeg * (MathF.PI / 180f); + + // Matches WorldBuilder's composition for a Z-up right-handed + // frame with heading measured clockwise from north. + var model = Matrix4x4.CreateScale(1.0f) + * Matrix4x4.CreateRotationZ(-headingRad) + * Matrix4x4.CreateRotationY(-rotationRad); + + _shader.SetMatrix4("uModel", model); + + // UV scroll accumulates real-time × velocity. Wrap to [0, 1] + // so long-running sessions don't accumulate float precision + // loss in the fragment UV. + float uOffset = (obj.TexVelocityX * secondsSinceStart) % 1f; + float vOffset = (obj.TexVelocityY * secondsSinceStart) % 1f; + _shader.SetVec2("uUvScroll", new Vector2(uOffset, vOffset)); + _shader.SetFloat("uTransparency", transparent); + _shader.SetFloat("uLuminosity", luminosity); + _shader.SetVec4("uTint", Vector4.One); + + EnsureMeshUploaded(gfxObjId); + if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes)) continue; + + foreach (var sub in subMeshes) + { + uint tex = _textures.GetOrUpload(sub.SurfaceId); + _gl.ActiveTexture(TextureUnit.Texture0); + _gl.BindTexture(TextureTarget.Texture2D, tex); + _gl.BindVertexArray(sub.Vao); + _gl.DrawElements(PrimitiveType.Triangles, + (uint)sub.IndexCount, + DrawElementsType.UnsignedInt, + (void*)0); + } + } + + // Restore GL state expected by the rest of the pipeline. + _gl.Disable(EnableCap.Blend); + _gl.DepthMask(true); + _gl.Enable(EnableCap.DepthTest); + _gl.BindVertexArray(0); + } + + /// + /// Find the entries for the + /// keyframe currently "active" at . + /// Matches WorldBuilder's single-keyframe lookup (it picks t1 + /// and doesn't interpolate the replace fields). + /// + private static Dictionary PickReplaces( + DayGroupData group, float dayFraction) + { + var result = new Dictionary(); + var times = group.SkyTimes; + if (times.Count == 0) return result; + + // Pick k1 = last keyframe with Begin <= dayFraction. + DatSkyKeyframeData k1 = times[^1]; + for (int i = 0; i < times.Count; i++) + { + if (times[i].Keyframe.Begin <= dayFraction) + k1 = times[i]; + else + break; + } + + foreach (var r in k1.Replaces) + result[r.ObjectIndex] = r; + + return result; + } + + /// + /// Lazy GfxObj build — reuses so the + /// pos/neg polygon splitting logic stays consistent with the main + /// static-mesh pipeline. Most sky meshes are single-surface. + /// + private void EnsureMeshUploaded(uint gfxObjId) + { + if (_gpuByGfxObj.ContainsKey(gfxObjId)) return; + + var gfx = _dats.Get(gfxObjId); + if (gfx is null) + { + _gpuByGfxObj[gfxObjId] = new List(); + return; + } + + var subMeshes = GfxObjMesh.Build(gfx, _dats); + var gpuList = new List(subMeshes.Count); + foreach (var sm in subMeshes) + gpuList.Add(UploadSubMesh(sm)); + _gpuByGfxObj[gfxObjId] = gpuList; + } + + private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm) + { + uint vao = _gl.GenVertexArray(); + _gl.BindVertexArray(vao); + + uint vbo = _gl.GenBuffer(); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo); + fixed (void* p = sm.Vertices) + _gl.BufferData(BufferTargetARB.ArrayBuffer, + (nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw); + + uint ebo = _gl.GenBuffer(); + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo); + fixed (void* p = sm.Indices) + _gl.BufferData(BufferTargetARB.ElementArrayBuffer, + (nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw); + + uint stride = (uint)sizeof(Vertex); + _gl.EnableVertexAttribArray(0); + _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0); + _gl.EnableVertexAttribArray(1); + _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float))); + _gl.EnableVertexAttribArray(2); + _gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float))); + + _gl.BindVertexArray(0); + return new SubMeshGpu + { + Vao = vao, + Vbo = vbo, + Ebo = ebo, + IndexCount = sm.Indices.Length, + SurfaceId = sm.SurfaceId, + }; + } + + public void Dispose() + { + foreach (var subs in _gpuByGfxObj.Values) + { + foreach (var sub in subs) + { + _gl.DeleteBuffer(sub.Vbo); + _gl.DeleteBuffer(sub.Ebo); + _gl.DeleteVertexArray(sub.Vao); + } + } + _gpuByGfxObj.Clear(); + } + + private sealed class SubMeshGpu + { + public uint Vao; + public uint Vbo; + public uint Ebo; + public int IndexCount; + public uint SurfaceId; + } +} diff --git a/src/AcDream.App/Rendering/TerrainChunkRenderer.cs b/src/AcDream.App/Rendering/TerrainChunkRenderer.cs index ba619d9..cd2df6a 100644 --- a/src/AcDream.App/Rendering/TerrainChunkRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainChunkRenderer.cs @@ -218,12 +218,11 @@ public sealed unsafe class TerrainChunkRenderer : IDisposable _shader.SetMatrix4("uView", camera.View); _shader.SetMatrix4("uProjection", camera.Projection); - // Lighting uniforms matching ACME Landscape.vert/frag. - // LightDirection (0.5, 0.3, -0.3) from ACME GameScene.cs:238. - // AmbientLightIntensity 0.45 from ACME LandscapeEditorSettings.cs:108. - var lightDir = Vector3.Normalize(new Vector3(0.5f, 0.3f, -0.3f)); - _shader.SetVec3("xLightDirection", lightDir); - _shader.SetFloat("xAmbient", 0.45f); + // Phase G: light direction + ambient + fog come from the shared + // SceneLighting UBO (binding=1) uploaded by GameWindow once per + // frame. Terrain bakes per-vertex AdjustPlanes lighting (r13 §7) + // from the UBO's slot-0 sun + uCellAmbient, then the fragment + // stage adds fog + lightning flash. No per-program uniforms here. // Terrain atlas on unit 0, alpha atlas on unit 1. _gl.ActiveTexture(TextureUnit.Texture0); diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index f1fb421..f3448ef 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -65,6 +65,22 @@ public sealed class GpuWorldState public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId); + /// + /// 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). + /// + public bool TryGetLandblock(uint landblockId, out LoadedLandblock? lb) + { + if (_loaded.TryGetValue(landblockId, out var found)) + { + lb = found; + return true; + } + lb = null; + return false; + } + /// /// Store the axis-aligned bounding box for a loaded landblock. Called from /// the render thread after the terrain mesh is built and uploaded. diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index c39bacf..e4d9649 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -109,6 +109,21 @@ public sealed class WorldSession : IDisposable /// public event Action? SpeechHeard; + /// + /// 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 WorldTimeService.SyncFromServer + /// so client-local day/night stays in lockstep with the server clock. + /// + public event Action? ServerTimeUpdated; + + /// + /// Latest server tick count from + /// events. 0 until the handshake completes. + /// + public double LastServerTimeTicks { get; private set; } + /// /// Allow re-sending LoginComplete after a portal teleport. The normal /// _loginCompleteSent latch prevents duplicate sends on the initial spawn @@ -224,6 +239,14 @@ public sealed class WorldSession : IDisposable // Step 3: seed ISAAC, send ConnectResponse to port+1, with 200ms race delay var opt = cr.Optional; + + // Phase G.1: server's initial PortalYearTicks (r12 §1.3) lives + // in the ConnectRequest optional section. Publish it to + // subscribers so WorldTimeService.SyncFromServer can seed the + // client clock. + LastServerTimeTicks = opt.ConnectRequestServerTime; + ServerTimeUpdated?.Invoke(opt.ConnectRequestServerTime); + byte[] serverSeedBytes = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed); byte[] clientSeedBytes = new byte[4]; @@ -393,6 +416,19 @@ public sealed class WorldSession : IDisposable SendAck(serverHeader.Sequence); } + // Phase G.1: propagate TimeSync-flagged server time to anyone who + // needs it (sky/day-night lerp in particular). Server sends this + // periodically — no explicit opcode, just the header flag. + if ((serverHeader.Flags & PacketHeaderFlags.TimeSync) != 0) + { + double t = dec.Packet!.Optional.TimeSync; + if (t > 0) + { + LastServerTimeTicks = t; + ServerTimeUpdated?.Invoke(t); + } + } + foreach (var frag in dec.Packet!.Fragments) { var body = _assembler.Ingest(frag, out _); diff --git a/src/AcDream.Core/Lighting/LightInfoLoader.cs b/src/AcDream.Core/Lighting/LightInfoLoader.cs new file mode 100644 index 0000000..63a250f --- /dev/null +++ b/src/AcDream.Core/Lighting/LightInfoLoader.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.Lighting; + +/// +/// Converts a 's Lights dictionary (dat-level +/// records) into runtime +/// instances the can consume. +/// +/// +/// Retail fields (r13 §1): +/// +/// ViewSpaceLocation: local Frame relative to the owning part. +/// Color: packed ARGB. Alpha is ignored; channels go through /255. +/// Intensity: multiplies color for final diffuse. +/// Falloff: world metres — acts as the hard cutoff. +/// ConeAngle: radians; 0 = point, >0 = spot cone. +/// +/// +/// +public static class LightInfoLoader +{ + /// + /// Extract all lights from a Setup, positioned in the entity's + /// world frame (via + + /// ). 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. + /// + public static IReadOnlyList Load( + Setup setup, + uint ownerId, + Vector3 entityPosition, + Quaternion entityRotation) + { + var results = new List(); + 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; + } +} diff --git a/src/AcDream.Core/Lighting/LightingHookSink.cs b/src/AcDream.Core/Lighting/LightingHookSink.cs new file mode 100644 index 0000000..9a052d2 --- /dev/null +++ b/src/AcDream.Core/Lighting/LightingHookSink.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Types; + +namespace AcDream.Core.Lighting; + +/// +/// Routes animation hooks to the +/// — when a torch lights / extinguishes via +/// an animation frame, flip the corresponding +/// latch. Per r13 §2 the hook is AC's +/// way of saying "this Setup's baked-in LightInfo is now active". +/// +/// +/// Registration: at entity spawn time the caller walks the Setup's +/// Lights dictionary and registers a +/// per LightInfo, 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). +/// +/// +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> _byOwner = new(); + + public LightingHookSink(LightManager lights) + { + _lights = lights ?? throw new System.ArgumentNullException(nameof(lights)); + } + + /// + /// Register a light with the manager + track it by owner so later + /// SetLightHook / Unregister calls can reach it. + /// + public void RegisterOwnedLight(LightSource light) + { + System.ArgumentNullException.ThrowIfNull(light); + _lights.Register(light); + if (!_byOwner.TryGetValue(light.OwnerId, out var list)) + { + list = new List(); + _byOwner[light.OwnerId] = list; + } + list.Add(light); + } + + /// Drop every light tagged to this owner (despawn / unload). + public void UnregisterOwner(uint ownerId) + { + if (!_byOwner.TryGetValue(ownerId, out var list)) return; + foreach (var l in list) _lights.Unregister(l); + _byOwner.Remove(ownerId); + } + + /// + /// Get the set of registered lights for an owner — exposed so + /// callers can reposition them (torch on hand follows hand part). + /// + public IReadOnlyList? 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; + } +} diff --git a/src/AcDream.Core/Lighting/SceneLightingUbo.cs b/src/AcDream.Core/Lighting/SceneLightingUbo.cs new file mode 100644 index 0000000..85d721f --- /dev/null +++ b/src/AcDream.Core/Lighting/SceneLightingUbo.cs @@ -0,0 +1,150 @@ +using System; +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.Core.World; + +namespace AcDream.Core.Lighting; + +/// +/// GPU-facing scene-lighting UBO layout. Matches the GLSL block in +/// mesh.frag / mesh_instanced.vert / terrain.vert +/// bound at binding=1. std140-compliant — each vec4 member +/// lives on a 16-byte boundary, arrays of vec4 pack contiguously, +/// and no pad elements are required because the struct's fields are +/// already 16-byte-aligned. +/// +/// +/// Layout (r13 §12.3 extended with R12 §13.2 sun+fog): +/// +/// struct Light { +/// vec4 posAndKind; // xyz = world pos, w = kind (0=dir, 1=point, 2=spot) +/// vec4 dirAndRange; // xyz = forward, w = range (metres, hard cutoff) +/// vec4 colorAndIntensity; // xyz = RGB linear, w = intensity scalar +/// vec4 coneAngleEtc; // x = cone (rad), y=unused, z=unused, w=unused +/// }; +/// +/// layout(std140, binding = 1) uniform SceneLighting { +/// Light uLights[8]; // 8 * 64 bytes = 512 bytes +/// vec4 uCellAmbient; // xyz = ambient RGB, w = active light count +/// vec4 uFogParams; // x = fogStart, y = fogEnd, z = lightningFlash, w = fogMode +/// vec4 uFogColor; // xyz = fog RGB, w = unused +/// vec4 uCameraAndTime; // xyz = camera world pos, w = day fraction (debug / sky shader) +/// }; +/// +/// +/// +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct UboLight +{ + public Vector4 PosAndKind; + public Vector4 DirAndRange; + public Vector4 ColorAndIntensity; + public Vector4 ConeAngleEtc; + + /// Pack a into UBO-ready bytes. + public static UboLight FromSource(LightSource ls) + { + return new UboLight + { + PosAndKind = new Vector4(ls.WorldPosition, (float)(int)ls.Kind), + DirAndRange = new Vector4(ls.WorldForward, ls.Range), + ColorAndIntensity = new Vector4(ls.ColorLinear, ls.Intensity), + ConeAngleEtc = new Vector4(ls.ConeAngle, 0f, 0f, 0f), + }; + } + + /// Packed "zero" light — stored in unused UBO slots so shaders + /// don't read garbage. dirAndRange.w = 0 disables the light + /// even if the active-count sentinel is wrong. + public static UboLight Empty => new() + { + PosAndKind = Vector4.Zero, + DirAndRange = Vector4.Zero, + ColorAndIntensity = Vector4.Zero, + ConeAngleEtc = Vector4.Zero, + }; +} + +/// +/// Full CPU-side scene-lighting UBO buffer. One per frame; lives on the +/// render thread. The GL-side wrapper (SceneLightingUboBinding +/// in AcDream.App) uploads this to binding=1 once per frame. +/// +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct SceneLightingUbo +{ + // 8 lights × 64 bytes = 512 bytes + public UboLight Light0; + public UboLight Light1; + public UboLight Light2; + public UboLight Light3; + public UboLight Light4; + public UboLight Light5; + public UboLight Light6; + public UboLight Light7; + + public Vector4 CellAmbient; // xyz = ambient RGB, w = active count + public Vector4 FogParams; // x = fogStart, y = fogEnd, z = flash, w = fogMode + public Vector4 FogColor; // xyz = color, w = reserved + public Vector4 CameraAndTime; // xyz = camera pos, w = day fraction + + public const int SizeInBytes = 8 * 64 + 4 * 16; // 576 + public const int BindingPoint = 1; + + /// + /// Build the full per-frame UBO payload from: + /// + /// An already-ticked . + /// The current (sky + weather). + /// The current camera world position (sky shader needs it, fog shader needs it). + /// The current day fraction (sky shader needs it for scrolling clouds). + /// + /// + public static SceneLightingUbo Build( + LightManager lights, + in AtmosphereSnapshot atmo, + Vector3 cameraWorldPos, + float dayFraction) + { + ArgumentNullException.ThrowIfNull(lights); + + var ubo = new SceneLightingUbo(); + + // Pack up to 8 lights. Empty slots stay zero. + var active = lights.Active; + int count = active.Length; + if (count > 8) count = 8; + for (int i = 0; i < 8; i++) + { + var packed = (i < count && active[i] is not null) + ? UboLight.FromSource(active[i]!) + : UboLight.Empty; + SetLightAt(ref ubo, i, packed); + } + + ubo.CellAmbient = new Vector4(lights.CurrentAmbient.AmbientColor, count); + ubo.FogParams = new Vector4( + atmo.FogStart, + atmo.FogEnd, + atmo.LightningFlash, + (float)(int)atmo.FogMode); + ubo.FogColor = new Vector4(atmo.FogColor, 0f); + ubo.CameraAndTime = new Vector4(cameraWorldPos, dayFraction); + return ubo; + } + + private static void SetLightAt(ref SceneLightingUbo ubo, int i, in UboLight v) + { + switch (i) + { + case 0: ubo.Light0 = v; break; + case 1: ubo.Light1 = v; break; + case 2: ubo.Light2 = v; break; + case 3: ubo.Light3 = v; break; + case 4: ubo.Light4 = v; break; + case 5: ubo.Light5 = v; break; + case 6: ubo.Light6 = v; break; + case 7: ubo.Light7 = v; break; + } + } +} diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs new file mode 100644 index 0000000..439aabb --- /dev/null +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -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; + +/// +/// One sky object (celestial mesh) per r12 §2. Each object has: +/// +/// A visibility window in day-fraction space. +/// A BeginAngle/EndAngle sweep — the arc it traces across the sky during its window. +/// A texture-velocity pair for UV scrolling (cloud drift, star twinkle). +/// A GfxObj mesh (the actual geometry rendered at large distance). +/// +/// +/// +/// This is the in-memory mirror of DatReaderWriter.Types.SkyObject +/// scrubbed of dat-reader dependencies and with a couple of derived +/// fields pre-computed. The per-keyframe +/// (r12 §2.3) lives off the owning . +/// +/// +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; + + /// Object is visible at day-fraction + /// by retail's begin/end semantics (r12 §2). Three cases: + /// + /// Begin == End → always visible. + /// Begin < End → daytime arc, visible in [Begin, End]. + /// Begin > End → wraps midnight, visible in [Begin, 1) ∪ [0, End]. + /// + 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; + } + + /// + /// Arc progress 0..1 through the visibility window; gives the angle + /// interpolation for BeginAngleEndAngle (r12 §2). + /// + 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); + } + + /// + /// Current arc angle in degrees given the day fraction. Linear + /// interpolation between and . + /// + public float CurrentAngle(float t) + { + if (BeginTime == EndTime) return BeginAngle; + return BeginAngle + (EndAngle - BeginAngle) * AngleProgress(t); + } +} + +/// +/// Per-keyframe override for one sky object — swap its mesh at dusk, +/// dim it, or rotate it (r12 §2.3). Indexed by +/// into the owning day group's SkyObjects list. +/// +public sealed class SkyObjectReplaceData +{ + public uint ObjectIndex; + public uint GfxObjId; + public float Rotate; + public float Transparent; + public float Luminosity; + public float MaxBright; +} + +/// +/// Full lighting + sky-object-override data for one SkyTimeOfDay +/// keyframe. Built alongside the the shaders +/// consume — this form keeps the per-object overrides which the +/// SkyRenderer needs to swap clouds for overcast keyframes. +/// +public sealed class DatSkyKeyframeData +{ + public SkyKeyframe Keyframe; + public IReadOnlyList Replaces = Array.Empty(); +} + +/// +/// One DayGroup 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. +/// +public sealed class DayGroupData +{ + public float ChanceOfOccur; + public string Name = ""; + public IReadOnlyList SkyObjects = Array.Empty(); + public IReadOnlyList SkyTimes = Array.Empty(); +} + +/// +/// 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: +/// +/// A ready to drop into . +/// A list of day groups for weather picking. +/// Calendar constants (DayLength, etc) for cross-checking. +/// +/// +public sealed class LoadedSkyDesc +{ + public double TickSize; + public double LightTickSize; + public IReadOnlyList DayGroups = Array.Empty(); + + /// + /// Default day group — currently group 0 per WorldBuilder's + /// SkyboxRenderManager.Render. Weather integration later picks + /// the current day's group by ChanceOfOccur. + /// + public DayGroupData? DefaultDayGroup => + DayGroups.Count > 0 ? DayGroups[0] : null; + + /// + /// Build a shader-facing for the default day group. + /// + 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()); + } +} + +/// +/// Parses the Region dat (0x13000000) into strongly-typed acdream data. +/// Safe to call off the render thread as long as the underlying +/// isn't being mutated (acdream's one-shot +/// startup path already holds the dat lock during Region reads). +/// +/// +/// Retail stores the entire world's sky + calendar in this single record +/// — there's only ever one Region. The loader reads the SkyDesc +/// out of region.SkyInfo, iterates every DayGroup, and converts +/// each SkyTimeOfDay to our record. +/// +/// +/// +/// The SunColor / AmbientColor fields store the color × brightness +/// product so the shader UBO layout can stay a flat vec3 without +/// extra multiplies per pixel. See r12 §4. +/// +/// +public static class SkyDescLoader +{ + public const uint RegionDatId = 0x13000000u; + + /// + /// Load + parse. Returns null if the Region doesn't have + /// or the dat is absent. + /// + public static LoadedSkyDesc? LoadFromDat(DatCollection dats) + { + ArgumentNullException.ThrowIfNull(dats); + var region = dats.Get(RegionDatId); + if (region is null) return null; + return LoadFromRegion(region); + } + + /// + /// Convert an in-memory Region object to our domain data. + /// Separated so tests can feed hand-built Regions without the dat + /// pipeline. + /// + 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(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, + }; + } + + /// + /// 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 . Alpha is ignored (retail lighting + /// doesn't use it). + /// + 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); + } +} diff --git a/src/AcDream.Core/World/SkyState.cs b/src/AcDream.Core/World/SkyState.cs index e278f53..48c1e50 100644 --- a/src/AcDream.Core/World/SkyState.cs +++ b/src/AcDream.Core/World/SkyState.cs @@ -5,25 +5,53 @@ using System.Numerics; namespace AcDream.Core.World; /// -/// One sky keyframe — the lighting + fog state for a specific day-fraction. -/// Multiple keyframes across [0, 1) interpolate linearly (with angular -/// wrap on sun direction) to produce the current sky state. +/// Fog modes mirroring retail's D3DFOGMODE. Retail only ever uses +/// and ; the Exp variants are +/// supported by the dat schema but never appear in shipped data. See r12 +/// §5 and SkyTimeOfDay.WorldFog (dat uint). +/// +public enum FogMode +{ + Off = 0, + Linear = 1, + Exp = 2, + Exp2 = 3, +} + +/// +/// One sky keyframe — the full lighting + fog state for a specific +/// day-fraction. Multiple keyframes across [0, 1) interpolate +/// linearly (with angular-shortest-arc wrap on sun direction) to produce +/// the current sky state. /// /// /// Retail's SkyTimeOfDay dat struct carries this exact data plus /// references to sky objects (sun mesh, moon mesh, cloud layer) which -/// belong to the renderer. This class exposes the lighting-relevant -/// subset — sun direction, sun color, ambient color, fog. +/// belong to the renderer. This record exposes the shader-relevant +/// subset — sun direction, sun color, ambient color, linear fog. See +/// references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyTimeOfDay.generated.cs +/// and r12 §4 + §5. +/// +/// +/// +/// 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 DirBright / AmbBright. Range is loosely +/// [0, N] — retail dusk tints have channels above 1.0 and the frag +/// shader clamps after lighting math. /// /// public readonly record struct SkyKeyframe( - float Begin, // [0, 1] day-fraction this keyframe kicks in - float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W) - float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith) - Vector3 SunColor, // RGB linear, post-brightness multiply - Vector3 AmbientColor, + float Begin, // [0, 1] day-fraction this keyframe kicks in + float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W) + float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith) + Vector3 SunColor, // RGB linear, post-brightness multiply + Vector3 AmbientColor, // RGB linear, post-brightness multiply Vector3 FogColor, - float FogDensity); + float FogDensity, // retained for tests; derive from FogStart/End + float FogStart = 80f, // meters (retail default ~120 clear, ~40 storm) + float FogEnd = 350f, // meters (retail default ~350 clear, ~150 storm) + FogMode FogMode = FogMode.Linear); /// /// Sky keyframe interpolator — given a day fraction in [0, 1), returns @@ -42,9 +70,8 @@ public readonly record struct SkyKeyframe( /// with wrap handling. /// /// -/// Lerp every vector component; SLERP the sun direction -/// quaternions to avoid artifacts when heading wraps (e.g. k1.Heading -/// = 350°, k2.Heading = 10°). +/// Lerp every vector component; use shortest-arc lerp for the sun +/// heading so k1=350° → k2=10° doesn't sweep backwards across the sky. /// /// /// @@ -64,12 +91,20 @@ public sealed class SkyStateProvider } public int KeyframeCount => _keyframes.Count; + public IReadOnlyList Keyframes => _keyframes; /// /// Default keyframe set based on retail observations — sunrise at 6am, /// noon at 12pm, sunset at 6pm. Used when the dat-loaded set isn't /// available yet or the player is in a region whose Region dat /// doesn't override it. + /// + /// + /// 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. + /// /// public static SkyStateProvider Default() { @@ -83,7 +118,10 @@ public sealed class SkyStateProvider SunColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue AmbientColor: new Vector3(0.05f, 0.05f, 0.12f), FogColor: new Vector3(0.02f, 0.02f, 0.05f), - FogDensity: 0.004f), + FogDensity: 0.004f, + FogStart: 30f, + FogEnd: 180f, + FogMode: FogMode.Linear), new SkyKeyframe( Begin: 0.25f, SunHeadingDeg: 90f, // east at dawn @@ -91,7 +129,10 @@ public sealed class SkyStateProvider SunColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm AmbientColor: new Vector3(0.4f, 0.35f, 0.3f), FogColor: new Vector3(0.8f, 0.55f, 0.4f), - FogDensity: 0.002f), + FogDensity: 0.002f, + FogStart: 60f, + FogEnd: 260f, + FogMode: FogMode.Linear), new SkyKeyframe( Begin: 0.5f, SunHeadingDeg: 180f, // south at noon @@ -99,7 +140,10 @@ public sealed class SkyStateProvider SunColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish AmbientColor: new Vector3(0.5f, 0.5f, 0.55f), FogColor: new Vector3(0.7f, 0.75f, 0.85f), - FogDensity: 0.0008f), + FogDensity: 0.0008f, + FogStart: 120f, + FogEnd: 500f, + FogMode: FogMode.Linear), new SkyKeyframe( Begin: 0.75f, SunHeadingDeg: 270f, // west at dusk @@ -107,7 +151,10 @@ public sealed class SkyStateProvider SunColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red AmbientColor: new Vector3(0.35f, 0.25f, 0.25f), FogColor: new Vector3(0.85f, 0.45f, 0.35f), - FogDensity: 0.002f), + FogDensity: 0.002f, + FogStart: 60f, + FogEnd: 260f, + FogMode: FogMode.Linear), }); } @@ -145,21 +192,34 @@ public sealed class SkyStateProvider u = Math.Clamp(u, 0f, 1f); // Angular lerp for sun heading: pick shortest arc. - float h1 = k1.SunHeadingDeg; - float h2 = k2.SunHeadingDeg; - float delta = h2 - h1; - while (delta > 180f) delta -= 360f; - while (delta < -180f) delta += 360f; - float heading = h1 + delta * u; + float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u); + // Fog mode doesn't interpolate — pick k1's mode (retail uses Linear everywhere). return new SkyKeyframe( - Begin: t, + Begin: t, SunHeadingDeg: heading, - SunPitchDeg: k1.SunPitchDeg + (k2.SunPitchDeg - k1.SunPitchDeg) * u, + SunPitchDeg: Lerp(k1.SunPitchDeg, k2.SunPitchDeg, u), SunColor: Vector3.Lerp(k1.SunColor, k2.SunColor, u), AmbientColor: Vector3.Lerp(k1.AmbientColor, k2.AmbientColor, u), FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, u), - FogDensity: k1.FogDensity + (k2.FogDensity - k1.FogDensity) * u); + FogDensity: Lerp(k1.FogDensity, k2.FogDensity, u), + FogStart: Lerp(k1.FogStart, k2.FogStart, u), + FogEnd: Lerp(k1.FogEnd, k2.FogEnd, u), + FogMode: k1.FogMode); + } + + private static float Lerp(float a, float b, float u) => a + (b - a) * u; + + /// + /// Shortest-arc heading lerp: r12 §4. If a=350 and b=10 + /// the lerp walks 20° forward through 0° rather than 340° backward. + /// + 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; } /// @@ -185,42 +245,89 @@ public sealed class SkyStateProvider /// Service that turns server-delivered tick counts into live sky state. /// Owns the "current time" clock (seeded from server sync, advanced by /// real-time elapsed between syncs). +/// +/// +/// Supports a debug "time override" (slash-command /time 0.5) 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. +/// /// public sealed class WorldTimeService { - private readonly SkyStateProvider _sky; + private SkyStateProvider _sky; private double _lastSyncedTicks; private DateTime _lastSyncedWallClockUtc = DateTime.UtcNow; + private float? _debugDayFractionOverride; + + /// + /// 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 SkyDesc.TickSize; see r12 §1.2. + /// + public double TickSize { get; set; } = 1.0; + public WorldTimeService(SkyStateProvider sky) { _sky = sky ?? throw new ArgumentNullException(nameof(sky)); } + /// + /// Hot-swap the keyframe source — typically called once at world-load + /// time after the Region dat has been parsed by . + /// + public void SetProvider(SkyStateProvider sky) + { + _sky = sky ?? throw new ArgumentNullException(nameof(sky)); + } + /// /// Set the authoritative tick count from a server TimeSync packet. + /// Clears any debug override. /// public void SyncFromServer(double serverTicks) { _lastSyncedTicks = serverTicks; _lastSyncedWallClockUtc = DateTime.UtcNow; + _debugDayFractionOverride = null; } + /// + /// Debug-only: force a specific day fraction in [0, 1). Overrides + /// server-synced time until cleared by + /// or . + /// + public void SetDebugTime(float dayFraction) + { + _debugDayFractionOverride = dayFraction; + } + + public void ClearDebugTime() => _debugDayFractionOverride = null; + /// /// Current ticks at , advanced from the - /// last sync by real-time elapsed seconds. + /// last sync by real-time elapsed seconds times . /// public double NowTicks { get { double elapsed = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds; - return _lastSyncedTicks + elapsed; + return _lastSyncedTicks + elapsed * TickSize; } } /// Current day fraction in [0, 1). - public double DayFraction => DerethDateTime.DayFraction(NowTicks); + public double DayFraction + { + get + { + if (_debugDayFractionOverride.HasValue) + return _debugDayFractionOverride.Value; + return DerethDateTime.DayFraction(NowTicks); + } + } /// Current sky lighting state. public SkyKeyframe CurrentSky => _sky.Interpolate((float)DayFraction); diff --git a/src/AcDream.Core/World/WeatherState.cs b/src/AcDream.Core/World/WeatherState.cs new file mode 100644 index 0000000..15fc543 --- /dev/null +++ b/src/AcDream.Core/World/WeatherState.cs @@ -0,0 +1,309 @@ +using System; +using System.Numerics; + +namespace AcDream.Core.World; + +/// +/// 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. +/// +/// +/// The rendering side reads to decide whether to +/// spawn rain/snow particles and which cloud mesh override to select. +/// The field lets the fog / particle rate / +/// cloud-darkness terms ease in and out smoothly rather than popping. +/// +/// +public enum WeatherKind +{ + Clear = 0, + Overcast = 1, + Rain = 2, + Snow = 3, + Storm = 4, +} + +/// +/// Server-forced fog override (retail EnvironChangeType). When +/// the server sends AdminEnvirons (0xEA60) with one of the +/// non- values, the client overrides its locally-computed +/// fog color and density with the tint shown below. See r12 §5.2 and +/// references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs. +/// +public enum EnvironOverride +{ + None = 0x00, // clear override, revert to dat-driven fog + RedFog = 0x01, + BlueFog = 0x02, + WhiteFog = 0x03, + GreenFog = 0x04, + BlackFog = 0x05, + BlackFog2 = 0x06, +} + +/// +/// Full per-frame atmosphere state consumed by the shader + particle +/// systems. Built by from +/// +/// the interpolated , +/// the current , +/// a possibly-active , +/// a transient lightning-flash bump. +/// +/// +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); + +/// +/// 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. +/// +/// +/// Algorithm (r12 §6.1–6.2): +/// +/// +/// Derive a deterministic Random(dayIndex) per in-game day. +/// Roll a weighted pick from a table matching retail's rough +/// 70/15/10/5 distribution (Clear dominates). +/// +/// +/// When the kind changes, store a transitionStart timestamp +/// and tween from 0 → 1 +/// over . +/// +/// +/// Storm kind only: every 8–30 seconds fire a lightning flash; the +/// shader reads as +/// an additive scene bump that decays with a 200 ms time constant. +/// +/// +/// Any server beats the local picks — +/// stick the override fog color and density in the snapshot until +/// the server sends . +/// +/// +/// +/// +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: 8–30 seconds between strikes. + private const float StrikeIntervalMinS = 8f; + private const float StrikeIntervalMaxS = 30f; + + // Overcast-kind fog feels like ~40–150m 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; + } + + /// Current active weather. + public WeatherKind Kind => _kind; + + /// Last-known server fog override (sticky between sync packets). + public EnvironOverride Override + { + get => _override; + set => _override = value; + } + + /// + /// Debug / test hook — force a specific weather kind, ignoring the + /// per-day roll. Passing returns to + /// normal behavior starting on the next day-roll. + /// + public void ForceWeather(WeatherKind kind) + { + BeginTransition(kind); + _rolledDayIndex = int.MaxValue; // "forced" sentinel — don't re-roll + } + + /// + /// Advance the state machine. Call once per frame from the render + /// loop. is the in-game day (derived + /// from ); when it changes we re-roll + /// the weather kind. + /// + 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; + } + } + + /// + /// Trigger a lightning flash manually (server-forced or test hook). + /// + public void TriggerFlash() + { + _flashLevel = 1f; + _flashAge = 0f; + } + + /// + /// 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 + /// tint if any. + /// + 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; + } + + /// + /// Deterministic per-day weighted roll. Seeded with + /// alone so every client running the same day sees the same weather — + /// retail's mechanism for "synchronized weather without any packets" + /// (r12 §6.1). + /// + 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), + }; +} diff --git a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs new file mode 100644 index 0000000..c3884a6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs @@ -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); + } +} diff --git a/tests/AcDream.Core.Tests/Lighting/SceneLightingUboTests.cs b/tests/AcDream.Core.Tests/Lighting/SceneLightingUboTests.cs new file mode 100644 index 0000000..e79a281 --- /dev/null +++ b/tests/AcDream.Core.Tests/Lighting/SceneLightingUboTests.cs @@ -0,0 +1,121 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.Core.Lighting; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.Lighting; + +public sealed class SceneLightingUboTests +{ + [Fact] + public void UboLight_StructSize_Is64Bytes() + { + // std140 mandates 4× vec4 = 64 bytes. If this drifts the shader + // will read garbage. + Assert.Equal(64, Marshal.SizeOf()); + } + + [Fact] + public void SceneLightingUbo_StructSize_MatchesConstant() + { + Assert.Equal(SceneLightingUbo.SizeInBytes, Marshal.SizeOf()); + } + + [Fact] + public void Build_PacksActiveLightsIntoSlotsInOrder() + { + var lights = new LightManager(); + lights.Register(new LightSource + { + Kind = LightKind.Point, + WorldPosition = new Vector3(1, 2, 3), + ColorLinear = new Vector3(1f, 0.5f, 0.25f), + Intensity = 0.8f, + Range = 6f, + }); + lights.Tick(Vector3.Zero); + + var atmo = new AtmosphereSnapshot( + Kind: WeatherKind.Clear, + Intensity: 1f, + FogColor: new Vector3(0.7f, 0.8f, 0.9f), + FogStart: 100f, + FogEnd: 400f, + FogMode: FogMode.Linear, + LightningFlash: 0f, + Override: EnvironOverride.None); + + var ubo = SceneLightingUbo.Build(lights, in atmo, new Vector3(10, 20, 30), 0.5f); + + // Light 0 is the slot we populated. + Assert.Equal(1f, ubo.Light0.PosAndKind.X); + Assert.Equal(2f, ubo.Light0.PosAndKind.Y); + Assert.Equal(3f, ubo.Light0.PosAndKind.Z); + Assert.Equal((float)(int)LightKind.Point, ubo.Light0.PosAndKind.W); + Assert.Equal(6f, ubo.Light0.DirAndRange.W); + Assert.Equal(0.8f, ubo.Light0.ColorAndIntensity.W); + + // Unused slots should be zero-packed. + Assert.Equal(0f, ubo.Light1.DirAndRange.W); + + // Active count lives in uCellAmbient.w. + Assert.Equal(1f, ubo.CellAmbient.W); + + // Fog params passed through. + Assert.Equal(100f, ubo.FogParams.X); + Assert.Equal(400f, ubo.FogParams.Y); + Assert.Equal(0f, ubo.FogParams.Z); // no flash + Assert.Equal((float)(int)FogMode.Linear, ubo.FogParams.W); + + // Camera + day fraction. + Assert.Equal(10f, ubo.CameraAndTime.X); + Assert.Equal(0.5f, ubo.CameraAndTime.W); + } + + [Fact] + public void Build_ClampsAtEightLights() + { + var lights = new LightManager(); + // Register 20; the active list caps at 8. + for (int i = 0; i < 20; i++) + { + lights.Register(new LightSource + { + Kind = LightKind.Point, + WorldPosition = new Vector3(i, 0, 0), + Range = 200f, // all in range + }); + } + lights.Tick(Vector3.Zero); + + var atmo = new AtmosphereSnapshot( + WeatherKind.Clear, 1f, Vector3.Zero, 0, 0, FogMode.Off, 0f, EnvironOverride.None); + var ubo = SceneLightingUbo.Build(lights, in atmo, Vector3.Zero, 0f); + + // Slot 7 populated (8th light), active count = 8. + Assert.Equal(8f, ubo.CellAmbient.W); + Assert.NotEqual(0f, ubo.Light7.DirAndRange.W); + } + + [Fact] + public void Build_WithSun_SlotZeroIsDirectional() + { + var lights = new LightManager(); + lights.Sun = new LightSource + { + Kind = LightKind.Directional, + WorldForward = new Vector3(0, 0, -1), + ColorLinear = new Vector3(1f, 0.9f, 0.8f), + Intensity = 1.2f, + }; + lights.Tick(Vector3.Zero); + + var atmo = new AtmosphereSnapshot( + WeatherKind.Clear, 1f, Vector3.Zero, 0, 0, FogMode.Off, 0f, EnvironOverride.None); + var ubo = SceneLightingUbo.Build(lights, in atmo, Vector3.Zero, 0f); + + Assert.Equal((float)(int)LightKind.Directional, ubo.Light0.PosAndKind.W); + Assert.Equal(1.2f, ubo.Light0.ColorAndIntensity.W); + } +} diff --git a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs new file mode 100644 index 0000000..bbb619d --- /dev/null +++ b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs @@ -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 +{ + /// + /// 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. + /// + 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); + } +} diff --git a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs new file mode 100644 index 0000000..e2c8d48 --- /dev/null +++ b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs @@ -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); + } +} diff --git a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs new file mode 100644 index 0000000..c0d5df7 --- /dev/null +++ b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs @@ -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); + } +}