Performance

Cheap by design

field-ui 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.

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.

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 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.
  • Adaptive quality governor — currently prefers-reduced-motion is the only quality lever, and it is binary (full physics or full freeze). No automatic response to sustained frame overrun.
  • Per-force timing — some force types (hunt, metaball, shaped source) are meaningfully more expensive than others with many bodies. No forceTimings map today.

These are tracked as the FieldPerf design direction — a proposed interface that would be observable the same way energy() is. See docs/engine-reference/observable-surface.md for the full gap analysis.