Skip to content

feat: flame_water and flame_lighting for the wet world - #1

Merged
SERDUN merged 57 commits into
serdunfrom
feat/wet-world
Oct 1, 2026
Merged

SERDUN merged 57 commits into
serdunfrom
feat/wet-world

Conversation

@SERDUN

@SERDUN SERDUN commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Reflections and light are the main look of the Umbrella game. This branch builds them into the fork: a wet night street made of elements. Lights, rain, water and wet surfaces are roles that components take on. The physics belongs to those roles; a scene only puts elements together and sets their values.

Includes #2 (feat/lighting) and #3 (feat/street-depth), merged into this branch.

Examples build fix

On Flutter 3.47, Dashbook takes ThemeData from material_ui, so the examples app did not compile. The fix is carried over from upstream 76c8e81a6 (flame-engine#4066).

flame_lighting (new package)

  • Lighting: the night over a world.
    • Every LightSource cuts its cone and halo out of the dark and adds its colour.
    • Cones lay pools of light on the Ground.
    • Every Emissive part and the light hanging in wet air go to each LightMirror.
  • LightSource: a point or cone light with a soft edge, reach, flicker, haze halo and the size of its source.
  • Torch: a cone held by a Facing holder. It turns with the holder.
  • Ground holds the ground band and how it is seen: depthSpan, eyeHeight, nearDistance, sinElevation. It also computes water's Fresnel reflectance from these.
  • Glossy: a wet wall's sheen, damp trails and darkening, drawn right after the component itself.
  • scatteredLightAt / dropLightAt: how much light a drop sends to the eye. Uses a Henyey–Greenstein phase, so a drop in front of a lamp flares and one behind it dims. Light from a source reaches a drop by its 3D distance.
  • WorldLookup: finds a world's Lighting, Ground or Rain once and remembers it.
  • Shaders (LightingShader.load). Until they load, a layer or oval is drawn instead.
    • light.frag: a cone in one pass.
    • pool.frag: a pool, computed per point of the ground from the light that reaches it and the angle it falls at.

Depth projection (#3)

Ground holds the one projection a side-view scene shares. Everything stands at a depth: below 0 behind the street line, 0 on it, 0..1 on the ground towards the eye. From the depth:

  • distanceAt: rows are equal steps of 1/distance;
  • yAt / depthAt: where the thing meets the ground;
  • scaleAt / projectX: its size and parallax;
  • sinElevation: the angle of view.

Rain and WaterSurface land and mirror by it. Reflectable.groundDepth mirrors a thing about its spot on the ground.

flame_water

New package in the monorepo.

  • Reflectable marks what water mirrors. ReflectionPass tells a component it is being drawn as a reflection.
  • RippleRings: rings from drops. The water shader bends the mirror around the newest ones.
  • Rain: drops over the camera's view at a depth from the street line to the eye.
    • Sizes follow Marshall–Palmer. Fall speed follows Atlas terminal speed. Each drop takes up the wind according to its size.
    • On screen, speed is perspective-corrected.
    • A drop's brightness is the light it scatters times its exposure (diameter over speed).
    • A drop splashes only when its impact number K = We^0.5·Re^0.25 is above 57.7.
  • RainCatcher: what rain lands on.
    • The first catcher on a drop's way takes it.
    • frontDepth: drops falling in front of something upright pass it by.
  • Wettable: wetness is the water an element holds.
    • Rain brings water in; it drains at dryRate × Rain.drying.
    • porosity drives wetDarkening (the element darkens first) and wetGloss (it shines once a film forms).
    • WetSheen ties these to Glossy.
  • WaterSurface is a LightMirror, a RainCatcher and Wettable. Through water.frag, in one draw:
    • rings sized by water depth, plus chop from the rain;
    • a smear down by roughness and across by the angle of view;
    • Fresnel per row;
    • the water or wet ground under the mirror, the tint, the fade and a soft rim.
    • It also:
      • mirrors about the Ground line;
      • mirrors each Reflectable about its own reflectionBase, and each drop about the spot it falls to;
      • shrinks a drying pool as its water goes;
      • dries water by its depth.
    • reflectivity is now how much of the surface is clear water. glowGain (10) is how much brighter than white a lamp is.

examples

The "Wet world" chapter:

  • Mirror puddle: a road and puddles, with sliders for reflectivity, squash and fade.
  • Ripples: rain falling into puddles.
  • Rainy night: everything together - the street, houses, lamps, a walker with a torch, the road's film of water, puddles, the lighting and the rain. Sliders: darkness, glow, haze, rain, drying speed and ground wetness. Changing a slider changes the running street, so the street dries or soaks over time.

tool/wet_bench.dart prints frame timings. --dart-define=BENCH_WITHOUT=lighting,water,rain leaves systems out.

Frame timings (profile)

UI p50 raster p50
macOS, M5 Pro, Impeller Metal: before 26 ms 11 ms
macOS: now 1.9 ms 6.7 ms
Pixel 9, Impeller Vulkan: now 8.5 ms 3.3 ms

On the phone the cost is Dart: every water surface re-records the world for its mirror and for its glow.

Tests and devices

  • flame_lighting: 15 tests, flame_water: 28. Most check pixels: pools, cones, halo headroom, drop scattering, splash threshold, mirroring about an element's own base, drying.
  • Analyze is clean.
  • Seen running:
    • macOS (Impeller Metal);
    • a Pixel 9 (Impeller Vulkan);
    • the web build (CanvasKit);
    • frames rendered in flutter test.

Impeller traps

  • On Impeller, dozens of MaskFilter.blur draws inside one toImageSync use up Metal's command queue. The frame then freezes for good.
  • On Vulkan, a vec2 uniform right after a lone float loses the whole frame. The fix is to keep runtime-shader uniforms as scalars.
  • toImageSync(targetFormat: rgbaFloat32) clamps values at 1. flutter_gpu float targets do not: verified on macOS and the Pixel 9.

Follow-ups

  • The serdun/1.38.2-1 tag, then moving these packages into the game in place of its RainLayer and puddles.
  • One snapshot of the world per frame, mirrored by every surface in the shader.
  • Ripples and an HDR light buffer on flutter_gpu, with the canvas path kept for the web.

On Flutter 3.47 dashbook 0.1.19 takes ThemeData from material_ui, so
passing it the framework's ThemeData failed to compile and the examples
app did not build at all. Taken from upstream 76c8e81 (flame-engine#4066): main.dart
imports material_ui and dashbook keeps its own theme.
A side-view game in the rain lives on reflections: puddles and wet
ground that show the street above them. flame_water is a new package in
the monorepo for that.

WaterSurface is a stretch of water (a rect or an ellipse) that draws the
Reflectable part of the world again, upside down about its water line,
clipped to its shape, squeezed for a low viewing angle, faded with
depth and tinted over the water's own colour. It walks the world tree
itself: a reflectable component is drawn with all it holds, any other
one only as a way down to its children with its transform, and water is
never reflected in water. No image of the scene is made, so it costs
draw calls rather than an offscreen render and works on every renderer,
the web included - the base quality tier the shader tiers build on.

ReflectionPass tells a component it is being drawn as a reflection, so
a lamp can reflect as a glow. RippleRings is a fixed pool of flattened
rings spreading from where drops hit, fed through WaterSurface.splash.

Tests render a surface to an image and read the pixels: a reflectable
block is mirrored below the line and a plain one is not, a squashed
reflection is shorter, the pass flag is set only inside the reflection.
A chapter of the fork's own samples, first in the list: a street at dusk
(sky, houses with lit windows, two lamps, a walker under an umbrella, a
road) where everything but the road is Reflectable.

Mirror puddle: a wet road with a faint mirror and two puddles showing
the same reflection brighter, with sliders for reflectivity, squash and
fade and a switch for the wet road. The lamps reflect as glows.

Ripples: the same street in the rain; a drop that lands in a puddle
starts a ring sized by the drop, and a tap splashes.

Code links point at the SERDUN fork, where these samples live.
In the wet world samples a drop and the ring it made did not read as
one event. Every drop lands at its own depth on the road, but all drops
looked alike, so a streak crossing a puddle seemed to hit it, fell on
and landed nearer, while a ring started under some other drop; and a
drop just vanished where it landed.

Depth now shows in the drop: a near one is longer, brighter, thicker
and crosses the screen faster than a far one. Where it lands it bursts
into a few droplets in the same step that starts the ring, so the burst
and the ring meet. Drops born in one step are spread over the way they
fall in it (they fell as lines at a low frame rate), and the scene opens
with the rain already falling. The rain is shared by both samples, and
the mirror puddle sample rains too.
Rings from drops were drawn over a still reflection: a drop hit the
water and the mirrored street did not stir, which water never does.

WaterQuality.rippled renders the mirror into an image once a frame, at
resolution pixels per unit, and draws it through water.frag. The shader
treats every ring as a travelling bump on the surface and looks the
reflection up a little to one side where it passes, so the mirrored
world wobbles around each drop and nowhere else. The newest 16 rings go
in as a uniform array, with the ring flattening and the wavelength.

WaterShader loads the program once (on the web it compiles at load);
until it has, and at WaterQuality.mirror, a surface draws the plain
mirror as before, with no offscreen render. ImageFilter.shader is not
used: it fails on the web.

A pixel test renders stripes over two surfaces: still water shows them
as the mirror does, a struck one moves them near the drop and leaves
the far ends alone - which also proves FlutterFragCoord comes in the
surface's local coordinates.
Both samples load the water shader and land every drop somewhere: in
the puddle that covers the spot, else in the film on the road, which
has small quick rings of its own. The rings now bend the reflection, so
the ring lines are fainter.
The rainy street needs its night: lamps, lit windows, and the light
they throw on wet things. flame_lighting is a new package for that.

LightSource is a point or cone light with a colour, an intensity, a
reach, a flicker and the radius of what glows itself. Its light falls
off smoothly with distance, and a cone eases out to its sides over the
outer part of each side instead of ending on a straight line.

Lighting covers the view with the night's colour at a darkness, cuts
every light out of it with a soft falloff and adds the lights' colour
on top. A cone is the disc of light masked across by a sweep gradient
in a layer, so it has no hard edge. lightAt tells how lit a point is and
in what colour, for things that shine in the light, like rain.

A LightMirror (water, a wet road) shows the lights above it mirrored:
the light's own source as a bright core nearly as strong as the source
- water seen low across mirrors most of what falls on it - and its glow
drawn out down the surface into a streak.

Only blend modes, gradients and one layer: no shader, so it works on
every renderer, the web included. Tests render a game and read pixels:
dark away from a light, a lamp lights below it and not above, a cone's
edge has no step, the mirrored lamp is nearly as bright as the lamp.
WaterSurface is a LightMirror: under a Lighting the lights above it show
in it as a bright core and a glow drawn out lightStretch times down the
water - a round spot in a puddle, a long streak on a wet road. The
package now depends on flame_lighting.
Everything at once, at night: lamps light soft cones of the street and
the lit windows glow, one flickering; the wet road draws every lamp out
into a streak and the puddles mirror it as a bright spot; rain draws
above the night as bright as the light where it is, in its colour; every
drop ripples the water it lands in. Sliders for darkness, glow and
rain.
Compared with photos of lamps on a wet night two things were missing.

A lamp's reflection on wet ground is not a smooth streak: the surface
is never flat, so it breaks into a column of short horizontal slivers
that shimmer. A LightMirror now draws a light's glow that way: slivers
from its water line down past the mirrored source, brightest there and
fading both ways, each wavering in width and brightness on its own in
time. The bright core of the source stays whole.

And wet air scatters light: a lamp glows far beyond its bulb. haze
(0..1) gives every light a wide soft halo round its source, sized by
the source's radius, so a window gets a small one and a lamp a big one.

Also fixed: the lighting's paints are shared, and a sliver's colour
alpha dimmed every gradient drawn after it - the mirrored core and the
next lights came out too faint. The paint's colour is reset after the
slivers. Tests: the mirrored lamp is nearly as bright as the lamp again,
the glow is looked for over a strip (a point can fall between slivers),
and thick air lights the space round a bulb but not the whole sky.
The lamps scatter into halos in the wet air (a haze slider), a lamp
gets one halo rather than two, windows get small ones, and the wet road
shows every lamp as a shimmering column of slivers.
Two faults in how wet ground showed a lamp.

The slivers made an accordion: evenly spaced, all alike and as wide as
the light's reach, they laid regular stripes across half the road. A
reflection on wet ground is a narrow column a few bulbs wide. It is now
a soft narrow glow with bright slivers over it at uneven gaps and
heights, set a little off the axis, each coming and going on its own.

And drops did nothing to it: the lighting drew the lights' reflections
apart from the water, so ripples bent the mirrored street but not the
lamp in it. A LightMirror now says how its own movement shifts what it
mirrors at a point (disturbanceAt, zero on still water). Every sliver
moves by it and catches more light where the surface tilts, and the
mirrored source is drawn in thin slices, each moved as the water under
it moves - a ring passing through the lamp tears it apart.

Test: water that shifts its mirror 10 to the right moves the mirrored
lamp's core 10 to the right.
WaterSurface reports its ripples through disturbanceAt: the water
shader wave (newest rings, flattening, wavelength, amplitude) worked out
on the CPU for a point, so the lighting moves the lamps reflections
exactly as the shader moves the mirrored street. Tested: nothing on
still water, a shift near a fresh ring, nothing far from it.
The lights' reflections were drawn by the lighting on its own, apart
from the water: a column of slivers faked over the surface. However it
was tuned it was not a reflection - drops that bent the mirrored street
did nothing to the lamp in it, and the slivers read as an accordion.

What gives light itself is now its own pass. Emissive parts (a light's
source, the halo haze makes round it, anything else that glows) are
drawn by the Lighting over the night, undimmed, after it has cut the
lights out of the dark and added their cast colour. Then every
LightMirror is handed that glow and draws it mirrored the way it draws
any reflection, so whatever the surface does - ripples, roughness -
happens to the lamps too.

LightSource is Emissive: its source disc and its haze halo. The slivers,
the sliced core, disturbanceAt and the mirror's line, squash, strength,
stretch and clip are gone; LightMirror is one method now,
renderMirroredGlow.

Tests: still water (a plain mirror in the test) shows the lamp nearly
as bright as the lamp, and nothing beside it.
WaterSurface draws the world's glow as it draws the world: mirrored
about its line into an image and through water.frag, added over the
night at its reflectivity times a grazing factor (water seen low across
mirrors most of the light) times glowGain. The rings bend a lamp's
reflection exactly as they bend the street's.

The shader gains a vertical smear, streak: a rough surface mirrors each
point over a range of heights, mostly above it, so a lamp runs down a
wet road towards the viewer - and since every row is bent by the
ripples on its own, the streak breaks where drops land. The mirrored
street takes a tenth of it, a soft mirror. A lamp is far brighter than
the screen's white, so a streaking surface can raise glowGain to keep
the thin smear bright; the lamp's own core clips to white, as in a photo.

Tests: still water shows the lamp as a lamp and no streak under it, a
drop next to it bends it, a rough surface smears it down narrowly.
The wet road is rough (streak 70, glowGain 10): every lamp and lit window
runs down it into a streak the drops break. The second puddle lies
under the right lamp, where its reflection falls, and shows the lamp.
A smoke test renders the rainy night for three seconds; with
WET_WORLD_FRAME=<path> it saves the last frame to look at.
The shader smeared a rough surface's reflection with 14 lookups down
the column. A lamp a few pixels tall, sampled at steps as long as
itself, came out as a chain of beads - copies of the lamp - under every
light and window.

The smear is now a real vertical blur (ImageFilter.blur, sigma a third
of streak) on the mirror before the shader sees it: one continuous
column of light the ripples then bend. The shader only ripples now.
A test walks down the streak and finds it only ever fades.
With the blur the road smear is streak 45 and glowGain 5: a soft warm
column under each lamp and window, torn at its edges by stronger road
ripples (3.5).
Only fresh rings bent a reflection, and they rarely crossed a lamp's
streak, so it hung still in pouring rain. Rain keeps the whole surface
astir: chop is a restless wave everywhere in the water shader, mostly
sideways and changing fastest from row to row, so a reflection breaks
into wavering bands. The shader also takes the 32 newest rings, not 16.

And one physics for all water: a wet road mirrors as much as a puddle,
only rough, so the same streak smears the street and the lamps alike
(the street took a tenth of it), and glowGain is the lights' own
brightness over white, 4 for every surface, not a per-surface knob.
The wet road is water like the puddles (reflectivity 0.6), differing
only in roughness; both are kept astir by the rain in proportion to how
hard it rains, and the road rings are bigger and stronger. Both lamps
now reflect the same way: a streak broken into wavering bands, a bright
lamp where a puddle lies under it.
A puddle and asphalt under a film of water are the same thing with more
or less water in it. WaterSurface.depth (0 a film, 1 a puddle) now sets
the rings drops leave (RippleRings.forDepth: radius 6 to 24, life 0.3
to 0.7 s), how much they bend the reflection and how plainly they show:
rings everywhere, wide and slow in a puddle, small and quick and faint
on a film.

And a puddle no longer ends on a hard ellipse. With edgeSoftness (0.35
of its radius by default) it is drawn in a layer and faded out towards
its rim by a radial mask over its whole rectangle; its mirrored glow
too, in an additive layer so the lamp keeps its brightness. A rect (a
road) keeps its hard edge. Tests: deeper water leaves wider longer
rings; a puddle is bright in its middle, thinner near its rim, empty
past it.
The road film is depth 0.1 and the puddles the default 1, instead of
ring settings of their own; the puddles fade out at their rims.
Rings on the road could not be seen: the small faint strokes were drawn
with the water, under the night, which darkened them to nearly nothing.
A ring's crest is a glint - it mirrors whatever light is round it. Under
a Lighting the rings are now drawn with the mirrored glow, over the
night, each as bright as the light where it lies (Lighting.lightAt) plus
a little sky: bright by a lamp, faint but there in the dark. Without a
Lighting they are drawn with the water as before. A film's rings are a
little bigger (radius 9 at depth 0) and less muted.
The road film's rings are drawn in a denser white, so the small rings
drops leave on the asphalt show all along it.
Rain wets more than the road: walls and roofs glint where the lights
fall on them. Glossy is a wet surface that is no mirror - rough and
upright, it does not show the street. The Lighting lays a sheen of every
light over its glossArea, as strong as its gloss, and water running
down it in short rivulets that start anywhere down the wall, each as
bright as the light where it is, with a bead of water sliding down
that catches more of it. Test: a lamp lights both sides of it alike,
but the wet side glints brighter.
The house fronts are wet (Glossy, gloss 0.55): the lamps glint off them
and water runs down them. Rain breaks on their roofs and lit window
sills too: a far drop - falling by the fronts - that crosses a ledge
bursts there, and every splash is lit like the drops.
The rivulets on wet walls read as bright lines with beads running down
them. Water on a wall at night is barely seen: each is now a damp trail
- a wide blurred band fading in and out along its length, only just
lighter than the wall, breathing slowly - and the beads are gone. Still
only where light falls on the wall.
A wall's sheen and damp trails, and every lamp's and window's glow, were
drawn by the Lighting over everything, so a walker in front of a house
had the water on the wall and the window's halo painted over him.

They belong to their surfaces. A Glossy component now draws its sheen
itself at the end of its render (renderSheen, which asks the world's
Lighting through Lighting.of), and a LightSource draws its source and
halo in its own render - so what stands in front hides them. The night
is cut round every halo as round every light, so the glow is not
dimmed; the Lighting still hands the glow to the mirrors. Emissive
components are to call renderEmissive from their render.
The houses draw their own sheen after their fronts, so the walker in
front of them hides it.
A torch pointed at the ground lights a patch of it, stretched along the
beam; a lamp lights a wide one round its foot. The lights here lit only
the air in their cones, and the road under them stayed dark.

Lighting.floor is the ground seen from the side: a band whose top edge
is the line things stand on, going away from the viewer. For each cone
that reaches it, the stretch of ground lit is worked out from the
light's height and the angles of the cone's edges (an edge that points
up or runs out first ends at the light's reach), and an oval of light
is laid over it on the band, cut out of the night and warmed like the
light itself, fading with the way the light went. Test: a torch held
low and tilted down lights the ground ahead of it, not behind.
The rainy night's road is the lighting's floor, so the lamps lay pools
of light under them and the torch one ahead of the walker; the torch
reaches 380 so its pool is not lost to the falloff. The smoke test can
look at any moment with WET_WORLD_SECONDS.
What the lighting did for a scene was set up in the scene: the floor
for pools by hand, a wall's sheen by a call from its render, a torch
aimed each frame by its holder. Those are properties of the elements.

Ground marks the ground seen from the side; the lighting (and the rain)
find it themselves - Lighting.floor stays only as an override. Glossy
draws its sheen right after its component, with nothing to call. Facing
says which way a component faces, and Torch - a LightSource in a hand -
turns with a Facing holder, mirroring where it is held and where it
points. Lighting.of finds a world's lighting.

Tests: a torch turns round with its holder and changes hands; a torch's
pool lands on a Ground element without a floor given.
The rain, where drops land, how water stirs and how wet things get were
all written into the sample scene. They belong to the elements, so a
game only puts them together.

Rain falls over the camera's view as real rain does (RainDrops, the
game's drop physics: Marshall-Palmer sizes for the intensity, Atlas
terminal speeds, drag response to the wind, a streak per exposure) at
metre world units per metre. Seen from the side each drop falls at a
depth: onto the world's Ground that far towards the viewer, faster on
screen the nearer (speed goes as one over distance), or behind the
street line, where only roofs see it. It is lit by the world's Lighting.

RainCatcher is what rain lands on: each catcher says where it stops a
drop and what the drop does to it; the first on a drop's way takes it,
and of two at one height the one with more water. WaterSurface catches
drops at their depth where it covers the ground and rings them.
Wettable gets wet in the rain and dries after it, not under cover.
WaterSurface.chopPerRain stirs the water as hard as it rains; a film
(the water on a road) mirrors only as much as it is wet.

Tests: rain lands in a puddle over the road, on the road beside it and
on a roof behind the street line; things get wet, dry, stay dry under
cover; the chop follows the rain; the drop physics tests from the game.
The samples carried their own rain, the landing of drops (landOn), the
roofs and sills, the chop worked out from the rain, the floor for the
light pools and the aiming of the torch. They are elements now: the
road is the Ground, the road water a film that stirs with the rain,
the houses get wet, glint as wet as they are and catch the rain behind
the street line on their roofs, the walker faces a way and carries a
Torch, and Rain comes from flame_water. A scene is a list of them.
How wet the world gets was all or nothing: under rain everything went to
streaming. Wettable.wetnessCap now says how wet a thing gets in this
weather, and a puddle holds water as wet as the ground is - a damp
street has small pools with a soft rim, a soaked one wide ones. A film
still mirrors as much as it is wet, and the rings of drops fade with the
water they fall into.

The rim mask covers the whole surface in the shrunk pool's scale, so
nothing drawn outside the pool is left.

Tests: wetness stops at the cap; a pool shrinks as the ground dries.
The rainy night was always soaked. A slider takes it from barely damp
to streaming: it sets how wet every Wettable in the scene gets.
A water surface brings the glow it mirrors back glowGain times brighter,
since a lamp's source is brighter than an image's white. It brought the
halo round the lamp back that much brighter too, though the light the
haze scatters is no brighter than it looks. Wherever a lamp's image
fell, the water burnt out white over the halo's whole width: a small
drying puddle under a lamp became a white disc, like a spotlight on the
road.

The mirror now tells the lighting how much it will brighten (headroom),
and the lighting draws what the air scatters - the halo, the beam in
the wet air - that much fainter. Brought back, the source comes out
brighter than white and the halo as bright as it is: a puddle shows the
lamp as a bright spot in a dim glow.

Tests: with headroom the mirrored halo is fainter, the bulb the same.
A drying puddle shrank only to a third of its size: on dry ground it
still lay there, lit by the lamp over it. Its area now goes with the
water in it - across it shrinks as the square root of its wetness - so
on barely wet ground it is a small patch and on dry ground it is gone,
catching no drops and drawing nothing.

Tests: a dry puddle has no outline and covers nothing.
How wet a thing got was capped by hand (wetnessCap): the rain soaked it
up to the cap however hard it rained, and it dried at one fixed rate.

Wetness is now the water a thing holds. Rain brings water on at wetRate
times the intensity, the more slowly the fuller it is; water leaves at
dryRate times the weather's Rain.drying, the faster the more there is.
A steady rain holds it where the two meet - a downpour near soaked, a
drizzle less - and without rain it drains to nothing at the weather's
pace. wetRate and dryRate are each element's own, so a wall sheds its
water first and a puddle keeps it longest. The cap is gone.

Tests: a drizzle and a downpour hold a wall at their balance; without
rain it dries as fast as the weather says, and not at all at drying 0;
a puddle outlasts a wall.
Every change of a property built the street anew, so it could never be
seen drying: stopping the rain started a fresh, soaked scene.

The story keeps one street running and changes its weather as it goes.
A drying speed joins the sliders; ground wetness wets everything that
much at once. Stop the rain and the walls dry first, the asphalt next,
the puddles last.
That a puddle outlasts the asphalt was set by hand on the sample's
puddle: a WaterSurface made anywhere else dried as fast as a film.

Water now dries as long as it is deep: WaterSurface.dryRate comes from
its depth - a film (0.1) at the usual 0.02, a puddle (1) ten times as
slowly - unless set outright. The sample's puddle no longer sets it.

Tests: a film dries as the asphalt, a puddle ten times as slowly, and
a rate set outright wins.
On Impeller (macOS, and iOS on the same Metal backend) the rainy night
drew four frames and froze: the raster thread waited for good on a new
Metal command buffer. Each damp trail on a wet wall was a rect with a
mask blur added with plus; inside a reflection's toImageSync every one
of them took a blur pass of its own, and a wall of some seventy trails
used up the command queue within a snapshot.

A trail is now soft all round by its own radial gradient, an ellipse
fading from its middle out: the same blurred, barely-there streak, drawn
in one pass. The scene runs on macOS at about 50 fps.
Several things were set by hand where the angle of view decides them.

Ground now says how the ground lies before the eye - how deep the band
really is (depthSpan), how high the eye is (eyeHeight), how far the
nearest ground is (nearDistance) - and from that the angle each depth
is seen at (sinElevation) and how much still water mirrors there
(waterReflectance, Fresnel by Schlick's approximation for water).

A cone's pool on the ground is worked out, not drawn as an oval: a new
shader (LightingShader, shaders/pool.frag) lights every point of the
ground band by the light that reaches it in 3D and the cosine it falls
at - round under a lamp, a strip along the street ahead of a torch.
Until the shader is loaded the oval stays.

Rain is seen by the light it scatters to the eye: scatteredLightAt
weights each light by the Henyey-Greenstein phase of a drop between it
and the eye (g 0.7), so a drop in front of a lamp flares and one behind
it dims. LightSource.lightAt takes how far in front of the light a
point is, so a window no longer lights a drop metres before it.

Glossy has a darkening: a wet wall is darker under whatever light then
falls on it. Damp trails draw nothing new.

Tests: a lamp's pool is brightest under it and fades along the street
and towards the eye; a torch's pool lies ahead along the street line;
a drop in front of a lamp outshines one beside it, one behind dims.
Water mirrored as much as a hand-set reflectivity said, times a hand-set
grazing boost, evenly from top to bottom; a rough surface smeared every
point into the same thin streak; everything was mirrored about one line;
every drop that landed splashed; a wet film mirrored as soon as it was
damp.

- Fresnel: through the shader each row of water mirrors as much as
  water does at the angle it is seen at (from the world's Ground) - most
  far off, little near. reflectivity now says how much of the surface is
  clear water (1 still water) and grazing is gone. glowGain is 10: a
  lamp at night is that much brighter than the screen's white, which is
  what keeps its reflection bright at a third of the light mirrored.
- A rough surface's streak is as wide as its length times the sine of
  the angle of view: a thin line far off, rounder near. Taps outside the
  mirror read nothing, so its clamped edge no longer runs on as a line.
- A Reflectable may say where it stands (reflectionBase); water mirrors
  it about that. Drops and their droplets are mirrored about where they
  fall (mirrorShift).
- Wettable has a porosity: a porous thing darkens as its pores fill
  (wetDarkening, to a fifth of its colour at full porosity) and shines
  only once a film forms (wetGloss), which goes first as it dries. A
  film darkens the ground under it and mirrors only as it shines.
- Rain: a streak is as bright as the light it scatters to the eye times
  its exposure (diameter over speed); a drop splashes only above the
  impact number K = We^0.5 Re^0.25 = 57.7, more droplets the harder.

Tests: a drop at 1 mm splashes and one at 0.5 mm does not; something
standing lower is mirrored about where it stands.
The rainy night loads the pool shader; the houses are brick and plaster
(porosity 0.6) and darken and shine by their wetness; the road's film
lies on asphalt (0.4); puddles are clear water (reflectivity 1), the
film 0.85; a street lamp reaches 420, some metres of street round it,
so its light reaches the ground.
Every drop asked the lighting how lit it was twice a frame, and each ask
walked the whole world for its lights; Lighting.of, Ground.of and
Rain.of walked it too, from every light, surface and drop. On macOS
(profile, Impeller) the rainy night spent 26 ms of Dart a frame.

WorldLookup finds the one component of a kind in a world and remembers
it until it is unmounted; Lighting, Ground and Rain find themselves
through it. The lighting gathers its lights and mirrors once a frame.
The same scene now spends 4 ms of Dart a frame.

examples/tool/wet_bench.dart runs the rainy night alone and prints frame
timings (flutter run -d macos --profile -t tool/wet_bench.dart).
Each cone - a lamp's, a torch's - was a disc masked by a sweep inside a
layer the size of its reach, up to 840 square, once to cut the night,
once to light it and once in every water's reflection. On Impeller every
such layer is an offscreen target.

A second shader (shaders/light.frag, loaded with the pool one by
LightingShader.load, which now takes the shaders' directory) works out
the same falloff and soft edge per point: one draw, no layer. Until it
is loaded the layer stays.
Water opened a layer for its soft rim, another for how much it mirrors,
a blur layer inside each mirrored image and a fade mask; its glow the
same again. The rain was a drawLine per drop and a drawPoints per
droplet, each asking the lighting for every drop, again in every
reflection.

- The water shader now does it all in one draw: the smear (a sigma down,
  taps a fraction of it apart over an image rendered no finer than the
  smear needs, read with linear filtering), the water or wet ground
  under the mirror, the tint, the fade below the line and the rim. The
  plain mirror keeps the layers.
- The rain works out each drop's look once a frame (dropLightAt: what it
  scatters, how lit it is and its colour in one pass over the lights)
  and draws every streak and droplet as one batch of vertices, each
  streak a strip full down its middle and clear at its edges.

On macOS (profile) the rainy night went from 26 ms of Dart and 11 ms of
raster a frame to 1.9 and 6.7 over this and the lookup change before.
On a Pixel 9 (Impeller on Vulkan) every frame with the lighting was
lost: the screen kept a bare street, with no night, no rain and no water
in it, while the same scene drew whole on macOS. Without the lighting's
shaders, or with only the cone one, the frame was whole again.

The pool shader declared a vec2 right after a lone float (uLight,
uHeight, uAxis). Flutter sets uniforms as floats in order; on Vulkan
that vec2 is laid out at the next 8-byte boundary, so the two disagree.
Its uniforms are now all floats, and the angle off the beam comes from
atan of the cross and dot products instead of acos.

Checked on the Pixel 9: the night, the cones, the pools on the road,
the rain and the reflections all draw.
…the world

Three rules lived in the sample's elements instead of the fork's:

- Water mirrored about its own top edge unless told the line things
  stand on; every scene set waterLine by hand. It now takes the top of
  the world's Ground when not told otherwise.
- A wet wall tied its gloss and darkening to its wetness by hand. The
  WetSheen mixin does it for anything Glossy and Wettable: it darkens as
  its pores fill and shines up to soakedGloss once a film forms.
- A house refused rain falling in front of it in its catchDrop.
  RainCatcher.frontDepth says how near the eye a catcher's front stands;
  the rain passes nearer drops by.

Tests: the rain tests, unchanged, still land drops on the roof only from
behind the street line.
The scene still drew a lamp's glow by hand when it was drawn in water,
from before lights showed in mirrors on their own; it is gone. The
houses use WetSheen and frontDepth, and the water takes its line from
the road.

The water no longer squashes what it mirrors (squash 0.6): a still
puddle mirrors full height. The lamps, about 135 above the street line,
are mirrored about 135 below it, past the bottom of the view; the
windows, the walker and the posts show taller. The mirror puddle story
still has squash as a slider, now from 1.
feat: lighting and rain roles for the wet world
@SERDUN SERDUN changed the title feat(flame_water): mirror the world in water surfaces feat: flame_water and flame_lighting for the wet world Oct 1, 2026
SERDUN added 2 commits October 1, 2026 12:19
What stood where in a side-view scene was worked out apart in each place:
the rain had its own perspective (1 / (1 - 0.6 depth)) and its own
landing line with pixel insets, the water its own, and a game laying
things out in depth (Umbrella's 2.5D road) a third.

Ground now holds the one projection everything shares. Everything stands
at a depth - below 0 behind the street line, 0 on it, 0..1 on the
ground towards the eye - and from it follow how far it is (distanceAt:
rows are equal steps of 1 / distance, crowding towards the line), where
it meets the ground (yAt, depthAt), how big and how fast it slides by
(scaleAt, projectX) and the angle the eye looks down at it
(sinElevation, now from the same distances).

Tests: the projection's places, sizes, parallax and crowding.
The rain and the water now take the world's projection (Ground) instead
of their own: a drop lands where its depth meets the ground (yAt) and
crosses the view as fast as that depth makes it (scaleAt); water catches
it there. Without a ground the old perspective stays. The rain's
margins past the view are a metre, not 40 world units, so it works in
metres as in pixels.

Reflectable.groundDepth: something standing at a depth is mirrored about
where that depth meets the ground, without working out its base itself.

Tests: a block at a depth is mirrored about its spot on the ground.
feat: one depth projection for the street
@SERDUN
SERDUN merged commit 7b73708 into serdun Oct 1, 2026
@SERDUN
SERDUN deleted the feat/wet-world branch October 1, 2026 12:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant