Sitelet https://github.com/pleiondev/flutter3d/pull/74
Skip to content

Keep a tipped liquid inside its glass, and colour its drops - #74

Open
dzolotov wants to merge 34 commits into
0.9.0from
fix/drops-look
Open

dzolotov wants to merge 34 commits into
0.9.0from
fix/drops-look

Conversation

@dzolotov

@dzolotov dzolotov commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

Two problems from the macOS app:

  • Liquid through the glass. A tipped tube's liquid stood out through the glass. The meniscus of a tipped surface is now the flat wall's, mean-free. liquidMeshes projects lifted points back inside a RevolvedVessel. New test: no vertex of a tipped tube's liquid is outside its glass.
  • Drops. Drops were drawn near white next to a blue stream. They now take the colour of their solutes, via ParticleFluid.concentrations, and are drawn as smoother spheres.

mixture_test now tips the glass to 1.3 rad instead of 1.2. At 1.2 rad, with the wall meniscus, the glass comes to rest at its lip with a film of oil still in it.

A tipped tube's liquid stood out through the glass along its long sides:
its meniscus was the upright tube's, by distance from the axis, lifted
straight up off a wall that leans. Tipped, the meniscus is now the flat
wall's, by distance from the glass and with its mean taken out so it
adds no liquid, and the liquid mesh keeps every lifted point inside the
vessel. Drops are drawn the colour of what is dissolved in them, rather
than near white, and as spheres of forty-two points.
Each labelled solution's colour now comes from Beer-Lambert with its
molar absorption coefficients at about 610, 550 and 465 nm and the
strength on its label, A = εcl, rather than from a swatch: permanganate
at 0.02 mol/L is nearly opaque in a tube, copper sulphate a deep clear
blue, dichromate orange, nickel chloride pale green, hydrochloric acid
clear. Particles that touch are drawn as one drop of all their liquid,
so a stream breaking up shows drops of its own size, not millimetre
dots.
The tabletop reflected the liquids and labels but not the glass round
them, a choice made while the glass was nearly invisible. The glass's
tint is what one surface lets through, and light crosses four surfaces
of a tube: at 0.96 an empty tube's shadow let through seven tenths of
the sun. It is nearly white now, and Fresnel alone darkens the shadow's
edges.
The tilt slider set a glass's lean at once, and its lift jumped by
centimetres the frame a tipping glass first came over a neighbour. The
liquid felt jolts of several g, its surface turned over, and a tube
leaned a tenth of a radian further emptied itself in a step. The slider
now asks for a lean; the hand eases towards it and lifts first, both with
no jolt at the start or the end. Also pours briskly, so the stream is
wide enough to reach the clean tube whole, and lets a tool read the
bench's frames through ext.flutter3d.render.
The tabletop cast no shadow, so it was not in the shadow map the engine's
photons search for somewhere to land; they found nothing and were lost,
and every solution threw the same grey shadow. It is in the map now.

