Skip to content

feat(sky): physics-based sky gradient - #157

Open
daneden wants to merge 8 commits into
mainfrom
claude/physics-sky-gradient
Open

feat(sky): physics-based sky gradient#157
daneden wants to merge 8 commits into
mainfrom
claude/physics-sky-gradient

Conversation

@daneden

@daneden daneden commented Jul 24, 2026

Copy link
Copy Markdown
Owner

What

Replaces SkyGradient's six hand-picked time-of-day colour keyframes with a physically-motivated sky model rendered as a SwiftUI MeshGradient. The colours now fall out of the sun's actual altitude and position instead of a clock-driven lerp between fixed palettes — a warm glow anchored to the sun (like the Apple Watch solar faces), realistic twilight, and a legible deep-blue night.

Continues (and completes) an earlier Xcode-agent session that scoped the direction but died before writing code.

How

  • SkyModel (co-located in SkyGradient.swift) — a CPU single-scattering atmosphere: Rayleigh + Mie (Henyey-Greenstein forward lobe) + ozone absorption, Nishita-style ray march through a spherical-shell atmosphere, keyed on sun altitude. radiance(...) → linear RGB → Reinhard + sRGB tone-map → Color. No Metal, so one implementation covers the main app, widgets, and watchOS at every deployment floor (iOS 18.5 / macOS 15.6 / watchOS 11.6 / visionOS 2).
  • mesh(sunAltitudeDeg:sunX:) builds a 7×5 MeshGradient: rows biased toward the horizon (where colour varies most), columns' azimuth measured relative to the sun so the Mie glow anchors at the sun's horizontal position. sunX uses the same time-of-day fraction as DaylightChart's x-axis, so the glow lines up with the on-chart sun.
  • Night floor — physical twilight blends to the old night palette below −6°…−18° sun altitude, keeping white .plusLighter chart content legible.
  • SkyGradient keeps its type name, View + ShapeStyle conformance, init(ntSolar:), and stops accessor (Instagram story export reads stops.first/.last), so every existing surface upgrades with no call-site changes. Sun altitude from NTSolar.altitude(at:).
  • All physics constants live in SkyModel.standard for on-device tuning (exposure, mie/mieG, nightFloor).

Tests & tuning

  • SolsticeTests/SkyModelTests.swift (Swift Testing): daylight brighter than twilight, zenith brightness rises with sun altitude, horizon reddens as the sun descends, physical sky → ~0 well below the horizon, night floor stays legible/blue, mesh well-formed, radiance finite/non-negative across a full angle sweep.
  • Added altitude-sweep and interactive slider previews to SkyGradient.swift for tuning in Xcode Previews.

Verification note

Built and authored in a remote session with no local Xcode — not yet compiled locally. Please rely on the Xcode Cloud build to confirm it compiles across all targets. Two things to eyeball on device:

  1. The one ShapeStyle-typed call site, watchOS DetailView.swift:90 (containerBackground(SkyGradient(…), for: .navigation)) — confirms the ShapeStyle-via-View bridge still resolves now that body returns MeshGradient instead of LinearGradient.
  2. That the mesh actually renders in the widget/watch containerBackground, and that noon isn't blown out / night stays readable.

🤖 Generated with Claude Code

daneden and others added 8 commits July 24, 2026 22:00
Replace SkyGradient's six hand-picked time-of-day colour keyframes with a
physically-motivated sky model rendered as a SwiftUI MeshGradient (no Metal —
works on every deployment floor: iOS/macOS/watchOS/visionOS).

SkyModel runs a CPU single-scattering atmosphere (Rayleigh + Mie with a
Henyey-Greenstein forward lobe + ozone absorption, Nishita-style ray march)
keyed on the sun's altitude. The Mie glow anchors to the sun's horizontal
time-of-day position — like the Apple Watch solar faces — while the physical
twilight blends down to a hand-tuned deep-blue floor below the horizon so white
plusLighter chart content stays legible.

SkyGradient keeps its type name, View + ShapeStyle conformance, init(ntSolar:),
and stops accessor (used for the Instagram story export), so all existing
surfaces upgrade at once with no call-site changes. Sun altitude comes from
NTSolar.altitude(at:); the glow's x matches DaylightChart's time-of-day axis.
All physics constants live in SkyModel.standard for on-device tuning.

Adds SkyModelTests exercising radiance/mesh behaviour, and altitude-sweep and
slider previews for tuning in Xcode Previews.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the hand-tuned deep-blue night floor and its twilight blend — the
transition into it read as jarring. The single-scattering model already
darkens to black below the horizon, so night now falls off naturally.

Switch tone-mapping from per-channel Reinhard (which desaturates highlights
toward grey) to luminance-based Reinhard, which compresses brightness while
preserving chroma. The noon sky now stays a saturated blue instead of washing
out to grey.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Center the sky's warm glow on a caller-supplied normalized sun anchor so the
brightest point coincides with where each chart draws the sun, instead of always
sitting at the horizon.

- SkyModel: the ray-march path lengths only depend on view elevation and sun
  altitude, so radiance now takes the scattering cosine directly instead of a
  relative azimuth. mesh() takes a sunAnchor (UnitPoint) and derives each
  vertex's scattering angle from its distance to it, so the forward-Mie glow
  peaks at the anchor. Denser 9x7 grid for a smoother 2-D glow.
- SkyGradient: new optional sunAnchor; when absent it synthesizes one from time
  of day (x) and sun altitude (y), so widgets, list rows, and the watch
  full-screen background follow the sun with no extra wiring.
