The engineering

One engine, three readings

Confluon keeps its simulation in a small Rust/WebAssembly core, then hands one compact state to a WebGPU renderer and a WebAudio instrument. The architecture is built around separation without disconnection: image, sound, and export are independent systems, but none may invent a second reality.

The whole system at a glance

The production site is a static application. There is no server-side simulation, streaming service, or account state. A browser downloads HTML, JavaScript, a small WebAssembly module, and an AudioWorklet used only when recording.

Input + URL seed · settings · gestures Rust / WebAssembly engine particles · metrics · formations Thin JS coordinator fixed step · frame · lifecycle WebGPU / Canvas the image WebAudio sound · WAV · video tap API
Simulation state has one owner. Rendering, audio, and capture consume it through a narrow boundary.

This split is a practical creative constraint. The renderer can be replaced without changing motion; the sound graph can be retuned without changing formation; and tests can run the core without a browser. At the same time, both outputs must read the same state rather than maintaining their own simulations.

A deliberately small Rust core

The Rust crate has only two runtime dependencies: wasm-bindgen for the JavaScript boundary and console_error_panic_hook for useful browser failures. Release builds use link-time optimisation and one code-generation unit. There is no graphics or audio dependency in Rust.

rustsrc/lib.rs — the public engine boundary
#[wasm_bindgen]
pub struct Engine {
    simulation: Simulation,
}

impl Engine {
    pub fn step(&mut self, dt: f32, x: f32, y: f32,
                strength: f32, twist: f32, settle: bool);
    pub fn snapshot(&self) -> Vec<f32>;
    pub fn metrics(&self) -> Vec<f32>;
    pub fn formations(&self) -> Vec<f32>;
}

Keeping the core ordinary Rust makes its important properties ordinary to test: identical seeds produce identical motion, particle count is conserved except when explicitly seeding, outputs stay finite and bounded, the neighbour grid agrees with a brute-force implementation, and gestures measurably alter the trajectory.

Fixed simulation time, fluid display time

A display may refresh at 60, 90, or 120 Hz; a backgrounded tab may miss frames entirely. None of those conditions should silently change the dynamics. The browser therefore accumulates elapsed wall time and advances the Rust engine in fixed 1/30-second steps:

jsweb/app.js — fixed-step frame coordination, abridged
accumulator += elapsed * settings.flow;

let steps = 0;
while (accumulator >= FIXED_STEP && steps < 2) {
  engine.step(FIXED_STEP, pointer.x, pointer.y,
              pointer.strength, pointer.twist, settle);
  accumulator -= FIXED_STEP;
  steps += 1;
}

renderer.render(engine.snapshot(), engine.metrics(), time);
audio.update(engine.metrics(), engine.formations());

Catch-up is capped at two steps per display frame. Under sustained overload the instrument slows rather than entering a spiral of ever more catch-up work. The Flow setting scales deterministic simulation time before it enters this accumulator; it never changes display cadence.

Finding neighbours without testing everyone

The expensive work is local: field, repulsion, ecology, and formation all depend on particles inside a finite sensing radius. A naïve implementation would still inspect every possible pair—more than eight million candidate pairs at the 4,096-particle cap—only to reject almost all of them.

Confluon divides its wrapped world into an 8 × 8 counting-sort grid. Cell boundaries are recorded in a flat prefix-sum array, and particle indices are written into one contiguous entry buffer. Because the cell width matches the largest support radius, a cell needs only itself and its adjacent cells.

A four-offset half stencil visits each neighbouring cell pair once. Pairs inside one cell use a triangular loop. Every accepted unordered particle pair is stored with its wrapped delta and distance, then reused by the field, energy derivative, force, and union-find passes.

Why one pair list matters

The hot loop does not allocate: vectors keep their capacity between steps. More importantly, the simulation cannot accidentally use one neighbourhood definition for motion and another for formation analysis. Both read the same collected pairs.

A compact state boundary

JavaScript does not receive Rust structs. It receives three flat arrays with stable, documented records:

Snapshot
Four floats per particle: x, y, packed species + energy, and normalised speed.
Metrics
Seven floats: energy, coherence, activity, density, formations, transition, encounters.
Formations
Four floats per reported cluster: toroidal x, y, population share, and species.

At the default population, the visual snapshot is only 8,000 floats. The renderer uploads it once per display frame; audio consumes the seven metrics and at most 32 formation floats. The boundary is small enough to be obvious and rich enough that neither output needs to inspect particle neighbourhoods itself.

The WebGPU image is a render graph

