---
name: incanto-performance
description: Graphics settings the engine provides so a game does not have to invent them — quality tier (shadows/bloom/post/clouds), frame cap, resolution scale, and the one lever (antialiasing) that can only change at boot. They persist, they apply themselves, and the ones that can change live do. Use when supporting low-end devices or building a settings menu.
---

# incanto — performance settings (quality tier, frame cap, resolution)

The settings a player expects to find in a graphics menu, provided by the engine so
your game does not have to invent them: **quality tier**, **frame cap**, **resolution
scale**. They persist, they apply themselves, and the ones that can change live do.

You do not implement any of this. You decide whether to expose it.

---

## First: where did the frame go?

A settings menu is what you offer a PLAYER. This is what you ask the game.

```ts
game.stats();
// { fps: 31, frameMs: 32.1, nodes: 4210, triangles: 890_000, drawCalls: 340,
//   phases: { fixedMs: 21.4, updateMs: 2.2, renderMs: 6.1, otherMs: 2.4 } }
```

`fps` and `frameMs` are a total, and a total has no next question. The four
slices say which part of the frame to look at:

| slice | what is in it | what to do about it |
| --- | --- | --- |
| `fixedMs` | physics and everything on `fixedUpdate` | fewer/simpler colliders, `fixedHz`, sleep distant bodies |
| `updateMs` | behaviors, node logic, tweens | the per-frame work your game does — profile it in your own code |
| `renderMs` | the renderer, reported by itself | quality tier, `renderScale`, draw calls, shadows |
| `otherMs` | the frame MINUS the three above | GC, browser layout, your own rAF work. **Allocations show up here.** |

The debug overlay's stats chip shows the same four numbers live
(`phys · logic · draw · other`), so you do not have to call anything to see a
frame go wrong while you are playing.

`otherMs` is the one worth knowing about. A frame drop that everyone reads as a
GPU problem is often garbage collection, and it looks identical from the outside
— this engine lost a day to exactly that. A big `otherMs` with small everything
else means you are allocating per frame: replace arrays and objects created in
`update()` with reused ones.

Averages over the same rolling window as `frameMs`, so the numbers add up to it
and are comparable to each other. Headless (`step()`), `fps`/`frameMs` are 0 and
the slices are still real CPU times — a behavior that got slower shows up in a
test.

### Measuring a frame by hand: `step()` ALREADY renders

The renderer subscribes to `engine.updated`, so a frame is one call:

```ts
const t0 = performance.now();
for (let i = 0; i < 120; i++) game.engine.step(1 / 60);   // draws, too
const frameMs = (performance.now() - t0) / 120;
```

Calling `game.renderer.render(...)` after `step()` draws the scene TWICE and
doubles every number you take. It reads as a plausible result rather than an
obvious mistake — the flagship template measured 24.9 ms a frame that way and
**8.96 ms** the honest way, which is the difference between "this does not hold
60" and "this holds 60 twice over".

The same trap is why a hidden tab needs care: a tool-created tab has its
animation frames paused, so driving `step()` yourself is the only way to get
frames — and the one call is the whole frame.

For reference, the numbers `bunx incanto-new` scaffolds (Beacon Isle, an M-class
laptop, 220 nodes and 29 `Tree3D` groves):

| tier | frame | render | draw calls | triangles |
| --- | --- | --- | --- | --- |
| high | 8.96 ms | 8.44 ms | 999 | 7.3 M |
| medium | 7.75 ms | 7.04 ms | 580 | 6.2 M |
| low | 7.17 ms | 6.68 ms | 576 | 6.2 M |

The tier is doing its job — it takes 42% of the draw calls out — and the render
is where a 3D game's frame goes, which is what the dials in this skill are for.

## The short version

