Friday 1 January 2016

The world, in 3 dimentions (part 12)

I hope that the previous sections gave a bit of an overview behind the thinking of creating a 2D game. The stable of 2D games are sprites which are then placed on screen usually within a fixed grid and while we'll leave most of that behind when jumping into 3D it is still usable for things like the UI.

But it isn't just the rendering side that is interesting, when we look at how we made Conrad move throughout the 2D environment having him move from one tile to the next, and thus giving properties to the tiles in the form of our interaction map, we very much simplified the logic needed to enable this.

When we look at 3D games we often think about games with a lot of freedom and indeed many of the out of the box 3D engines focus on enabling this using relatively expensive collision detection and similar techniques to let a character interact with the 3D world.

But there are legions of games out there that take a simpler approach very much keeping the same logic for interacting with a 2D world but rendering the environment in 3D. Take many RTS games for instance which still play on a 2D map (often with a few levels giving more of a 3D feel to the maps), but simply render everything in 3D giving the player full control of the camera.

When we look at platform games, Flashback tells an interesting story as its successor, Fade to Black, was an early attempt to bring this kind of game into the 3D world with mixed success. Fast forward to today and we see many successful games in this genre that allow full 3D interactivity, Tomb Raider and Uncharted spring to mind.
But there also was a remake of Flashback that essentially kept the game dynamics the same as the original but rendered the environment in 3D.
Another title that comes to mind that made a similar jump is Oddworld. I had the pleasure of playing an altered version of the 3D version of Oddworld using stereoscopic rendering and it simply blew me away, after that I firmly believe the industry gave up on 3D stereoscopic gaming way to quickly.

The reason for this rant? Simple, for an indie game developer on a budget I believe that looking at developing games that internally are essentially 2D games but rendered to 3D is a worthwhile endeavor. This will have my main focus for the continuation of this series, to turn our little 2D platformer into a 3D platformer keeping much of the game dynamics the same.

But before we get there, we've got some boring ground to cover. For now I've gutted our little example and put the bare minimum in there to render a box in 3D. On purpose I'm leaving a few things out as to not put to much information in one write-up. This one will be too long as it stands.

Oh, I've also not removed some of the files that are now no longer in use so please ignore those.

3D projection

In our 2D tutorial we introduced the concept of orthographic projections which basically told OpenGL how to transform a vertex to the correct coordinates on the screen. This type of projection ignores the Z component of our vectors and simply scales and translates the X and Y for display on screen. The Z is still used for layering but does not influence where things are drawn.

In 3D this changes. The further away an object is, the smaller it should be drawn on screen. I won't go to much in-depth to the internals of this but in essence we will be using a matrix that divides the X and Y by our Z to create the illusion of depth (in the matrix itself the division actually happens through our W component which is derived from our Z, this so we retain our Z value which comes in handy in our rendering process). This matrix creates what is called a viewing frustrum which defines a 3D volume that confines what we are seeing on screen:
ViewFrustum
(images courtesy of wikipedia)

We can create this matrix using the function mat4Frustrum in our math3d.h library however there is a second helper function that is a little bit nicer called mat4Projection. This function takes a FOV (Field Of View) value and the aspect ratio of your display to calculate the right values for our mat4Frustrum.

We also need to define our near and far plane. When we look at the human eye near is very close and far is infinite but for a computer these values are important because they determine how our Z is scaled so we can use our depth buffer. The bigger the gap between near and far, the less precise our Z buffer becomes, the more chance things won't look right on screen as something that is behind another object could be drawn on top of it.
The flipside of this argument is that the human eye is focused on a particular distance and only objects near that distance are sharp and everything else gets blurry while the computer renders everything in focus (mimicking focus we might look into much further down as there are some neat tricks to make this work though I am not a big fan of it).

In our engineRender function in engine.c we can see this function used where we previously had our mat4Ortho call:
  // set our model view projection matrix
  ratio = (float) pWidth / (float) pHeight;

  // init our projection matrix, we use a 3D projection matrix now
  mat4Identity(&projection);
  mat4Projection(&projection, 45.0, ratio, 1.0, 10000.0);