The hand tipped to where the tube held the next eighth of a second's
worth less, which on a tube nearly on its side stood the surface a
fraction of a millimetre over the lip and ran a trickle. It now tips to
where the lip's weir runs the flow it wants, and a tube pours half in
about a second with a stream that reaches the clean tube whole. The
liquid meshes are thirty-two by twenty-four, which halved what a pour's
frame cost.
Once the bench took part in the shadow map, every photon found somewhere
to land, and the ones leaving a liquid's rim edge-on were drawn as quads
over most of the caustic tile. Their light is too thin to see, so both the
GLSL pass and its CPU port skip them; camera orbits are smooth again.
A drop that came down on the bench kept its speed along it and slid
off for good, ending up under another glass where it looked as if it
were inside. A viscous liquid has no velocity along a wall at rest,
and a drop smaller than the capillary length is held whole by its
pinned edge, so particles touching a wall keep only what leaves it,
and such drops stand still. A drop that touches a vessel's glass from
inside runs down into its liquid, as a stream's parcels already do.
A floor given to the fluid world now takes what reaches it as puddles:
one body each, with its volume and what is dissolved in it, instead of
thousands of particles. A spread puddle is 2lc*sin(theta/2) deep and a
small one is a cap at the contact angle; it spreads as a viscous gravity
current and runs together with the ones it meets. capMesh draws them.
A tube emptied on the bench used to leave 7500 particles that took a
quarter of an hour to step.
A tube can now be tipped past the lean it holds its liquid at, mouth down
at the most, and what runs out lies on the bench as a puddle drawn in its
own colour. The hand lifts it out of the row by the height either side
needs, so the tube at the end of the row no longer lies down on the
bench when it leans off it.
Once the lip stops feeding a stream, its upper end eats the thread ahead
of it at Keller's sqrt(sigma/(rho*r)) and gathers it into a bulb. The
tail of a pour used to fall whole, as wide at its end as along it, like
a bent rod of glass.
The bank kept the end of every run of drops, up to a particle's worth,
counted and nowhere. It now leaves as a last, smaller particle where
the liquid last came in, and lands as the amount it is.
Only the liquids in the glasses stood in the mirror; a stream poured
over the bench had no reflection. Each stream, drop and puddle node now
has one sharing its mesh. Drops are sized and centred by the liquid
each particle carries.
The light a glass or a liquid lets through to its shadow was read from
the shadow atlas in one tap, relying on a bilinear step the atlas does
not get, so a translucent shadow's edge stepped a texel at a time. It is
now averaged over the same 3x3 texels as the opaque edge, in the GLSL
and in the CPU port.
The clean tube's liquid material was made with the first drops, where
its round bottom is nearly no width, and never remade, so what was
poured into it showed a fraction of its colour. It is remade whenever
the width at the level changes. A vessel emptied and filled again now
fills with what it last held rather than plain water. The bench's sun
has its quarter-degree size, so shadow edges get their penumbra instead
of stepping along the map's texels.
A pour into a tube spent most of its time working out the surface's
modes again: each time the level rose half a cell, conjugate gradients
solved the same Laplacian afresh, seventy milliseconds a cross-section.
The modes are now cached by the cells a cross-section takes, so a
straight-sided glass solves once, and the Laplacian is factored once as
a band, three milliseconds a solve. Drops and streams look only at the
walls they can reach, and the drops' pair loops no longer make an object
for every difference, which had the collector pausing whole frames. A
four-second pour costs a tenth of the CPU it did.
A FluidWorld now has an Atmosphere, room air by default, its density,
viscosity and vapour diffusivity worked out from temperature, pressure
and humidity. Drops fall against a sphere's drag at their own size,
streams bend in a crosswind, and open liquid evaporates: a tube up
Stefan's column and out of its mouth, a puddle by Hu and Larson or a
flat plate's boundary layer in a wind, a drop by Ranz and Marshall.
Only the solvent leaves, so solutions grow stronger, and the vapour is
kept in the world's accounts. Only water has a vapour curve so far.
Pipes between vessels now lose to Darcy friction: 64/Re laminar,
Haaland's form of Colebrook turbulent for the bore's roughness, and a
straight line through the transition. A stream leaving a lip above a
Reynolds number of 2300 parts at Grant and Middleman's turbulent
intact length rather than its laminar one.
Walls now say what they are made of: a SolidSurface with water's contact
angle and its hysteresis. A drop stays where it is while its weight along
the wall is no more than Furmidge's sigma*w*(cos receding - cos advancing)
across the circle it wets, and slides when it is: a microlitre hangs on a
pane, twenty run down. Puddles take their surface's angle, so water beads
on the bench's lacquer and spreads on glass.
A floating body's spin was never touched by the liquid round it, and a
ball spun in glycerol spun on for ever. The part under the surface now
feels 8*pi*mu*R^3*omega on the sphere of its volume, taken implicitly
about the body's own axes.
A FluidWorld can now take a GravityField, a uniform part and any number
of attractors pulling as mu/r^2 outside and as a uniform ball inside.
Each vessel levels across the gravity where it stands, and each drop and
parcel of a stream falls by the gravity at its own place.
…ffers

