acdream/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
Erik 3d21c1352a refactor(sky): replace per-frame wrap-mode mutation with persistent samplers
Ports WorldBuilder's GL sampler-object pattern
(references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132,
SkyboxRenderManager.cs:312). Two persistent samplers (Repeat +
ClampToEdge) are created once at GL init; the sky pass binds the
appropriate one to texture unit 0 per submesh instead of mutating
per-texture GL_TEXTURE_WRAP_S/T state.

Why this is better than the prior M1 track-and-restore hack:

  1. Sampler state is decoupled from texture state. Two renderers can
     share the same texture handle but sample it with different wrap
     modes simultaneously and safely — sampler state at the bind point
     overrides the texture's own wrap parameters.

  2. No bookkeeping. Drops the HashSet<uint> clamped-textures tracking
     and the end-of-pass restore loop. The only restore needed is
     BindSampler(0, 0) to release unit 0 back to per-texture state.

  3. Constant cost. Sampler objects are created once per GL context,
     not per draw. Filter modes match TextureCache's upload defaults
     (Linear/Linear, no mipmaps) so the binding is purely a wrap-mode
     selection.

Field count: SkyRenderer.cs -28 lines, +14 lines. GameWindow.cs gets
the SamplerCache field + ctor + Dispose. SkyRenderer disposed before
SamplerCache so the sky teardown path doesn't reference a freed
sampler handle.

dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:08:26 +02:00

