Skip to content

Neural particle systems

Give a particle system a picture to make, and let a small neural network learn how to move the particles into place. Here it has assembled 300,000 particles into the word FIELD:

The word FIELD drawn in warm gold particles on black, the letter outlines bright and their interiors a dimmer haze

Three hundred thousand particles, each asking a small neural network where to go next, every frame. The rule that assembles them into that word was fitted in about a second of Kotlin and then compiled into a shader.

A particle system is normally something you write: a few lines of arithmetic, run over a million points. That works when you know the arithmetic. When what you have is a shape you want the cloud to become, or a behaviour you can recognise but not write down, the usual answer is tuning coefficients until the picture looks right, which is slow, hard to repeat, and gets harder with every parameter you add.

Fit the rule instead when:

  • You can state the destination but not the route. "Become these letters" is easy to specify and hard to hand-write as a velocity field. Training takes a second and the answer runs live.
  • You have a rule that is expensive, and want a cheap one that behaves like it. A learned rule costs the same every frame no matter how intricate the thing it imitates.
  • The target changes and you do not want to re-derive anything. Retraining is a couple of seconds and swaps a buffer; the shader is never recompiled. In the first scene below the target is a string you can edit while it runs.

A trained rule that looks convincing is easy to get: almost any attraction-and-repulsion rule with a speed limit produces something an audience will accept as flocking. So the learned rules here are compared against a written rule that does the same job.

The first section covers the part in between, which you can use on its own: turning a network trained in Kotlin into a GLSL forward pass that runs once per particle.

Particle rules that were trained

There are two kinds of rule a particle can follow. The first reads only its own state: a velocity field v(x, t), blind to every other particle in the buffer. The second reads its neighbours: a force that depends on the particles nearby.

Field fits either one. A velocity field moves particles toward a target shape; an interaction produces forces from their neighbours, making flocks, swarms and crowds. The latter needs a neighbour search to run efficiently.

The two are built in that order below: tier one is sections 1 to 3, the neighbour search is section 4, and tier two is sections 5 to 7.

Source files

file what is in it
afield/simulation/particles/neuralparticles.kt MlpWeights, MlpWeights.glsl, FlowMatchExport, blendWith, MlpAgreement — the network-to-shader bridge
afield/simulation/particles/neuralparticles_system.kt ShaderNeuralParticles, NeuralParticleSettings — tier one, as a running system
afield/ai/learning/flowmatching.kt flowMatch, contourComponents, mixtureSource — the CPU half, shared with Flow matching
afield/ai/learning/deepsets.kt trainDeepSets, trainEquivariant, and their GLSL emitters — tier two's learned rule
afield/graphics/vulkan/util/GpuNeighbourGrid.kt hash, sort, ranges: the neighbour search both flocks share
afield/simulation/physics/ShaderBoids.kt ShaderBoids, BoidSettings: the written rule and the learned one, in one system

Four transcripts open as complete documents from Agent transcripts or the welcome screen: neural_particles, learned_boids, letter_interpolation and letter_interpolation_panels. Everything below is reachable from one of them, and they are the fastest way in: open one, edit a number, watch it change.


1. A trained field, running as a shader

Flow matching fits v(x, t) by least squares in about a second of pure Kotlin. Three calls take that network to the GPU:

val field = flowMatch(pairing.to, pairing.from, FlowMatchOptions(coupling = FlowCoupling.PAIRED))
val export = field.export() // weights, training frame, source cloud
val glsl = export.glsl("neuralField") // place this immediately after #version

export() is a snapshot: the weights flat and layer-major, plus the centring and scaling the network was trained in, plus the source cloud so the system knows where particles start. glsl(name) emits an unrolled forward pass that reads those weights out of a storage buffer rather than baking them in as literals, so retraining swaps a buffer between frames and never recompiles a shader. The layer loop is unrolled and the dot products are not, so the emitted source stays proportional to the depth of the network: a five-thousand-parameter net is still readable GLSL.

ShaderNeuralParticles is the system that runs it:

val particles = ShaderNeuralParticles(export) {
    particleCount = 300_000
    stepsPerCycle = 150
    dwellCycles = 3f // how long a particle rests on the target once it arrives
}
particles.installInto() // defaults to renderer.renderList

Each particle is a struct rather than a texel, because of the two unusual fields:

struct Particle {
    vec2 position;
    vec2 start;
    vec2 velocity;
    float t;
    float pad;
};

A flow is a map. Each particle travels from one specific source point to wherever the field takes it by t = 1, and when it arrives it goes back and does it again. start and t are what make that possible, and neither fits in a four-channel texel.

One forward pass per particle per frame. The default flow-matching network is 11 -> 64 -> 64 -> 2, about five thousand multiply-adds, so two hundred thousand particles is roughly a GFLOP a frame. Width is a cheaper dial than count: a 32 x 32 network is four times less work, and a 2D flow rarely needs 64.

When the picture is a haze

If the picture is a haze, reach for dwellCycles first. A particle that resets the instant it reaches t = 1 is on the target for exactly one frame out of stepsPerCycle, so with phases spread out only a hundred and fiftieth of the cloud is ever on the shape, and the rest, still in transit, drown it. At 0f a correctly trained 300,000-particle system renders a featureless smudge that looks exactly like an undertrained field. At the default of 3f a particle spends three quarters of its life arrived.

Set it to 0f to see the flow alone, or very high to see only the distribution.


2. Making your own target

The image at the top of this page is the word FIELD. Getting a word rather than a single letter needs one piece of work that has nothing to do with the GPU, and it is the piece you will need for any target that comes in more than one part.

Integrating a velocity field is a continuous invertible map. It can deform a blob but it cannot tear one into five, so a standard Gaussian source cannot become the five disjoint outlines of a word, however long you train and however big the network. mixtureSource fixes it by giving the source the target's topology: one ellipse per closed subpath, fitted to that subpath's own mean and covariance, in point-for-point correspondence with the contour it has to become.

val contours = layoutText("FIELD", typeface("Helvetica", 260.0)).toFLines()
val pairing = mixtureSource(contourComponents(contours, total = 12000), seed = 5)
val field = flowMatch(pairing.to, pairing.from, FlowMatchOptions(coupling = FlowCoupling.PAIRED))

contourComponents splits the outlines into closed subpaths and samples each in proportion to its length; mixtureSource fits an ellipse to each and pairs it, point for point, with its contour. FlowCoupling.PAIRED is what tells flowMatch to respect that pairing instead of drawing its own.

Here is that construction on its own, on the CPU: the source it builds, drawn against the target it has to reach.

:::static
val contours = layoutText("Fi", typeface("Helvetica", 300.0)).toFLines()
val components = contourComponents(contours, total = 2400, seed = 7)
val pairing = mixtureSource(components, seed = 5)

fun cloud(points: List<Vec3>, size: Double, c: Vec4): FLine {
    val f = FLine()
    points.forEach { f.moveTo(it) }
    f.pointed = true
    f.pointSize = size
    f.color = c
    return f
}

// Every 100th correspondence, to show the pairing is point-for-point and not a cloud match.
val threads = FLine()
for (i in pairing.from.indices step 100) {
    threads.moveTo(pairing.from[i])
    threads.lineTo(pairing.to[i])
}
threads.color = vec(1.0, 0.62, 0.25, 0.75)
threads.thickness = 1.2
:::fold draw the source, the target and the pairing
lines(
    [
        cloud(pairing.to, 2.0, vec(0.55, 0.55, 0.62, 0.9)),
        cloud(pairing.from, 1.6, vec(0.44, 0.83, 0.78, 1.0)),
        threads,
    ],
    w = 1000.0,
    h = 450.0,
    fitToSize = true,
)
markdown(
    "what",
    """
    ~~~
closed subpaths found   ${components.size}
points per component    ${components.map { it.size }.joinToString(", ")}
paired points           ${pairing.from.size}
    ~~~
""",
)
:::endfold

The letters Fi in grey outline on black, with a green ellipse fitted over each of the three closed subpaths — a large one across the F, a small circle on the dot of the i, a narrow one down its stem — and thin orange threads running from ellipse to outline showing the point-for-point pairing; below, a table reading closed subpaths found 3, points per component 1583, 623, 193, paired points 2399

Grey is the target, the letter outlines. Teal is the source the flow starts from: one ellipse per closed subpath, so the "i" arrives as two components, its stem and its dot, each with its own ellipse. A letter with a counter (a "D", an "e") would contribute two as well, outer and inner. Orange threads are the correspondence, every hundredth pair. The flow's whole job is to shorten those threads.

Three choices inside that construction have defaults you should keep:

what if you turn it off why
subpaths, not glyphs contourComponents given whole letters a "D" becomes one annulus, and its counter has to be torn open
alignPhase = true identity pairing a contour's arc-length origin has nothing to do with angle zero on its ellipse, so every component carries an arbitrary rotation the flow must unwind
no jitter on the source jitter above zero the ellipse thickens to an annulus and the correspondence blurs

alignPhase is the least obvious of the three and changes the result most. To see it, open the neural_particles scene, set it to false, retrain, and watch the letters arrive twisted.

Almost every setting in that scene is live, including the text. Retraining takes a couple of seconds.


3. Between two trained fields

Train a second field, from the same source, and you can interpolate the weights:

val blended = fieldA.blendWith(fieldB, t) // t from 0 to 1
particles.updateWeights(blended.network) // a buffer write; nothing recompiles

Interpolating the targets, sending point i to \( (1-t)\,A_i + t\,B_i \), is a straight-line crossfade that needs no network. Interpolating the weights gives a field that was never trained on anything, and where it sends a particle is a nonlinear function of t.

The two give very different results. Measured at t = 0.5, the weight-space landing sits 0.85 of the whole width of the morph from where the crossfade would put it — nearly as far as the two letters are from each other.

Three particle glyphs in a row on black, each a rough ring drawn in warm gold: the first with a bulge on its right, the second a closed lumpy ring, the third with a deep S-shaped buckle pressing in from the right

Those are t = 0.25, 0.5 and 0.78 between a "C" and an "S", and none of them is a C, an S, or a blend of the two. The outer two show a ring with the S's spine pressing into it, before it has finished being a ring.

The interpolation seals the C into a closed ring and re-opens it the other way; it never slides the C's mouth shut. The middle panel is that ring, and it is the tidiest shape in the sequence: walked in source order, its landing cloud measures about 0.6 of a letter's perimeter, shorter than either endpoint, because a circle is the shortest closed curve there is.

What blendWith needs from the two fields

The same source cloud. Both fields must be trained from one shared cloud, so a particle has one start point and two destinations. That is also what makes the two normalisation frames agree, and the API enforces it: blendWith refuses to blend fields normalised differently, because t = 1 meaning "B's weights read in A's coordinates" is a different field from B and would look exactly like an interesting result.

The seed matters less than you would expect. Hidden units are exchangeable, so two runs from different random starts could represent similar functions through different permutations of the same machinery, and averaging across a permutation would produce nothing. For letters trained with PAIRED coupling from a shared source, it does not happen: midpoints of fields trained from different seeds are as tidy as those from one seed, because the two training problems are nearly the same problem. blendWith does not check seeds. For a wider network, harder targets or another coupling, train both with the same seed to be safe.

The letter_interpolation starting point sweeps t slowly and continuously, forty seconds there and back, and letter_interpolation_panels is the still above. Both need letters that are a single closed contour in the typeface: C, S, E, F, L, T, V, Z and so on. An O or a D has two, and one circle cannot be paired with two contours at once.


4. The neighbour grid

Tier two needs a sum over nearby particles, which needs a sort. GpuNeighbourGrid is that, and it is useful on its own for anything that needs neighbours on the GPU:

val grid = GpuNeighbourGrid(
    capacity = 12_000,
    cellsPerSide = 32, // cellSize = domainSize / cellsPerSide = 0.0625
    domainMin = Vector2f(-1f, -1f),
    domainSize = 2f,
)

// Positions are read as a flat float array: particle i's x is at i * strideFloats + offsetFloats,
// so the grid never needs to know what else is in your struct.
grid.install(renderer.renderList, myParticles, count, strideFloats = 8, offsetFloats = 0)

ShaderBoids calls install for you; you need it only when putting the grid under something of your own.

