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.

The input mirror of the surfaces. The render surfaces are bundled output layers — 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.

JavaScript
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:

JavaScript
// 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.

JavaScript
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

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 primitive diffuse/memory run on): you deposit into 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.