diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag
index 4ddfbde..3a25b3a 100644
--- a/src/AcDream.App/Rendering/Shaders/sky.frag
+++ b/src/AcDream.App/Rendering/Shaders/sky.frag
@@ -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);
}
diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
index e6238cb..b3df2b8 100644
--- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
+++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
@@ -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.
///
public bool NeedsUvRepeat;
+ ///
+ /// Surface.Translucency float (0..1) carried through from
+ /// . Passed to the
+ /// sky fragment shader as uSurfTranslucency; the shader
+ /// multiplies output alpha by (1 - x). 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 curr_alpha derivation in
+ /// D3DPolyRender::SetSurface at 0x59c767.
+ ///
+ public float SurfTranslucency;
}
}
diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs
index 240e4db..6ddb68c 100644
--- a/src/AcDream.Core/Meshing/GfxObjMesh.cs
+++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs
@@ -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(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;
diff --git a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs
index 488b0dd..f85d5aa 100644
--- a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs
+++ b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs
@@ -53,4 +53,22 @@ public sealed record GfxObjSubMesh(
/// Defaults to false so non-sky consumers get the previous behavior.
///
public bool NeedsUvRepeat { get; init; } = false;
+
+ ///
+ /// Surface.Translucency float (0..1 — distinct from the
+ /// classifier above, which buckets the
+ /// flag bits). Retail's D3DPolyRender::SetSurface at
+ /// 0x59c767 reads this when the Translucent (0x10) bit
+ /// is set on the surface and feeds it into the per-vertex alpha
+ /// (curr_alpha); the rasterizer then multiplies fragment alpha
+ /// by (1 - translucency) so the resulting opacity is
+ /// 1 - x. ACViewer (TextureCache.cs:142) and WorldBuilder
+ /// (ObjectMeshManager.cs:1115) both use the same convention.
+ /// For the rain Surface 0x080000C5, Translucency = 0.5 ⇒
+ /// opacity = 0.5; with the (SrcAlpha, One) 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.
+ ///
+ public float SurfTranslucency { get; init; } = 0f;
}