This sets a projection matrix with a 45 degree field of view.

As before in our 2D tutorial next to our projection matrix we need a view matrix which defines our "camera". At this point in time we'll leave this as an identity matrix, we'll discuss positioning a "camera" in a future part of this tutorial.

With this combination we now have our X axis going from left to right (positive X is to the right), our Y axis going from bottom to top (positive Y is pointing up) and our Z axis going from far to near (so we're looking into negative Z) with 0,0,0 being at the center of the screen.

So point 10, 20, -30 is position 10 units to the right, 20 units up and 30 units in front of our "camera".

Note that we do later on use our  orthographic projection so we can draw our FPS counter and you would normally do this for other UI elements as well.

3D Models

Now that we have our projection all set up we need something to render to screen. For our first step we're going to keep this pretty boring and render a cube. This will however allow us to slowly introduce individual concepts and deal with individual problems as we enhance our example.

We construct our cube using two primitives, vertices which are shown as the green dots in the diagram above, and polygons which are solid shapes formed by connecting 3 or more vertices. While many 3D modeling packages allow any number of vertices that form a polygon OpenGL prefers the use of triangles and in fact since OpenGL 3 you can only use triangles for rendering.

In the fixed pipeline of OpenGL 1 the definition of our vertices was fixed but with the advent of programmable shaders we've now got a lot of freedom in how much information we record for each vertex. We call these attributes of our vertex and they can be things like:
- the position of the vertex in 3D space
- the normal for that vertex (we'll get back to this)
- one or more texture coordinates for that vertex (we'll also come back to this)
- the color for that vertex
The only attribute we're likely to always find is the position of the vertex. You have a lot of control over how this data is organized but for our example we're going to define a structure for our vertex and define an array. Our initial example will record the position and a color for that vertex.
// we define a structure for our vertices, for now we define the location and color of each of our vertices on our cube
typedef struct vertex {
  vec3    V;          // position of our vertex (XYZ)
  vec3    C;          // color of our vertex (RGB)
} vertex;

vertex vertices[] = {
  -0.5,  0.5,  0.5, 1.0, 0.0, 0.0,          // vertex 0
   0.5,  0.5,  0.5, 0.0, 1.0, 0.0,          // vertex 1
   0.5, -0.5,  0.5, 0.0, 0.0, 1.0,          // vertex 2
  -0.5, -0.5,  0.5, 1.0, 0.0, 1.0,          // vertex 3
   0.5,  0.5, -0.5, 1.0, 1.0, 0.0,          // vertex 4
  -0.5,  0.5, -0.5, 0.0, 1.0, 1.0,          // vertex 5
  -0.5, -0.5, -0.5, 1.0, 1.0, 1.0,          // vertex 6
   0.5, -0.5, -0.5, 0.0, 0.0, 0.0,          // vertex 7
};
The position (V) is a simple X,Y,Z coordinate, for the color (C) we also use a 3D vector which now contains an R,G,B value. In this way we store 8 vertices for our cube.

For our triangles we use a simple index list in which we store 3 entries for each triangle we render:
// and now define our indices that make up our triangles
GLint indices[] = {
  0, 1, 2,
  0, 2, 3,

...
  
  4, 5, 6, 
  4, 6, 7,  
};
Now the order of our 3 vertices that make up each triangle is very important. For any 3D model that forms a solid any 'back facing' polygon will always be obscured by a 'forward facing' polygon. On our cube it is never possible to have more then 3 sides visible at any given time. Now luckily there is a very easy check for this by looking at the "normal" vector of the polygon and seeing if this points towards or away from the camera. The "normal" vector of a polygon is a vector that stand perpendicular to the plane of the polygon. This is extremely easy to calculate for a triangle by simply calculating the cross product of two edges of the triangle and OpenGL is able to do this automatically for you by turning backface culling on:
  // enable and configure our backface culling
  glEnable(GL_CULL_FACE); // enable culling
  glFrontFace(GL_CW);     // clockwise
  glCullFace(GL_BACK);    // backface culling
The first command enables the culling logic. The second command tells OpenGL that if vertices are position in clockwise order they are front facing (the default is counter clockwise). The third command tells OpenGL to cull the 'back facing' polygons.

Now in the old days we would use the arrays we just defined (or loaded from disk, which we'll dive into in a later tutorial) directly but this would cause a lot of copying data from normal memory to our graphics card on each frame. Eventually OpenGL got smart enough to do this copy once and reuse the data loaded into the graphics card and in OpenGL 3 this now is the only way to go.

We've already used part of this in our previous examples but we're now going to use the full deal.
First we again need a Vertex Array Object or VAO. Here VAOs are beginning to shine as they will encapsulate all the data related to our model. With our one cube this isn't to special but if we have different objects all we need to do is bind the VAO for the model we wish to render and all state associated with that model is made current.

Second we need two Vertex Buffer Objects or VBOs that contain our actual data. We'll copy our vertices in the first VBO and our indices into our second VBO.

Last but not least we need to tell OpenGL about the two attributes our vertex VBO now contains. The whole code for loading our cube looks as follows:
void load_objects() {
  // we start with creating our vertex array object
  glGenVertexArrays(1, &VAO);
  glGenBuffers(2, VBOs);
  
  // select our VAO
  glBindVertexArray(VAO);
  
  // now load our vertices into our first VBO
  glBindBuffer(GL_ARRAY_BUFFER, VBOs[0]);
  glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), &vertices, GL_STATIC_DRAW);
  
  // now we need to configure our attributes, we use one for our position and one for our color attribute 
  glEnableVertexAttribArray(0);
  glEnableVertexAttribArray(1);
  glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(vertex), (GLvoid *) 0);
  glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(vertex), (GLvoid *) sizeof(vec3));
  
  // now we load our indices into our second VBO
  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, VBOs[1]);
  glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), &indices, GL_STATIC_DRAW);
  
  // at this point in time our two buffers are bound to our vertex array so any time we bind our vertex array
  // our two buffers are bound aswell

  // and clear our selected vertex array object
  glBindVertexArray(0);
};
Hopefully the comments are enough to explain the code. The tricky command in this is glVertexAttribPointer which tells OpenGL how the data loaded into our VBO is organized.

