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;
}
}