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:
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.
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
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.
createField is the unguarded path
<field-root> protects itself. Its wiring, all automatic:
- Adaptive quality (#413) — the platform runtime feeds a
QualityGovernorrAF-to-rAF frame spacing and forwards each tier change tosetQualityTier, 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
IntersectionObserveron the element callssetVisible(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-modelayer 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.
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.
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:
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 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/renderDurationsplit. 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 rawcreateFieldfield 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
forceTimingsmap 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.