Note: as long as we wish to render this model we need to keep both the VAO and the two VBOs alive but if we had loaded the model from disk into temporary arrays we could free up this memory as we've copied it into memory managed by OpenGL. Freeing up our VAO and VBOs and thus freeing up the memory allocated by OpenGL is done in this function:
void unload_objects() {
  glDeleteBuffers(2, VBOs);
  glDeleteVertexArrays(1, &VAO);
};

Shaders

The final ingredient here is our shader but in this case it is deceptively simple. I didn't want to touch on the complex stuff just yet which is why I opted for adding a color attribute. The effect is fairly psychedelic as our shader simply interpolates the color value between each vertex as it draws the polygons.

Here however the difference between vertex shader and fragment shader becomes much more apparent. Our vertex shader is executed for each of our 8 vertices and at this point in time it has two goals:
  1. calculate the screen coordinates of each vertex by applying our model-view-projection matrix
  2. determine the color of each vertex
This is the code for our vertex shader:
#version 330

layout (location=0) in vec3 positions;
layout (location=1) in vec3 colors;

uniform mat4 mvp;              // our model-view-projection matrix

out vec3 color;

void main(void) {
  // load up our values
  vec4 V = vec4(positions, 1.0);
  color = colors;
  
  // projection of V
  gl_Position = mvp * V;
}
The first new thing we see are our attribute definitions, these map directly to our attribute definitions we declared when we loaded the data for our cube. Within our shader these variables will point directly to the entry in our array for which our vertex shader is being called.

