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.
166 lines
7.5 KiB
Markdown
166 lines
7.5 KiB
Markdown
# 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.02–0.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.
|