```ts
const game = await createGame3D({ canvas, scene });

// createGame3D has ALREADY done all of this:
//   - picked a starting tier from the device, on a first visit only
//   - read the saved tier at boot for antialiasing (see "boot vs live" below)
//   - bound the tier, the frame cap and the resolution scale to the live game

game.engine.settings.set('quality', 'low');   // shadows/bloom/post drop THIS FRAME
game.engine.settings.set('maxFps', 30);       // the loop slows down, and stays cool
game.engine.settings.set('renderScale', 0.7); // fewer pixels, same world
```

Every one of those is written to the save store and comes back on the next launch.

---

## Leaks: a level swap has to give the GPU back

`stats().geometries` and `stats().textures` are the leak witnesses — they count
what three is holding right now. Swap levels a few times and read them again:

```ts
const before = game.stats().geometries;
await loadLevel(2);
await loadLevel(1);
game.stats().geometries; // should be ~before, not 2x before
```

Every node that builds geometry releases it when it is freed, and a scene swap
frees the old tree. If your own code holds a three object (a custom behavior
that built a mesh), dispose it in the behavior's `onExitTree`.

## The four levers, and what each actually costs

| setting | values | costs | changes live |
| --- | --- | --- | --- |
| `quality` | `'low'` `'medium'` `'high'` | shadows, bloom, post, clouds | **yes** |
| `maxFps` | `0` (uncapped) or fps | nothing visual — battery and heat | **yes** |
| `renderScale` | `0.25`–`2` | image softness only | **yes** |
| antialiasing | follows the tier | jagged edges at `low` | **no — boot only** |

`maxFps` and `renderScale` apply in **2D as well as 3D** now — they persisted for
every game and were applied by the 3D boot only, so a 2D options menu saved a
number and changed nothing. `quality` remains a 3D tier (there are no shadows,
bloom or clouds to drop in 2D).

### quality

**Water follows the tier too, and it is the biggest single saving.**

| tier | what water does |
| --- | --- |
| `high` | everything the scene asked for |
| `medium` | fancy shader, **no planar mirror** (the cube still gives it sky and far shore) |
| `low` | single-pass lake shader — no mirror, no cube, no grab, no depth pass |

**Big water does not collapse at `low`.** The simple shader's only waterline is
a fade of its own rectangle's outer 18%, so a surface that does not fill its
basin — an ocean plane with an island in the middle — would meet the beach in a
razor-straight line with no surf. Above a ~110 m plane the tier keeps the fancy
shader and takes `medium`'s behaviour instead (no mirror). Small water, which is
what most games have, still gets the full saving.

At `low` several full scene re-renders a frame become **none**, and the render
targets are released rather than held. At `medium` the mirror — a whole extra
render of the scene, per water node, per frame — goes, and the reflection cube
is held to a 2 s floor so the saving is not handed straight back to it.

A surface the scene authored as `simple`, or one that never asked for a mirror,
is untouched — a tier only ever takes away.

`low` turns off shadows, bloom, post and clouds. `medium` keeps shadows but makes them
**static** (they render once instead of every frame — the measured 42% of a shadow's
cost is re-rendering it) and drops post. `high` is everything.

The tier is applied as an environment patch, so a scene that never had bloom does not
gain any at `high` — the tier can only take things away or restore what the scene asked
for.

### maxFps

Gates the **whole frame**, not just the render: an uncapped loop on a 144 Hz phone runs
game logic 144 times a second that nobody will ever see. `0` means "as fast as the
display goes" and is the default — a cap nobody asked for is a downgrade.

`dt` still comes from the clock, so a capped game is not a slow game. Physics is fixed-
step and unaffected.

### renderScale

The cheapest lever on a weak GPU. At `0.66` the fragment shaders run over **44%** of the
pixels; the image goes soft and nothing else changes. Compare that with dropping a
quality tier, which costs the shadows outright. On a phone that cannot hold 60, try
`renderScale` before `quality`.

It multiplies with the engine's automatic resolution governor rather than fighting it,
so a player who already scaled down does not get scaled down twice.

---

## Boot vs live — the one thing that cannot change while running

`antialias` is a **WebGL context attribute**. three fixes it when the context is
created, and there is no API anywhere that changes it afterwards. So:

- the tier's antialiasing is read **at boot** from the saved setting
- changing `quality` mid-game changes shadows/bloom/post immediately, and the
  antialiasing on the **next launch**

If your settings menu wants to be honest about that, say "applies on restart" next to
antialiasing only. Everything else is immediate.

A scene's own `environment.rendering` and an explicit `pixelRatio` option both still win
over the tier — an author who pinned a value meant it.

---

## Detection runs once, and a choice is permanent

```ts
engine.settings.autoQuality();          // first visit only — picks from the device
engine.settings.chooseQuality('high');  // a HUMAN chose; never auto-detect again
```

`createGame3D` calls `autoQuality()` for you. Call `chooseQuality(tier)` — not
`set('quality', tier)` — from a settings menu, so re-detection on the next launch does
not silently undo it. (A separate `qualityChosen` flag is needed because a chosen tier
that happens to equal the detected one is otherwise indistinguishable from no choice.)

Device hints used: `hardwareConcurrency`, `deviceMemory`, and whether the pointer is
coarse. They are hints; a player's own choice always wins.

---

## The menu itself is three nodes

You do not build a graphics menu. You place it.

```json
{ "name": "Graphics", "type": "UiPanel", "props": { "anchor": "center" },
  "children": [
    { "name": "Quality",    "type": "UiQualitySelect" },
    { "name": "FrameCap",   "type": "UiFrameCapSelect" },
    { "name": "Resolution", "type": "UiRenderScaleSelect" }
  ] }
```

No script, no signal connections, no `Settings` knowledge. Each node reads the
setting it owns, writes it when the player picks, and follows a change made
anywhere else — so two menus (a title screen and a pause screen) can never
disagree.

| node | writes | reads as |
| --- | --- | --- |
| `UiQualitySelect` | `chooseQuality()` | Low / Medium / High |
| `UiFrameCapSelect` | `maxFps` + `engine.maxFps` | 30 / 60 / 120 / Unlimited |
| `UiRenderScaleSelect` | `renderScale` | 50% / 75% / 100% |

`UiQualitySelect` goes through **`chooseQuality`**, not `set('quality')`, so the
next launch's device detection cannot undo what a person picked.

Restrict or extend the choices with `options` — `"60,120,0"` on a game that is
unplayable at 30, `"0.5,0.75,1,1.5"` for supersampling on a strong GPU:

```json
{ "name": "FrameCap", "type": "UiFrameCapSelect", "props": { "options": "60,120,0" } }
```

### Localizing the labels

The option text is English unless the scene declares a string for it — a menu
reading `settings.quality.high` is worse than one reading `High`, so an
undeclared key falls back to English rather than showing the key:

```json
"strings": {
  "ko": {
    "settings.quality.low": "낮음",
    "settings.quality.medium": "보통",
    "settings.quality.high": "높음",
    "settings.frameCap.unlimited": "제한 없음"
  }
}
```

The `label` prop takes `@t:` like every other widget:
`{ "label": "@t:settings.quality" }`.

---

## Wiring your own menu

Each setting has a `bind*` that applies the saved value immediately and again on every
change, and returns an unsubscribe:

```ts
const off = engine.settings.bindFrameCap((fps) => { engine.maxFps = fps; });
engine.settings.bindRenderScale((s) => renderer.setRenderScale(s));
engine.settings.bindQuality((patch) => setEnvironment3D(engine, patch));
```

`createGame3D` already installs all three. You only reach for these when you build your
own game loop.

To read the current values for your UI:

```ts
engine.settings.get('quality');     // 'low' | 'medium' | 'high'
engine.settings.get('maxFps');      // 0 = uncapped
engine.settings.get('renderScale'); // 1 = native
```

---

## What NOT to do

- **Do not build your own frame limiter** with `setTimeout` around your update. The
  engine's cap is deadline-based; a naive `if (now - last < interval) return` snaps a
  45 fps request to 30 on a 60 Hz display, which is worse than not capping.
