# 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.
