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.
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:
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.