Skip to content

Shader weather

Rain and snow drawn by shader instead of by the server's particle spawners, with splashes and puddles on open ground, rings on open water, and lightning drawn from the server's strike. Presentation only: the spawners arrive exactly as before, nothing sent to the server changes, and everything here is built from data the client already holds.

Status

Rain and puddles are checked in play. The owner looked at the falling rain, splashes and puddles in play five times up to 27 September 2026, and each look found faults that were then fixed headless: the rain is now a box of drops, the splashes small rings in the ground's own shading and the puddles water filling the relief. The owner checked the rain and puddles again in play on 11 October 2026 and found them fine; the splashes were not named in that check. The storm cells, snowstorms, settled snow, hail, blowing dust and the other games' weather were run against local servers with a headless client on the software renderer on 27 September, and given one GPU look pass in headless gamescope on 28 September, which found the defects under "Known limits"; they landed on main on 10 October 2026 after a rebase, checked since only by the headless tests. The wakes, ripple patch and body splashes have been seen headless on software rendering only.

The weather log holds the dated record: what each live check found and what was changed after it, the measurements of other games' weather, the earlier tunings of the ripple patch, and the status recorded at the landing.

Main files

File What it does
project/game/effects/particles.gd Recognises weather spawners and particles and routes them to the shader or to particles; precipitation(), rainfall() and snowfall(); the covered particle material
project/game/effects/weather.gd Intensities and easing, the storm field, the drop MultiMeshes, debug_state(), and GDScript copies of the shader maths for the tests
project/shaders/precipitation.gdshader Falling rain, snow, hail and dust
project/shaders/weather_common.gdshaderinc The ground's damp film, standing water, basins, splashes and settled snow, and the rain rings on water
project/shaders/nodes_array.gdshader, nodes_array_scissor.gdshader The terrain shaders, both of which call the shared rain and snow terms
project/shaders/waving_leaves.gdshader, grass_volume.gdshader Snow on leaves, droplets on wet grass
project/shaders/particle_covered.gdshaderinc and particle_covered_{mix,add,sub}.gdshader Particle weather kept out from under roofs
project/game/effects/rain_cover.gd; GoannaClient::rain_cover_rows in src/goanna_client_player.cpp The rain cover map
project/game/effects/lightning.gd, project/shaders/lightning.gdshader The bolt and its light
project/game/main.gd The wind (grass_wind), the storm deck, wetness, settled snow cover, the fog in a whiteout or dust, and the lightning flash (_take_lightning)
project/game/effects/wake.gd, project/shaders/wake.gdshaderinc Wakes, and which bodies the ripple patch draws
src/goanna_ripples.{h,cpp} The ripple patch (RippleField, GoannaRipples)
project/game/effects/splashes.gd, project/shaders/splash_crown.gdshader, droplet.gdshader Droplets and crowns thrown by bodies
project/shaders/water.gdshader Draws the rain rings, wakes and ripples on water
project/tests/weather.gd, wake.gd, ripples.gd; src/goanna_ripples_test.cpp The tests (see "Tests")

Where weather comes from

Luanti has no weather in the protocol. A game that rains attaches particle spawners to the player. Mineclonia's mcl_weather loops over two raindrop textures (weather_pack_rain_raindrop_1.png and _2.png) and two flake textures (weather_pack_snow_snowflake1.png and 2.png), but its add_spawner_player deletes every spawner the player has before adding one under a new id, so one is running at a time: 500 a second for rain, 900 in a thunderstorm, 100 for snow. The first live check, on 26 September 2026, saw one rain spawner of 500.

project/game/effects/particles.gd recognises these by texture name (the is_weather test: the name contains rain or snow) for precipitation(), which drives wet ground (goanna_wetness) and the storm sky in main.gd.

With shader weather on, a weather spawner that follows the player (attached, or given in player relative coordinates) is handed to project/game/effects/weather.gd and no GPUParticles3D is built for it. Everything else keeps the particle path, including a spawner with a rain texture fixed somewhere in the world, since the shader only draws round the camera. precipitation() reports the same either way, so wetness and the sky do not change with the setting.

Other games

Games do weather their own ways, so the recognition is written to the shapes seen in the games installed here, 2026-09-27:

Game Its weather Recognised by
Mineclonia, VoxeLibre (mcl_weather) one attached spawner, weather_pack_rain_raindrop_*, weather_pack_snow_snowflake* name, attached
pmb_core (aom_weather), backroomtest (br_weather) attached spawners, aom_weather_rain*.png, aom_weather_snow_*.png; heavy rain adds sheets 260 across on the horizon name, attached
Kythen a 0.55 second burst every half second while outdoors, at world coordinates round the player, textured kythen_water.png falls like rain, round the player
Minetest Game, Asuna no weather nothing to draw
Regional Weather (Climate API), on any game a new half second burst round the player on every server step, weather_raindrop.png, weather_rain*.png, weather_snowflake*.png, weather_snow.png, weather_hail*.png; lightning through the lightning mod name, round the player; hail keeps its particles
theFox's weather a half second burst round the player every step, 30 by 30 and 10 up, shifted upwind by as much as 14; weather_rain.png, weather_rain_dark.png (storm), weather_snow.png, weather_hail.png, weather_sand.png name, round the player; hail and sand keep their particles
Mymonths half second bursts, 20 by 20 and 7 up, the kind chosen by the nodes within 5 of the player (rain turns to snow among snow, to sand by sand); weather_rain*.png, weather_snow.png name, round the player; hail and sandstorm keep their particles
Snowdrift no spawner: one add_particle a drop every half second, 128 drops or 32 flakes, snowdrift_raindrop.png, snowdrift_snowflake*.png counted one by one (below)

Three rules make that general:

  • Weather by name. A spawner is weather if its texture names rain, snow or flake.
  • Weather by fall. A spawner is rain whatever it is called if it is short lived (time up to 3 seconds), vertical, removed on collision, and falls at least 6 nodes a second, more than three times faster than it drifts (particles.gd, rain_burst). Snow is too like falling leaves or ash to be told by its fall, so snow needs its name.
  • Following the player. Either rule counts only for a spawner that is attached to the player, given in coordinates relative to them, or whose box is centred within 20 nodes of them (theFox's weather shifts its box upwind by as much as 14), at least 6 nodes across, with its middle above their head (_centred_on_player). A burst counts only while it spawns; weather.gd's easing makes a run of bursts steady. Climate API starts a new half second burst on every server step, about five running at once, and that overlap is its real rate.

Intensity is drops a second over each square node of the spawner's box, scaled so Mineclonia's boxes (30 by 30 for rain, 50 by 50 for snow) draw as before. Counted by raw rate, pmb_core's heavy rain came out lighter than Mineclonia's ordinary rain and barely heavier than its own light rain; by density its light rain is 0.50 and its heavy rain 0.67 of Mineclonia's, before its third spawner. Sheets 40 across or more are a curtain on the horizon, not drops, and are left out.

Weather sent one particle at a time, as Snowdrift sends it, is counted instead of drawn: a rain or snow named particle falling within 40 nodes of the player and above their head is added to a count per kind, and the count over the last second, with the box the drops fell in, their mean velocity and size, stands in for a spawner (_weather_particle, _update_single_weather). A texture says snow by flake first, then not if it says rain or drop, then by snow: Snowdrift's rain is snowdrift_raindrop.png (weather.is_snow_name). Snowdrift's particles arrive in the batched particle packet (protocol coverage).

Each of these was run live on 27 September 2026 against a local server with a headless client on the software renderer, and the recognised weather and its intensity read from the client; the numbers are in the weather log. Two traps for anyone repeating the Regional Weather run: Climate API skips a dead player, and a client that spawned underground dies there; respawn and fly up before forcing a preset with /set_weather. theFox's weather only rains where it finds sky light in the air above, so a player flown up over ungenerated air gets none, and Mymonths' weather follows the nodes round the player, so a spot by sand or snow changes it.

For a game author who wants Goanna to draw their weather: attach the spawner to the player (attached = player), or keep spawning it, or its particles, round the player, and put rain or snow in the texture's name. Nothing else is needed, and nothing changes for any other client.

What it draws

Falling rain and snow (project/shaders/precipitation.gdshader, placed by project/game/effects/weather.gd). Each drop is one instance of a MultiMesh of unit quads, one draw call per kind of weather. The shader places every drop itself from its instance number (skip_vertex_transform), so the instance transforms are identity and nothing is uploaded per frame.

  • A drop has a seed, hashed from its instance number (PCG), and falls through a box round the eye. Its world position is the box's corner plus (seed times box size + velocity times time minus the corner) wrapped into the box. So a drop is fixed in the world, not in the view: when the eye moves the box moves round the drops, and a drop that leaves by one face comes in by the opposite one. Drops fade over their last 1.5 nodes toward every face, so the wrap is not seen.
  • Two boxes share the instances. The far box is 24 by 16 by 24 nodes, the eye 10 above its bottom, about 0.3 drops a cubic node at intensity 1: the rain round about, falling to the ground below and toward the face from above. The near box is 6 nodes a side, the eye 3.5 above its bottom, about 9 drops a cubic node: the rain falling past the face and between the eye and the ground looking down, where the far box alone leaves a node or two of air with almost nothing in it.
  • Intensity picks the drops: the MultiMesh holds enough for intensity 2, and a drop is drawn when its own hash is under intensity / 2, so the same drops are drawn from frame to frame. At intensity 1 that is 5000 rain drops, or 2600 flakes.
  • Rain: a streak along the fall, turned about its own axis to face the camera, 6 mm wide, as long as 0.035 seconds of fall (Mineclonia's 17.5 nodes a second makes 0.5 to 0.7 nodes, with a per drop variation in speed and length). A streak narrower than a pixel and a half is drawn that wide at the coverage it really has, so a far drop is a faint line and not a sparkle. Peak opacity at 1080 lines and 70 degrees: 0.85 at 3 nodes, 0.26 at 10.
  • Snow: round flakes 5 cm across facing the camera, falling at the snow spawners' speed (2.5 for Mineclonia) with a slow sway of their own.
  • Both lean with the wind. There is no wind in the protocol either; main.gd derives one for the grass from the cloud drift and the storm cover (grass_wind, strength 0.25 to 1), and the weather uses the same air, scaled so 1 is 7 nodes a second. Rain leans no more than 0.6 nodes of drift per node of fall; snow drifts at up to twice its fall speed. On a main.gd without grass_wind the same rule is worked out in weather.gd from cloud_speed and storm_cover.
  • Nothing on the lens: a drop with any part of it within 0.3 nodes of the eye is not drawn, and drops fade in from 0.3 to 1.2 nodes, since a drop that close is out of focus, and drawn hard reads as a scratch on the screen.
  • A drop is drawn only where the rain cover map says the sky reaches: asked at the top of the streak in the vertex stage (a covered drop's quad collapses and draws nothing) and again at each pixel, so a streak crossing a roof's edge stops at the edge. The cover map holds the ground too, so a drop that has fallen below the surface of its column is not drawn.
  • Lit by the scene's own lights, with a light grey albedo and a share of the terrain's sky fill as emission, so a drop is as bright as its surroundings by day and dark at night, whatever the exposure. The light function has no facing: a drop takes 0.45 of a light from any side and up to all of it with the light behind it. Fogged, receiving and casting no shadow, outside global illumination.
  • Nothing is drawn while the eye is underwater.

Rain ends about 12 nodes out: the far box fades out from 10.5 nodes to its sides at 12, and the storm fog and sky are left to carry the distance. A box has no angle it looks wrong from, which is why it is a box; the cylinders it replaced, and what they did at steep angles, are in the log.

Intensity comes from the spawners' rate (amount a second for an endless spawner, amount over time for a timed one), summed per kind and divided by Mineclonia's own rates, 500 for rain and 100 for snow. It is clamped to 0.3 to 2, so any running weather spawner is visible and a thunderstorm (1.8) is heavier than rain (1). It eases over 2.5 seconds, so a storm starting, stopping or turning to thunder (Mineclonia replaces its spawners then) does not pop.

The ground in rain (weather_common.gdshaderinc, drawn by both nodes_array.gdshader and nodes_array_scissor.gdshader, one block in each after the material decode). Four parts, none of which adds light or paints a colour of its own: whatever shows is the ground's own albedo, darkened, in the scene's own light, with a smoother surface and a bent normal to catch the sky or a lamp.

  • The damp film. Everything up facing that rain reaches, by the cover map where there is one and by strong sky light beyond it, from goanna_wetness: darker by the porosity (soil soaks, stone sheens), roughness pulled toward 0.32, the normal map's relief softened by up to 30 per cent, as a film of water fills the fine texture. On an authored relief the crests (height over 0.75) drain first and take up to half as much. Sky light alone wet floors several nodes in from a doorway, where the light still reaches.
  • Standing water in the relief (goanna_water_level, goanna_pool). The owner's idea: the soft wet look is the dips between ridges filled with water. A LabPBR _n map carries the surface's height in its alpha, which the parallax march already reads; the water stands at a level on that height, rising with the wetness to 0.45 (none below a wetness of 0.35, 0.15 at 0.6, 0.40 after a minute of rain from dry). Where the height at the texel drawn is under the level the pixel is water: the normal map's relief drowned, so the normal is the face's own, roughness 0.06, SPECULAR 0.25 (water's F0 of 0.02), and the albedo darkened by 28 to 40 per cent by porosity and up to 20 more where it is deepest. Just above the waterline, a quarter of the height, is a wet margin: darker, roughness toward 0.22, relief softened. Above that the film alone. After the march the texel drawn is the one the eye ray met, so the test is exact: a ray that met the relief under the level crossed the water's surface first. The height comes from the fetch the shader makes anyway, so it costs no read. Measured on the Mineclonia packs here, 0.45 is over the lowest tenth to quarter of the authored pack's dirt, sand, gravel and stone tops, and more of the Material Maker pack's, whose heights sit lower.
  • Basins (goanna_puddle). The level is lifted by up to 1.2 in patches a node or three across, where a smooth noise on the world grid passes a threshold that falls with the wetness, so the patches stay put and spread through a shower. The lift ramps up toward a patch's middle, so at its rim only the hollows fill and the edge follows the texture; in the middle every crest is under and the tile is a puddle. None at a wetness of 0.3, 5 per cent of open flat ground at 0.6, 23 after a minute of rain and 34 at 1.
  • Splashes (goanna_rain_splash, goanna_splash_ring). From goanna_rain, what is falling now. A cell of 0.22 nodes holds at most one drop each 0.6 seconds, on two grids offset by half a cell: about 31 drops a square node a second at intensity 1. Each throws a ring for 0.36 seconds, growing fast and then slower to between 7 and 15 centimetres across, its crest (1.3 centimetres half width) carried only in the normal and dying away as it spreads, over a wet mark that darkens and smooths the ground under it and fades over the same time. The ring is a third as strong on damp ground as in standing water. Rings are widened to the pixel and lowered in proportion, and faded out where a pixel is wider than 1.2 to 3 centimetres (about 9 to 23 nodes away at 1080 lines, nearer at a low angle) and past 10 to 22 nodes.

Water and its margin are drawn only on flat (normal y over 0.95), open, up facing ground, fading out as the far tiers flatten (from lod_flatten_near). Splashes need up facing (over 0.7) and open. "Open" is the rain cover map, or the sky light where there is no map. A layer with no authored height passes a height of 1, a crest everywhere, and so holds water only in a basin: a missing _n gets a filler layer whose alpha is 0, and one inferred from the colour has 255, neither a height, and layer_depth is measured only from an authored height, so it tells them apart.

Rings on water (project/shaders/water.gdshader). Expanding rings in the water normal on open water within 22 nodes, on top of the waves.

The water's rings come from goanna_rain_rings, the ground's splashes from goanna_rain_splash, both in project/shaders/weather_common.gdshaderinc. Each evaluates each cell alone, so a ring is kept inside its cell: its largest radius is the distance from its drop to the nearest cell edge, less the ring's width, and what is left of its envelope fades out before the edge.

Splashes and rings follow goanna_rain (what is falling now), not goanna_wetness (which lingers for minutes after rain), so they stop with the rain.

Which shader draws the ground

The rain terms (film, standing water, basins, splashes) and settled snow live in functions in weather_common.gdshaderinc that both array shaders call, because the ground can be drawn by either. The near mesh chooses the shader per tile, by its own layer (see materials, "Which shader draws a tile"): sand, grass tops and stone go to nodes_array.gdshader, which also carries the parallax march, while cut-outs and the far tiers stay on nodes_array_scissor.gdshader. The status trace (below) names the shader of the ground under the eye. When the terms were in nodes_array.gdshader alone, nobody ever saw a splash or a puddle in play; the log has how that was found.

Storm cells

The server says only that it rains: Mineclonia sends one spawner at 500 drops a second, 900 in a thunderstorm, the same everywhere and for as long as the weather lasts, so drawn as sent it pelted down at one strength. The storm is given a structure here. weather.gd keeps a field over the world (storm_severity_at, simplex fbm, cells about 600 nodes across) that drifts with the wind at 0.3 of its speed and changes slowly on its own. The viewer's place in it, 0 at a cell's edge to 1 at its heart and eased over 8 seconds, scales what falls from a quarter of the server's intensity (a drizzle) to 1.35 times it (a downpour), and main.gd scales the storm deck and the wetness the same way: at the edge a thinner, lighter deck and damp ground, at the heart full overcast and standing water. A thunderstorm widens the hearts. Sampled over a grid, about a third of the world sits at the edge and a fifth at the heart; standing still in a moderate wind a cell takes about three minutes to come over, rains hard for a few, and tails off into a drizzle.

Lightning is still the server's: Mineclonia strikes where it likes, and a strike at a cell's edge is drawn at full strength. Presentation only, and a function of the viewer's position, so each view of a split screen has its own.

Hail, blowing sand and the rest

weather.kind_of sorts a weather texture into rain, snow, hail (hail) or dust (sand, dust). Hail is rain's streak made short (8 ms of fall) and thick (6 mm) and ice white, and it feeds the splashes and the wet ground at 0.6 of its strength; dust is snow's flake without the crystal, 1.2 cm, sand coloured, half as opaque, and driven by the wind at three times its strength and up to eight times its fall, and main.gd closes the fog in to 70 nodes and browns it and the sky by weather.dust_haze(), as it greys them for a whiteout. Both take the storm field as rain does. Their units were set from live runs (see the constants in weather.gd): Regional Weather's and theFox's hail draw near 0.6 of full, Mymonths' hail at the cap; Regional Weather's sandstorm about 1.6, Mymonths' 0.6, theFox's 0.2 (its box is 40 by 40, and it blows at head height, rising a little, so a dust box counts from just below the head).

Particle weather this client does not draw by shader, and all of it with shader weather off, is drawn with particle_covered.gdshaderinc in place of its StandardMaterial3D (through particle_covered_mix, _add or _sub.gdshader, one per blend, loaded through the view's scope) when it falls from a box round the player and above their head: the same texture, colour, billboard, kept scale, animation and blend, and nothing under the cover map's roof, since Goanna's particles do not collide and such weather fell through every roof. weather.gd keeps the cover map while any is running (cover_wanted). Godot does not give a script the shader it generates for a StandardMaterial3D, so this is a small shader of its own rather than one line added to that. GOANNA_PARTICLE_UNCOVERED=1 keeps the plain material, to compare. Pollen (Regional Weather) rises from below the head, so it is neither weather nor covered, and keeps its particles.

Snowstorms

Snow goes further than rain. It has its own ceiling, 4 against rain's 2, with the flakes built for it, and at a storm cell's heart it rises to 2.6 times the server's intensity against rain's 1.35, late and hard (severity to the power 1.6), so most of a cell is flurries and its middle a blizzard. Past 1.4 the snow becomes a whiteout (weather.whiteout(), 0 to 1 by 3.2): the flakes take up to three times the wind and may be blown five times as far as they fall; main.gd pulls the fog in to end at 50 nodes on a linear ramp, turns it toward the grey of driven snow and lays it over the sky; and settled snow builds up to three times faster.

A flake is not a disc of one size. Its own hash sets its size, from 0.45 to 1.2 of the radius, with one in twenty five a clump half as big again, and its spin. Close enough to be more than a couple of pixels across it is a six armed crystal turning as it falls; driven by the wind it smears along its path, up to three and a half times its length and thinner as it lengthens, so a blizzard is streaks rather than dots. The game's own flake textures (Mineclonia's are 3 to 12 pixels) are not used: at a flake's size they would only show within a metre or two.

Settled snow

Snow falling is not rain falling: particles.gd reports rainfall() and snowfall() apart, and only rain drives goanna_wetness. Counted together, a snowfall left puddles.

While snow falls, main.gd builds goanna_snow_cover toward 1 (a time constant of 80 seconds, so most of the way in about three minutes), and after it melts it back (200 seconds, 40 under rain). goanna_snow in weather_common.gdshaderinc turns that into cover per texel on up facing surfaces the sky reaches, by the cover map where there is one and by sky light beyond it. It settles on the art's own grid, sixteen texels to a node, each texel at its own threshold, so a dusting is scattered white texels that fill in rather than a smooth fade; on an authored relief the hollows fill first. Covered, the surface is pale (0.80 linear), matt (roughness 0.72), non metal, and its relief is flattened by up to 70 per cent. Rain after it pools on it as slush. The leaves shader takes the same texel dusting on the tops of a canopy, gated by sky light alone.

The server's own snow layers (Mineclonia's ABM piling mcl_core:snow in cold places) are nodes and stay. This is the dusting everywhere else a snowfall passes, which the game has no node for. Mineclonia chooses snow or rain by the temperature where the player stands, altitude and noise included, so near a snowline it swaps one spawner for the other a step at a time and both show, eased over 2.5 seconds each.

Occlusion: the rain cover map

project/game/effects/rain_cover.gd keeps a 64 by 64 map, one texel per node column, centred on the player: the top face of the highest node in the column that would stop rain. The precipitation shader draws a drop only above it; the splash and ripple terms apply only at or above it (with a margin of 0.55 for a liquid surface, which sits a little below its node's top). So there is no rain indoors, under a roof or under a canopy, while rain still falls past the window of a house you are standing in, over the open ground outside. The depth buffer hides any drop behind a wall.

The sky light the mesh carries already answers "is this under a roof", but only at the faces of solid nodes. Rain falls through air, where there is no face to ask, so the shader needs a height to compare a drop against.

What stops rain is decided in GoannaClient::rain_cover_rows: any walkable node (roofs, glass, and leaves in every game checked), any liquid (its surface is where rain lands), and allfaces_optional leaves in a game that makes them walk through. Plants, torches and other walk through, see through nodes do not, so rain reaches the ground in a meadow. Unloaded blocks are skipped, never treated as solid.

Each column is scanned from 48 above the player to 32 below. A roof higher than that (a cave ceiling far overhead) does not shelter, and a column with nothing in the way reads as open to the scan floor.

The scan runs on the main thread, 8 rows (512 columns) a frame, into a pending buffer that replaces the live map only when it is whole, so a half old, half new map never draws a line of rain through a roof. A full map takes 8 frames. It rescans once a second while weather is falling, and at once when the player has moved 6 nodes sideways or 10 up or down from the map's centre. It keeps scanning while the ground is still wet, since the damp film dries by it, and is kept, not withdrawn, when the rain stops. Mineclonia deletes its rain spawner wherever the player cannot see the sky and sends it again a step later; a map rebuilt from nothing each time left the drops without cover for the 8 frames of the scan, so by a doorway it rained indoors for a moment. In fair, dry weather nothing is scanned.

The map reaches the shaders as three global uniforms, registered in project.godot: goanna_rain_cover (the RF texture), goanna_rain_cover_area (its world corner, size, and 1 once published) and goanna_rain. Off the map, and before the first map of a session is whole, the precipitation shader draws no drops; the water treats a point as open, and the ground splash falls back to the sky light channel; both splashes and rings fade out before the map's edge. The falling drops read the map in the vertex stage too, so the lookup names its level of detail.

The control channel's status carries weather (weather.gd's debug_state()): the eased intensities, the spawner count, the drops drawn, whether a map is up and its area, and over the eye the cover height, whether the eye is open, and open_share, the share of the map's columns open at eye height. On an open beach that share should be near 1; if it is near 0 there, the map is what is hiding the rain.

Under weather.ground is every gate between the weather and a splash or a puddle on the ground below the eye, worked out by GDScript copies of the shader's maths (ground_trace): the node and the shader that draws its top face (from GoannaClient::top_surface_at, by the tests materialFor makes), whether that shader has the terms, goanna_rain and goanna_wetness, the facing, whether the cover map has the point open, the distance and the pixel size taken for it (pixel, estimated for 1080 lines and 70 degrees), the basin noise (basin), the water level (water_level), how much of it the ground holds (pool) and the splash term (splash); and failing, the first gate that is shut, empty when all are open. Whether water stands at the exact point under the eye depends on the tile's relief, which the trace does not read, and on the basin noise, so the level is reported but is not a gate.

Lightning

Luanti has no lightning in the protocol either. Mineclonia's mcl_lightning.strike_func sends, per strike: one particle spawner (texture lightning_lightning_N.png, N 1 to 3, amount 1, time 0.2, vertical, glow LIGHT_MAX, size 1000, which is 100 nodes, centred 50.5 nodes over the struck node so that the quad's bottom edge meets its top face); a white sky colour layer through mcl_weather.skycolor with the day night ratio overridden to 1, removed about a tenth of a second later; and a thunder sound.

The bolt (project/game/effects/lightning.gd, project/shaders/lightning.gdshader). With shader weather on, particles.gd recognises the spawner by its texture name, as it does rain (the name contains lightning and the amount is 4 or less), and hands it to lightning.gd instead of building an emitter. That draws the server's own texture on a quad of the spawner's size, turned about its upright axis to face the camera as a vertical particle is, additive, unshaded and six times white so it blooms; with no texture, a jagged line with one branch is drawn instead, meeting the ground at the same point. It flickers through a first stroke and up to two weaker return strokes over the spawner's time, then dies away over a quarter second.

The light. An OmniLight3D a few nodes over the strike point, range 48, energy 16 at the stroke's peak, following the same flicker. No shadow: an omni shadow is six more passes over everything in range for a light that lives a fifth of a second, so a strike beside a house lights its inside.

The flash (main.gd, _take_lightning). The server's white sky would not reach the screen through the ordinary sky easing: _apply_sky eases every sky colour set over four seconds so a biome border does not flip the dome, and a tenth of a second of white would move it about 2 per cent of the way, then take seconds to ease back. So a sky set white through day, dawn and night is held out of the easing and becomes a flash: 1 while the server's white lasts, dying away over a few hundredths of a second after it. A white sky that lasts longer than 0.6 seconds is taken as the game's real sky. lightning.gd's own flash (each strike's flicker, weighed by distance: full within 50 nodes, down to 0.3 of it at 225 and beyond) covers a game that strikes without whitening the sky; the two together are one flash, not two. The flash whitens the dome and horizon, and so the fog, lights the cloud deck and its undersides, raises the day night ratio the ambient, background and haze follow, and adds to the sky fill, which lights whatever is open to the sky and nothing under a roof.

With shader weather off, the strike keeps the particle path and the server's flash still reaches the sky. The particle path's culling box includes the particle's own size, so the bolt stays drawn while any of it is in view.

Wakes

Rings on the water round the player and the animals moving through it. Not weather, but drawn by the same water shader beside the rain rings, so it is described here. Presentation only: the positions are the ones the client already draws, the water is looked up in the map it already holds, and nothing is asked of the server. Tested headless only; nothing of it has been observed.

Sources (project/game/effects/wake.gd, a child of the particles node). Ten times a second: the local player (its walking position from step_player, or 1.6 nodes under the eye with the free camera) and the entities in GoannaClient::entity_list, the nearest 15 of them within 32 nodes of the eye. An entity within 0.4 nodes of the local player is taken to be its own object and skipped. The entity list carries no collision box, so a body is taken to span from half a node under its position to 1.7 over it, and it touches the water when the top of a water node with no water over it lies in that span. Water is recognised by name (node_name_at, the name contains water), which covers Mineclonia's and Minetest Game's water and river water, source and flowing; a game naming its water otherwise gets no wake. Lava is left out on purpose: its own shader draws no wake.

Points. Each source that touches the water lays points on its path since the last sample, each at its own place and at the time the body passed it, into a ring buffer of 64 (x, z, birth, strength):

  • Moving (0.3 nodes a second or more across): a point every 0.25 nodes, or every 0.12 seconds of travel at speed, whichever is further, so a boat or a sprint drops about eight a second and one body cannot fill the buffer. Strength 0.35 plus 0.2 a node a second, up to 1.
  • Standing still in the water: one point of strength 0.3 every 1.2 seconds (the bob), the first after a jittered delay so a herd in a pond does not pulse in step.
  • Coming into the water from out of it: one point of strength 1.
  • Out of the water, wholly under it, or after a jump of more than 4 nodes between samples (a teleport): nothing.

Rings (goanna_wake in project/shaders/wake.gdshaderinc). The live points (under 2 seconds old) go to the water shader as a 64 by 1 float texture, one texel a point, with a count, wake.gd's own clock (not TIME, which rolls over) and the xz box the rings can reach. Each point grows a ring from 0.15 nodes across, spreading at 0.6 nodes a second: a short wave packet (wavelength 0.24 nodes, half width 0.12) whose slope is summed into the water normal on top of the waves and the rain rings. It eases in over a tenth of a second, fades linearly to nothing at 2 seconds, and weakens as it spreads. A swimmer faster than 0.6 nodes a second leaves its rings on two lines behind it, the V of a wake (half angle about 17 degrees at 2 nodes a second); a still body bobs small rings. The crest also whitens the water a little (up to 18 per cent, a lit albedo taken out of what comes up through the surface), which is what is left to read at night when the sky in the water is too dark for the bent normal to show. Drawn from below the surface too. Faded out from 19 to 32 nodes from the eye.

No setting. The wake is always on, and not tied to shader weather: rings round a swimmer are wanted in fair weather most of all. With no point live the shader's whole term is one uniform branch, and wake.gd samples ten times a second whatever happens.

The control channel's status carries wake: the bodies followed, how many of them touch water, and the points live.

Ripples

Wake rings alone read as pings from first person: each point grows one thin ring on its own, nothing comes ahead of a swimmer, and a body standing still pings once every 1.2 seconds. So round the eye they give way to a patch of water that keeps what is done to it. Presentation only, like the rings, and tested headless only; nothing of it has been observed.

The field (src/goanna_ripples.h, RippleField, wrapped for GDScript as GoannaRipples). A square 32 nodes across, eight cells a node (256 by 256), holding the surface's height and its rate of change, stepped at a fixed 60 Hz as a damped wave equation: waves spread at 1.2 nodes a second, a pull back to the flat surface keeps a pressed patch a dimple rather than a hole (and makes the waves slightly dispersive), and they halve in about 1.5 seconds. A sponge two nodes wide round the edge soaks up what reaches it. Nodes that are not open water hold still, so waves stop and reflect at the banks. The patch follows the eye in whole nodes once it is four from the middle, keeping the water already on it, and sleeps, costing nothing, once nothing presses on it and it is flat to 5e-4 nodes. A step of the whole patch is about 0.2 ms.

Bodies (RippleField::swimmer). Everything a body does to the water comes from how it moves, so a body holding still leaves the water still. Moving across, it shoves the water it displaces ahead of it, raising it along the leading edge of its footprint and lowering it along the trailing edge, more water the slower it goes (up to three times as much at two thirds of a node a second), or a wader would barely mark the water. The shove is what stands a bow crest up in front of a swimmer: a press alone cannot, once the body outruns its own waves. Sinking into the water (its submerged depth growing, up to 3 nodes a second counted) it pushes three tenths of the water it now displaces out from under itself, and rising lets it back: half into a ring hugging the edge of its footprint and half spread out to twice its radius, summed over the cells so no water is made or lost and every crest has its trough.

The footprint is a disc whose soft edge (kEdge) is centred 0.42 nodes out, just outside a player's 0.3, so the crest stands at the body's edge rather than inside it. That is what rings out from a body falling in, bobbing or climbing out; a body in the air over the water, or wholly under it, does nothing to the surface by going up or down (wake.gd's sinking). A swimmer faster than the waves leaves them in a V along its path, curving where it turns (about 15 degrees each side at 3 nodes a second). The player's velocity is the movement step's own (last_move.speed); animals shove at 0.7 of the player and move between the ten-a-second samples along their last velocity.

The native test (goanna_ripples_test) prints and floors the steepest slope 0.4 to 1.5 and 1.5 to 6 nodes from a swimmer. For a player: nothing holding still; moving, 0.35 to 1.2 near and 0.22 to 0.30 further out; bobbing 0.11 near and 0.024 out; 0.09 out two seconds after dropping in. Jumping in and out for five seconds peaks at 0.08 round the body. It also checks a swimmer's crest is ahead of it and its trough behind, centred on it.

Which bodies (project/game/effects/wake.gd, claim and ripple_frame). The patch lies on one water surface: the local player's, when it is in the water, or else that of the nearest wet body inside the patch. Every body on that surface (within 0.35 of it) and at least three nodes inside the patch is drawn by the patch and lays no rings; every other body, a duck on a pond at another height or out past the patch, keeps its rings. Which nodes are open water at that surface (water under it and none over it) is read from the map by name, as the rings' water is, whenever the patch moves or changes surface and every four seconds while it is moving, four rows of nodes a frame so the couple of thousand lookups do not land in one frame.

Drawing (project/shaders/water.gdshader). The heights reach the water shader as a 256 by 256 float texture (goanna_ripple_height), with the patch's corner, side and surface height (goanna_ripple_area) and whether it is moving (goanna_ripple_state). On the top of the water within 0.4 of that surface, from above or below, the slope bends the normal (0.8 of it) on top of the waves, rain and rings. Crests are not whitened: they are not breaking, and whitening every one over 0.05 nodes made a splash an all white ring. Still water or none: one uniform branch. Looked down on, water reflects only a fiftieth of the sky, so in the shallows a ripple shows by how it bends the bed seen through it: the refraction offset is the tilt the waves give the normal, times the depth of water over the bed (only the first 1.5 nodes of it), over the distance (refraction, 0.12; the physical 0.18, a quarter of the shift over the 1.4 of the view, over depths to 4 nodes, swung deep pools about queasily), capped at 3 per cent of the screen. In sunlight the patch also throws caustics: the light on the bed is scaled by how the surface where the sun's refracted ray came in focuses it, 1 / (1 + a quarter of depth times curvature), the curvature taken over two cells either side, clamped to 0.3 to 2.5 and blended in at 0.7 of full sun. Crests throw bright moving lines on the bed and troughs dim it. goanna_sun_glow gates it, so there are none at night, under a weather sky or in a ridge's shadow; and only the ripple patch throws them, not the everyday waves. Round a swimmer at 2 nodes a second over a bed a node down the bed ranges from 0.54 to 2.5 of its light where the wake is, and nearly none of it sits at a clamp (goanna_ripples_test prints both).

Seen from high up, refraction and caustics alone do not show the rings round a body, so the top of the water also carries:

  • Shading. A ripple's faces turned towards the sun are brightened and those turned away darkened, by 0.6 of the slope along the sun's direction (RIPPLE_SHADE), as a slope of ground is lit. With the sun straight overhead the light comes from across the view.
  • Froth. Where the patch's slope runs from 0.35 to 1 (FOAM_SLOPE), at the bow of a body pushing through and in the ring hard round one going in, the water churns: bubbles on two world grids (0.07 and 0.16 nodes), each cell holding one of its own size and place with a chance that grows with the slope, drawn as a bright rim round a clearer middle. Each lives a second or so and pops, and the cell draws again; a slow noise makes the churn uneven, so it comes in clumps. Covering up to 0.85 of the surface, within 28 nodes. This is the crest whitening above done differently: an even white over every crest read as smoke rings, and a plain white edge was what the owner asked not to have.

Neither has been looked at on a GPU; the headless captures are in the log.

The control channel's status carries it under wake.ripples: the surface, how many bodies the patch draws, whether it is moving and its highest crest.

Splashes

The droplets a body throws out of the water, beside the waves the ripple patch draws (project/game/effects/splashes.gd, driven by wake.gd). Only the bodies the patch draws splash. Presentation only; seen headless on software rendering (a fall into the water, a blow at it, crowns at fixed ages), not yet on a GPU.

  • Going in. A body whose submerged depth grows faster than 1.2 nodes a second (a fall or a jump into the water) throws a crown: droplets from a ring round where it went in (the crown's foot), up at 1.8 to 3.8 nodes a second and out at 0.8 to 2.2, 20 to 96 of them by how fast it went in (full at 3 nodes a second).
  • The crown. Going in at half a full splash or more (sinking at 1.5 nodes a second), a sheet of water stands up in a ring round the body (splashes.crown, shaders/splash_crown.gdshader): its foot spreads to 0.55 nodes round and it rises to 0.9, flaring out towards the lip like a tulip, then leans out and falls back, all in about three quarters of a second. The sheet is a clear streaky film with a thick white lip drawn up into 18 uneven fingers, rounded and beaded at their tips; partway through (CROWN_SHED) the lip between them goes, the fingers stand alone and shed a ring of drops (rim), and the sheet thins away. It stands taller, and is thrown further, on the side the body was moving across, wholly lopsided at 4 nodes a second, so diving in at a run throws a wall of water ahead. A blow at the water throws a smaller one (0.22 round, 0.45 up) leaning the way the blow went. The mesh is a plain 144 by 8 ring the shader shapes from the crown's age; at most 8 at once.
  • Coming out as fast: drips off the sides of the body, below the eye, falling back, so in first person none come in front of the lens.
  • Moving through the surface faster than 2.2 nodes a second across, with the surface somewhere up its body: spray fanned up and forward off its bow, harder the faster it goes, for as long as it keeps moving.
  • A blow at the water. A dig or punch pressed (main.gd, _strike_water) looks along the view ray, within the hand's 4 nodes and short of any node the blow hit, for the first open water surface; there it kicks the ripple patch and throws a burst and a crown. It works from the bank, with nobody in the water: the patch then lies on the struck surface. A blow at a mob is the mob's.

At most one crown or drip a body every 0.3 seconds. The droplets come back down as small kicks on the ripple patch, round where they were thrown, 0.3 to 0.8 seconds later; spray lands beside the bow. Each droplet is a short lit streak along its flight, about 0.02 by 0.09 nodes, turned about that line to face the eye (shaders/droplet.gdshader), fading over the last third of its flight. At most 16 bursts and 6 sprays at once.

Settings and knobs

Shader weather in the Graphics tab (shader_weather, on by default), declared in game_ui.gd and listed in LOCAL_KEYS, so it is saved with the client's own settings. Off, weather spawners get their particle emitters as before and the shader draws nothing, so there are no splashes or rings either, and a lightning strike is a textured particle with no light. Puddles follow the wetness, which does not depend on the setting. Changing it mid storm rebuilds the running storm the other way at once, from the spawners as they arrived.

The wet look on the ground, grass and leaves is gated by the graphics tier's wet surfaces flag (render_wet_surfaces, see render feature switches).

The wakes, ripples and body splashes have no setting and are always on, whatever shader weather says.

For tests and screenshots:

  • storm_override on the weather node: 0 to 1 pins where in a storm cell the viewer stands; below 0, the default, the field decides.
  • The control channel's weather command (kind clear|rain|snow|thunder) sends the game's /weather chat command; with fake=true it injects a local test spawner instead (particles.gd, inject_test_spawner). That spawner is far heavier than real weather (see "Known limits").
  • GOANNA_PARTICLE_UNCOVERED=1 keeps particle weather on its plain material instead of the covered one, to compare.
  • status carries weather and wake, described under the cover map, "Wakes" and "Ripples".

Expected cost

Not measured. What the code does:

  • One draw call per kind of weather falling: 10000 quads for rain (the instances for intensity 2), each placed by a handful of hashes and a cover map read in the vertex stage. A drop not drawn at this intensity, covered, or at the lens collapses to a point and costs no fragments.
  • Fragments: each drawn drop is a thin transparent quad, lit, with one cover map read. The near box's drops a node or two away are the large ones, a few pixels wide and a few hundred long, and there are some tens of them in view; the far ones are a pixel and a half wide. Most pixels carry no rain at all.
  • Standing water: on flat up facing pixels once the world is wet, the basin noise (eight hashes) and a cover map read, in and for minutes after rain, in both array shaders. The height it fills is the alpha of the normal map fetch the shader makes anyway: no texture read of its own.
  • A lightning strike: one quad and one unshadowed omni light for under half a second.
  • The occlusion scan: at most 512 columns a frame for 8 frames each second, each column read block by block under the map lock until it hits something. Open ground stops a few nodes down; the worst case is flying high over empty air, 80 nodes a column.
  • On terrain and water: a uniform branch that costs nothing in fair weather; in rain, one texture read and two splash cells (three hashes and at most one exponential each) per up facing pixel where a pixel is under 3 centimetres and within 22 nodes, and two ring evaluations per water pixel within 22.
  • Against that, the particle path it replaces was up to 1500 GPU particles per spawner.
  • Wakes: ten times a second, entity_list and up to four node name lookups per body for 16 bodies, and a 64 texel upload while any point is live. On water within 32 nodes and inside the box the live rings reach, one texture read and a few dozen operations per live point, up to 64 points, per water pixel; at most one exponential, a cosine and a sine for a point whose ring is near the pixel. Nothing when no point is live.

Known limits

Open defects from the GPU look pass of 28 September 2026, at realistic rates, before the storm work was rebased and landed (rain and snow did scale with the storm as intended in that pass):

  • Blowing dust is invisible. The grains (DUST_RADIUS 0.012, alpha 0.5) are near invisible, and Mymonths' sandstorm at 0.67 shows nothing. The fog does close in and brown.
  • The sky stays sunny under a storm. storm_cover reaches 0.93, but the sky keeps blue gaps and full sun lies on the ground.
  • Hail is sparse: thin, sparse streaks rather than a hailstorm.
  • The fake snow spawner is far heavier than real snow. weather snow fake=true (particles.gd, inject_test_spawner) is 400 a second over 16 by 16 nodes, about 39 times Mineclonia's snow by density, so it sits at the cap of 4 and a full whiteout at any storm strength. Judge snow with a spawner of 100 a second over 50 by 50 (density 1) instead. Its rain is over the cap too. It is left as it is because local_bench.gd's rain benchmark uses the same spawner.

Other limits:

  • Rain ends about 12 nodes out.
  • Lightning strikes where the server says, and a strike at a storm cell's edge is drawn at full strength.
  • Regional Weather's snow and heavy snow both reach the cap of 2 before the storm field, so the two look alike; at a storm cell's heart theFox's rain and its storm both reach the cap and look alike too.
  • The lightning mod's strike (one spawner of one sprite, lightning_lightning_N.png, the sky set plain white for a moment) has not been seen.
  • Whether the cover test hides the right particles for particle weather has not been seen: particle rain did not show on the software renderer with either material.

What is untested

Everything visual. In particular, the owner's visual check should look at:

  • Settled snow: whether the texel dusting reads as snow on stone, grass, sand and leaves, whether it builds and melts at a sensible pace, and whether it stays out from under roofs.

  • Looking straight down and at 30 to 60 degrees down in rain: drops should be seen falling past the face and down to the ground all round the feet, with no ring of rain rushing in and no dry disc. Whether the close drops (the near box, 1 to 3 nodes away) read as rain or as marks on the screen, and whether there are too many or too few of them.

  • Looking straight up: drops falling toward the face, seen nearly end on, with no radial lines and no clear circle.
  • Walking and turning: the drops must stay put in the world (walk forward and pass them), and no drop should pop in or out at the box's faces, which are 3 nodes out for the near box and 12 for the far.
  • The distance. Rain ends about 12 nodes out; whether the storm still reads as heavy at the horizon, or whether a far layer is wanted back.
  • The status trace, status then weather.ground, on open sand in rain: shader should be nodes_array, layer_alpha false, shader_has_terms true and failing empty. If it is not empty, it names the gate.
  • Splashes on open ground at intensity 1, by day and by night, within a few nodes: whether each reads as a small ring spreading and fading rather than a spot, whether it takes the ground's colour (a darker, glossier patch of the same sand or dirt, lit as the ground round it is), whether there are too many or too few, and that they fade out further off without a visible edge or shimmer. None under a roof. At night by a torch in particular.
  • Water in the relief as a shower goes on: first the joints and dips of an authored tile (the dirt, gravel and cobble tops show it best) going dark and mirror smooth with the crests damp between, then basins where whole tiles drown. Whether it reads as soft wet ground or as wet plastic; whether the water level against the pack's heights is right (0.45 at full wetness may be too much for the Material Maker pack, whose heights sit lower, or too little for the authored one); whether a basin's rim follows the texture; whether the water flickers or swims with the parallax as the view moves (it should not); and that it drains after the rain. A tile with no authored height (the filler, or an inferred normal) holds water only in basins.
  • The distance: the normal map's mipmaps average the height, so far off the hollows blur toward the tile's mean and the relief's water thins out; whether that shows as a band where the near ground looks wetter.
  • Standing under a roof edge or in a doorway looking out: drops should stop at the roof line, and none should fall indoors.
  • Lightning, by /lightning (needs maphack) near and far, by day and by night: the bolt (the server's texture, bright, blooming, flickering), the light on the ground round the strike, and the flash across sky, clouds, fog and open ground, gone within about a third of a second, with nothing left white after it. A strike beside a house will light its inside briefly (no shadow).
  • Whether it is too strong at night or in thunder. The constants are still guesses, with a floor.
  • The wind lean direction against the grass and clouds: the sign of the wind vector in world space has not been checked against a real frame.
  • Snow: flake size, sway, drift, and whether 2600 flakes is enough.
  • The rings on a lake (the warm beach scene is a good place).
  • Toggling the setting mid storm, and a thunderstorm starting.
  • Frame time with and without shader weather.

  • Wakes, on calm water by day and by night, in first person (look down while swimming, then turn round) and watching an animal swim: whether the rings read, whether a swimmer's V is visible behind it and at what angle, whether the bob of a body standing in shallow water is too busy or too faint, the whitening at the crests, and that a boat's wake does not fill the view. Whether bodies are found at all: status then wake, in_water should count the player while swimming and any animal in the water. The rings with rain falling on them too.

Not done: splashes on leaves and plants, drops sliding down walls, and splashes thrown up from the ground by the falling drops themselves (the splashes are the ground's own, not the drops'). Lamps light nearby rain, since it is lit, which is also untested.

Tests

The ripple patch has two of its own, and project/tests/ripples.gd covers the splashes too: one crown for a fall, not one a frame, whose drops come down as rings; drips climbing out; spray off the bow swimming at 3 nodes a second and none wading at 1 or floating still; a blow from the bank that hits the lake, and none past the hand's reach, through a nearer block or level; and the emitters' scaling and budgets. goanna_ripples_test (native, in cmake --build build --target check) steps RippleField alone: still water sleeps; a standing body makes a dimple that stays one; a body walking at 3 nodes a second has a crest ahead of it and quiet water further ahead, and its waves 3 nodes behind stand out to either side, symmetrically, at a half angle between 12 and 45 degrees; a bank stops a wave; a splash settles and sleeps within 20 seconds; moving the patch keeps the waves where they were; a fast body turning hard stays finite; and a swimmer as wake.gd hands it over, a player or an animal, treading water or moving at 1 to 3 nodes a second, leaves a slope the eye can pick out 1.5 to 6 nodes away. It prints the numbers the constants were tuned on, and the cost of a step. project/tests/ripples.gd (headless) drives wake.gd over a stand in lake with an island and a higher pond: the water shader reads the globals and they are registered; a swimmer and a duck in the lake are the patch's and lay no rings, while a frog on the pond keeps its rings; the patch pushes a crest ahead of the swimmer and is published round it on the lake's surface; the mask marks the lake open and the island and the pond not, and is read in; coming into the water splashes the patch and lays no ring; and after the swimmer climbs out onto the bank the waves settle, the patch sleeps and the shader is told.

project/tests/weather.gd, headless:

godot --headless --path project --script res://tests/weather.gd

It checks that the precipitation, water and both node array shaders compile with the uniforms the scripts set, as do the leaves, the grass volume and the three covered particle shaders (Godot's shader parser runs under --headless; no GPU compiles anything), that the globals are registered, goanna_snow_cover among them, the rain cover map against a stand in for rain_cover_rows (banding, whole map publishing, texel placement matching the shader's lookup, recentring, clearing), GoannaClient.rain_cover_rows's and top_surface_at's bindings with no world loaded, and the routing in particles.gd (rain and snow to the shader, other spawners and world fixed rain to particles, intensities for Mineclonia's rain, thunder and snow, switching the setting both ways mid storm). It reads the cover texture back the way the shader does over an open beach and checks nothing round the eye is covered there, that an empty or part scanned map hides nothing, and that a roof does cover.

The rain box, through a GDScript copy of the shader's drop placing (weather.gd's drop and drop_shown, tied to the shader's lines by text, with the PCG hash in 32 bit arithmetic): every drop is inside its box; a drop inside its box stays at the same world position when the eye moves and falls at its own velocity; intensity 1 draws half the instances; rays from the eye at pitches of 80 and 45 degrees down, level, and 45 and 80 up, at three yaws, over level ground 1.625 below the eye, meet drawn drops within 12 degrees in at least six of eight moments (the test prints the drawn opacity per pitch); no drawn drop is within 0.3 nodes of the eye; and under the roof of the stand in map no drop is drawn while beside it many are. It prints and floors the peak opacity of a streak at 3 and 10 nodes, caps it at 0.45 for a drop 0.6 nodes away, and checks the materials carry the constants.

The ground terms, through copies in weather.gd of goanna_ground_rain, goanna_water_level, goanna_pool, goanna_wet_darken and goanna_splash_ring, each tied to its source line by line and its constants read from the shader. Both array shaders call the shared functions, take the height only from a layer with an authored one, and have no EMISSION and no mix toward a colour in their rain block; nodes_array.gdshader reads the height from the fetch at the coordinate the march moved to. On open flat ground clear of any basin at wetness 0.6, a hollow at height 0.05 is under water and a crest at 0.95 is neither water nor margin, just above the waterline is a margin, and at 0.3 nothing stands even at height 0; a wall holds nothing; in a basin after a minute of rain a crest is under water and splashes; under the roof of the stand in map there is no water and no splash; and splashes are gone where a pixel is 5 centimetres. Basins flood none of open ground at wetness 0.3, less at 0.6 than at 1, a tenth or more after a minute and under two fifths at 1. A splash ring's leading edge moves outward at every tenth of its life while its slope dies to under a tenth, its mark is there at impact and gone at the end; rings grow to between 4 and 20 centimetres across; there are at least 20 splashes a square node a second; and over four albedos, black to near white, and every mix of water, margin, mark and porosity, rain scales the albedo by a factor between 0.35 and 1 and never adds to it. The status trace, on open sand drawn by nodes_array, reports every gate open, the splash term and the water held over 0.5 and the level over 1 in a basin; with the scissor shader's text as it was, without the terms, it names that shader as the failing gate; under a roof it names the cover.

It checks lightning: a synthetic spawner as Mineclonia sends it, with lightning_lightning_2.png, builds no emitter, one bolt 100 nodes tall and one light a few nodes over the strike point, lit, flashes the sky, and all of it is gone after the spawner's time and the tail; with shader weather off it is an emitter whose culling box holds the whole quad; a stream of 40 is not a strike; and the flash sky test tells Mineclonia's white layer from a storm sky. It renders nothing.

project/tests/wake.gd, headless, the wakes:

godot --headless --path project --script res://tests/wake.gd

It checks that the water shader compiles with the wake include and the globals are registered, and ties wake.gd's copy of the ring (ring) and its constants to the include by text. With a fake body across a fake lake (water at and below y 0 for x 0 to 40): at 1, 2 and 4 nodes a second the points are spacing_for apart, about as many as the path over the spacing, each born when the body passed it and stronger the faster it goes; a body standing in the water for six seconds bobs four or five times, BOB_PERIOD apart, where it stands, and one drifting slower than STILL_SPEED lays no trail; walking on land, deep under the water, or standing on a bank over it lays nothing; walking in from the bank makes one splash, a teleport lays nothing across the gap, and a source that leaves is forgotten; published points are the live ones, the area holds them, and none outlives MAX_AGE; the ring buffer overwrites its oldest; the nearest sources within range are taken up to the cap. The ring: a fresh full strength ring bends the normal (its peak slope is printed), nothing before a point's birth, past its age or off its packet; and across a line two nodes behind a body swimming at 2 nodes a second the slope is on both sides of the path, more than twice what it is on the path, and none 1.6 nodes to the side (the profile is printed), and nothing beyond range.