diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag index 3a25b3a..e492f6e 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -22,13 +22,18 @@ out vec4 fragColor; uniform sampler2D uDiffuse; 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. +// 1.0 = apply fog mix to this submesh; 0.0 = skip fog (Additive sky +// surfaces — sun/moon/stars per retail SetFFFogAlphaDisabled(1) at +// D3DPolyRender::SetSurface 0x59c882). Set per-submesh on the CPU side. +uniform float uApplyFog; +// Surface.Translucency float (0..1) used DIRECTLY as opacity (NOT 1-x). +// Distinct from uTransparency (per-keyframe Replace override). Retail +// D3DPolyRender::SetSurface at 0x59c7a6 (decomp 425255-425260) reads +// Surface.Translucency when the Translucent (0x10) bit is set and feeds +// _ftol2(translucency × 255) directly as vertex alpha. ACViewer +// (TextureCache.cs:142) + WorldBuilder (ObjectMeshManager.cs:1115) both +// invert it (1-x) and are wrong. For non-Translucent surfaces the CPU +// side (GfxObjMesh.Build) sets uSurfTranslucency = 1.0 ⇒ no effect. uniform float uSurfTranslucency; // Shared SceneLighting UBO — fog params drive the mix, flash channel @@ -60,14 +65,36 @@ void main() { // composite. vec3 rgb = sampled.rgb * vTint; - // Retail vertex fog: lerp(fogColor, scene, fogFactor). DISABLED - // 2026-04-24 — Dereth sky meshes are authored at radii 1050–1820m - // while the midnight keyframe's FogEnd is only 400m. Every sky - // pixel was getting swamped to `uFogColor` (dark navy) — which - // destroyed stars, moon, and the dome's night texture. Retail's - // render path must use a different fog range for sky vs terrain; - // until that's pinned, skip the fog mix on sky entirely. - // rgb = mix(uFogColor.rgb, rgb, vFogFactor); + // Retail-faithful sky fog mix with a "fog floor" mitigation: + // + // Dereth sky meshes are authored at radii 1050–1820m. At midnight + // (storm keyframes FogEnd ~400m) the raw vFogFactor saturates to 0 + // for every dome pixel — `mix(fogColor, rgb, 0)` would render the + // entire dome as flat fogColor, destroying stars / moon / texture. + // That was the reason fog was disabled on sky 2026-04-24 (issue #4). + // + // Retail clearly DOES apply fog to its sky meshes — distant horizon + // mountains and the dome itself fade toward the fog color in retail + // screenshots. Mechanism unknown (sky-specific FogEnd? elevation- + // weighted? different formula?). Until pinned, the workaround is + // a clamp on the minimum fog factor so the dome NEVER mixes more + // than (1 - SKY_FOG_FLOOR) toward fogColor — preserves stars/moon + // while still letting the horizon haze visibly in low-FogEnd + // keyframes. + // + // SKY_FOG_FLOOR=0.2 means dome shows AT LEAST 20% raw texture, AT + // MOST 80% fog color even at extreme distances. Tuned via dual- + // client visual comparison 2026-04-27 — adjust if night sky goes + // back to flat-fog or stays too vivid vs retail. + // Skip fog mix entirely on Additive surfaces (sun, moon, stars, + // additive cloud sheets) — retail's SetFFFogAlphaDisabled(1) at + // D3DPolyRender::SetSurface 0x59c882. Without this gate the sun + // dims to fog color at horizon, which doesn't match retail. + if (uApplyFog > 0.5) { + const float SKY_FOG_FLOOR = 0.2; + float skyFogFactor = max(vFogFactor, SKY_FOG_FLOOR); + rgb = mix(uFogColor.rgb, rgb, skyFogFactor); + } // Lightning additive bump — client-driven during storm flashes. // NOTE: the exact retail mechanism for lightning visual is still @@ -84,14 +111,24 @@ void main() { float cap = mix(1.0, 3.0, clamp(flash, 0.0, 1.0)); rgb = min(rgb, vec3(cap)); - // 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); + // Final fragment alpha: + // uTransparency — keyframe-replace transparency override (0..1). + // 0 = fully visible, 1 = fully transparent. + // Applied as (1 - x). + // uSurfTranslucency — the dat's Surface.Translucency value when the + // Translucent flag is set, else 1.0. Despite the + // name, retail uses this as OPACITY directly (per + // D3DPolyRender::SetSurface at 0x59c7a6 which + // writes _ftol2(translucency × 255) into vertex + // alpha). Multiply directly — NOT (1 - x). + // + // For the rain mesh 0x01004C42/4C44 (translucency=0.5): a = 1*1*0.5 = 0.5 + // matches retail curr_alpha=127, halves the additive streak. + // For cloud surface 0x08000023 (translucency=0.25): a = 1*1*0.25 = 0.25 + // matches retail curr_alpha=63, dim cloud (was 3× too bright with + // the previous 1-x formula). + // For non-Translucent surfaces uSurfTranslucency = 1.0, no effect. + float a = sampled.a * (1.0 - uTransparency) * 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 b3df2b8..76f5fa6 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -325,16 +325,28 @@ public sealed unsafe class SkyRenderer : IDisposable _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. + // at 0x59c7a6, decomp 425255-425260): when the Surface's + // Translucent (0x10) bit is set, retail computes + // curr_alpha = _ftol2(translucency × 255) and writes it as vertex + // alpha — i.e. the dat's Translucency float is the OPACITY + // directly, NOT inverted. ACViewer and WorldBuilder both invert + // it (1 - x) and are wrong by the same misread. The shader uses + // it directly as an opacity multiplier; for non-Translucent + // surfaces the GfxObjMesh.Build() path keeps SurfTranslucency=1.0 + // (no effect). Critical for rain (Translucency=0.5 → opacity 0.5) + // and clouds (Translucency=0.25 → opacity 0.25, dim like retail). _shader.SetFloat("uSurfTranslucency", sub.SurfTranslucency); + // Retail D3DPolyRender::SetSurface at 0x59c882 calls + // SetFFFogAlphaDisabled(1) when the Additive flag (0x10000) + // is set on the Surface — so the sun, moon, stars, and any + // additive cloud sheet are drawn WITHOUT fog. Skipping fog + // on additive surfaces keeps the sun bright at horizon + // dusk/dawn (where fog would otherwise dim it to fog color). + // Non-additive sky meshes (the dome, opaque cloud layers) + // still mix toward fog with the floor mitigation in sky.frag. + _shader.SetFloat("uApplyFog", sub.IsAdditive ? 0f : 1f); + uint tex = _textures.GetOrUpload(sub.SurfaceId); _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, tex); diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs index 6ddb68c..47f4368 100644 --- a/src/AcDream.Core/Meshing/GfxObjMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs @@ -200,7 +200,21 @@ public static class GfxObjMesh // docs/research/2026-04-23-sky-retail-verbatim.md §6). var translucency = TranslucencyKind.Opaque; var luminosity = 0f; - var surfTranslucency = 0f; + // SurfTranslucency = the OPACITY multiplier the shader applies + // to fragment alpha. 1.0 = fully opaque (default, non-Translucent + // surfaces). For Translucent-flag surfaces, retail's + // D3DPolyRender::SetSurface at 0x59c7a6 (decomp lines 425255- + // 425260) computes curr_alpha = _ftol2(translucency × 255) and + // feeds that as vertex.color.alpha — so the dat's Translucency + // float is the OPACITY directly (NOT inverted). For rain + // (translucency=0.5) opacity is 0.5; for cloud surface + // 0x08000023 (translucency=0.25) opacity is 0.25 — that's why + // retail's clouds are dim and acdream's were 3× too bright + // before this fix (we used 1-translucency, inverting the + // semantic). ACViewer's TextureCache.cs:142 and WorldBuilder's + // ObjectMeshManager.cs:1115 also use 1-translucency and are + // both wrong by the same misread. + var surfTranslucency = 1.0f; if (dats is not null) { var surface = dats.Get(surfaceId); @@ -208,15 +222,13 @@ 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; + // Apply the dat's Translucency value as opacity ONLY + // when the Translucent flag (0x10) is set on the + // Surface. Without this gate, surfaces with + // Translucency=0 (non-Translucent default) would + // render fully transparent. + if (((uint)surface.Type & (uint)DatReaderWriter.Enums.SurfaceType.Translucent) != 0) + surfTranslucency = surface.Translucency; } } diff --git a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs index f85d5aa..31542a6 100644 --- a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs @@ -55,20 +55,25 @@ public sealed record GfxObjSubMesh( public bool NeedsUvRepeat { get; init; } = false; /// - /// Surface.Translucency float (0..1 — distinct from the + /// Surface.Translucency float (0..1) treated as an OPACITY + /// multiplier on fragment alpha. 1.0 = fully opaque (default for + /// non-Translucent surfaces). 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. + /// flag bits. Retail's D3DPolyRender::SetSurface at + /// 0x59c7a6 (decomp lines 425255-425260) reads + /// Surface.Translucency when the Translucent (0x10) bit + /// is set, computes curr_alpha = _ftol2(translucency × 255), + /// and writes that as vertex alpha — i.e. the dat's Translucency float + /// is used DIRECTLY as opacity, NOT inverted. ACViewer + /// (TextureCache.cs:142) and WorldBuilder + /// (ObjectMeshManager.cs:1115) both use 1 - translucency + /// and are wrong by the same misread. + /// For the rain Surface 0x080000C5 (translucency=0.5): opacity = 0.5; + /// with the (SrcAlpha, One) additive blend the rain streaks + /// contribute at half intensity. For cloud surface 0x08000023 + /// (translucency=0.25): opacity = 0.25 (matches retail's dim clouds). + /// Defaults to 1.0 (fully opaque) so non-Translucent surfaces render + /// at full opacity without change. /// - public float SurfTranslucency { get; init; } = 0f; + public float SurfTranslucency { get; init; } = 1f; }