fix #427: sky drawn without fog like retail; world fog range is the keyframe's authored MinWorldFog/MaxWorldFog
Two April stand-ins, neither registered, met at the horizon from altitude as a hard line between the dome's rim and the fog-coloured clear: 1. sky.frag fogged every non-additive sky layer with a 0.2 floor. Retail's GameSky::Draw @0x00506FF0 disables fixed-function fog around the whole sky draw unless an AdminEnvirons fog override is active (SetFFFogEnable( LScape::m_override_enabled ? 1 : 0)); additive layers stay unfogged via SetFFFogAlphaDisabled(1) at D3DPolyRender::SetSurface 0x59c882. The sky pass now sets ApplyFog only for (override active && !additive), with no floor. 2. WorldRenderFrameBuilder overwrote the authored fog range with one derived from the streaming window (538..2189 m always). Retail sets FOGSTART/ FOGEND straight from the keyframe's MinWorldFog/MaxWorldFog (SkyDesc::GetWorldFog @0x00500CE0 -> SetFFFogProperties @0x005A2F70) with no draw-distance scaling; zfar is a constant 4000 m. The builder now leaves SceneLightingUbo.Build's values alone; ACDREAM_FOG_START_MULT / _END_MULT are deleted from RuntimeOptions. Guards: SkyFogRuleTests (source-level, the sky renderer has no hermetic harness); sky.frag.spv re-pinned in VulkanShaderManifestTests with the reason. Research note 2026-04-23-sky-fog.md carries a correction banner. App hermetic 6,070/0, Core 4,707/0 (Release). Owner look gate owed: night and rain fog are now retail's shorter authored ranges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
517d17b4b3
commit
cc42edc8e3
11 changed files with 180 additions and 62 deletions
|
|
@ -24,6 +24,42 @@ What does NOT go here:
|
|||
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
||||
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
||||
|
||||
## #427 — Hard line below the horizon from altitude: the sky dome was fogged with a 0.2 floor and the world fog range came from the streaming window, not the keyframe
|
||||
|
||||
**Status:** ✅ FIXED 2026-08-23 (owner report, Candeth Keep/Holtburg heights: "a cut off where the sky ends and the world background void begins").
|
||||
**Component:** rendering / sky + world fog (retail parity)
|
||||
|
||||
**Root causes (two April-2026 stand-ins, neither registered):**
|
||||
1. `sky.frag` fogged every non-additive sky layer and clamped the blend with
|
||||
`SKY_FOG_FLOOR = 0.2` ("mechanism unknown, workaround until pinned",
|
||||
`97fc1b51`). The dome's rim (a sphere cut 500 m below the camera, radius
|
||||
1050 m) was therefore 20 % texture / 80 % fog against a frame cleared to
|
||||
100 % fog — a visible seam wherever terrain doesn't cover the rim (altitude,
|
||||
water, map edge). Retail: `GameSky::Draw @0x00506FF0` draws the sky with
|
||||
fixed-function fog DISABLED (`SetFFFogEnable(LScape::m_override_enabled ?
|
||||
1 : 0)` around the sky draw) — fog touches the dome only under an
|
||||
AdminEnvirons override; additive layers are never fogged
|
||||
(`SetFFFogAlphaDisabled(1)` at `D3DPolyRender::SetSurface 0x59c882`).
|
||||
2. `WorldRenderFrameBuilder` overwrote the UBO's authored fog range with
|
||||
0.7 × near radius .. 0.95 × far radius (538..2189 m) at every hour and
|
||||
weather (`ACDREAM_FOG_START_MULT`/`_END_MULT`). Retail sets
|
||||
`D3DRS_FOGSTART/FOGEND` straight from the keyframe's `MinWorldFog/
|
||||
MaxWorldFog` (`SkyDesc::GetWorldFog @0x00500CE0` → `LScape::UseTime` →
|
||||
`RenderDeviceD3D::SetFFFogProperties @0x005A2F70`, no draw-distance
|
||||
scaling; `Render::zfar` is a constant 4000 m). Authored Sunny: day
|
||||
150–2400 m, dawn/dusk 90–800, night 0–400; Rainy day 150–1500.
|
||||
|
||||
**Fix:** sky pass `ApplyFog = environOverrideActive && !additive`, no floor;
|
||||
the frame builder leaves `SceneLightingUbo.Build`'s authored `FogParams.xy`
|
||||
alone; the two env multipliers are deleted from `RuntimeOptions`. Guard:
|
||||
`SkyFogRuleTests` (source-level; the sky renderer has no hermetic harness);
|
||||
`sky.frag.spv` re-pinned in `VulkanShaderManifestTests` with the reason.
|
||||
**Visible consequence (owner gate owed):** night and rain fog are now
|
||||
retail's much shorter ranges; the dome's horizon tint is the authored
|
||||
texture alone. The streaming window (≥ 2.1 km from the player) still sits
|
||||
at/beyond every authored fog end except Sunny day's 2400 m, where the far
|
||||
edge lands ~96 % fogged.
|
||||
|
||||
## #426 — Every solid-colour (untextured) polygon on every object client-wide was invisible: mesh extraction misread NO_POS_UVS as "no positive face"
|
||||
|
||||
**Status:** ✅ FIXED 2026-08-23 (found on the Holtburg windmill axle, GfxObj
|
||||
|
|
|
|||
|
|
@ -1,3 +1,13 @@
|
|||
> **CORRECTION 2026-08-23 (#427).** This note assumed retail fogs its sky
|
||||
> meshes ("the sky dome mesh is at a distance where fog contribution
|
||||
> dominates"). It does not: `GameSky::Draw @0x00506FF0` disables fixed-
|
||||
> function fog around the entire sky draw unless an AdminEnvirons fog
|
||||
> override is active. The dome's horizon colour is the authored texture plus
|
||||
> the keyframe tint. The `SKY_FOG_FLOOR = 0.2` clamp that this assumption
|
||||
> produced was removed the same day. Q3's terrain-fog conclusions (authored
|
||||
> `MinWorldFog/MaxWorldFog` applied directly, no scaling) stand and are now
|
||||
> what the client does.
|
||||
|
||||
# Sky Fog — How Retail Applies Fog to Sky Meshes (Decompile Trace)
|
||||
|
||||
**Date:** 2026-04-23
|
||||
|
|
|
|||
|
|
@ -57,19 +57,24 @@ void main() {
|
|||
|
||||
vec3 rgb = sampled.rgb * vTint;
|
||||
|
||||
// SHOULD-FIX S2/mech NOTE N-3 (OP4 review-fix round, 2026-08-11):
|
||||
// PlayerOption DisableDistanceFog forces FogMode.Off (uFogParams.w
|
||||
// == 0) — mesh_modern.frag/terrain_modern.frag both gate their own
|
||||
// fog blend on this same word (`if (mode == 0) return lit;`), but the
|
||||
// sky dome's blend here read only uApplyFog (the CPU per-submesh
|
||||
// "is this layer foggable at all" flag) and never uFogParams.w, so
|
||||
// toggling the option stopped terrain/objects fading into fog while
|
||||
// the dome's horizon band kept blending toward fog color.
|
||||
// Retail draws the sky with fixed-function fog DISABLED:
|
||||
// GameSky::Draw @0x00506FF0 saves GetFFFogEnable, calls
|
||||
// SetFFFogEnable(LScape::m_override_enabled ? 1 : 0), draws every sky
|
||||
// object, and restores it. So the dome is fogged ONLY while an
|
||||
// AdminEnvirons fog override is active; the horizon tint otherwise
|
||||
// comes from the authored dome textures and the keyframe tint, and the
|
||||
// frame's fog-coloured clear meets the dome's own colour at the rim.
|
||||
// The CPU sets uApplyFog = 1 only for (override active && surface not
|
||||
// Additive — SetFFFogAlphaDisabled(1) at D3DPolyRender::SetSurface
|
||||
// 0x59c882). uFogParams.w == 0 is PlayerOption DisableDistanceFog
|
||||
// (LScape::m_fFogEnabled -> SetFFFogUserDisabled), honoured here like
|
||||
// every other fogged pass. The 2026-04-27 "SKY_FOG_FLOOR = 0.2" clamp
|
||||
// was a stand-in for this rule before it was read from retail; it
|
||||
// left the dome's rim at 80 % fog against a 100 % fog clear, which
|
||||
// showed as a hard line below the horizon from altitude (2026-08-23).
|
||||
int fogMode = int(uFogParams.w);
|
||||
if (uApplyFog > 0.5 && fogMode != 0) {
|
||||
const float SKY_FOG_FLOOR = 0.2;
|
||||
float skyFogFactor = max(vFogFactor, SKY_FOG_FLOOR);
|
||||
rgb = mix(uFogColor.rgb, rgb, skyFogFactor);
|
||||
rgb = mix(uFogColor.rgb, rgb, vFogFactor);
|
||||
}
|
||||
|
||||
float flash = uFogParams.z;
|
||||
|
|
|
|||
|
|
@ -316,7 +316,7 @@
|
|||
},
|
||||
{
|
||||
"stage": "frag",
|
||||
"sourceSha256": "2ddf210d69b0c4a3c0870eecfb0ccba2097d93b365729739402bfe91e3b120d0",
|
||||
"sourceSha256": "b1f3b924af2040c909337c02461ed6795b5320fb04b5bb371ce1546feead567c",
|
||||
"compiled": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -372,18 +372,19 @@ public sealed partial class SkyRenderer : IDisposable
|
|||
// with texture alpha and keyframe transparency.
|
||||
_params.SurfOpacity = sub.SurfOpacity;
|
||||
|
||||
// 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/background layers)
|
||||
// still mix toward keyframe fog with the floor mitigation
|
||||
// in sky.frag. That restores the broad green/purple Rainy
|
||||
// DayGroup tint behind the cloud sheet while raw-additive
|
||||
// 0x08000023 remains unfogged and keeps the pink detail.
|
||||
_params.ApplyFog = sub.DisableFog ? 0f : 1f;
|
||||
// Retail GameSky::Draw @0x00506FF0 draws the whole sky with
|
||||
// fixed-function fog DISABLED unless an AdminEnvirons fog
|
||||
// override is active (SetFFFogEnable(LScape::
|
||||
// m_override_enabled ? 1 : 0) around the sky draw). Within
|
||||
// that, D3DPolyRender::SetSurface at 0x59c882 calls
|
||||
// SetFFFogAlphaDisabled(1) for Additive surfaces (sun,
|
||||
// moon, stars, additive cloud sheets), so those are never
|
||||
// fogged. The dome's horizon tint is the authored texture
|
||||
// plus keyframe tint — not fog — which is why the frame's
|
||||
// fog-coloured clear meets the dome cleanly at its rim.
|
||||
// (Until 2026-08-23 every non-additive layer was fogged
|
||||
// with a 0.2 floor; see sky.frag for the symptom.)
|
||||
_params.ApplyFog = environOverrideActive && !sub.DisableFog ? 1f : 0f;
|
||||
|
||||
// Sky meshes need per-object wrap mode driven by the
|
||||
// mesh's authored UV range, not by TexVelocity:
|
||||
|
|
|
|||
|
|
@ -500,17 +500,18 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
|
|||
in atmosphere,
|
||||
camera.Position,
|
||||
(float)_worldTime.DayFraction);
|
||||
float fogStart = _ranges.NearRadius
|
||||
* LandblockSize
|
||||
* _options.FogStartMultiplier;
|
||||
float fogEnd = _ranges.FarRadius
|
||||
* LandblockSize
|
||||
* _options.FogEndMultiplier;
|
||||
ubo.FogParams = new Vector4(
|
||||
fogStart,
|
||||
fogEnd,
|
||||
ubo.FogParams.Z,
|
||||
ubo.FogParams.W);
|
||||
// FogParams.xy stay the keyframe's authored MinWorldFog/MaxWorldFog
|
||||
// that SceneLightingUbo.Build wrote (AtmosphereSnapshot.FogStart/
|
||||
// FogEnd). Retail sets D3DRS_FOGSTART/FOGEND straight from those
|
||||
// (SkyDesc::GetWorldFog @0x00500CE0 -> LScape::UseTime ->
|
||||
// RenderDeviceD3D::SetFFFogProperties @0x005A2F70) with no draw-
|
||||
// distance scaling; its far plane is a constant 4000 m
|
||||
// (Render::zfar @0x0081EC88). Until 2026-08-23 this site overwrote
|
||||
// them with a range derived from the streaming window (0.7 x near
|
||||
// radius .. 0.95 x far radius = 538..2189 m at every hour and
|
||||
// weather), which thinned retail's night (0..400 m) and rain
|
||||
// (150..1500 m) fog and hid the fact that the sky pass was being
|
||||
// fogged at all (see SkyRenderer / sky.frag).
|
||||
_lightingUbo?.Upload(ubo);
|
||||
|
||||
RenderingDiagnostics.EmitLight(
|
||||
|
|
|
|||
|
|
@ -81,8 +81,6 @@ public sealed record RuntimeOptions(
|
|||
/// <summary>Diagnostic-only initial orbit elevation in degrees. Null keeps
|
||||
/// the normal camera default.</summary>
|
||||
float? InitialOrbitPitchDegrees,
|
||||
float FogStartMultiplier,
|
||||
float FogEndMultiplier,
|
||||
ResidencyBudgetOptions ResidencyBudgets,
|
||||
StreamingWorkBudgetOptions StreamingWorkBudgets,
|
||||
string? VulkanDeviceOverride,
|
||||
|
|
@ -207,8 +205,6 @@ public sealed record RuntimeOptions(
|
|||
InitialOrbitPitchDegrees:
|
||||
TryParseOrbitPitchDegrees(
|
||||
env("ACDREAM_ORBIT_PITCH_DEGREES")),
|
||||
FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f,
|
||||
FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f,
|
||||
ResidencyBudgets: ResidencyBudgetOptions.Parse(env),
|
||||
StreamingWorkBudgets: StreamingWorkBudgetOptions.Parse(env),
|
||||
// Physical-device override, matched as a decimal index first and then
|
||||
|
|
|
|||
|
|
@ -47,7 +47,12 @@ public sealed class VulkanShaderManifestTests
|
|||
["particle_mesh.vert.spv"] = "f7fe8b203cadcd4d54af5cdbcfd9d5bf733146e10bafa78ca730fb6970db0479",
|
||||
["portal_depth.frag.spv"] = "96755196d4d0da7be4792107557465778be2ebefb5584834cc75bf90ec55a6cc",
|
||||
["portal_depth.vert.spv"] = "cd113860b7acd6afad3ebcc0a68dd7147f6baae729df51ab360c123588dc3ae2",
|
||||
["sky.frag.spv"] = "ae0d9e3e1e1b5742dd986cb39c62ea6e71e19783feea8b86a5cd940504d6047e",
|
||||
// sky.frag re-pinned 2026-08-23: the dome's fog blend lost its
|
||||
// 0.2 floor and is now applied only under an AdminEnvirons fog
|
||||
// override, retail's GameSky::Draw @0x00506FF0 rule (see
|
||||
// SkyFogRuleTests). A deliberate default-path change, reviewed
|
||||
// with the world-fog-range fix in the same commit.
|
||||
["sky.frag.spv"] = "b3b544829f2dd85be04b6b16d78a0490e1fe7884590cb541b95756b7d7511620",
|
||||
["sky.vert.spv"] = "77176cf33c761ee4e9730357895c941dbf5949d8e0d28e0bb0dcde87f4d30288",
|
||||
["terrain_modern.frag.spv"] = "7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9",
|
||||
["terrain_modern.vert.spv"] = "9f4cb221ea6aed94a8d23af6cb8e3f3ed96c3cce6e50d135a72d3b55667b1557",
|
||||
|
|
|
|||
86
tests/AcDream.App.Tests/Rendering/Sky/SkyFogRuleTests.cs
Normal file
86
tests/AcDream.App.Tests/Rendering/Sky/SkyFogRuleTests.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Sky;
|
||||
|
||||
/// <summary>
|
||||
/// Retail draws the sky with fixed-function fog DISABLED (GameSky::Draw
|
||||
/// @0x00506FF0: SetFFFogEnable(LScape::m_override_enabled ? 1 : 0) around
|
||||
/// the sky draw) and sets the world fog range straight from the keyframe's
|
||||
/// authored MinWorldFog/MaxWorldFog (SkyDesc::GetWorldFog @0x00500CE0 ->
|
||||
/// RenderDeviceD3D::SetFFFogProperties @0x005A2F70, no draw-distance
|
||||
/// scaling). Two April-2026 stand-ins contradicted that — a 0.2 "fog floor"
|
||||
/// on the dome and a fog range derived from the streaming window — and
|
||||
/// together showed as a hard line below the horizon from altitude
|
||||
/// (2026-08-23). These source guards keep both retail rules in place; the
|
||||
/// renderer itself needs a GPU and the installed DATs, so there is no
|
||||
/// hermetic behavioural harness for it.
|
||||
/// </summary>
|
||||
public sealed class SkyFogRuleTests
|
||||
{
|
||||
[Fact]
|
||||
public void SkyFragmentShaderFogsTheDomeOnlyThroughUApplyFogAndWithoutAFloor()
|
||||
{
|
||||
string frag = File.ReadAllText(Path.Combine(ShaderRoot(), "sky.frag"));
|
||||
string code = StripLineComments(frag);
|
||||
|
||||
Assert.DoesNotContain("SKY_FOG_FLOOR", code, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("max(vFogFactor", code, StringComparison.Ordinal);
|
||||
Assert.Contains("if (uApplyFog > 0.5 && fogMode != 0)", code, StringComparison.Ordinal);
|
||||
Assert.Contains("rgb = mix(uFogColor.rgb, rgb, vFogFactor);", code, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkyRendererAppliesFogOnlyUnderAnAdminEnvironsOverrideAndNeverOnAdditiveLayers()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Sky", "SkyRenderer.cs"));
|
||||
string code = StripLineComments(source);
|
||||
|
||||
Assert.Contains(
|
||||
"_params.ApplyFog = environOverrideActive && !sub.DisableFog ? 1f : 0f;",
|
||||
code,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_params.ApplyFog = sub.DisableFog ? 0f : 1f;", code, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorldFrameBuilderLeavesTheAuthoredFogRangeAlone()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot(), "src", "AcDream.App", "Rendering", "WorldRenderFrameBuilder.cs"));
|
||||
string code = StripLineComments(source);
|
||||
|
||||
// SceneLightingUbo.Build writes FogParams.xy from AtmosphereSnapshot
|
||||
// (the keyframe's authored range, SceneLightingUboTests pins that);
|
||||
// nothing downstream may overwrite them from the streaming window.
|
||||
Assert.DoesNotContain("ubo.FogParams = new Vector4(", code, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("FogEndMultiplier", code, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("FogStartMultiplier", code, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string StripLineComments(string source)
|
||||
{
|
||||
var lines = source.Split('\n');
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
int idx = lines[i].IndexOf("//", StringComparison.Ordinal);
|
||||
if (idx >= 0)
|
||||
lines[i] = lines[i][..idx];
|
||||
}
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
private static string ShaderRoot() =>
|
||||
Path.Combine(RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders");
|
||||
|
||||
private static string RepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
directory = directory.Parent;
|
||||
return directory?.FullName
|
||||
?? throw new InvalidOperationException("Could not locate repository root.");
|
||||
}
|
||||
}
|
||||
|
|
@ -167,8 +167,6 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.Null(opts.UiProbeScript);
|
||||
Assert.Null(opts.AutomationArtifactDirectory);
|
||||
Assert.False(opts.ExactAutomationFramebuffer);
|
||||
Assert.Equal(0.7f, opts.FogStartMultiplier);
|
||||
Assert.Equal(0.95f, opts.FogEndMultiplier);
|
||||
Assert.False(opts.UiProbeEnabled);
|
||||
Assert.False(opts.HasLiveCredentials);
|
||||
}
|
||||
|
|
@ -339,26 +337,6 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.Equal(12, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "12" })).LegacyStreamRadius);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FogMultipliers_ParseInvariantFloats_AndFallBackIndependently()
|
||||
{
|
||||
var parsed = RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_FOG_START_MULT"] = "0.625",
|
||||
["ACDREAM_FOG_END_MULT"] = "1.125",
|
||||
}));
|
||||
Assert.Equal(0.625f, parsed.FogStartMultiplier);
|
||||
Assert.Equal(1.125f, parsed.FogEndMultiplier);
|
||||
|
||||
var invalid = RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_FOG_START_MULT"] = "not-a-number",
|
||||
["ACDREAM_FOG_END_MULT"] = "",
|
||||
}));
|
||||
Assert.Equal(0.7f, invalid.FogStartMultiplier);
|
||||
Assert.Equal(0.95f, invalid.FogEndMultiplier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DayGroupOverride_IsReadOnceIntoTypedOptions()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue