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
Yet Another Weather App: 3D scene with clouds over a map
  • 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.

© 2026 SKOFTWARE Maciej Skorus

Cookie Policy