WebGPU does not run the simulation. It receives the current particle snapshot and builds the image in a sequence of render passes:

  1. Kernel field. One instanced quad per particle splats its exact species shell into a one-third-resolution rgba16float texture using additive blending.
  2. Motion memory. Two half-resolution HDR textures ping-pong. One frame's history fades with a frame-rate-independent exponential before current particles are deposited.
  3. Scene composition. A full-screen pass combines background, field, growth-response contours, and trails into a full-resolution HDR target.
  4. Particle cores. Instanced luminous points are added over the field, preserving the actual material that made the membranes.
  5. Bloom. Bright matter is soft-knee extracted at quarter resolution, then blurred horizontally and vertically.
  6. Final grade. Scene and bloom are combined, ACES tone-mapped, gently lifted, vignetted, and given fine animated grain before reaching the canvas.

Halo and Memory alter this render graph but never feed back into Rust. Ecology and gestures alter the engine. That separation keeps aesthetic controls from becoming hidden physics.

A fallback that preserves the idea

WebGPU is preferred, but it is not assumed. If no adapter is available—or if ?renderer=canvas is present—the app uses Canvas 2D. Cached radial sprites avoid rebuilding thousands of gradients every frame, a second canvas accumulates trails, and additive compositing keeps the field luminous.

The fallback is simpler and visually different, but it keeps the important contract: particle-built matter, three populations, energy and speed encoded in appearance, and no replacement simulation.

WebAudio is a sibling, not a post-process

The sound graph is built from standard WebAudio nodes after an explicit user gesture. It contains sustained oscillators, filtered procedural noise, panners, delay, convolution reverb, waveshaping, compression, limiting, and an analyser. JavaScript decides the musical targets; the browser's audio thread renders them.

Audio starts only after Enter — Sound on because browsers require a user gesture. Visibility and pointer/keyboard events attempt to resume suspended audio, which is especially important on mobile devices that interrupt a context when the app loses focus.

The limiter feeds both the speakers and a MediaStreamAudioDestinationNode. Recording therefore receives the same mastered signal as playback, rather than an approximate second mix.

A URL is part of the instrument state

The initial seed comes from the query string or is generated once and written there. Ecology, flow, touch strength, halo, memory, tone, level, population, and selected gesture mode use the same mechanism. Share ensures the current complete settings are present before opening the native device share sheet or falling back to the clipboard.

Reproduction has an explicit contract: engine revision, seed, particle count, fixed timestep, performance settings, and gesture stream. The current share URL covers an untouched field and the starting state of a performance. A future performed-take bundle can add timestamped gestures without changing the engine boundary.

Producing synchronized video

The local production command builds the release artifact, opens a clean Chrome profile at the requested dimensions, and calls a narrow capture API exposed by the app. Preparation starts the audio graph silently, resets the seeded engine, and clears visual history. Capture releases simulation and master gain together on the first recorded frame.

The canvas stream and post-limiter audio stream enter one MediaRecorder. Chunks are sent back to a local Node process as they arrive so a long recording does not live entirely in browser memory. FFmpeg can retain a VP9/Opus WebM or transcode H.264/HEVC with AAC and fast-start metadata.

Each landscape, square, portrait, story, 4K, or custom aspect ratio is a fresh run from the same seed. Beside the video, the tool writes a PNG preview and JSON manifest containing source revision, encoded URL, settings, resolution, measured cadence, duration, and probed codecs.

What the checks defend

npm run check is the release gate. It verifies formatting, compiles Rust with Clippy warnings treated as errors, runs the native test suite, builds optimised WebAssembly, and creates the production Vite bundle. A passing push to main deploys that exact artifact through GitHub Actions and Cloudflare Workers Static Assets.

The native tests concentrate on invariants that matter creatively: same-seed reproduction, different-seed divergence, stable dense fields, bounded metrics, finite long runs, pair-grid agreement, gesture effect, species presence, formation records, and particle conservation.

A useful division of labour

Rust owns the rules that must reproduce. WebGPU owns a replaceable interpretation of those rules as light. WebAudio owns a replaceable interpretation as music. The browser app owns timing and consent. The production tool owns delivery formats. Keeping those responsibilities narrow is what lets them remain one instrument.

Standards and source

  1. W3C GPU for the Web Working Group. WebGPU specification.
  2. WebAssembly Community Group. WebAssembly core specification.
  3. W3C Web Audio Working Group. Web Audio API 1.1.
  4. Confluon source. github.com/tre-systems/geno-5 — simulation, renderer, audio graph, tests, and production tooling.