Building a fake microscope: simulating a yeast wet mount
[latexpage] Repository: github.com/lynchaos/generative-microscopy Why this sketch I wanted to find out whether I could make something that genuinely looked like microscopy footage. Not cells bouncing around a canvas, but Saccharomyces cerevisiae behaving the way it actually behaves under a coverslip, seen through real glass and real light. The sketch began life as a generic n-body...
Repository: github.com/lynchaos/generative-microscopy
Why this sketch
I wanted to find out whether I could make something that genuinely looked like microscopy footage. Not cells bouncing around a canvas, but Saccharomyces cerevisiae behaving the way it actually behaves under a coverslip, seen through real glass and real light.
The sketch began life as a generic n-body attraction and repulsion toy. Pretty, but weightless. The moment it came alive was the moment I stopped tuning numbers until they looked nice and started asking a different question about every single force in the loop: what does physics say this should be?
The answer, over and over, was that physics had already solved it decades ago. Every equation I reached for was one I already knew from bioreactor work. I just had to let it run at sixty frames per second and watch it breathe.
The moment it clicked: inertia does not exist down there
Start with the Langevin equation for a sphere of mass and radius in a fluid of viscosity :
The drag coefficient is Stokes drag, and the ratio is the momentum relaxation time: how long a cell coasts after you stop pushing it.
Put numbers in. A yeast cell of radius and density has mass
and in water, with ,
so
One and a half microseconds. A single animation frame is 16 milliseconds, roughly ten thousand relaxation times long. The coasting distance is even more absurd: at a speed of , a cell glides metres before stopping dead. That is smaller than a hydrogen atom.
The Reynolds number says the same thing from the other direction:
So the inertial term is not small. It is irrelevant. Delete it and the Langevin equation collapses to the overdamped limit:
Velocity proportional to force, not acceleration. Which in code is this, and only this:
vx[i] = (vx[i] + fx) * 0.62;
One line. One multiply. Look at what that discrete map actually does. It is a geometric series with ratio , so a constant force settles to
with memory decaying over frames. Two frames of memory in a system whose true memory is 1.5 microseconds. Close enough to zero that no eye can tell, and it keeps the numerics beautifully stable.
I changed that line, hit Run, and the entire field of view suddenly had viscosity. Nothing else about the sketch had changed. The cells just knew where they lived now.
That is what I love about this kind of coding. Encode one physical truth correctly and a hundred emergent details you never programmed come along for free.
Brownian motion, and why small cells shiver
Stokes-Einstein connects diffusion to drag through nothing but temperature:
At we have , so for the same cell,
In two dimensions the root mean square displacement is , which over one minute gives
A yeast cell wanders roughly one cell diameter per minute purely from being bombarded by water molecules. That is the entire reason a wet mount never sits still, and it falls out of thermodynamics with not one fitted parameter.
Now the part I find genuinely delightful. Since , the per-step displacement scales as
which in the sketch is one term:
float br = 0.42 / sqrt(rad[i]);
A freshly separated daughter at against a mature mother at gives a jiggle ratio of . The small ones shiver 37% harder, permanently, without me ever writing a rule that says so. Nobody watching can articulate why it looks correct. Everybody feels it.
Contact mechanics, jamming, and growth that stops on its own
Cells are rigid and do not interpenetrate. The soft-sphere answer is a Hookean penalty force on the overlap :
Stability in the overdamped map needs the effective per-step correction to stay below 2, otherwise a contacting pair overshoots and rings. Here it is . Comfortably stable, and I got to reason about it once instead of discovering it through explosions.
Then comes my favourite accident in the whole sketch. Contact count is now a free variable, and for frictionless disks in two dimensions the isostatic condition fixes the mean contact number at jamming:
So a fully jammed cell has about four touching neighbours. And the growth term reads:
float crowd = constrain(1 - touch * 0.16, 0.12, 1);
At that is . Growth falls to roughly a third of maximum exactly when the packing jams. I did not design that. It dropped out of the geometry, and I only noticed afterwards.
Layer global substrate limitation on top and the population obeys a spatially resolved logistic law:
which is Monod kinetics and Verhulst’s logistic equation shaking hands. The field fills, exponential phase rolls smoothly into stationary, and the growth curve you would plot from the on-screen count is the same sigmoid I stare at in fermentation data all week. Watching it emerge from a particle simulation instead of an ODE solver is a small and completely real joy.
Flocculation is a logarithmic potential
Roughly 70% of the population carries FLO1 and bridges to other flocculent cells through calcium-dependent lectin binding. The rest stay stubbornly dispersed. One boolean per cell, and a uniform blob becomes a field with social structure.
The force law matters enormously here:
float f = 6.0 / d;
An inverse-first-power force means a logarithmic potential, . Compare gravity’s , whose potential diverges hard at short range and drags everything into a singular clump. The logarithm is gentle. Its restoring force grows slowly, so it binds aggregates without ever collapsing them, and the contact force takes over long before anything pathological happens.
The result is flocs that form, recruit neighbours, jostle, and hold a stable open packing, exactly like a real flocculating brewing strain. It works because the rule in the code is the real rule, not a fudge factor.
The optics deserve equal billing
Here is what surprised me most, and it is worth saying loudly: biologically perfect motion still looks fake without the optics.
Take the objective in the HUD, 40x at . Abbe’s diffraction limit at :
and the depth of field:
Read that second number again. The depth of field is 1.3 micrometres and the cell is 5 micrometres thick. A real yeast cell is physically incapable of being entirely in focus at once. The blur is not a limitation of my renderer, it is a property of light, and simulating it is what makes the image honest.
So every cell carries a Perlin-driven that drifts it slowly through the focal plane, and the defocus halo is stacked translucent shells standing in for a proper point spread function. Add Köhler illumination unevenness, a field stop vignette, and sensor grain jittered a few pixels each frame to break temporal correlation. Photon shot noise is Poisson, so signal to noise goes as , which is why the grain reads as photon-limited rather than as static.
The dark outline around each cell earns its own sentence, because it is not a stain. It is a refractive index mismatch between cytoplasm at and mounting medium at , bending light out of the collection cone at the steep edges of the sphere. That single mismatch is the entire reason unstained brightfield works at all.
One honest note on sampling. The sketch runs at 3.6 pixels per micrometre, while Nyquist against the Abbe limit wants about . So the render is slightly undersampled relative to a true 40x image. That is a fair trade for an 800 by 800 canvas, and it is exactly the compromise you make on a real camera anyway.
Composite all of it and the thing stops reading as a simulation and starts reading as footage. The physics earns your belief. The optics earn your eyes. You need both, and discovering that felt like being handed a second and entirely separate discipline to play in.
What fought back hardest
Budding. Making division look organic rather than mechanical took more iteration than every force calculation combined.
The trap is determinism. Fix the bud angle, the growth curve, and the mature size, and after three or four generations every cell on screen is a photocopy of every other cell. The eye catches it instantly. The fix is to make traits noisy and heritable:
rMax[k] = rMax[i] * random(0.94, 1.06);
Treat that as a multiplicative random walk down the lineage. After generations the log-size is a sum of independent draws, so by the central limit theorem the size distribution converges to log-normal with spread growing as . Which is precisely what real microbial size distributions look like, and it costs one line.
Daughters resemble their mothers without duplicating them. Bud sites rotate to a fresh angle after each division. Chitin scars accumulate on the mother, so replicative age becomes something you can literally count on screen, and since a real S. cerevisiae mother manages somewhere around 20 to 30 divisions before senescence, that scar count is a genuine clock.
One line, doing something quietly beautiful: inheritance, expressed as a single multiplication.
A note on cost
The interaction loop is , but with an early-out at before any square root gets taken. The interaction radius is fixed and so is the field area, so neighbours per cell saturate at a constant and the real scaling sits closer to with an unflattering constant. At it does not matter. At it will, and the fix is a uniform grid cell list that drops the neighbour search to genuinely linear. Future entry.
Why p5.js and Processing are such a gift
You can write every equation above in Python and get a number. You can write the same mathematics here and get a world, running live, sixty times a second, responding while you edit it.
There is no meaningful distance between “I wonder what happens if the flocculation constant doubles” and seeing the answer with your own eyes three seconds later. That feedback loop is the entire point. It turns equations from things you solve into things you inhabit.
Anyone who has ever stared at a Langevin equation and felt absolutely nothing should try plotting it as motion instead. It is the same mathematics wearing clothes you can finally see.
What is next
This is entry one in a growing collection of microscopy-style generative sketches, all living at github.com/lynchaos/generative-microscopy. See the roadmap in the README.
On the list:
- Phase contrast. Zernike’s insight that a phase plate converts optical path difference into amplitude, making transparent objects visible. The whole technique is interference, and the famous bright halo is an artefact of imperfect spatial filtering rather than a flaw in the specimen.
- DIC (differential interference contrast). A Wollaston prism shears two orthogonally polarised beams by less than the resolution limit, so the image renders the gradient of optical path length. Mathematically a directional derivative, visually a pseudo-3D relief.
- Fluorescence. Emission spectra, out-of-focus background bleed, and photobleaching as first-order decay, , so the field slowly fades while you watch.
Each one is a different optical physics problem, which is to say each one is a different excuse to learn something properly and turn it into light on a screen.
Notes to self
- [ ] Add a short screen recording or GIF to the main README once there is a second sketch to compare against.
- [ ] Factor the optics compositing (vignette, grain, defocus) into a shared include once sketch #2 exists. See roadmap.
- [ ] Replace the stacked-shell defocus with a real Gaussian PSF convolution and measure the frame cost.
- [ ] Uniform grid cell list for the neighbour search, so the population cap can go past 5000.
- [ ] Try a p5.js port so the sketch runs in a browser and can be embedded directly in a post.
Did you enjoy this article?
Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.