Guides

The Three.js door

@fundamental-engine/three runs the same reciprocal field engine that drives <field-root> — headless, with no DOM canvas — and renders its conserved particle swarm as a THREE.Points cloud in your own WebGL scene. The physics is identical; only the renderer changes.

One engine, a different surface. The core computes the field against plain data and stays renderer-agnostic. The DOM binds it through the platform layer; this package binds it to a Three.js scene through a FieldProjection (the 2D ⇄ 3D map) and a FieldHost. Every FieldHandle method — burst, flowTo, setFormation, seed — drives the 3D layer exactly as it drives the page field.

Install

npm i @fundamental-engine/three three

three is a peer dependency — you bring your own version (≥ 0.147, the tested floor). The package touches only long-stable three APIs, so it works against modern three (built against 0.169) and is verified live at r147. On a no-bundler page, pin the peer to the same revision your scene already uses so the library and your page share one Three.js (see the package README for the CDN recipe).

The particle bridge

The engine runs in signals-only mode (render: 'none') and exposes its particle pool through readParticles(). FieldLayer pulls that each frame and writes it onto a THREE.Points geometry through the projection. Create a layer, add layer.object to your scene, and call layer.tick() in your render loop — the engine self-steps, so tick() only pulls the latest swarm.

JavaScript
import * as THREE from 'three';
import { createFieldLayer, PlaneProjection } from '@fundamental-engine/three';

const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer({ antialias: true });

const layer = createFieldLayer({
  projection: new PlaneProjection({ relief: 2 }), // the field on a plane, z lifted from heat
  renderer,                                        // reads the device-pixel ratio
  accent: '#4da3ff',
});
scene.add(layer.object);                           // a THREE.Points cloud

renderer.setAnimationLoop(() => {
  layer.tick();                  // the engine self-steps; tick() pulls the latest swarm
  renderer.render(scene, camera);
});

Want to see it first? The live Three.js field is this bridge running a volumetric swarm you can push around with the pointer.

Flat plane or real volume

A FieldProjection maps the engine's CSS-pixel field space to 3D world space. Two ship, both behind one interface, so the layer and overlays are unchanged by the choice:

  • PlaneProjection — the field on a quad; z is lifted stylistically from per-particle heat. The right choice for a flat field.
  • VolumeProjection — maps the engine's real depth lane (z ∈ [0, depth), the opt-in z axis) onto a world depth range, for a genuinely volumetric swarm.

Pass depth and the layer picks a VolumeProjection for you:

JavaScript
// Pass depth and the layer defaults to a VolumeProjection: bodies stay on the page
// plane (z = 0) while free matter drifts through the volume and is pulled gently back.
const layer = createFieldLayer({ depth: 300, renderer, accent: '#4da3ff' });

Bodies — meshes that bend the field

A scene object can be a body (a force source). layer.addBody(object3d, spec) registers it: it bends the field and the swarm responds, while density/load/lit feedback flows back to the mesh (drive a uniform or material from onFeedback). A body carries a data record (a genome, an inventory), so a mesh can be a meaningful agent — not just a force. Params are reactive: body.set(...) is read on the next measure cycle, no re-create.

JavaScript
const bloom = layer.addBody(blossomMesh, {
  tokens: 'attract', strength: 0.8, range: 260,   // field px
  species: 1,                                      // this is "pollen-1" matter…
  data: genome,                                    // …carrying its genome
  onFeedback: (ch) => (glow.material.opacity = ch.density ?? 0),
});
bloom.set({ strength: 0.3 });  // live — no re-create, no re-scan

Matter tagging — many ecologies, one field

Tag a body with a species (the matter it emits or represents) and make it affects-selective (the species it acts on). Matter outside a body's set feels no force from it and is skipped in its density sample — so pollen, seeds, and spores can share one field, each pulled only by its own bodies. Tags are read once at scan, allocation-free per frame.

