<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Greg Hodson</title>
    <description>The latest articles on DEV Community by Greg Hodson (@h0dson).</description>
    <link>https://dev.to/h0dson</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F125213%2Fbb919a93-6b68-482c-a847-dcc537aa6b6f.png</url>
      <title>DEV Community: Greg Hodson</title>
      <link>https://dev.to/h0dson</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/h0dson"/>
    <language>en</language>
    <item>
      <title>Using spryte on its own: fast 2D drawing on any canvas</title>
      <dc:creator>Greg Hodson</dc:creator>
      <pubDate>Wed, 28 Oct 2026 03:36:31 +0000</pubDate>
      <link>https://dev.to/h0dson/using-spryte-on-its-own-fast-2d-drawing-on-any-canvas-4gk8</link>
      <guid>https://dev.to/h0dson/using-spryte-on-its-own-fast-2d-drawing-on-any-canvas-4gk8</guid>
      <description>&lt;p&gt;spryte is the renderer inside debuff, but it doesn't need debuff. If you have a canvas (an editor, a visualisation, your own engine, a tiny game), it gives you fast 2D drawing with a small, explicit API. Here's everything a standalone setup needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and create
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;spryte
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createRenderer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;spryte&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;draw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRenderer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;smooth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// smooth: false for pixel art&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                       &lt;span class="c1"&gt;// instant on WebGL2; waits for the fallback otherwise&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                                 &lt;span class="c1"&gt;// "webgl2", or "2d" on the Canvas 2D fallback&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;createRenderer&lt;/code&gt; uses WebGL2 when it can. Otherwise it loads the Canvas 2D backend, a separate small chunk that WebGL2 browsers never download. Pass &lt;code&gt;{ backend: "webgl2" }&lt;/code&gt; to require WebGL2, or &lt;code&gt;"2d"&lt;/code&gt; to force the fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Size the canvas for sharp pixels
&lt;/h2&gt;

&lt;p&gt;spryte draws in &lt;strong&gt;logical units&lt;/strong&gt; you choose, and you own the canvas's backing size. Size it for the display, then tell spryte:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dpr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;devicePixelRatio&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientWidth&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dpr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientHeight&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dpr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;dpr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The frame loop
&lt;/h2&gt;

&lt;p&gt;Each frame: &lt;code&gt;begin&lt;/code&gt;, your drawing in paint order, then &lt;code&gt;end&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dpr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientWidth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#111&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dpr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// clear colour, logical width and height, backing pixels per unit&lt;/span&gt;
  &lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#cffb2d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sprite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hero&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; · &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;drawCalls&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; draw calls`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;font&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;14px sans-serif&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a fixed pixel-art resolution, draw at, say, 320x180 logical units with &lt;code&gt;scale = canvas.width / 320&lt;/code&gt;, and let the browser letterbox the canvas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Images
&lt;/h2&gt;

&lt;p&gt;spryte draws &lt;code&gt;HTMLImageElement&lt;/code&gt;, &lt;code&gt;HTMLCanvasElement&lt;/code&gt;, &lt;code&gt;OffscreenCanvas&lt;/code&gt; and &lt;code&gt;ImageBitmap&lt;/code&gt; objects. Each is uploaded to the GPU the first time you draw it, and cached per object.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Make sure an image has loaded&lt;/strong&gt; before drawing it: &lt;code&gt;await img.decode()&lt;/code&gt;, or use &lt;code&gt;createImageBitmap&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;For a sprite sheet, pass a &lt;code&gt;Frame&lt;/code&gt;, &lt;code&gt;{ image, x, y, w, h }&lt;/code&gt;, to draw part of an image.&lt;/li&gt;
&lt;li&gt;If you change a canvas you draw from, call &lt;code&gt;draw.invalidate(thatCanvas)&lt;/code&gt; so spryte re-uploads it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Context loss
&lt;/h2&gt;

&lt;p&gt;On phones, the browser can take the GPU away from a backgrounded tab. spryte handles this: while &lt;code&gt;draw.contextLost&lt;/code&gt; is true, drawing is skipped, and when the context comes back it rebuilds its GPU state and re-uploads images on first use. The one thing that doesn't come back is the contents of render targets, so redraw those each frame or when &lt;code&gt;contextLost&lt;/code&gt; turns false.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning up
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// frees every GPU object and listener&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;dispose()&lt;/code&gt;, using that &lt;code&gt;Draw&lt;/code&gt; throws a clear error rather than failing silently. Disposing twice is harmless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;draw.kind&lt;/code&gt;: which backend you're on.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;draw.quads&lt;/code&gt; and &lt;code&gt;draw.drawCalls&lt;/code&gt;: how much you drew this frame, and in how many GPU calls.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;draw.raw&lt;/code&gt;: the underlying &lt;code&gt;WebGL2RenderingContext&lt;/code&gt; or &lt;code&gt;CanvasRenderingContext2D&lt;/code&gt;, if you need to step outside spryte (restore any state you change).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next: &lt;a href="https://debuff.jp/blog/spryte-particles-and-bullets" rel="noopener noreferrer"&gt;Particles and bullets with draw.sprites&lt;/a&gt;, and &lt;a href="https://debuff.jp/blog/spryte-post-processing" rel="noopener noreferrer"&gt;Post-processing with spryte&lt;/a&gt;. The full &lt;a href="https://debuff.jp/api/spryte/" rel="noopener noreferrer"&gt;API reference&lt;/a&gt; is on this site.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>webgl</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Why moving sprites are slow in the browser</title>
      <dc:creator>Greg Hodson</dc:creator>
      <pubDate>Sun, 25 Oct 2026 22:32:26 +0000</pubDate>
      <link>https://dev.to/h0dson/why-moving-sprites-are-slow-in-the-browser-55gg</link>
      <guid>https://dev.to/h0dson/why-moving-sprites-are-slow-in-the-browser-55gg</guid>
      <description>&lt;p&gt;A GPU can draw millions of sprites, but most browser games stall in the tens of thousands, and the reason is JavaScript, not the GPU. Games, data visualisations and particle effects all share this job: draw lots of small images, called sprites, in new positions sixty times a second.&lt;/p&gt;

&lt;p&gt;We build &lt;a href="https://github.com/debuffjp/spryte" rel="noopener noreferrer"&gt;spryte&lt;/a&gt;, an open-source 2D renderer for the web. On an open benchmark it draws moving sprites &lt;strong&gt;2-4x faster than PixiJS&lt;/strong&gt;, the most widely used library for this job, from about an eighth of the JavaScript. Here's where the time goes and what we changed. You can check every number on your own machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The budget: 16.7 milliseconds
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So the question for a 2D renderer is: &lt;strong&gt;how much JavaScript work does each sprite cost?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to describe a scene
&lt;/h2&gt;

