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