Skip to content

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 hero’s animation and the camera in one callback. This page lists what runs, measures each line, and says which lines are worth cutting.

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.

  1. Whether drei’s loaders have a file in flight, copied into useLoading on a change.
  2. The pause checks: a modal that pauses returns before anything is banked; the devtools’ pause hands the loop one owed step or none.
  3. The accumulator: how many whole 1/60 s steps this frame’s time buys, and the leftover.
  4. The browser’s input, read once: the movement keys and the stick, whether the player is steering, and the camera’s azimuth. The copy in useInput, written only on a change.
  5. The pending debit out of useWallet, spent on the hero.
  6. stepWorld, once per step bought: the previous transforms, then the world’s systems in place order.
    • input: the hero’s path and steer.
    • move: every velocity applied.
    • behaviours: the behaviours, then the round.
    • resolve: every actor moved through Rapier’s character controller, which is most of a step.
  7. The wallet’s copy in useWallet, on a change; the step count in useTime, on every frame that stepped; while editing, the recording’s dump of the world.
  8. The hero’s pose off the step’s steer and turn.
  9. The transforms onto the objects, blended between the last two steps by the leftover.
  10. The devtools’ hidden entities hidden again.
  11. The hero’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.
  12. The camera: the orbit moved to the aim, its update, and the eye settled against the obstacle bake and the ground.
  13. While editing, the hovered and selected entities’ screen rectangles.
  14. The day clock.

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 hero’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.

Four 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
useInput on a change of intent nobody; the joystick writes it
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

Chromium 153 on Windows 11 with an AMD Ryzen 9 3900X and an RTX 3070, the built mmorpg at 1280 by 720, the hero 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 day clock
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
Day clock 0.003
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, the hero running in a ring on the mmorpg map with its obstacle bake, over 3,600 frames after 600 of warm-up, the mean of five runs. The hero wears the test VRM fixture, prepared as the render prep prepares a body, so its two spring chains query the bake the way the real body’s 176 joints on 54 chains do: once from the hips each frame, and from a joint only near a surface. Milliseconds per frame on the same machine:

Line mean ms p99 ms share
accumulateSteps 0.0003 0.0005 0.1%
publishInput 0.0004 0.0011 0.2%
takePendingDebit 0.0001 0.0003 0.1%
stepWorld 0.1418 0.2814 67.8%
publishWallet 0.0030 0.0062 1.5%
publishSteps 0.0009 0.0018 0.4%
resolveHeroPose 0.0037 0.0080 1.8%
syncTransforms 0.0069 0.0133 3.3%
hideEntities 0.0003 0.0006 0.1%
animateHeroes 0.0365 0.0723 17.4%
syncCamera 0.0131 0.0301 6.3%
writeScreenRects 0.0003 0.0006 0.1%
advanceDayClock 0.0019 0.0041 0.9%
whole frame 0.2091

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.

In the order the numbers point:

  1. The springs away from the camera, 0.66 ms. Once other bodies share the scene, a body beyond some distance from the camera can skip its obstacle collider, or its springs altogether.
  2. 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.
  3. 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.
  4. The eye settle, 0.005 ms. Three rays a frame against the bake, each walking one tree. A tree per placement would walk every placement’s on every ray, which is why every query tree is built one way.
  5. The per-frame queries. The step’s and the camera’s are built once with createQuery, which spares the key an ad hoc query builds on every call; the rest, such as the wallet’s queryFirst, are below the noise.

pnpm nx bench @daniel-zarinski/engine runs the frame bench in packages/engine/test/components/bench.test.ts beside the physics bench and prints the Node table. The bench lists the loop’s lines by hand, in the loop’s order: add a line to the loop, add it to the bench, and it shows as a number on the next run.

In the browser, the devtools overlay splits every frame while it is mounted: the step, the pose and animation, the transforms and camera, the render and the rest on the CPU, and the GPU’s time where the browser has a timer query, each by its mean and its worst over the last 60 frames. The top bar shows the CPU’s and the GPU’s means, the Frame tab every line, and an agent reads frameSplit off the store; the devtools page says what each line holds.

Inside the step, the profile times each system with no bench to write. game simulate --profile prints each system’s mean and worst milliseconds per step over the last 60 steps, slowest first, with recordPreviousTransforms among them; --json carries them as profile. The MCP’s simulate tool takes profile and replies with the same lines under the hash. In the browser, the devtools store’s setProfiling(true) times the running world, and readProfile() reads the same lines. Headless, a world with the StepProfile trait is timed, and readStepProfile(world) reads it. While profiling is off, the step reads no clock.

The browser numbers come from a CPU profile of the built app. Build the game without minification and with a source map, so the profile’s function names and positions map back to the engine’s files, then profile it:

Terminal window
pnpm nx build @game/mmorpg --minify=false --sourcemap=true
pnpm game play profile --project mmorpg --seconds 20

The command serves the build with vite preview and opens it in a headed Chromium, fullscreen on the real GPU at the display’s own rate, so the window takes the screen for about half a minute. Once the loading screen has gone, it plays 20 s as a player does: it walks, jumps, strafes and drags the camera round. The first 7 s of the same walk run before the recording, so every clip’s shader has compiled by then. V8 samples the main thread every 100 µs while it plays. The table sums each sample into its function and every function above it, counting a function once per stack, and divides by the frames requestAnimationFrame counted over the same span. Each function is named by the source file and line its source map gives, so an anonymous one, such as the Frameloop’s callback, still has a name. --url profiles a page that is already served, and --json prints the same numbers as fields. The MCP’s profile_game tool takes the same options and returns the same fields as JSON. A game that throws while it plays fails the run, and the command prints the page’s errors in place of numbers that would time a stopped game.

Adding two lines that nest counts the inner one twice: measureEscape calls closestPointToPoint, and three-mesh-bvh’s closestPointToPoint and Rapier’s computeColliderMovement each wrap a function of the same name.

The command also times each frame and counts what it draws. The following options change what it reads:

  • --budget <ms>, 7.5 by default, is the frame’s budget, and framesOver counts the frames that took longer. frameMs spreads each frame’s time, from one requestAnimationFrame to the next, as p50, p95, p99, worst and mean; mainThreadMs spreads the main thread’s, from the frame’s first callback to the end of its task.
  • --no-vsync launches Chromium with --disable-frame-rate-limit --disable-gpu-vsync, so a frame’s time is its cost rather than the display’s period.
  • --gpu times each frame’s GL work with EXT_disjoint_timer_query_webgl2 into gpuMs. With vsync on, the query spans the wait for the display, so the command reads GPU time only with --no-vsync and says so otherwise.
  • --render counts each frame’s WebGL2 calls into gl, each counter’s mean and max: the draws, triangles and instances to the screen and into render targets such as the shadow map, the program switches and links, and the texture uploads and the buffer uploads apart. A WEBGL_multi_draw call, as a BatchedMesh makes, counts as one draw of all its ranges. The multi-draw calls and the ranges they hold, their sub-draws, are also counted apart, per pass, because ANGLE’s Direct3D 11 backend likely runs each sub-draw as a draw of its own. Only the game’s context is counted: the first WebGL2 context the page makes, which the GPU timer times too.
  • --skip <class> drops one class of draw: rt, rtinstanced, instanced, skinned, rtskinned or sky. What those draws cost is the frame time’s difference from a run that draws them.
  • --probe lists the first recorded frame’s draws into probe, in the order the frame drew them, folded by pass, program, what the program draws, vertex count and instances, with how many draws each holds. A program is numbered in the order the page first used it, and it draws skinned or sky where its uniforms say so. A multi-draw call is one entry, with its ranges’ vertices summed and its sub-draws.
  • --alloc samples the heap’s allocations into allocations, the objects a collection freed too, since those are the frame’s garbage. allocations.top lists the bytes a frame by call site, a function and up to three callers, each named through the source map. The recorder’s own allocations are left out.
  • --trace records a Chromium trace and counts the page’s minor and major collections between the marks it sets as the recording starts and stops, into trace. A worker’s collection holds no frame up, so it is not counted.
  • --quality <level>, one of auto, minimum, low, medium or high, plays at that quality level in place of the one the game picks.
  • --no-cpu leaves the sampler off. The sampler slows every frame, so a run that reads the frame times against the budget passes it.

The machine’s other work moves every number. Run one configuration three times and read the spread before reading a difference.

Performance on main runs this command on every game at each merge to main and charts the numbers by head.

The machine’s other work moves the timings by about 20%, but it does not move the counts, so the check fails on counts and only prints timings. Each game commits the counts its frame makes in test/profile-baseline.json. pnpm nx run-many -t perf -p @game/<game> builds each game and runs game play profile --check in its folder. The check profiles the build twice in one configuration, at the high quality level with the sampler off:

  1. Vsync on, with --render and --alloc: the counts and the garbage, at the display’s rate.
  2. Vsync off, with --gpu: the GPU’s time.

It prints both runs’ timings and each count beside its baseline, and exits 1 when a count rose past it. A count is its highest frame over the walk, because the walk’s timing moves a count’s mean between runs and not its highest frame. Two counts may rise a little. The garbage a frame makes may rise by a tenth, because V8 samples it and runs of one build spread by up to 1.5%. The program switches may rise by one, because the order a page’s models load in sets the order three.js draws their materials. A count that only one side holds fails too. When a count rises, the check profiles the counts once more and keeps each count’s lower reading, so a one-off frame, such as a model compiled late, does not fail it and a rise that repeats does.

When a change adds draws on purpose, game play profile --update-baseline writes the baseline, and the pull request says why. It takes each count’s highest over three runs of the counts, because a camera that follows a steered hero, as the sled’s does, reaches its busiest view at a different moment each run. The baselines were measured on the machine that #862 describes, fullscreen at 2560 by 1080. A narrower display sees less of the world, so its counts come out at or under the baseline; a wider one can see more and fail.