Frame
A frame is one call of React Three Fiber’s loop: every useFrame subscriber in the order it mounted, then the render. The engine’s Frameloop is the first subscriber, and it runs the simulation, the character’s animation and the camera in one callback. This page lists what runs, measures each line, and says which lines are worth cutting.
Measuring a frame describes how to measure it again, and Finding hitches describes how to find the frames that run long.
What one frame runs
Section titled “What one frame runs”The Frameloop mounts before any scene, so its callback runs first. Every line after the fixed steps runs once per frame, so it scales with the display’s rate; the steps do not.
- Whether drei’s loaders have a file in flight, copied into
useLoadingon a change. - The pause checks: a modal that pauses returns before anything is banked; the devtools’ pause hands the loop one owed step or none.
- The accumulator: how many whole 1/60 s steps this frame’s time buys, and the leftover.
- The browser’s input, read once: every input the game declares, the character’s move from its keys and the stick among them, then the camera’s azimuth, written into the character’s heading.
- The pending debit out of
useWallet, spent on the player’s character. stepWorld, once per step bought: the previous transforms, then the world’s systems by phase.input: the character’s path and steer.motion: the stats, then every velocity applied.rules: the day clock, the cooldowns, the behaviours, the resources and the state machines, with the rules of each plugin the game lists, such as the round.physics: every actor walked by the engine’s own character controller, with Rapier as its query tree, which is most of a step, then the physics scene’s one Rapier step.
- The wallet’s copy in
useWallet, on a change; the step count inuseTime, on every frame that stepped; while editing, the recording’s dump of the world. - The character’s pose off the step’s steer and turn.
- The transforms onto the objects, blended between the last two steps by the leftover.
- The devtools’ hidden entities hidden again.
- The character’s animation: a clip switch and its blend, the mixer, the head turn, the humanoid, the world matrices, then
vrm.update, which runs the spring bones with their colliders and the look-at. - The camera: the aim swept out from her eye line, the orbit moved to it and updated, then the eye swept in from the aim, each as a small sphere against the world’s fixed colliders and held off the ground.
- While editing, the hovered and selected entities’ screen rectangles.
Then the other subscribers, in mount order: drei’s camera controls, the daylight rig (the sun and fill from the clock), the VRM view (nothing while the character’s animation is bound), drei’s Html and PerformanceMonitor, and the game’s own, such as the mmorpg’s autosave. Last, gl.render: the shadow map, then the scene. A scene that mounts post-processing draws through its composer instead, which renders the scene and then its passes.
Three stores change during a frame. A component that subscribes re-renders after the frame, in React’s scheduler:
| Store | Written | Who reads it at the frame rate |
|---|---|---|
useWallet |
on a change of coins | a HUD’s coin count, on a pickup |
useTime |
every frame that stepped | the devtools’ playback bar while editing; a game’s HUD reads running alone |
useLoading |
on a change | a scene or loading screen waiting for its models |
A game’s lint reports a useTime call that reads seconds or steps, or takes no selector, engine/time-subscription: a HUD reads useTime((state) => state.running), a clock the player reads comes from the step, such as useRound()’s secondsLeft, and an animation reads useTime.getState().seconds inside useFrame.
The engine’s own frame
Section titled “The engine’s own frame”The engine’s own frame is the stock scene with no game on it: the meadow and its whole scatter, the daylight rig, the camera and one character. StockScene mounts it, with the character’s body as its character prop, and the bench page at tools.spawnite.com/bench mounts StockScene with the mmorpg’s heroine, Fris, under the performance overlay’s Full level. Of the overlay’s levels, FPS shows the frame rate and, in a room, the ping; Graph adds the 1% low and a small line of the last ten seconds’ frame times; and Full adds the CPU’s and the GPU’s time per frame and, in a room, the ping’s jitter beside the ping, such as 58 ms ±4; the settings page says what each number measures. The draw calls and triangles are in the devtools’ Frame tab and in spawnite play stats. The page is the tools app’s, so no game’s own work runs beside the engine’s. It also registers one scene per engine look, morning, noon, dusk and overcast, beside the stock scene, stock, which opens first and draws the rig alone.
The goal is half the 6.94 ms budget, 3.47 ms, on the reference machine, so a creator’s game keeps the other half. The perf check measures the page as it measures a game: it profiles /bench, checks its counts against the page’s baseline, and keeps a row of its timings. On the machine below, with vsync off, the whole frame’s p50 is 1.70 ms and its p99 2.50 ms, and no frame passes 6.94 ms; the GPU’s p50 is 0.66 ms. With the counts run pinned to 60 fps, the main thread’s p50 is 2.50 ms. The frame is under the goal by about half.
A change to the engine’s frame is measured on this page, with nothing of a game’s in the way. spawnite play compare, run in a playtest of the bench page, shoots the stock scene and each look.
What it costs
Section titled “What it costs”Chromium 153 on Windows 11 with an AMD Ryzen 9 3900X and an RTX 3070, the built mmorpg at 1280 by 720, the character running back and forth for 20 s, a CPU profile sampled every 100 µs and read back through the source map, the median of three runs. The display runs at 144 Hz, and the loop kept pace at 144 fps with about 2.8 ms of CPU per frame: on this machine the display, not the CPU, sets the rate. At 60 Hz the same lines cost the same per frame and less than half as much per second. Inclusive milliseconds per frame, in the frame’s order:
| Line | ms per frame | Note |
|---|---|---|
| The Frameloop’s callback | 1.64 | everything below through the screen rectangles |
Accumulator, input read, useInput copy |
0.007 | |
stepWorld |
0.14 | the controller 0.064 |
useWallet and useTime writes |
0.005 | with no subscriber at the frame rate |
| Pose | 0.009 | |
| Transforms | 0.009 | |
| Animation | 1.44 | the springs’ colliders 0.66, of which the obstacle queries 0.017; head turn 0.011 |
| Camera | 0.031 | the eye settle 0.005 |
| Daylight rig | 0.008 | |
| Autosave | 0.001 | saves once every 1,800 frames |
gl.render |
1.09 | the scene 0.48, the shadow map 0.38, the world matrices 0.13 |
The animation is the frame’s largest line. Its spring bones collide with the model’s own spheres, the ground and the baked obstacles. Fris carries 176 spring joints on 54 chains, and the obstacle collider asks the bake once per body before any joint does: one query from the hips, reaching half a metre past the furthest spring tail. A joint queries the bake itself only when the hips’ nearest surface stands within half a metre of the joint’s own distance from the hips. In the open a frame runs one query, the hips’ own. Near a surface, each joint within reach of it queries as well, running or at rest. The hips’ query runs only on a frame where the hips moved, and the idle clip moves them, so a body at rest still asks once a frame. Each query walks one tree over the whole bake, so the obstacle queries are a small part of the colliders: most of their time is the joints against the model’s own spheres and the ground.
Measured on an M2 Pro with the devtools editing, the frame gains the recording’s dump, 0.036 ms, and the playback bar’s re-render off useTime, 0.38 ms of React work per frame. That is the cost of one subscriber at the frame rate: a HUD clock that subscribed the same way would pay it too.
The Node bench runs the same lines with no render on the bench page’s own StockScene, mounted headless, the character running in a ring on the meadow with its obstacle bake, over 3,600 frames after 600 of warm-up, the mean of five runs. The character wears the bench page’s body, Fris, parsed from the assets with the engine’s VRM loader and prepared as the render prep prepares a body, with the run clip on every pose, so its 176 spring joints on 54 chains query the bake as they do on the page. Milliseconds per frame on the same machine:
| Line | mean ms | p99 ms | share |
|---|---|---|---|
accumulateSteps |
0.0003 | 0.0006 | 0.0% |
publishInput |
0.0007 | 0.0012 | 0.1% |
takePendingDebit |
0.0002 | 0.0004 | 0.0% |
stepWorld |
0.1956 | 0.3334 | 25.4% |
publishWallet |
0.0013 | 0.0019 | 0.2% |
publishInventory |
0.0011 | 0.0018 | 0.1% |
publishSteps |
0.0012 | 0.0018 | 0.2% |
resolveCharacterPose |
0.0027 | 0.0038 | 0.3% |
syncTransforms |
0.0036 | 0.0051 | 0.5% |
hideEntities |
0.0005 | 0.0008 | 0.1% |
animateCharacters |
0.5227 | 0.7042 | 68.0% |
syncCamera |
0.0363 | 0.0648 | 4.7% |
writeScreenRects |
0.0006 | 0.0011 | 0.1% |
| whole frame | 0.7687 |
publishSteps runs with one subscriber on useTime, so the number includes waking it. The animation’s line and the camera’s query the same bake the browser does; the two runtimes price the rest of their work differently.
What to reduce
Section titled “What to reduce”In the order the numbers point:
- The springs of the character the page steers, 0.66 ms. Every other character is posed every other frame while small on screen and not at all out of view, so its springs cost nothing there. The page’s own character is posed every frame, because a game may aim her shots from her hands.
- The render’s JS side, 1.09 ms. The shadow map is a third of it. Nothing in the engine’s own code is here; the levers are the quality tier’s shadow map size and how much scatter casts.
- The step’s controller, 0.064 ms. Cheap at one actor; its cost with a crowd is the physics bench’s subject, and a room budget question rather than a frame one.
- The eye settle, not yet remeasured. Two sphere casts a frame in the physics world, one for the aim and one for the eye, and up to about 100 looks at the ground’s height along the eye’s line at the farthest zoom. It measured 0.005 ms as three rays against the bake before the sweep replaced them.
- The per-frame queries. koota’s
world.querybuilds an array of the entities, a closure per method and, for each trait of fields, a record per entity on every call: most of the arena’s garbage in a room, 40 KB of its 79 KB a frame. The step’s systems and the frame’s lines walk their queries through the engine’s own walk in the entity folder, which builds none of them. A query that runs on an event rather than on a frame, such as a click’s, still calls koota. - Numbers across a call V8 does not inline. V8 boxes a fractional number passed into or returned from such a call, 12 bytes each way. three-vrm’s spring joints handed each collider the joint’s hit radius and read a distance back, through a call that sees four shape classes: 377 KB of the arena’s frame with its heroines drawn. The engine’s loop in render/springCollision.ts passes them through the fields of one preallocated contact, which the collider writes into and V8 writes in place. A hot call that takes or returns a fractional number takes the same shape: the bake’s
measureEscapewrites its depth into the query it is handed rather than returning it. - A library’s walk that builds per call. three-mesh-bvh’s
shapecastbuilds three typed-array views of the tree, a callbacks object and a closure on every call, and boxes each node’s score on its way out of the score callback; three-vrm’s spring manager builds a closure for every object under every joint, every frame. With the heroines beside the arena’s rocks, the two made about 31 KB of a frame. maths/escape.ts walks the bake’s tree itself over views built once for each tree, and render/springCollision.ts walks the joints’ children in a plain loop. Each reads the pinned library’s private members, and a test runs the library’s own walk beside it.
Spawns and deaths
Section titled “Spawns and deaths”A game that spawns and destroys entities many times a second, as a wave shooter does, keeps to three rules:
- Measure a spawn on a build, not on the dev server. React’s development build makes every element through
jsxDEV, which records where each was made, so a list that draws again costs many times what it costs in production. At Holdfast’s wave 10 with four wardens, the dev page played at a p99 of 83.3 ms with 289 frames over 50 ms in the run, most of them right after a spawn or a destroy. The production build played the same wave at a p99 of 33.4 ms with 10 such frames, and the frames after its 630 spawns and 618 destroys were no slower than the rest. Profile without--devbefore you change how a game spawns. - Memoize the view of one entity in a list. koota’s
useQuerydraws its list again whenever an entity joins or leaves the query, so a view that is not wrapped inmemodraws its whole tree again for every other entity’s spawn and death.Replicasmemoizes each entity’s view; the overlay you hand it keeps that only when it is the same function every render, one declared at module level or held inuseCallback. - Draw many spawned things from one pool. InstancedModel, the skinned batch, Particles and FloatingText draw every copy from objects made once, so a spawn adds a copy rather than mounting a tree. A spawned material is compiled under the loading screen only when the scene holds one, so keep one of each hidden in the scene, as Shaders compile under the screen says.
Unity games pool spawned objects with ObjectPool<T>, Unreal pools actors for projectiles and crowds, and PlayCanvas documents entity pooling; React Three Fiber’s own guidance is to reuse objects rather than mount and unmount them in the hot path. The engine’s pooled components are that path, and a memoized view keeps the list itself cheap.
Frame pacing
Section titled “Frame pacing”A game can average a full 120 frames a second and still stutter: the frames a player notices are the slowest few, which the average hides. On a 120 Hz screen a frame that misses its refresh shows for 16.7 ms, and one such frame in ten seconds already pulls the overlay’s 1% low from 120 to about 111. Most of those frames in a browser game are not the game’s code. The page’s own frame can finish in 4 ms and the frame still miss, because what runs after it is the browser’s.
Chrome draws a page in two processes. The page’s process runs the game’s code, React and three.js, and turns the HTML and CSS into paint commands. The GPU process runs everything that touches the graphics card, on one main thread, in turn: every WebGL command the game sends, the raster that draws the HTML and CSS into tiles, and the compositor’s paint that lays the tiles and the canvas into the frame. While that thread rasters a HUD panel, the frame’s WebGL commands wait behind it, however short the page’s own frame was. A HUD that changes every frame, such as a number that counts up or a bar that animates its width, is painted again every frame and rastered again every frame on that same thread: Holdfast’s HUD painted about 54 times a second, and half its slow frames were the GPU process busy with the raster and the compositing. spawnite play profile --trace names that work on each slow frame, as Finding hitches says, and prints the page’s paints and raster a second.
What a HUD avoids, beside what does the same without the repaint:
| Avoid | Instead |
|---|---|
A value written every frame through React state, such as a timer’s text or a bar’s width |
Write it only when it changes, or through a ref into transform or opacity, which the compositor moves without a repaint |
A CSS animation of width, height, top, left, color, box-shadow or filter |
The same motion as transform: scale() or translate(), and a fade as opacity |
| A blur or a drop shadow on an element that changes or animates | The effect on a still element, or baked into an image |
backdrop-filter over the canvas on a panel that stays up while the game plays |
An opaque or translucent fill: a blur behind a panel is redrawn every frame the canvas changes, which is every frame |
| A panel that changes every frame outside a slot, where its repaint reaches the page beneath it | A slot Panel: the screen layer that holds the slots is contained on a layer of its own, so a panel there rasters alone; a Hud’s own children are not inside it |
A model a wave spawns draws for the first time as the wave rises, and Chrome’s GPU process builds the draw’s vertex layout then: 24 and 44 ms of its time at Holdfast’s first wave before the engine drew each skinned batch once under the loading screen. Keep one of each spawned model in the scene from the first frame, as Shaders compile under the screen says, and the batch draws it there.
What loads before the first frame
Section titled “What loads before the first frame”Before a page draws its first frame, it downloads the page, its entry chunk, what that chunk imports statically and their stylesheets. Each game in this repository guards a standalone build of those files, the engine bundled in, by calling checkGameBuild from @spawnite/engine/vite in its test/build.test.ts, or in test/build.extended.test.ts where only the full suite runs it. The guard is 1,600,000 bytes gzipped: the engine release’s own budget of 1,100,000, which the engine’s release test holds, plus the 500 KB of game code past which spawnite build warns. The engine is most of a standalone build, so the guard catches a large mistake, such as a WebAssembly module imported statically, rather than tracking the game’s size:
// @vitest-environment nodeimport * as path from "node:path";import { checkGameBuild } from "@spawnite/engine/vite";import { it } from "vitest";
it("builds for production within the platform's rules", async () => { await checkGameBuild(path.resolve(import.meta.dirname, ".."));}, 180_000);checkGameBuild builds the game for production into a temporary folder and throws when those files weigh more than 1,600,000 bytes gzipped, naming the size and the game’s own source files’ share, or when any chunk holds zod’s classic API or a zod locale other than English. It returns every file the build wrote, so a game can assert more about its own bundle. Its env option sets environment variables for the build, such as ROOMS_DOMAIN, and its plugins option adds Vite plugins, such as one that reads a written file in writeBundle.
What a creator is told about is the game’s own code, not the guard. To weigh what a published page downloads before the game starts, run spawnite build: it builds the game’s bundle, prints the gzipped size of the game’s own code as Game code, warns past 500 KB, and lists the heaviest files and the heaviest modules of the game’s own code. --json gives the same data under code, with the engine release’s part as code.engineBytes. The example’s build test also fails when any of the following comes back into them. Each loads where it is first used, and a game keeps it there:
- motion’s drag and layout. ui renders
minsideLazyMotionwithdomAnimation, and a game’s own animated component does the same. The devtools takemotionfrommotion/react-client:motion/reactbindsmotionin its entry module, so a use of it from any chunk puts drag and layout in the first load. Lint refusesmotionfrommotion/reactin ui, the devtools and every game. - Sounds. Vite writes a file under its 4 kB inline limit into the importing chunk as base64. The engine’s Vite plugin, which every game loads, keeps an
.mp3a file, and the view that plays a sound preloads it. - A sky’s
EXRLoader. A look’s image loads through three’sHDRLoader, andEXRLoaderis fetched the first time an image names an.exr. - The devtools’ console capture. Only the engine’s
./devtoolssubpath exportscaptureLog, so a game’s first load carries neither of its parsers. - Rapier’s wasm. The physics scene loads it at the first body a world declares, through
loadRapier, and a World waits for it before its ground spawns. - recast’s library and the navmesh wireframe’s line materials. They load in one chunk on the first call of
loadRecast, beside the wasm, which every bake awaits.
Most of a published page’s first load is the engine release, which the platform holds to a budget of its own. The release’s part is what the engine, react, react/jsx-runtime and react-dom/client import statically, the modules every game’s page boots with. The engine’s release test builds the release, sums those files gzipped, and fails over the budget the engine’s release build sets, listing each file heaviest first with the packages its code comes from. The same test file fails when zod’s locales other than English or its JSON Schema writer come back into those files: only a page that imports z from the schema loads them.
A lazy import alone does not keep a module out while any module the barrel reaches imports it statically. The engine’s barrel reaches every engine module, and the engine declares no sideEffects, so the bundler keeps any module that runs code at its top level in the entry chunk. The room client stays there for that reason, though a game played alone never calls it.
What a deploy carries
Section titled “What a deploy carries”A game’s build copies every file that a module it reaches names, whether or not the page ever fetches it: the bundler drops unused code, not the files that code names. The built-in looks light from the engine’s own sky and name no image, so a look adds no file to a deploy. A look whose environment.image names an .hdr ships that file, about 1.5 MB for a 1K image, as Looks says.
A shared file, from @spawnite/assets or one the engine loads itself, such as the scatter’s tall tree, ships in no deploy: the engine’s build and the package name it by its URL on https://assets.spawnite.com, and every game’s page fetches it from there. Each URL names the file’s bytes, so no deploy carries a copy. In this repository a game’s own build reads the kit’s files themselves; spawnite build names them by URL, as a published game does. Where a game’s public/assets holds a kit file byte for byte, as spawnite add asset puts one there, a build in this repository ships the game’s copy alone and points the import at it. A game on the npm engine fetches the engine’s copy from the shared site and its own from its deploy, so the page fetches that model twice.