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.
The problem
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.
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.
First version: two layers
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.
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.
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.
Looking into it, most of the time per frame went into calculating positions, not into drawing.
Moving the calculation to the GPU
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.
The 3D model
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.
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.
Dead reckoning in the shader
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.
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.
// seconds since the last update, max 30
float dt = clamp(u_time - last_update, 0.0, MAX_DT);
// distance flown, as an angle
float theta = speed * dt / EARTH_RADIUS;
// Right to left: forward, heading, latitude, longitude
mat3 rotation = rot_y(lng) * rot_x(-lat) * rot_z(-heading) * rot_x(-theta);
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.
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.
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.
Picking
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.
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.
// 8 bits of the id per channel: red, green, blue
// 0 is reserved for "no aircraft"
uint id = uint(gl_InstanceID) + 1u;
pick_color = vec4(id & 0xFFu, (id >> 8) & 0xFFu, (id >> 16) & 0xFFu, 255u) / 255.0;
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.
// Read the pixel under the mouse and rebuild the id
gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel)
const id = pixel[0] | (pixel[1] << 8) | (pixel[2] << 16)
Aircraft behind the globe
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.
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.
if (dot(center, horizon_plane.xyz) + horizon_plane.w < 0.0) {
// z = 2 is outside -1..1, so the GPU discards it
gl_Position = vec4(0.0, 0.0, 2.0, 1.0);
return;
}
You can see it live at airspace.juanesroig.com and the code is on GitHub.
Top comments (0)