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; private readonly SamplerCache _samplers; // 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, SamplerCache samplers) { _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)); _samplers = samplers ?? throw new ArgumentNullException(nameof(samplers)); } /// /// Draw all NON-WEATHER sky objects (dome, sun, moon, stars, clouds — /// every SkyObject with Properties & 0x04 == 0). /// Called BEFORE the scene; terrain / meshes / debug lines / overlay /// land on top via depth-test. /// /// /// Mirrors the first half of retail's LScape::draw at /// 0x00506330: that function calls GameSky::Draw(0) /// (sky pass) before the landblock loop, then GameSky::Draw(1) /// (weather pass) after. acdream splits the same way — see /// for the post-scene companion. /// /// /// /// 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 RenderSky( ICamera camera, Vector3 cameraWorldPos, float dayFraction, DayGroupData? group, SkyKeyframe keyframe, bool environOverrideActive = false) => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: false, environOverrideActive: environOverrideActive); /// /// Draw the POST-SCENE sky objects (the foreground rain mesh /// 0x01004C44 on Rainy DayGroups, plus any other SkyObject with /// Properties & 0x01 != 0). Called AFTER the scene so these /// meshes paint on top of terrain and entities — retail-faithful order /// from LScape::draw at 0x00506330, where /// GameSky::Draw(1) fires after the DrawBlock loop and /// renders the after_sky_cell contents. With depth-test /// disabled and additive blend (the rain Surface flag includes /// Additive), the 815m-tall rain cylinder's bright streak texels add /// over the scene — making rain appear in the air between camera and /// character instead of only at the horizon. /// /// Method name kept as RenderWeather for API stability; the /// pass actually partitions on /// (Properties bit 0x01), not /// (bit 0x04). The two bits are independent in retail per /// GameSky::CreateDeletePhysicsObjects at 0x005073c0. /// /// public void RenderWeather( ICamera camera, Vector3 cameraWorldPos, float dayFraction, DayGroupData? group, SkyKeyframe keyframe, bool environOverrideActive = false) => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: true, environOverrideActive: environOverrideActive); /// /// Shared pass for and . /// Sets up the same GL state for both (depth-test off, additive + /// alpha-blend per submesh, camera-anchored translation) and iterates /// only the SkyObjects matching the requested partition by /// — bit 0x01 per the /// retail decomp at GameSky::MakeObject (0x00506ee0). /// private void RenderPass( ICamera camera, Vector3 cameraWorldPos, float dayFraction, DayGroupData? group, SkyKeyframe keyframe, bool postScenePass, bool environOverrideActive) { 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); // Save + disable CullFace for the sky pass; restore at the end. // Mirrors TextRenderer.cs's save/restore pattern. Without this the // sky pass left CullFace disabled regardless of its prior state, // which is benign today (the global convention in this codebase is // off and subsequent renderers manage their own CullFace) but // would break the moment any future caller assumes back-face // culling stays on across the sky pass. bool wasCullFace = _gl.IsEnabled(EnableCap.CullFace); _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]; // Partition by post-scene flag (Properties bit 0x01) — the // caller chose either the pre-scene sky pass (bit clear) or // the post-scene pass (bit set). Mirrors retail // GameSky::CreateDeletePhysicsObjects at 0x005073c0 / decomp // line 269036 which routes (Properties & 1) into // before_sky_cell vs after_sky_cell, and GameSky::Draw at // 0x00506ff0 which renders those cells in the two passes. // NOTE: bit 0x04 (IsWeather) is independent — it gates whether // the object is instantiated when weather_enabled is false. // Earlier acdream incorrectly used IsWeather for this // partition, putting the outer rain cylinder 0x01004C42 // (Props=0x04, NO bit 0x01) into the post-scene pass with the // foreground rain — double-thick rain not matching retail. if (obj.IsPostScene != postScenePass) continue; if (!obj.IsVisible(dayFraction)) continue; // Retail GameSky::Draw (0x00506ff0) skips Properties bit 0x02 // objects while an AdminEnvirons fog override is active. Normal // DayGroup fog/tint still draws them. if (environOverrideActive && (obj.Properties & 0x02u) != 0u) continue; // Apply per-keyframe replace overrides. uint gfxObjId = obj.GfxObjId; float headingDeg = 0f; float transparent = 0f; // Replace-override luminosity. Stays NaN when there is no // replace entry or none of the keyframe's overrides are set, // and that NaN is the signal to fall back to the surface's // authored Luminosity at draw time. This replaces the previous // `luminosity = 1f` default which masked the surface value // because the `(luminosity > 0) ? luminosity : sub.SurfLuminosity` // fallback at the inner loop never fired (1f is always > 0). // RainMeshProbe (committed b8e0857) confirmed empirically that // NO Dereth sky surface carries the SurfaceType.Luminous flag // bit (0x40) — the differentiator is purely the float field. float replaceLuminosity = float.NaN; float replaceDiffuse = float.NaN; 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) replaceLuminosity = rep.Luminosity; // Retail GameSky::UseTime routes max_bright through // CPhysicsObj::SetDiffusion, so it replaces material diffuse, // not emissive/luminosity. if (rep.MaxBright > 0f) replaceDiffuse = 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); // Retail weather Z-offset (GameSky::UpdatePosition at // 0x00506dd0, decomp lines 0x506e96..0x506e98): // // if (((eax_13 & 4) != 0 && (eax_13 & 8) == 0)) // int32_t var_4_1 = 0xc2f00000; // 0xc2f00000 == -120.0f // // Gate: bit 0x04 (weather) set AND bit 0x08 unset. NOT every // post-scene SkyObject — bit 0x01 (post-scene) is independent // of bit 0x04 (weather). Today's Dereth ships every post-scene // entry as also weather-flagged so the previous unconditional // offset was a no-op divergence, but a future DayGroup with a // post-scene-but-not-weather entry (e.g. a foreground sun rim) // would have been pushed 120m below the camera and rendered as // floor lint. // // Without the offset on the rain cylinder GfxObjs // 0x01004C42/0x01004C44 (local Z range 0.11..814.90) the // cylinder bottom sits at z=0.11 ABOVE the camera (skyView // translation is zeroed so model-origin == camera); looking // horizontally shows nothing. With -120m the cylinder spans z // = (camera-119.89)..(camera+694.90) — camera is inside, // looking in any direction shows surrounding walls — the // volumetric foreground-rain look retail has. if (postScenePass && obj.IsWeather && (obj.Properties & 0x08u) == 0u) model = model * Matrix4x4.CreateTranslation(0f, 0f, -120f); _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); 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 picks the surface's authored Luminosity by // default; the per-keyframe replace data can OVERRIDE // (rep.Luminosity > 0) or CAP (rep.MaxBright). This matches // retail's FUN_0059da60: surface.Luminosity → D3DMATERIAL.Emissive // (via material cache +0x3c), with the keyframe replace // promoting bright-keyframe clouds when the keyframe asks. // // Empirical Dereth sky surfaces (RainMeshProbe, b8e0857): // dome/sun/moon → Lum=1.0 → vTint saturates → texture // passthrough (correct retail look); // stars/clouds → Lum=0.0 → vTint = ambient + diffuse → // picks up the time-of-day tint; // rain → Lum=0.1484 → faint emissive baseline, // ambient+diffuse adds atmospheric tint. // // Pre-fix: the replace-override variable defaulted to 1f and // the fallback `(luminosity > 0) ? luminosity : sub.SurfLuminosity` // never fired — every sky mesh got effEmissive=1.0, // saturating vTint. That made stars/clouds look full-bright // instead of time-of-day-tinted, and made rain streaks // 6.7× too bright (one of two factors compounding the // foreground-rim visibility bug). float effEmissive = float.IsNaN(replaceLuminosity) ? sub.SurfLuminosity : replaceLuminosity; float effDiffuse = float.IsNaN(replaceDiffuse) ? sub.SurfDiffuse : replaceDiffuse; _shader.SetFloat("uEmissive", effEmissive); _shader.SetFloat("uDiffuseFactor", effDiffuse); // Material alpha is final opacity: 1 - Surface.Translucency // for Translucent surfaces, 1 for non-Translucent surfaces. // The CPU computes it once so the shader just multiplies it // with texture alpha and keyframe transparency. _shader.SetFloat("uSurfOpacity", sub.SurfOpacity); // Retail D3DPolyRender::SetSurface at 0x59c882 calls // SetFFFogAlphaDisabled(1) when the Additive flag (0x10000) // is set on the Surface — so the sun, moon, stars, and any // additive cloud sheet are drawn WITHOUT fog. Skipping fog // on additive surfaces keeps the sun bright at horizon // dusk/dawn (where fog would otherwise dim it to fog color). // Non-additive sky meshes (the dome/background layers) // still mix toward keyframe fog with the floor mitigation // in sky.frag. That restores the broad green/purple Rainy // DayGroup tint behind the cloud sheet while raw-additive // 0x08000023 remains unfogged and keeps the pink detail. _shader.SetFloat("uApplyFog", sub.DisableFog ? 0f : 1f); uint tex = _textures.GetOrUpload(sub.SurfaceId); _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, tex); // Sky meshes need per-object wrap mode driven by the // mesh's authored UV range, not by TexVelocity: // * The outer dome (0x010015EE/F0/F1/F2) authors UVs // strictly in [0,1]. Under GL_REPEAT the bilinear // filter at wall-seam edges would average a texel // near the right edge with one near the left edge of // the texture, drawing a visible "bleed line" along // every dome seam. CLAMP_TO_EDGE avoids that. // * The inner sky/star layer (0x010015EF) and the // cloud meshes (0x010015B6, 0x01004C36 etc) author // UVs that deliberately exceed [0,1] (~0.4..4.6) so // the texture tiles across the geometry. CLAMP_TO_EDGE // would clamp ~99% of the surface to a single edge // texel, leaving only a small "square" where UVs // happen to fall in [0,1] (Bug B in // docs/research/2026-04-26-sky-investigation-handoff.md). // The mesh builder pre-computes NeedsUvRepeat from the // actual UV range so the right answer is data-driven. // Scrolling clouds are also forced to REPEAT (the running // UV offset can drift outside [0,1] regardless of authored // range, and they'd show their own seam bleed otherwise). // // Implementation: bind a persistent sampler object to // texture unit 0. Sampler state overrides the texture's // own wrap state, so two renderers can share the same // texture handle but sample it with different wrap modes // safely. Ported from WorldBuilder // (Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs:312). bool needsRepeat = sub.NeedsUvRepeat || obj.TexVelocityX != 0f || obj.TexVelocityY != 0f; _gl.BindSampler(0, needsRepeat ? _samplers.Wrap : _samplers.Clamp); _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. // Critical: unbind the sampler from unit 0. While bound, sampler // state overrides the texture's own wrap parameters, so leaving // (e.g.) Clamp bound would silently force ClampToEdge on every // subsequent draw on unit 0 regardless of how that texture was // configured at upload time. _gl.BindSampler(0, 0); _gl.Disable(EnableCap.Blend); _gl.DepthMask(true); _gl.Enable(EnableCap.DepthTest); if (wasCullFace) _gl.Enable(EnableCap.CullFace); _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 mesh build for a sky object. Handles two cases: /// /// /// 0x010xxxxx — direct . Reuses /// so the pos/neg polygon /// splitting logic stays consistent with the main static-mesh /// pipeline. Most sky meshes are single-surface. /// /// /// 0x020xxxxx. The agent at /// 2026-04-27 found these Setup-backed sky objects (e.g. /// 0x02000588, 0x02000589, 0x02000714, /// 0x02000BA6) were silently dropped: every cache miss /// fell into the GfxObj branch, returned null, and got cached /// as an empty submesh list. Per the named retail decomp /// CPhysicsObj::InitPartArrayObject at 0x0050ed40 /// dispatches type 7 to CPartArray::CreateSetup /// (decomp 280484) which loads the Setup and walks its parts. /// We mirror that here: walks /// Setup.Parts at the default placement frame and /// produces submeshes for each /// part. Per-part transforms are baked into vertex positions /// (sky setups are static — no animation needed for the static /// mesh half of the visual). /// /// /// /// Even with this fix the visible aurora-style sheen most retail /// rainy/cloudy setups produce comes from the pes_id field /// on each (a Particle /// Effect Schedule) — that's a separate Phase-level feature. /// Rendering the Setup's static parts here is the geometry half; /// the dynamic particle half is deferred. /// /// private void EnsureMeshUploaded(uint gfxObjId) { if (_gpuByGfxObj.ContainsKey(gfxObjId)) return; // Setup-backed sky object: walk Setup.Parts and bake per-part // transforms into the per-vertex positions. See doc comment above. if ((gfxObjId & 0xFF000000u) == 0x02000000u) { EnsureSetupUploaded(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; } /// /// Setup-backed sky object loader. Walks at /// the default placement frame, builds submeshes via /// , and bakes the per-part transform /// into the vertex positions before upload. Static-pose only — sky /// setups don't animate in any meaningful way for the visual we care /// about (the dynamic look comes from pes_id particles, not /// the underlying mesh). /// /// Mirrors retail's at /// decomp 280484 dispatching type 7 → CPartArray::CreateSetup /// → CSetup::SetSetupID, which loads the setup and instantiates /// each part as a separate CPhysicsObj child. We collapse the /// children into a flat submesh list because the sky pass renders /// without per-part transforms anyway. /// /// private void EnsureSetupUploaded(uint setupId) { Setup? setup = null; try { setup = _dats.Get(setupId); } catch { setup = null; } if (setup is null) { _gpuByGfxObj[setupId] = new List(); return; } var parts = SetupMesh.Flatten(setup); var allSubs = new List(parts.Count); foreach (var partRef in parts) { GfxObj? partGfx = null; try { partGfx = _dats.Get(partRef.GfxObjId); } catch { partGfx = null; } if (partGfx is null) continue; System.Collections.Generic.IReadOnlyList? partSubs = null; try { partSubs = GfxObjMesh.Build(partGfx, _dats); } catch { partSubs = null; } if (partSubs is null) continue; // Bake the part's local transform into the vertices. For sky // setups we don't expect non-uniform scale, so transforming // normals as directions is fine; if a future sky setup ever // breaks that assumption we'd need an inverse-transpose here. var partTx = partRef.PartTransform; foreach (var sub in partSubs) { var transformed = new Vertex[sub.Vertices.Length]; for (int i = 0; i < sub.Vertices.Length; i++) { var v = sub.Vertices[i]; var p = Vector3.Transform(v.Position, partTx); var n = Vector3.Normalize(Vector3.TransformNormal(v.Normal, partTx)); transformed[i] = v with { Position = p, Normal = n }; } var rebuilt = sub with { Vertices = transformed }; allSubs.Add(UploadSubMesh(rebuilt)); } } _gpuByGfxObj[setupId] = allSubs; } /// /// 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} SurfaceTranslucency={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, SurfDiffuse = sm.Diffuse, NeedsUvRepeat = sm.NeedsUvRepeat, SurfOpacity = sm.SurfOpacity, DisableFog = sm.DisableFog, }; } 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; public float SurfDiffuse; /// /// True when the source mesh's authored UVs exceed [0,1] (e.g. /// the inner sky/star layer 0x010015EF and the cloud meshes — /// they tile their texture across the geometry). The renderer /// must use GL_REPEAT for these or only the small region /// where UVs fall in [0,1] samples the actual texture; the rest /// clamps to the edge texel ("square in one corner" symptom). /// Computed once at mesh build from the actual UV range. /// public bool NeedsUvRepeat; /// /// Final surface opacity from . /// Translucent surfaces use 1 - Surface.Translucency; other /// surfaces stay at 1.0. /// public float SurfOpacity; public bool DisableFog; } }