sky(phase-2): retail-verbatim per-vertex lighting via Surface.Luminosity

Phase 2 of the sky port. Empirically confirmed from the Phase 1 dump
(ACDREAM_DUMP_SKY=1 on the live Dereth region): retail distinguishes
self-illuminated sky meshes from lit ones by the `Surface.Luminosity`
FLOAT field (0..1), NOT by the `SurfaceType.Luminous` flag bit (none of
Dereth's sky meshes have the flag set).

Observed values on the 4 currently-visible sky GfxObjs:

  GfxObj 0x010015EE (dome, 4 surfaces)    Luminosity = 1.0
  GfxObj 0x010015EF (upper cloud)         Luminosity = 0.0
  GfxObj 0x01004C36 (lower drift cloud)   Luminosity = 0.0
  GfxObj 0x01001348 (sun/moon additive)   Luminosity = 1.0

Retail uses this as an emissive coefficient in the per-vertex lighting
formula (decompiled chunk_00500000.c:7535 FUN_00508010 + chunk_00530000.c
AdjustPlanes per-vertex math):

  tint = clamp(vec3(Luminosity) + AmbColor*AmbBright
               + max(dot(N, -sunDir), 0) * DirColor*DirBright,
               0.0, 1.0)
  fragment = texture * tint

When Luminosity=1.0 the clamp saturates → full texture brightness
regardless of time of day (dome gradient preserved; sun/moon always
bright). When Luminosity=0.0 only the ambient + diffuse term drives the
tint, so clouds pick up the time-of-day ambient (purple at midnight
per AmbColor=(200,100,255)×AmbBright=0.4 ≈ (0.31,0.16,0.40); warm tan
at dusk; pale-cool at noon).

Also empirically confirmed: raw SkyObjectReplace Transparent/Luminosity
/MaxBright are in 0..100 percent range (observed 11, 15, 22, 66, 100,
and -1 sentinel). The `/100` divide in SkyDescLoader (eeae83a) is
retail-correct; `_DAT_007a1870` in the decompile must be 0.01f.

Code changes:
- src/AcDream.Core/Meshing/GfxObjSubMesh.cs: new `Luminosity` field on
  the per-submesh record (0..1, defaults to 0 for non-sky meshes).
- src/AcDream.Core/Meshing/GfxObjMesh.cs: pull Surface.Luminosity when
  building submeshes (alongside existing Translucency capture).
- src/AcDream.App/Rendering/Sky/SkyRenderer.cs:
  - SubMeshGpu gains SurfLuminosity, propagated from GfxObjSubMesh.
  - Render() pushes uAmbientColor/uSunColor/uSunDir once per frame from
    the interpolated keyframe; uEmissive once per submesh.
  - uTint uniform removed (replaced by the vTint varying computed in
    the vertex shader).
- src/AcDream.App/Rendering/Shaders/sky.vert: computes vTint per-vertex
  using the retail AdjustPlanes formula.
- src/AcDream.App/Rendering/Shaders/sky.frag: consumes vTint, drops
  uTint uniform. uLuminosity (the per-keyframe SkyObjectReplace
  override) still applied as a final scalar multiply.

Expected visual difference from Phase 1 baseline:
  - Dome gradient: IDENTICAL (Luminosity=1 saturates).
  - Sun / moon: IDENTICAL (Luminosity=1 saturates, additive blend).
  - Clouds: now tinted by time of day. Midnight → purple haze. Noon →
    pale cool. Dusk → warm tan.

Open questions (unchanged from Phase 1 doc):
  - Does the 15s LightTickSize throttling need porting? Phase 3.
  - Does FUN_00532440 (AdjustPlanes per-cell terrain relight) need
    porting for non-sky geometry to follow the sky? Phase 3.

Build + 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-23 18:19:22 +02:00
parent 58afd4850f
commit aa2e20a42e
5 changed files with 152 additions and 78 deletions

View file

@ -74,18 +74,21 @@ public sealed unsafe class SkyRenderer : IDisposable
/// terrain / meshes / debug lines / overlay land on top.
///
/// <para>
/// <paramref name="keyframe"/> is accepted for forward-compatibility
/// with the retail-verbatim per-vertex lighting path (see
/// <c>docs/research/2026-04-23-sky-retail-verbatim.md</c>). It is
/// NOT currently consumed by the shader — sky meshes render at
/// <c>uTint = white</c> (texture passthrough). A prior experiment
/// multiplied alpha-blended submeshes by <c>keyframe.AmbientColor</c>
/// to tint clouds; this dimmed the sky dome's baked gradient
/// (user-verified regression) and was reverted. Retail actually
/// routes sky meshes through the normal mesh pipeline with
/// Surface.Type.Luminous controlling lit-vs-unlit per submesh; the
/// correct port lives downstream in Phase 2 once we have the live
/// Surface flags dumped.
/// 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 Render(
@ -116,23 +119,24 @@ public sealed unsafe class SkyRenderer : IDisposable
_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 based
// on the Surface's TranslucencyKind + Luminous flag. Sun/moon/stars
// in retail use Additive (their texture has a black background and a
// bright body painted on top; additive blending ignores the black and
// lets the body glow over the sky gradient). Clouds use AlphaBlend.
// Without per-object blend, sun renders as "black square with sun in
// it" because our default alpha-blend treats the black background as
// opaque. See SurfaceType enum (DatReaderWriter.Enums.SurfaceType):
// Additive = 0x10000 → GL_ONE / GL_ONE
// Luminous = 0x40 → additive for sky purposes
// Alpha = 0x100 / Translucent = 0x10 → GL_SRC_ALPHA / GL_ONE_MINUS_SRC_ALPHA
// Base1ClipMap = 0x04 → alpha with shader discard
// 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
@ -183,17 +187,6 @@ public sealed unsafe class SkyRenderer : IDisposable
_shader.SetVec2("uUvScroll", new Vector2(uOffset, vOffset));
_shader.SetFloat("uTransparency", transparent);
_shader.SetFloat("uLuminosity", luminosity);
// uTint stays white: retail renders sky meshes as texture
// passthrough (the gradient lives in the mesh texture, not in
// a shader ambient multiply). D3DRS_AMBIENT is set to 0 once
// at retail device-init and never changes per-frame — verified
// in chunk_005A0000.c (state 0x8b = 139, only external caller
// is the default-reset at line 704). The "cloud tint" effect
// comes from per-vertex lighting on non-Luminous submeshes
// routed through the normal mesh pipeline. That path is
// Phase 2 — see docs/research/2026-04-23-sky-retail-verbatim.md
// §6 + §10 and the hunt-B finding at 2026-04-23-sky-decompile-hunt-B.md.
_shader.SetVec4("uTint", Vector4.One);
EnsureMeshUploaded(gfxObjId);
if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes)) continue;
@ -202,20 +195,25 @@ public sealed unsafe class SkyRenderer : IDisposable
{
// Per-submesh blend mode: sun/moon/stars are Additive
// (SurfaceType.Additive = 0x10000), clouds are AlphaBlend,
// sky dome is either Opaque or AlphaBlend depending on the
// dat. We map Opaque to a passthrough SrcAlpha/InvSrcAlpha
// with alpha=1, which is equivalent to not blending. This
// split is architecturally correct (sun's additive blend
// stops its black texture background from occluding the
// sky dome behind it) but is NOT how retail does it —
// retail routes sky meshes through the normal mesh pipe
// where Surface flags dictate blend state per primitive.
// See FUN_00508010 (chunk_00500000.c:7535).
// 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);
// Per-submesh emissive (Surface.Luminosity FLOAT field —
// 1.0 for dome + sun + moon, 0.0 for clouds). The vertex
// shader saturates the lighting math when emissive=1.0 so
// self-illuminated meshes render at full texture brightness
// regardless of time of day; emissive=0.0 meshes get the
// full `ambient + diffuse × sun` tint (producing retail's
// purple night clouds / warm dusk clouds / pale noon clouds).
_shader.SetFloat("uEmissive", sub.SurfLuminosity);
uint tex = _textures.GetOrUpload(sub.SurfaceId);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
@ -403,6 +401,7 @@ public sealed unsafe class SkyRenderer : IDisposable
IndexCount = sm.Indices.Length,
SurfaceId = sm.SurfaceId,
IsAdditive = isAdditive,
SurfLuminosity = sm.Luminosity,
};
}
@ -428,10 +427,19 @@ public sealed unsafe class SkyRenderer : IDisposable
public int IndexCount;
public uint SurfaceId;
/// <summary>
/// True if the Surface's flags indicate additive blending should be
/// used (SurfaceType.Additive OR SurfaceType.Luminous). Computed
/// once at upload; avoids a per-frame dat lookup.
/// 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;
}
}