From 4678b3ee6b233ca9430e83d69121dade09a1f9ef Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Apr 2026 12:04:55 +0200 Subject: [PATCH] fix(sky): apply per-Surface Translucency + Luminosity for retail-faithful weather MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/Shaders/sky.frag | 50 +++++++----- src/AcDream.App/Rendering/Sky/SkyRenderer.cs | 81 ++++++++++++++++---- src/AcDream.Core/Meshing/GfxObjMesh.cs | 11 +++ src/AcDream.Core/Meshing/GfxObjSubMesh.cs | 18 +++++ 4 files changed, 127 insertions(+), 33 deletions(-) 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; }