Performance Measurements
MVT cost measurements: keeping Pixi containers in step with a model, reacting to changes, building and reusing containers, the scene passes, each hot path rule, memory and garbage collection, and the games and demos themselves. The tables come straight from this repo's benchmark suites, and each section says what the numbers mean.
Related: Why Performance Matters · Hot Paths · Benchmarking Methods · Reactivity: Why MVT Uses Polling
What Is Measured
Most benchmarks here time one frame: the model changes, then the view catches up. Nothing is drawn, so rendering is not included in any number.
- Pixi container: one
Container, whose properties (x,y,alpha) follow one model record, a plain object such as{ x, y, alpha }. Most benchmarks use 1000 of them. - Dynamic and static properties: properties on each Pixi container, such as
xandalpha. A dynamic property is updated every frame to reflect a value in the model, so it can change. A static property is assigned once when the scene is built, and never updated from the model after that. "3 dynamic" meansx,yandalphaare all dynamic; "1 dynamic, 2 static" means onlyxis. - Changed per frame: the share of containers that change in a given frame. A container that changes gets a new value in every one of its dynamic properties, so this is also the share of dynamic properties that change. 0% is a scene at rest; 100% means every container changes every frame.
The ways of keeping the containers in step with the model:
| Approach | How the view learns about a change |
|---|---|
| MVT (hand-written) | Each container's refresh method reads the model and assigns its properties, called by refreshView every frame. This is polling, as the rest of these docs describe it |
| MVT (JSX) | The same polling, but built with this repo's JSX runtime (packages/pixi/src/jsx/), where each dynamic property is given as a function that reads the model |
| Events | The model calls a listener for each record it changes, and the listener assigns the properties |
| Solid signals | The model's values are Solid signals, with one effect per container that assigns its properties when they change |
| Model only, no view | Just the model's changes, to show how much of a frame they are |
Each cell is the median of two runs, or three where the first two disagreed by more than 5%, each in its own Node process, rounded to 3 significant figures. Where the runs disagreed by more than 5% either way, the cell also shows half the spread between the fastest and slowest run, as a share of the median: 3.8 ±40% marks a noisy result. A cell without "±" had runs within 5% of each other. Every run's numbers are in the saved results. Times are in microseconds (µs); a frame at 60 frames per second lasts 16,667 µs.
Measured 2026-10-02 on Intel(R) Core(TM) Ultra 9 185H, win32 10.0.26200, Node.js 26.10.0 (V8 14.6.202.34-node.34), pixi.js 8.21.0, solid-js 1.9.15.
About the figures
Every figure on this page comes from the one machine above, in Node.js's V8 engine, with nothing drawn. Absolute times will differ on other hardware, in browsers, and in other engines. The ratios between approaches, and the patterns (what grows with scene size, what allocates), are what generally carry over. Re-run the benchmarks on your own machine to get your own numbers; see Benchmarking Methods.
The Short Version
- Polling 1000 Pixi containers costs 5-15 µs per frame and leaves no garbage, however much changes.
- Signals are cheaper when little changes, and dearer when a lot does. They win when fewer than about 4-10% of containers change per frame, cost 7-13x as much when everything changes, and leave garbage on every change.
- Scene size matters more than it looks. Past a few thousand containers the cost per container climbs; 100,000 containers at rest cost about 4.2 ms per frame. Skipping inactive subtrees with
SKIP_DESCENDANTSis the remedy. - Four hot path rules matter a lot, two not at all (in V8). Strings built per game object,
Object.values(), array methods and recomputing unchanged values cost several to tens of times more, and the first three leave kilobytes of garbage per frame.for...ofand returned tuples cost nothing extra. - This repo's games take 4-9 µs per frame before drawing.
Keeping Containers in Step
1000 Pixi containers, each following one model record, as the share changed per frame goes from nothing to everything.
1000 containers, 3 dynamic properties each: time per frame (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Changed per frame | model only, no view | MVT (hand-written) | MVT (JSX) | events | Solid signals |
|---|---|---|---|---|---|
| 0% | 0 | 4.96 | 13.3 | 0 | 0.01 |
| 1% | 0.01 | 5.75 | 14 | 0.09 | 2.5 |
| 10% | 0.12 | 6.12 | 14.6 | 1.09 | 23.3 |
| 50% | 0.61 | 7.85 | 16.3 | 5.28 | 117 |
| 100% | 1.2 | 9.8 | 18.9 | 6.67 | 244 |
1000 containers, 1 dynamic property and 2 static properties each: time per frame (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Changed per frame | model only, no view | MVT (hand-written) | MVT (JSX) | events | Solid signals |
|---|---|---|---|---|---|
| 0% | 0 | 4.17 | 8.96 | 0 | 0.01 |
| 1% | 0.01 | 4.32 | 9.07 | 0.03 | 1.07 |
| 10% | 0.08 | 4.48 | 9.18 | 0.36 | 10.1 |
| 50% | 0.38 | 5.27 | 10.3 | 2 | 49.1 |
| 100% | 0.8 | 6.66 | 11.9 | 4.18 | 95.5 |
- Polling costs about 4-13 µs per frame for 1000 containers at rest, under 0.1% of a frame. It rises to 7-19 µs when everything changes, mostly because Pixi's setters then have real work to do.
- Signals cost almost nothing at rest, and about 240 ns per changed container (100 ns with 1 dynamic property). They are cheaper than polling until about 2-5% of containers change per frame with 3 dynamic properties, or 4-9% with 1. Past that they cost more, and when everything changes they cost 8-24x as much.
- Events are the cheapest here however many containers change, because the model only notifies about what changed and nothing is scanned. That is a timing, not a recommendation: Events and Signals covers what events cost in design, and at 1000 containers the difference from polling is a few microseconds per frame.
- The JSX runtime costs 1.8-2.6x as much as hand-written refresh methods: about 5-9 ns more per container, most of it per container rather than per property. Its refresh methods are closures, with no generated code, so they work under any Content Security Policy; in scenes of thousands of elements, where the cost matters, they come within 1.1-1.3x of code generated per element shape, which an earlier runtime used. A static property costs nothing per frame, so in JSX, pass values that never change as plain values rather than getters.
- The model's own changes are small: at most 1.2 µs per frame, even with every record changed each frame.
Scaling
The same scene, with 3 dynamic properties per container, from 100 to 100,000 containers. These tables divide each frame's time by the number of containers, so a flat column means the cost grows in proportion.
Nothing changed: time per frame per container (ns; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Containers | MVT (hand-written) | MVT (JSX) | Solid signals |
|---|---|---|---|
| 100 | 4.8 | 12.8 | 0.15 |
| 1,000 | 4.97 | 13.1 | 0.01 |
| 10,000 | 11.3 | 16.6 | 0 |
| 100,000 | 37.1 | 49.8 | 0 |
Every container changed each frame: time per frame per container (ns; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Containers | MVT (hand-written) | MVT (JSX) | Solid signals |
|---|---|---|---|
| 100 | 9.96 | 18.6 | 235 |
| 1,000 | 10 | 20.4 | 239 |
| 10,000 | 14.9 | 27.2 | 301 |
| 100,000 | 47 | 60.7 | 435 |
- The cost per container is flat up to about 1,000 containers, then climbs. Polling a scene at rest costs about 5 ns per container at 1,000, 12 ns at 10,000, and 42 ns at 100,000: past a few thousand containers, the scene no longer fits in the CPU's caches. Do not multiply a small scene's numbers up to a large one.
- At 100,000 containers, polling a scene at rest takes about 4.2 ms per frame with hand-written refresh methods, and 5.5 ms with the JSX runtime: a quarter to a third of a 60fps frame, before anything is drawn. Scenes that large are where skipping inactive subtrees pays.
- Signals slow down at scale too. With everything changed each frame, each container costs 255 ns at 1,000 containers and 435 ns at 100,000, which is 44 ms per frame: more than two and a half frames' worth.
Every container changed each frame: time per frame (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Containers | MVT (hand-written) | MVT (JSX) | Solid signals |
|---|---|---|---|
| 100 | 1 | 1.86 | 23.5 |
| 1,000 | 10 | 20.4 | 239 |
| 10,000 | 149 | 272 | 3,010 |
| 100,000 | 4,690 | 6,070 | 43,500 |
Occasional Changes and Derived Values
1000 Pixi containers again. In the first table, each follows a value that changes only occasionally, such as a level or a score, and updates two properties when it does. Polling here means change detection: comparing with last frame's value, either by hand or with the watch() helper.
A value that changes occasionally, updating two properties when it does: time per frame (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Changed per frame | compare by hand | watch() | events | Solid signals |
|---|---|---|---|---|
| 0% | 4.02 | 12 | 0 | 0.01 |
| 1% | 4.16 | 12 | 0.11 | 1.11 |
| 10% | 4.62 | 12.9 | 1.21 | 10.3 |
In the second, each container shows a value computed from 8 model values, such as a total. Polling either recomputes it every frame, or uses watch() on all 8 values and recomputes only when one changed. Signals use a createMemo.
A property computed from 8 model values: time per frame (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Changed per frame | recompute every frame | watch() | events | Solid signals |
|---|---|---|---|---|
| 0% | 8.19 | 68.6 | 0 | 0.01 |
| 10% | 7.79 | 69 | 0.82 | 59.8 |
| 100% | 11.5 | 75.6 | 8.05 | 591 |
watch()costs about 8 ns per watched value per frame, nearly three times as much as comparing by hand. It allocates nothing (see Memory), so the cost is in the calls. The likely cause, not yet confirmed with a profile, is that every watcher calls its getters from the same line ofpoll(), which the engine cannot inline when it sees thousands of different getters there. At game scale this is still small: 1000 watched values cost about 12 µs per frame.- Recomputing a cheap derived value every frame is cheaper than detecting whether it needs recomputing. Summing 8 numbers costs less than checking 8 numbers for changes. Change detection pays when the work it skips is expensive, such as rebuilding text or restructuring the scene, not when the work is a little arithmetic.
- A memo per container is expensive when its inputs change often. With every input changed each frame, Solid's memos cost about 590 ns per container per frame, against about 10 ns to recompute by polling.
Building, Destroying and Reusing Containers
The first table is the whole life of one Pixi container with 3 dynamic properties: building it and its model record, bringing it up to date once, and destroying it. A bare new Container() and destroy() is included for scale.
One Pixi container and its model record, from construction to destruction (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Scenario | bare container | MVT (hand-written) | MVT (JSX) | events | Solid signals |
|---|---|---|---|---|---|
| build, first update, destroy | 0.125 | 0.202 | 0.491 | 0.125 | 0.398 |
The second is a pool of short-lived items, such as bullets, held in a SlotList. About 500 are alive at once. Either <List> shows the slots, building one container per slot and reusing it for every later item, or a hand-written view builds a container for each new item and destroys it when the item goes.
About 500 short-lived items alive at once: time per frame (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| New items per frame | <List> over a SlotList (reuses containers) | build and destroy a container per item |
|---|---|---|
| 5 | 15.7 | 19.8 |
| 50 | 17.5 | 91 |
- A container costs about 0.1-0.5 µs over its life, most of it Pixi's own
Container. The JSX runtime and signals cost about 3-4x a bare container. Building a few containers per frame is cheap; building hundreds is not. - Reusing containers makes a pool's cost independent of how fast items come and go. With
<List>, the frame costs about 17-18 µs whether 5 or 50 items appear per frame. Building and destroying costs 21 µs at 5 per frame and 95 µs at 50, and it allocates far more (see Memory).
What refreshView Costs
What refreshView itself costs, apart from the work the refresh methods do: each one here does almost nothing. It is compared with a plain recursive walk over the tree, which calls every refresh method it finds, and with Pixi's own onRender callbacks. The last scenario has 100 groups of 100 containers, 90 of the groups inactive, either only hidden or returning SKIP_DESCENDANTS from their own refresh method.
refreshView and its alternatives (median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Scenario | Approach | Time per frame (µs) | Method calls per frame |
|---|---|---|---|
| 20,000 containers, 200 with a refresh method | plain recursive walk | 197 | 200 |
refreshView | 0.6 | 200 | |
| 2,000 containers, all with a refresh method | plain recursive walk | 8.76 | 2,000 |
Pixi onRender | 1.85 | 2,000 | |
refreshView | 5.34 | 2,000 | |
| 2,000 containers, all with a refresh method, 100 replaced per frame | plain recursive walk | 26.7 | 2,000 |
refreshView | 105 | 2,000 | |
| 100 subtrees of 25 containers without a refresh method, detached and re-attached per frame | plain recursive walk | 39.8 | 1 |
refreshView | 8.32 | 1 | |
100 containers added and removed per frame, no refreshView | @mvtjs/pixi not imported | 11 | 0 |
| @mvtjs/pixi imported | 11.1 | 0 | |
| 10,000 containers with a refresh method each, in 100 groups, 90 groups inactive | inactive groups hidden (visible = false) only | 117 | 10,000 |
inactive groups return SKIP_DESCENDANTS | 9.41 | 1,090 |
refreshViewvisits only the containers that have a refresh method. It keeps a cached method list of them, so in a large scene where few containers have one, it is hundreds of times faster than walking the tree: 0.65 µs against 206 µs for 200 refresh methods among 20,000 containers.- Replacing many containers that have a refresh method every frame is its worst case. 100 replacements per frame make
refreshViewrebuild its method list every frame, walking the tree to do it: 127 µs, against 29 µs for a plain walk. Thepackages/pixi/src/README explains why this is accepted. - It costs about 2 ns more per call than Pixi's
onRender, which is what checking for containers removed during the call costs. - Skipping inactive subtrees pays at scale. Returning
SKIP_DESCENDANTSfrom 90 inactive groups cut the frame from 128 µs to 10 µs. Hiding a container withvisible = falsedoes not stop its refresh methods from running. - Importing @mvtjs/pixi costs a tree that never uses it about 6 ns per added and removed container, for tracking changes to the tree.
The Hot Path Rules
Each rule on the Hot Paths page, written two ways that compute the same result: the pattern the rule warns against, and the one it recommends. The work is typical of a game's frame, over 1000 game objects (plain objects with a position, a velocity and a few stats).
Time per frame (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Rule (per frame, over 1000 game objects unless stated) | Avoid | Prefer |
|---|---|---|
.filter().map() vs an index loop | 3.11 | 0.85 |
for...of vs an index loop | 0.9 | 0.91 |
template-string key into a Map vs arithmetic index into an array | 50.1 | 4.21 |
numeric key into a Map vs index into an array | 5.46 | 4.27 |
forEach with a new closure vs an index loop | 2.46 | 2.46 |
Object.values() vs reading properties | 48 | 1.53 |
returning a [col, row] tuple vs an out-parameter | 4.67 | 4.49 |
| summing 1000 scores every frame vs caching the sum | 0.36 | 0.02 |
100 Text labels: set every frame vs only on change | 0.66 | 0.21 |
Bytes allocated per frame (bytes; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Rule (per frame, over 1000 game objects unless stated) | Avoid | Prefer |
|---|---|---|
.filter().map() vs an index loop | 24,500 | 0 |
for...of vs an index loop | 0 | 0 |
template-string key into a Map vs arithmetic index into an array | 48,000 | 0 |
numeric key into a Map vs index into an array | 0 | 0 |
forEach with a new closure vs an index loop | 56 | 0 |
Object.values() vs reading properties | 80,000 | 0 |
returning a [col, row] tuple vs an out-parameter | 0 | 0 |
| summing 1000 scores every frame vs caching the sum | 0 | 0 |
100 Text labels: set every frame vs only on change | 1 ±44% | 1 ±26% |
- Four rules clearly matter.
Object.values()per game object is 32x slower than reading the properties and leaves 80 KB of garbage per frame. A template-string key is 12x slower (48 KB),.filter().map()4x (24 KB), and recomputing an unchanged sum 20x, though that last one costs only 0.4 µs. - Two make no difference in V8. A
for...ofloop over an array runs as fast as an index loop and allocates nothing. A function returning a[col, row]tuple allocates nothing either: once it is inlined, the engine never builds the array. - Two matter a little. A
Mapwith numeric keys is 1.3x slower than an array but leaves no garbage. A closure created per frame costs 56 bytes and no measurable time. - Pixi's
Textalready skips unchanged text. Setting the same string every frame costs about 7 ns per label, and V8 reuses the strings of small numbers, so it allocates nothing. Checking for a change first is still about 3x cheaper.
Memory and Garbage Collection
Garbage is memory a frame allocates and then drops. The engine reclaims it by pausing to collect it, and a game that allocates every frame collects often. These tables show the bytes each frame allocates, how often the engine collected over one simulated minute, and how much memory each container keeps alive. "MVT (hand-written, wasteful)" is the hand-written approach, plus building a string and an array every frame, as the hot path rules warn against. It is there to show that the measurement sees allocation when it happens.
1000 containers, 3 dynamic properties each: bytes allocated per frame (bytes; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Changed per frame | MVT (hand-written) | MVT (JSX) | events | Solid signals | MVT (hand-written, wasteful) |
|---|---|---|---|---|---|
| 0% changed | 0 | 0 | 0 | 104 | 96,600 |
| 10% changed | 0 | 9 | 0 | 62,600 | 99,000 |
| 100% changed | 1 ±21% | 9 | 0 | 631,000 | 96,700 |
1000 containers reacting to a value that changes occasionally, 10% changed per frame: bytes allocated per frame (bytes; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Changed per frame | compare by hand | watch() | events | Solid signals |
|---|---|---|---|---|
| 10% changed | 0 | 0 | 0 | 30,600 |
About 500 short-lived items, 50 new per frame: bytes allocated per frame (bytes; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| New items per frame | <List> over a SlotList | build and destroy per item |
|---|---|---|
| 50 new per frame | 5,600 | 70,000 |
1000 containers, 3 dynamic properties, all changed every frame, for one simulated minute (3600 frames) (median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Approach | Young-generation collections | Full collections | Time in collections (ms) |
|---|---|---|---|
| MVT (hand-written) | 0 | 0 | 0 |
| MVT (JSX) | 0 | 0 | 0 |
| events | 0 | 0 | 0 |
| Solid signals | 271 | 0 | 45.2 ±8% |
| MVT (hand-written, wasteful) | 41 | 0 | 6.9 |
About 500 short-lived items, 50 new per frame, for one simulated minute (3600 frames) (median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Approach | Young-generation collections | Full collections | Time in collections (ms) |
|---|---|---|---|
<List> over a SlotList | 5 | 0 | 0.8 |
| build and destroy per item | 15 | 0 | 4.9 |
Memory kept alive per Pixi container and its model record (bytes; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Measure | bare container | MVT (hand-written) | MVT (JSX) | events | Solid signals |
|---|---|---|---|---|---|
| per container | 712 | 883 | 1,190 | 874 | 2,550 |
- Polling allocates next to nothing. Hand-written refresh methods and
watch()allocate nothing per frame, and the JSX runtime a constant 8 bytes per frame, however much changes. Over a simulated minute with everything changed each frame, the engine never needed to collect. - Signals allocate on every change. Solid's effects left about 630 bytes of garbage per changed container per frame (330-380 with solid-js 1.9.11 under Node 22), and about 100 bytes per frame even at rest. With everything changed each frame, that is 272 collections a minute, about 32 ms of pauses: six times the garbage of the deliberately wasteful refresh methods.
- Events allocate nothing in this benchmark, because each record's listener is created once and called with the record.
- These scenes use whole numbers. V8 stores whole numbers without allocating, but can box a fractional number in a small heap object when it stores or passes one, and whether it does can depend on code nearby. The JSX runtime keeps fractional values unboxed in the attributes it writes only on a change, such as
width: 1000 sprites with a fractionalwidthallocate nothing per frame, changed or not. - Reusing containers avoids most of a pool's garbage. With
<List>over aSlotList, the only allocation is the new model records (5.6 KB per frame at 50 new items). Building and destroying a container per item allocates about 70 KB per frame, and collects about three times as often. - A Pixi container is already about 710 bytes. Following a model record with a hand-written refresh method or an event listener adds about 170 bytes, including the record itself; the JSX runtime adds about 470, and signals and an effect about 1,800.
The Games and Demos
This repo's games and demos as they ship, each started through its entry and run under Node with nothing drawn. The games are driven by a fixed pattern of simulated input: directions changing every half second and buttons pressed every second or so. The demos run unattended, as they do before anyone touches them. Each frame is what the game loop runs: the session's update (its models), then updateView and refreshView on the stage. The first 10 seconds are a warm-up, and times are averaged over the minute after.
Time per frame, averaged over one simulated minute (median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Kind | Name | Models (µs) | updateView (µs) | refreshView (µs) | Total (µs) | Pixi containers | Update and refresh methods |
|---|---|---|---|---|---|---|---|
| Game | Astrovoid | 0.85 ±13% | 0.33 ±10% | 4.03 ±9% | 5.12 ±9% | 46 | 22 |
| Burrow Bust | 1.28 ±6% | 0.35 ±16% | 2.74 | 4.37 | 37 | 14 | |
| Crumb Chase | 0.18 | 0.49 ±12% | 4.06 ±6% | 4.79 ±6% | 234 | 11 | |
| Dojo Duel | 1.65 ±20% | 0.4 ±13% | 1.93 ±7% | 3.94 ±12% | 27 | 6 | |
| Fuel Run | 1.09 ±18% | 0.54 ±8% | 3.15 | 4.82 | 84 | 42 | |
| Galaxy Raiders | 3.64 ±13% | 0.42 ±46% | 3.34 | 7.42 ±7% | 79 | 38 | |
| Kwazy Cactii | 0.17 ±8% | 0.71 ±14% | 8.55 | 9.6 | 306 | 76 | |
| Demo | Boids | 507 ±11% | 0.86 ±7% | 15.1 | 523 ±11% | 313 | 33 |
| Falling sand | 1.85 ±7% | 0.74 ±6% | 182 | 184 | 3,890 | 3,790 | |
| Reordering lists | 0.2 ±124% | 0.54 ±19% | 2.3 ±10% | 3.32 ±8% | 46 | 37 |
- Each game takes 4-9 µs per frame, well under 0.1% of a 60fps frame, before drawing. Drawing is not measured here, but is likely to cost far more.
refreshViewtakes the larger share in most games, since that is where the views read the model and set their properties. The models take 0.2-4 µs, andupdateViewunder 1 µs; in Galaxy Raiders, with more going on in its model, the models take longer than the refresh.- Two demos cost far more than any game, for different reasons. Falling sand has a sprite per grain, about 3,800 containers once its opening scene settles, and its refresh takes about 160 µs, about 41 ns per container with nothing moving. How that grows with the number of grains is in the
falling-sand-scalingresults: about 2.2 ms at 20,000 grains. Boids takes about 0.5 ms, almost all of it in its model, which compares every pair of its 200 boids each frame. - The games allocate a little every frame, from tens of bytes to about 1 KB. The hot path rules aim for none, and the allocation benchmark is a way to find where it comes from. At these rates the engine collects at most three times a minute, for under a millisecond in total.
- Fuel Run and Crumb Chase used to allocate 2-3 KB per frame. Fuel Run's came from views redrawing graphics every frame (each explosion, and the fuel bar), now drawn once and then scaled or resized. Crumb Chase's came from its model: every one-tile step of the mouse or a cat started a GSAP tween. The steps are now plain arithmetic advanced by
update(deltaMs), and the model allocates nothing, with its update down from about 6 µs to 0.2 µs. - Boids used to allocate about 360 KB per frame, and the engine collected 88 times a minute. Most of it was the view redrawing all 200 boids into one Pixi
Graphicsevery frame, which makes Pixi build new shape data each time; each boid is now its ownGraphics, sharing one triangle, positioned and turned. The rest came from sliders redrawing unchanged, and from numbers the engine had to box: writes to a boid record that had getters, a random-number function called per boid, and Pixi'srotationsetter. It now allocates about 34 bytes per frame, and its frame time fell from about 1 ms.
Bytes allocated per frame (median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Kind | Name | Bytes per frame |
|---|---|---|
| Game | Astrovoid | 104 ±7% |
| Burrow Bust | 234 | |
| Crumb Chase | 443 | |
| Dojo Duel | 939 | |
| Fuel Run | 352 | |
| Galaxy Raiders | 224 ±21% | |
| Kwazy Cactii | 19 | |
| Demo | Boids | 34 |
| Falling sand | 17 | |
| Reordering lists | 204 |
Garbage collections over one simulated minute (3600 frames) (median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)
| Kind | Name | Young-generation collections | Full collections | Time in collections (ms) |
|---|---|---|---|---|
| Game | Astrovoid | 0 | 0 | 0 |
| Burrow Bust | 0 | 0 | 0 | |
| Crumb Chase | 0 | 0 | 0 | |
| Dojo Duel | 1 ±50% | 0 | 0.1 ±48% | |
| Fuel Run | 0 | 0 | 0 | |
| Galaxy Raiders | 1 | 0 | 0.1 ±83% | |
| Kwazy Cactii | 1 | 0 | 0.4 | |
| Demo | Boids | 0 | 0 | 0 |
| Falling sand | 0 | 0 | 0 | |
| Reordering lists | 0 | 0 | 0 |
What Is Not Measured
- Rendering. Nothing is drawn, so Pixi's transform updates and draw calls are not in any number. When values change, rendering is likely to cost more than anything measured here.
- Browsers. Everything is V8 under Node on one machine. Other engines, and the same engine in a browser, may differ; ratios travel better than absolute times.
- Textures and text. The games and demos run with every texture replaced by a 1x1 white texture, because loading a spritesheet needs a browser, and with text widths estimated from the font size, because measuring text needs a canvas.
To re-run any of this, see Benchmarking Methods - Running the Repo's Benchmarks.