CASE STUDIESPersonal project
A weather simulation driven by API data
The background of a weather app had to show the real sky from the forecast, not an animation. A custom GLSL raymarcher, cloud physics derived from Open-Meteo data and rendering that stays smooth on a phone.
- When
- 2022–2026
- Role
- Author: concept, shaders, physics, UI
- Stack
- ReactTypeScriptThree.jsGLSLSassOpen-Meteo

4 032
place-hours used to calibrate coverage
4
quality tiers from desktop down to “potato”
5–9
draw calls per frame, no post-processing
Problem
Yet Another Weather App started in 2022 as an ordinary weather app. I wanted the background to be not an illustration but a sky computed from the same forecast the user reads: if Open-Meteo reports 80 percent low cloud and a thunderstorm, the scene should look like a closed grey deck with lightning, not a pretty library animation.
Off-the-shelf options failed in testing. A volumetric cloud library for Three.js rendered at full resolution and killed mid-range phones, drei’s sprite clouds looked flat and drowned the GPU in overdraw, and post-processing added yet another full-screen pass. A second problem sat behind the first: how to translate forecast numbers honestly (layered coverage, CAPE, boundary-layer height, winds at pressure levels) into scene parameters so the model does not contradict a “mainly clear” label.
Approach
I wrote a Nubis-style raymarcher (the technique known from Horizon Zero Dawn): the ray travels through a spherical shell above a curved Earth, in metres. Density is Perlin-Worley noise shaping the body, a separate height gradient for stratus, cumulus and cumulonimbus, a remap by the coverage field and Worley detail erosion. Lighting combines Beer’s law, the powder effect, a three-lobe Henyey-Greenstein phase function and three octaves of scattering, while a two-stream “diffuse floor” keeps overcast skies grey instead of black.
I cut the cost on phones with four passes on a fraction of the canvas. The march draws one pixel of every 2×2 block per frame, and a temporal resolve reprojects the history along the view direction and clamps it to the mean ± 1.5σ of the neighbouring fresh texels, so ghosting disappears without blur. The march itself is adaptive: cheap steps through empty air, a step back on hitting cloud, fine steps of about 2 percent of the distance. Four quality tiers pick the DPR, step count, 3D noise size and range; drei’s PerformanceMonitor drops a tier when frames fall below 75 percent of the cap.
Forecast data runs through a custom cloud-physics module: the base from the lifting condensation level (about 125 m per degree of temperature-dewpoint spread), the top from the boundary layer and CAPE, layer heights from pressure levels, and optical depth from layer thickness blended with a value inverted from the clearness index. Coverage is calibrated so that the API number means exactly that share of the sky, not “some” noise threshold.
Solution
- The shader split into modules: march, density, lighting, resolve. Constants shared between TypeScript and GLSL come from one export, so they cannot drift apart as the code evolves.
- Cloud drift in noise space from the 850 hPa wind (low clouds) and 300 hPa wind (cirrus), wrapped by tile periods, because after a day float32 quantised the noise every 520 metres.
- An ANGLE/D3D11 driver bug on Windows: texture() with implicit derivatives inside a data-dependent loop returned an empty ray and clouds near the camera vanished. The fix was textureLod at level 0.
- The model contradicted the label: for Jēkabpils the API said “mainly clear, 26 percent” while the low layer read 78 percent. I measured it on 4 032 real place-hours from 42 locations and introduced one rule: no layer exceeds the total. Closed decks under a total below 50 percent went from 12 to 0, and the correction touched 2.4 percent of hours.
- Frames issued by a FrameRateGate every Nth rAF frame instead of a time-based gate, which dropped frames at random.
Result
The scene runs without post-processing and without an API key, in 5–9 draw calls per frame. On desktop the cloud target is half the canvas at 64 lit steps and 128³ noise; on the lowest tier a third of the canvas, 32 steps and 64³ noise; on “potato” a flat deck. Rebuilding the shader into modules closed with bit-identical output across 760 000 comparisons on 20 000 random inputs and a bundle 197 kB smaller.
What I learned
- Measure, do not guess: the coverage rule changed only after statistics on thousands of real hours, not after one screenshot.
- Pure logic outside the frame loop and one source of constants for TS and GLSL are what make it possible to keep developing a shader without fear.
- Premultiplied alpha with sRGB gives white fringes, float32 uniforms grow over time, and renderer.info resets inside useFrame. Each of those cost an evening.