Four steps, each avoiding something a compute shader cannot cheaply do:

  1. hash: one invocation per particle writes key = cell index, value = particle index.
  2. sort: GpuRadixSort sorts the pairs, so a cell's members become a contiguous run. This turns a scatter problem into a gather problem.
  3. ranges: one invocation per sorted entry compares its key with its neighbours' and writes each run's bounds. No atomics: an entry can tell whether it begins a run by looking left.
  4. read: a consumer walks the 3x3 block of cells around its own.

Cells are row-major rather than Morton-ordered. A hierarchy needs a node's children contiguous; a flat grid needs a cell's members contiguous, and row-major does that while making the nine indices of a 3x3 neighbourhood plain arithmetic instead of a bit interleave.

The 3x3 block covers one cell width, so cellSize (domainSize / cellsPerSide) is the largest radius the query answers correctly. install refuses a larger radius, and one raised past it later is held to cellSize with a warning in the log. Size the grid from the radius, not from the particle count.

Particles outside the domain are clamped into the edge cells rather than dropped, because a lost particle is a particle that silently stops interacting.

The whole thing installs as one entry rather than four, because ComputeTarget.render runs every module before any other consumer, so four separate installs would interleave. If you build something like it, install once rather than once per module.


5. Boids on the grid

ShaderBoids runs classical Reynolds boids, with its three terms, on top of that grid:

val written = ShaderBoids(grid, count = 12_000) {
    containment = 1.5f
    containmentRadius = 0.44f // a soft wall, applied after the rule
    driverStrength = -0.9f // negative: flee the driver
}
written.installDrawInto() // defaults to renderer.renderList

The default weights flock at this density and radius = 2f / 64f. They are far from the textbook 0.02 / 1.2 / 0.8, which gives an evenly spaced gas here, because separation sums over neighbours while alignment and cohesion take means. In this scene a boid has about 57 neighbours, and the textbook numbers put separation 27x above alignment and 4600x above cohesion. If you change radius or the particle count, retune all three. Bring the three terms to the same order rather than adjusting the weights until it looks right.

separationRadiusFraction is the other dial that decides the shape. One radius for all three terms cannot clump: separation wants to be short-range while alignment and cohesion want the whole neighbourhood. Shrinking separation's reach to a quarter turns one even disc into groups.

Two flocks of 12,000 at the default weights, with nothing driving them, after a few seconds: separationRadiusFraction = 1 on the left spreads into one wide, evenly spaced disc that overruns its soft wall; 0.25 on the right packs into a dense, grainy clump. Undriven, this run did not split the clump into separate groups.

Two flocks of points on black: lower left a large, evenly spaced disc of faint points; upper right, overlapping its edge, a smaller, much denser and grainier oval clump

ShaderBoids works on its own as a fast flocking system. Its force also generates tier two's training data and is what the learned rule is compared against, which is why it is a written rule rather than recorded motion: recorded motion would leave nothing exact to compare against.


6. A learned interaction

A velocity field is blind to the other particles. An interaction reads them, and Field fits one with Deep Sets, a network that takes a set of neighbours of any size:

a_i = phi( v_i, SUM_j psi(x_j - x_i, v_j - v_i), MEAN_j psi(...) )

Two small networks and a reduction. The aggregation is permutation-invariant and takes any number of neighbours, so one set of weights serves a particle with three neighbours and a particle with three hundred. Classical Reynolds is one particular choice of psi and phi, so the family contains at least one good answer.

Both the sum and the mean are fed to phi, because one reduction is not enough. Separation is a sum (being crowded by twenty neighbours pushes harder than by two) while alignment and cohesion are means. Feeding both is worth a factor of four.

Training is one call. You supply a batch of InteractionExample, each a velocity, a list of neighbours, and the acceleration the rule you are imitating produced. Because you have the rule, no simulation is needed to generate them:

fun sample(): InteractionExample {
    val v = /* a random velocity */
    val neighbours = /* up to 70 of them, as (dx, dy, dvx, dvy) */
    return InteractionExample(v, neighbours, reynoldsForce(v, neighbours))
}

