Tuesday, November 13, 2012

A Guide to Instanced Geometry

As stated in the title, this is more of a guide than a tutorial. It isn't for the complete beginner, some experience is necessary. Throughout this I assume you have a general knowledge of what a mesh is, basic understanding of how matrices work, basic knowledge of GLSL, and experience with C++ or another object-oriented language. I am just writing about my own personal implementation of instanced geometry, which is definitely open for comments and suggestions. The code snippets are stripped down versions directly from my basic 2D OpenGL rendering engine I have dubbed IronClad.

What Is Instanced Geometry?

Primitive rendering techniques have many copies of a single object's data. Say you wanted to draw a tiled map, with 2 unique tiles. Say, for instance (no pun intended), a floor tile and a wall tile. Now, each of these objects contains, at the very least, 8 floats for vertex positions, and 8 floats for texture coordinates. That's 8 * 4 + 8 * 4 = 64 bytes. If each instance of a tile contains this information for rendering, and you have 1000 wall tiles, that's 64 kilobytes of memory! And that's not even considering the other tile types. Obviously, this is an example of a very simple mesh with only 4 vertices. Most games have models with hundreds if not thousands of vertices, so you can see why it'd be a serious problem to have multiple copies of that data.

Of course, there's a simple solution to this problem; you keep around one copy of the data in the first object you create, and the other objects simply refer to the original, just in their own position. Well, that's exactly where instancing comes in!



A Visual Example

A Visual Explanation of the Benefits of Instanced Geometry

How Is It Done?

Meshes are loaded. Mesh instances contain pointers to meshes as well as position data. The meshes are offloaded to GPU memory and deleted. The scene renderer loads a model-view matrix with the position data for each instance of a mesh, passing it on to a vertex shader. Finally, the mesh is rendered.


Loading Meshes Efficiently

A mesh is an object containing vertex data that can represent something in the game world, like the tile in the above example. This is loaded from a file a single time. Most likely, you'll need some sort of management system that ensures no more than one copy of any given mesh is loaded at one time. IronClad uses an asset manager for image files, fonts, sounds, and meshes. If an asset is already loaded, it returns a pointer to the asset, and if it is not, it loads it and stores it in a list of assets. It's relatively simple, but maybe I'll make another blog post showcasing it.


Mesh Instances

So, once you have meshes only being loaded once, the battle is halfway over. Now, there should be a class that is an instance of a mesh. A mesh instance contains a pointer to the mesh asset and some position data. When drawing, the same mesh data is used for every instance of a mesh, but the position data changes where it appears. Here's an example of a mesh instance class stripped from my engine:


Organizing the Scene

Most engines have some sort of "scene" object that contains everything that need to be rendered on the screen. This high-level interface should be all that the user interacts with, so they can do something like

Scene.AddMesh("Test.mesh", 100, 100);
Scene.AddMesh("Test.mesh", 200, 200);

Which would load the mesh once, and add two instances in two different positions. That instanced geometry in a nutshell, once more.


Here's a very basic example of what it could look like:

bool Scene::AddMesh(const char* filename, int x, int y)
{
  // Create an instance and load the mesh into it.
  MeshInstance* inst = new MeshInstance;
  Mesh* loaded_mesh = MeshManager::LoadMesh(filename);

  // Quick error check, assuming LoadMesh returns NULL
  // on failure to load the mesh.
  if(!loaded_mesh) return false;

  // Set up the instance.
  inst->SetMesh(loaded_mesh);
  inst->Move(x, y);

  // Add instance to scene
  this->allMeshes.push_back(inst);
  
  return true;
}

If you have something like this implemented, it's time to move on to loading matrices and rendering!


Matrices

There are matrix libraries out there specifically made for OpenGL, such as the GLM library, but for IronClad, I rolled my own matrix class. It has the bare minimum of what I need, and it helped me learn a lot. Since this is purely in 2D and there are no transformations involved, the model-view matrix only needs to have the position data; that's perfect because that's exactly what the mesh instances already have! So, we add this method to the mesh instance:

void CMeshInstance::LoadPosition(math::matrix4x4_t& MVMatrix) {
  MVMatrix[0][3] = this->Position.x;
  MVMatrix[1][3] = this->Position.y;
}

Keep in mind that my matrix class is row-major, and OpenGL shaders do matrix math as column-major, so it needs to be transposed before being sent to the shader, which we will see later. Now, we have this method set up to call in the scene and set up the shader for rendering!


Vertex Shader

Since OpenGL did away with the built-in matrix stack long ago, we need a simple vertex shader that outputs the proper position data. Here we go:

#version 330

// Input: vertex position, texture coordinates, color.
        in  vec2 vs_vertex;
smooth  in  vec2 vs_texc;
smooth  in  vec4 vs_color;

// Matrices
uniform mat4 proj;
uniform mat4 mv;

// Output to the fragment shader: texture coordinates, color.
smooth  out vec4 fs_color;
smooth  out vec2 fs_texc;

void main()
{
    // Do the transformation.
    gl_Position = proj * mv * vec4(vs_vertex, 1.0, 1.0);

    // Pass to fragment shader.
    fs_color    = vs_color;
    fs_texc     = vs_texc;
}

Putting It All Together

Now comes the grande finale: rendering. We have our mesh, we have our mesh instances, we have our shaders, our matrices, and our scene. The time has come.
We first enable the VBO containing the mesh vertex data, then iterate through the scene's mesh instances, passing the matrix loaded with position data to the shader, and finally draw it to the screen.

Here is a simplified render loop I use in IronClad:


void CScene::Render()
{
    static math::matrix4x4_t MVMatrix = math::matrix4x4_t::CreateIdentityMatrix();
    
    // When drawing, retrieve data from the geometry VBO we loaded with 
    // mesh data on calls to CScene::AddMesh()
    this->GeometryVBO.Bind();

    // Iterate through each mesh, load the matrix, and render.

    // There are more advanced concepts such as multi-material meshes with
    // surfaces that can be added here, but that's for another time.

    for(size_t i = 0; i < this->allMeshes.size(); ++i)
    {
        // Load the model-view matrix.
        this->allMeshes[i]->LoadPosition(MVMatrix);
        
        // Bind mesh texture.
        allMeshes[i]->BindTexture();

        // Use the default shader we created above (obviously loaded).
        this->DefaultShader.Bind();

        // Pass the model-view matrix to the vertex shader.
        // We specify GL_TRUE as the last 3rd param to transpose the
        // matrix since it is in row-major here and GLSL expects column-major.
        glUniformMatrix4fv(
            glGetUniformLocation("mv"), 1, GL_TRUE,
            MVMatrix.GetMatrixPointer());

        // This would normally be a glDrawElements() call, 
        // based on whether or not IBOs are implemented with the VBOs.
        this->allMeshes[i].Render();

        this->DefaultShader.Unbind();
        this->allMeshes[i].UnbindTexture();
    }
}

And there you have it! Instanced geometry.
Using this technique, I've achieved drawing several thousand triangles on the screen at once with lighting and post-processing enabled, so you can see that this is a very powerful technique.

Happy instancing; comment below with any questions or remarks :)

No comments:

Post a Comment