---
name: set-anima-viewport-performance
description: The AnimaViewportPerformanceSystem, the one strategy every Set scene that draws Animas uses to fit what it draws to the screen it is on, namely the drawing buffer and its density, adaptive density under GPU load, antialiasing, shadow resolution, the floor texture, mesh detail and resizing, with a forecast for every common canvas and zoom. Use when building a scene that must look the same on a phone and a desktop, when a scene is slow or soft on some screen, when choosing what a renderer may reduce, or when reproducing how Set's scenes render.
license: "Free to use when obtained from set.world, commercially too (the API, what it returns and the skills). Its models, animations and textures, Anima included, are CC BY 4.0: free to use with attribution to Jimmy Lee (j@internet.dev). The source code of Set is © Jimmy Lee under CC BY-NC 4.0 and its commercial use needs a paid waiver."
metadata:
  version: "0.15.1"
  title: Viewport performance
---

# Viewport performance (AnimaViewportPerformanceSystem)

The AnimaViewportPerformanceSystem (`set-world-0-15-1-viewport-5`) decides, every frame, how much a scene draws: how many pixels, how many samples per pixel, how sharp its shadows and floor are and how many triangles each Anima keeps. It fits all of that to what the screen can actually show, so a small canvas does not pay for detail it cannot display and a close-up gets everything. It is part of the scenes ([Scenes](https://set.world/skills/anima-scenes)), not of the Anima System's rules: it never changes an outcome, a position, a hit or a number a game reads. Its math is §13.67 of [Algorithms](https://set.world/skills/algorithms). The order in which a scene's work is loaded (models, armor, the floor, compiles), and which goes first, is [Orchestration](https://set.world/skills/anima-orchestration)'s.

It runs in every scene that draws Animas. The crowds: `/anima-scene` and the pages built on its stage (`/anima-stratagem`, `/anima-battle`, `/anima-battle-effects`), **Battle with armies** on the home page, `/anima-all-weapons` and `/anima-model-teams`. The close-ups: `/anima`'s previews, `/anima-object-models` and the Anima Preview, which take every rule here except mesh detail, so they keep every triangle. The portraits (stills), the dungeon (its own fixed pixel size) and the craft stone keep their own rules.

## One engine for every screen

The system reads five measured inputs and nothing else:

| Input | Read from | Moves |
| --- | --- | --- |
| Canvas size | The canvas's CSS box (`ResizeObserver`) | The drawing buffer, the camera fit |
| Pixel ratio | `devicePixelRatio`, watched with the `(resolution: Ndppx)` media query | The density |
| Pinch zoom | `visualViewport.scale`, clamped to 1…2 | The density, the on-screen size |
| Camera zoom | The scene's own camera | Everything sized by the screen: detail, cloth, shadows, floor, antialiasing |
| Frame time | The frame interval, the main thread's work for it, and the GPU's time where the browser exposes timer queries | The adaptive density |

It never detects a device, a GPU, an input type or a platform, and nothing is tuned for one. A phone and a desktop with the same canvas, pixel ratio, zoom and frame rate draw the same frame, so web and mobile get the same experience. The only other things read are hard limits a GPU reports (the largest renderbuffer and texture, the most multisamples) and, for work that must scale to capacity, the reported core count (the cloth and mesh-detail workers).

## The plan for one frame

The schema of what a frame decides, as a game's own record (hypothetical, the scene's):

```ts
type ViewportInputs = {
  cssWidth: number; cssHeight: number;   // the canvas's CSS box
  devicePixelRatio: number; pinch: number; // pinch is visualViewport.scale, 1 when not pinched
  cssPerWorldUnit: number;                // CSS pixels one world unit spans at the current camera zoom
  shadowHalfExtent: number;               // half the key light's full shadow square, in world units
  frameMs: number; workMs: number; gpuMs: number | null;
};

type ViewportPlan = {
  nativeDensity: number;   // drawing-buffer pixels per CSS pixel before any adaptive step
  density: number;         // after the adaptive step
  drawingBuffer: { width: number; height: number };
  samples: number;         // multisamples per pixel
  detailTier: 0 | 1 | 2;   // from the Anima's on-screen height
  meshCell: number | null; // the detail-level cell in model units, null for full meshes
  shadowSquare: number;    // the key light's full shadow square, in texels a side
  floorScale: number;      // the floor texture's scale of its full board
};
```

How each field is computed, in the order a frame computes them:

```ts
function planViewport(input: ViewportInputs, adaptiveScale: number, crowd: boolean, previous: ViewportPlan): ViewportPlan {
  const screenPerWorld = input.cssPerWorldUnit * input.pinch;               // on-screen size, pinch included
  const nativeDensity = Math.min(input.devicePixelRatio, 2) * input.pinch; // then capped by the GPU's renderbuffer limit
  const density = Math.max(Math.min(nativeDensity, 1), nativeDensity * adaptiveScale);
  const animaHeight = 3.02 * screenPerWorld;           // CSS pixels, feet to ear tips
  const detailTier = tierOf(animaHeight);                                   // 100 and 50 CSS pixels, with hysteresis
  return {
    nativeDensity, density,
    drawingBuffer: { width: Math.floor(input.cssWidth * density), height: Math.floor(input.cssHeight * density) },
    samples: previous.samples || (nativeDensity >= 1.75 ? 2 : 4), // chosen once, then kept
    detailTier,
    meshCell: crowd ? meshCellFor(1 / screenPerWorld) : null,               // Item models' detail rule; close-ups keep full meshes
    shadowSquare: powerOfTwoNear(2 * input.shadowHalfExtent * screenPerWorld / 1.6), // 512 to 8192
    floorScale: coarsestFloorScaleCovering(input.cssPerWorldUnit * nativeDensity),  // 1 texel per rendered pixel
  };
}
```

`tierOf`, `meshCellFor`, `powerOfTwoNear` and `coarsestFloorScaleCovering` each keep the previous answer inside a hysteresis band, so a slow zoom never flickers between two levels. The scene's camera keeps at least 56 world units across and 38 tall, opening at zoom 1.2, with orbit zoom from 0.6 to 7: on a canvas narrower than their ratio its width sets the scale, on a wider one its height does.

## Every screen we plan for

The tables below are computed from the same functions the scenes run, for `/anima-scene`'s camera, so they change when the rules do. A canvas is the scene's own CSS box, not the window: the page's controls take the rest.

At the home view:

| Canvas | CSS pixels | Pixel ratio | Density | Drawing buffer | Lowest adaptive density |
| --- | ---: | ---: | ---: | ---: | ---: |
| Small phone, portrait | 375 × 273 | 2 | 2.00 | 750 × 546 | 1.00 |
| Phone, portrait | 393 × 379 | 3 | 2.00 | 786 × 758 | 1.00 |
| Phone, landscape | 852 × 300 | 3 | 2.00 | 1704 × 600 | 1.00 |
| Tablet, portrait | 820 × 926 | 2 | 2.00 | 1640 × 1852 | 1.00 |
| Tablet, landscape | 1180 × 626 | 2 | 2.00 | 2360 × 1252 | 1.00 |
| Laptop | 1470 × 712 | 2 | 2.00 | 2940 × 1424 | 1.00 |
| Laptop at 125% | 1536 × 610 | 1.25 | 1.25 | 1920 × 762 | 1.00 |
| 1080p monitor | 1920 × 840 | 1 | 1.00 | 1920 × 840 | 1.00 |
| 4K monitor at 150% | 2560 × 1185 | 1.5 | 1.50 | 3840 × 1777 | 1.00 |
| 5K or 4K monitor at 200% | 2560 × 1185 | 2 | 2.00 | 5120 × 2370 | 1.00 |
| Ultrawide monitor | 3440 × 1185 | 1 | 1.00 | 3440 × 1185 | 1.00 |

By zoom: the Anima's on-screen height, its detail tier, its mesh cell (model units; `full` keeps every triangle), the samples, the requested shadow square and the floor scale.

| Canvas | Zoom 0.6 | Zoom 1.2 | Zoom 7 |
| --- | --- | --- | --- |
| Small phone, portrait | 12 px · tier 2 · cell 0.192 · 2× MSAA · shadow 512 · floor 0.5 | 24 px · tier 2 · cell 0.192 · 2× MSAA · shadow 512 · floor 0.5 | 141 px · tier 0 · cell 0.064 · 2× MSAA · shadow 2048 · floor 1.5 |
| Phone, portrait | 13 px · tier 2 · cell 0.192 · 2× MSAA · shadow 512 · floor 0.5 | 25 px · tier 2 · cell 0.192 · 2× MSAA · shadow 512 · floor 0.5 | 148 px · tier 0 · cell 0.064 · 2× MSAA · shadow 2048 · floor 1.5 |
| Phone, landscape | 14 px · tier 2 · cell 0.192 · 2× MSAA · shadow 512 · floor 0.5 | 29 px · tier 2 · cell 0.192 · 2× MSAA · shadow 512 · floor 0.5 | 167 px · tier 0 · cell 0.048 · 2× MSAA · shadow 2048 · floor 1.5 |
| Tablet, portrait | 26 px · tier 2 · cell 0.192 · 2× MSAA · shadow 512 · floor 0.5 | 53 px · tier 1 · cell 0.128 · 2× MSAA · shadow 1024 · floor 1 | 309 px · tier 0 · cell 0.032 · 2× MSAA · shadow 4096 · floor 1.5 |
| Tablet, landscape | 30 px · tier 2 · cell 0.192 · 2× MSAA · shadow 512 · floor 0.5 | 60 px · tier 1 · cell 0.128 · 2× MSAA · shadow 1024 · floor 1 | 348 px · tier 0 · cell 0.024 · 2× MSAA · shadow 4096 · floor 1.5 |
| Laptop | 34 px · tier 2 · cell 0.192 · 2× MSAA · shadow 512 · floor 0.5 | 68 px · tier 1 · cell 0.128 · 2× MSAA · shadow 1024 · floor 1 | 396 px · tier 0 · cell 0.024 · 2× MSAA · shadow 4096 · floor 1.5 |
| Laptop at 125% | 29 px · tier 2 · cell 0.192 · 4× MSAA · shadow 512 · floor 0.5 | 58 px · tier 1 · cell 0.128 · 4× MSAA · shadow 1024 · floor 0.5 | 339 px · tier 0 · cell 0.024 · 4× MSAA · shadow 4096 · floor 1.5 |
| 1080p monitor | 40 px · tier 2 · cell 0.192 · 4× MSAA · shadow 512 · floor 0.5 | 80 px · tier 1 · cell 0.096 · 4× MSAA · shadow 1024 · floor 1 | 467 px · tier 0 · cell 0.016 · 4× MSAA · shadow 8192 · floor 1.5 |
| 4K monitor at 150% | 56 px · tier 1 · cell 0.128 · 4× MSAA · shadow 1024 · floor 1 | 113 px · tier 0 · cell 0.064 · 4× MSAA · shadow 2048 · floor 1.5 | 658 px · tier 0 · cell 0.012 · 4× MSAA · shadow 8192 · floor 1.5 |
| 5K or 4K monitor at 200% | 56 px · tier 1 · cell 0.128 · 2× MSAA · shadow 1024 · floor 1 | 113 px · tier 0 · cell 0.064 · 2× MSAA · shadow 2048 · floor 1.5 | 658 px · tier 0 · cell 0.012 · 2× MSAA · shadow 8192 · floor 1.5 |
| Ultrawide monitor | 56 px · tier 1 · cell 0.128 · 4× MSAA · shadow 1024 · floor 0.5 | 113 px · tier 0 · cell 0.064 · 4× MSAA · shadow 2048 · floor 1 | 658 px · tier 0 · cell 0.012 · 4× MSAA · shadow 8192 · floor 1.5 |

Read the tables this way: a phone renders at its full 2× like a laptop, its Animas are smaller because its canvas is, so it keeps fewer of the triangles it could not show and a smaller shadow and floor; zooming in on the phone brings back exactly what the laptop shows at the same on-screen size.

## Every way a viewport changes

| Change | What happens |
| --- | --- |
| A window is resized, dragged or snapped | Inside the size callback the camera refits, every level is recomputed and the frame is drawn, so no frame is shown stretched or cleared. While the size stays within 25% of the drawing buffer, the buffer is kept and drawn with the new aspect; the exact size is allocated once the size has rested 160 ms |
| The page's layout reflows (controls wrap, a phone rotates, the address bar hides) | The same: it arrives as a size change |
| The browser zooms (Ctrl or Cmd plus or minus) | The CSS size and the pixel ratio change together; both are watched |
| The window moves to a screen with another pixel ratio | The resolution query fires; the density and the drawing buffer follow |
| The page is pinched | The density follows the pinch up to 2×, and detail, cloth and shadows follow the larger on-screen size |
| The camera zooms | Detail tier, mesh cell, cloth rate, shadow square and floor scale follow, each with hysteresis; the samples never change. Animas the view and its shadow window cannot show are not posed ([Scenes](https://set.world/skills/anima-scenes), Crowds) |
| The GPU falls behind | The adaptive density steps down (below) |
| The scene is hidden or offscreen | It stops drawing; nothing is measured or caught up while hidden |

## Adaptive density

The one change a viewer could notice, and only while the GPU cannot keep up. Each scene names its frame-rate target (60, or the rate it is capped at). While the smoothed frame rate stays under 40 FPS at a 60 target and the GPU is the bottleneck, the density steps down through the scales 1, 0.9, 0.8, 0.7, 0.6, 0.5, a tenth at a time, never under 1 drawing-buffer pixel per CSS pixel.

- **The GPU is the bottleneck** when its measured time fills at least 75% of the frame. Where the browser exposes no timer, when the main thread's work is under 60% of the frame.
- **Each step is a probe.** It is kept only if the frame rate rises by 10%; otherwise it is undone. After 2 undone steps the scene stops stepping down, so a screen that caps its own frame rate keeps its resolution and never flickers.
- **Stepping back up** waits until the frame rate holds 55 FPS at a 60 target and, where the GPU is timed, until the finer step's projected cost fits the frame.
- Nothing else follows a step: the samples stay as chosen, and the floor keeps the level of the density before the step, so a slow GPU never rebuilds it.

## Antialiasing

A scene renders into its own multisampled target and copies it to the canvas, keeping each material's own tone mapping and sRGB output. It chooses the sample count once, when it first sizes itself: 4 samples while a rendered pixel is coarser than 1/1.75 of a CSS pixel, 2 on a finer screen. Never change it while the scene runs. Chrome and Safari on macOS and iOS draw WebGL through Metal, which builds a render pipeline per shader program for each sample count, so a new count rebuilds every pipeline the scene uses: in a traced 32-Anima battle, letting the count follow the zoom froze two frames for about 9 s each. The target relies on three.js revision 186; under any other revision the scene uses the canvas's own antialiasing instead.

## What never runs on a frame

- **Mesh detail levels** are built in workers, one level at a time and only for the zoom the view settles on; until a level is ready the scene draws the nearest finer level it already has.
- **The floor** is painted in a worker: the page decodes the board's SVG and the worker paints the concrete over it and returns one byte a texel, its shade, which the floor's material turns back into the palette's colours, so a floor costs a quarter of a colour image in memory and on the GPU. Without a worker the same code runs in short slices. Set's pages keep the half and full boards for the page's life once painted, whether the idle preloader or a scene painted them, so each is painted at most once a page and a scene seldom paints its floor at all.
- **Transparent double-sided materials** draw in one pass (`forceSinglePass` in three.js). Otherwise three.js draws each one twice and re-resolves its shader program before each pass, every frame.
- **A copy of a skinned body** (a ghost or a mirror image) shares the body's bounding sphere, so sorting it never skins every vertex on the CPU.
- **Every cost is named in a trace.** Each of these writes a User Timing entry beginning `Set ` (floor build, paint and upload, detail level, drawing buffer, antialiasing, adaptive density), so a DevTools performance trace shows what each one cost on that screen.

## Shadows, floor and detail

- **Shadows** (`set-world-0-15-1-shadow-window-2`, crowds; a close-up's studio shadow fits the Anima's own bounds). The key light's shadow square takes the power of two, from 512 to 8192, at which one texel spans about 1.6 on-screen pixels, and only the part of it the view can show is drawn. How often it redraws follows the detail tier ([Scenes](https://set.world/skills/anima-scenes), Crowds).
- **Floor** (`set-world-0-15-1-concrete-board-4`). The floor is built from the coarsest of the scales 0.5, 1, 1.5 of its 2200 × 1600-texel board that gives 1 texel per rendered pixel, at most scale 1.5 on every device. A finer one is prepared in the background as the camera zooms in; a coarser one only after the view has stayed out for 1.5 s. A scene that knows its first view starts at the right scale; a close-up starts at the full board and settles. Each scale is uploaded to the GPU as soon as it is ready, never on a drawn frame, and the scene times each upload (the main thread's time and, where the browser exposes timer queries, the GPU's) so the cost of a sharper floor is known on every screen. Every scale is the same floor: the concrete is defined on the full board's texels and the board's vector markings are drawn at the scale's own size.
- **Mesh detail** (crowds only). Worn items, held items in `/anima-scene` and the body drop only detail smaller than the rule in [Item models](https://set.world/skills/anima-item-models) allows; zooming in returns the full mesh. Purely visual physics (cloth rate, book pages) eases off as Animas get small on screen, and nothing that decides a fight does: hit recoil shapes where later hits land, so it always runs in full, and every pose a rule reads comes from the simulation step, never a drawn frame.

## Limits

- The tables are computed from the rules for a given canvas, pixel ratio and zoom; frame rates, GPU cost and floor upload times are measured live on the page and are not forecast.
- Adaptive density only lowers resolution under measured load; it never caps the frame rate, hides slow frames or drops detail a viewer could see.
- The system plans one camera at a time. Each scene keeps its own framing: the home army's overhead camera and the weapon field's fit give other on-screen sizes from the same rules.

## What stays the game's decision

A game that renders its own scenes chooses its own frame-rate target, the inputs it trusts and how far it lets resolution fall. The rules above are Set's choices for its own scenes; a replica that follows them renders as Set's scenes do.
