Performance

Cheap by design

Fundamental separates DOM participation from engine computation. The platform runs a six-phase scheduler over ~130 particles at default density, and the core stays off the main thread's way.

The platform scheduler

The platform runs one frame loop with explicit, ordered phases, so geometry reads never thrash against DOM writes:

discover → read → compute → state → write → render

Measurement happens in the read phase; CSS-variable and event feedback happen in the write phase; overlays draw in render. Batching all reads before all writes is the single most important layout-performance property — it prevents the read/write interleaving that forces synchronous reflow. An off-phase read is caught by the scheduler's guard.

What runs each frame

  • The body-force loop — O(particles × bodies), range-culled: a body past its reach is skipped before any maths, on squared distance (no sqrt).
  • Integration + damping — a few flops per particle, plus a spatial-hash reindex for neighbour queries.
  • Render — plain additive arcs. The path uses zero shadowBlur (the most expensive canvas op); glow is a cheap halo under each dot.
  • No per-frame reflowscrollHeight is cached and variable-font writes are quantized, so the loop doesn't thrash layout.

Benchmark

The integrator hot path, measured across scales well beyond a real page:

Benchmark
scale     load              ms/frame    fps      throughput
light     800p ×  3b          0.25      ~3900     ~9.5M int/s
typical   2000p ×  6b         0.58      ~1730     ~21M  int/s
heavy     5000p × 10b         1.84       ~540     ~27M  int/s
stress    10000p × 16b        6.11       ~164     ~26M  int/s
# pnpm --filter @fundamental-engine/core bench  (Node 22, one core)

A real page runs ~130 particles against a handful of bodies — the "light" row and below — so the simulation is a rounding error; the render and layout discipline above are what keep it smooth.

That holds on a real page, not just the hot path: the live homepage — 42 separate field instances, IntersectionObserver-gated — sustains 120 fps with zero long tasks and no visible jank on desktop, a result an independent integrator corroborated (conservatively). The one measurement still open is a throttled mid-tier-phone profile of that page — the honest remaining gap.

The bench above measures the engine's algorithmic cost in Node. The half that actually gates the frame — fill rate, canvas compositing on the real GPU — is measured by the live fill-rate benchmark: it sweeps render-mode × density × DPR in a real browser and reports median/p95 fps. Run it on your own hardware (headless software-rasterizes and misleads).

It pauses itself

  • Backgrounded tab — the loop stops on visibilitychange and resumes on return.
  • Reduced motionprefers-reduced-motion freezes the sim (dt = 0) to a single static frame.
  • Field Cells<field-cell> demos gate their loop on an IntersectionObserver, so off-screen cells cost nothing.

Tuning

Lower density for very large or low-power surfaces; density: 0.5 halves the particle count. Within one field, the cost does not grow with the number of bodies on the page beyond the per-body loop term — one field is one shared canvas. Many separate field instances on a page (the homepage runs 42) are a different cost model: each instance is its own loop, kept cheap by the IntersectionObserver gating above, which idles every off-screen field.

createField is the unguarded path

