fix(sky): apply per-Surface Translucency + Luminosity for retail-faithful weather

Two independent brightness bugs were compounding to make rain ~6.7×
too bright at the cylinder rim, and clouds full-bright instead of
time-of-day-tinted:

**Fix 1 — Surface.Translucency was never plumbed to the shader.**

Retail's D3DPolyRender::SetSurface at 0x59c767: when the Surface's
Translucent (0x10) bit is set, its translucency float drives per-vertex
alpha (curr_alpha = ftol(0.5 × 255) = 127). ACViewer
(TextureCache.cs:142) and WorldBuilder (ObjectMeshManager.cs:1115) both
encode the same as `opacity = (1 - x)`. acdream read only Surface.Type
and Surface.Luminosity in GfxObjMesh.Build() — Surface.Translucency
(the float) was never read, never stored, never reached the shader.
For the rain Surface 0x080000C5 (Translucency=0.5) this meant rain
streaks were at full alpha=1.0 instead of 0.5 — 2× brighter than retail
under the (SrcAlpha, One) blend.

Plumbed end-to-end:
  GfxObjSubMesh.SurfTranslucency (init float, default 0)
  GfxObjMesh.Build() reads surface.Translucency next to .Luminosity
  SubMeshGpu.SurfTranslucency carries it to draw time
  SkyRenderer.RenderPass writes uniform `uSurfTranslucency`
  sky.frag final alpha: a = sampled.a × (1 - uTransparency) ×
                            (1 - uSurfTranslucency)

Bonus reach: cloud surface 0x08000023 has Translucency=0.25 → clouds
also dimmed by 25%, more retail-faithful overall.

**Fix 2 — Emissive default was 1.0 instead of the surface's actual Luminosity.**

The sky shader's `effEmissive = (luminosity > 0) ? luminosity : sub.SurfLuminosity`
fallback never fired because the local `luminosity` defaulted to 1f (always
> 0). Every sky mesh got effEmissive=1.0, saturating vTint to white before
the alpha blend. The comment claimed the fallback was active; the code
disagreed.

Empirical sky-surface LUMINOUS audit (RainMeshProbe a6e7108) found that
NO Dereth sky surface carries the SurfaceType.Luminous flag (0x40) —
the previous code comment that did was wrong. The differentiator is
purely the Surface.Luminosity FLOAT:
  dome/sun/moon: Lum=1.0 → vTint saturates → texture passthrough
  stars/clouds:  Lum=0.0 → vTint = ambient + sun·N·L → time-of-day tint
  rain:          Lum=0.1484 → faint emissive baseline + lit additions

Refactored:
  replaceLuminosity = NaN sentinel for "no replace override"
  rep.Luminosity > 0  → set replaceLuminosity to override value
  rep.MaxBright  > 0  → cap replaceLuminosity at MaxBright
  effEmissive = NaN ? sub.SurfLuminosity : replaceLuminosity

Dead uniform `uLuminosity` removed from sky.frag and SkyRenderer SetFloat
call — the redundant multiply was already commented-out earlier this
year (would have double-dimmed clouds), and the uniform value was unused
in the fragment.

Visual verification (Holtburg, live ACE, Rainy DG forced and natural
LCG-picked): rain rim is no longer visible; cloud direction matches
retail when the same DayGroup is active; sky lighting transitions through
day cycle with appropriate time-of-day tint on stars/clouds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-27 12:04:55 +02:00
parent a6e7108122
commit 4678b3ee6b
4 changed files with 127 additions and 33 deletions

View file

