From 9957070cab86b7b62aa4e12698368945a03ed197 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:39:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Phase=20G.1/G.2=20=E2=80=94=20S?= =?UTF-8?q?ceneLighting=20UBO=20+=20sky=20renderer=20+=20shader=20integrat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the existing LightManager + WorldTimeService state into visible rendering. Every draw call (terrain, static mesh, instanced mesh, sky) now shares one SceneLighting UBO at binding=1 carrying: - 8 Light slots (Directional / Point / Spot, retail hard-cutoff) - Ambient RGB + active light count - Fog start/end/mode + color + lightning flash scalar - Camera world position + day fraction The CPU side (SceneLightingUbo in Core.Lighting) is a POD struct that gets BufferSubData'd once per frame from GameWindow.OnRender. Shaders read the block via `layout(std140, binding = 1) uniform SceneLighting` — no per-program uniform uploads. Shader changes: - mesh.frag + mesh_instanced.frag accumulate 8 dynamic lights per fragment using the retail no-attenuation hard-cutoff model (r13 §10.2 / §13.1). Sun reads slot 0; spots use hard cos-cone test. Additive lightning flash + linear fog layered on top. Saturate clamps per-channel to 1.0. - terrain.vert bakes AdjustPlanes sun+ambient per vertex using the retail MIN_FACTOR = 0.08 ambient floor (r13 §7). terrain.frag adds fog + flash on top of the baked vertex color. - mesh.vert + mesh_instanced.vert emit vWorldPos so the fragment stage can do per-pixel lighting against world-space positions. - New sky.vert / sky.frag pair — unlit, scroll-UV, camera-centered, with its own 0.1..1e6 far plane. Ports WorldBuilder's skybox. SkyRenderer (new file in App/Rendering/Sky/) ports WorldBuilder's SkyboxRenderManager verbatim for the C# idiom: zeroed view translation, dedicated projection, depth mask off, iterate each visible SkyObject in the day group, apply arc transform (Z rot for heading + Y rot for arc sweep), feed TexVelocityX/Y as a scrolling UV offset, apply per-keyframe SkyObjectReplace overrides (mesh swap + transparency + luminosity) for overcast / dusk cloud variants. GameWindow integration: - OnLoad parses Region (0x13000000) into LoadedSkyDesc and hot-swaps WorldTime's provider to the dat-accurate keyframes. Seeds to noon for offline rendering. Creates the SceneLightingUboBinding and the SkyRenderer. - OnRender: set clear color from atmosphere fog, tick WeatherSystem, spawn/stop rain/snow camera-local emitters on kind change, feed sun to LightManager (zero intensity indoors — r13 §13.7), tick LightManager against viewer pos, build + upload the UBO, draw sky before terrain, draw terrain + static + instanced using the shared UBO. 5 new UBO packing tests (struct sizes, slot population, 8-light cap, directional slot 0). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 300 +++++++++++++++++- .../Rendering/InstancedMeshRenderer.cs | 8 +- .../Rendering/SceneLightingUboBinding.cs | 67 ++++ src/AcDream.App/Rendering/Shaders/mesh.frag | 122 +++++-- src/AcDream.App/Rendering/Shaders/mesh.vert | 5 +- .../Rendering/Shaders/mesh_instanced.frag | 77 ++++- .../Rendering/Shaders/mesh_instanced.vert | 9 +- src/AcDream.App/Rendering/Shaders/sky.frag | 51 +++ src/AcDream.App/Rendering/Shaders/sky.vert | 22 ++ .../Rendering/Shaders/terrain.frag | 62 ++-- .../Rendering/Shaders/terrain.vert | 49 ++- src/AcDream.App/Rendering/Sky/SkyRenderer.cs | 292 +++++++++++++++++ .../Rendering/TerrainChunkRenderer.cs | 11 +- src/AcDream.Core/Lighting/SceneLightingUbo.cs | 150 +++++++++ .../Lighting/SceneLightingUboTests.cs | 121 +++++++ 15 files changed, 1255 insertions(+), 91 deletions(-) create mode 100644 src/AcDream.App/Rendering/SceneLightingUboBinding.cs create mode 100644 src/AcDream.App/Rendering/Shaders/sky.frag create mode 100644 src/AcDream.App/Rendering/Shaders/sky.vert create mode 100644 src/AcDream.App/Rendering/Sky/SkyRenderer.cs create mode 100644 src/AcDream.Core/Lighting/SceneLightingUbo.cs create mode 100644 tests/AcDream.Core.Tests/Lighting/SceneLightingUboTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b7a7d41..722c8a2 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -162,6 +162,23 @@ public sealed class GameWindow : IDisposable new AcDream.Core.World.WorldTimeService( AcDream.Core.World.SkyStateProvider.Default()); public readonly AcDream.Core.Lighting.LightManager Lighting = new(); + public readonly AcDream.Core.World.WeatherSystem Weather = new(); + + // Phase G.1 sky renderer + shared UBO. Created once the GL context + // exists in OnLoad; shared across every other renderer via + // binding = 1 so terrain/mesh/instanced/sky all read the same + // sun / ambient / fog / flash data per frame. + private AcDream.App.Rendering.SceneLightingUboBinding? _sceneLightingUbo; + private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer; + private AcDream.Core.World.LoadedSkyDesc? _loadedSkyDesc; + + // Current rain/snow emitter handles — spawned on weather-kind change + // and stopped when the kind leaves Rain/Snow. Non-zero == active. + private int _rainEmitterHandle; + private int _snowEmitterHandle; + private AcDream.Core.World.WeatherKind _lastWeatherKind = + AcDream.Core.World.WeatherKind.Clear; + private double _weatherAccum; // Phase B.2: player movement mode. private AcDream.App.Input.PlayerMovementController? _playerController; @@ -566,6 +583,12 @@ public sealed class GameWindow : IDisposable Path.Combine(shadersDir, "mesh_instanced.vert"), Path.Combine(shadersDir, "mesh_instanced.frag")); + // Phase G.1/G.2: shared scene-lighting UBO. Stays bound at + // binding=1 for the lifetime of the process — every shader that + // declares `layout(std140, binding = 1) uniform SceneLighting` + // reads from this without further intervention. + _sceneLightingUbo = new SceneLightingUboBinding(_gl); + _debugLines = new DebugLineRenderer(_gl, shadersDir); // Debug HUD: load a system monospace font and set up the text overlay. @@ -640,6 +663,35 @@ public sealed class GameWindow : IDisposable if (heightTable is null || heightTable.Length < 256) throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated"); + // Phase G.1: parse the full sky descriptor (day groups, keyframes, + // celestial mesh layers) and swap WorldTime's provider over to the + // dat-backed keyframes. The stub default provider is only used if + // the Region lacks HasSkyInfo. + if (region is not null) + { + _loadedSkyDesc = AcDream.Core.World.SkyDescLoader.LoadFromRegion(region); + if (_loadedSkyDesc is not null) + { + WorldTime.SetProvider(_loadedSkyDesc.BuildDefaultProvider()); + WorldTime.TickSize = _loadedSkyDesc.TickSize > 0 ? _loadedSkyDesc.TickSize : 1.0; + Console.WriteLine( + $"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " + + $"TickSize={_loadedSkyDesc.TickSize}, LightTickSize={_loadedSkyDesc.LightTickSize}"); + if (_loadedSkyDesc.DefaultDayGroup is not null) + { + Console.WriteLine( + $"sky: default group '{_loadedSkyDesc.DefaultDayGroup.Name}' has " + + $"{_loadedSkyDesc.DefaultDayGroup.SkyObjects.Count} sky objects, " + + $"{_loadedSkyDesc.DefaultDayGroup.SkyTimes.Count} keyframes"); + } + } + } + + // Seed WorldTime to noon so outdoor scenes aren't pitch-black before + // the server sends its first TimeSync packet (offline rendering in + // particular never receives one). + WorldTime.SyncFromServer(AcDream.Core.World.DerethDateTime.DayTicks * 0.5); + // Build the terrain atlas once from the Region dat. var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats); @@ -676,6 +728,15 @@ public sealed class GameWindow : IDisposable _textureCache = new TextureCache(_gl, _dats); _staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache); + // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) + // with depth writes off + far plane 1e6 so celestial meshes + // never clip. Shares the TextureCache with the static pipeline. + var skyShader = new Shader(_gl, + Path.Combine(shadersDir, "sky.vert"), + Path.Combine(shadersDir, "sky.frag")); + _skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer( + _gl, _dats, skyShader, _textureCache); + // Phase A.1: replace the one-shot 3×3 preload with a streaming controller. // Parse runtime radius from environment (default 2 → 5×5 window). // Values outside [0, 8] fall back to the field default of 2. @@ -2863,6 +2924,16 @@ public sealed class GameWindow : IDisposable private void OnRender(double deltaSeconds) { + // Phase G.1: set the clear color from the current sky's fog + // tint so the horizon band continues naturally past the + // rendered geometry. Fog blends to this color at max distance + // so there's no visible seam. Updated each frame from the + // interpolated keyframe. + var kf = WorldTime.CurrentSky; + var atmo = Weather.Snapshot(in kf); + var fogColor = atmo.FogColor; + _gl!.ClearColor(fogColor.X, fogColor.Y, fogColor.Z, 1f); + _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); // Phase 6.4: advance per-entity animation playback before drawing @@ -2870,6 +2941,21 @@ public sealed class GameWindow : IDisposable if (_animatedEntities.Count > 0) TickAnimations((float)deltaSeconds); + // Phase G.1: weather state machine — deterministic per-day roll + // + transitions + lightning flash. + var cal = WorldTime.CurrentCalendar; + int dayIndex = cal.Year * (AcDream.Core.World.DerethDateTime.DaysInAMonth * + AcDream.Core.World.DerethDateTime.MonthsInAYear) + + (int)cal.Month * AcDream.Core.World.DerethDateTime.DaysInAMonth + + (cal.Day - 1); + Weather.Tick(nowSeconds: _weatherAccum, dayIndex: dayIndex, dtSeconds: (float)deltaSeconds); + _weatherAccum += deltaSeconds; + + // Update the rain/snow particle emitters when the weather kind + // changes. Keep the emitters fed by the ParticleSystem tick so + // visuals stay alive frame-over-frame. + UpdateWeatherParticles(atmo); + // Phase E.3: advance live particle emitters AFTER animation tick // so emitters spawned by hooks fired this frame get integrated. _particleSystem?.Tick((float)deltaSeconds); @@ -2882,26 +2968,14 @@ public sealed class GameWindow : IDisposable var camera = _cameraController.Active; var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection); - // Never cull the landblock the player is currently on. - uint? playerLb = null; - if (_playerMode && _playerController is not null) - { - var pp = _playerController.Position; - int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); - int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); - playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); - } - _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); - - // Step 4: portal visibility — determine which interior cells to render. - // Extract camera world position from the inverse of the view matrix. + // Extract camera world position from the inverse of the view + // matrix — needed by the scene-lighting UBO (for fog distance) + // and by the sky renderer (for the camera-centered sky dome). System.Numerics.Matrix4x4.Invert(camera.View, out var invView); var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43); // Phase E.2 audio: update listener pose so 3D sounds pan/attenuate - // correctly relative to where we're looking. Fwd = -Z of the view - // matrix (OpenGL convention), up = +Y. Both live in the inverse - // view matrix's basis vectors. + // correctly relative to where we're looking. if (_audioEngine is not null && _audioEngine.IsAvailable) { var fwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33); @@ -2912,9 +2986,44 @@ public sealed class GameWindow : IDisposable up.X, up.Y, up.Z); } + // Step 4: portal visibility — compute BEFORE the UBO upload so + // the indoor flag drives the sun's intensity to zero for + // dungeons (r13 §13.7). var visibility = _cellVisibility.ComputeVisibility(camPos); bool cameraInsideCell = visibility?.CameraCell is not null; + // Phase G.1/G.2: feed the sun, tick LightManager, build + upload + // the scene-lighting UBO once per frame. Every shader that + // consumes binding=1 reads the same data for the rest of the + // frame — terrain, static mesh, instanced mesh, sky. + UpdateSunFromSky(kf, cameraInsideCell); + Lighting.Tick(camPos); + var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( + Lighting, in atmo, camPos, (float)WorldTime.DayFraction); + _sceneLightingUbo?.Upload(ubo); + + // Never cull the landblock the player is currently on. + uint? playerLb = null; + if (_playerMode && _playerController is not null) + { + var pp = _playerController.Position; + int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); + int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); + playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); + } + + // Phase G.1: sky renderer — draws the far-plane-infinity + // celestial meshes FIRST so the rest of the scene z-tests + // on top of them (depth mask off, no depth writes). Skipped + // when indoors; dungeons fully block sky visibility. + if (!cameraInsideCell) + { + _skyRenderer?.Render(camera, camPos, (float)WorldTime.DayFraction, + _loadedSkyDesc?.DefaultDayGroup); + } + + _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); + // Conditional depth clear: when camera is inside a building, clear // depth (not color) so interior geometry writes fresh Z values on top // of the terrain color buffer. Exit portals show outdoor terrain color @@ -3409,6 +3518,163 @@ public sealed class GameWindow : IDisposable ae.CurrFrame = ae.LowFrame; } + /// + /// Derive the current sun (directional light, slot 0 of the UBO) + /// from the interpolated , + /// plus the cell ambient. Indoor cells force the sun intensity to + /// zero (r13 §13.7) and substitute a fixed dungeon-tone ambient. + /// + private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool cameraInsideCell) + { + // Sun direction: points FROM the sun TOWARDS the world. Our + // shader does dot(N, -forward) so a positive N·L means the + // surface faces the sun. + var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf); + + if (cameraInsideCell) + { + // Dungeon default per r13 §3 — warm-dark ambient, no sun. + Lighting.Sun = new AcDream.Core.Lighting.LightSource + { + Kind = AcDream.Core.Lighting.LightKind.Directional, + WorldForward = sunToWorld, + ColorLinear = System.Numerics.Vector3.Zero, + Intensity = 0f, + Range = 1f, + }; + Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState( + AmbientColor: new System.Numerics.Vector3(0.10f, 0.09f, 0.08f), + SunColor: System.Numerics.Vector3.Zero, + SunDirection: sunToWorld); + } + else + { + // Outdoor: full keyframe sun + ambient; colors are already + // pre-multiplied by DirBright / AmbBright inside + // SkyDescLoader so we feed them straight into the UBO. + Lighting.Sun = new AcDream.Core.Lighting.LightSource + { + Kind = AcDream.Core.Lighting.LightKind.Directional, + WorldForward = sunToWorld, + ColorLinear = kf.SunColor, + Intensity = 1f, + Range = 1f, + }; + Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState( + AmbientColor: kf.AmbientColor, + SunColor: kf.SunColor, + SunDirection: sunToWorld); + } + } + + /// + /// Keep the rain/snow camera-anchored emitters aligned with the + /// current weather state. Spawns on entry, stops on exit, with no + /// per-frame churn while the state is stable. Emitters are camera- + /// local () + /// so walking never leaves the rain volume (r12 §7). + /// + private void UpdateWeatherParticles(in AcDream.Core.World.AtmosphereSnapshot atmo) + { + if (_particleSystem is null) return; + + if (atmo.Kind == _lastWeatherKind) return; // no change + + // Stop any existing emitters first. + if (_rainEmitterHandle != 0) + { + _particleSystem.StopEmitter(_rainEmitterHandle, fadeOut: true); + _rainEmitterHandle = 0; + } + if (_snowEmitterHandle != 0) + { + _particleSystem.StopEmitter(_snowEmitterHandle, fadeOut: true); + _snowEmitterHandle = 0; + } + + // Anchor at camera world position; AttachLocal keeps it moving. + var anchor = System.Numerics.Vector3.Zero; + if (_cameraController is not null) + { + System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var inv); + anchor = new System.Numerics.Vector3(inv.M41, inv.M42, inv.M43); + } + + switch (atmo.Kind) + { + case AcDream.Core.World.WeatherKind.Rain: + case AcDream.Core.World.WeatherKind.Storm: + _rainEmitterHandle = _particleSystem.SpawnEmitter( + BuildRainDesc(), anchor); + break; + case AcDream.Core.World.WeatherKind.Snow: + _snowEmitterHandle = _particleSystem.SpawnEmitter( + BuildSnowDesc(), anchor); + break; + } + + _lastWeatherKind = atmo.Kind; + } + + /// + /// Rain emitter tuned per r12 §7: streaks falling at ~50 m/s with + /// a slight wind bias, 500 drops/sec, 2000 max alive, 1.2s life so + /// drops cover the ~60m fall at terminal velocity. + /// + private static AcDream.Core.Vfx.EmitterDesc BuildRainDesc() => new() + { + DatId = 0xFFFF_0001u, // synthetic id + Type = AcDream.Core.Vfx.ParticleType.LocalVelocity, + Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal | + AcDream.Core.Vfx.EmitterFlags.Billboard, + EmitRate = 500f, + MaxParticles = 2000, + LifetimeMin = 1.0f, + LifetimeMax = 1.4f, + OffsetDir = new System.Numerics.Vector3(0, 0, 1), + MinOffset = 0f, + MaxOffset = 50f, + SpawnDiskRadius = 15f, + InitialVelocity = new System.Numerics.Vector3(0.5f, 0f, -50f), + VelocityJitter = 2f, + Gravity = System.Numerics.Vector3.Zero, + StartColorArgb = 0x40B0C0E0u, + EndColorArgb = 0x20B0C0E0u, + StartAlpha = 0.3f, + EndAlpha = 0f, + StartSize = 0.05f, + EndSize = 0.05f, + }; + + /// + /// Snow emitter tuned per r12 §8: slow fall at ~2 m/s, tumbling + /// sideways drift, small billboards, 100 flakes/sec, long lifespan. + /// + private static AcDream.Core.Vfx.EmitterDesc BuildSnowDesc() => new() + { + DatId = 0xFFFF_0002u, + Type = AcDream.Core.Vfx.ParticleType.LocalVelocity, + Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal | + AcDream.Core.Vfx.EmitterFlags.Billboard, + EmitRate = 100f, + MaxParticles = 1000, + LifetimeMin = 4f, + LifetimeMax = 8f, + OffsetDir = new System.Numerics.Vector3(0, 0, 1), + MinOffset = 0f, + MaxOffset = 30f, + SpawnDiskRadius = 15f, + InitialVelocity = new System.Numerics.Vector3(0.3f, 0.2f, -2f), + VelocityJitter = 0.8f, + Gravity = System.Numerics.Vector3.Zero, + StartColorArgb = 0xE0FFFFFFu, + EndColorArgb = 0x80FFFFFFu, + StartAlpha = 0.85f, + EndAlpha = 0.3f, + StartSize = 0.08f, + EndSize = 0.06f, + }; + private void OnClosing() { // Phase A.1: join the streamer worker thread before tearing down GL @@ -3422,6 +3688,8 @@ public sealed class GameWindow : IDisposable _meshShader?.Dispose(); _terrain?.Dispose(); _shader?.Dispose(); + _sceneLightingUbo?.Dispose(); + _skyRenderer?.Dispose(); _debugLines?.Dispose(); _textRenderer?.Dispose(); _debugFont?.Dispose(); diff --git a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs index 0034e50..18a67ae 100644 --- a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs +++ b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs @@ -159,10 +159,10 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable var vp = camera.View * camera.Projection; _shader.SetMatrix4("uViewProjection", vp); - // Lighting uniforms matching ACME StaticObject.vert. - var lightDir = Vector3.Normalize(new Vector3(0.5f, 0.3f, -0.3f)); - _shader.SetVec3("uLightDirection", lightDir); - _shader.SetFloat("uAmbientIntensity", 0.45f); + // Phase G: lighting + ambient + fog are owned by the + // SceneLighting UBO (binding=1) uploaded once per frame by + // GameWindow. The instanced mesh fragment shader reads it + // directly — no per-draw uniform uploads needed. // ── Collect and group instances ─────────────────────────────────────── CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds); diff --git a/src/AcDream.App/Rendering/SceneLightingUboBinding.cs b/src/AcDream.App/Rendering/SceneLightingUboBinding.cs new file mode 100644 index 0000000..92f7e8d --- /dev/null +++ b/src/AcDream.App/Rendering/SceneLightingUboBinding.cs @@ -0,0 +1,67 @@ +using System; +using System.Runtime.InteropServices; +using AcDream.Core.Lighting; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// GL wrapper that owns the SceneLighting UBO buffer, updates its +/// contents each frame, and keeps it bound at binding=1 so every +/// shader sampling uLights[] / uFogColor / etc reads +/// consistent data without per-shader re-upload. +/// +/// +/// Usage (r12 §13.2 + r13 §12.3): +/// +/// Instantiate once at startup, after the GL context exists. +/// Each frame, after , call with a freshly-built . +/// Shader programs that declare layout(std140, binding = 1) uniform SceneLighting { ... } automatically pick up the data. +/// +/// +/// +public sealed unsafe class SceneLightingUboBinding : IDisposable +{ + private readonly GL _gl; + private readonly uint _ubo; + private bool _disposed; + + public SceneLightingUboBinding(GL gl) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _ubo = _gl.GenBuffer(); + _gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo); + // Pre-allocate with the final size; BufferSubData each frame. + _gl.BufferData( + BufferTargetARB.UniformBuffer, + (nuint)SceneLightingUbo.SizeInBytes, + (void*)0, + BufferUsageARB.DynamicDraw); + _gl.BindBuffer(BufferTargetARB.UniformBuffer, 0); + + // Bind the buffer to the chosen binding point exactly once — shaders + // that declare this binding in their layout block will read from it + // on every draw without further intervention. + _gl.BindBufferBase(BufferTargetARB.UniformBuffer, + SceneLightingUbo.BindingPoint, _ubo); + } + + /// + /// Push the current frame's UBO contents to the GPU. Cheap (576 bytes) + /// so fine to call unconditionally every frame. + /// + public void Upload(SceneLightingUbo data) + { + _gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo); + _gl.BufferSubData(BufferTargetARB.UniformBuffer, + (nint)0, (nuint)SceneLightingUbo.SizeInBytes, &data); + _gl.BindBuffer(BufferTargetARB.UniformBuffer, 0); + } + + public void Dispose() + { + if (_disposed) return; + _gl.DeleteBuffer(_ubo); + _disposed = true; + } +} diff --git a/src/AcDream.App/Rendering/Shaders/mesh.frag b/src/AcDream.App/Rendering/Shaders/mesh.frag index f8570ac..94cc31f 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh.frag @@ -1,6 +1,7 @@ #version 430 core in vec2 vTex; in vec3 vWorldNormal; +in vec3 vWorldPos; out vec4 fragColor; uniform sampler2D uDiffuse; @@ -11,35 +12,114 @@ uniform sampler2D uDiffuse; // 2 = AlphaBlend — GL blending handles compositing; do NOT discard // 3 = Additive — GL additive blending; do NOT discard // 4 = InvAlpha — GL inverted-alpha blending; do NOT discard -// -// Only ClipMap uses the alpha-discard path. AlphaBlend/Additive/InvAlpha -// rely entirely on the GL blend stage — discarding low-alpha fragments -// would make semi-transparent surfaces (portals, glows) fully invisible. uniform int uTranslucencyKind; -// Phase 3a: simple directional lighting. A single sun direction + ambient term -// gives scenery and building faces enough differentiation to read as 3D instead -// of looking like paper cutouts. Hardcoded for now; a later phase can route -// light parameters through uniforms driven by the game's time-of-day. -// Sun direction tuned after Phase 3a verification: (0.4,0.3,0.8) was too -// vertical — roofs and ground both landed near peak brightness and only -// walls dropped, so the contrast was hard to read through textures. More -// oblique + lower ambient + higher diffuse = contrast ratio ~3.3x. -const vec3 SUN_DIR = normalize(vec3(0.5, 0.4, 0.6)); -const float AMBIENT = 0.25; -const float DIFFUSE = 0.75; +// ───────────────────────────────────────────────────────────── +// Phase G.1+G.2: shared scene-lighting UBO (binding = 1). +// +// Layout mirrors SceneLightingUbo in C#: +// struct Light { +// vec4 posAndKind; xyz = world pos, w = kind (0=dir,1=point,2=spot) +// vec4 dirAndRange; xyz = forward, w = range (metres, hard cutoff) +// vec4 colorAndIntensity; xyz = RGB linear, w = intensity +// vec4 coneAngleEtc; x = cone (rad), yzw = reserved +// }; +// layout(std140, binding = 1) uniform SceneLighting { +// Light uLights[8]; +// vec4 uCellAmbient; xyz = ambient RGB, w = active count +// vec4 uFogParams; x = start, y = end, z = flash, w = mode +// vec4 uFogColor; xyz = color +// vec4 uCameraAndTime; xyz = camera pos, w = day fraction +// }; +// ───────────────────────────────────────────────────────────── +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; + +// Retail hard-cutoff lighting equation (r13 §10.2). No distance +// attenuation inside Range; hard edge at Range; spotlights use a +// binary cos-cone test. This is deliberate — the retail "bubble of +// light" look relies on crisp boundaries. +vec3 accumulateLights(vec3 N, vec3 worldPos) { + vec3 lit = uCellAmbient.xyz; + int active = int(uCellAmbient.w); + for (int i = 0; i < 8; ++i) { + if (i >= active) break; + + int kind = int(uLights[i].posAndKind.w); + vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w; + + if (kind == 0) { + // Directional: "forward" is the light's direction vector + // pointing INTO the scene. N·(-forward) = light-facing. + vec3 Ldir = -uLights[i].dirAndRange.xyz; + float ndl = max(0.0, dot(N, Ldir)); + lit += Lcol * ndl; + } else { + // Point / spot: falloff is a HARD bubble at Range. + vec3 toL = uLights[i].posAndKind.xyz - worldPos; + float d = length(toL); + float range = uLights[i].dirAndRange.w; + if (d < range && range > 1e-3) { + vec3 Ldir = toL / max(d, 1e-4); + float ndl = max(0.0, dot(N, Ldir)); + float atten = 1.0; // retail: no attenuation inside Range + if (kind == 2) { + // Spotlight: hard-edged cos-cone test. + float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); + float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); + atten *= (cos_l > cos_edge) ? 1.0 : 0.0; + } + lit += Lcol * ndl * atten; + } + } + } + return lit; +} + +// Linear fog (r12 §5.1): mode 1 = LINEAR, 0 = off, others reserved. +vec3 applyFog(vec3 lit, vec3 worldPos) { + int mode = int(uFogParams.w); + if (mode == 0) return lit; + float d = length(worldPos - uCameraAndTime.xyz); + float fogStart = uFogParams.x; + float fogEnd = uFogParams.y; + float span = max(1e-3, fogEnd - fogStart); + float fog = clamp((d - fogStart) / span, 0.0, 1.0); + return mix(lit, uFogColor.xyz, fog); +} void main() { vec4 sampled = texture(uDiffuse, vTex); // Alpha cutout only for clip-map surfaces (doors, windows, vegetation). - // Blended surface types (AlphaBlend, Additive, InvAlpha) must NOT - // discard here — that would make every semi-transparent pixel invisible - // before the blend stage even runs. if (uTranslucencyKind == 1 && sampled.a < 0.5) discard; vec3 N = normalize(vWorldNormal); - float ndotl = max(dot(N, SUN_DIR), 0.0); - float lighting = AMBIENT + DIFFUSE * ndotl; - fragColor = vec4(sampled.rgb * lighting, sampled.a); + vec3 lit = accumulateLights(N, vWorldPos); + + // Lightning flash (r12 §9) — additive cold-white pulse layered on top + // of diffuse lighting. + float flash = uFogParams.z; + lit += flash * vec3(0.6, 0.6, 0.75); + + // Clamp per-channel to 1.0 — matches retail (r13 §13.1). + lit = min(lit, vec3(1.0)); + + vec3 rgb = sampled.rgb * lit; + + // Atmospheric fog — applied after lighting. + rgb = applyFog(rgb, vWorldPos); + + fragColor = vec4(rgb, sampled.a); } diff --git a/src/AcDream.App/Rendering/Shaders/mesh.vert b/src/AcDream.App/Rendering/Shaders/mesh.vert index 509ee49..8f9134f 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh.vert @@ -9,6 +9,7 @@ uniform mat4 uProjection; out vec2 vTex; out vec3 vWorldNormal; +out vec3 vWorldPos; void main() { vTex = aTex; @@ -17,5 +18,7 @@ void main() { // scale would require the inverse transpose; we accept that as a Phase 3+ // concern. vWorldNormal = normalize(mat3(uModel) * aNormal); - gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0); + vec4 world = uModel * vec4(aPos, 1.0); + vWorldPos = world.xyz; + gl_Position = uProjection * uView * world; } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_instanced.frag b/src/AcDream.App/Rendering/Shaders/mesh_instanced.frag index 0ad961b..6199aa4 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_instanced.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_instanced.frag @@ -2,7 +2,7 @@ in vec2 vTex; in vec3 vWorldNormal; -in float vLightingFactor; +in vec3 vWorldPos; out vec4 fragColor; @@ -18,14 +18,81 @@ uniform sampler2D uDiffuse; // 4 = InvAlpha — GL inverted-alpha blending; do NOT discard uniform int uTranslucencyKind; +// Phase G.1+G.2: shared scene-lighting UBO (see mesh.frag for layout docs). +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; + +vec3 accumulateLights(vec3 N, vec3 worldPos) { + vec3 lit = uCellAmbient.xyz; + int active = int(uCellAmbient.w); + for (int i = 0; i < 8; ++i) { + if (i >= active) break; + + int kind = int(uLights[i].posAndKind.w); + vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w; + + if (kind == 0) { + vec3 Ldir = -uLights[i].dirAndRange.xyz; + float ndl = max(0.0, dot(N, Ldir)); + lit += Lcol * ndl; + } else { + vec3 toL = uLights[i].posAndKind.xyz - worldPos; + float d = length(toL); + float range = uLights[i].dirAndRange.w; + if (d < range && range > 1e-3) { + vec3 Ldir = toL / max(d, 1e-4); + float ndl = max(0.0, dot(N, Ldir)); + float atten = 1.0; + if (kind == 2) { + float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); + float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); + atten *= (cos_l > cos_edge) ? 1.0 : 0.0; + } + lit += Lcol * ndl * atten; + } + } + } + return lit; +} + +vec3 applyFog(vec3 lit, vec3 worldPos) { + int mode = int(uFogParams.w); + if (mode == 0) return lit; + float d = length(worldPos - uCameraAndTime.xyz); + float fogStart = uFogParams.x; + float fogEnd = uFogParams.y; + float span = max(1e-3, fogEnd - fogStart); + float fog = clamp((d - fogStart) / span, 0.0, 1.0); + return mix(lit, uFogColor.xyz, fog); +} + void main() { vec4 color = texture(uDiffuse, vTex); // Alpha cutout only for clip-map surfaces (doors, windows, vegetation). - // Blended surface types must NOT discard here — that kills every - // semi-transparent pixel before the blend stage runs. if (uTranslucencyKind == 1 && color.a < 0.5) discard; - // Apply pre-computed Lambert + ambient lighting factor from the vertex shader. - fragColor = vec4(color.rgb * vLightingFactor, color.a); + vec3 N = normalize(vWorldNormal); + vec3 lit = accumulateLights(N, vWorldPos); + + // Lightning flash — additive scene bump. + lit += uFogParams.z * vec3(0.6, 0.6, 0.75); + + // Retail clamp per-channel to 1.0 (r13 §13.1). + lit = min(lit, vec3(1.0)); + + vec3 rgb = color.rgb * lit; + rgb = applyFog(rgb, vWorldPos); + fragColor = vec4(rgb, color.a); } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_instanced.vert b/src/AcDream.App/Rendering/Shaders/mesh_instanced.vert index e9c6896..a2f3893 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_instanced.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_instanced.vert @@ -16,12 +16,10 @@ layout(location = 5) in vec4 aInstanceRow2; layout(location = 6) in vec4 aInstanceRow3; uniform mat4 uViewProjection; -uniform vec3 uLightDirection; // world-space light direction (points FROM sun, matching ACME) -uniform float uAmbientIntensity; out vec2 vTex; out vec3 vWorldNormal; -out float vLightingFactor; +out vec3 vWorldPos; void main() { // Reconstruct the per-instance model matrix from its four row vectors. @@ -30,11 +28,8 @@ void main() { vec4 worldPos = model * vec4(aPosition, 1.0); gl_Position = uViewProjection * worldPos; + vWorldPos = worldPos.xyz; // Transform normal into world space. vWorldNormal = normalize(mat3(model) * aNormal); vTex = aTexCoord; - - // Lambert + ambient matching ACME StaticObject.vert: - // LightingFactor = max(dot(Normal, -uLightDirection), 0.0) + uAmbientIntensity; - vLightingFactor = max(dot(vWorldNormal, -uLightDirection), 0.0) + uAmbientIntensity; } diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag new file mode 100644 index 0000000..8945781 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -0,0 +1,51 @@ +#version 430 core +// Sky mesh fragment shader — sample the object's diffuse texture with +// the scrolled UVs from the vertex stage. Unlit: sky meshes ARE the +// gradient (r12 §2.2), not a surface lit by the sun. +// +// The per-keyframe replace override can dim the mesh (Transparent) or +// brighten it (Luminosity); those two floats arrive as uTransparency / +// uLuminosity uniforms. + +in vec2 vTex; +out vec4 fragColor; + +uniform sampler2D uDiffuse; +uniform float uTransparency; // 0 = fully visible, 1 = invisible +uniform float uLuminosity; // 1 = normal, >1 makes the mesh glow +uniform vec4 uTint; // per-object color tint (default white) + +// Shared SceneLighting UBO — we only need the fog parameters to let the +// horizon band of the sky blend smoothly into the scene's fog color at +// the far edge, and the lightning flash to give storms their signature +// strobe. +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; + +void main() { + vec4 sampled = texture(uDiffuse, vTex); + + // Apply tint + luminosity. Retail's SkyObjReplace.Luminosity can push + // above 1 to make the sun mesh brighter than its texture; r12 §2.3. + vec3 rgb = sampled.rgb * uTint.rgb * uLuminosity; + + // Lightning additive bump — makes the sky itself flash during storms. + rgb += uFogParams.z * vec3(0.5, 0.5, 0.55); + + rgb = min(rgb, vec3(1.2)); // soft clamp to let luminosity over-bright mildly + + float a = sampled.a * (1.0 - uTransparency) * uTint.a; + if (a < 0.01) discard; + fragColor = vec4(rgb, a); +} diff --git a/src/AcDream.App/Rendering/Shaders/sky.vert b/src/AcDream.App/Rendering/Shaders/sky.vert new file mode 100644 index 0000000..35df9ca --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/sky.vert @@ -0,0 +1,22 @@ +#version 430 core +// Sky mesh vertex shader — each celestial object is a GfxObj mesh +// (sun billboard, cloud sheet, moon, star dome) rendered at large +// distance with depth writes disabled. The view matrix has its +// translation zeroed so the sky stays camera-centered; the projection +// matrix has a huge far plane so 1e6-metre-away sky meshes never clip. + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTex; + +uniform mat4 uModel; // per-object arc transform (r12 §2.1) +uniform mat4 uSkyView; // camera view with M41..M43 = 0 +uniform mat4 uSkyProjection; // near=0.1, far=1e6 +uniform vec2 uUvScroll; // cumulative TexVelocityX/Y * time + +out vec2 vTex; + +void main() { + vTex = aTex + uUvScroll; + gl_Position = uSkyProjection * uSkyView * uModel * vec4(aPos, 1.0); +} diff --git a/src/AcDream.App/Rendering/Shaders/terrain.frag b/src/AcDream.App/Rendering/Shaders/terrain.frag index f615929..479939d 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain.frag +++ b/src/AcDream.App/Rendering/Shaders/terrain.frag @@ -1,12 +1,14 @@ #version 430 core // Per-cell terrain blending (Phase 3c.4) — ported from WorldBuilder's // Landscape.frag, trimmed of editor-specific features (grid, brush, -// walkable-slope highlighting) and with Phase 3a/3b directional lighting -// layered on at the end. +// walkable-slope highlighting). Phase G extends this with the shared +// SceneLighting UBO driving per-vertex sun bake + fragment-stage fog +// + lightning flash. in vec2 vBaseUV; in vec3 vWorldNormal; -in float vLightingFactor; +in vec3 vWorldPos; +in vec3 vLightingRGB; in vec4 vOverlay0; in vec4 vOverlay1; in vec4 vOverlay2; @@ -18,24 +20,34 @@ out vec4 fragColor; uniform sampler2DArray uTerrain; // 33+ layers — TerrainAtlas.GlTexture uniform sampler2DArray uAlpha; // 8+ layers — TerrainAtlas.GlAlphaTexture -uniform float xAmbient; // ambient intensity (matching ACME Landscape.frag) + +// Shared scene-lighting UBO — fog + flash are consumed here; the per-vertex +// AdjustPlanes bake already incorporated sun + ambient. +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; // Per-texture tiling repeat count across a cell. WorldBuilder uses // uTexTiling[36] uploaded from the dats; we default to 1.0 (one tile per -// cell, 8 tiles across a landblock) until we wire the array. The previous -// Phase 2b/3 single-layer path tiled at ~2 per cell, so the world may read -// slightly coarser at 1.0 — tunable here if it looks wrong. +// cell, 8 tiles across a landblock). const float TILE = 1.0; -// Three-layer alpha-weighted composite. Each terrain overlay layer -// contributes based on its own alpha mask; missing layers (h == 0) collapse -// to transparent. Lifted verbatim from WorldBuilder's Landscape.frag. +// Three-layer alpha-weighted composite. vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) { float a0 = h0 == 0.0 ? 1.0 : t0.a; float a1 = h1 == 0.0 ? 1.0 : t1.a; float a2 = h2 == 0.0 ? 1.0 : t2.a; float aR = 1.0 - (a0 * a1 * a2); - // avoid divide-by-zero when all three overlays are absent float aRsafe = max(aR, 1e-6); a0 = 1.0 - a0; a1 = 1.0 - a1; @@ -82,7 +94,6 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) { result = texture(uTerrain, vec3(baseUV * TILE, pRoad0.z)); if (pRoad0.w >= 0.0) { vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w)); - // Roads use inverted alpha (the mask stores NON-road coverage). result.a = 1.0 - a0.a; if (h1 > 0.0 && pRoad1.w >= 0.0) { vec4 a1 = texture(uAlpha, vec3(pRoad1.xy, pRoad1.w)); @@ -93,9 +104,18 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) { return result; } +vec3 applyFog(vec3 lit, vec3 worldPos) { + int mode = int(uFogParams.w); + if (mode == 0) return lit; + float d = length(worldPos - uCameraAndTime.xyz); + float fogStart = uFogParams.x; + float fogEnd = uFogParams.y; + float span = max(1e-3, fogEnd - fogStart); + float fog = clamp((d - fogStart) / span, 0.0, 1.0); + return mix(lit, uFogColor.xyz, fog); +} + void main() { - // Base color: if there's no base layer (sentinel -1) just render black - // (shouldn't happen in valid data). vec4 baseColor = vec4(0.0); if (vBaseTexIdx >= 0.0) { baseColor = texture(uTerrain, vec3(vBaseUV * TILE, vBaseTexIdx)); @@ -115,9 +135,15 @@ void main() { vec3 roadMasked = roads.rgb * roads.a; vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0); - // Lighting matching ACME Landscape.frag: - // litColor = finalColor * (saturate(vLightingFactor) + xAmbient); - vec3 litColor = rgb * (clamp(vLightingFactor, 0.0, 1.0) + xAmbient); + // Apply the per-vertex baked sun+ambient. + vec3 lit = rgb * min(vLightingRGB, vec3(1.0)); - fragColor = vec4(litColor, 1.0); + // Lightning flash — additive. + float flash = uFogParams.z; + lit += flash * vec3(0.6, 0.6, 0.75); + + // Atmospheric fog. + lit = applyFog(lit, vWorldPos); + + fragColor = vec4(lit, 1.0); } diff --git a/src/AcDream.App/Rendering/Shaders/terrain.vert b/src/AcDream.App/Rendering/Shaders/terrain.vert index c72295d..4b77642 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain.vert +++ b/src/AcDream.App/Rendering/Shaders/terrain.vert @@ -8,11 +8,28 @@ layout(location = 5) in uvec4 aPacked3; // bits: rot fields + splitDir (see bel uniform mat4 uView; uniform mat4 uProjection; -uniform vec3 xLightDirection; // world-space sun direction (matching ACME Landscape.vert) + +// Phase G.1+G.2: sky/scene UBO. Terrain reads uLights[0] for the sun +// (slot 0 is reserved) plus uCellAmbient for outdoor ambient; the fog +// fields are consumed by the fragment stage. +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; out vec2 vBaseUV; out vec3 vWorldNormal; -out float vLightingFactor; +out vec3 vWorldPos; +out vec3 vLightingRGB; // pre-computed sun+ambient contribution for retail-style AdjustPlanes bake // Per-layer "UV.xy in cell-local 0..1 space, tex index .z, alpha index .w". // Negative .z means "layer not present, skip it in the fragment shader." out vec4 vOverlay0; @@ -22,6 +39,11 @@ out vec4 vRoad0; out vec4 vRoad1; flat out float vBaseTexIdx; +// Retail's "ambient floor" constant from the decompiled AdjustPlanes +// path (r13 §7, DAT_00796344). Even a back-lit vertex sees at least +// this fraction of the sun color — NOT additive with ambient. +const float MIN_FACTOR = 0.08; + // Port of WorldBuilder's Landscape.vert unpackOverlayLayer: sentinel-check // 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's // 90° rotation count. @@ -56,13 +78,6 @@ void main() { // gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a // specific order for each split direction; the table below must stay // in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches. - // - // Corner labels: 0=BL (low x/y), 1=BR (high x, low y), - // 2=TR (high x/y), 3=TL (low x, high y). - // WorldBuilder assigns cell-local UV per corner: - // 0 → (0, 1) 1 → (1, 1) 2 → (1, 0) 3 → (0, 0) - // (the v axis is flipped vs. geometric convention — harmless, just a - // texture-space choice). int vIdx = gl_VertexID % 6; int corner = 0; if (splitDir == 0u) { @@ -90,12 +105,20 @@ void main() { else baseUV = vec2(0.0, 0.0); vBaseUV = baseUV; - // Vertices are baked in world space; normals need no model transform. + vWorldPos = aPos; vWorldNormal = normalize(aNormal); - // Lambert diffuse term matching ACME Landscape.vert: - // vLightingFactor = max(0.0, dot(vNormal, -normalize(xLightDirection))); - vLightingFactor = max(0.0, dot(vWorldNormal, -normalize(xLightDirection))); + // Retail AdjustPlanes bake (r13 §7): + // L = max(N · -sunDir, MIN_FACTOR) + // vertex.color = sun_color * L + ambient_color + // + // Slot 0 of the UBO is the sun (directional). We read its forward + // vector and pre-multiplied color, apply the ambient floor, layer + // in the scene ambient separately. + vec3 sunDir = uLights[0].dirAndRange.xyz; + vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w; + float L = max(dot(vWorldNormal, -sunDir), MIN_FACTOR); + vLightingRGB = sunCol * L + uCellAmbient.xyz; float baseTex = float(aPacked0.x); if (baseTex >= 254.0) baseTex = -1.0; diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs new file mode 100644 index 0000000..ba7a58b --- /dev/null +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.Core.Meshing; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Sky; + +/// +/// Port of references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs. +/// Draws the retail sky as a stack of independent celestial meshes (the +/// "it's not a dome" insight from r12 §2) rather than a cube/sphere +/// with a gradient texture. Each is +/// visible in a window of day-fraction space, sweeps from +/// BeginAngle to EndAngle across the sky, and samples its +/// texture with a per-frame UV scroll driven by TexVelocityX/Y. +/// +/// +/// GL state delta per frame: +/// +/// Depth mask OFF, depth test OFF, cull OFF — the sky +/// should never occlude scene geometry. +/// Separate projection matrix with a 0.1–1e6 near/far +/// so mesh vertices at large distance don't clip. +/// View matrix with translation zeroed — sky is +/// always camera-centred; moving doesn't get you closer to the +/// sun. +/// +/// +/// +/// +/// Meshes are built lazily per GfxObj id on first reference. The +/// per-object arc transform matches WorldBuilder's composition: +/// scale × RotZ(-heading) × RotY(-rotation) — the negative signs +/// come from AC's Z-up right-handed convention where heading is +/// measured clockwise from north. +/// +/// +public sealed unsafe class SkyRenderer : IDisposable +{ + private readonly GL _gl; + private readonly DatCollection _dats; + private readonly Shader _shader; + private readonly TextureCache _textures; + + // Lazily-built GPU resources per sky-GfxObj. + private readonly Dictionary> _gpuByGfxObj = new(); + + // When did we start running — used to accumulate TexVelocityX/Y over + // real time (independent of the day-fraction clock). + private readonly DateTime _startedAt = DateTime.UtcNow; + + // Configurable render distance — retail uses ~1e6; anything larger + // than the scene far plane works. + public float Near { get; set; } = 0.1f; + public float Far { get; set; } = 1_000_000f; + + public SkyRenderer(GL gl, DatCollection dats, Shader shader, TextureCache textures) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _shader = shader ?? throw new ArgumentNullException(nameof(shader)); + _textures = textures ?? throw new ArgumentNullException(nameof(textures)); + } + + /// + /// Draw the sky for this frame. Called FIRST in the render loop — + /// terrain / meshes / debug lines / overlay land on top. + /// + public void Render( + ICamera camera, + Vector3 cameraWorldPos, + float dayFraction, + DayGroupData? group) + { + if (group is null || group.SkyObjects.Count == 0) return; + + // Build a sky projection with a huge far plane so 1e6m-distant + // celestial meshes don't clip. The FOV is cargo-culted from the + // camera's projection — see WorldBuilder's implementation. + float fovY = MathF.PI / 3f; // 60° — matches FlyCamera/ChaseCamera + float aspect = camera.Aspect; + if (aspect <= 0f) aspect = 16f / 9f; + var skyProj = Matrix4x4.CreatePerspectiveFieldOfView(fovY, aspect, Near, Far); + + // View with translation zeroed — keeps the sky at camera origin + // regardless of camera position in the world. + var skyView = camera.View; + skyView.M41 = 0f; + skyView.M42 = 0f; + skyView.M43 = 0f; + + _shader.Use(); + _shader.SetMatrix4("uSkyView", skyView); + _shader.SetMatrix4("uSkyProjection", skyProj); + + // Save + override GL state. + _gl.DepthMask(false); + _gl.Disable(EnableCap.DepthTest); + _gl.Disable(EnableCap.CullFace); + _gl.Enable(EnableCap.Blend); + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); + + // Look up the keyframe's override list so we can apply + // SkyObjReplace (r12 §2.3): per-keyframe GfxObj swaps + rotation + // override + transparency fade + luminosity cap. + var replaces = PickReplaces(group, dayFraction); + + float secondsSinceStart = (float)(DateTime.UtcNow - _startedAt).TotalSeconds; + + for (int i = 0; i < group.SkyObjects.Count; i++) + { + var obj = group.SkyObjects[i]; + if (!obj.IsVisible(dayFraction)) continue; + + // Apply per-keyframe replace overrides. + uint gfxObjId = obj.GfxObjId; + float headingDeg = 0f; + float transparent = 0f; + float luminosity = 1f; + if (replaces.TryGetValue((uint)i, out var rep)) + { + if (rep.GfxObjId != 0) gfxObjId = rep.GfxObjId; + if (rep.Rotate != 0f) headingDeg = rep.Rotate; + transparent = Math.Clamp(rep.Transparent, 0f, 1f); + if (rep.Luminosity > 0f) luminosity = rep.Luminosity; + if (rep.MaxBright > 0f) luminosity = MathF.Min(luminosity, rep.MaxBright); + } + if (gfxObjId == 0) continue; + + // Current arc angle across the sky. + float rotationDeg = obj.CurrentAngle(dayFraction); + float headingRad = headingDeg * (MathF.PI / 180f); + float rotationRad = rotationDeg * (MathF.PI / 180f); + + // Matches WorldBuilder's composition for a Z-up right-handed + // frame with heading measured clockwise from north. + var model = Matrix4x4.CreateScale(1.0f) + * Matrix4x4.CreateRotationZ(-headingRad) + * Matrix4x4.CreateRotationY(-rotationRad); + + _shader.SetMatrix4("uModel", model); + + // UV scroll accumulates real-time × velocity. Wrap to [0, 1] + // so long-running sessions don't accumulate float precision + // loss in the fragment UV. + float uOffset = (obj.TexVelocityX * secondsSinceStart) % 1f; + float vOffset = (obj.TexVelocityY * secondsSinceStart) % 1f; + _shader.SetVec2("uUvScroll", new Vector2(uOffset, vOffset)); + _shader.SetFloat("uTransparency", transparent); + _shader.SetFloat("uLuminosity", luminosity); + _shader.SetVec4("uTint", Vector4.One); + + EnsureMeshUploaded(gfxObjId); + if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes)) continue; + + foreach (var sub in subMeshes) + { + uint tex = _textures.GetOrUpload(sub.SurfaceId); + _gl.ActiveTexture(TextureUnit.Texture0); + _gl.BindTexture(TextureTarget.Texture2D, tex); + _gl.BindVertexArray(sub.Vao); + _gl.DrawElements(PrimitiveType.Triangles, + (uint)sub.IndexCount, + DrawElementsType.UnsignedInt, + (void*)0); + } + } + + // Restore GL state expected by the rest of the pipeline. + _gl.Disable(EnableCap.Blend); + _gl.DepthMask(true); + _gl.Enable(EnableCap.DepthTest); + _gl.BindVertexArray(0); + } + + /// + /// Find the entries for the + /// keyframe currently "active" at . + /// Matches WorldBuilder's single-keyframe lookup (it picks t1 + /// and doesn't interpolate the replace fields). + /// + private static Dictionary PickReplaces( + DayGroupData group, float dayFraction) + { + var result = new Dictionary(); + var times = group.SkyTimes; + if (times.Count == 0) return result; + + // Pick k1 = last keyframe with Begin <= dayFraction. + DatSkyKeyframeData k1 = times[^1]; + for (int i = 0; i < times.Count; i++) + { + if (times[i].Keyframe.Begin <= dayFraction) + k1 = times[i]; + else + break; + } + + foreach (var r in k1.Replaces) + result[r.ObjectIndex] = r; + + return result; + } + + /// + /// Lazy GfxObj build — reuses so the + /// pos/neg polygon splitting logic stays consistent with the main + /// static-mesh pipeline. Most sky meshes are single-surface. + /// + private void EnsureMeshUploaded(uint gfxObjId) + { + if (_gpuByGfxObj.ContainsKey(gfxObjId)) return; + + var gfx = _dats.Get(gfxObjId); + if (gfx is null) + { + _gpuByGfxObj[gfxObjId] = new List(); + return; + } + + var subMeshes = GfxObjMesh.Build(gfx, _dats); + var gpuList = new List(subMeshes.Count); + foreach (var sm in subMeshes) + gpuList.Add(UploadSubMesh(sm)); + _gpuByGfxObj[gfxObjId] = gpuList; + } + + private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm) + { + uint vao = _gl.GenVertexArray(); + _gl.BindVertexArray(vao); + + uint vbo = _gl.GenBuffer(); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo); + fixed (void* p = sm.Vertices) + _gl.BufferData(BufferTargetARB.ArrayBuffer, + (nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw); + + uint ebo = _gl.GenBuffer(); + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo); + fixed (void* p = sm.Indices) + _gl.BufferData(BufferTargetARB.ElementArrayBuffer, + (nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw); + + uint stride = (uint)sizeof(Vertex); + _gl.EnableVertexAttribArray(0); + _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0); + _gl.EnableVertexAttribArray(1); + _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float))); + _gl.EnableVertexAttribArray(2); + _gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float))); + + _gl.BindVertexArray(0); + return new SubMeshGpu + { + Vao = vao, + Vbo = vbo, + Ebo = ebo, + IndexCount = sm.Indices.Length, + SurfaceId = sm.SurfaceId, + }; + } + + public void Dispose() + { + foreach (var subs in _gpuByGfxObj.Values) + { + foreach (var sub in subs) + { + _gl.DeleteBuffer(sub.Vbo); + _gl.DeleteBuffer(sub.Ebo); + _gl.DeleteVertexArray(sub.Vao); + } + } + _gpuByGfxObj.Clear(); + } + + private sealed class SubMeshGpu + { + public uint Vao; + public uint Vbo; + public uint Ebo; + public int IndexCount; + public uint SurfaceId; + } +} diff --git a/src/AcDream.App/Rendering/TerrainChunkRenderer.cs b/src/AcDream.App/Rendering/TerrainChunkRenderer.cs index ba619d9..cd2df6a 100644 --- a/src/AcDream.App/Rendering/TerrainChunkRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainChunkRenderer.cs @@ -218,12 +218,11 @@ public sealed unsafe class TerrainChunkRenderer : IDisposable _shader.SetMatrix4("uView", camera.View); _shader.SetMatrix4("uProjection", camera.Projection); - // Lighting uniforms matching ACME Landscape.vert/frag. - // LightDirection (0.5, 0.3, -0.3) from ACME GameScene.cs:238. - // AmbientLightIntensity 0.45 from ACME LandscapeEditorSettings.cs:108. - var lightDir = Vector3.Normalize(new Vector3(0.5f, 0.3f, -0.3f)); - _shader.SetVec3("xLightDirection", lightDir); - _shader.SetFloat("xAmbient", 0.45f); + // Phase G: light direction + ambient + fog come from the shared + // SceneLighting UBO (binding=1) uploaded by GameWindow once per + // frame. Terrain bakes per-vertex AdjustPlanes lighting (r13 §7) + // from the UBO's slot-0 sun + uCellAmbient, then the fragment + // stage adds fog + lightning flash. No per-program uniforms here. // Terrain atlas on unit 0, alpha atlas on unit 1. _gl.ActiveTexture(TextureUnit.Texture0); diff --git a/src/AcDream.Core/Lighting/SceneLightingUbo.cs b/src/AcDream.Core/Lighting/SceneLightingUbo.cs new file mode 100644 index 0000000..85d721f --- /dev/null +++ b/src/AcDream.Core/Lighting/SceneLightingUbo.cs @@ -0,0 +1,150 @@ +using System; +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.Core.World; + +namespace AcDream.Core.Lighting; + +/// +/// GPU-facing scene-lighting UBO layout. Matches the GLSL block in +/// mesh.frag / mesh_instanced.vert / terrain.vert +/// bound at binding=1. std140-compliant — each vec4 member +/// lives on a 16-byte boundary, arrays of vec4 pack contiguously, +/// and no pad elements are required because the struct's fields are +/// already 16-byte-aligned. +/// +/// +/// Layout (r13 §12.3 extended with R12 §13.2 sun+fog): +/// +/// struct Light { +/// vec4 posAndKind; // xyz = world pos, w = kind (0=dir, 1=point, 2=spot) +/// vec4 dirAndRange; // xyz = forward, w = range (metres, hard cutoff) +/// vec4 colorAndIntensity; // xyz = RGB linear, w = intensity scalar +/// vec4 coneAngleEtc; // x = cone (rad), y=unused, z=unused, w=unused +/// }; +/// +/// layout(std140, binding = 1) uniform SceneLighting { +/// Light uLights[8]; // 8 * 64 bytes = 512 bytes +/// vec4 uCellAmbient; // xyz = ambient RGB, w = active light count +/// vec4 uFogParams; // x = fogStart, y = fogEnd, z = lightningFlash, w = fogMode +/// vec4 uFogColor; // xyz = fog RGB, w = unused +/// vec4 uCameraAndTime; // xyz = camera world pos, w = day fraction (debug / sky shader) +/// }; +/// +/// +/// +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct UboLight +{ + public Vector4 PosAndKind; + public Vector4 DirAndRange; + public Vector4 ColorAndIntensity; + public Vector4 ConeAngleEtc; + + /// Pack a into UBO-ready bytes. + public static UboLight FromSource(LightSource ls) + { + return new UboLight + { + PosAndKind = new Vector4(ls.WorldPosition, (float)(int)ls.Kind), + DirAndRange = new Vector4(ls.WorldForward, ls.Range), + ColorAndIntensity = new Vector4(ls.ColorLinear, ls.Intensity), + ConeAngleEtc = new Vector4(ls.ConeAngle, 0f, 0f, 0f), + }; + } + + /// Packed "zero" light — stored in unused UBO slots so shaders + /// don't read garbage. dirAndRange.w = 0 disables the light + /// even if the active-count sentinel is wrong. + public static UboLight Empty => new() + { + PosAndKind = Vector4.Zero, + DirAndRange = Vector4.Zero, + ColorAndIntensity = Vector4.Zero, + ConeAngleEtc = Vector4.Zero, + }; +} + +/// +/// Full CPU-side scene-lighting UBO buffer. One per frame; lives on the +/// render thread. The GL-side wrapper (SceneLightingUboBinding +/// in AcDream.App) uploads this to binding=1 once per frame. +/// +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct SceneLightingUbo +{ + // 8 lights × 64 bytes = 512 bytes + public UboLight Light0; + public UboLight Light1; + public UboLight Light2; + public UboLight Light3; + public UboLight Light4; + public UboLight Light5; + public UboLight Light6; + public UboLight Light7; + + public Vector4 CellAmbient; // xyz = ambient RGB, w = active count + public Vector4 FogParams; // x = fogStart, y = fogEnd, z = flash, w = fogMode + public Vector4 FogColor; // xyz = color, w = reserved + public Vector4 CameraAndTime; // xyz = camera pos, w = day fraction + + public const int SizeInBytes = 8 * 64 + 4 * 16; // 576 + public const int BindingPoint = 1; + + /// + /// Build the full per-frame UBO payload from: + /// + /// An already-ticked . + /// The current (sky + weather). + /// The current camera world position (sky shader needs it, fog shader needs it). + /// The current day fraction (sky shader needs it for scrolling clouds). + /// + /// + public static SceneLightingUbo Build( + LightManager lights, + in AtmosphereSnapshot atmo, + Vector3 cameraWorldPos, + float dayFraction) + { + ArgumentNullException.ThrowIfNull(lights); + + var ubo = new SceneLightingUbo(); + + // Pack up to 8 lights. Empty slots stay zero. + var active = lights.Active; + int count = active.Length; + if (count > 8) count = 8; + for (int i = 0; i < 8; i++) + { + var packed = (i < count && active[i] is not null) + ? UboLight.FromSource(active[i]!) + : UboLight.Empty; + SetLightAt(ref ubo, i, packed); + } + + ubo.CellAmbient = new Vector4(lights.CurrentAmbient.AmbientColor, count); + ubo.FogParams = new Vector4( + atmo.FogStart, + atmo.FogEnd, + atmo.LightningFlash, + (float)(int)atmo.FogMode); + ubo.FogColor = new Vector4(atmo.FogColor, 0f); + ubo.CameraAndTime = new Vector4(cameraWorldPos, dayFraction); + return ubo; + } + + private static void SetLightAt(ref SceneLightingUbo ubo, int i, in UboLight v) + { + switch (i) + { + case 0: ubo.Light0 = v; break; + case 1: ubo.Light1 = v; break; + case 2: ubo.Light2 = v; break; + case 3: ubo.Light3 = v; break; + case 4: ubo.Light4 = v; break; + case 5: ubo.Light5 = v; break; + case 6: ubo.Light6 = v; break; + case 7: ubo.Light7 = v; break; + } + } +} diff --git a/tests/AcDream.Core.Tests/Lighting/SceneLightingUboTests.cs b/tests/AcDream.Core.Tests/Lighting/SceneLightingUboTests.cs new file mode 100644 index 0000000..e79a281 --- /dev/null +++ b/tests/AcDream.Core.Tests/Lighting/SceneLightingUboTests.cs @@ -0,0 +1,121 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.Core.Lighting; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.Lighting; + +public sealed class SceneLightingUboTests +{ + [Fact] + public void UboLight_StructSize_Is64Bytes() + { + // std140 mandates 4× vec4 = 64 bytes. If this drifts the shader + // will read garbage. + Assert.Equal(64, Marshal.SizeOf()); + } + + [Fact] + public void SceneLightingUbo_StructSize_MatchesConstant() + { + Assert.Equal(SceneLightingUbo.SizeInBytes, Marshal.SizeOf()); + } + + [Fact] + public void Build_PacksActiveLightsIntoSlotsInOrder() + { + var lights = new LightManager(); + lights.Register(new LightSource + { + Kind = LightKind.Point, + WorldPosition = new Vector3(1, 2, 3), + ColorLinear = new Vector3(1f, 0.5f, 0.25f), + Intensity = 0.8f, + Range = 6f, + }); + lights.Tick(Vector3.Zero); + + var atmo = new AtmosphereSnapshot( + Kind: WeatherKind.Clear, + Intensity: 1f, + FogColor: new Vector3(0.7f, 0.8f, 0.9f), + FogStart: 100f, + FogEnd: 400f, + FogMode: FogMode.Linear, + LightningFlash: 0f, + Override: EnvironOverride.None); + + var ubo = SceneLightingUbo.Build(lights, in atmo, new Vector3(10, 20, 30), 0.5f); + + // Light 0 is the slot we populated. + Assert.Equal(1f, ubo.Light0.PosAndKind.X); + Assert.Equal(2f, ubo.Light0.PosAndKind.Y); + Assert.Equal(3f, ubo.Light0.PosAndKind.Z); + Assert.Equal((float)(int)LightKind.Point, ubo.Light0.PosAndKind.W); + Assert.Equal(6f, ubo.Light0.DirAndRange.W); + Assert.Equal(0.8f, ubo.Light0.ColorAndIntensity.W); + + // Unused slots should be zero-packed. + Assert.Equal(0f, ubo.Light1.DirAndRange.W); + + // Active count lives in uCellAmbient.w. + Assert.Equal(1f, ubo.CellAmbient.W); + + // Fog params passed through. + Assert.Equal(100f, ubo.FogParams.X); + Assert.Equal(400f, ubo.FogParams.Y); + Assert.Equal(0f, ubo.FogParams.Z); // no flash + Assert.Equal((float)(int)FogMode.Linear, ubo.FogParams.W); + + // Camera + day fraction. + Assert.Equal(10f, ubo.CameraAndTime.X); + Assert.Equal(0.5f, ubo.CameraAndTime.W); + } + + [Fact] + public void Build_ClampsAtEightLights() + { + var lights = new LightManager(); + // Register 20; the active list caps at 8. + for (int i = 0; i < 20; i++) + { + lights.Register(new LightSource + { + Kind = LightKind.Point, + WorldPosition = new Vector3(i, 0, 0), + Range = 200f, // all in range + }); + } + lights.Tick(Vector3.Zero); + + var atmo = new AtmosphereSnapshot( + WeatherKind.Clear, 1f, Vector3.Zero, 0, 0, FogMode.Off, 0f, EnvironOverride.None); + var ubo = SceneLightingUbo.Build(lights, in atmo, Vector3.Zero, 0f); + + // Slot 7 populated (8th light), active count = 8. + Assert.Equal(8f, ubo.CellAmbient.W); + Assert.NotEqual(0f, ubo.Light7.DirAndRange.W); + } + + [Fact] + public void Build_WithSun_SlotZeroIsDirectional() + { + var lights = new LightManager(); + lights.Sun = new LightSource + { + Kind = LightKind.Directional, + WorldForward = new Vector3(0, 0, -1), + ColorLinear = new Vector3(1f, 0.9f, 0.8f), + Intensity = 1.2f, + }; + lights.Tick(Vector3.Zero); + + var atmo = new AtmosphereSnapshot( + WeatherKind.Clear, 1f, Vector3.Zero, 0, 0, FogMode.Off, 0f, EnvironOverride.None); + var ubo = SceneLightingUbo.Build(lights, in atmo, Vector3.Zero, 0f); + + Assert.Equal((float)(int)LightKind.Directional, ubo.Light0.PosAndKind.W); + Assert.Equal(1.2f, ubo.Light0.ColorAndIntensity.W); + } +}