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>
567 lines
26 KiB
C#
567 lines
26 KiB
C#
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.1–1e6 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 & 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 & 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;
|
||
}
|
||
}
|