val learnedRule = trainEquivariant(
    radius = radius, velocityScale = velocityScale,
    accelScale = 20f, sumScale = 8f, minSeparation = minSeparation,
    options = DeepSetsOptions(steps = 4000, batch = 16, seed = 5,
                              psiHidden = intArrayOf(8, 8), messageWidth = 4),
) { List(16) { sample() } }

val learned = ShaderBoids(grid, count = 12_000, equivariant = learnedRule) { /* same settings */ }

learned_boids has this written out in full, with the Reynolds force inline.

Train on the density you will run at

Draw up to seventy neighbours, not a handful. A flock runs at around fifty-seven neighbours per boid once cohesion has gathered it, and a model trained only on sparse neighbourhoods is extrapolating when it runs. That hurts here, because cohesion is weighted against the mean offset, so a small error in the meaned branch becomes a large force.

trainDeepSets and trainEquivariant fit by reverse-mode autodiff over ADTape, because Smile's MLP cannot express a gradient flowing back through a sum over a variable-length set with shared weights. Both halves of either form come out as ordinary MlpWeights, so the GLSL emitter from section 1 applies to them unchanged.

Prefer trainEquivariant. The true rule satisfies a(R x) = R a(x) identically, and nothing in the plain Deep Sets form makes it do so: a network with scalar weights on Cartesian components has a preferred direction baked in by its initialisation. The symptom is a flock that drifts steadily in one direction, and the residual grows with crowding because it rides on the aggregated message. EquivariantWeights removes it by construction: psi sees only invariants of a neighbour and emits scalars, the vectors come from the neighbour's own direction and velocity difference, and phi sees only the lengths of the aggregates. Rotate every input and nothing any network sees changes. Constraining it this way also improved the fit, which is the usual result when a symmetry is real.

ShaderBoids runs either rule, branching only where the force is computed. The buffer, neighbour search, integration and speed limit are shared, so two flocks side by side differ only in the rule:

Two flocks of points on black, each a sweeping arc: on the left a blue-grey flock, on the right a warm-toned one, both curled around the wandering point they are fleeing

Left is the written rule, right the learned one, twelve thousand boids each, seeded from the same random draw into their own halves of the domain, both fleeing a point that wanders on the same smooth random walk about each flock's own centre.

The learned force has a relative error of about a quarter. That gives a flock that behaves like boids, and over ten thousand chaotic steps the two flocks never look the same.

Retrain after editing the written rule

The learned flock follows the rule as it was when you trained it. Re-run the training box to catch it up; it takes a few seconds. Leaving the two out of step on purpose is how you see how far they can be pulled apart.

Cost

Cost is per neighbour, not per particle, and the learned rule pays a psi forward pass for each one. Once cohesion has gathered the flock, a boid can have a hundred or more neighbours inside its radius. At 12,000 boids a flock:

Configuration frames per second
the written rule alone 116
a 16x16 psi, message width 6 17.6
an 8x8 psi, message width 4 71.2
the equivariant 8x8, both flocks, driven 77

The written flock runs at the same speed on the same grid whatever psi is doing, so the cost is in psi, and tuning cells or particle counts will not help. Keep the depth: a single hidden layer of 16 costs more than two of 8 and fits worse than either.


7. The driver

With the symmetry built in and no external forcing, the flock settles into something that reads as static: an evenly spaced disc that barely changes. That is what the rule does on its own here; the drift of the plain Deep Sets form was what made it look lively. To get motion, add it from outside the interaction:

boids.settings.driverStrength = -1.5f // negative flees, positive pursues
boids.settings.driverRadius = 0.25f
boids.driver = {
    wanderingPoint()
} // read once a frame

driverStrength adds a pull toward, or a push away from, a point you supply each frame. It is applied after the rule exactly like containment, defaults to zero, and the network knows nothing about it, so two flocks side by side still differ only in their rule. No retraining is needed to add or change it.


Settings

NeuralParticleSettings — tier one:

