Implement fog: source-derived depth formula, not a guess

BTDPL.INI's fog values for this mission (testarn.egg = arena1/day/clear,
[ardayclear] leaf: fog=200.0 1250.0 0.32 0.3 0.5, clip=0.25 1300.0) are exact,
verified ground truth. The depth term took two tries: `tz` (texz) looked like
the right candidate (BT411 says fog is linear on "W") but measured live it
DECREASES with distance -- the wrong direction/shape. Re-derived from the
actual firmware source instead of guessing again: AS860/XFPROJ.SS's own
comment on the projection code ("pz=wz*invz, where pz=1 at near clip, 0 at
infinity") establishes the z-buffer value as a normalized hither/distance
quantity, giving distance = hither * 2^20 / raw_zbuf_value.

gpu_raster.py: added a 10th shader output channel carrying the raw z-buffer
float (previously only a filled/empty flag existed), and the fog blend using
this formula + BTDPL.INI's exact color. Verified offline: distance climbs
smoothly (394->1051) across a receding ceiling, landing inside the authored
200-1250 range with no further fudging; visually the horizon shows the
authored blue-purple haze, correctly absent near the camera.

IG-SHADING-MODEL.md: documented the false start (tz) and the source-grounded
fix, with the honest caveats that remain (2^20 exact scale, hither as a fixed
per-mission constant vs. per-view from the wire).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-20 11:13:55 -05:00
co-authored by Claude Opus 4.8
parent 1f2f8ac42a
commit 66cef329d1
2 changed files with 74 additions and 19 deletions
+34 -8
View File
@@ -204,16 +204,42 @@ Verified live from the actual wire + firmware source (`sda4/DPL3/VRENDER/PXPL5SU
rule) against its r24/g24/b24 value to see if unlit polys show the
material ramp's actual endpoint colors.
### 6b. Fog — NOT YET IMPLEMENTED in this renderer
### 6b. Fog — IMPLEMENTED (2026-07-20), depth formula source-derived
BT411 (`rendering.md` §Fog) has the complete, verified model: per-map/time/
weather leaf in `content/BTDPL.INI` (`fog=<near> <far> <r> <g> <b>`), per-pixel
LINEAR fog on perspective W (not the nonlinear z-buffer). This renderer already
computes the per-pixel `tz` (perspective W-ish denominator) for every polygon
(the M5 divide), so the mechanism to blend by depth already exists — what's
missing is (a) reading the CURRENT mission's resolved BTDPL.INI fog leaf
(map+time+weather → near/far/rgb) and (b) the blend itself
(`out = mix(surface_color, fog_rgb, clamp((depth-near)/(far-near), 0, 1))`
applied after texture/flat-color resolution). Not yet attempted.
LINEAR fog on distance. For THIS mission (testarn.egg = arena1/day/clear) the
leaf is `[ardayclear]`: `clip=0.25 1300.0`, `fog=200.0 1250.0 0.32 0.3 0.5`
verified against the live game tree, exact match.
**The depth term was initially attempted with `tz` (texz) directly, which is
WRONG** — measured live (a vertical scan up a receding ceiling, row 0 to row
160): `tz` DECREASES with distance (324 -> 121), i.e. it behaves as an
inverse-depth quantity, not the "linear on W" BT411 describes. Re-derived from
actual firmware SOURCE instead of guessing again: `sda4/DPL3/VRENDER/AS860/
XFPROJ.SS`'s own comment on the projection code says `invz=eye_d/rz` and
`pz=wz*invz (where pz=1 at near clip, 0 at infinity)` — i.e. the z-buffer value
(`dvpx_zbuf`, 20-bit) is a NORMALIZED `hither/distance` quantity. So:
```
distance = hither * 2^20 / raw_zbuf_value
```
Implemented in `gpu_raster.py` (`FOG_HITHER/FOG_ZSCALE/FOG_NEAR/FOG_FAR/FOG_RGB`
+ the shader's new 10th output channel carrying the raw z-buffer float). Sanity
check (frame 11, netdeath capture, a receding ceiling): distance climbs
394 -> 1051 smoothly from the near edge to the horizon vanishing point,
landing squarely inside the authored 200-1250 fog range with no re-guessing
needed after the tz correction. Visually: the horizon now shows the authored
blue-purple haze (0.32,0.3,0.5), fading correctly toward the near floor.
**Not yet independently cross-validated:** the exact 2^20 scale (vs. 2^20-1 or
a slightly different fixed-point convention) and treating `hither` as a fixed
per-mission constant (0.25) rather than reading it per-view from the wire's
view-flush payload (the OTHER renderer already parses `hither +44, yon +48`
see [[dpl3-revive-integration]] memory) are reasonable, source-grounded
choices, not exact proof. Good enough to trust qualitatively; treat exact
near/far crossover distances as approximate until cross-checked against a
reference screenshot or the view-flush hither/yon.
### 6c. HUD — not rendering at all, cause NOT YET INVESTIGATED
No wire-level investigation done yet. BT411's `gauges-hud.md` /
+40 -11
View File
@@ -19,6 +19,23 @@ W, H = 832, 512
PROG_LO, PROG_HI = 0x08158000, 0x08170000
EDGE = {0x42, 0x0d, 0x2c}
# --- Fog (IG-SHADING-MODEL.md sec 6b) ---
# BTDPL.INI arena_day_default -> ardayclear leaf (THIS mission: testarn.egg =
# arena1/day/clear): fog=200.0 1250.0 0.32 0.3 0.5, clip=0.25 1300.0. Exact
# match, verified against the live game tree.
#
# Depth term derived from source, not guessed: AS860/XFPROJ.SS's own comment
# on the projection code -- "pz=wz*invz (where pz=1 at near clip, 0 at
# infinity)" -- i.e. the z-buffer value is a normalized hither/distance
# quantity, quantized to the 20-bit dvpx_zbuf field. So distance =
# hither * 2^20 / raw_z. The 2^20 scale and treating hither as a fixed
# per-mission constant (vs. reading it per-view from the wire) are NOT yet
# independently cross-validated -- see IG-SHADING-MODEL.md sec 6b.
FOG_HITHER = 0.25
FOG_ZSCALE = float(1 << 20)
FOG_NEAR, FOG_FAR = 200.0, 1250.0
FOG_RGB = np.array([0.32, 0.3, 0.5]) * 255.0
FX_SHADER = """
#version 430
#extension GL_ARB_gpu_shader_fp64 : enable
@@ -61,15 +78,20 @@ void main() {
litR = float(prims[b+40]); litG = float(prims[b+41]); litB = float(prims[b+42]);
}
int pix = y*width + x;
outbuf[pix*9+0] = (zbest > -1e29lf) ? 0x3f800000u : 0xff800000u;
outbuf[pix*9+1] = floatBitsToUint(float(uu));
outbuf[pix*9+2] = floatBitsToUint(float(vv));
outbuf[pix*9+3] = floatBitsToUint(float(tz));
outbuf[pix*9+4] = floatBitsToUint(tid);
outbuf[pix*9+5] = floatBitsToUint(hasuv);
outbuf[pix*9+6] = floatBitsToUint(litR);
outbuf[pix*9+7] = floatBitsToUint(litG);
outbuf[pix*9+8] = floatBitsToUint(litB);
outbuf[pix*10+0] = (zbest > -1e29lf) ? 0x3f800000u : 0xff800000u;
outbuf[pix*10+1] = floatBitsToUint(float(uu));
outbuf[pix*10+2] = floatBitsToUint(float(vv));
outbuf[pix*10+3] = floatBitsToUint(float(tz));
outbuf[pix*10+4] = floatBitsToUint(tid);
outbuf[pix*10+5] = floatBitsToUint(hasuv);
outbuf[pix*10+6] = floatBitsToUint(litR);
outbuf[pix*10+7] = floatBitsToUint(litG);
outbuf[pix*10+8] = floatBitsToUint(litB);
// raw z-buffer plane value (dvpx_zbuf): per AS860/XFPROJ.SS's own comment,
// "pz=wz*invz (where pz=1 at near clip, 0 at infinity)" -- a normalized
// hither/distance quantity, quantized to the 20-bit zbuf field. Used for
// fog depth (see Renderer.frame): distance = hither*2^20 / raw_z.
outbuf[pix*10+9] = floatBitsToUint(float(zbest > -1e29lf ? zbest : 0.0lf));
}
"""
@@ -161,13 +183,13 @@ class Renderer:
return None
n_prims = len(prims) // 11
b0 = self.ctx.buffer(prims.tobytes())
out = self.ctx.buffer(reserve=W * H * 9 * 4)
out = self.ctx.buffer(reserve=W * H * 10 * 4)
b0.bind_to_storage_buffer(0)
out.bind_to_storage_buffer(1)
self.prog_gl['n_prims'] = n_prims
self.prog_gl['width'] = W
self.prog_gl.run(group_x=(W + 63) // 64, group_y=H)
raw = np.frombuffer(out.read(), dtype=np.uint32).reshape(H, W, 9)
raw = np.frombuffer(out.read(), dtype=np.uint32).reshape(H, W, 10)
b0.release(); out.release()
zb = raw[..., 0].view(np.float32)
uu = raw[..., 1].view(np.float32).astype(np.float64)
@@ -175,6 +197,7 @@ class Renderer:
tz = raw[..., 3].view(np.float32).astype(np.float64)
tid = raw[..., 4].view(np.float32).astype(np.int64)
hasuv = raw[..., 5].view(np.float32)
rawz = raw[..., 9].view(np.float32).astype(np.float64)
# lit color (dvpx_r24/g24/b24 -> TREEclmpintoMEM): the polygon's
# computed lighting, clamped to [0,255] -- see build_prims. Used ONLY
# for flat (untextured) polys. NOT applied to textured polys: per
@@ -209,4 +232,10 @@ class Renderer:
ui = wrap_index((ucoord[sel] * tu) >> 8, tu, wrap_u)
vi = wrap_index((vcoord[sel] * tv) >> 8, tv, wrap_v)
composite_masked(img, sel, arr[vi, ui], cut_mode)
# per-pixel linear fog on world distance (see FOG_* constants above)
with np.errstate(divide='ignore', invalid='ignore'):
dist = np.where(rawz > 0, FOG_HITHER * FOG_ZSCALE / rawz, np.inf)
fog_t = np.clip((dist - FOG_NEAR) / (FOG_FAR - FOG_NEAR), 0.0, 1.0)
img[filled] = (img[filled].astype(np.float64) * (1 - fog_t[filled, None])
+ FOG_RGB * fog_t[filled, None]).astype(np.uint8)
return img