We then define our color as a vec3 output variable that we can use in our fragment shader.
In our function we calculate V as a 4D vector for our position and simply copy our color.
We then apply our model-view-projection matrix (mvp) to V and store the result in gl_Position, one of the few build in OpenGL variables still supported in OpenGL 3.0.

Next OpenGL will use our indices to render the polygons taking the output of our vertex shader for each of the 3 vertices of our triangle and then interpolating our output variables. As we only output our color the color is nicely mixed over the surface of our cube. Our fragment shader is then called for each pixel we render out to screen.

As the interpolation is done before our fragment shader is called there is very little more to do and our fragment shader simply copies its input to our output:
#version 330

in vec3 color;
out vec4 fragcolor;

void main() {
  fragcolor = vec4(color, 1.0);  
}
This is where OpenGL gets a little silly. In the original shader implementation our fragment shader the output variable was a build in variable just like gl_Position but unlike gl_Position it was removed and instead our fragment shader must have a single output variable.
There is good reason for this however which we may come back to in a later tutorial. When we use techniques such as deferred rendering a fragment shader can have multiple outputs and this change starts making sense.

I've covered loading the shader into OpenGL in previous parts of this tutorial so I won't go over it again, the code can be found in the function load_shaders().

Rendering our cube

This is the bit where I really like VAOs. To render our cube we need to do 3 things:
  1. calculate our model-view-projection matrix
  2. select our shader
  3. render our VAO
For rendering one cube this may not seem very special but imagine rendering dozens of things you can see how little work there is to do in rendering them if you've loaded them into VBOs and setup a VAO for each of the objects to render.

The code for this is:
  // set our model view projection matrix
  mat4Copy(&mvp, &projection);
  mat4Multiply(&mvp, &view);
  mat4Translate(&mvp, vec3Set(&tmpvector, 0.0, 0.0, -30.0));   // move it back so we can see it
  mat4Rotate(&mvp, rotate, vec3Set(&tmpvector, 10.0, 5.0, 15.0)); // rotate our cube
  mat4Scale(&mvp, vec3Set(&tmpvector, 10.0, 10.0, 10.0));   // make our cube 10x10x10 big

  // select our shader
  glUseProgram(program);
  glUniformMatrix4fv(mvpId, 1, false, (const GLfloat *) mvp.m);  

  // now render our cube
  glBindVertexArray(VAO);
  glDrawElements(GL_TRIANGLES, 12 * 3, GL_UNSIGNED_INT, 0); 
For calculating our mvp I'm doing everything in one go instead of first creating a model matrix. The model matrix consists of 3 steps (they are in reverse order in the code):
  • Scale the cube to size
  • Rotate the cube based on an angle that we increase as time goes by
  • Move (translate) the cube into position
I've put the scale step in on purpose, I could have defined the cube at the correct size. We'll get back to that in due time.

Selecting the shader is a simple call to glUseProgram and then loading our mvp into our shader.

Rendering the VAO is now 2 simple steps: binding the VAO (which also binds the VBOs and calling glDrawElements to tell OpenGL to draw our triangles.



Now in this last bit we do see our first little hickup for those of you who are aware of how 3D modeling software often handles models especially when different materials are used with a single object. Often enough we'll have one array of vertices but then multiple arrays of indices which each need to be drawn using a different material (often different shaders, or the same shader with different settings). This mostly is a space saving issue as we can reuse vertices but as we'll later on find out this issue quickly becomes mute in a 3D engine as vertices will need to be duplicated for other reasons.

Now we can use the same VBO over multiple VAOs and thus reuse the same vertex array but personally I think it works much better in OpenGL if each VAO has its own vertex array and index array that renders (part of) an object with a single material.

Download the source code here

What's next?

In the next part we're going to make our cube a little less boring by applying a texture to it.
The part after that we'll discuss basic lighting.
After that we'll probably be ready to start applying the first part of the changes to our platform game.

No comments:

Post a Comment