April 13, 2026
I learned something about GPUs today.
I'd just released an update to Blackshift that added, among other things, these sand tiles:

All was good until bug reports started coming in with screenshots like this:

Waving goodbye to my evening, I started to think about what this could be.
Each sand tile uses the same model: a subdivided plane. A vertex shader moves the vertices of this plane around to give it a bumpy surface, and a fragment shader adds the shadows around the edges where the sand tiles butt onto other tiles1.

So it knows where to put the shadows, the fragment shader reads an adjacency map. This is an 8-bit integer for each tile, with one bit per neighbouring direction.
Like everything in Blackshift, sand tiles are drawn using GPU instancing: every sand tile on the screen is drawn together in a single batch. The GPU receives a transformation matrix for each instance, and knows to use the same mesh for all of them. Because each instance has different adjacency data, the adjacency value is also sent along with the transformation matrix as per-instance data.3
The adjacency value is an 8-bit integer, but because bgfx only supports floats for instance data, this integer is cast to a float before being written into the instance buffer.
The vertex shader reads it and passes it on to the fragment shader, which then casts it back to an int, and checks the individual bits so it knows where to put the shadows.
Of course, you've got to be careful with floats, because of precision issues, but they have more than enough precision to store any integer from 0 to 255, so there's no problem. If the CPU decides the adjacency integer is 238, it'll write 238.0f and the shader will read 238.0f, cast it back to 238 and read the bits.4
And that's how sand tiles are rendered. So, where's the bug?
* * *
My first thought was that it looked like Z-fighting, but this made no sense. The sand surface is a single surface; there's nothing for it to fight with. I turned off z-buffering to check, and, yep, the artifacts persisted. It wasn't Z-fighting.
Secondly, I checked the Level Pit Previews. In the Level Pit GUI, Blackshift renders previews of the levels people upload which you can look at before you decide which one to play. These previews are rendered a bit differently from normal gameplay frames, so they can sometimes make a good test case; if a bug appears on the normal render but not the preview render, or vice-versa, then the differing render techniques can suggest what might be going wrong.

So I asked the affected players and, in this case, the bug didn't show up in the previews, only the game itself.
This was a great clue. The biggest difference between the preview and the main render is that the preview render doesn't use GPU instancing at all; it's all turned off. It's more-or-less one draw call per object per material, and everything that's normally sent as instance data is sent as uniforms instead.
So I spent a long time poring over my instancing code and double-checking everything, but in the end, it was a total red herring. I turned off instancing in the main render too and the bug persisted. Which meant... of course. There was only one other thing it could be. After all, there's really only one other difference between the preview render and the real render.
Think back to those floats. The CPU decides the adjacency integer is 238, it writes 238.0f into the instance data buffer, the vertex shader pulls it out and writes it into a varying, and the fragment shader reads it from there, interprets it and draws the shadows.
The thing is, when that 238.0f makes its way from the vertex shader to the fragment shader, like any varying variable, it gets interpolated by the GPU over the area of the triangle being drawn. Now, because the values at all three corners of each triangle are the same, I would have thought the resulting interpolated values at every point on the triangle would also all be the same. And, indeed, they are... on my machine.
But when GPUs do this interpolation, they do it in a perspective-correct fashion, which involves dividing by the depth of each fragment and then multiplying again at the end5, and—hand-waving a bit, I know—this can cause numeric imprecision to creep in. I suppose my GPU is clever enough to notice that all three points of the triangle have the same value, and skip all the calculations, but these players' GPUs aren't doing that. They're doing all those perspective divides and multiplies, and getting those very-slightly-off float values.
This explains the artifacts seen; whenever, by interpolation, the adjacency value for a pixel ends up being lower (even infinitesmially so) than it should be, it gets interpreted as the next integer down, which represents a completely different adjacency map, and so a different shadow pattern.
In the end, my fix was CPU-side; instead of writing
(float) adjacency
into the instance buffer, I instead had it write
(float) adjacency + 0.5f
Any jitter now falls safely within the same integer, and the artifacts are gone.6
So, why didn't the artifact show up in the preview renders? Well, look again. Do you see it?

Answer: The previews are rendered with an orthographic camera. No perspective correction is necessary.
Conclusion
The takeaway here is: in GPU world, writing the same value to all three vertices of a triangle does not guarantee that all fragments in the triangle will get that exact value. Some fragments might end up with a value that's slightly off, but only on certain hardware, and only if you're using perspective projection, although there might also be hardware that does the same thing even if you aren't.
Links
How I still use Flash, about another game I made
Fixing Quicklook, about dealing with Tim Cook's macOS
Blackshift is available on Steam and it's got one less bug than it used to
RSS Feed of stuff I write and make
1. I think of this as a kind of fake ambient occlusion. Actually, all ambient occlusion is fake, along with all other computer graphics.
2. The four quad strips around the edges become the visible edges of any non-sand tiles adjacent to the sand tile. You can see them as the vertical sides of the grey tiles in the picture. If a tile isn't there, the vertex shader just moves them all down out of view.
3. Why not just keep the adjacency map for the whole level in a texture? Well, even if I did that the shader would still have to know which coordinates in the texture to look at, so I'd still have to send the coordinates of the cell being drawn as instance data. So, if instance data is necessary either way, I might as well send the value itself instead of some coordinates to look the value up in a texture. Also, bgfx limits you to 20 floats per instance, and I only had one spare. I suppose I could have reconstructed the cell coordinates by looking inside the transformation matrix that's already taking up most of those 20 floats; I think that would work. But I didn't think of it at the time. Of course, if I used a texture, I'd also then need to keep that texture up-to-date, and I'd actually need several textures, because Blackshift sometimes draws several levels at the same time (like when you're scrolling through the Level Pit looking at pictures of people's levels; those are rendered concurrently, client-side). It's just more complex.
4. Why use floats at all? Because bgfx only supports floats. Why value-cast them, instead of bitcasting them? Because I'm supporting Old OpenGL, which can't bitcast.
5. There's a good explanation of perspective-correct interpolation here.
6. Why not just mark the varying variable as flat? The answer is that I'm still supporting Old OpenGL where flat isn't a thing.