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 DatReaderWriter.Enums; 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. /// /// /// Each submesh renders with retail's per-vertex lighting formula: /// tint = clamp(emissive + ambient + max(dot(N, -sunDir), 0) * sunColor, 0, 1) /// where emissive is the submesh's Surface.Luminosity /// float (1.0 for dome + sun + moon → texture passthrough via /// saturation; 0.0 for clouds → get the full time-of-day tint). /// supplies the AmbientColor and SunColor /// already pre-multiplied by AmbBright / DirBright (loader-side). /// /// /// See docs/research/2026-04-23-sky-retail-verbatim.md §6 for /// the full decompile citation. The empirical Dereth dump ( /// ACDREAM_DUMP_SKY=1, logged 2026-04-23) confirmed the /// SurfaceType.Luminous flag bit is NOT set on any Dereth sky /// mesh — the differentiator is the Surface.Luminosity FLOAT /// field. /// /// public void Render( ICamera camera, Vector3 cameraWorldPos, float dayFraction, DayGroupData? group, SkyKeyframe keyframe) { 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); // Retail per-vertex lighting inputs (AdjustPlanes formula). // AmbColor/SunColor are already × AmbBright/DirBright from // SkyDescLoader. SunDir is the unit vector FROM surface TO sun // derived from the keyframe's DirHeading/DirPitch. _shader.SetVec3("uAmbientColor", keyframe.AmbientColor); _shader.SetVec3("uSunColor", keyframe.SunColor); _shader.SetVec3("uSunDir", AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(keyframe)); // Save + override GL state. _gl.DepthMask(false); _gl.Disable(EnableCap.DepthTest); _gl.Disable(EnableCap.CullFace); _gl.Enable(EnableCap.Blend); // Default blend — overridden per-submesh inside the inner loop. // Additive surfaces (sun/moon/stars via SurfaceType.Additive = // 0x10000) get GL_SRC_ALPHA / GL_ONE; alpha-blended (clouds, dome // with Alpha flag) get GL_SRC_ALPHA / GL_ONE_MINUS_SRC_ALPHA. _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); EnsureMeshUploaded(gfxObjId); if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes)) continue; foreach (var sub in subMeshes) { // Per-submesh blend mode: sun/moon/stars are Additive // (SurfaceType.Additive = 0x10000), clouds are AlphaBlend, // sky dome is Base1Image (Opaque, mapped to // SrcAlpha/InvSrcAlpha for a no-op blend at alpha=1). // See FUN_00508010 (chunk_00500000.c:7535) for the retail // pattern — retail routes sky meshes through the normal // mesh pipeline where Surface flags dictate state. if (sub.IsAdditive) _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); else _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); // Emissive source: retail's FUN_0059da60 for non-luminous // surfaces writes rep.Luminosity into D3DMATERIAL9.Emissive // (via material cache +0x3c). This PROMOTES bright-keyframe // clouds into the self-lit term so the litColor saturates // and the texture renders at full brightness rather than // being dimmed by a per-fragment multiply. // // If no rep.Luminosity override: fall back to the Surface's // static Luminosity (1.0 for dome/sun/moon → saturates; // 0.0 for stars → stays ambient-lit, correct retail look). float effEmissive = (luminosity > 0f) ? luminosity : sub.SurfLuminosity; _shader.SetFloat("uEmissive", effEmissive); 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; // DatCollection isn't thread-safe and the streaming loader can be // actively reading a shared DatBinReader buffer; sky meshes are // loaded on the render thread but GfxObj.Unpack can race with the // streamer. Cache a null entry on any read failure so we don't // retry every frame and crash the render loop. A future // refactor should move all dat access behind the _datLock. GfxObj? gfx = null; try { gfx = _dats.Get(gfxObjId); } catch { gfx = null; } if (gfx is null) { _gpuByGfxObj[gfxObjId] = new List(); return; } System.Collections.Generic.IReadOnlyList? subMeshes = null; try { subMeshes = GfxObjMesh.Build(gfx, _dats); } catch { subMeshes = null; } if (subMeshes is null) { _gpuByGfxObj[gfxObjId] = new List(); return; } // Phase 1 diagnostic: dump Surface.Type flags on every sky GfxObj // once, so we can determine which submeshes carry Luminous (0x40) // vs plain-lit. This settles the retail "cloud tint = per-vertex // lighting on non-Luminous meshes" hypothesis — see // docs/research/2026-04-23-sky-retail-verbatim.md §6. if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1") DumpGfxObjSurfaces(gfxObjId, gfx, subMeshes); var gpuList = new List(subMeshes.Count); foreach (var sm in subMeshes) gpuList.Add(UploadSubMesh(sm)); _gpuByGfxObj[gfxObjId] = gpuList; } /// /// Log each surface's raw flag bits and the derived /// . Called once per GfxObj when /// ACDREAM_DUMP_SKY=1. Output format is grep-friendly so /// we can pipe the launch log through | grep sky-dump and /// recover a complete picture of the Dereth sky without re-running. /// private void DumpGfxObjSurfaces( uint gfxObjId, GfxObj gfx, System.Collections.Generic.IReadOnlyList subMeshes) { Console.WriteLine( $"[sky-dump] GfxObj 0x{gfxObjId:X8} Surfaces.Count={gfx.Surfaces.Count} Polygons.Count={gfx.Polygons.Count} SubMeshes.Count={subMeshes.Count}"); for (int i = 0; i < gfx.Surfaces.Count; i++) { uint surfaceId = (uint)gfx.Surfaces[i]; DatReaderWriter.DBObjs.Surface? surface = null; try { surface = _dats.Get(surfaceId); } catch { surface = null; } if (surface is null) { Console.WriteLine($"[sky-dump] Surface[{i}] 0x{surfaceId:X8} -- (dat read failed)"); continue; } // SurfaceType is a flag enum — `ToString()` gives the // comma-joined names (e.g. "Base1Image, Additive"). uint rawType = (uint)surface.Type; string names = surface.Type.ToString(); uint origTex = surface.OrigTextureId?.DataId ?? 0u; var trans = TranslucencyKindExtensions.FromSurfaceType(surface.Type); // Surface's own Luminosity (0..1 fraction per test fixture — // different from SkyObjectReplace.Luminosity which lives in the keyframe). Console.WriteLine( $"[sky-dump] Surface[{i}] 0x{surfaceId:X8} Type=0x{rawType:X8} ({names}) " + $"OrigTexture=0x{origTex:X8} Translucency={trans} " + $"SurfLuminosity={surface.Luminosity:F4} SurfTranslucency={surface.Translucency:F4}"); } } 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); // Classify blend mode from the Surface's flags. Sun/moon/stars with // `SurfaceType.Additive = 0x10000` get GL_ONE / GL_ONE (their texture // has a black background and a bright body; additive makes the // background contribute nothing and the body glow on top of the sky). // // NOTE: earlier revision also treated `SurfaceType.Luminous = 0x40` // as additive, but that flag is present on the sky DOME itself and // on cloud sheets — turning those additive blew the whole sky to // white. `Luminous` means "self-illuminated / unshaded" in retail's // render pipeline, not "additive blend". Only the Additive bit // toggles the blend mode. bool isAdditive = sm.Translucency == TranslucencyKind.Additive; return new SubMeshGpu { Vao = vao, Vbo = vbo, Ebo = ebo, IndexCount = sm.Indices.Length, SurfaceId = sm.SurfaceId, IsAdditive = isAdditive, SurfLuminosity = sm.Luminosity, }; } 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; /// /// True if the Surface's SurfaceType.Additive flag (0x10000) /// is set. Drives the blend func switch (GL_ONE vs GL_ONE_MINUS_SRC_ALPHA). /// Computed once at upload; avoids a per-frame dat lookup. /// public bool IsAdditive; /// /// Surface.Luminosity float (0..1 — NOT the SurfaceType.Luminous /// flag bit). Passed to the sky fragment shader as uEmissive; /// when 1.0 it saturates the lighting math so the mesh renders at /// full texture brightness (dome, sun). When 0.0 the mesh picks up /// the time-of-day ambient+diffuse tint (clouds). See /// docs/research/2026-04-23-sky-retail-verbatim.md §6. /// public float SurfLuminosity; } }