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.

Looking for which knob to turn? This page is the architecture of the cost — the scheduler and what runs each frame. The performance-tuning cookbook is the tuning layer: density, dprCap and qualityTier measured on a live pool, with inspectBudget.

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 reflow — scrollHeight 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 motion — prefers-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.