fix(sky): translucency-as-opacity + sky fog floor + additive fog-skip

Three retail-faithful sky/weather composite fixes (one cohesive commit
because they touch the same per-Surface flag plumbing path).

1. Surface.Translucency is OPACITY, not (1 - opacity).
   Retail D3DPolyRender::SetSurface at 0x59c7a6 (decomp 425255-425260)
   computes `curr_alpha = _ftol2(translucency × 255)` and writes that
   directly as vertex.color.alpha. ACViewer (TextureCache.cs:142) and
   WorldBuilder (ObjectMeshManager.cs:1115) both use `1 - translucency`
   and are wrong by the same misread. Cloud surface 0x08000023 has
   Translucency=0.25; under the old (1-x) formula opacity was 0.75,
   making clouds 3× too bright vs retail. Flipped to use translucency
   directly. Gated on the Translucent flag (0x10) so non-Translucent
   surfaces (which carry Translucency=0 in the dat) keep opacity 1.0
   instead of going invisible.

2. Sky fog re-enabled with a "fog floor" mitigation.
   Disabled 2026-04-24 because Dereth sky meshes are authored at radii
   1050-1820m while storm-keyframe FogEnd is ~400m, which would saturate
   the entire dome to flat fogColor and destroy stars/moon/dome texture.
   Retail visibly DOES fog its sky, mechanism still un-pinned. Workaround:
   clamp `vFogFactor` to a minimum of SKY_FOG_FLOOR=0.2 so the dome shows
   AT LEAST 20% raw texture even at extreme distances. Tuned via dual-
   client visual comparison; preserves stars/moon while letting the
   horizon haze visibly in low-FogEnd keyframes.

3. Additive sky surfaces skip fog entirely.
   Retail D3DPolyRender::SetSurface at 0x59c882 calls
   SetFFFogAlphaDisabled(1) when the Additive flag (0x10000) is set —
   sun, moon, stars, additive cloud sheets render unfogged. Without this
   gate the sun dimmed to fog color at horizon dusk/dawn instead of
   staying bright. Plumbed via new `uApplyFog` shader uniform driven by
   the existing SubMeshGpu.IsAdditive boolean (already set from
   TranslucencyKind.Additive at upload time).

User visually verified all three vs retail screenshots in Holtburg.
Tests: 1223 pass.
This commit is contained in:
Erik 2026-04-27 19:49:51 +02:00
parent 63b50c5291
commit 97fc1b51d8
4 changed files with 121 additions and 55 deletions

View file

@ -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 10501820m
// 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 10501820m. 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);
}

View file

@ -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);

View file

@ -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<Surface>(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;
}
}

View file

@ -55,20 +55,25 @@ public sealed record GfxObjSubMesh(
public bool NeedsUvRepeat { get; init; } = false;
/// <summary>
/// <c>Surface.Translucency</c> float (0..1 — distinct from the
/// <c>Surface.Translucency</c> float (0..1) treated as an OPACITY
/// multiplier on fragment alpha. 1.0 = fully opaque (default for
/// non-Translucent surfaces). 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.
/// flag bits. Retail's <c>D3DPolyRender::SetSurface</c> at
/// <c>0x59c7a6</c> (decomp lines 425255-425260) reads
/// <c>Surface.Translucency</c> when the <c>Translucent</c> (0x10) bit
/// is set, computes <c>curr_alpha = _ftol2(translucency × 255)</c>,
/// and writes that as vertex alpha — i.e. the dat's Translucency float
/// is used DIRECTLY as opacity, NOT inverted. ACViewer
/// (<c>TextureCache.cs:142</c>) and WorldBuilder
/// (<c>ObjectMeshManager.cs:1115</c>) both use <c>1 - translucency</c>
/// and are wrong by the same misread.
/// For the rain Surface 0x080000C5 (translucency=0.5): opacity = 0.5;
/// with the <c>(SrcAlpha, One)</c> 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.
/// </summary>
public float SurfTranslucency { get; init; } = 0f;
public float SurfTranslucency { get; init; } = 1f;
}