782 lines
37 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// Port of <c>references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs</c>.
/// 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 <see cref="SkyObjectData"/> is
/// visible in a window of day-fraction space, sweeps from
/// <c>BeginAngle</c> to <c>EndAngle</c> across the sky, and samples its
/// texture with a per-frame UV scroll driven by <c>TexVelocityX/Y</c>.
///
/// <para>
/// GL state delta per frame:
/// <list type="bullet">
/// <item><description>Depth mask OFF, depth test OFF, cull OFF — the sky
/// should never occlude scene geometry.</description></item>
/// <item><description>Separate projection matrix with a 0.11e6 near/far
/// so mesh vertices at large distance don't clip.</description></item>
/// <item><description>View matrix with translation zeroed — sky is
/// always camera-centred; moving doesn't get you closer to the
/// sun.</description></item>
/// </list>
/// </para>
///
/// <para>
/// Meshes are built lazily per GfxObj id on first reference. The
/// per-object arc transform matches WorldBuilder's composition:
/// <c>scale × RotZ(-heading) × RotY(-rotation)</c> — the negative signs
/// come from AC's Z-up right-handed convention where heading is
/// measured clockwise from north.
/// </para>
/// </summary>
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<uint, List<SubMeshGpu>> _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));
}
/// <summary>
/// Draw all NON-WEATHER sky objects (dome, sun, moon, stars, clouds —
/// every <c>SkyObject</c> with <c>Properties &amp; 0x04 == 0</c>).
/// Called BEFORE the scene; terrain / meshes / debug lines / overlay
/// land on top via depth-test.
///
/// <para>
/// Mirrors the first half of retail's <c>LScape::draw</c> at
/// <c>0x00506330</c>: that function calls <c>GameSky::Draw(0)</c>
/// (sky pass) before the landblock loop, then <c>GameSky::Draw(1)</c>
/// (weather pass) after. acdream splits the same way — see
/// <see cref="RenderWeather"/> for the post-scene companion.
/// </para>
///
/// <para>
/// Each submesh renders with retail's per-vertex lighting formula:
/// <c>tint = clamp(emissive + ambient + max(dot(N, -sunDir), 0) * sunColor, 0, 1)</c>
/// where <c>emissive</c> is the submesh's <c>Surface.Luminosity</c>
/// float (1.0 for dome + sun + moon → texture passthrough via
/// saturation; 0.0 for clouds → get the full time-of-day tint).
/// <paramref name="keyframe"/> supplies the AmbientColor and SunColor
/// already pre-multiplied by AmbBright / DirBright (loader-side).
/// </para>
/// <para>
/// See <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6 for
/// the full decompile citation. The empirical Dereth dump (
/// <c>ACDREAM_DUMP_SKY=1</c>, logged 2026-04-23) confirmed the
/// <c>SurfaceType.Luminous</c> flag bit is NOT set on any Dereth sky
/// mesh — the differentiator is the <c>Surface.Luminosity</c> FLOAT
/// field.
/// </para>
/// </summary>
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);
/// <summary>
/// Draw the POST-SCENE sky objects (the foreground rain mesh
/// <c>0x01004C44</c> on Rainy DayGroups, plus any other SkyObject with
/// <c>Properties &amp; 0x01 != 0</c>). Called AFTER the scene so these
/// meshes paint on top of terrain and entities — retail-faithful order
/// from <c>LScape::draw</c> at <c>0x00506330</c>, where
/// <c>GameSky::Draw(1)</c> fires after the <c>DrawBlock</c> loop and
/// renders the <c>after_sky_cell</c> 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.
/// <para>
/// Method name kept as <c>RenderWeather</c> for API stability; the
/// pass actually partitions on <see cref="SkyObjectData.IsPostScene"/>
/// (Properties bit <c>0x01</c>), not <see cref="SkyObjectData.IsWeather"/>
/// (bit <c>0x04</c>). The two bits are independent in retail per
/// <c>GameSky::CreateDeletePhysicsObjects</c> at <c>0x005073c0</c>.
/// </para>
/// </summary>
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);
/// <summary>
/// Shared pass for <see cref="RenderSky"/> and <see cref="RenderWeather"/>.
/// 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
/// <see cref="SkyObjectData.IsPostScene"/> — bit <c>0x01</c> per the
/// retail decomp at <c>GameSky::MakeObject</c> (<c>0x00506ee0</c>).
/// </summary>
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);
}
/// <summary>
/// Find the <see cref="SkyObjectReplaceData"/> entries for the
/// keyframe currently "active" at <paramref name="dayFraction"/>.
/// Matches WorldBuilder's single-keyframe lookup (it picks <c>t1</c>
/// and doesn't interpolate the replace fields).
/// </summary>
private static Dictionary<uint, SkyObjectReplaceData> PickReplaces(
DayGroupData group, float dayFraction)
{
var result = new Dictionary<uint, SkyObjectReplaceData>();
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;
}
/// <summary>
/// Lazy mesh build for a sky object. Handles two cases:
/// <list type="bullet">
/// <item><description>
/// <c>0x010xxxxx</c> — direct <see cref="GfxObj"/>. Reuses
/// <see cref="GfxObjMesh.Build"/> so the pos/neg polygon
/// splitting logic stays consistent with the main static-mesh
/// pipeline. Most sky meshes are single-surface.
/// </description></item>
/// <item><description>
/// <c>0x020xxxxx</c> — <see cref="Setup"/>. The agent at
/// 2026-04-27 found these Setup-backed sky objects (e.g.
/// <c>0x02000588</c>, <c>0x02000589</c>, <c>0x02000714</c>,
/// <c>0x02000BA6</c>) 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
/// <c>CPhysicsObj::InitPartArrayObject</c> at <c>0x0050ed40</c>
/// dispatches type 7 to <c>CPartArray::CreateSetup</c>
/// (decomp 280484) which loads the Setup and walks its parts.
/// We mirror that here: <see cref="SetupMesh.Flatten"/> walks
/// <c>Setup.Parts</c> at the default placement frame and
/// <see cref="GfxObjMesh.Build"/> 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).
/// </description></item>
/// </list>
/// <para>
/// Even with this fix the visible aurora-style sheen most retail
/// rainy/cloudy setups produce comes from the <c>pes_id</c> field
/// on each <see cref="DatReaderWriter.Types.SkyObject"/> (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.
/// </para>
/// </summary>
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<GfxObj>(gfxObjId); }
catch { gfx = null; }
if (gfx is null)
{
_gpuByGfxObj[gfxObjId] = new List<SubMeshGpu>();
return;
}
System.Collections.Generic.IReadOnlyList<GfxObjSubMesh>? subMeshes = null;
try { subMeshes = GfxObjMesh.Build(gfx, _dats); }
catch { subMeshes = null; }
if (subMeshes is null)
{
_gpuByGfxObj[gfxObjId] = new List<SubMeshGpu>();
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<SubMeshGpu>(subMeshes.Count);
foreach (var sm in subMeshes)
gpuList.Add(UploadSubMesh(sm));
_gpuByGfxObj[gfxObjId] = gpuList;
}
/// <summary>
/// Setup-backed sky object loader. Walks <see cref="Setup.Parts"/> at
/// the default placement frame, builds submeshes via
/// <see cref="GfxObjMesh.Build"/>, 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 <c>pes_id</c> particles, not
/// the underlying mesh).
/// <para>
/// Mirrors retail's <see cref="CPhysicsObj.InitPartArrayObject"/> at
/// decomp <c>280484</c> dispatching type 7 → <c>CPartArray::CreateSetup</c>
/// → <c>CSetup::SetSetupID</c>, which loads the setup and instantiates
/// each part as a separate <c>CPhysicsObj</c> child. We collapse the
/// children into a flat submesh list because the sky pass renders
/// without per-part transforms anyway.
/// </para>
/// </summary>
private void EnsureSetupUploaded(uint setupId)
{
Setup? setup = null;
try { setup = _dats.Get<Setup>(setupId); }
catch { setup = null; }
if (setup is null)
{
_gpuByGfxObj[setupId] = new List<SubMeshGpu>();
return;
}
var parts = SetupMesh.Flatten(setup);
var allSubs = new List<SubMeshGpu>(parts.Count);
foreach (var partRef in parts)
{
GfxObj? partGfx = null;
try { partGfx = _dats.Get<GfxObj>(partRef.GfxObjId); }
catch { partGfx = null; }
if (partGfx is null) continue;
System.Collections.Generic.IReadOnlyList<GfxObjSubMesh>? 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;
}
/// <summary>
/// Log each surface's raw flag bits and the derived
/// <see cref="TranslucencyKind"/>. Called once per GfxObj when
/// <c>ACDREAM_DUMP_SKY=1</c>. Output format is grep-friendly so
/// we can pipe the launch log through <c>| grep sky-dump</c> and
/// recover a complete picture of the Dereth sky without re-running.
/// </summary>
private void DumpGfxObjSurfaces(
uint gfxObjId,
GfxObj gfx,
System.Collections.Generic.IReadOnlyList<GfxObjSubMesh> 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<DatReaderWriter.DBObjs.Surface>(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;
/// <summary>
/// True if the Surface's <c>SurfaceType.Additive</c> 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.
/// </summary>
public bool IsAdditive;
/// <summary>
/// <c>Surface.Luminosity</c> float (0..1 — NOT the SurfaceType.Luminous
/// flag bit). Passed to the sky fragment shader as <c>uEmissive</c>;
/// 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
/// <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6.
/// </summary>
public float SurfLuminosity;
public float SurfDiffuse;
/// <summary>
/// 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 <c>GL_REPEAT</c> 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.
/// </summary>
public bool NeedsUvRepeat;
/// <summary>
/// Final surface opacity from <see cref="GfxObjSubMesh.SurfOpacity"/>.
/// Translucent surfaces use <c>1 - Surface.Translucency</c>; other
/// surfaces stay at 1.0.
/// </summary>
public float SurfOpacity;
public bool DisableFog;
}
}