feat(sky): physics-based sky gradient - #157
Open
daneden wants to merge 8 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Replaces
SkyGradient's six hand-picked time-of-day colour keyframes with a physically-motivated sky model rendered as a SwiftUIMeshGradient. 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 inSkyGradient.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×5MeshGradient: 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.sunXuses the same time-of-day fraction asDaylightChart's x-axis, so the glow lines up with the on-chart sun.nightpalette below −6°…−18° sun altitude, keeping white.plusLighterchart content legible.SkyGradientkeeps its type name,View + ShapeStyleconformance,init(ntSolar:), andstopsaccessor (Instagram story export readsstops.first/.last), so every existing surface upgrades with no call-site changes. Sun altitude fromNTSolar.altitude(at:).SkyModel.standardfor 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.SkyGradient.swiftfor 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:
DetailView.swift:90(containerBackground(SkyGradient(…), for: .navigation)) — confirms theShapeStyle-via-Viewbridge still resolves now thatbodyreturnsMeshGradientinstead ofLinearGradient.containerBackground, and that noon isn't blown out / night stays readable.🤖 Generated with Claude Code