feat(sky): load Setup-backed (0x020xxx) sky objects via SetupMesh.Flatten

Independent code review by an external agent (2026-04-27) flagged
that SkyRenderer.EnsureMeshUploaded only ever called
_dats.Get<GfxObj>(...) — every 0x020xxx Setup ID returned null and
got cached as an empty submesh list, silently dropping every
Setup-backed sky object across the Dereth Region. In Rainy DG3
alone that's 6 dropped SkyObjects (0x02000714, 0x02000BA6 ×2,
0x02000588 ×4, 0x02000589 ×3 across various time-of-day windows).

Verbatim from retail's CelestialPosition struct at acclient.h:35451:

    struct CelestialPosition {
        IDClass<...> gfx_id;
        IDClass<...> pes_id;          // particle scheduler
        float heading; float rotation;
        Vector3 tex_velocity;
        float transparent; float luminosity; float max_bright;
        unsigned int properties;
    };

Per the named retail decomp, CPhysicsObj::InitPartArrayObject (decomp
~280484) dispatches gfx_id by type prefix: type 6 → direct GfxObj,
type 7 → Setup via CPartArray::CreateSetup (decomp ~287490) which
walks Setup.Parts. Mirror that here: detect 0x020xxxxx in
EnsureMeshUploaded, route to a new EnsureSetupUploaded helper that
flattens via SetupMesh.Flatten (existing Phase-2 utility) and bakes
each part's transform into the vertex positions before upload.
Sky setups don't animate in any way that affects the static-mesh
visual we render here.

Probe extension: also added the Diffuse column to RainMeshProbe's
sky-surface audit so the (Type, Translucency, Luminosity, Diffuse)
quadruple is visible on every flag-bit row.

Visual impact at verification launch: not observable. The Setup
objects in Rainy DGs appear to be tiny placeholder meshes existing
mainly to anchor PES emitters. The dynamic "aurora-like" sheen the
user observes in retail comes from the PES particle layer, which
remains unimplemented (issue #28). Keeping this fix because the
geometry path is now decomp-correct and provides foundation for
the eventual PES wiring.

Issue #29 filed for the residual cloud-density gap. 1227 tests pass.
This commit is contained in:
Erik 2026-04-27 23:24:09 +02:00
parent 375065ba94
commit 646ccca85e
2 changed files with 108 additions and 4 deletions

View file

@ -439,14 +439,53 @@ public sealed unsafe class SkyRenderer : IDisposable
}
/// <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.
/// 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
@ -487,6 +526,71 @@ public sealed unsafe class SkyRenderer : IDisposable
_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

View file

@ -59,7 +59,7 @@ static void ProbeSkySurface(DatCollection dats, uint sid)
{ Console.WriteLine($" Surface 0x{sid:X8} (NOT FOUND)"); return; }
uint t = (uint)s.Type;
bool luminous = (t & 0x40u) != 0u;
Console.Write($" Surface 0x{sid:X8} Type=0x{t:X8} Luminous={(luminous ? "YES" : "no ")} Lum={s.Luminosity:F4} Trans={s.Translucency:F4} ");
Console.Write($" Surface 0x{sid:X8} Type=0x{t:X8} Luminous={(luminous ? "YES" : "no ")} Lum={s.Luminosity:F4} Trans={s.Translucency:F4} Diff={s.Diffuse:F4} ");
// Decode bits inline.
var bits = new (uint mask, string n)[] {
(0x01u,"B1Solid"),(0x02u,"B1Image"),(0x04u,"B1ClipMap"),(0x10u,"Translucent"),