<field-root> protects itself. Its wiring, all automatic:

  • Adaptive quality (#413) — the platform runtime feeds a QualityGovernor rAF-to-rAF frame spacing and forwards each tier change to setQualityTier, so under sustained frame overrun the engine caps the effective backing-store DPR (1.5 / 1.25 / 1 by tier) and drops the heaviest ambient layer (the heatmap) at tier 2+ — and restores full quality when frames recover.
  • Offscreen draw-skip — an IntersectionObserver on the element calls setVisible(false) whenever the host is hidden or zero-sized, skipping all draw work while the simulation and its feedback signals stay live.
  • Overlay-surface hygiene — the front overlay canvas (a full-viewport mix-blend-mode layer that costs a whole-screen re-blend every frame just by being in the compositing tree) is created lazily and taken out of the tree whenever no reading is active.

Raw createField — and the mountField / FieldField doors that wrap it — wires none of this. Reaching for the low-level door to drive a custom or full-viewport canvas gives you the full-cost path with no automatic throttle and no signal. Only two guards are built into that path: the default dprCap of 2, and the tab-level pause on visibilitychange.

Full-viewport createField can starve the host page. A heavy full-screen canvas at DPR 2 is fill-rate-bound (see the DPR note below); left at the default ceiling it competes with the host's own CSS transitions and timers on the main thread — frozen mid-transition tweens and throttled timers are the symptom. This is expected browser behavior for any heavy canvas, not an engine bug — but with createField nothing steers you away from it.

So for a full-viewport createField canvas, set the guards yourself: cap the DPR with the dprCap option (the dominant fill-rate lever), wire the same QualityGovernor the element uses (exported from @fundamental-engine/dom) to setQualityTier, and call setVisible(false) when your canvas leaves the screen. Better still, prefer <field-root> for a page field, or a contained field (new FieldField({ bounds: el }) / containerHost(el)) so the canvas only covers the region that needs it.

Guard a createField canvas
import { createField } from '@fundamental-engine/vanilla';
import { QualityGovernor } from '@fundamental-engine/dom';

// 1. Cap DPR up front — the dominant fill-rate lever. The default ceiling is 2;
//    dprCap: 1 quarters the pixels written per frame on a DPR-2 display.
const field = createField(canvas, { render: 'dots', dprCap: 1 });

// 2. Or wire the same adaptive governor <field-root> uses: feed it frame spacing and
//    forward tier changes — the engine caps DPR + drops the heatmap on its own levers.
const governor = new QualityGovernor();
let last = 0;
const tick = (t: number) => {
  const dt = last ? t - last : 0;
  last = t;
  if (dt > 0 && dt < 500) {          // skip discontinuity frames (tab switch, sleep)
    const tier = governor.feed(dt);
    if (tier !== undefined) field.setQualityTier(tier);
  }
  requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
document.addEventListener('visibilitychange', () => { last = 0; governor.reset(); });

// 3. Skip all draw work whenever your canvas is hidden or scrolled away.
field.setVisible(false);   // simulation + feedback signals stay live

Performance budget

The engine ships a PerformanceBudget — a set of default limits you can check your scene against at runtime. Use inspectBudget() from @fundamental-engine/core:

Budget check
import { inspectBudget, withinBudget, DEFAULT_BUDGET } from '@fundamental-engine/core';

const field = document.querySelector('field-root');

const findings = inspectBudget({
  particles: field.particleCount(),   // via FieldHandle
  bodies:    document.querySelectorAll('[data-body]').length,
  localCells: document.querySelectorAll('field-cell').length,
});
// findings: BudgetFinding[] — each over-budget dimension
// { field: 'particles', value: 680, limit: 600, over: 80 }

console.log(withinBudget({ particles: field.particleCount() })); // boolean shorthand
DimensionDefault limitNotes
particles600Pool grows on demand; no built-in shrink path.
bodies80[data-body] elements tracked by the engine.
localCells3Active <field-cell> scoped regions.
fieldLines256Field-line overlay count cap.
heatmapResolution6 pxDensity heatmap cell size (4–8 px range).
dprCap2Device pixel ratio ceiling for the canvas backing store.

inspectBudget is partial-safe — supply only the dimensions you can observe. withinBudget(counts) is the boolean shorthand. Both are pure; they do not sample the engine.

Energy accounting

handle.energy() returns a per-frame snapshot of the field's kinetic and thermal energy. The primary use case is debug overlays and the DataConsole — it tells you whether the field is active, cooling, or frozen.

Energy snapshot
const field = document.querySelector('field-root');

// pull-based — call in your rAF loop or debug tool
const { kinetic, thermal, total, count } = field.energy();
// kinetic: Σ ½·m·|v|²   thermal: Σ heat   total: kinetic + thermal

What the runtime doesn't measure (yet)

The engine core has no self-measurement of frame cost. inspectBudget() can tell you particle count is over limit, but can't tell you if that's actually causing a frame-time problem. The highest-priority gaps:

  • Frame duration split — no simDuration / renderDuration split. Without it, you can't tell whether the bottleneck is the physics tick or the canvas draw.
  • Self-governance outside <field-root> — the adaptive governor shipped (#413), but only the element feeds it: a raw createField field never measures its own frame cost and never responds to sustained overrun on its own (see the unguarded path above).
  • Per-force timing — some force types (hunt, metaball, shaped source) are meaningfully more expensive than others with many bodies. No forceTimings map today.

The first slice of this shipped as createFieldPerf in @fundamental-engine/dom — whole-frame timing (fps, detected budget, percentiles, dropped frames, optional LoAF/TBT), fed rAF timestamps from your own loop. The sim/render split and per-force timings remain open. See docs/engine-reference/observable-surface.md for the full gap analysis.