Cookbook

Performance tuning

Three dials carry almost all of the cost: how much matter there is, how many device pixels it is drawn into, and how much the engine is allowed to give up when a device cannot keep pace. This page measures each one rather than asserting it.

Shipped · measured at build time
Different page, different question. /docs/performance explains the six-phase scheduler and what runs each frame — the architecture of the cost. This page is the tuning: which knob to turn, and what it actually does.

density — the pool is 130 × density, field-wide

density is a particle-count multiplier over a base of 130. The number to internalise is that this is a field-wide count, not a per-area one: the pool does not grow because your field is large, and does not shrink because it is small.

That single fact explains the most common contained-field complaint. A card-sized field still holds 130 particles spread across the whole volume, so each body gathers less and --d reads low. The fix is to raise density for a small field, not lower it — see contained fields.

JS ✓ Swift ✓ Kotlin —

dprCap — the backing-store ceiling

The effective device-pixel ratio is min(devicePixelRatio, dprCap). On a 3× display an uncapped field fills nine times the pixels of a 1× one for the same CSS box, which is why the shipped budget caps it at 2. Lower it before you start cutting matter: it costs sharpness, not behaviour.

TypeScript
// The backing store is the dominant fill cost on a retina display.
// Effective DPR is min(devicePixelRatio, dprCap).
const field = createField(canvas, { host, dprCap: 1.5 });

field.setDprCap(1); // and it is runtime-settable

JS ✓ Swift ✓ Kotlin ✓

qualityTier — the degradation ladder

A tier is a coarse, reversible statement of how much the engine may give up: 0 full, 1 effects reduced, 2 minimal, 3 paused. At tier 2 and above the engine caps the effective DPR and drops the heatmap on its own levers. The QualityGovernor detects sustained overruns and tells you which tier to ask for; <field-root> wires that loop for you, and a raw createField does not.

TypeScript
import { QualityGovernor } from '@fundamental-engine/dom';

// The governor DETECTS sustained overruns; you decide what to do about them.
// <field-root> wires this for you. On a raw createField you own the loop:
const gov = new QualityGovernor(16.67);

function frame(t) {
  const tier = gov.feed(t);          // 0 full · 1 effects reduced · 2 minimal · 3 paused
  if (tier !== null) field.setQualityTier(tier);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
A cross-platform asymmetry worth knowing. On JS, setQualityTier is write-only — the handle exposes no property to read the tier back, as the example below measures. The Swift and Kotlin handles both publish one. The support row is generated from the parity matrix, so it cannot drift from the ports: JS — Swift — Kotlin ✓ the read-back property — not on JS

Judging a configuration before it ships

inspectBudget(counts) compares a configuration against the shipped budget and returns one finding per breach — no field required, so it runs in a unit test or a build step.

The dials, measured apps/site/src/lib/cookbook/tuning.ts
Runs at build time
// PERFORMANCE TUNING — the three dials that actually move the cost.
//
//   density      the particle-count multiplier. The pool is `130 × density`, rounded — a FIELD-WIDE
//                count, NOT a per-area one. (This is why a small contained field reads a low `--d`:
//                the same 130 particles are spread across the whole volume, so a card-sized field
//                needs a HIGHER density, not a lower one.)
//   dprCap       the device-pixel-ratio ceiling. Effective DPR is `min(devicePixelRatio, dprCap)`;
//                the backing store is the dominant fill cost on a retina display.
//   qualityTier  the degradation ladder: 0 full, 1 effects reduced, 2 minimal, 3 paused. The engine
//                caps DPR and drops the heatmap at tier 2+ on its own levers, reversibly.
//
// `inspectBudget(counts)` judges a CONFIGURATION against the shipped budget before you ship it.
import { createField, headlessHost, seededRng, inspectBudget, DEFAULT_BUDGET } from '@fundamental-engine/core';

export interface TuningResult {
  /** the pool at density 1 — the `130 × density` rule, measured. */
  particlesAtDensity1: number;
  /** …and at 0.25: a quarter of the matter, a quarter of the integration cost. */
  particlesAtQuarter: number;
  /** the shipped budget's particle ceiling. */
  budgetParticles: number;
  /** a deliberately over-budget configuration, judged before it ships. */
  findings: { field: string; value: number; limit: number; over: number }[];
  /** JS takes the tier but exposes NO read-back property for it — `setQualityTier` is write-only
   *  here, where the Swift and Kotlin handles both publish a `qualityTier` property. */
  tierReadableBackOnJs: boolean;
}

function poolAt(density: number): number {
  const host = headlessHost({ width: 1200, height: 800 });
  const field = createField(undefined as unknown as HTMLCanvasElement, {
    host,
    render: 'none',
    density,
    rng: seededRng(29),
  });
  host.tick();
  const n = field.particleCount();
  field.destroy();
  return n;
}

export function runTuning(): TuningResult {
  const particlesAtDensity1 = poolAt(1);
  const particlesAtQuarter = poolAt(0.25);

  // judge a configuration against the shipped budget — no field required
  const findings = inspectBudget({ particles: 900, bodies: 40, dprCap: 3 }).map((f) => ({
    field: String(f.field),
    value: f.value,
    limit: f.limit,
    over: f.over,
  }));

  // a sustained-overrun response: the governor detects, you forward the tier, the engine adapts
  const host = headlessHost({ width: 1200, height: 800 });
  const field = createField(undefined as unknown as HTMLCanvasElement, {
    host,
    render: 'none',
    density: 1,
    dprCap: 2,
    rng: seededRng(31),
  });
  field.setQualityTier(2); // what QualityGovernor.feed() would hand you on a slow device
  host.tick();
  // Measured, not assumed: the JS handle carries no `qualityTier` property to read it back from.
  const tierReadableBackOnJs = 'qualityTier' in field;
  field.destroy();

  return {
    particlesAtDensity1,
    particlesAtQuarter,
    budgetParticles: DEFAULT_BUDGET.particles,
    findings,
    tierReadableBackOnJs,
  };
}
What it returned when this page was built
particlesAtDensity1
130
particlesAtQuarter
33
budgetParticles
600
findings
[{"field":"particles","value":900,"limit":600,"over":300},{"field":"dprCap","value":3,"limit":2,"over":1}]
tierReadableBackOnJs
false

130 particles at density 1 and 33 at 0.25 — the rule, measured on a live pool rather than quoted. The findings array is a real over-budget verdict, and the tier read-back is false on JS.

The order to turn them

  • Measure first. createFieldPerf gives you fps, frame budget, percentiles and dropped frames from your own rAF timestamps.
  • Cap the pixels. dprCap costs sharpness only.
  • Then cut matter. density changes how the field feels; do it deliberately.
  • Let the governor handle the bad device. A tier is reversible; a hard cutoff is not.