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

7.5 KiB
Raw Blame History

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

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:124L = max(dot(vWorldNormal, -sunDir), 0.08); vLightingRGB = sunCol * L + uCellAmbient.xyz
  • mesh.frag:54-67lit = uCellAmbient.xyz + Lcol * max(0, dot(N, -forward))
  • sky.vert:87-91lit = 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):

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:

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