acdream/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
Erik 3e0da496e0 feat(sky): split SkyRenderer into pre-/post-scene passes + retail -120m weather Z offset
Bug A (foreground rain) from docs/research/2026-04-26-sky-investigation-handoff.md:
rain mesh was only visible at horizon, not in the air between camera and
character. Two retail mechanisms ported here:

1. **Render order split.** Retail's `LScape::draw` at 0x00506330 calls
   `GameSky::Draw(0)` BEFORE the landblock DrawBlock loop and
   `GameSky::Draw(1)` AFTER — i.e. weather meshes render after scene
   geometry so additive rain streaks paint on top of terrain and entities.
   Acdream was rendering both passes pre-scene, so terrain immediately
   painted over the rain.

   Refactored `SkyRenderer.Render` into `RenderSky` (filter !IsWeather)
   and `RenderWeather` (filter IsWeather) sharing a private `RenderPass`
   core that takes a `weatherPass` bool. Partition is per-SkyObject by
   `Properties & 0x04` (the WEATHER_BIT, mirroring tools/WeatherEnumerator).
   Added `SkyObjectData.IsWeather` getter for the partition.

   `GameWindow.OnRender` now calls `RenderSky` before terrain/static-mesh/
   particles (line ~4322) and `RenderWeather` after particles (line ~4368).

2. **Weather Z offset.** Retail `GameSky::UpdatePosition` at 0x00506dd0,
   lines 0x506e96..0x506e98:

       if (((eax_13 & 4) != 0 && (eax_13 & 8) == 0))
           int32_t var_4_1 = 0xc2f00000;   // 0xc2f00000 == -120.0f

   Weather objects (property bit 0x04 set, bit 0x08 unset) get their frame
   origin set to player_pos + (0, 0, -120m). The rain cylinder GfxObjs
   0x01004C42/0x01004C44 have local Z range 0.11..814.90 (815m tall, 113m
   radius). Without the offset the cylinder bottom sat just above the
   camera; with -120m the cylinder spans (camera-119.89)..(camera+694.90)
   so the camera is inside.

   `SkyRenderer.RenderPass` applies the -120m model translation when
   `weatherPass` is true (line ~253-254).

3. **Legacy camera-attached emitter gated.** `UpdateWeatherParticles` —
   the pre-research workaround that emitted camera-attached rain particles
   (broken alpha fade, fixed disk around camera) — is now gated behind
   `ACDREAM_FAKE_RAIN_PARTICLES=1`. Default off; the retail-faithful
   world-space mesh is the default path.

User-verified: rain is now visible in foreground from many perspectives,
but the cylinder's open-top rim is still visible when looking straight up.
That rim issue is a separate brightness-excess bug filed for follow-up
(Translucency float not plumbed to shader; surface.Translucency=0.5 ignored
so streaks render at 2× retail intensity).

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

567 lines
26 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;
// 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)
{
_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));
}
/// <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)
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, weatherPass: false);
/// <summary>
/// Draw the WEATHER sky objects (the foreground rain mesh
/// <c>0x01004C42</c>/<c>0x01004C44</c> on Rainy DayGroups, plus the
/// per-storm 5cm flash dummies — every <c>SkyObject</c> with
/// <c>Properties &amp; 0x04 != 0</c>). Called AFTER the scene so the
/// rain meshes paint on top of terrain and entities — that's the
/// 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. With depth-test disabled and additive blend
/// (the rain Surface flag <c>0x080000C5</c> 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.
/// </summary>
public void RenderWeather(
ICamera camera,
Vector3 cameraWorldPos,
float dayFraction,
DayGroupData? group,
SkyKeyframe keyframe)
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, weatherPass: true);
/// <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.IsWeather"/>.
/// </summary>
private void RenderPass(
ICamera camera,
Vector3 cameraWorldPos,
float dayFraction,
DayGroupData? group,
SkyKeyframe keyframe,
bool weatherPass)
{
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];
// Partition by weather flag — the caller chose either the
// pre-scene sky pass (non-weather) or the post-scene weather
// pass (weather only). Mirrors retail GameSky::Draw at
// 0x00506ff0 where arg2==0 iterates non-weather sky_obj
// entries (filtered by property bit 0x04 == 0 inside the
// loop) and arg2==1 draws after_sky_cell which only contains
// weather objects.
if (obj.IsWeather != weatherPass) continue;
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);
// 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
//
// Weather objects (property bit 0x04 set, bit 0x08 unset)
// have their frame origin set to player_pos + (0, 0, -120m).
// The rain cylinder GfxObjs 0x01004C42/0x01004C44 have local
// Z range 0.11..814.90 (815m tall, 113m radius). Without the
// offset the cylinder bottom sits at z=0.11 ABOVE the camera
// (skyView translation is zeroed so model-origin == camera);
// looking horizontally shows nothing, looking up shows a
// distant cylinder. With -120m the cylinder spans z =
// (camera-119.89)..(camera+694.90) in view space — camera
// is inside, looking in any direction shows surrounding
// walls — the volumetric foreground-rain look retail has.
if (weatherPass)
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);
_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);
// 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).
bool needsRepeat = sub.NeedsUvRepeat
|| obj.TexVelocityX != 0f
|| obj.TexVelocityY != 0f;
int wrapMode = needsRepeat
? (int)TextureWrapMode.Repeat
: (int)TextureWrapMode.ClampToEdge;
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, wrapMode);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, wrapMode);
_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);
}
/// <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 GfxObj build — reuses <see cref="GfxObjMesh"/> so the
/// pos/neg polygon splitting logic stays consistent with the main
/// static-mesh pipeline. Most sky meshes are single-surface.
/// </summary>
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<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>
/// 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} 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,
NeedsUvRepeat = sm.NeedsUvRepeat,
};
}
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;
/// <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;
}
}