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:
parent
375065ba94
commit
646ccca85e
2 changed files with 108 additions and 4 deletions
|
|
@ -439,14 +439,53 @@ public sealed unsafe class SkyRenderer : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lazy GfxObj build — reuses <see cref="GfxObjMesh"/> so the
|
/// Lazy mesh build for a sky object. Handles two cases:
|
||||||
/// pos/neg polygon splitting logic stays consistent with the main
|
/// <list type="bullet">
|
||||||
/// static-mesh pipeline. Most sky meshes are single-surface.
|
/// <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>
|
/// </summary>
|
||||||
private void EnsureMeshUploaded(uint gfxObjId)
|
private void EnsureMeshUploaded(uint gfxObjId)
|
||||||
{
|
{
|
||||||
if (_gpuByGfxObj.ContainsKey(gfxObjId)) return;
|
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
|
// DatCollection isn't thread-safe and the streaming loader can be
|
||||||
// actively reading a shared DatBinReader buffer; sky meshes are
|
// actively reading a shared DatBinReader buffer; sky meshes are
|
||||||
// loaded on the render thread but GfxObj.Unpack can race with the
|
// 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;
|
_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>
|
/// <summary>
|
||||||
/// Log each surface's raw flag bits and the derived
|
/// Log each surface's raw flag bits and the derived
|
||||||
/// <see cref="TranslucencyKind"/>. Called once per GfxObj when
|
/// <see cref="TranslucencyKind"/>. Called once per GfxObj when
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ static void ProbeSkySurface(DatCollection dats, uint sid)
|
||||||
{ Console.WriteLine($" Surface 0x{sid:X8} (NOT FOUND)"); return; }
|
{ Console.WriteLine($" Surface 0x{sid:X8} (NOT FOUND)"); return; }
|
||||||
uint t = (uint)s.Type;
|
uint t = (uint)s.Type;
|
||||||
bool luminous = (t & 0x40u) != 0u;
|
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.
|
// Decode bits inline.
|
||||||
var bits = new (uint mask, string n)[] {
|
var bits = new (uint mask, string n)[] {
|
||||||
(0x01u,"B1Solid"),(0x02u,"B1Image"),(0x04u,"B1ClipMap"),(0x10u,"Translucent"),
|
(0x01u,"B1Solid"),(0x02u,"B1Image"),(0x04u,"B1ClipMap"),(0x10u,"Translucent"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue