2DX - Projection Matrix For 2D Graphics With 3D Capabilities

The new Rogue Plasmacore I'm working on has 2D and 3D capabilities. The 3D will be typical 3D in many ways but the 2D is something a little different. It's important to me that the 2D graphics be as easy and straightforward to work with and think through as always and also have 3D capabilities in terms of 2D sprites moving and rotating in 3D space. The quintessential scenario would be a digital card game where cards are 2D but can flip over in 3D.

I call Plasmacore's new system "2DX", a programming language-friendly way of saying "2D+". I just finished figuring out the necessary projection matrix parameters.

In 2DX, sprite images can be drawn at any (x,y) location from top-left (0,0) to bottom-right (screen_width-1,screen_height-1). They also have a z value that can be adjusted independently; z starts at a nominal distance of 1.0 representing 1.0 units away from the "camera" or viewer. Setting z to 0.5 would make it twice as close to the camera while setting it to 2.0 would make it twice as far away. The vanishing point is in the middle of the screen, not at the top-left corner.

My method to set up 2DX drawing mode looks like this:

method set_mode_2dx( width:Real64, height:Real64,
  unit_z:Real64, max_z:Real64 )

The first two parameters supply the pixel dimensions of the view.

unit_z is an arbitrary value that defines the value of z in underlying 3D coordinates that we'll be treating as the unit distance 1.0 in drawing logic. It effectively sets the depth of field; small values like 30 or 100 give sprites an extreme perspective when they're rotated into and out of the screen; large values like 1000 flatten out the 3D effect too much. I chose to use 384 as a default value. It looks pretty good in my initial tests so far. By the way there's no particular reason it can't be 383 or 385, but I like 384 because it was in the ballpark and it's one of those numbers that turns up a lot in programming - it's not a power of 2 but it's close, 256 + 128.

The final parameter, max_z, is how far away a sprite can move in normalized z coordinates before it clips out. My default is 16.

The code to implement the projection matrix from those 2dx parameters is quite simple. I don't have a very math-y mind but I played around with the values and found the logical patterns. I'll write the code in pseudo-Rogue instead of the file-spanning mix of Rogue and C++ that it actually is:

method set_mode_2dx( w, h, unit_z, max_z )
  local k = (unit_z + 1) * 2
  transform = Matrix.frustum( -w/k, -h/k, w/k, h/k, 1, (max_z*k)/3 )
  transform = transform * Matrix.translate( -w/2, -h/2, 0 )

That's it! frustum() is a call that creates a matrix using the same parameters and equations as glFrustum. Since we want the vanishing point to be in the center we have to send in symmetrical negative/positive bounds, but that also means that (0,0) is in the middle of the screen instead of the top-left. Multiplying the transform by a final translation matrix fixes that up.

As a final note, OpenGL's coordinate system requires negative z values, where z = -1 is close to the camera and z = -500 is far away. So my API's normalized z coordinates range from 0 < z <= 40 and under the hood I convert that to render_z = -(z * unit_z).