A photograph held by a crowd of living particles.
Open the piece, on a phone or a computer. There is nothing to install.
A portrait being remembered, let go and remembered again. Captured from the piece at 12,000 particles. Watch the vertical clip (18 s, no sound).
Developed by Mehran Ahmadi © 2026
- Open it. On a phone you meet a face. On a computer, tap PORTRAIT on the YOUR MEMORY card.
- Touch it. Hold a finger still on the piece, or move your mouse, and the swarm leans toward you.
- Let it go, then bring it back. Tap LET GO, then REMEMBER. On a computer they are RELEASE and RECONSTRUCT.
- Keep the moment. CAPTURE saves the image, or offers it to your phone's share sheet.
- Bring your own. SOURCE takes a photo, a 3D model or a point cloud.
Browsers: needs WebGL2, which current phone and desktop browsers have. Without it the CPU engine takes over. A faster WebGPU engine is available as an option where the browser supports it.
Privacy: nothing is uploaded or sent. Your last source is kept in your own browser only. The camera and microphone are used only when you turn on a feature that asks for them.
License: free to experience and share, but not open source. See License.
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 estimate of deaths from hunger and its causes that 238 humanitarian organizations published in September 2022; their own range is one death every 4.25 to 12 seconds. The count runs on wall-clock time, so pausing the piece doesn't pause it.
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 (including Gaussian-splat scenes), SPLAT |
| A body that moves | 4D clip (a PLY with time: see below) |
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.
void-portrait.webp is a photograph by Mehran Ahmadi and isn't generated.
The empty card offers them directly (PORTRAIT / FIGURE / CLOUD / SPHERE /
WALKER), so you can see the piece working before you have a file of your own.
Depth. A picture is flat, so VOID gives it depth from its brightness: light
comes forward, dark falls back. That reads well head-on but breaks from the side,
where a shadowed nose becomes a trench. A picture can bring a painted depth map
instead (white near, black far, any size, the same framing), and the portrait
does: public/samples/void-portrait.depth.png, a starter sculpted from simple
forms by scripts/depth/portrait-depth.py. Paint over it in any image editor and
reload. For now only the bundled portrait looks for one.
A photograph is one moment. A body that moves is many, and VOID can hold
them as a 4D clip: a sequence of point sets over time, kept in one PLY
whose points also carry t (the second they belong to) and id (which
point of the body they are):
comment v4d clip fps=30 frames=90 duration=3.0
property float x / y / z · property uchar red / green / blue
property float t · property int id
Drop a clip, or press WALKER on the empty card, and the swarm chases a
body that keeps moving. Its lag is the memory: the present is held crisp
while the trailing side comes apart into dust that keeps less colour and
light the older it is. The card says CLIP · 41 FRAMES · 2.7 s · 12,000 PARTICLES, and the clip comes back after a reload like any source (up to
64 MB; a larger clip plays but is not kept).
View (U) |
What the swarm holds |
|---|---|
| NOW | The present, as it happens. |
| ECHO | Each particle a moment late: the default. Echo by chooses who is late (GRADIENT: the trailing side of the body; RANDOM; SPECIES; HEAT), Echo depth how far back, Present how much of the swarm holds the present, Dust and Fade how the past comes apart. |
| SCULPT | The whole clip at once, sheared through time (Shear). |
| SLIT | A photo-finish: a slab of space per moment, stacked (Slab). |
Q plays or holds the clip, J loops (LOOP, PING-PONG, HOLD), I chooses
the identity (TRACKED: each particle keeps its point of the body; RENEWED:
the body is drawn anew every instant), ← → scrub a frame and ↑ ↓
change the speed. All of it is in the panel's CLIP section, with a
Moment slider. A body that travels walks on a treadmill: its present
stays in the middle of the view and its past trails where it really was.
The memory states mean something in time. RECONSTRUCT follows the present tightly (life goes nearly quiet, or a thin limb would bead into droplets), ALIVE follows a tenth of a second late with life on, DRIFT lets the moment it holds slip further and further behind (retention), VOID forgets which moment each particle belongs to, and REMEMBER runs the clock backwards while the swarm finds its way home. Targets are written once on the CPU every frame and uploaded by each engine, so CPU, WebGL2 and WebGPU chase exactly the same body.
A reader that knows nothing of time still opens a clip as one point cloud,
every moment at once. To make something that holds still, bake a time
sculpture: scripts/bake/bake_time_sculpture.py writes both.
# A walking figure: the clip, and its echoes as a sculpture (no ML, numpy only)
python scripts/bake/bake_time_sculpture.py --synthetic walker --mode echo
# A photo-finish of a dancer turning, lit by time
python scripts/bake/bake_time_sculpture.py --synthetic dancer --mode slit --color time
# A light drawing: one point of a swung firebrand over two seconds
python scripts/bake/bake_time_sculpture.py --synthetic firebrand --mode firebrand --window 2
# Your own capture: per-frame point clouds (e.g. a Record3D export; needs scipy)
python scripts/bake/bake_time_sculpture.py --frames "capture/*.ply" --name me --mode sculpt
# Re-bake a clip as a different sculpture
python scripts/bake/bake_time_sculpture.py --clip baked/me.clip.ply --mode strobe --every 6The files land in baked/ (<name>.clip.ply and <name>.sculpture.ply);
drop either on the window or open it with ?src=.
| Mode | The sculpture |
|---|---|
sculpt |
Every moment at once, sheared through time: x += k·t (--shear, --axis). Marey, Boccioni. |
echo |
Every Nth frame (--every), the older ones sparser and darker. |
strobe |
A comb of frames, all alike (--every, --width). |
slit |
A thin slab of space per frame, stacked along the motion axis: a photo-finish (--slab, --slit-at, --slit-speed). |
firebrand |
One point's path as a line of light, over an exposure of 0 to N seconds (--window, --t-end, --point-id). |
--color source keeps the body's own light; --color time lights the past
dim and the present bright. The defaults (90 frames of 12,000 points) make
a 23.7 MB clip; the script prints every size and warns past 25 MB, because
the piece keeps a source across reloads only up to 64 MB.
From a rigged character (a Mixamo FBX with skin, or a GLB): run
npm run dev, open http://localhost:5173/tools/bake-skinned.html, drop
the model, BAKE, SAVE. Points are chosen once on the skin and carried
through the animation with their texture colours: a tracked clip.
From a video (optional, for a CUDA PC): --video clip.mp4 masks the
subject (rembg, or --mask sam), estimates depth with Depth Anything V2
Small, and lifts each frame into points through an assumed field of view
(--fov, --near, --relief). Install scripts/bake/requirements-ml.txt
first; the first run downloads the models. The baker's own tests
(python -m unittest discover -s scripts/bake) run this path on a mocked
input and need nothing but numpy and scipy.
docs/4d-memory.md has the whole of it: the format,
the exact PowerShell commands to bake your own clip on Windows (including
the video path on a CUDA card), the measurements, and the known limits.
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) |
W |
Wind (the swarm feels how the room moves) |
X |
Exhibition (the piece plays itself for a room) |
Y |
Ecology (predation, birth and death) |
Q |
Play / hold a moving memory (4D clip) |
U |
Clip time view (NOW, ECHO, SCULPT, SLIT) |
J |
Clip loop (LOOP, PING-PONG, HOLD) |
I |
Clip identity (TRACKED, RENEWED) |
← → / ↑ ↓ |
Scrub the clip a frame / its speed |
? |
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, 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 (Presence and Wind). 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, PAUSE,
CAPTURE and HIDE, REMEMBER and LET GO sit above the bottom bar, and
the bar (LOOKS, TOOLS, PANEL, MORE) raises one sheet at a time. Touch the
piece and the sheet goes away. A first visit on a phone opens on a portrait in
the Portrait look, with one line of help instead of the guide (HELP is under
MORE). CAPTURE on a phone offers the moment to the share sheet.
Twenty-two authored looks ship with the piece: Moon Dust, Genesis (the ring
of fire, a look since 0.13; N still lights it anywhere), Walker (a body
that moves, brought with its clip), Grain (a memory drawn in single pixels
of light), 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). Two more arrived in 0.11.2: Sketch
(a photograph remembered only by its lines) and Kin (each colour of the
memory becomes a species that only wants to be near its own, and the picture
slowly sorts itself apart). 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.
The eye is steadier than it sounds. Every pixel learns how much it flickers on its own, so a lamp or a screen in the room isn't mistaken for a person. When the whole room gets lighter or darker, whether from a cloud or the camera's own exposure, that's measured as one change and taken out. Speckle is filtered away, and only the main bodies in the frame count, so a stray patch in a corner never becomes a visitor. The particles lean toward the edge of the body, so a visitor reads as a figure with an outline rather than a flat shape.
Wind (W, or the YOU tool) lets the swarm feel how the room moves. It
reads the same camera as Presence. Wave a hand and the swarm is pushed the
way it moves and scattered, its grip on the memory loosened; stand still and
the memory re-forms. "It can only remember you when you stand still."
It compares each 96×72 grey frame with the one before it, a twelfth of a second earlier, in 8×8 blocks (block matching, unaffected by the room getting lighter or darker). A flat wall, camera noise and a flickering lamp add nothing, and a stalled camera releases the swarm. The previous frame is the only thing held, and only for that twelfth of a second: nothing is recorded, stored or sent. Wind strength (0 to 2) sits in the YOU tool, and a visitor who asks for reduced motion gets a gentler wind. Presence and Wind share one camera, which closes when both are off.
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.
Two choices change what the memory is rather than how it looks:
| Choice | Where | Options |
|---|---|---|
| Form | MEMORY section | TONE (the default) remembers a photograph by its light. LINE keeps only its contours: edges are traced, thinned to a single line, and given a soft pen width, and the particles live on the drawing. It applies to images; a model or a point cloud has one form. |
| Kin | LIFE section, KINDS tool | MIXED (the default) spreads every species over the whole memory. COLOUR groups the source's own colours into as many clusters as there are species, dark to light, and each particle belongs to the colour it came from. |
A change of form retargets the living swarm, so the particles travel from the photograph to its drawing instead of being reborn. Both choices are part of a look, so undo and your saved state carry them.
CUTOUT forgets everything behind the subject of a photograph, so the swarm remembers only the subject. It's on the YOUR MEMORY card (tap the card for details) and in MEMORY > Background, and works for images only. Under the hood it's a GrabCut-style segmentation: the subject and the background each get a colour model, and an exact minimum graph cut decides every pixel, refined over a few rounds. It takes about half a second and loads on demand, so the first page load doesn't pay for it. If a picture has no clear subject (a flat colour, noise, a subject the colour of its ground) it says so instead of guessing. RESTORE BACKGROUND brings the original back. A reload returns to the original picture: the stored source is the file you gave it.
These are VOID's own versions of classic image operations that
OpenCV also offers: Canny edges, the
distance transform, a median filter, connected components and k-means
(src/sources/vision.ts), block-matching optical
flow for Wind (src/input/wind.ts) and a GrabCut-style
cut for CUTOUT (src/sources/cutout.ts). They're
written for the piece's sizes and use no OpenCV code or binary. The bulk of
the page's first download is unchanged: the cut-out code loads on demand.
docs/PLAN-NEXT.md has the plan they came from.
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. |
Particles (VISUAL) chooses how sizes behave. SOFT is the piece's own: a sprite's size breathes with its phase, speed, life and focus, and its edge is soft. DEFINITE gives every particle one of three sizes for good: dust, one device pixel with a hard edge (85% of the swarm); grain, a crisp disc (12%); and motes, large enough to carry a shape and a halo (3%). Breathing moves a particle's light, not its size. It needs numbers to read, so the Grain look raises the density to 50,000 (it never lowers it). Both GPU renderers draw it the same way, on the CPU engine too.
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 or 4D clips (.ply) 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. The 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.


