# Single-Color Sky Model — Implementation Checklist Goal: reuse the physically-based scattering model (Rayleigh + Mie + ozone) but collapse the output down to **one RGB value** representing the dominant sky color, instead of a full gradient texture. **Phasing note:** this checklist is split into a **Baseline** (single scattering only — get a working, correct-looking sky first) and a **Later** phase (multiple scattering — added once the baseline is solid). Multiple scattering is the most complex and error-prone piece of this whole model, so it's deliberately pushed to the end rather than blocking your first working version. --- ## 0. Decision to lock in first: what does "dominant color" mean? This determines your sampling strategy in Phase 4, so pick one before writing code. - [ ] **Option A — Fixed elevation sample.** Always sample one direction, e.g. 45° above the horizon, facing the sun's azimuth (or away from it). Simplest, cheapest, deterministic. Misses the horizon's saturated sunset colors. - [ ] **Option B — Luminance-weighted average over the sky dome.** Sample a handful of directions (e.g. 8–16, spread from zenith to horizon) and average their colors weighted by brightness and/or solid angle. Gives a genuinely representative "overall" color, costs more samples. - [ ] **Option C — Zenith only.** Cheapest possible, but ignores the sun entirely at low elevation — bad choice if sunset color is the point. - [ ] **Option D — Max-saturation sample.** Scan a few elevations and pick the most chromatically vivid one — tends to grab the horizon band at sunset automatically without hardcoding an angle. **Recommendation:** start with Option A for a working baseline, then upgrade to Option B (5–8 samples, luminance-weighted) once the pipeline works — it's a small code change and noticeably more robust across sun angles. --- ## BASELINE — single scattering only ### 1. Port the physical model (reuse, don't reinvent) - [x] Port constants: Rayleigh/Mie/ozone coefficients, scale heights, ground/atmosphere radii, `groundAlbedo` — same as `gradient.ts` / the `common` shader tab. - [ ] Port `getScatteringValues` (density + extinction at a given altitude). - [x] Port the phase functions (`rayleighPhase`, `miePhase`). - [x] Port `rayIntersectSphere` / `intersectSphere`. - [ ] Decide: **do you need a full LUT texture, or a direct function call?** Since you're only evaluating a handful of directions per frame (not a whole screen), you likely don't need GPU textures at all — a plain function that computes transmittance on demand (like the original `computeTransmittance` in `gradient.ts`) is simpler and fine performance-wise for this use case. Skip the texture-baking machinery from the Shadertoy version unless you're calling this hundreds of times per second. ### 2. Transmittance (Buffer A equivalent) - [ ] Implement `getSunTransmittance(pos, sunDir)`: march toward the sun, accumulate extinction, return `exp(-opticalDepth)` per channel. - [ ] Add the ground-occlusion check (if the ground blocks the path to the sun, transmittance = 0) — matters for anything near/below horizon. - [ ] Unit-test in isolation: at zenith with sun straight up, transmittance should be close to 1 (thin path); at grazing angles it should drop and redden noticeably. ### 3. Sample the sky and integrate single scattering - [ ] Implement the raymarch (single-scattering integral) reusing `computeTransmittance` and the phase functions — same structure as `raymarchScattering` in Buffer C, **but without the `psiMS` term** — that's the multiple-scattering addition, deferred to the Later phase below. For now the in-scattering term is just `phase * sunTransmittance`. - [ ] Run this for however many directions Phase 0's option requires (1 direction for Option A, several for Option B/D). - [ ] If using multiple directions: combine into one color (weighted mean). Weight by radiance/luminance, not a flat average, or dim sky patches will wash out the sunset color you actually want to capture. ### 4. Tonemap down to a single displayable color - [ ] Apply exposure scaling (this model's raw output is in physical radiance units, wildly out of displayable range). - [ ] Apply a tonemap curve — reuse ACES (from `gradient.ts`) or Reinhard variant (from the Shadertoy `Image` tab). Either is fine; ACES is more standard if you want consistency with other rendering work. - [ ] Apply gamma correction (`pow(color, 1/2.2)`) last. - [ ] Clamp to `[0,1]` and convert to your output format (hex, RGB byte triple, etc). ### 5. Validate the baseline - [ ] Sanity check across the day: noon → pale blue; sunset/sunrise → orange/red shifting toward purple-gray as sun altitude approaches 0 and below; night → near-black/deep blue. - [ ] Compare a few outputs against real photos or a reference renderer at matching sun angles — even rough visual agreement catches sign errors and unit mistakes fast. - [ ] Check edge cases: sun exactly at horizon, sun directly overhead, sun below horizon (civil/nautical/astronomical twilight ranges). - [ ] If using Option B/D (multi-sample average), verify the result doesn't look muddier than any single sampled direction — a common failure mode of naive averaging is producing a duller color than the actual sky ever shows. - [ ] Expect colors to look slightly too dark/saturated compared to real photos, especially near the horizon at sunset — that gap is exactly what the Later phase below is for. Don't chase it with exposure hacks before adding real multiple scattering. --- ## LATER — add multiple scattering Only start this once the baseline above is working and validated. ### 6. Multiple scattering estimate (Buffer B equivalent) - [ ] Implement the multi-direction integration (`getMulScattValues`): for a sample position, integrate single-scattered light arriving from many directions over the sphere. - [ ] Apply the geometric-series shortcut: `psi = lum / (1 - f_ms)`. - [ ] Decide sample density: the Shadertoy version bakes this into a 32×32 LUT ahead of time because it's evaluated per-pixel, per-frame. For a single-color output you likely only need this computed at a handful of altitudes near ground level — consider precomputing it **once per session** (or once per few minutes, since it barely changes with sun angle at a fixed altitude) rather than per call. - [ ] Cache the result keyed by `(height, sunZenithAngle)` rounded to a coarse bucket, to avoid recomputing every frame. ### 7. Fold it into the raymarch - [ ] Go back to Section 3's raymarch and add the `psiMS` term back in: `phase * sunTransmittance + psiMS`, per channel, for both Rayleigh and Mie contributions. This is the only change needed in the baseline raymarch code. - [ ] Re-run Section 5's validation checks and confirm the horizon/sunset colors look less oversaturated and a bit brighter than the baseline — that's the expected effect of this addition. --- ## Aside: pollution-aware Mie scattering via Open-Meteo Open-Meteo's Air Quality API can supply real PM2.5/PM10 to drive the Mie coefficient instead of a fixed haze constant, per the MEE approach discussed earlier. This is independent of the multiple-scattering work above and can be done at any point. - [ ] Endpoint: `https://air-quality-api.open-meteo.com/v1/air-quality` with query params `latitude`, `longitude`, and `hourly=pm10,pm2_5` (add `relative_humidity_2m` too if you want the humidity-growth correction — check Open-Meteo's weather API for that field, since air-quality and weather are separate endpoints there). - [ ] No API key required for the free tier; check current rate limits on Open-Meteo's docs before relying on it for anything high-frequency. - [ ] Convert PM2.5 (µg/m³) to an extinction coefficient using a mass extinction efficiency: `extinction ≈ MEE × PM2.5`. Use ~4.4 m²/g as a reasonable default (urban average per Cheng et al.), and expose it as a tunable parameter — real MEE varies roughly 3–8 m²/g by region/year. - [ ] Add a separate, weaker term for the coarse fraction: `PM10 − PM2.5`, with a lower efficiency (coarse particles scatter less per unit mass and are far less wavelength-dependent than the fine mode). - [ ] **Units check**: your scattering coefficients in the model are "per megameter" (per 10⁶ m). MEE from the literature is in m²/g and PM concentration is in µg/m³ (= g per megameter³ if you juggle unit prefixes carefully) — work through the unit conversion explicitly once, write it as a comment, and sanity check the output magnitude against `mieScatteringBase` (~4) before trusting it. - [ ] If you add the humidity correction, note it's often the *dominant* effect on hazy-day extinction — don't skip it if accuracy matters more than simplicity. - [ ] Fallback: default to the model's static `mieScatteringBase` when the API call fails or returns no data for the requested location — don't let a failed fetch produce a completely clear or completely opaque sky. - [ ] Cache the API response per location for some reasonable interval (air quality doesn't change minute to minute) rather than calling it per-frame.