- **Do not lower the quality tier to fix a slow scene you can fix properly.** The tier
  is the player's lever. If your game is slow at `high` on a normal machine, run
  `bunx incanto-check` and look at the triangle and draw-call counts first.
- **Do not offer antialiasing as a live toggle.** It cannot be one. See above.
- **Do not read `devicePixelRatio` yourself.** The renderer already clamps it to 2 and
  scales it by the tier and the governor.

---

## Verifying

```ts
const { fps, triangles, drawCalls } = game.stats();
```

Set `maxFps` to 30 and confirm `fps` settles near 30 — if it settles near 20, the game
is slower than the cap and the cap is not what is limiting it.

See `incanto-verifying-your-game.md` for the full loop.


## Identical meshes share their GPU objects

`MeshInstance3D` used to make a private geometry AND material per node, even
when byte-identical. Measured at 3,200 units — all runs at the same 3,209 draw
calls, 1,741,932 triangles and an identical screenshot:

```
per-node geometry + material   31.0 ms wall  (renderMs 27.3)
share one geometry             27.0 ms
+ share two materials          15.4 ms       <- 2x the headroom
```

13.1 ms of that cliff was GPU state churn from per-node objects. Nodes now share
one geometry per (mesh, size) and one material per distinct `material` dict, and
a node whose look changes gets its own again. **Nothing to do — it is the
default.** The consequence to know: mutate node PROPS, never the three.js
material you fished out of a node, because it may not be only yours.

This does not replace `InstancedMesh3D`. A field of identical props is still one
node and one draw call there (12,800 units, 11 draw calls, 2.30 ms); sharing
helps the one-node-per-thing path, which is what a hand-authored level is.

## The quality tier only ever takes away

`high` no longer mentions `bloom` or `post` at all, because mentioning them
CREATED them: on a scene that declared neither, `high` and `medium` both
rendered a five-pass frame at 3.3 ms where `low` rendered one pass at 1.24 ms —
62% of the frame on a chain the game never asked for, and
`engine.scene.environment.bloom` read back `{strength: 0.8, threshold: 1}`.

A tier is also REVERSIBLE now: it is applied against the scene FILE rather than
against whatever the last tier left behind, so `low` → `high` restores exactly
what you authored instead of leaving it stripped.

## How big a game actually is, and the chunk named after the wrong thing

Measured on all six templates, scaffolded from the packed tarball and built:

```
                    eager     on disk
molehill-2d        967 KB      5.8 MB
platformer-2d      981 KB      5.9 MB
star-survivor      962 KB      5.9 MB
beacon-isle-3d    1414 KB      5.8 MB
tps-3d            1376 KB      5.8 MB
village-quest-3d  1469 KB      5.8 MB
```

**`dist/` is six megabytes and a player downloads one.** The rest is lazy: the
Rapier wasm builds (2D and 3D, ~4.5 MB together) load only when a scene has
physics bodies, and the editor chunk only if someone opens it. Read the
`<script>` and `<link rel=modulepreload>` tags in `dist/index.html` for what
actually loads — `du -sh dist` answers a question nobody asked.

**The vendor chunk is named after whichever module the bundler happened to pick,
and it is not the one you think.** In every one of those builds the ~790 KB
chunk is called `quiet-rapier-*.js` and contains **three.js** — no Rapier at
all. A size audit that reads names concludes the physics engine is eager in a
mouse game with no bodies in it. Read the contents:

```bash
grep -c WebGLRenderer dist/assets/quiet-rapier-*.js   # three.js lives here
```

Name them yourself if it matters to you — it changes nothing about what loads,
only what the file is called:

```ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: (id) =>
          id.includes('node_modules/three') ? 'three'
          : id.includes('@dimforge/rapier') ? 'rapier'
          : undefined,
      },
    },
  },
});
```

three is eager in a 2D game too, and that is not a bug: `Renderer2D` draws
through the same WebGL renderer as the 3D one — shared-PlaneGeometry meshes and
an orthographic camera. It is the price of one renderer instead of two.