@ -2,17 +2,16 @@
// Sky mesh fragment shader — final composite matching retail's
// D3D fixed-function:
//
// fragment.rgb = texture.rgb × vTint × uLuminosity + lightning_flash
// fragment.a = texture.a × (1 - uTransparency)
// fragment.rgb = texture.rgb × vTint + lightning_flash
// fragment.a = texture.a × (1 - uTransparency) × (1 - uSurfTranslucency)
//
// 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.
//
// 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.
// max(N·L, 0)) — see sky.vert for the decompile citation. The keyframe
// SkyObjectReplace.Luminosity override is folded into uEmissive on the
// CPU side (SkyRenderer.cs) so vTint already saturates properly for
// bright keyframes; the previous shader had a redundant uLuminosity
// multiply that was double-dimming clouds, removed 2026-04-26.
//
// See `docs/research/2026-04-23-sky-material-state.md`.
@ -22,8 +21,15 @@ in float vFogFactor; // 1 = no fog (near), 0 = full fog color (far)
out vec4 fragColor;
uniform sampler2D uDiffuse;
uniform float uTransparency; // 0 = fully visible, 1 = fully transparent
uniform float uLuminosity; // SkyObjectReplace.Luminosity override (0..1)
uniform float uTransparency; // 0 = fully visible, 1 = fully transparent
// Surface.Translucency float (0..1) — distinct from uTransparency
// (which is the per-keyframe Replace override). Retail
// D3DPolyRender::SetSurface at 0x59c767 reads this when the Surface's
// Translucent (0x10) bit is set and converts to per-vertex alpha;
// ACViewer + WorldBuilder both apply opacity = (1 - x). Both factors
// compose multiplicatively into final fragment alpha. For non-Translucent
// surfaces uSurfTranslucency = 0 ⇒ no effect.
uniform float uSurfTranslucency;
// Shared SceneLighting UBO — fog params drive the mix, flash channel
// bumps sky brightness during lightning strikes. Matches sky.vert's
@ -45,14 +51,13 @@ layout(std140, binding = 1) uniform SceneLighting {
void main() {
vec4 sampled = texture(uDiffuse, vTex);
// Composite: texture × per-vertex lit.
// `rep.Luminosity` is now pushed into `uEmissive` on the CPU side
// (SkyRenderer.cs) so `vTint` already saturates properly for bright
// keyframes. Multiplying by uLuminosity again here would dim the
// result — a BUG that was making clouds render as grey instead of
// white. Retail's fragment formula (FUN_0059da60 non-luminous
// branch) is texture × litColor × vertex.color(=white), so just
// `texture × vTint` is the retail-faithful composite.
// Composite: texture × per-vertex lit. Replace.Luminosity (per
// keyframe) and Surface.Luminosity are both folded into uEmissive
// on the CPU side (SkyRenderer.cs) so vTint already carries the
// right tint for the time-of-day. Retail's fragment formula
// (FUN_0059da60 non-luminous branch) is texture × litColor ×
// vertex.color(=white), so `texture × vTint` is the retail-faithful
// composite.
vec3 rgb = sampled.rgb * vTint;
// Retail vertex fog: lerp(fogColor, scene, fogFactor). DISABLED
@ -79,7 +84,14 @@ void main() {
float cap = mix(1.0, 3.0, clamp(flash, 0.0, 1.0));
rgb = min(rgb, vec3(cap));
float a = sampled.a * (1.0 - uTransparency);
// Final fragment alpha = texture-alpha × keyframe-replace-opacity
// × surface-translucency-opacity. Both opacity factors are
// (1 - x) form per ACViewer (TextureCache.cs:142) and WorldBuilder
// (ObjectMeshManager.cs:1115). For the rain mesh 0x01004C42/0x01004C44:
// sampled.a = 1.0 (R8G8B8 texture), uTransparency = 0,
// uSurfTranslucency = 0.5 → a = 0.5 → with the (SrcAlpha, One) blend
// the streak contribution is halved, matching retail's curr_alpha = 127.
float a = sampled.a * (1.0 - uTransparency) * (1.0 - uSurfTranslucency);
if (a < 0.01) discard;
fragColor = vec4(rgb, a);
}

View file

@ -211,14 +211,31 @@ public sealed unsafe class SkyRenderer : IDisposable
uint gfxObjId = obj.GfxObjId;
float headingDeg = 0f;
float transparent = 0f;
float luminosity = 1f;
// Replace-override luminosity. Stays NaN when there is no
// replace entry or none of the keyframe's overrides are set,
// and that NaN is the signal to fall back to the surface's
// authored Luminosity at draw time. This replaces the previous
// `luminosity = 1f` default which masked the surface value
// because the `(luminosity > 0) ? luminosity : sub.SurfLuminosity`
// fallback at the inner loop never fired (1f is always > 0).
// RainMeshProbe (committed b8e0857) confirmed empirically that
// NO Dereth sky surface carries the SurfaceType.Luminous flag
// bit (0x40) — the differentiator is purely the float field.
float replaceLuminosity = float.NaN;
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 (rep.Luminosity > 0f) replaceLuminosity = rep.Luminosity;
// MaxBright is a CAP: even if the surface authored Lum=1.0,
// a per-keyframe MaxBright trims it. When no explicit
// Luminosity replace exists, MaxBright still acts as the
// ceiling (applied against sub.SurfLuminosity at draw time).
if (rep.MaxBright > 0f)
replaceLuminosity = float.IsNaN(replaceLuminosity)
? rep.MaxBright
: MathF.Min(replaceLuminosity, rep.MaxBright);
}
if (gfxObjId == 0) continue;
@ -262,7 +279,6 @@ public sealed unsafe class SkyRenderer : IDisposable
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;
@ -281,19 +297,44 @@ public sealed unsafe class SkyRenderer : IDisposable
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.
// Emissive source picks the surface's authored Luminosity by
// default; the per-keyframe replace data can OVERRIDE
// (rep.Luminosity > 0) or CAP (rep.MaxBright). This matches
// retail's FUN_0059da60: surface.Luminosity → D3DMATERIAL.Emissive
// (via material cache +0x3c), with the keyframe replace
// promoting bright-keyframe clouds when the keyframe asks.
//
// 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;
// Empirical Dereth sky surfaces (RainMeshProbe, b8e0857):
// dome/sun/moon → Lum=1.0 → vTint saturates → texture
// passthrough (correct retail look);
// stars/clouds → Lum=0.0 → vTint = ambient + diffuse →
// picks up the time-of-day tint;
// rain → Lum=0.1484 → faint emissive baseline,
// ambient+diffuse adds atmospheric tint.
//
// Pre-fix: the replace-override variable defaulted to 1f and
// the fallback `(luminosity > 0) ? luminosity : sub.SurfLuminosity`
// never fired — every sky mesh got effEmissive=1.0,
// saturating vTint. That made stars/clouds look full-bright
// instead of time-of-day-tinted, and made rain streaks
// 6.7× too bright (one of two factors compounding the
// foreground-rim visibility bug).
float effEmissive = float.IsNaN(replaceLuminosity)
? sub.SurfLuminosity
: replaceLuminosity;
_shader.SetFloat("uEmissive", effEmissive);
// Retail per-Surface translucency override (D3DPolyRender::SetSurface
// at 0x59c767): when the Surface's Translucent (0x10) bit is set,
// its translucency float drives per-vertex alpha. Both ACViewer
// and WorldBuilder render this as opacity = (1 - x). The shader
// multiplies output alpha by (1 - uSurfTranslucency); for surfaces
// without the bit, SurfTranslucency=0 ⇒ no effect. Critical for
// the rain mesh 0x01004C42/0x01004C44 (Translucency=0.5) so its
// streaks contribute at half intensity instead of full under the
// additive (SrcAlpha, One) blend.
_shader.SetFloat("uSurfTranslucency", sub.SurfTranslucency);
uint tex = _textures.GetOrUpload(sub.SurfaceId);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
@ -514,6 +555,7 @@ public sealed unsafe class SkyRenderer : IDisposable
IsAdditive = isAdditive,
SurfLuminosity = sm.Luminosity,
NeedsUvRepeat = sm.NeedsUvRepeat,
SurfTranslucency = sm.SurfTranslucency,
};
}
@ -563,5 +605,16 @@ public sealed unsafe class SkyRenderer : IDisposable
/// Computed once at mesh build from the actual UV range.
/// </summary>
public bool NeedsUvRepeat;
/// <summary>
/// <c>Surface.Translucency</c> float (0..1) carried through from
/// <see cref="GfxObjSubMesh.SurfTranslucency"/>. Passed to the
/// sky fragment shader as <c>uSurfTranslucency</c>; the shader
/// multiplies output alpha by <c>(1 - x)</c>. For the rain
/// surface 0x080000C5 this is 0.5 → opacity 0.5 → rain streaks
/// contribute at half intensity under the additive blend, matching
/// retail's <c>curr_alpha</c> derivation in
/// <c>D3DPolyRender::SetSurface</c> at <c>0x59c767</c>.
/// </summary>
public float SurfTranslucency;
}
}

