Build
Field channels
A field is more than its own forces. addField(name, sampler) lets you register an
external scalar field you already own — terrain height, soil moisture, a
temperature map — as a named channel the engine samples on the same read path
as its built-in field functions. So a consumer queries one field, instead of
bolting a parallel grid alongside it and keeping the two in sync by hand.
setRender draws the underlay,
setOverlay the overlay. A channel is the open input analog: you hand the
field a sampler and it becomes part of what the field can read. Same shape as the host-authorable
grid(name) buffer, but for a field you already
compute yourself.
Register and sample
addField(name, sampler) takes a name and a pull-based function
(x, y) => number in field-pixel space. Read it back with
sampleField(name, x, y) — an unregistered name reads 0, so a sampler
is always safe to call. Both are on the FieldHandle, mirrored through
@fundamental-engine/vanilla, <field-root>, and the
Three.js layer.
import { createField } from '@fundamental-engine/core';
const field = createField(canvas, { host });
// Register an external scalar field as a named CHANNEL — your data, sampled on the
// engine's own read path. The sampler takes field-pixel coords and returns a number.
const moisture = field.addField('moisture', (x, y) => soil.wetnessAt(x, y));
// Read it back anywhere — through the field, not a second structure beside it.
const wet = field.sampleField('moisture', px, py); // 0 for an unregistered name Pull, not push — you keep ownership
A channel sampler is pull-based: the engine calls it on demand and never caches
the result, so the data stays yours and always current — change your terrain and the next
sampleField sees it, with nothing to invalidate. Keep the sampler cheap; it can be
called many times a frame. It must obey the field-function contract — side-effect free and
stable for a fixed state (the same contract the built-in field lines,
streamlines, and heatmaps rely on).
This is the same ownership split the engine draws everywhere: it owns the particle pool and
moves it; you own your terrain and the engine only reads it. Reading a channel as a
force potential — letting matter drift down its gradient — is a separate, opt-in step.
addField is the read substrate, not yet a cause.
Worked example: terrain on the field's path
Habitat — a simulation garden running this engine through
@fundamental-engine/three — has a rich soil model (three-horizon moisture, ground
height) that used to live in a parallel grid the field read through by hand. Registered as
channels, terrain becomes part of the field's own sampling path, and any agent can consult it
the same way it samples force or density:
// Habitat (a Three.js garden on this engine) registers its soil model as channels,
// then samples terrain through the field instead of reaching into World.getTile() alongside it.
const tileAt = (fx, fy) => world.getTile(toTileX(fx), toTileY(fy)); // field px → garden tile
layer.addField('moisture', (fx, fy) => tileAt(fx, fy)?.moisture ?? 0);
layer.addField('height', (fx, fy) => tileAt(fx, fy)?.height ?? 0);
// A bunny prefers the lusher tile — terrain now feeds the agent's decision on the field's path.
const best = candidates.reduce((a, b) =>
layer.sampleField('moisture', b.x, b.y) > layer.sampleField('moisture', a.x, a.y) ? b : a);
The win isn't the read itself — it's the single path. Force, density, and now
terrain all answer to sample* calls on one field, so a consumer never has to thread
a second structure through every function that already has the field in hand.
The handle — swap and remove
addField returns a FieldChannelHandle: set(sampler) swaps
the function live (a season changes the map; a layer toggles), and remove()
unregisters the channel so sampleField falls back to 0.
const heat = field.addField('heat', summerMap); // FieldChannelHandle
heat.set(winterMap); // swap the sampler live — a season changes the map
heat.remove(); // unregister; sampleField('heat', …) returns 0 again Reading --d: the density signal and how to scale it
The complement to a channel you read is the density the field writes back. Every
data-feedback body receives its locally gathered density as --d — the
canonical raw density channel — and as --field-density, the longer, field-namespaced
form of the same value (both are written every frame, three decimals). It is the
live reciprocal signal: matter gathers on a body, the body's --d rises, your CSS reacts.
--d is small by design. The value is an eased density in
0..1, but at rest it sits in the low fractions — often around
0.04–0.05. It is not a normalized "how lit is this body" gauge you can map straight
to 0..1 opacity; it is the raw fraction of the pool a body has gathered, kept calm so
ambient drift doesn't flicker your type.
What moves it: the body's per-frame matter count within range (driven by field
density and the body's strength / range — a stronger,
wider body gathers more), plus a fixed bump while the body is engaged
(data-active / hover adds a large step). Concretely the eased target is roughly
count / 20 + (engaged ? 0.45 : 0), clamped to 0..1 — so a resting body
with a handful of nearby particles lands near 0.05, and engagement is what pushes it
toward the top of the range. Cranking field density or body strength
raises the resting floor, but only so far; the design intent is a quiet baseline, not a hot one.
So amplify in CSS rather than expecting a pre-scaled 0..1. Scale
--d once into a derived variable (with a clamp() for a calm floor and a
hard ceiling), then drive every reaction off that. This keeps the magic number in one place
instead of sprinkling * 30 through your stylesheet:
/* --d (the canonical raw density channel; --field-density is its
field-namespaced form) rests small by design — fractions of 1.
Scale it into a usable range in CSS; don't bake per-app magic numbers into
every consumer. clamp() gives a calm floor and a hard ceiling. */
[data-feedback] {
--d-amp: clamp(0, calc(var(--d, 0) * 12), 1); /* raw fraction → ~0..1 */
}
/* drive the visible reaction off the amplified value, not the raw one */
.hero-mass {
transform: translateY(calc(var(--d-amp) * -8px));
opacity: calc(0.4 + var(--d-amp) * 0.6);
}
Tune the multiplier to the resting --d you actually observe for your field's
density and body params — there is no universal constant, because the resting count
depends on your scene. For a binary "is this body engaged" gate that doesn't need scaling, read
the engine's thresholded data-field-density="high" state attribute instead of the
numeric var.
Channels, grids, and surfaces
Three open primitives, one principle — name it, and the field extends to hold it:
- Channels (
addField) — an input field you compute and own; the engine samples it. Pull-based, never cached. - Grids (
grid(name)) — a host-authorable engine buffer (the same primitivediffuse/memoryrun on): youdepositinto it and the engine advances it each frame. A scent map, a wear layer. - Surfaces (
setRender/setOverlay) — output layers the engine draws onto a canvas. The visible counterpart to the channels you read.
For the hard contract a channel sampler obeys, see the FieldHandle reference.