Amana’s Sky Shader Worked Perfectly, Except It Kept Getting the Weather Wrong

Wait 5 sec.

Amana is a solo-built iOS app that draws the sky outside your window — from your time, your location and the position of the sun — and then tries to get you to put the phone down and go look at the real one. It is on the App Store.I already wrote about why I abandoned satellite imagery for an on-device sky: from orbit, a clear sky is a dark ocean. The blue you see is scattering, and it only exists from below. This piece is the part after that decision — what actually broke once the sky was being generated on the device.The short version: the physics was never the problem. All three bugs below are cases where the shader was internally correct and still produced a sky that contradicted the weather outside. Two of them were composition-order mistakes. One was a color-space mistake. None of them showed up in a unit test.The setup, brieflyThe sky is a single Metal fragment shader invoked from SwiftUI through colorEffect, which runs a [[stitchable]] shader function per pixel. It takes a handful of uniforms — solar altitude normalised to −1…1, cloud cover 0…1 from WeatherKit, precipitation, a night factor, moon phase, and a time value for drift and twinkle.The base color is a Rayleigh-flavoured approximation: interpolate a zenith color and a horizon color by day-ness, then blend between them by height with a sub-linear exponent so the blue band stays thick.float day = smoothstep(-0.18, 0.30, sunElev);float3 zenith = mix(zenithNight, zenithDay, day);float3 horiz = mix(horizNight, horizDay, day);float3 col = mix(horiz, zenith, pow(h, 0.55)); // h: 0 = horizon, 1 = zenithThat is not a real atmosphere model — it is a stand-in on the way to something like Hillaire's 2020 technique or Apple's own MDLSkyCubeTexture. It looks convincingly like a sky. That turned out to be exactly the trap.Bug 1: the overcast that never ranA user-visible report, on 8 August: it is cloudy outside, but the screen shows a hazy blue sky.The shader did have code to sink the sky toward grey. It lived inside the rain branch:if (rain > 0.001) { float3 overcast = float3(0.42, 0.47, 0.53); col = mix(col, mix(float3(lum), overcast, 0.65) * 0.82, wetAmt); // ...rain streaks}Cloud cover of 0.9 with no precipitation never enters that branch. The only thing high coverage did on its own was thicken the fbm cloud layer and add a little haze — so the sky stayed blue and merely went white-ish. An overcast day rendered as a bright hazy one.The fix is to drive the same desaturation from coverage directly. The interesting part is the gating:float overAmt = smoothstep(0.62, 0.95, coverage) * mix(0.30, 0.62, day);overAmt *= 1.0 - sunsetAmt * 0.45; // don't flatten the burn bandBelow 0.62 the value is exactly zero, so clear and lightly-clouded skies are mathematically untouched. That mattered, because the clear-sky render is the thing the whole app exists to show, and I did not want a weather fix to cost me a single pixel of it. The second line keeps the horizon burn from being greyed out on a cloudy evening: the mid-sky stays leaden while the horizon still burns, which is what an overcast sunset actually looks like.Bug 2: the grey sunrise, and the fix that made it worseThe next day, 9 August: sunrise rendered leaden. The colors were there in the base sky and then something ate them.The cause was in how cloud color was derived. Clouds were shaded from luminance alone:float L = dot(col, float3(0.299, 0.587, 0.114));float3 cloudCol = mix(float3(L * 1.05), float3(0.98, 0.98, 1.0), clamp(L * 1.3, 0.0, 1.0));Read that carefully: the two endpoints are a grey and a white. Whatever hue col was carrying — the entire sunrise — is discarded at this line. And cloud opacity is weighted toward the horizon, which is precisely where the burn is strongest. The densest, greyest clouds were painted over the most colorful band of the sky.The obvious fix is to mix some of col back into the cloud color. That made it worse, and this is the part worth measuring rather than eyeballing: mixing col directly drags its luminance along with its hue. At a solar altitude of −0.02, the horizon band went from 236 to 122 — the sunrise clouds stopped glowing and sank into the sky.What you want is the hue without the brightness. Normalise the sky color to the cloud's own luminance first:float Lc = dot(cloudCol, float3(0.299, 0.587, 0.114));float3 skyHue = col * (Lc / max(L, 0.001)); // hue of the sky, brightness of the cloudfloat3 litBase = mix(cloudCol, skyHue, sunsetAmt * 0.80);float3 shadeBase = mix(cloudCol, skyHue, sunsetAmt * 0.40);The lit side gets more of the sky's hue than the shadow side, because the part of a sunrise cloud that reads as pink is the side facing the sun.One honest caveat, which I left in the source as a comment rather than quietly enjoying the win: that luminance-preserving property only holds inside the gamut. In the burn band col is already super-saturated — red channel between 1.16 and 1.62 before clipping — so the brightest pixels still clip, and clipping pushes pink toward yellow. Measured, the clip rate in that band went from 61 to 110 out of 305 sampled pixels. Luminance stays monotonic, which is why I shipped it, but the fix trades one artefact for a smaller one. It does not eliminate it.The guard against regression is the same shape as before: sunsetAmt is about 0.000002 at noon and 0.002 at midnight, so outside a sunrise or sunset this entire block is a no-op to well under 0.2%.Bug 3: shader uniforms do not interpolateThis one is not about color at all, and it is the one I would most want to know in advance.On launch, before location resolves, the app draws an approximate sky — sunrise pinned at 06:00. One to five seconds later the real solar altitude arrives and replaces it. In the twilight band, the same pixel jumped by up to 141/255 in a single frame. Not a fade. A cut.The reason is simple once you have been bitten: values passed to colorEffect as .float(...) are uniforms handed to the GPU, and SwiftUI does not interpolate them. Change the Swift value and the next frame is drawn with the new number, full stop. The TimelineView around it re-runs at 30fps, so it looks animated, which is exactly why the discontinuity is easy to miss in a simulator and obvious on a device at dusk.The fix is to make the values animatable at the SwiftUI layer, by conforming to Animatable:var animatableData: AnimatablePair { get { AnimatablePair(sunAltitude, AnimatablePair(cloudCoverage, precipitation)) } set { sunAltitude = newValue.first cloudCoverage = newValue.second.first precipitation = newValue.second.second }}Two things about this are worth spelling out.First, the Swift 6.2 detail. View is @MainActor-isolated, but Animatable's requirement is nonisolated, so a plain conformance fails to compile on the grounds of a possible data race. The correct answer is an isolated conformance:struct SkyView: View, @MainActor Animatable {SwiftUI drives interpolation on the main thread, so stating the isolation is the accurate description of what happens. Reaching for @preconcurrency to downgrade the error would have hidden a true fact rather than expressing one.Second, this conformance does not add animation. If the transaction carries no animation, the new value still lands immediately — identical to the old behaviour. It only means that when the caller opts in, the sky can be interpolated. Live cloud cover arriving from WeatherKit had the same discontinuity — the default 0.25 snapping to the real value made clouds pop into existence in one frame — and the same fix let them flow in instead.The accessibility degradation is one wordFreezing motion for reduced-motion users is usually where a generative visual falls apart, because everything is tied to a clock. Here it is one parameter:TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: reduceMotion)) { tl inOnly three things read the time value: cloud drift, star twinkle, and rain streaks. Color and composition are derived from solar altitude and moon phase, neither of which depends on t. So pausing the timeline yields a still frame that is a correct sky for the current moment, not a broken animation caught mid-cycle. That was not planning. It was a property I noticed the shader already had, and then deliberately did not break.What the three have in commonNone of these was a physics error. In every case the model was doing what I asked.Bug 1 was a scope mistake: the right operation nested under the wrong condition.Bug 2 was a color-space mistake: discarding chroma, then restoring it in a way that also moved luminance.Bug 3 was an interpolation mistake: assuming a value that changes smoothly in Swift changes smoothly on the GPU.And all three were found the same way — I looked out the window, then at the phone, and they disagreed. The pixel measurements came afterward, to check that the fix did what I thought and, more usefully, to check that it left everything else alone. Two of the three fixes above are wrapped in a gate whose only purpose is to prove the clear-sky render is untouched.A sky shader has an unusual property for a piece of software: the ground truth is free, continuous, and visible from any window. It took me three bugs to start treating that as a test suite.