acdream/docs/research/2026-04-24-lambert-brightness-split.md
Erik 1d54880213 sky(phase-8): retail-faithful night sky + README refresh
Iteration on the sky rendering pipeline to restore stars/moon visibility
at night and fix washed-out grey daytime clouds. Key fixes:

* sky.frag: disable fog-mix on sky meshes. Retail's keyframe FogEnd
  (0..400m at midnight, up to 2400m during day) is calibrated for
  terrain; sky meshes are authored at radii 1050-14271m which sits
  past FogEnd universally, causing every sky pixel to saturate to
  fogColor (dark navy). Stars, moon, dome texture all got
  obliterated. The horizon-glow trade-off is noted in the shader
  comment; research item to find retail's sky-specific fog range
  later.

* SkyRenderer + sky.frag: promote rep.Luminosity into uEmissive so the
  vertex lighting saturates properly for bright keyframes. Retail's
  FUN_0059da60 non-luminous path writes rep.Luminosity into
  material.Emissive via the cache +0x3c slot; we were instead using
  it as a post-fragment multiply which could only dim, never brighten.
  Net effect: daytime clouds now render saturated white, dome dims
  correctly at night (rep.Luminosity=0.11 → Emissive=0.11), stars
  and moon unchanged.

* terrain.vert: MIN_FACTOR 0.08 -> 0.0 per retail FUN_00532440 decompile
  (DAT_00796344 ambient-floor = 0.0). Back-lit terrain now falls to
  pure ambient rather than getting an 8% sun floor.

New research / tooling (no runtime impact):

* docs/research/2026-04-24-lambert-brightness-split.md — retail's
  ambient-brightness formula pinned from PE .rdata read + live
  RetailTimeProbe capture: effAmbBright = AmbBright + |sunDir| * 0.2
  where scale constant 0x0079a1e8 = 0.2f exactly.

* docs/research/2026-04-23-lightning-real.md — research note on the
  dat-baked PhysicsScript-driven lightning path (Rainy DayGroup has
  explicit PES-triggered flash SkyObjects with 5ms time windows).

* Corrections stapled to sky-decompile-hunt-{B,C}.md: DAT_00842778 is
  DirColor, DAT_0084277c is AmbColor (the hunt docs had the swap
  backwards).

* tools/RetailTimeProbe/Program.cs: extended with pid=NNNN selector,
  sky global probe (DirColor/AmbColor/AmbBright/sunDir/cache.amb),
  and the 0x0079a1e8 scale-factor readout.

* tools/SkyObjectInspect/: throwaway dat-inspector built by the Opus
  deep-dive agent. Identified GfxObj 0x010015EF as the stars layer
  (A8R8G8B8 128x128 texture, 4% bright-pixel ratio).

* src/AcDream.App/Rendering/TextureCache.cs: per-texture alpha
  histogram dump under ACDREAM_DUMP_SKY=1 for diagnosing "are the
  clouds decoded with proper alpha" type questions.

README: rewrite to reflect current state (playable pre-alpha rendering
Dereth with animated characters, day-night cycle, weather, etc.)
instead of the stale "Phase 0 dat inventory only" description.

All 742 tests green.
2026-04-24 20:34:36 +02:00

