Particle Stock – Part 1 : Building a Renderer

I’ve almost finished a useful tool, but there’s quite a lot to it so I’ve decided to split this into two parts. In this first part I’ll go over the core mechanic of the tool and something that a lot of people seem to have difficulty with, building a renderer. Sadly, not a full renderer, just a way of converting 3D points to 2D screen space.

In order to understand the code, we’ll need to do a quick little crash course in how rendering works. I’ll try keep it simple and short, but if you already know or simply want to see the Particle Stock code, skip on Part 2 (as soon as it’s up!).

The process is done in the following steps:

  1. Convert target position to camera local space (It’s relative position to a camera sitting at origin and pointing down the -z axis).
  2. Convert from camera local space to NDC (Normalized Device Coordinates) space. This is a perspective transformation which uses the camera’s settings to map positions into a -1 to 1 range for each axis. These are the points which are visible to the camera.
  3. Convert NDC space to the current format (eg, 1920 x 1080).

This is mostly done using matrix math. Matrices can be daunting at first but are actually quite simple. If you want a detailed breakdown on how all of this works, I highly recommend the scratchapixel explanation. The simple version is that matrices store the rotation, scale and translation of a point in one easy to use form. These are known as affine transformations, which means they are linear and preserve straight lines resulting in orthographic views. These are what we use to move objects around in 3D space, and is where we begin step 1.

Rendering a Perspective View

Step 1

 

CameraSpace
Converting to Camera Space

We need the particle positions to be in the space of the default camera position. As the difference between the camera and it’s default position is simply the current transformation on the camera, the matrix we need is the reverse of it’s current transform. We can easily take the camera matrix and just use a simple built in invert function. The multiplication however we’ll need to handle ourselves.

float4 multVectMatrix( float4 vec, float4x4 M ) {
  float4 out;
  out[0] = vec.x * M[0][0] + vec.y * M[0][1] + vec.z * M[0][2] + M[0][3];
  out[1] = vec.x * M[1][0] + vec.y * M[1][1] + vec.z * M[1][2] + M[1][3];
  out[2] = vec.x * M[2][0] + vec.y * M[2][1] + vec.z * M[2][2] + M[2][3];
  float w = vec.x * M[3][0] + vec.y * M[3][1] + vec.z * M[3][2] + M[3][3];
 
  if (w != 1.0f) { 
    out.x /= w; 
    out.y /= w; 
    out.z /= w; 
  } 

  return out;
}

worldToCamM = camToWorldM.invert();

float4 point_local = multVectMatrix( particle, worldToCamM );

Note: This multiplication uses row-order (more on that in step 2)

We want to multiply a 3D vector ( x, y and z ) by a 4×4 matrix, but matrix multiplication requires the the vector to have the same number of columns. We can get around this by simply assuming the fourth position is 1, eg ( x, y, z, 1 ), which won’t affect the multiplication. To get a perspective transform however, we need to divide the result of the first three rows by the perspective divide. If the input matrix is not a perspective matrix, the fourth value will be 1 and no divide is necessary as anything divided by 1 won’t change.

 

Step 2

There is another type of matrix however known as homogenous transforms. These apply a perspective distortion to the points, which is how we normally perceive objects. The logic behind it is simple trigonometry which can applied now that the point is in default camera space, as the screen’s x and y axis are now aligned with the world space x and y; We simply divide each position by it’s z position.

PerspectiveMatrix
Perspective Projection Matrix

The perspective projection matrix above uses a few simple variables, top, right, bottom, left, near and far. The first four refer to what’s called the viewing frustrum, the corner points in 3d space of where the screen would intersect the view of the camera. To think of it another way, our final render is a 2D image, meaning all the points are sitting on a flat plane. That plane exists in 3D space directly in front of the camera.

Screen Space
2D planar position in 3D space

How close that plane is, is determined by the last two variables, near and far. These are what’s called the clipping planes of the camera and are used to determine how far from the camera to render. In the same way that you can’t divide by 0 without getting infinity, you need to give the projection a fixed range to fit the values to. This means we won’t render anything beyond those values (or at least, we won’t map their depth accurately. More on this later) .

To show how we calculate it, let’s look over the PerspectiveRender code.

// Output image aspect
 float w = dst.bounds.width();
 float h = dst.bounds.height();
 float aspect = w / h;

// Corner co-ordinates of the viewing frustrum
 float right = ( 0.5f * haperture / focal) * znear;
 float left = -right;
 float top = right / aspect;
 float bottom = -top;

// Set the Perspective Matrix ( Fits camera space to screen space)
 perspM[0][0] = ( 2 * znear ) / ( right - left );
 perspM[0][2] = ( right + left ) / ( right - left );
 perspM[1][1] = ( 2 * znear ) / ( top - bottom );
 perspM[1][2] = ( top + bottom ) / ( top - bottom );
 perspM[2][2] = - ( ( zfar + znear ) / ( zfar - znear ) );
 perspM[2][3] = - ( ( 2 * zfar * znear ) / ( zfar - znear ) );
 perspM[3][2] = -1;

// Transform position to screen space
 float4 screen_center = multVectMatrix( point_local, perspM );

This is all quite straightforward. We take existing variables from the camera; the horizontal aperture (haperture), focal length, near and far, and then from them we can calculate the variables we need. Obviously, we’ll also need to know the size of our output image, which we calculate by taking the width and height of the destination image. Finally, we plug all of these into the perspective matrix using the formula above.

Matrix Order
Matrix Orders

There is one more thing to mention about matrices however, and that is there are two different ways of writing them; Row order and Column order. These refer to the order of the numbers that you see in the last section of code (eg, perspM[0][2] vs perspM[2][0]). This is easiest to think about when we write out the matrix in a 4 x 4 grid as shown above. The order type refers to which line we reference first. This is important for how multiplication of matrices works, otherwise we’d be multiplying by the wrong side of the matrix. Swapping between the ordering is called transposing, and is effectively just mirroring the matrix along the diagonal from top left to bottom right.

By multiplying our point by this matrix we’ve completed step two.

 

Step 3

The final step is easy. We have all our positions now in a -1 to 1 range for each axis that is aligned with our screen (ie, x and y axis is the same in 2D and 3D space). To map them to our output range we simply map them to a 0 to 1 range and then multiply by our destination width and height.

float ct_x = ( screen_center.x + 1 ) * 0.5f * dst.bounds.width();
float ct_y = ( screen_center.y + 1 ) * 0.5f * dst.bounds.height();

But what about the depth? It’s been squashed into a -1 to 1 range as well, but it’s perspective distortion will give us some weird results. The most notable thing here is that unlike the x and y where values extend beyond -1 and 1, the z values are stuck to this range. In fact, due to the distortion, only a small section will be fitted towards the center of this range. This is why placing the near and far planes as close as possible to the target is advantageous as it gives the most information for us to work with.

Really, we just want to know the positions distance from the camera, and seeing as we are using the default camera at origin we could just Pythagoras’ Theorem to calculate the length of the current position, but that’s costly when calculating a lot of them. We can simply remap our new z position back to linear space.

float zdepth = 2.0 * znear * zfar / (zfar + znear - screen_center.z * (zfar - znear));
zdepth = clamp( 1.0f - zdepth / depth_max, 0.0f, 1.0f );

And that is that, we have successfully mapped a point to our screen with the correct depth value. To build an accurate rendering we’ll need to do a few more things, but coming up in Part 2 will be a whole host of fun things that can be done with this method.

If there’s enough interest, I’d be willing to go into more detail on how and why certain aspects of the projection matrix are what they are, or feel free to contact me below.

.

Advertisement
Particle Stock – Part 1 : Building a Renderer

One thought on “Particle Stock – Part 1 : Building a Renderer

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s