JavaScript
// Several ecologies share one field. A tagged body only acts on matter it cares about.
layer.addBody(beeMesh,   { tokens: 'attract', affects: [1] });      // bees chase pollen-1 only
layer.addBody(pollenMesh, { tokens: 'spawn',   species: 1 });        // pollen-1 source
// matter whose species is outside a body's `affects` set feels no force from it, and
// is skipped in its density sample — no cross-talk between the ecologies.

Agents — creatures the engine moves

layer.addAgent(object3d, opts) makes a mesh a field agent: the engine steps it — it lives in the particle pool, so it feels every force the swarm feels (body forces and the particle-level hunt/align/cohesion) — and drives the object's position each frame. This is the engine-stepped successor to the self-integrating FieldAgent: you no longer hand-roll a motion loop.

JavaScript
const bee = layer.addAgent(beeMesh, {
  maxSpeed: 95,          // field px/frame — a hard clamp
  species: 1,            // tagged bodies (affects) steer it selectively
  faceVelocity: true,    // orient the mesh along its heading
  hover: { amp: 0.12, freq: 3 },
});
// layer.tick() now also advances the engine that MOVES the bee — no hand-rolled motion loop.
bee.remove();            // retire it; the pool shrinks back
maxSpeed clamps; the agent bounces. An agent is held under maxSpeed (field px/frame) and edge-bounces at the field bounds rather than wrapping toroidally, so it stays inside the world. It's counted by particleCount() but excluded from readParticles() — your mesh is the agent's body, so it isn't drawn into the swarm.

Reading the field — your own visuals

The package re-exports the engine's field samplers — forceAt and netField — so you can drive your own 3D visuals (streamline tubes, vector grids, density volumes) from the live field without a second import. For forage-by-gradient, layer.sampleScalar(x, y) returns the smooth diffused density ∈ [0, 1]; unlike a nearest-body readout, its gradient stays meaningful right at a source, so a creature can always tell which way is "more". Enable the scalar grid with heatmap: true (it returns 0 when off):

JavaScript
const layer = createFieldLayer({ heatmap: true, renderer }); // enable the scalar grid

// in your creature's brain: climb the density gradient toward food
const here  = layer.sampleScalar(x, y);
const ahead = layer.sampleScalar(x + dx, y + dy);
if (ahead > here) move(dx, dy);  // forage-by-gradient — meaningful even at a source

Diagnostic overlays

threeBackend implements the engine's structural drawing seam (RenderBackend), so the diagnostic overlays — streamlines, field-lines, grid, contours, force-vector arrows — draw as scene geometry. Inject it through the lower-level createThreeField and add overlay.object to your scene:

JavaScript
import { createThreeField, threeBackend, PlaneProjection } from '@fundamental-engine/three';

const projection = new PlaneProjection();
const overlay = threeBackend({ projection });
scene.add(overlay.object);

const field = createThreeField({
  viewport: () => ({ ...projection.size(), dpr: renderer.getPixelRatio() }),
  overlayBackend: overlay,
  overlay: 'streamlines',   // field-lines, grid, contours, force-vectors — drawn as scene geometry
});

The line overlays render fully; numeric label sprites (the data reading) are a tracked follow-up. See the diagnostic overlays page for what each mode shows.

Lifecycle & cleanup

When the scene goes away, call layer.destroy(). It stops the engine, releases the host's listeners, retires every agent, drops the body↔mesh references (so a retained body handle can't pin the registry), and frees the swarm's GPU buffers. Then dispose your renderer as usual.

JavaScript
// stop the engine, release host listeners, retire agents, drop body↔mesh refs,
// and free the swarm's GPU buffers — call it when the scene goes away.
layer.destroy();
renderer.dispose();
One destroy per layer. A FieldLayer owns real resources — a running engine, a THREE.Points geometry, growable overlay buffers, and (in the DOM build) registry observers. destroy() tears all of them down; skipping it on a re-mounted or route-swapped scene is the usual source of a slow leak. The page field's <field-root> does the equivalent automatically on disconnect.

Next

  • The live Three.js field — the bridge running a volumetric swarm.
  • The FieldHandle reference — every method the layer exposes, including addAgent and sampleScalar.
  • The concepts — bodies, the shared field context, and the Field Agent Consumption Model the agents extend.