166 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Retail Lambert — brightness split pseudocode
**Date:** 2026-04-24
**Owner:** lighting (terrain / mesh / sky)
**Decompile refs:** `chunk_00450000.c:2073` (`FUN_004530e0`), `chunk_00500000.c:6030` (`FUN_00505f30`), `chunk_00530000.c:1997` (`FUN_00532440` AdjustPlanes)
## Purpose
Retail's per-vertex lighting equation does **not** match what acdream is
currently shipping. Side-by-side screenshots show acdream as warmer /
less-blue than retail under the same DayGroup, and the 2026-04-24 user
investigation narrowed it to the **ambient component being static instead
of dynamic**. This doc captures the retail formula verbatim from the
decompile and maps it to concrete code changes.
## Retail globals (decompiled, names corrected)
CLAUDE.md currently labels these backwards. Walking the math in
`FUN_00532440`:
| Symbol | Real meaning | Source |
|---|---|---|
| `DAT_00842778` | **Directional color** (ARGB uint32) — multiplied by N·L per-vertex | `FUN_00505f30` param_5 |
| `DAT_0084277c` | **Ambient color** (ARGB uint32) — multiplied by `ambBright`, no N·L | `FUN_00505f30` param_3 |
| `DAT_00842780` | **Ambient brightness scalar** (float) | `FUN_00505f30` param_2 |
| `DAT_00842950/54/58` | **Sun direction** (vec3). Magnitude encodes sun intensity (not unit length). | `FUN_00505f30` param_4 |
| `DAT_00796344` | **Ambient floor** (float) — lower bound on N·L clamp. Retail ~0.08. | hardcoded constant |
| `DAT_007938c0` | **Ceiling** (float) = 1.0 — per-channel clamp | hardcoded |
| `DAT_00799208` | 1/255.0 — for unpacking ARGB bytes | hardcoded |
| `_DAT_008682b0/b4/b8` | Per-frame cache: `(ambBright + |sunDir|·scale) × ambColor.rgb` | Written by `FUN_004530e0`, read by `FUN_00532440` |
## Retail per-vertex formula (from FUN_00532440)
```
// Once per frame (FUN_00505f30 line 6067, FUN_004530e0):
effectiveAmbBright = ambBright + |sunDir| * scale // scale = _DAT_0079a1e8
ambPremul = effectiveAmbBright * ambColor // cached at _DAT_008682b0
// Per vertex (FUN_00532440 line 2118, iterated for all vertices):
NdotL = dot(sunDir, N) // sunDir NOT normalized
NdotL = max(NdotL, floor) // floor = DAT_00796344 (~0.08)
out.r = dirColor.r * NdotL + ambPremul.r
out.g = dirColor.g * NdotL + ambPremul.g
out.b = dirColor.b * NdotL + ambPremul.b
out = min(out, 1.0) // per-channel ceiling
```
Structure:
1. **Ambient term** = `(ambBright + |sunDir|·scale) × ambColor.rgb` — flat
per vertex, but changes per-frame as sun rises/falls.
2. **Directional term** = `dirColor × max(N·sunDir, floor)` where sunDir
keeps its length so N·L can exceed 1.0 when sun is strong overhead.
3. Final per-channel clamp to 1.0.
## acdream today (for contrast)
- `terrain.vert:124``L = max(dot(vWorldNormal, -sunDir), 0.08); vLightingRGB = sunCol * L + uCellAmbient.xyz`
- `mesh.frag:54-67``lit = uCellAmbient.xyz + Lcol * max(0, dot(N, -forward))`
- `sky.vert:87-91``lit = vec3(uEmissive) + uAmbientColor + uSunColor * max(dot(N, uSunDir), 0)`
Common bugs:
1. `uCellAmbient` / `uAmbientColor` are **pre-multiplied at load time** by
the keyframe's `AmbBright`. No dynamic per-frame scaling. Retail
re-computes `(ambBright + |sun|·scale) × ambColor` every frame.
2. `sunDir` is **always normalized** in
`SkyStateProvider.SunDirectionFromKeyframe` — loses the magnitude that
encodes sun intensity. In retail, `sunDir` with magnitude > 1 pushes
N·L above 1.0 pre-clamp; with magnitude < 1 it dims the directional
term globally (dusk).
3. `MIN_FACTOR = 0.08` is hard-coded in terrain.vert. Should be a
uniform sourced from retail's `DAT_00796344`.
## Port plan (minimum necessary)
### CPU side (SkyKeyframe struct)
Add three fields, **do not remove the pre-multiplied ones** (tests consume
them; preserve source compatibility):
```csharp
public readonly record struct SkyKeyframe(
// ... existing fields ...
Vector3 SunColor, // = DirColor * DirBright (kept for compat)
Vector3 AmbientColor, // = AmbColor * AmbBright (kept for compat)
// ── NEW for retail-accurate lighting ───────────────────────────
Vector3 DirColorRaw = default, // ColorToVec3(DirColor) — no bright mult
Vector3 AmbColorRaw = default, // ColorToVec3(AmbColor) — no bright mult
float DirBright = 1f, // DAT_00842780 is ambient scalar; rename accordingly
float AmbBright = 1f); // dat's AmbBright
// Sun-dir magnitude: keep heading/pitch unit-length. Retail's
// scale factor is small (_DAT_0079a1e8 looks like ~0.020.05 from
// context but I haven't decoded its exact value yet). Defer to
// later sprint unless it moves the needle.
```
### Shader side
Both `terrain.vert` and `mesh.frag` / `mesh_instanced.frag`:
```glsl
// Replace pre-baked uCellAmbient read with dynamic effective:
float ambBright = uCellAmbient.w /* or a new uniform */;
vec3 ambPremul = uCellAmbient.xyz * ambBright;
float L = max(dot(N, -uLights[0].dirAndRange.xyz), uAmbientFloor);
vec3 lit = uLights[0].colorAndIntensity.xyz * L + ambPremul;
```
But `uCellAmbient.w` is currently used for `active light count`, not
brightness. Two options:
- **Option A:** repurpose `uCellAmbient.w` as ambient brightness, move
active count to a new uniform / UBO field. Clean but invasive.
- **Option B:** Leave UBO layout alone; write the already-scaled ambient
into `uCellAmbient.xyz` at UBO-build time (same as today). Defer the
magnitude-encoding sunDir for a later sprint. This is the **minimum
change that matches user intent** the ambient will now respond to
sun magnitude.
We're going with **Option B** multiply `AmbientColor * (ambBright + |sunDir|·scale)`
at UBO build time, not at load time. Tests currently assume
`AmbientColor` is already pre-multiplied so we keep that semantic but
recompute per-frame instead of per-keyframe.
### CLAUDE.md fix
Line in the "Reference hierarchy by domain" section or wherever lighting
globals are documented:
- Swap "ambient from DAT_00842778, diffuse from DAT_0084277c"
"**directional from DAT_00842778, ambient from DAT_0084277c**".
## Rollout order
1. Expose `AmbBright` scalar on `SkyKeyframe` + `AtmosphereSnapshot`
(load it, don't pre-multiply). Keep `AmbientColor` as the unscaled
vec3.
2. `SceneLightingUbo.Build` multiplies `AmbientColor * AmbBright` at
build time (per frame).
3. Run tests. `SkyDescLoaderTests`, `SkyStateProviderTests`,
`WeatherSystemTests` must all still pass.
4. Launch. Visual check: retail should now look indistinguishable for
overcast / rainy DayGroups. Sunny may be unchanged because
`AmbBright` is typically ~1.0 at noon.
5. If (4) still shows mismatch, investigate sunDir magnitude (Phase 2).
## Tests to add
- `SkyDescLoaderTests.ConvertTimeOfDay_ExposesAmbBrightScalar` assert
that after load, `kf.AmbBright` matches the dat value and
`kf.AmbientColor` is NOT pre-multiplied (or that a new `AmbColorRaw`
field exists alongside).
- `SceneLightingUboTests.AmbientScalesWithAmbBright` build two UBOs
with `AmbBright = 0.5` vs `AmbBright = 1.0`; assert `ubo.CellAmbient.xyz`
is half.
## Risks
- **Dim outdoor shading** if `AmbBright` is often < 0.5 in retail dats.
Mitigation: visual verify against retail screenshot. If too dim,
retail might apply a gamma/brightness offset elsewhere we haven't
spotted.
- **Breaks existing lighting tests** that pin `AmbientColor` magnitude.
Mitigation: update tests to check `AmbColorRaw * AmbBright` == old
value.