返回资讯中心
外部精选
软件工程
#开发者实时流

Why moving sprites are slow

We build spryte, an open-source 2D renderer for the web. On an open benchmark it draws moving sprites 2-4x faster than PixiJS, the most widely used library for this job, from about a ninth of the JavaScript. Here's wher…

Hacker News 最新gregjw2026/10/24 · 21 天前12 分钟阅读

以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。

We build spryte, an open-source 2D renderer for the web. On an open benchmark it draws moving sprites 2-4x faster than PixiJS, the most widely used library for this job, from about a ninth of the JavaScript. Here's where the time goes and what we changed. You can check every number on your own machine.

A screen refreshing at 60 Hz gives each frame 16.7 ms. In that time the page has to run its game logic, then tell the GPU what to draw, then let the GPU draw it. Miss the deadline and the animation stutters.

The GPU is rarely the problem for 2D. Drawing a small textured rectangle is about the cheapest thing a GPU does. The problem is the conversation before it: JavaScript has to describe every sprite to the GPU, every frame. If describing one sprite costs 100 nanoseconds, 100,000 sprites cost 10 ms, which is most of the budget gone before a single pixel is drawn.

So the question for a 2D renderer is: how much JavaScript work does each sprite cost?

Most 2D libraries, including PixiJS, use a scene graph (also called retained mode). You create an object for each thing on screen, attach it to a tree, and change its properties over time. The library walks the tree every frame: it updates each object's position relative to its parent, works out its bounds, decides how to group it with others, and writes its corners into a buffer for the GPU.

That's a very good design for an interface or a mostly still scene. It handles nesting, hit-testing and events for you, and if nothing changes, a lot of work can be skipped.

But in a game, things move. When every object changes every frame, all of that per-object bookkeeping runs for every sprite, every frame.

The other approach is immediate mode. There are no objects to keep. Each frame you say "draw this image here", in order, and the renderer forgets it afterwards. spryte works this way:

draw.begin("#111", 320, 180); for (const e of enemies) draw.sprite(enemyImage, e.x, e.y, { rotation: e.angle }); draw.end();

There's no tree to walk and nothing to keep in sync, so the cost per sprite is just the cost of writing its numbers into memory the GPU will read.

Even immediate mode isn't free. When we profiled spryte's original renderer, each sprite still cost about 70 ns of JavaScript, and PixiJS's specialised particle container was beating it. The time was spread thinly over reading options, transform maths and writing 13 numbers per sprite. No single line was slow, so tuning lines couldn't fix it. Two structural changes did.

  1. A bulk call. Games often draw many copies of the same image: bullets, particles, coins. So spryte has one call that takes the image once and a whole array of positions:

const positions = new Float32Array(2 * MAX); // x, y pairs your game already updates draw.sprites(spark, positions, count);

The shared work, such as which texture and which part of it, is done once per call instead of once per sprite.

  1. Sending less to the GPU. Each sprite used to send 52 bytes to the GPU: a full transform, texture coordinates, colour and more. For copies that only differ in position, that's mostly repeated data. spryte's bulk path sends 8 bytes per sprite, just x and y, and the GPU fills in the rest from values sent once per call. Better still, if your positions are already in a Float32Array, spryte hands that array to the GPU as it is. There's no JavaScript loop over sprites at all.

Particles in a game also vary in size, rotation and colour, so those can be passed as extra arrays, each uploaded as is. Colours stay packed as one 32-bit number and are unpacked on the GPU.

None of this is new to graphics programmers. Native game engines have done it for decades; it's called instancing. What's unusual is packaging it as a tiny, plain browser library that you can use without a game engine.

Renderer benchmarks are easy to rig by accident: leave the rival library on a slow default, pick a scene that suits your design, or time one lucky run. We make spryte, so we have every reason to want a flattering result, and every reason to distrust one we wrote ourselves.

So we built 2dbench, an open benchmark that any 2D renderer can plug into. Its rules:

There's more detail in Benchmarking 2D web renderers without fooling yourself.

Most moving sprites each library can draw at 60 fps, on a laptop with an NVIDIA RTX 5060 in Chrome:

Counts are tested in steps (50k, 100k, 200k…), so each figure is the last step that held 60 fps. The frame-time charts and raw data are in the 2dbench README. The maze scene is a Wolfenstein 3D-style raycaster, and it's the hardest case for a scene graph, because nearly every object changes every frame.

spryte also ships about 10 kB of JavaScript at startup (compressed), against about 88 kB for PixiJS.

The longer, more technical version of this story, from the first profile to the 8-byte instance format, is in How spryte got fast.

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →