<?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: Juan Esteban Roig Perez</title>
    <description>The latest articles on DEV Community by Juan Esteban Roig Perez (@juanesroig).</description>
    <link>https://dev.to/juanesroig</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%2F4210074%2F51e456ce-6b93-4a56-8355-b079af9e3fc6.jpeg</url>
      <title>DEV Community: Juan Esteban Roig Perez</title>
      <link>https://dev.to/juanesroig</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/juanesroig"/>
    <language>en</language>
    <item>
      <title>Animating 10,000+ live aircraft at 60 fps: moving dead reckoning to the GPU</title>
      <dc:creator>Juan Esteban Roig Perez</dc:creator>
      <pubDate>Tue, 27 Oct 2026 06:38:45 +0000</pubDate>
      <link>https://dev.to/juanesroig/animating-10000-live-aircraft-at-60-fps-moving-dead-reckoning-to-the-gpu-59k6</link>
      <guid>https://dev.to/juanesroig/animating-10000-live-aircraft-at-60-fps-moving-dead-reckoning-to-the-gpu-59k6</guid>
      <description>&lt;p&gt;For a while I’ve been working on Airspace, a 3D globe that shows live air traffic around the world. It looks simple, but getting thousands of aircraft to move smoothly ended up being the hardest part of the project. In this post I explain how I went from drops to 10-15 fps to a stable 60 fps with around 10,000 aircraft on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;The flight data comes from a backend that sends the position of every aircraft about every 30 seconds. If I just drew each aircraft where the last update put it, it would stay still for 30 seconds and then jump to its new position.&lt;/p&gt;

&lt;p&gt;So I have to animate the flight myself. This is called dead reckoning: if I know where an aircraft was, its heading and its speed, I can estimate where it should be right now. For one aircraft it’s easy. For thousands, on every frame, it gets complicated.&lt;/p&gt;

&lt;h2&gt;
  
  
  First version: two layers
&lt;/h2&gt;

&lt;p&gt;My first approach used two map layers. When you were zoomed out, a symbol layer drew the aircraft as flat icons. When you zoomed in, a Three.js layer took over and drew them as 3D models.&lt;/p&gt;

&lt;p&gt;Only the Three.js layer was animated, and the dead reckoning was computed in JavaScript. On every frame the CPU went through all the aircraft inside the visible area, calculated their new position and updated their model. In quiet areas it worked fine, but over Europe or the US, with thousands of aircraft visible, the frame rate dropped to 10-15 fps.&lt;/p&gt;

&lt;p&gt;On top of that, switching between the two layers while zooming caused a small flash. Not a big deal, but you noticed it every time.&lt;/p&gt;

&lt;p&gt;Looking into it, most of the time per frame went into calculating positions, not into drawing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving the calculation to the GPU
&lt;/h2&gt;

&lt;p&gt;A GPU is very good at running the same small program over a lot of data at once, and dead reckoning is exactly that: the same formula for every aircraft, with different numbers. So I removed both layers and wrote a single custom WebGL layer, where the position of each aircraft is calculated in the shader.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 3D model
&lt;/h3&gt;

&lt;p&gt;The aircraft model was a glTF file. Instead of loading it every time the app starts, I converted it once into a list of vertices and saved it in the code, so it only has to be sent to the GPU once.&lt;/p&gt;

&lt;p&gt;To draw all the aircraft I use instancing: the GPU draws the same model many times in a single call. Next to the model there’s an array with one entry per aircraft: coordinates, altitude, heading, speed and the time of its last update. When new data arrives, the CPU only has to fill that array and upload it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dead reckoning in the shader
&lt;/h3&gt;

&lt;p&gt;On every frame I pass the shader the current time. With it, each aircraft knows how many seconds have passed since its last update and, with its speed, how far it has moved.&lt;/p&gt;

&lt;p&gt;To place it on the globe I use rotations. I start with the aircraft at the north pole and move it forward, in the direction of its nose, by the distance it has travelled. Then I rotate it to its heading, and after that to its latitude and longitude. Moving in a straight line on a sphere is the same as rotating around its centre, so the aircraft follows the right path and ends up pointing in the right direction.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="c1"&gt;// seconds since the last update, max 30&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u_time&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MAX_DT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// distance flown, as an angle&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;theta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;speed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;EARTH_RADIUS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Right to left: forward, heading, latitude, longitude&lt;/span&gt;
&lt;span class="kt"&gt;mat3&lt;/span&gt; &lt;span class="n"&gt;rotation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rot_y&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;rot_x&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rot_z&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rot_x&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;theta&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the end, the dead reckoning is just an offset applied before each vertex is placed in its position. I also cap it at 30 seconds, so if an aircraft stops sending data it doesn’t keep flying forever.&lt;/p&gt;

&lt;p&gt;One thing to be careful with: the GPU works with 32-bit floats, and a Unix timestamp doesn’t fit with enough precision, so the aircraft would move in jumps. I measure time from when the page loads instead, which keeps the numbers small.&lt;/p&gt;

&lt;p&gt;With this the app runs at a stable 60 fps at any zoom level, even with around 10,000 aircraft on screen. And since there’s only one layer now, the flash when zooming is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking
&lt;/h2&gt;

&lt;p&gt;Having the positions only on the GPU has one downside: the CPU doesn’t know where each aircraft is, so when you click on the map it can’t tell which one you clicked.&lt;/p&gt;

&lt;p&gt;I solved it in the shader too. A second shader draws the aircraft into a hidden framebuffer, but instead of their real colour, each one is painted with a colour that represents its id. The id is split across the red, green and blue channels with bitwise operations, 8 bits per channel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 8 bits of the id per channel: red, green, blue&lt;/span&gt;
&lt;span class="c1"&gt;// 0 is reserved for "no aircraft"&lt;/span&gt;
&lt;span class="kt"&gt;uint&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;uint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;gl_InstanceID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1u&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;pick_color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;vec4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFFu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFFu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFFu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you click, I read the pixel under the mouse with readPixels and rebuild the id from its colour. That gives me the aircraft. Since it uses the same calculation as the visible shader, the aircraft you click is always where you see it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Read the pixel under the mouse and rebuild the id&lt;/span&gt;
&lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readPixels&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;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RGBA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UNSIGNED_BYTE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pixel&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pixel&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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="nx"&gt;pixel&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&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="nx"&gt;pixel&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Aircraft behind the globe
&lt;/h3&gt;

&lt;p&gt;There was one more issue. The hidden framebuffer only has the aircraft, not the globe, so you could click an aircraft that was on the other side of the Earth.&lt;/p&gt;

&lt;p&gt;To fix it I use a dot product between each aircraft’s position and the horizon plane that MapLibre provides, which tells me if the aircraft is on the visible side of the globe. If it isn’t, I set the z of all its vertices to 2. The GPU only draws what falls between -1 and 1, so the aircraft is discarded before it’s drawn. It also saves work in the normal view, because those aircraft aren’t drawn there either.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;center&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;horizon_plane&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xyz&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;horizon_plane&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// z = 2 is outside -1..1, so the GPU discards it&lt;/span&gt;
  &lt;span class="nb"&gt;gl_Position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;vec4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&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;p&gt;You can see it live at &lt;a href="https://airspace.juanesroig.com" rel="noopener noreferrer"&gt;airspace.juanesroig.com&lt;/a&gt; and the code is on &lt;a href="https://github.com/juanesroig/airspace-visualization" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
