Developed by Mehran Ahmadi © 2026
A generative particle artwork. Give VOID a photograph, a 3D model, or a point cloud, and it becomes a living memory: thousands of particles that try to reconstruct the source while behaving like an organism of their own.
A source becomes a memory. The swarm converges on it, lives with it, drifts away from it, forgets it almost completely, and then finds its way back.
The piece lives in the tension between three forces:
- MEMORY - the shape the particles are trying to remember
- LIFE - the particle-life ecosystem of attracting and repelling species
- VOID - the pull toward dissolution
Nothing is a pre-rendered video. The artwork is a simulation you can steer: every source, state and parameter is yours to change.
Every memory is held by a crowd of small, living things that do not know what they are holding. VOID makes that visible. It's a piece about what it takes to keep something — a face, a place, a person — and what's left when you stop.
That's also why it can bear witness. Witness is a look in which every particle is a person, and one light goes out every four seconds. That's the rate at which, by humanitarian estimates, people die of hunger and its causes. The count runs on wall-clock time, so pausing the piece doesn't pause it. Nothing in it is faster than the truth.
The piece belongs to the lineage it's built on: William Reeves' Genesis
Effect (1982), which gave us the words "particle system"; Craig Reynolds'
boids (1987); Karl Sims' Particle Dreams (1988); Jeffrey Ventrella's
Clusters; and the particle-life simulations of the 2020s. GENESIS (press
N) is VOID's tribute to the first of them: a ring of fire spreads out from
the heart of the memory, and the memory re-forms behind it.
npm install
npm run dev # http://localhost:5173npm test # vitest: engine, memory, sources, keymap
npm run build # production build into dist/Needs Node 18+ and a WebGL2-capable browser. Without WebGL2 the CPU engine takes over automatically.
VOID remembers three kinds of source:
| Kind | Formats |
|---|---|
| Image | PNG, JPG, WEBP, BMP, GIF |
| 3D model | GLB, GLTF, OBJ, STL |
| Point cloud | PLY |
Bring one in three ways:
- UPLOAD SOURCE on the YOUR MEMORY card (bottom left of the canvas)
- Drag and drop a file anywhere on the window
- URL hook:
?src=/samples/void-cloud.ply(plus?count=8000)
The card then shows exactly what VOID is remembering:
MEMORY
portrait.jpg
IMAGE · 12,000 PARTICLES
[ CHANGE SOURCE ]
Loading is a small lifecycle: READING the file, FORMING the memory, then ready. If a file cannot be read you get a plain-language error and a TRY AGAIN button, and the memory that was already running stays untouched.
Density is live: [ and ] step through 4k to 50k particles, resampling the
same source with the same seeds so the memory stays stable as it thickens.
Loading an image also shows a small thumbnail of the source.
The last memory survives a reload: local files are kept in IndexedDB (best effort, capped at 64 MB) and restore on the next visit with their thumbnail; URL sources restore by URL. A broken record never stands in the way: VOID just returns to its synthetic memory until you give it something new.
Samples live in public/samples/ (void-figure.png, void-cloud.ply,
void-sphere.obj); regenerate them with node scripts/make-samples.mjs.
The empty card offers them directly (FIGURE / CLOUD / SPHERE), so you can see
the piece working before you have a file of your own.
Press ? at any time for the in-app guide, which groups every control by what
it affects and explains the five memory states. On a first visit the guide opens
itself once; after that it waits for ?.
| Keys | Does |
|---|---|
1 2 3 4 5 |
Force a memory state |
A |
Toggle the automatic memory cycle |
H |
Cycle species interaction matrices |
R |
Randomize the interaction matrix |
C |
Color mode (cycles monochrome, source, species, random, gradient) |
K |
Sprite shape (circle, box, triangle, ring, star) |
T |
Trails |
D |
Depth of field |
O |
Open a source (file picker) |
P |
Control panel |
F |
Fullscreen |
[ ] |
Particle density |
G |
Simulation backend (GPU / CPU) |
S |
Screensaver mode |
L |
Listen to sound (music drives the look) |
E |
Evolve (search for better interaction matrices) |
N |
Genesis (a ring of fire re-forms the memory) |
V |
Presence (the swarm remembers whoever stands at the camera) |
X |
Exhibition (the piece plays itself for a room) |
Y |
Ecology (predation, birth and death) |
? |
Controls guide |
ESC |
Close the guide / leave fullscreen |
Drag to orbit, scroll to zoom; on touch, one finger orbits, two fingers pinch to zoom and drag to pan, and a touch-and-hold is the touch itself — touch has no hover, so the swarm leans on an explicit hold. On a touch device the guide is the keyboard: every key chip in it is tappable and runs its shortcut.
The instrument is laid out like a painter's application, so you can find things by where they are before you read what they're called. Every action has an icon with its name underneath.
| Where | What |
|---|---|
| Top bar | The main gestures: SOURCE, GENESIS, RECONSTRUCT, RELEASE, PAUSE, CAPTURE. Then SCREENSAVER, EXHIBIT and FULLSCREEN, the window toggles (TOOLS / PANEL / LOOKS), HELP and HIDE. |
| Tool rail (left) | SIZE, GLOW, COLOR, SHAPE, TRAILS, FOCUS, HAND, DOTS, KINDS, SOUND, YOU. Each tool opens a small flyout beside the rail. Colours show up as swatches, ramps as gradients, shapes as their own glyphs. A dot on a tool means it's on. |
| Properties (right) | Every parameter, in sections that fold independently: MOTION, MEMORY, LIFE, FIELD, SCENT & HEAT, ECOLOGY, VISUAL, SOUND, EVOLVE, and the LAB (backend, ghost, modulators, stats, RESET LAYOUT). |
| Looks dock (bottom) | Every look as a face (Moon Dust first), plus RANDOM / UNDO / RESET. |
You can close any panel with its ×, bring it back from the top bar, and the
browser remembers your layout. P (or HIDE) hides everything except the
piece. On a phone the piece comes first: a slim top bar keeps SOURCE,
GENESIS, PAUSE, CAPTURE and HIDE, and a bottom bar (LOOKS, TOOLS, PANEL,
MORE) raises one sheet at a time. Touch the piece and the sheet goes away.
Sixteen authored looks ship with the piece: Moon Dust, Portrait, Organic, Scan, Architecture, Void, Chaos, Predator, Galaxy, Fireworks, Hearth, Traces, Exhale, and three new in 0.11: Witness, Murmuration (two courting flocks folding like starlings at dusk) and Aurora (the memory hung in the sky as breathing curtains of light). Each look is a full state, so nothing leaks from one into the next. Several carry their own screensaver camera, and the whole instrument state persists and comes back on your next visit.
Exhibition (X, the EXHIBIT button, or ?exhibit=1) plays an authored
programme inside the screensaver: Moon Dust, Galaxy, Organic, Murmuration,
then Aurora opened by GENESIS, Traces, Exhale, Witness (given the most
time), and Void, looping for as long as the room is open. Each look's
statement appears as a quiet caption. Any input hands the piece back. The
programme lives in src/app/exhibition.ts.
Presence (V, or the YOU tool) lets the swarm remember whoever stands in
front of the camera. It learns the empty room first, then takes a
visitor's silhouette as its memory, and lets them go a few seconds after
they walk away. The camera is read at 96×72 in grey, a mask is derived,
and the frame is dropped. Nothing is recorded, stored or sent.
Witness needs no file: with no memory of your own loaded, it brings its own crowd of people, so a first visit shows what it's about.
Every look has one line, and the piece says it quietly when the look is
chosen. All the lines live in one file,
src/presets/statements.ts: the key is the
look's name and the value is the line. Edit a line, save, and the piece
speaks differently. Delete a line and that look stays silent.
The piece is monochrome by default, and every option below is off until you choose it: the artwork is unchanged unless you ask for more.
| Color | What it does |
|---|---|
| MONOCHROME | One cool grey, brightness only. The default. |
| SOURCE | The source's own colours: an image's pixels, a cool white for models and clouds. |
| SPECIES | A hue per species, spaced around the wheel and matched in perceived brightness, so the ecosystem is legible instead of invisible. |
| RANDOM | One seeded hue per particle, stable for the whole run. |
| GRADIENT | An authored ramp, mapped across AGE (a particle's own life cycle), DEPTH (its distance from the camera), RADIAL (its distance from the subject's centre), or the two stigmergic fields: SCENT (where the swarm has been) and HEAT (where it is working hardest right now). Palettes: DUSK, EMBER, ICE, ASH, SPECTRAL, GRAIN, AURORA, TIDE. |
Shapes are drawn analytically in the fragment shader: circle, box, triangle, ring, star. No textures, no extra geometry, no simulation change. With By species on, each species gets its own sprite, which pairs with SPECIES colour to make the organism readable at a glance.
The look is also reachable from the URL, which is how the screensaver and tester links configure it:
?color=species ?color=random
?color=gradient&axis=age&palette=ICE
?color=gradient&axis=radial&palette=SPECTRAL
?color=gradient&axis=heat&palette=EMBER
?shape=star&color=monochrome
The five states are the heart of the piece:
| State | Meaning |
|---|---|
| RECONSTRUCT | The memory returns to the source. |
| ALIVE | Memory and life coexist. |
| DRIFT | The memory begins to weaken. |
| VOID | The source is almost forgotten. |
| REMEMBER | The organism finds its way back. |
States are data, not code (src/memory/MemorySystem.ts): blend, memory
strength, decay, chaos, regain and duration ranges, all JSON-serializable.
Transitions are smoothstep, so a manual switch never snaps, and the automatic
cycle jitters its durations so the loop never feels mechanical.
Particles belong to species, and every pair of species has its own affinity in an interaction matrix: positive values attract, negative values repel. Three force kernels shape the neighbour response (pulse, inverse, linear).
The fields bend the swarm back: Scent affinity and Heat affinity scale the species affinities by how much of each field is around a particle, so the swarm can grow stickier (or looser) along its own trails and in the places it works - the environment it makes, shaping the behaviour that made it.
The heat field is a second, faster memory: particles leave warmth where they move, it cools away quickly, and the swarm can either avoid the hot trails it made (Heat steer negative, the default) or seek them out. Scent is where the swarm has been; heat is where it is working.
On top of that sit the organism behaviours: per-particle phase clocks that can couple into a shared heartbeat, stress and sleep hysteresis, Ornstein-Uhlenbeck wander, and a Physarum-style scent field the swarm writes, follows and slowly forgets. The FIELD section of the panel exposes all of it.
VOID can listen while it remembers. Press L, or open the panel's SOUND
section and switch Listen on: the audio is analysed with WebAudio, and the
swarm answers in how it looks rather than in its physics.
Input chooses where the sound comes from:
- MICROPHONE - anything the mic hears: a room, speakers, an instrument.
- TAB AUDIO - music playing on your system. The browser asks you to pick a tab (or screen) and, importantly, to tick share tab audio; VOID keeps only the audio and ignores the video. Stopping the share from the browser chrome switches listening off by itself. Bass swells the particles, overall loudness lifts the glow, and the high end opens the exposure, with a quick attack and a slow release so it moves with the music instead of twitching at it. Sensitivity sets how far it travels.
The audio is analysed inside the page and never recorded, stored or sent anywhere; switching Listen off releases the microphone immediately.
VOID can also breathe out loud, with no audio files involved: a synthesised ambience whose hum intensifies with the swarm's stress, whose whisper swells while the memory is re-forming, and whose faint shimmer follows density. Switch Soundscape on in the SOUND section and set its Volume. Because it is generated rather than captured, it keeps working in the screensaver.
Switch Life on in the panel's LIFE section (or leave it off for the quiet piece) and every particle gets a life of its own:
- Birth - it appears at its own point of the memory with a small spark and a puff outward.
- Growth - over the first fifth of its life it grows into the memory it is made of, so young particles are dim, small and barely pulled by the source.
- Life - it lives at full strength.
- Dissipation - over the last fifth it forgets and fades toward nothing.
- Rebirth - it returns to the source and starts again.
Lifespan sets how long one life lasts, and Spread staggers births so the swarm never dies all at once. Age is a pure function of time and particle index, so the whole system needs no extra GPU state: both engines run the same curve, and the renderer reads it for size and light.
Move the pointer across the canvas and the swarm leans after it. Cursor sets how hard, and its toggle switches between PULL (attract) and PUSH (repel). The touch fades out when your hand rests, so it reads as contact rather than a permanent magnet, and the point is recomputed through the live camera every frame, so it stays true while the view drifts. Both engines run the same falloff, so CPU and GPU behave identically.
Ghost answers the screensaver's one hard rule: any mouse movement exits it, so a real pointer can never nudge a screensaver. Instead VOID records the path your pointer takes while you work (a rolling ~40 s of it) and replays that path as a ghost hand while the saver runs, through the same camera and the same force. Before anything has been recorded, a slow figure-of-eight stands in. Switch Ghost off if you would rather the screensaver be left alone.
Press E, or open the panel's EVOLVE section, and VOID searches its own
behaviour. A genome is exactly the species interaction matrix the particle
life runs on; each candidate gets a trial window (Trial seconds) to prove
itself, and fitness rewards both halves of the piece: converging on the memory
and staying alive. A stiff matrix that locks the particles down scores
poorly, and so does one that never reconstructs.
The best of each generation (with Mutation controlling how far children
drift from their parents) breeds the next through tournament selection,
uniform crossover and gaussian mutation. Stopping keeps the champion applied:
the search is a way of finding an organism, not a mode you sit in. Any manual
change - a preset, a randomize, the H key, a different species count - ends
the search and leaves your change alone.
Appearance is evolvable too. With Look on in the EVOLVE panel, each candidate also carries a hue and a shape per species, inherited from the same winner and mutated at the same rate. Nothing about a colour makes a swarm remember better, so these genes cannot be selected for directly: they hitch a ride on the behaviour that can, and a champion arrives looking unlike its ancestors.
Off by default, and CPU-backend only. With Ecology on, the chase the interaction matrix already describes becomes literal: a species the matrix makes predatory can catch what it chases. A capture ends the prey's life and feeds the hunter, and hunger is real - a predator that does not eat starves, spent particles carry an age risk, and a well-fed particle leaves offspring in the slot a death freed. The living are a prefix of the buffers and the dead are the tail, so death and birth need no extra buffers and nothing for the renderer to know.
It runs on the CPU engine deliberately. WebGL2 cannot do the allocation and
scatter that population dynamics need; the CPU already owns that kind of
bookkeeping for the grid. On the GPU backend the panel says so rather than
pretending - press G to switch, and the stats line shows the living
population with births and deaths counted.
| Mortality | Cause |
|---|---|
| Predation | Caught by a species the matrix makes a hunter. |
| Starvation | A hunter that has gone too long without a meal. |
| Age risk | A per-second chance that grows as a particle is spent. |
With Sound on in the same section, the room drives the ecology as well as the look: a loud room makes the swarm hungrier, low end makes it breed on the beat, and a transient startles the prey away from whatever is hunting them.
Predator in the presets is the clearest way in: three species in a rock-paper-scissors chase, the ecology on, species colour and shape.
Where the swarm has been and where it is working are already two fields the simulation steers by. With COLOR on GRADIENT and Axis on SCENT or HEAT they become visible: the ramp is baked from the local field value, refreshed a few times a second, and normalised against the field's own peak so the contrast survives a long run.
With Ecology on in the EVOLVE panel, the search evolves the ecology too - how far a hunt reaches, how deadly it is, how long a hunter lasts between meals, and how well fed a particle must be to breed. These are the first genes the search can score on their own terms: a hue cannot be measured, but an ecology can, because one that eats and breeds sustains its population and one that does not collapses. The population term is bounded by its weight, so a thriving swarm can never out-score real progress toward the memory.
Simulation is split on purpose:
- The CPU rebuilds the spatial grid each step (a counting sort WebGL2 cannot do portably) and packs it into textures.
- The GPU evaluates neighbour forces and integration for every particle.
- When WebGL2 is missing the CPU engine runs instead;
Gswitches live and carries the current memory across.
Measured in the running app (dev GPU): the GPU path holds 60 fps from 4k to 50k particles with a single synchronous readback per frame — the renderer samples the simulation's own compute textures, so no vertex data is uploaded per frame. The CPU engine sustains real time to its 4k ceiling; past it the frame loop dilates time instead of spiralling. Density is per-backend honest: the CPU menu ends at 4k, the GPU menu at 50k.
Budgets are enforced, not hoped for: the build fails if the eager JS exceeds
220 kB gzip (183 kB today), the perf suite carries a 30 ms catastrophic
ceiling and a 12 ms real-time budget on a pinned runner
(VOID_ENFORCE_BUDGETS=1), and the LAB stats show the per-frame readback
count and bytes.
packaging/build.ps1 produces a single-file VOID.scr (the built app plus a
small C# host, no SDK required). Right-click it and choose Install, or drop
your own images into Documents\VOID\Sources and the screensaver will use one
of them. The core stays a plain web app; the wrapper is a thin shell.
src/
app/ entry point, keyboard map, source lifecycle
particles/ CPU engine, spatial grid, interaction matrix, GPU engine
memory/ the five states and their transitions
sources/ image / PLY / mesh loaders and samplers
rendering/ sprites, trails, HDR filmic pipeline
presets/ preset definitions, randomization, localStorage, evolver
ui/ control panel, source card, controls guide, shared keymap
audio/ audio-reactive mode (mic or shared-tab analysis) and the soundscape
input/ pointer track, ghost playback, idle fade
(life cycle lives in particles/lifeCycle.ts, shared by both engines)
tests/ vitest suites (engine, memory, organism, sources, keymap)
scripts/ sample generator
The public site is a landing page with the app running inside it:
/ landing page (landing/index.html, version stamped at deploy)
/app/ the built app
node scripts/deploy-pages.mjs # build, stage, and push the gh-pages branchGitHub Pages serves the gh-pages branch at the repo root. The build uses
relative asset paths and the samples load relative to the build base, so the
same output works at a domain root, in a subfolder, or inside the embed on the
landing page. Edit landing/index.html to change the copy around the piece.
VOID is free to experience and share, but it is not open source. It is an artwork rather than a template: anyone may view, run, link to and write about it, and no one may copy, redistribute, modify or reuse its code or assets, or build derivative works from it. Improvement, and authorship of any version of VOID, belongs to the author alone.
The full terms are in LICENSE. The public site links them from its footer.
The engine is framework-free: flat typed arrays, no Three.js in the simulation, so the logic is unit-testable and the buffers upload straight to the GPU. 337 tests cover the engine, grid, matrix, memory system, organism layer, life cycle, the scent and heat fields, environment-modulated affinities, sources, persistence, samples, sound mapping and the soundscape, the pointer force and its ghost playback, the touch-gesture decisions, the evolvable matrix search and its gene pools, presets as full states, the camera choreography, rendering settings, colour sources, gradient ramps, sprite shapes, the three-tier panel, the controls guide and its tap-to-run keyboard, the source lifecycle, screensaver logic, the keymap, and the simulation and bundle budgets.