Setting Default Effect
particleCount 200,000 Sets the allocation and dispatch size at construction; it cannot be increased on a live system. A few hundred thousand particles is a useful starting scale.
stepsPerCycle 150 Sets frames per source-to-target journey, coupling integrator accuracy to animation speed. Below roughly 60 steps, the target can visibly lose corners.
dwellCycles 3 Holds particles at the target for this multiple of travel time. At three, particles spend three quarters of their cycle on the target.
phaseSpread 1 Spreads initial phases over this fraction of a cycle: one gives a continuous stream and zero a synchronized morph. Call reset() after changing it.
arrivalBias 0.8 Weights brightness toward the end of the journey, from zero to one. Raise it to emphasize the learned target over travelling particles.
sourceJitter 0.02 Adds spread around each source point during reset. Call reset() after changing it; excessive jitter blurs the source correspondence.
randomSeed 1 Controls initial source assignments and particle phases. Reset with the same seed to repeat the initialization.
viewScale, viewCenter 2.6, origin Frame the cloud in normalized training coordinates. Scale controls the visible extent and centre controls the point the view is centred on.
flipY true Flips the displayed vertical axis. The default accommodates glyph contours, whose y coordinate increases downward.
pointSize 1.5f Sets the drawn point size in pixels. This changes the appearance without changing the simulated positions.
sourceColor, targetColor (0.15, 0.45, 1, 1), (1, 0.62, 0.22, 1) Sets RGBA colours at the beginning and end of each journey. The shader blends between them as the particle travels.
intensity 0.5f Scales brightness under additive blending. Use it as exposure when overlapping particles make the cloud too bright.

BoidSettings — tier two. radius must not exceed the grid's cellSize:

Setting Default Effect
radius 2/64 Sets the neighbour interaction radius in simulation coordinates. It must not exceed the spatial grid’s cell size, or the query would miss neighbours.
separation, alignment, cohesion 0.02, 3, 200 Weights repulsion, velocity matching and attraction to neighbours’ centre respectively. The defaults flock at about 12,000 boids over a unit square at the default radius; their balance depends on density, so retune them if you change either.
separationRadiusFraction 0.25 Sets separation’s reach as a fraction of radius. Values below one let longer-range cohesion gather clumps without equally long-range repulsion.
minSeparationFraction 0.05 Floors pair distance in the separation denominator at this fraction of radius. This keeps coincident or nearly coincident neighbours from producing unbounded force.
maxSpeed 0.25 Caps speed after acceleration is applied. This keeps one strong interaction from sending a particle arbitrarily far in the next step.
dt 1/60 Sets the fixed integration time step. Set zero to calculate accelerations without moving the particles.
containment, containmentRadius, containmentCenter 0, 0.8, origin Adds a soft restoring force outside a circle centred on containmentCenter. Strength zero disables it; the radius sets where the force begins.
driverStrength, driverRadius 0, 0.25 Controls the moving driver’s force and influence radius. Positive strength attracts and negative strength repels; zero disables the driver.
pointSize 2f Sets the drawn point size in pixels. This changes the appearance without changing the simulated positions.
slowColor, fastColor (0.16, 0.40, 0.95, 1), (1, 0.75, 0.25, 1) Sets the RGBA colours at the low and high ends of the speed tint. This lets the drawing reveal velocity changes without a CPU readback.
intensity 0.9f Scales brightness under additive blending. Use it as exposure when overlapping particles make the cloud too bright.

Agents: smoke tests

The four GPU smokes that check this machinery, and what each compares, are listed in dev_docs/NEURAL_PARTICLES_MEASUREMENTS.md.

Limits

  • The learned rule cannot predict the equilibrium residual. In a settled flock, separation and cohesion are large and nearly cancelling, and what survives is a small difference of big terms. Scored on a settled flock, the learned rule does only a little better than predicting exactly zero, and its forces come out about half the size they should be. On scattered neighbourhoods like the ones it was trained on, it is far closer.
  • A flock that behaves like boids is not boids, and the picture cannot tell you how close the learned rule is. Compare its forces against the written rule instead.

See also

Flow matching is the CPU half of tier one and worth reading first. Field 4 Graphics is the compute-then-draw shape both systems are built on. The API is in Machine learning and Simulation.

Where the numbers come from

The measurements quoted on this page are in dev_docs/NEURAL_PARTICLES_MEASUREMENTS.md. The figures are screenshots of the live system, because the native window capture takes stills only.