- Classic DaylightChart: reports the sun marker's position via a new
  SkySunAnchorPreferenceKey in a shared named coordinate space; DailyOverview
  normalizes it against the gradient background and passes it through — precise
  vertical alignment despite the summary-title offset.
- CircularSolarChart: polar anchor from angle(for:) so the glow orbits the dial.

watchOS uses the linear chart over a scrolling full-screen background, so the
synthesized time/altitude default anchor is the right fit there rather than
pixel-tracking a moving chart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tuning pass from on-device feedback:

- Bluer daylight: drop Mie scattering (21e-6 -> 13e-6) so the sky reads bluer
  and the horizon carries less neutral aerosol haze.
- Cleaner horizon: sample the mesh between ~6 and 45 degrees of elevation
  instead of 0-60, dropping the muddy long-path band at the very horizon and
  giving a more even, consistent vertical ramp.
- Deep-navy night: add a faint ambient skylight floor before tone-mapping so
  night settles into a dark navy instead of pure black, and the day->night
  transition ramps smoothly rather than popping to black.
- Dramatic horizon: the daylight chart now deepens the sky toward black below
  the horizon line (multiply ramp) so twilight is barely visible below the
  horizon and the noon horizon band reads as ground/night.
- Readable list rows: darken the graphical location row gradient slightly so the
  plusLighter row text stays legible over bright skies.

Also fix the glow-direction test: Rayleigh scattering is symmetric, so
back-scatter isn't dimmer than side-scatter — only assert the forward lobe is
the brightest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The horizon was faked by the chart: a multiply-to-black overlay clipped
to the plot geometry, which erased below-horizon marks and stopped short
of the background's edges. The horizon is now a first-class input to the
sky itself: DaylightChart publishes its sun marker and horizon line via
SkyChartGeometryPreferenceKey, and SkyModel.mesh renders rows below the
horizon as ground — sky-at-horizon radiance reflected darkly, floored by
the shared ambient so chart content stays legible. The chart keeps only
a subtle veil in simple appearance, where there is no sky background.

On watchOS the container background stops competing with the chart and
reads as ambient sky (time-of-day glow anchor), which scrolling can't
break.

The circular chart's background is now an AngularGradient sampling the
sky model around the 24-hour dial, so day, each twilight band, and night
appear as arcs in their true positions, with a radial glow at the sun
whose colour fades out naturally at night. Flat phase-slice darkening is
skipped when the angular sky renders the phases itself.

Preview-only tuning strings are marked verbatim to keep them out of the
string catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…al, no ambient sun spot

- The first ground row now sits 0.004 (was 0.02) below the horizon
  fraction, so the sky→ground edge is crisp and lands exactly on the
  chart's horizon line instead of a few points beneath it.
- Ground reflection moved to display-linear space: scene-linear
  reflectance was mostly undone by the luminance-compressing tone-map,
  leaving the daytime ground too bright. The ambient floor still keeps
  it no darker than the night sky.
- The dial keeps its time-of-day ring only for the static night and
  twilight arcs; the sunrise→sunset wedge is now a dynamic fill of the
  current sky, glow anchored to the sun dot (replacing the separate
  radial glow overlay).
- Ambient surfaces (watch container background, list rows, widgets) no
  longer synthesize a glow anchor: with no sunAnchor the mesh uses the
  angular distance to a sun at a fixed 60° azimuthal offset, so colour
  varies with elevation only and no bright spot forms. body is now
  `some View` (also fixes a previews-only thunk compile error with the
  concrete MeshGradient type).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Tuned constants for a bluer sky: stronger blue / weaker red Rayleigh
  coefficients, less aerosol (mie 8e-6), tighter forward lobe (g 0.8).
- New multipleScattering term: single scattering alone leaves long
  near-horizon paths yellow-green under a high sun, since blue is
  extinguished with no second bounce to replace it. An isotropic,
  sky-tinted fill proportional to path opacity restores the pale blue
  horizon, fading with sun altitude so twilight keeps its colours.
- Ground vertices widen their scattering angle by 30°, so the ground
  band reflects only a hint of the sun's aureole instead of mirroring
  it below the horizon.
- The dial ring samples ~60° from the sun (was ~30°): its twilight and
  night arcs — the only parts visible under the dynamic day wedge —
  keep transmittance-driven colour without showing the aureole.
- Fix: the below-horizon sun marker filled itself with a shrunk copy of
  the whole background, which now rendered as a miniature dial inside
  the marker. It now evaluates the sky model at the current moment,
  which matches the static ring at exactly the sun's angle. Added a
  "Half a day away" preview to exercise the night-side dial.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tch march

SwiftUI re-evaluates the sky-consuming bodies every frame during
scrubbing, time travel, and list scrolling while the inputs barely
move. Three memoizations keep those frames cheap:

- SkyModel.cachedMesh quantises inputs below what the soft mesh can
  show (0.1° sun altitude, 1/128 glow anchor, 1/256 horizon) and
  memoizes the MeshGradient. The mesh renders from the quantised
  values, so hits and misses agree exactly (covered by a test).
- cachedDialStops memoizes the circular chart's ring by day and place;
  it re-evaluated every frame during time travel despite depending on
  neither the time of day nor the anchor.
- DaylightChart's 49 altitude samples were computed twice per body
  evaluation (y-scale + line marks, ~100 Schlyter evaluations per scrub
  frame); they're now memoized by day and place and shared by both.

watchOS additionally drops to viewSamples 8 / lightSamples 4 — the
same coarse march the dial ring already proved indistinguishable after
tone-mapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant