WebGPU game (#15): Left-Handed Coordinate System
I’ve been sitting on the idea of flipping the game into a left-handed coordinate system for a while. I wanted to see how much of the existing codebase would be affected and which concepts would translate almost directly. The largest motivation is to keep the coordinate system consistent throughout. Since WebGPU works in a left-handed coordinate system, let’s make it all left handed! There is no need to consider the right-handed coordinate system except for one case that will be discussed at the end of the post.
Overview
We first need to consider what needs to change and what we’d like to change.
- Make the camera look through positive instead of down negative .
- Fix the cross product.
- Fix up projection matrices.
- Fix up the winding order of vertices.
- Fix up calculation of tangent, bitangent and normal.
- Fix up UV coordinates
- Fix up normal map sampling.
It seems like a lot, but it’s a bunch of tiny changes! There are proofs backing each of these, but in same cases it can just be explained through words.
Camera axis flip
The camera previously looked down its negative . This is just a convention. We could have made it look down its positive , but the flip would then have to occur inside of the projection matrix. Having the camera in a left-handed coordinate system allows the camera to look down its positive and avoid any additional changes to the projection matrices. To me, this feels more natural than the camera looking down the “back” of its transform, as we had in the right-hand coordinate system. So, our view matrix is now:
Cross product
The cross product doesn’t actually change when you flip coordinate systems, but the output does. You now use the “left-hand rule” instead of the right-hand rule to determine the output. You can think of it being the vector mirrored along the plane of its inputs.
The only case of the cross product changing was as a result of redefining forward for the camera (as above). This just means we calculate “up” by flipping the cross product to account for this change. All other uses of the cross product are actually unchanged!
Projection matrices
Shadow mapping and lighting
The orthographic matrix was already derived in a left-handed system to be compatible with NDCs. The version in code for the light is for a right-handed system. The simple solve here is to negate the 3rd row and column again to get it back to the version that was originally derived.
Then, we also need to fix up the last two parameters for this projection. Previously we had to flip and negate the min and max -values we calculated for the frustum. Since we want the camera to look down its positive , we just use the min and max directly instead of the awkward hack we had before.
Perspective projection
For this part, I just went through the same mechanical process as in my 3D projection post, Perspective Projection. You’ll notice that, in the end, only two values changed and they were simply negations of the right-handed projection. For posterity, I’ve decided to derive the projection matrix for this coordinate system. First, note that the coordinates for a projected point are much simpler (all positive values):
So, using similar triangles for a point in space, , we can derive:
Similarly,
The desired transformation matrix has no special negations as in the previous projection post. It’s given as:
I’ve, highlighted the , which is now a positive value, since both our and the values are positive. We do not need to divide out a negative sign as we would have to in the right-handed coordinate system.
Again, we have to solve:
We know it’s satisfied at both the near and far clipping planes:
Substituting (3) into (1) we have:
Finally, substituting (4) into (3) we have:
Substituting in, we get the perspective-to-orthographic transformation matrix :
Recall that the camera is centered on the z-axis. For the orthographic projection we have , . Therefore, we can infer the following:
Now, combining the orthographic and perspective-to-orthographic matrices, we get the perspective projection matrix, .
As before, you can replace the coordinates at and with and , respectively.
Calculation of tangent, bitangent and normal
The basis for our TBN matrix also changes, since the tangent is now on the “left”.
The key difference is that, in texture space, we have -right and -down. Both of these are inverted from our left-handed coordinate system, so similar to the Normal Maps and Specular Lighting post, we start with our edges (except that both delta coordinates are negated):
where
We can also write this as:
Similar to the existing post, we can rewrite this as a system of linear equations in matrix form:
Solving for and , we get the following:
Winding order of vertices
We want the vertices to continue to wind anti-clockwise, but if we imagine the normal as the -axis in the left-handed coordinate system, we can see that -axis flips over to the other side. This means the winding has flipped to clockwise. This requires redoing the vertices for the planes (player, enemy and bullets) and the terrain cubes.
UV coordinates
The UV coordinates were actually wrong for the hidden faces, and the new camera orientation actually exposed these faces. At first, I assumed I had made a mistake in my calculations above. There are some corrections to be made, and I did this by holding up my left hand to represent the world coordinates and imagined the UVs winding over the face in the orientation I desired (choose one of the visible axes as “up”)1. I opted to start at the top-left in texture space and wind anti-clockwise from there.
Normal map sampling
The last error I made was assuming that the normal texture was authored for a left-handed coordinate system. We know that the , , and coordinates are represented by the RGB channels, respectively. But, that’s in a right-handed space. I made one concession here, and left the texture as is. Most software is built for authoring normal maps in a right-handed space. Most people are used to visually looking at normal maps in a right-handed space (if they have worked with them before). The benefit here is that it’s just a flip in the shader, there is no application code that needs to be aware of this. Simply, after sampling the normal map and before mapping to , invert the red channel:
normal.x = 1.0 - normal.x;
To reduce wasted runtime calculations, I’d either:
- Use left-handed normal maps.
- Pre-process the normal maps to be in a left-handed space on load.
Conclusion
That’s it! After all these changes, nothing has changed visually. And, even more interestingly, not a single piece of the rotational logic surrounding geometric algebra needed changing.
There were a few minor changes which I didn’t call out directly, but it’s worth noting:
- The player controller needed to have the -direction swapped for moving backwards and forwards relative to the camera.
- Pitching is now positive for up. This means most uses need to flip to negative. I believe this is more conventional. Yaw is still anti-clockwise from above.
- There were a few changes to the camera’s yaw, positioning and player-following logic, but it just had to account for the new orientation. The camera now sits at negative and negative instead of on the positive of both axes.
Any posts moving forward will assume a left-handed coordinate system.
Links
Footnotes
-
My wife finds the hand signs I need, to figure this out, very entertaining. ↩