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.
7.5 KiB
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:
- Ambient term =
(ambBright + |sunDir|·scale) × ambColor.rgb— flat per vertex, but changes per-frame as sun rises/falls. - Directional term =
dirColor × max(N·sunDir, floor)where sunDir keeps its length so N·L can exceed 1.0 when sun is strong overhead. - 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.xyzmesh.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:
uCellAmbient/uAmbientColorare pre-multiplied at load time by the keyframe'sAmbBright. No dynamic per-frame scaling. Retail re-computes(ambBright + |sun|·scale) × ambColorevery frame.sunDiris always normalized inSkyStateProvider.SunDirectionFromKeyframe— loses the magnitude that encodes sun intensity. In retail,sunDirwith magnitude > 1 pushes N·L above 1.0 pre-clamp; with magnitude < 1 it dims the directional term globally (dusk).MIN_FACTOR = 0.08is hard-coded in terrain.vert. Should be a uniform sourced from retail'sDAT_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.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:
// 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.was 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.xyzat 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
- Expose
AmbBrightscalar onSkyKeyframe+AtmosphereSnapshot(load it, don't pre-multiply). KeepAmbientColoras the unscaled vec3. SceneLightingUbo.BuildmultipliesAmbientColor * AmbBrightat build time (per frame).- Run tests.
SkyDescLoaderTests,SkyStateProviderTests,WeatherSystemTestsmust all still pass. - Launch. Visual check: retail should now look indistinguishable for
overcast / rainy DayGroups. Sunny may be unchanged because
AmbBrightis typically ~1.0 at noon. - If (4) still shows mismatch, investigate sunDir magnitude (Phase 2).
Tests to add
SkyDescLoaderTests.ConvertTimeOfDay_ExposesAmbBrightScalar— assert that after load,kf.AmbBrightmatches the dat value andkf.AmbientColoris NOT pre-multiplied (or that a newAmbColorRawfield exists alongside).SceneLightingUboTests.AmbientScalesWithAmbBright— build two UBOs withAmbBright = 0.5vsAmbBright = 1.0; assertubo.CellAmbient.xyzis half.
Risks
- Dim outdoor shading if
AmbBrightis 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
AmbientColormagnitude. Mitigation: update tests to checkAmbColorRaw * AmbBright== old value.