sky(phase-4): retail-verbatim per-vertex lighting on sky meshes

Re-enables the Phase 2 lighting formula that was reverted in Phase 3b
due to a "blue-green-yellow sweep" across clouds. Root cause of that
earlier regression was NOT the formula — it was that we rolled the
wrong DayGroup (Sunny when retail was Cloudy), producing a sharp warm
sun against a sky that should have been rendered with diffuse
overcast light. After Phase 3g pinned the LCG multiplier to 360
(DaysPerYear) so retail + acdream agree on DayGroup, the same
per-vertex formula now faithfully reproduces retail's visuals.

The formula is verified in decompile agent Q2+Q4+Q6 results,
`docs/research/2026-04-23-sky-material-state.md`:

  D3DRS_LIGHTING = ON         (FUN_0059da60:10648)
  D3DRS_AMBIENT  = 0          (never written after init)
  Material.Emissive = (Luminosity, Luminosity, Luminosity, 1)
  Material.Ambient/Diffuse = defaults (≈1,1,1,1) for non-luminous
  light.Ambient = keyframe AmbColor × AmbBright (via SetDirectionalLight)
  light.Diffuse = keyframe DirColor × DirBright

Fixed-function lighting per vertex:
  lit = Emissive + Ambient × lightAmbient + Diffuse × lightDiffuse × max(N·L, 0)
      = Surface.Luminosity + AmbColor×AmbBright + DirColor×DirBright × max(N·L, 0)

Fragment: texture × lit × SkyObjectReplace.Luminosity.

Expected visual:
- Dome (Surface.Luminosity=1): `lit = 1 + amb + diff·N·L` saturates to 1
  → texture passthrough, baked gradient preserved.
- Clouds (Surface.Luminosity=0): `lit = 0 + amb + diff·N·L`
  → purple haze at night (ambient dominates, sun below horizon);
  → warm tan at dusk (ambient + warm sun on west-facing vertices);
  → pale cool gray at noon (ambient + white sun from above).
- Sun/moon (SurfaceType.Additive, Luminosity=1): same as dome +
  additive blend — stays bright regardless.

The shader uniforms (uAmbientColor, uSunColor, uSunDir, uEmissive)
were already wired in the C# renderer from Phase 2; Phase 3b just
stopped using them in the shader. This commit re-activates them.

No clamp at the vertex — retail's D3D lighting allows Emissive+sum
to exceed 1, relies on the framebuffer per-channel saturation. We
keep the 1.2 ceiling in the frag (for lightning flash overbright
headroom) consistent with that convention.

No fog yet (Q1 confirmed retail leaves fog enabled for sky; will add
in a follow-up if horizon looks too bright).

Build + 733 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-24 10:37:40 +02:00
parent 1e1d3875f7
commit 3a117bd91a
2 changed files with 68 additions and 41 deletions

View file

@ -1,29 +1,31 @@
#version 430 core
// Sky mesh fragment shader — UNLIT texture passthrough modulated by the
// per-keyframe SkyObjectReplace.Luminosity and .Transparent overrides.
// Sky mesh fragment shader — final composite matching retail's
// D3D fixed-function:
//
// fragment.rgb = texture.rgb * uLuminosity + lightning_flash
// fragment.a = texture.a * (1 - uTransparency)
// fragment.rgb = texture.rgb × vTint × uLuminosity + lightning_flash
// fragment.a = texture.a × (1 - uTransparency)
//
// uLuminosity defaults to 1.0 (no dim). A SkyObjectReplace entry with
// Luminosity_raw=11 (11%) sets uLuminosity to 0.11 — mesh renders at
// 11% brightness. MaxBright is min-clamped into uLuminosity by the C#
// renderer before it reaches the shader.
// uTransparency defaults to 0.0. Replace.Transparent_raw=100 (100%) sets
// uTransparency to 1.0 — alpha is zeroed and the pixel discarded
// (cloud hidden so the dome behind shows through).
// vTint arrives from the vertex shader with retail's per-vertex
// lighting formula baked in (Emissive + lightAmbient + lightDiffuse ×
// max(N·L, 0)) — see sky.vert for the decompile citation.
//
// See `docs/research/2026-04-23-sky-retail-verbatim.md` §6 + Phase 3b
// rationale in sky.vert.
// uLuminosity is the per-keyframe SkyObjectReplace.Luminosity override
// (0..1, /100 in SkyDescLoader). It's a SEPARATE field from the
// Surface.Luminosity that feeds uEmissive in the vertex shader — they
// compose multiplicatively in retail too.
//
// See `docs/research/2026-04-23-sky-material-state.md`.
in vec2 vTex;
in vec3 vTint;
out vec4 fragColor;
uniform sampler2D uDiffuse;
uniform float uTransparency;
uniform float uLuminosity;
uniform float uTransparency; // 0 = fully visible, 1 = fully transparent
uniform float uLuminosity; // SkyObjectReplace.Luminosity override (0..1)
// Shared SceneLighting UBO — only fog-flash channel used (lightning).
// Shared SceneLighting UBO — only need the fog-flash channel for
// client-driven lightning strobes; sun/ambient already baked into vTint.
struct Light {
vec4 posAndKind;
vec4 dirAndRange;
@ -41,13 +43,16 @@ layout(std140, binding = 1) uniform SceneLighting {
void main() {
vec4 sampled = texture(uDiffuse, vTex);
// Unlit passthrough with per-keyframe dim.
vec3 rgb = sampled.rgb * uLuminosity;
// Composite: texture × per-vertex lit × per-keyframe dim.
vec3 rgb = sampled.rgb * vTint * uLuminosity;
// Lightning additive bump (client-driven during storm keyframes).
float flash = uFogParams.z;
rgb += flash * vec3(1.5, 1.5, 1.8);
// Soft clamp. Normal frame caps at 1.2 so the D3D-style overbright
// from Emissive+Ambient+Diffuse at day-time saturates cleanly; during
// a flash the ceiling relaxes so the strobe blows out visibly.
float cap = mix(1.2, 3.0, clamp(flash, 0.0, 1.0));
rgb = min(rgb, vec3(cap));

View file

@ -1,26 +1,35 @@
#version 430 core
// Sky mesh vertex shader — UNLIT texture passthrough.
// Sky mesh vertex shader — retail-verbatim D3D fixed-function lighting
// ported to per-vertex GLSL. Evidence trail:
//
// Phase 2 experimented with per-vertex `emissive + ambient + diffuse×sun`
// lighting driven from the Surface.Luminosity field. The Phase 3a live
// verification (2026-04-23, user-observed against retail side-by-side
// at MorntideAndHalf) produced a "blue-green-yellow sweep" across the
// sky in acdream while retail showed a clean blue sky with white clouds.
// That's the signature of `diffuse × (250,215,151) warm-gold sunColor`
// tinting the cloud mesh's west-facing faces — retail does NOT do this.
// docs/research/2026-04-23-sky-material-state.md
// §Q2 — retail FUN_0059da60 writes D3DMATERIAL9 per-mesh:
// Material.Emissive.rgb = (Surface.Luminosity, Lum, Lum, 1)
// Material.Ambient/Diffuse from texture-modulate defaults
// §Q4 — D3DRS_LIGHTING is ON for sky meshes
// §Q6 — fragment formula:
// lit = Emissive
// + material.Ambient × light.Ambient
// + material.Diffuse × light.Diffuse × max(dot(N, -sun), 0)
//
// Retail sky meshes render UNLIT. The time-of-day color variation users
// observe (purple haze at night, warm dusk) comes from SkyObjectReplace
// per-keyframe Luminosity + Transparent modulation, revealing/dimming
// different mesh layers — NOT from per-vertex ambient multiply.
// Our `uAmbientColor` = retail's light.Ambient (AmbColor × AmbBright,
// pre-multiplied by SkyDescLoader). `uSunColor` = retail's light.Diffuse
// (DirColor × DirBright). `uSunDir` is a unit vector FROM surface TO
// sun (so `dot(N, uSunDir)` is the diffuse intensity directly; no
// extra negation needed — see SkyStateProvider.SunDirectionFromKeyframe).
// `uEmissive` is Surface.Luminosity for this submesh.
//
// See `docs/research/2026-04-23-sky-retail-verbatim.md` §6 for the
// surviving hypotheses and the Phase 3b decision rationale.
// Phase 2 (2026-04-23) tried the same formula and produced a visible
// east/west "blue-green-yellow sweep" — in hindsight that was CORRECT
// retail behaviour but paired with a wrong DayGroup pick ("Sunny" with
// sharp warm sun when retail rolled "Cloudy" with diffuse overcast).
// After Phase 3g fixed the LCG multiplier so acdream + retail agree on
// the DayGroup, the same formula should now match retail visually.
//
// Uniforms for Ambient/Sun/Emissive stay declared below so the C#-side
// plumbing doesn't need to change — they are simply UNUSED. A future
// phase can revive them if the decompile hunt proves retail applies
// lighting to sky through a different channel.
// NOTE: no clamp at the vertex — retail's D3D fixed-function lighting
// can produce lit values > 1.0 and the final clamp happens at the
// framebuffer write. Doing that same "let it overbright" here keeps
// the dome's emissive=1 saturation path intact.
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
@ -31,16 +40,29 @@ uniform mat4 uSkyView;
uniform mat4 uSkyProjection;
uniform vec2 uUvScroll;
// Unused in Phase 3b — see header. Kept for forward-compat with the
// C# renderer's push calls.
uniform vec3 uAmbientColor;
uniform vec3 uSunColor;
uniform vec3 uSunDir;
// Per-frame lighting (from SkyKeyframe):
uniform vec3 uAmbientColor; // AmbColor × AmbBright (retail light.Ambient)
uniform vec3 uSunColor; // DirColor × DirBright (retail light.Diffuse)
uniform vec3 uSunDir; // unit vector FROM surface TO sun
// Per-submesh (from Surface.Luminosity float):
uniform float uEmissive;
out vec2 vTex;
out vec3 vTint;
void main() {
vTex = aTex + uUvScroll;
gl_Position = uSkyProjection * uSkyView * uModel * vec4(aPos, 1.0);
// uModel for sky is pure rotation (Z then Y) — orthonormal, so
// mat3(uModel) transforms normals correctly without inverse-transpose.
vec3 worldNormal = normalize(mat3(uModel) * aNormal);
// Retail per-vertex fixed-function lighting (AMBIENT=0 globally,
// so the global ambient term drops; only light.Ambient contributes).
float diff = max(dot(worldNormal, uSunDir), 0.0);
vTint = vec3(uEmissive) // material.Emissive
+ uAmbientColor // material.Ambient(1) × light.Ambient
+ uSunColor * diff; // material.Diffuse(1) × light.Diffuse × N·L
}