View file

@ -200,6 +200,7 @@ public static class GfxObjMesh
// docs/research/2026-04-23-sky-retail-verbatim.md §6).
var translucency = TranslucencyKind.Opaque;
var luminosity = 0f;
var surfTranslucency = 0f;
if (dats is not null)
{
var surface = dats.Get<Surface>(surfaceId);
@ -207,6 +208,15 @@ public static class GfxObjMesh
{
translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type);
luminosity = surface.Luminosity;
// Retail D3DPolyRender::SetSurface at 0x59c767: when the
// Translucent (0x10) flag is set, the surface's
// Translucency float drives per-vertex alpha. Both
// ACViewer and WorldBuilder apply opacity = (1 - x).
// For the rain Surface 0x080000C5 this is 0.5. Carrying
// the float verbatim and converting to opacity in the
// shader keeps non-Translucent surfaces (Translucency=0)
// identical to the previous behavior.
surfTranslucency = surface.Translucency;
}
}
@ -235,6 +245,7 @@ public static class GfxObjMesh
Translucency = translucency,
Luminosity = luminosity,
NeedsUvRepeat = needsUvRepeat,
SurfTranslucency = surfTranslucency,
});
}
return result;

View file

@ -53,4 +53,22 @@ public sealed record GfxObjSubMesh(
/// Defaults to false so non-sky consumers get the previous behavior.
/// </summary>
public bool NeedsUvRepeat { get; init; } = false;
/// <summary>
/// <c>Surface.Translucency</c> float (0..1 — distinct from the
/// <see cref="TranslucencyKind"/> classifier above, which buckets the
/// flag bits). Retail's <c>D3DPolyRender::SetSurface</c> at
/// <c>0x59c767</c> reads this when the <c>Translucent</c> (0x10) bit
/// is set on the surface and feeds it into the per-vertex alpha
/// (<c>curr_alpha</c>); the rasterizer then multiplies fragment alpha
/// by <c>(1 - translucency)</c> so the resulting opacity is
/// <c>1 - x</c>. ACViewer (<c>TextureCache.cs:142</c>) and WorldBuilder
/// (<c>ObjectMeshManager.cs:1115</c>) both use the same convention.
/// For the rain Surface 0x080000C5, <c>Translucency = 0.5</c> ⇒
/// opacity = 0.5; with the <c>(SrcAlpha, One)</c> additive blend the
/// rain streaks contribute at half intensity instead of full.
/// Defaults to 0.0 (fully opaque) so non-translucent surfaces render
/// through the normal lighting path without change.
/// </summary>
public float SurfTranslucency { get; init; } = 0f;
}