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.

  1. Make the camera look through positive zz instead of down negative zz.
  2. Fix the cross product.
  3. Fix up projection matrices.
  4. Fix up the winding order of vertices.
  5. Fix up calculation of tangent, bitangent and normal.
  6. Fix up UV coordinates
  7. 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 zz. This is just a convention. We could have made it look down its positive zz, 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 zz 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:

V=[rxryrzer^uxuyuzeu^dxdydzed^0001]V = \begin{bmatrix} r_x & r_y & r_z & -\vec{e}\cdot\hat{r} \\ u_x & u_y & u_z & -\vec{e}\cdot\hat{u} \\ d_x & d_y & d_z & -\vec{e}\cdot\hat{d} \\ 0 & 0 & 0 & 1 \\ \end{bmatrix}

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 zz-values we calculated for the frustum. Since we want the camera to look down its positive zz, 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, (x,y,z)(x, y, z), we can derive:

ysn=yzys=nyz\begin{align} \frac{y_s}{n} &= \frac{y}{z} \\ \therefore y_s &= \frac{ny}{z} \end{align}

Similarly,

xsn=xzxs=nxz\begin{align} \frac{x_s}{n} &= \frac{x}{z} \\ \therefore x_s &= \frac{nx}{z} \end{align}

The desired transformation matrix has no special negations as in the previous projection post. It’s given as:

[n0000n0000m1m20010][xyzw]=[xs=nxys=nyzs=z2z]\begin{bmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ 0 & 0 & m_1 & m_2 \\ 0 & 0 & \color{red}{1} & 0 \\ \end{bmatrix} \begin{bmatrix} x \\ y \\ z \\ w \\ \end{bmatrix} = \begin{bmatrix} x_s = nx \\ y_s = ny \\ z_s = z^2 \\ z \\ \end{bmatrix}

I’ve, highlighted the 1\color{red}{1}, which is now a positive value, since both our zz and the zsz_s 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:

m1z+m2=z2m_1z + m_2 = z^2

We know it’s satisfied at both the near and far clipping planes:

m1f+m2=f2m1n+m2=n2m2=n2m1n\begin{align*} m_1f + m_2 &= f^2 \tag{1} \\ m_1n + m_2 &= n^2 \tag{2} \\ m_2 &= n^2 - m_1n \tag{3} \end{align*}

Substituting (3) into (1) we have:

m1f+n2m1n=f2m1(fn)+n2=f2m1=f2n2fn=(fn)(f+n)fnm1=f+n\begin{align*} m_1f + n^2 - m_1n &= f^2 \\ m_1(f-n) + n^2 &= f^2 \\ m_1 &= \cfrac{f^2 - n^2}{f-n} \\ &= \cfrac{(f-n)(f+n)}{f-n} \\ \therefore m_1 &= f+n \tag{4} \end{align*}

Finally, substituting (4) into (3) we have:

m2=n2(f+n)n=n2fn+n2m2=fn\begin{align*} m_2 &= n^2 - (f+n)n \\ &= n^2 - fn + n^2 \\ \therefore m_2 &= -fn \end{align*}

Substituting in, we get the perspective-to-orthographic transformation matrix POP_O:

PO=[n0000n0000f+nfn0010]\begin{align*} P_O = \begin{bmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ 0 & 0 & f+n & -fn \\ 0 & 0 & 1 & 0 \end{bmatrix} \end{align*}

Recall that the camera is centered on the z-axis. For the orthographic projection we have r=l\ni r=-l, t=bt=-b. Therefore, we can infer the following:

r+l=0rl=2rb+t=0bt=2b\begin{align*} r+l &= 0 \\ r-l &= 2r \\ b+t &= 0 \\ b-t &= 2b \\ \end{align*}

Now, combining the orthographic and perspective-to-orthographic matrices, we get the perspective projection matrix, PP.

P=OPO=[2rl00r+lrl02tb0t+btb001fnnfn0001][n0000n0000f+nfn0010]=[nr0000nb0000ffnfnfn0010]\begin{align*} P = O P_O &= \begin{bmatrix} \frac{2}{r-l} & 0 & 0 & -\frac{r+l}{r-l} \\ 0 & \frac{2}{t-b} & 0 & -\frac{t+b}{t-b} \\ 0 & 0 & \frac{1}{f-n} & -\frac{n}{f-n} \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} \begin{bmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ 0 & 0 & f+n & -fn \\ 0 & 0 & 1 & 0 \end{bmatrix} \\ &= \begin{bmatrix} \frac{n}{r} & 0 & 0 & 0 \\ 0 & -\frac{n}{b} & 0 & 0 \\ 0 & 0 & \frac{f}{f-n} & -\frac{fn}{f-n} \\ 0 & 0 & 1 & 0 \\ \end{bmatrix} \end{align*}

As before, you can replace the coordinates at (1,1)(1, 1) and (2,2)(2, 2) with 1atanθ2\frac{1}{a\tan{\frac{\theta}{2}}} and 1tanθ2\frac{1}{\tan{\frac{\theta}{2}}}, 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 xx-right and yy-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):

E1=(Δu1)T+(Δv1)BE2=(Δu2)T+(Δv2)B\begin{align*} E_1 = (-\Delta u_1) T + (-\Delta v_1) B \\ E_2 = (-\Delta u_2) T + (-\Delta v_2) B \\ \end{align*}

where

Δu1=u2u1Δu2=u3u2Δv1=v2v1Δv2=v3v2\begin{align*} \Delta u_1 = u_2 - u_1 \\ \Delta u_2 = u_3 - u_2 \\ \Delta v_1 = v_2 - v_1 \\ \Delta v_2 = v_3 - v_2 \\ \end{align*}

We can also write this as:

(E1x,E1y,E1z)=(Δu1)(Tx,Ty,Tz)+(Δv1)(Bx,By,Bz)(E2x,E2y,E2z)=(Δu2)(Tx,Ty,Tz)+(Δv2)(Bx,By,Bz)\begin{align*} (E_{1x}, E_{1y}, E_{1z}) = (-\Delta u_1) (T_x, T_y, T_z) + (-\Delta v_1) (B_x, B_y, B_z) \\ (E_{2x}, E_{2y}, E_{2z}) = (-\Delta u_2) (T_x, T_y, T_z) + (-\Delta v_2) (B_x, B_y, B_z) \\ \end{align*}

Similar to the existing post, we can rewrite this as a system of linear equations in matrix form:

[E1xE1yE1zE2xE2yE2z]=[Δu1Δv1Δu2Δv2][TxTyTzBxByBz]\begin{align*} \begin{bmatrix} E_{1x} & E_{1y} & E_{1z} \\ E_{2x} & E_{2y} & E_{2z} \\ \end{bmatrix} = \begin{bmatrix} -\Delta u_1 & -\Delta v_1 \\ -\Delta u_2 & -\Delta v_2 \\ \end{bmatrix} \begin{bmatrix} T_x & T_y & T_z \\ B_x & B_y & B_z \\ \end{bmatrix} \end{align*}

Solving for TT and BB, we get the following:

[TxTyTzBxByBz]=1Δu1Δv2Δv1Δu2[Δv2Δv1Δu2Δu1][E1xE1yE1zE2xE2yE2z]\begin{align*} \begin{bmatrix} T_x & T_y & T_z \\ B_x & B_y & B_z \\ \end{bmatrix} = \frac{1}{\Delta u_1 \Delta v_2 - \Delta v_1 \Delta u_2} \begin{bmatrix} -\Delta v_2 & \Delta v_1 \\ \Delta u_2 & -\Delta u_1 \\ \end{bmatrix} \begin{bmatrix} E_{1x} & E_{1y} & E_{1z} \\ E_{2x} & E_{2y} & E_{2z} \\ \end{bmatrix} \end{align*}

Winding order of vertices

We want the vertices to continue to wind anti-clockwise, but if we imagine the normal as the zz-axis in the left-handed coordinate system, we can see that xx-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 xx, yy, and zz 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 [1,1][-1, 1], invert the red channel:

normal.x = 1.0 - normal.x;

To reduce wasted runtime calculations, I’d either:

  1. Use left-handed normal maps.
  2. 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:

  1. The player controller needed to have the zz-direction swapped for moving backwards and forwards relative to the camera.
  2. 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.
  3. 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 zz and negative xx instead of on the positive of both axes.

Any posts moving forward will assume a left-handed coordinate system.

  1. Git commit

Footnotes

  1. My wife finds the hand signs I need, to figure this out, very entertaining.