# Sky Fog — How Retail Applies Fog to Sky Meshes (Decompile Trace) **Date:** 2026-04-23 **Scope:** Q1-Q5 of the sky-fog hunt. Pins retail's fog mode, fog-distance source, and whether sky meshes actually render through fog — with file:line citations from `docs/research/decompiled/`. ## TL;DR — the retail fog equation for ALL meshes (sky included) Retail uses **linear vertex fog** (`D3DRS_FOGVERTEXMODE = 3`) with **RANGEFOGENABLE = TRUE**, meaning the fog factor is computed per-vertex using **true 3D eye-space distance** `|eyePos - vertexPos|`, interpolated to fragments, and blended in fixed-function D3D: ``` // Computed per VERTEX by the fixed-function pipeline: dist = length(eyePos - worldPos) // RANGEFOG=1 f = saturate((FOGEND - dist) / (FOGEND - FOGSTART)) // linear // Stored as vertex fog coord. Interpolated to fragment: fragment.rgb = lerp(FOGCOLOR.rgb, fragment.rgb, f) // f=1 ⇒ no fog ``` **Sky meshes go through this exact path**: no D3D state is toggled around the sky render (confirmed hunt B). The sky render loop `FUN_00508010` at `chunk_00500000.c:7535-7603` enqueues sky GfxObjs via the normal mesh path with **identity transform (translation = 0, rotation = identity)**, then `FUN_005079e0` applies a rotation-only two-axis transform. **Sky vertices are rendered at their raw mesh-space positions in world-space (centered at the world origin).** ## Q1 — Eye-space Z / vertex distance at which the sky is rendered **Answer: the sky mesh's own intrinsic radius (scale = 1.0, no transform offset), taken at world origin (0,0,0) in world space.** ### Evidence — transform setup at sky render `chunk_00500000.c:7571-7586` (sky render loop, per sky object): ```c local_48 = 0x3f800000; // quaternion w = 1.0f local_44 = 0; // quaternion x = 0 local_40 = 0; // quaternion y = 0 local_3c = 0; // quaternion z = 0 local_14 = 0; // translation x = 0 local_10 = 0; // translation y = 0 local_c = 0; // translation z = 0 FUN_00535b30(); // quaternion → 3x3 rotation matrix if ((*(byte *)(param_1[6] + uVar7 * 4) & 4) != 0) { // billboard branch: copy 3-float translation from iVar5 + 0x84..0x8c local_14 = *(undefined4 *)(iVar5 + 0x84); local_10 = *(undefined4 *)(iVar5 + 0x88); local_c = *(undefined4 *)(iVar5 + 0x8c); } FUN_005079e0(&local_48, uVar3, uVar4); // apply 2-axis rotation (no translation) FUN_00514b90(&local_48); // enqueue mesh draw with this transform ``` `FUN_00535b30` at `chunk_00530000.c:4509-4531` is a pure quaternion-to-3x3 rotation builder — **no translation written**. So the transform passed to every sky mesh is `{rotation, translation=(0,0,0)}` (except for billboard-flagged objects that take a translation from the GfxObj's +0x84 slot, which historically is small; not addressed here). ### Evidence — no camera-centered sky projection Hunt B searched for view-matrix manipulation around the sky render and found **nothing**. See `docs/research/2026-04-23-sky-decompile-hunt-B.md:323-335`: > The view matrix is NOT rewritten with zero translation before the sky > draw. This is consistent with the conclusion that there is no discrete > "sky dome" — the weather/fog volume objects follow the camera by being > placed in camera-relative world position by their parent scene-graph > node. And hunt B also confirms no huge far-plane constants in the `.rdata` (lines 337-349): no `1e5`, `1e6`, `1e7` floats anywhere. The only far-plane change is the weather-volume pass: ```c // chunk_00500000.c:7272 (weather volume, NOT sky proper) FUN_0054bf30(DAT_0081fc98 * _DAT_007c6f14); ``` `_DAT_007c6f14` appears in cubic-spline math in `chunk_005E0000.c:258, 474, 742` — it's a small constant (~1-3), not a huge sky-scale multiplier. ### Implication for vertex distance Since the sky transform is `(rotation, 0)` and the camera view matrix is unchanged, the sky vertex's world-space position is `rotation × meshVertex`. The vertex's **eye-space distance** is therefore `length(meshVertex_rotated - cameraWorldPos)` — i.e. it **depends on the sky GfxObj's intrinsic mesh radius and where the camera is**. For the standard sky GfxObjs (dome `0x010015EE`, stars, sun, moon), the mesh dimensions live in the `.dat` file (not decompiled here). **WorldBuilder's sky implementation** at `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs:247` explicitly comments: > Using 1.0f scale as the far plane is now huge and AC meshes are already > at large distances. So empirical evidence from a known-working AC client port confirms the sky GfxObjs are intrinsically **thousands of meters in radius** (requiring far plane ≈ 1e6 to not clip). This is consistent with the typical retail FOGEND = 2400m saturating the sky to FOGCOLOR — **which IS what retail does** and is why the user sees a colored "sky glow" matching the fog color at ground level. ## Q2 — Fog mode (vertex vs table, linear vs exp) **Answer: Vertex-linear fog with 3D range-distance.** ### Evidence — device-init state (`FUN_005a10f0` → the master init at 0x005A4F20) `chunk_005A0000.c:3361-3389` (state reset block, written when the device is initialized or reset): ```c // D3DRS state-value pairs written on device init/reset: (**...0xe4)(dev, 0x1c, 1); // FOGENABLE = TRUE (**...0xe4)(dev, 0x1d, 0); // FOGTABLEMODE = D3DFOG_NONE (**...0xe4)(dev, 0x22, 0xaaaaaa); // FOGCOLOR = RGB(170,170,170) (**...0xe4)(dev, 0x23, 0); // ? (state 35) (**...0xe4)(dev, 0x24, 0x43c80000); // FOGSTART = 400.0f (**...0xe4)(dev, 0x25, 0x44fa0000); // FOGEND = 2000.0f (**...0xe4)(dev, 0x26, 0x3e4ccccd); // FOGDENSITY = 0.2f (unused) (**...0xe4)(dev, 0x30, 1); // RANGEFOGENABLE = TRUE ... (**...0xe4)(dev, 0x8c, 3); // FOGVERTEXMODE = D3DFOG_LINEAR (3) ``` Reading the D3DRS hex codes: | Hex | Dec | D3DRS Name | Value | Meaning | |-----|-----|-------------------|-------------|---------| | 0x1c | 28 | FOGENABLE | 1 | fog ON | | 0x1d | 29 | FOGTABLEMODE | 0 | **NO pixel fog** | | 0x22 | 34 | FOGCOLOR | 0xaaaaaa | default gray | | 0x24 | 36 | FOGSTART | 400.0f | start distance | | 0x25 | 37 | FOGEND | 2000.0f | end distance | | 0x30 | 48 | RANGEFOGENABLE | 1 | **use 3D distance** | | 0x8c | 140 | FOGVERTEXMODE | 3 (LINEAR) | **per-vertex linear fog** | **Verification that FOGSTART = 400.0f:** `0x43c80000` = 400.0. **Verification that FOGEND = 2000.0f:** `0x44fa0000` = 2000.0. The per-frame fog writer `FUN_005a4080` at `chunk_005A0000.c:2870-2907` only writes states `0x22` (FOGCOLOR), `0x24` (FOGSTART), `0x25` (FOGEND). **It NEVER writes FOGVERTEXMODE or FOGTABLEMODE** — those stay at their init values for the entire session. Hunt B (`2026-04-23-sky-decompile-hunt-B.md:302-306`) independently verified: > **D3DRS_FOGTABLEMODE=0x23, FOGVERTEXMODE=0x8c, FOGDENSITY=0x26** — > these are only set once in the default-init (`FUN_005a10f0`) and > never per-frame. Retail uses linear fog (FOGSTART/FOGEND), not > exponential (FOGDENSITY). (Note the doc calls them by D3DRS name; 0x1d is TABLEMODE, 0x8c is VERTEXMODE. The doc's hex is slightly off but the conclusion is correct.) ## Q3 — What "distance" does retail use per-sky-vertex **Answer: true 3D eye-space distance from camera to vertex** (because `D3DRS_RANGEFOGENABLE = 1`). D3D fixed-function linear vertex fog with `RANGEFOGENABLE = 1` computes: ``` fogDistance = length(EyePos - VertexPos) // 3D euclidean fogFactor = saturate((FOGEND - fogDistance) / (FOGEND - FOGSTART)) ``` `fogFactor = 1.0` means "fully visible (no fog)"; `fogFactor = 0.0` means "fully fogged (100% FOGCOLOR)". With a sky dome mesh of radius `R` rendered at world origin and a camera at world position `cam`: ``` fogDistance(skyVertex) = |cam - (rotation × skyVertex)| ≈ R (for R ≫ |cam|) ``` In Dereth, `|cam|` is the ground-level camera position (say ~100m altitude, ~10,000m absolute if near a Holtburg landblock). The sky dome vertex is at `rotation × meshVertex` — rotation is a unit-quat, so magnitude is preserved. If the dome mesh has radius ~3000m, `fogDistance ≈ 3000m` — well past `FOGEND = 2000m` in the init — so the **sky renders fully fogged** unless the keyframe-driven FOGEND is large enough (see note about MaxWorldFog below). ### Per-keyframe FOGEND override At `chunk_00500000.c:6294-6326`, every `LightTickSize` seconds the `FUN_00501860` fog-lerp writes per-keyframe `fogStart, fogEnd, fogColor` (from `SkyTimeOfDay.MinWorldFog, MaxWorldFog, WorldFogColor`). Typical retail dusk values are `Min ≈ 150`, `Max ≈ 2400`. At `Max = 2400`, a sky-dome vertex at ~3000m is fully fogged to `WorldFogColor`. **This is the mechanism by which the horizon colors in retail:** the sky dome mesh is at a distance where fog contribution dominates, so the screen-space sky color IS `WorldFogColor` (the dusk purple, the dawn peach, etc.) interpolated between keyframes. ## Q4 — Fog application order **Answer: fixed-function D3D applies fog as the LAST stage**, after material × texture modulate, per standard D3D pipeline: ``` fragment.rgb = texture.rgb * litColor.rgb // see Q6 of the material doc fragment.a = texture.a * litColor.a // Fog stage (D3D hardware, always after everything else in FFP): fragment.rgb = lerp(FOGCOLOR.rgb, fragment.rgb, fogFactor) ``` Retail does NOT alter this ordering for sky meshes — no state is flipped around the sky render (see `2026-04-23-sky-material-state.md:309-327`). The sky fragment is the fully lit+textured surface × fog blend. Since sky meshes typically have `Surface.Luminous = true` (see material-state doc §2), the lit color is `texture × Luminosity` (emissive-only); fog then blends this with `WorldFogColor`. ## Q5 — Port-ready pseudocode for acdream's GLSL sky shader ```glsl // Vertex shader — compute fog factor on the CPU or in the vertex shader: vec3 worldPos = (uModel * vec4(aPos, 1.0)).xyz; // sky mesh at world origin vec3 eyeToVert = worldPos - uCameraWorldPos; float dist = length(eyeToVert); // RANGEFOG=1 (3D, not Z) float fogFactor = clamp((uFogEnd - dist) / (uFogEnd - uFogStart), 0.0, 1.0); v_FogFactor = fogFactor; // …normal vertex transform… // Fragment shader: vec4 tex = texture(uSkyTex, vUv); vec3 lit = tex.rgb * uLuminosity; // for luminous sky meshes float alpha = tex.a * (1.0 - uTransparency); // Fog: fogFactor = 1 ⇒ no fog; fogFactor = 0 ⇒ 100% fog color vec3 withFog = mix(uFogColor, lit, v_FogFactor); out_Color = vec4(withFog, alpha); ``` ### Uniforms — all driven per-keyframe by SkyTimeOfDay - `uFogStart` = interpolated `SkyTimeOfDay.MinWorldFog` (meters) - `uFogEnd` = interpolated `SkyTimeOfDay.MaxWorldFog` (meters) - `uFogColor` = interpolated `SkyTimeOfDay.WorldFogColor` (RGB, A unused) - `uCameraWorldPos` = player's camera world-space position - `uLuminosity`, `uTransparency` = already-interpolated keyframe override ### DO NOT suppress fog on the sky The retail behavior IS "sky saturates to WorldFogColor at long distance," and that produces the correct dusk-purple / dawn-peach horizon gradient. Suppressing fog on the sky would make our sky look like a retail-client rendered WITHOUT fog — which is not what the user sees in retail. ### DO scale sky vertices intrinsically The sky GfxObj meshes have large built-in radii (thousands of meters). **Do not apply an artificial scale** — the dat-provided vertex positions are already in the "right" units for the retail fog system to work correctly against `FOGSTART ∈ [0, 400]`, `FOGEND ∈ [150, 2400]` from keyframes. If our current implementation is placing the sky at the wrong distance (too close ⇒ almost no fog; too far ⇒ always 100% fog), check: 1. Are we reading `GfxObj` vertex positions raw (no scaling)? 2. Is our `uModel` matrix setting the sky at world origin (translation = 0, rotation = sky-heading rotation around Z + sky-arc rotation around Y, from FUN_005079e0's two-axis transform)? 3. Is `uCameraWorldPos` the ACTUAL player world position (not 0)? ### Should fog use per-pixel (table) instead of per-vertex? No — retail uses vertex fog. Per-vertex fog is correct for the sky dome because the dome's triangles are large and the distance varies smoothly across them, so per-vertex interpolation gives identical results to per-pixel at the cost of massively fewer ALU cycles. (Modern GLSL can do per-pixel fog cheaply, so the visual result should be indistinguishable; use whichever is cleaner in our shader.) ## Summary of the acdream code-change recommendation 1. **Keep fog enabled for the sky pass.** The sky draw goes through the normal mesh path; fog contributes to the horizon color by design. 2. **Use linear fog**, compute `fogFactor` per-vertex with `clamp((FOGEND - dist) / (FOGEND - FOGSTART), 0, 1)`, where `dist = length(world - cameraWorld)` (3D distance, not eye-Z). 3. **Use the keyframe-lerped FOGSTART/FOGEND/FOGCOLOR** (from SkyTimeOfDay.Min/Max/WorldFogColor, interpolated on LightTickSize cadence). Already in `SkyStateProvider`. 4. **Draw sky meshes at world-origin** with a rotation-only transform. Do NOT strip the camera's view translation — the camera's world position is correct, and the sky's distance from the camera is the mesh's intrinsic radius relative to the camera's world position. This matches retail. ## Files cited - `chunk_00500000.c:6213-6333` — `FUN_005062e0` (per-frame sky+fog tick) - `chunk_00500000.c:7535-7603` — `FUN_00508010` (sky render loop) - `chunk_00500000.c:7571-7586` — sky transform setup (trans=0, quat=id) - `chunk_00530000.c:4509-4531` — `FUN_00535b30` (quat-to-3x3, no trans) - `chunk_00510000.c:4563-4591` — `FUN_00514b90` (mesh draw enqueue) - `chunk_005A0000.c:3361-3389` — device-init state block (FOGVERTEXMODE=3, FOGTABLEMODE=0, FOGSTART=400, FOGEND=2000, RANGEFOGENABLE=1) - `chunk_005A0000.c:2868-2907` — `FUN_005a4080` (per-frame fog writer: FOGCOLOR/START/END only) - `chunk_005A0000.c:2808-2819` — `FUN_005a3f90` (FOGENABLE master gate) - `references/WorldBuilder/.../SkyboxRenderManager.cs:247` — independent confirmation that AC sky GfxObj meshes are at "large distances" in dat - `docs/research/2026-04-23-sky-decompile-hunt-B.md:300-349` — hunt B confirming no per-frame FOGVERTEXMODE writes, no view-matrix strip, no huge far-plane constants - `docs/research/2026-04-23-sky-material-state.md:56-95` — hunt that fog stays enabled through sky render ## Remaining uncertainty - **Exact sky GfxObj mesh radius** is in the `.dat` file and was not decompiled. For a faithful port, load the mesh and inspect its max vertex magnitude; compare to typical FOGEND = 2400. WorldBuilder evidence suggests 3000+ meters. - `_DAT_007c6f14` — the weather-far-plane multiplier. Only used in the weather-volume pass (`FUN_00507a50`), not sky. Likely a small (< 3) constant. - Billboard flag `(*(byte*)(param_1[6] + uVar7 * 4) & 4)` at `chunk_00500000.c:7579` — when set, the sky object takes a 3-float translation from `iVar5 + 0x84..0x8c`. Not addressed here; typical sky objects (dome, stars, sun, moon) are likely NOT billboard-flagged and render at origin.