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:
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 —
scrollHeightis 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:
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
visibilitychangeand resumes on return. - Reduced motion —
prefers-reduced-motionfreezes the sim (dt = 0) to a single static frame. - Field Cells —
<field-cell>demos gate their loop on anIntersectionObserver, 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:
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 | Dimension | Default limit | Notes |
|---|---|---|
particles | 600 | Pool grows on demand; no built-in shrink path. |
bodies | 80 | [data-body] elements tracked by the engine. |
localCells | 3 | Active <field-cell> scoped regions. |
fieldLines | 256 | Field-line overlay count cap. |
heatmapResolution | 6 px | Density heatmap cell size (4–8 px range). |
dprCap | 2 | Device 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.
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/renderDurationsplit. Without it, you can't tell whether the bottleneck is the physics tick or the canvas draw. - Adaptive quality governor — currently
prefers-reduced-motionis 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
forceTimingsmap 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.