&lt;p&gt;Most 2D libraries, including &lt;a href="https://pixijs.com" rel="noopener noreferrer"&gt;PixiJS&lt;/a&gt;, use a &lt;strong&gt;scene graph&lt;/strong&gt; (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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;But in a game, things move. When every object changes every frame, all of that per-object bookkeeping runs for every sprite, every frame.&lt;/p&gt;

&lt;p&gt;The other approach is &lt;strong&gt;immediate mode&lt;/strong&gt;. 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#111&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;320&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;enemies&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sprite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;enemyImage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making each sprite cheaper
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A bulk call.&lt;/strong&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;positions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Float32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// x, y pairs your game already updates&lt;/span&gt;
&lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sprites&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;positions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shared work, such as which texture and which part of it, is done once per call instead of once per sprite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Sending less to the GPU.&lt;/strong&gt; 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 &lt;strong&gt;8 bytes&lt;/strong&gt; 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 &lt;code&gt;Float32Array&lt;/code&gt;, spryte hands that array to the GPU as it is. There's no JavaScript loop over sprites at all.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring it without fooling ourselves
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So we built &lt;a href="https://github.com/debuffjp/2dbench" rel="noopener noreferrer"&gt;2dbench&lt;/a&gt;, an open benchmark that any 2D renderer can plug into. Its rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same work.&lt;/strong&gt; One shared, seeded simulation moves everything. Libraries only draw.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same pixels.&lt;/strong&gt; Before anything is timed, every library draws the same frame, and the pixels are compared with PixiJS's output. If a library draws something different, the run fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real cost.&lt;/strong&gt; Each frame is timed until the GPU has finished, so nothing hides behind the screen's refresh.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No luck.&lt;/strong&gt; Several rounds, in rotating order, reported as the median with the spread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything published.&lt;/strong&gt; Every result file records the GPU, browser, CPU and even the power plan. If you know a faster way to set up a library, a pull request will change it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's more detail in &lt;a href="https://debuff.jp/research/benchmarking-2d-renderers-fairly" rel="noopener noreferrer"&gt;Benchmarking 2D web renderers without fooling yourself&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;p&gt;Most moving sprites each library can draw at 60 fps, on a laptop with an NVIDIA RTX 5060 in Chrome:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scene&lt;/th&gt;
&lt;th&gt;spryte&lt;/th&gt;
&lt;th&gt;PixiJS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Moving sprites, bulk&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;800,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;400,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Particles that change size, rotation and colour, bulk&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;400,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Particles that change size, rotation and colour, one call each&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A retro first-person maze drawn as thousands of texture strips&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20,000 enemies&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;a href="https://github.com/debuffjp/2dbench#results-so-far" rel="noopener noreferrer"&gt;2dbench README&lt;/a&gt;. The maze scene is a &lt;a href="https://debuff.jp/research/raycasting-with-a-sprite-renderer" rel="noopener noreferrer"&gt;Wolfenstein 3D-style raycaster&lt;/a&gt;, and it's the hardest case for a scene graph, because nearly every object changes every frame.&lt;/p&gt;

&lt;p&gt;spryte also ships about &lt;strong&gt;10 kB&lt;/strong&gt; of JavaScript at startup (compressed), against about 88 kB for PixiJS.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't mean
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PixiJS isn't badly made.&lt;/strong&gt; It does far more: filters, text layout, interaction, an ecosystem of plugins. Its per-object design is a sensible trade-off, and the cost only shows when huge numbers of things move at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Still scenes are much closer.&lt;/strong&gt; If most of your scene doesn't change, a scene graph can skip work, and the gap shrinks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your game won't run 4x faster.&lt;/strong&gt; A real game also spends time on its own logic, physics and sound. Faster drawing gives that time back; it doesn't make the rest faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Devices vary.&lt;/strong&gt; spryte's one-call-per-sprite path is ahead on every device we've measured: desktops on NVIDIA and AMD GPUs, iPhones in Safari and Chrome, and an Android phone in Firefox. The gap is widest on desktops (up to 4x) and smallest on the Android phone (about 1.6x). More devices make the picture clearer, and you can add yours.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run the benchmark on your device:&lt;/strong&gt; &lt;a href="https://debuffjp.github.io/2dbench/mobile.html" rel="noopener noreferrer"&gt;debuffjp.github.io/2dbench&lt;/a&gt;. It takes a few minutes, needs no account, and adds your phone or laptop to the public results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use spryte:&lt;/strong&gt; &lt;code&gt;npm install spryte&lt;/code&gt;. It's MIT licensed with no dependencies, and falls back to Canvas 2D where WebGL2 isn't available. It's also the renderer inside &lt;a href="https://debuff.jp" rel="noopener noreferrer"&gt;debuff&lt;/a&gt;, our toolkit for 2D web games.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prove us wrong:&lt;/strong&gt; if you know a faster way to set up PixiJS, or any other library, &lt;a href="https://github.com/debuffjp/2dbench" rel="noopener noreferrer"&gt;open a pull request on 2dbench&lt;/a&gt;. If our lead shrinks, we'll publish that too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The longer, more technical version of this story, from the first profile to the 8-byte instance format, is in &lt;a href="https://debuff.jp/research/how-spryte-got-fast" rel="noopener noreferrer"&gt;How spryte got fast&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://debuff.jp/blog/why-moving-sprites-are-slow" rel="noopener noreferrer"&gt;debuff.jp&lt;/a&gt;. Questions and benchmark runs are welcome in the &lt;a href="https://discord.gg/jshbDHSpCY" rel="noopener noreferrer"&gt;debuff Discord&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>gamedev</category>
      <category>webgl</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
