Skip to content

Fitting a 2D renderer inside V8's heuristics ​

spryte 0.13 spends 4-27% less JavaScript time submitting 200k sprites through draw.sprite, in eight of 2dbench's nine sprite scenes, with no change to what it draws. Most of it came from replacing Math.round with Math.floor(v + 0.5). The rest came from keeping the hot function under V8's inlining limit, plus a helper that keeps options objects to one shape. All three come from how V8, the JavaScript engine in Chrome and Node, decides what code to optimise.

2dbench, 200k sprites (text: 20k labels), draw.sprite adapter, median of 5 interleaved rounds (results). RTX 5060 laptop, Ryzen 7 260, Chrome 157, 2026-11-11. "Submit" is the JavaScript time spent issuing draw calls; "frame" waits for the GPU to finish.

SceneSubmit beforeSubmit afterChangeFrame beforeFrame after
bunny11.7 ms8.5 ms-27%15.1 ms14.9 ms
churn11.5 ms8.4 ms-27%14.9 ms11.7 ms
lowres12.3 ms9.0 ms-27%18.7 ms15.4 ms
upload13.8 ms10.7 ms-22%18.7 ms14.4 ms
clip12.0 ms9.7 ms-19%17.8 ms15.0 ms
text10.6 ms8.8 ms-17%13.2 ms10.9 ms
tilemap11.9 ms10.5 ms-12%15.2 ms13.8 ms
mixed13.0 ms11.8 ms-9%16.8 ms15.0 ms
particles11.3 ms10.9 ms-4%17.3 ms15.9 ms
raycaster173.4 ms193.0 ms+11%202.8 ms220.1 ms

Two rows need reading carefully. Bunny's frame barely moved although its submit time fell by a quarter: at that count, the GPU's share of the frame decides it. Raycaster got slower on the median, but its rounds overlap heavily (186-264 ms before, 184-253 ms after), so we're treating it as noise until a longer run says otherwise. Bulk draw.sprites doesn't go through the changed code, and its submit times at 200k were the same before and after, within 0.1 ms.

1. Math.round is slower than it looks ​

Pixel-art sprites snap to whole pixels, or their edges shimmer as they move. spryte did that with Math.round, twice per sprite.

Math.round has to round halves towards positive infinity and return -0 for inputs between -0.5 and 0. We didn't read the generated machine code to see what that costs per call; we measured the swap. For screen positions, -0 and 0 are the same pixel, and Math.floor(v + 0.5) gives the same result otherwise:

ts
export function pixel(v: number): number {
  return Math.floor(v + 0.5);
}

Apart from -0, the two differ where v + 0.5 rounds in floating point: 0.49999999999999994, and values above 2^52. Neither is a screen position. 2dbench's pixel-parity gate, which compares every adapter's frames against a reference, passed unchanged.

Measured on its own when it went in, this swap took bunny at 200k sprites from 18.6 ms to 14.6 ms a frame, in a session with a different machine load from the table above. It was the largest of the three changes.

2. The inlining budget ​

V8's optimising compiler copies small functions into their callers (inlining), which removes the call and lets it keep numbers in registers. By default TurboFan won't inline a function over 460 bytes of bytecode (--max-inlined-bytecode-size), and stops inlining into one caller after 920 bytes in total (--max-inlined-bytecode-size-cumulative). Call frequency also gates it. These are flag defaults in Node 24's V8, not a documented contract.

A call that isn't inlined passes tagged values, so a number that isn't a small integer, like a sprite's x, is boxed as a heap number to cross it. We didn't measure the garbage directly; we measured the frame.

Draw.sprite was 497 bytes of bytecode: just over the limit. Most of it handled rare cases (recording to a command list, a fallback quad path). We moved those into a separate method and left a short hot path:

ts
sprite(frame, x, y, o = NO_OPTIONS) {
  const b = this.backend;
  if (b.sprite && !this.rec) {
    this.quads++;
    b.sprite(/* ... */);
    return;
  }
  this.spriteSlow(frame, x, y, o);
}

That's 145 bytes. Measured on its own, bunny went from 15.3 ms to 14.7 ms in one session, and the mixed scene didn't move, so this change is small next to the first.

The catch is that the next feature can push it back over without anyone noticing. So there's a test. It runs the sprite path in Node with --print-bytecode and fails if Draw.sprite passes 200 bytes or either backend function passes 460:

ts
const out = execFileSync(process.execPath, [
  "--print-bytecode", `--print-bytecode-filter=${name}`, fixture,
]);
// ... read "Bytecode length: N" for each function
expect(length).toBeLessThanOrEqual(max);

On the old code it fails with "expected 497 to be less than or equal to 200". Bytecode length is a proxy: V8 can change its limits between versions, and the test says to re-measure before raising one.

3. Options objects and shapes ​

draw.sprite(frame, x, y, { rotation, tint }) reads properties from an options object. V8 speeds up property reads with inline caches, which remember the object shapes (hidden classes) seen at that line. Up to four shapes, a read stays fast. Past four, the cache becomes megamorphic and every read goes through a slower lookup.

A game makes a new shape every time it writes an options literal with different keys, or the same keys in a different order. Eight call sites with eight different literals is easy to reach, and in Node it made 200k calls take 13.9-14.7 ms instead of 5.3-5.9 ms with one shape.

spriteOptions() builds every options object with the same keys in the same order, with unused ones set to undefined:

ts
import { spriteOptions } from "spryte";

const hurt = spriteOptions({ tint: "#ff4040", flash: 0.6 });
const ghost = spriteOptions({ alpha: 0.4 });
draw.sprite(player, x, y, hurt);

Eight call sites built this way ran in 7.1-8.4 ms. That's not one shape's speed, but it's about half the cost of eight literals. Filling in real default values instead of undefined was slower, so the helper doesn't. The sprites guide covers when to use it.

What didn't help ​

  • Caching texture and sampler binds, and orphaning the vertex buffer each frame: no measurable change.
  • Trimming the backend's write function further: the mixed scene got slower in Chrome, so we dropped it.
  • A cheaper tint path: under 1 ms per 200k sprites, inside the run-to-run noise.
  • Faster shaders. Shrinking every draw to a 1x1 scissor didn't change frame times in bunny, mixed, tilemap or particles, so these scenes aren't limited by per-pixel (fragment) work. Vertex work and uploads still run under a scissor, so this doesn't rule them out.

What this doesn't show ​

  • The per-change numbers in sections 1 and 2 came from separate sessions as each change went in. The table at the top is the one run that compares before and after directly.
  • The shape numbers are from Node, not a browser frame. They show the size of the effect, not what a game will see.
  • One laptop and one browser. V8 heuristics also differ between versions.

Past the call path ​

The remaining cost is the call itself: about 60-70% of a 200k-sprite draw.sprite frame is spent submitting sprites. Bulk draw.sprites skips per-sprite calls entirely, and a prototype sprite pool built on it drew 200k sprites in 1.4-1.8 ms of CPU time per frame against 13.8-15.8 ms for a draw.sprite loop. That's ADR 0018, still a proposal.

Run the bunny scene on your own machine with 2dbench: node run.mjs bunny spryte.