The loops over neighbour pairs in a Position Based Fluids substep, density,
normals, forces, the density constraint and XSPH, now live behind
PbfKernels and work on Float64List positions and velocities and
compressed neighbour rows, so another implementation can take them as
they are. The Dart one is the reference and adds in the same order as
before: a run of drops with walls and wind comes out bit for bit the
same. Walls, receivers and drops stay with ParticleFluid between calls.
flutter3d_physics now builds src/pbf_kernels.c with a build hook and binds
it through @Native, passing the fluid's own buffers by address. A fluid
made with native: true, or a FluidWorld with nativeKernels: true, runs its
pair loops and its neighbour search in C, summing two neighbours at a time
with the compiler's vector extensions; held to the Dart kernels within a
part in 10^12 per kernel. Without a compiler the hook builds nothing and
the fluid runs on Dart, as it does on the web. The neighbour search is
now a sort by cell instead of a map of lists, in Dart too, with the same
rows in the same order, so the Dart path steps exactly as before.
A drop in flight does not feel its own weight, and the air breaks it up
only past an aerodynamic Weber number of about twelve, which drops off a
pour are a fifteenth of. So a drop is now one body: a sphere's drag and
evaporation, Furmidge's hold or slide on a wall, and drops that touch run
together. A drop wider than the capillary length spreads as particles
where it meets a wall, and particles that leave every wall gather into a
drop again. A free block of particles used to ring until it flew apart;
a pour now flies ten bodies at most instead of thirty-eight, and costs
two fifths less.
Filling a tube up its round bottom wanted a new meniscus every other
step, and each took ten milliseconds: sixty halvings of a shot of 512
Runge-Kutta steps. The curvature is now found by Illinois false position
and the shot takes two steps a sample, which against a reference eight
times finer is as close as before, at 1.5 ms. Menisci are shared by
every vessel of the same liquid and gravity, and a stream asks only the
receivers its way passes near. A four-second pour, its caches warm, now
steps in 0.3 s with no frame over 14 ms.
The tolerance's power of two came from math.pow, which a step may not
ask the platform for; it is now the exact constant. The bench's golden
moves by one pixel with the new meniscus solve.
A FluidWorld made with background: true hands what would stop a frame,
the modes of a cross-section not met before and a meniscus not solved
before, to a long-lived isolate, and the vessel carries on its old grid
or the nearest meniscus until the answer comes. The answers are the
same numbers, checked to the bit; only when they arrive differs, so it
is off unless asked for. On the web there are no isolates and nothing
changes. The bench asks for it and for the native kernels: the first
pour of a session no longer has frames of 40 ms.
A world with nativeKernels: true now runs the heavy half of a
cross-section's mode solve in C too: the banded Cholesky factor and the
Lanczos steps with full reorthogonalisation, a millisecond where Dart
takes three or four. The tridiagonal is finished in Dart from the same
start vector. Native and Dart modes are cached apart, so a world that
must replay never reads the other's, and the background isolate solves
natively when asked to.
TubeMeniscus takes native: true and runs the same Illinois search over
Runge-Kutta shots in C, on the platform's sine and cosine. It agrees
with the Dart solve to about 1e-16 and takes 0.34 ms where Dart takes
0.56. A world with nativeKernels sets it on its vessels; native and
Dart menisci are cached apart, and the background isolate solves the
kind it is asked for.
The native neighbour search read each candidate's position through its
index, nearly always a cache miss, and with one search per cell. It now
copies the positions out in sorted order and finds each row of three
cells with one search. Rows and their order are unchanged. On 576
particles on a pane the search takes half the time it did, and a step
4.3 ms instead of 5.4.
Finding which particles make one drop walked each union-find chain to
its end, and blocks of particles on a wall made chains hundreds long.
The three copies of it are now one helper that halves the path on the
way; roots, and so every result, are the same. The Dart neighbour
search reads candidates in cell order and finds each row of three cells
with one search, as the native one does, with the same rows. A pour
replays bit for bit against the previous code. On 576 particles on a
pane a Dart step takes 7.3 ms instead of 8.3, a native one 3.4 instead
of 4.3.
The liquids page now says that nativeKernels also runs the surface mode
solve and the meniscus solve natively.
All six native pair loops now come from one source, pbf_lanes.h, built
at two, four and eight lanes. Which one runs is chosen on first use by
what the processor reports: AVX-512F takes eight, AVX2 four, SSE2 and
NEON two. The hook builds on the machine that builds the app, so the
wider widths are compiled with a target attribute and only called where
cpuid allows; nothing is built for the build machine's own processor.
No fused multiply-adds, so each lane rounds as the scalar loop does.

Every width agrees with the scalar loop to about 1e-15, checked on arm64
and on x86-64 under Rosetta with AVX2. A new CI job builds the check
with gcc and clang on an x86-64 runner and times each width there. On
arm64, where two lanes measured fastest, a PBF step on 576 particles
takes 3.1 ms instead of 3.4.
The CI runner, which has AVX-512F, measured eight lanes as fast as four
with gcc and slower with clang: the native loops wait on gathering each
neighbour's numbers, not on arithmetic. Four lanes stay the default
wherever AVX2 is; eight remain for a test to ask for.
On the web there is neither dart:ffi nor a second isolate, and the code
rightly works everything out in Dart at once there; the tests that check
the native kernels and the background isolate now say @teston('vm').
The width test's name now carries the width instead of a literal
$lanes.
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