Hi there stranger of the realm called internet! Finally I got myself to write article to this tutorial after almost two weeks since it has been created! Well, you know, beginnings of new years are usually a bit harsh and there's lot of stuff to do, so that's why. Anyway, let's not waste with prologue and let's get to work ! This tutorial is all about refactoring what we've done until now.
If you don't know, refactoring is the process of looking back at the code, that already works and writing it in a different manner, while perserving the functionality. In my case this means, that after 9 tutorials, I have decided that to make things better already now, before it would get out of control in later tutorials (for more information about refactoring in general, visit Wikipedia Refactoring article.
What was the problem in our case? Well, the easiest thing is to look at the code from tutorial 009:
Shader groundVertexShader, groundFragmentShader;
Shader vertexShader, fragmentShader;
Shader ortho2DVertexShader, ortho2DFragmentShader;
ShaderProgram groundProgram;
ShaderProgram mainProgram;
ShaderProgram ortho2DProgram;
VertexBufferObject shapesVBO;
VertexBufferObject texCoordsVBO;
GLuint mainVAO;
VertexBufferObject hudVerticesVBO;
VertexBufferObject hudTexCoordsVBO;
GLuint hudVAO;
Texture snowTexture;
Texture pathTexture;
Texture pavementTexture;
Texture houseTexture;
Texture houseTextureFront;
Texture houseTextureSide;
Texture roofTexture;
Texture christmasTree;
Texture snowflake;
Sampler mainSampler;
Sampler hudSampler;
And then somewhere later in the initializeScene() function:
void OpenGLWindow::initializeScene()
{
// ...
glGenVertexArrays(1, &mainVAO); // Creates one Vertex Array Object
glBindVertexArray(mainVAO);
// Setup vertex positions first
shapesVBO.createVBO();
shapesVBO.bindVBO();
shapesVBO.addData(static_geometry::plainGroundVertices, sizeof(static_geometry::plainGroundVertices));
shapesVBO.addData(static_geometry::cubeVertices, sizeof(static_geometry::cubeVertices));
shapesVBO.addData(static_geometry::pyramidVertices, sizeof(static_geometry::pyramidVertices));
shapesVBO.uploadDataToGPU(GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(glm::vec3), (void*)0);
// Setup texture coordinates next
texCoordsVBO.createVBO();
texCoordsVBO.bindVBO();
texCoordsVBO.addData(static_geometry::plainGroundTexCoords, sizeof(static_geometry::plainGroundTexCoords));
texCoordsVBO.addData(static_geometry::cubeTexCoords, sizeof(static_geometry::cubeTexCoords), 6);
texCoordsVBO.addData(static_geometry::pyramidTexCoords, sizeof(static_geometry::pyramidTexCoords), 4);
// ...
}
What is exactly wrong with this, you might ask? The thing is, that we are creating now tens of variables out in the wilderness of the code, in the namespace of global variables. Morever, initalizing objects to render is done using one VAO and one VBO and then later, we have to write indices of the vertices correctly to see pyramid or cube. This was acceptable until now, because our scenes have not been so complex. But the more advanced tutorials I want to write, the more complex scenes are going to be rendered and we have to find some systematic way of doing this. And probably the most problematic thing was proper releasing of those objects. In the releaseScene() function, we just had not to forget about any variable, which is just wrong.
So let's discuss now what I've done, to make the code better and more readable .
The first major thing I've done was to create managers for OpenGL objects. These classes are responsible for creating and caching OpenGL objects (for later use) and they can release them all at once, not forgetting anything. I have created 4 OpenGL objects managers in total - shader manager, shader program manager, texture manager and sampler manager. There is one more manager - matrix manager, but this one just serves as a holder for matrices. All those classes are singletons - that means, there is only one instance of them available and you can't create your own (no two texture managers can even exist for example). Let's take texture manager as an example (all other classes are pratically same, they just handle different object types):
class TextureManager
{
public:
TextureManager(const TextureManager&) = delete;
void operator=(const TextureManager&) = delete;
static TextureManager& getInstance();
void loadTexture2D(const std::string& key, const std::string& fileName, bool generateMipmaps = true);
const Texture& getTexture(const std::string& key) const;
void clearTextureCache();
private:
TextureManager() {}
bool containsTexture(const std::string& key) const;
std::map> _textureCache;
};
Let's discuss some C++ stuff here first. Can you see the first two functions, ending with delete keyword? With this, we are just telling compiler not to generate those two methods, that it does by default - copy constructor and copy assignment operator. By defining this, we have disabled a way to copy this object - that is to prevent anyone to make another copy of the texture manager. Moreover, for complete singleton implementation, the default constructor is made private - that means, nobody out of the class can really create the texture manager .
In order to get to that one and only texture manager, we have to call static function getInstance(). It's implementation looks like this:
TextureManager& TextureManager::getInstance()
{
static TextureManager tm;
return tm;
}
Pretty simple huh? This approach is called lazy initialization - that means, that first time someone calls this method, that one and only instance called tm initializes and gets returned. From second time calling this function on, the object is just returned. The advantage is, that if we don't ever get to do anything with textures in our code, it won't be even initialized at all! This is a small, but nice optimization.
In every manager, we have a way to get to the object it manages and to add another one. In our case, we have a loadTexture2D function to add a texture to the manager. Managers have their cache of objects, implemented using std::map. Whenever you're loading texture, you have to provide string key to store it with. Let's have a look at the function:
void TextureManager::loadTexture2D(const std::string& key, const std::string& fileName, bool generateMipmaps)
{
if (containsTexture(key)) {
return;
}
auto texturePtr = std::make_unique();
if (!texturePtr->loadTexture2D(fileName, generateMipmaps))
{
auto msg = "Could not load texture with key '" + key + "' from file '" + fileName + " '!";
throw std::runtime_error(msg.c_str());
}
_textureCache[key] = std::move(texturePtr);
}
The code starts with checking, if we aren't accidentally adding another texture with the same key. If not, then we try to load the texture and use the unique_ptr to hold it. In case we fail to load the texture, we throw an exception, otherwise, we just move the ownership of texture to our cache, so that only one holding our texture is the cache within the texture manager (that's why there is this std::move). If you don't understand the concepts behind unique pointers and C++ move semantics, I recommend you to read some articles about it, for example this one. So the basic idea is to store the texture using a string key, for example, to store the snow texture, we can simply use "snow" as a key to represent it .
As mentioned before, the other 3 managers - shader, shader program and sampler managers they manage shaders, shader programs and samplers, respectively. The principle behind it is really the same, so I won't explain it in detail again - anyway I recommend you to see the code for yourself. The last remaining manager I have introduced is MatrixManager, but this is really just a holder for matrices, nothing else.
Another stumbling block in our code is initialization of objects like cube or pyramid out in the wilderness with no information about them stored whatsoever! This has to change, especially for the tutorials I plan in the future. I have decided to create two classes, that will hold basically arbitrary static objects - StaticMesh3D and StaticMesh2D. One is used for rendering 3D and another one for rendering 2D objects. They are more or less the same, so let's go through the StaticMesh3D class:
class StaticMesh3D
{
public:
static const int POSITION_ATTRIBUTE_INDEX;
static const int TEXTURE_COORDINATE_ATTRIBUTE_INDEX;
static const int NORMAL_ATTRIBUTE_INDEX;
StaticMesh3D(bool withPositions, bool withTextureCoordinates, bool withNormals);
virtual ~StaticMesh3D();
virtual void render() const = 0;
void deleteMesh();
bool hasPositions() const;
bool hasTextureCoordinates() const;
bool hasNormals() const;
int getVertexByteSize() const;
protected:
bool _hasPositions = false;
bool _hasTextureCoordinates = false;
bool _hasNormals = false;
bool _isInitialized = false;
GLuint _vao = 0;
VertexBufferObject _vbo;
virtual void initializeData() = 0;
void setVertexAttributesPointers(int numVertices);
};
This class holds everything about the static mesh, information like: does it use vertex positions (well, this one will probably always be true )? Does it use texture coordinates? Does it use normals? You could notice, that the render method is a pure virtual function - this is because every static mesh (cube, pyramid) has its own way to use its data and render itself. That means, that this class is just an abstract class to hold static meshes. Let's derive class Cube from it and examine its functions:
class Cube : public StaticMesh3D
{
public:
Cube(bool withPositions = true,
bool withTextureCoordinates = true,
bool withNormals = true);
void render() const override;
void renderFaces(int facesBitmask) const;
static glm::vec3 vertices[36];
static glm::vec2 textureCoordinates[6];
static glm::vec3 normals[6];
private:
void initializeData() override;
};
Basically, you can see that in the Cube class we have predefined array of vertices, texture coordinates and normals. By default, if you create cube, it is created with vertex positions, texture coordinates and normals. In constructor, we initialize the cube data by calling the initializeData() function:
void Cube::initializeData()
{
if (_isInitialized) {
return;
}
glGenVertexArrays(1, &_vao);
glBindVertexArray(_vao);
const int numVertices = 36;
int vertexByteSize = getVertexByteSize();
_vbo.createVBO(vertexByteSize * numVertices);
_vbo.bindVBO();
if (hasPositions()) {
_vbo.addData(vertices, sizeof(glm::vec3)*numVertices);
}
if (hasTextureCoordinates()) {
for (auto i = 0; i < 6; i++) {
_vbo.addData(textureCoordinates, sizeof(glm::vec2)*6);
}
}
if (hasNormals()) {
for (auto i = 0; i < 6; i++) {
_vbo.addData(normals, sizeof(glm::vec3)*6);
}
}
_vbo.uploadDataToGPU(GL_STATIC_DRAW);
setVertexAttributesPointers(numVertices);
_isInitialized = true;
This virtual method is always responsible for creating VAO and VBO and filling it with correct data in every static mesh, not only Cube. The important thing to notice here is, that this class has one inherited VAO and VBO, so all the vertex data are packed within that VBO and vertex attributes set are in that VAO. First, there are all vertex positions, then texture coordinates (if needed) follow and finally there are normals (if needed). After storing data in GPU, setVertexAttributesPointers() function is called to set vertex attributes correctly (this is similar for all static meshes, that is why this function is defined in the base class StaticMesh3D). Rendering cube is then just a matter of calling glDrawArrays(GL_TRIANGLES, 0, 36) .
In similar manner, other static meshes have their own classes. For now, it's pyramid, snow covered ground and house plus there is a 2D static mesh in class Quad (this will be used for HUD refactoring later). I won't go into details of every static mesh, but practically it's all very similar and every object is now responsible for its data and rendering, rather than setting stuff up in the wilderness of global variables . Feel free to study all of those classes and functions, but I hope you got the main idea and reasonings behind those changes .
Another major refactor is creating a HUD class. From now on, everything that will be rendered over the scene will be defined in the separate HUD class, which looks like this:
class HUD
{
public:
static const std::string ORTHO_2D_PROGRAM_KEY;
static const std::string HUD_SAMPLER_KEY;
HUD(const OpenGLWindow& window);
virtual void renderHUD() const = 0;
protected:
const OpenGLWindow& _window;
int getWidth() const;
int getHeight() const;
void renderTexturedQuad2D(int x, int y,
int renderedWidth, int renderedHeight,
bool fromRight = false, bool fromTop = false) const;
ShaderProgram& getOrtho2DShaderProgram() const;
const Sampler& getHUDSampler() const;
static_meshes_2D::Quad _texturedQuad;
};
As with static meshes, render method is pure virtual, because every tutorial will have different HUD elements, so we have to inherit from this class. Nevertheless, this base HUD class should implement the common methods for all HUDs (in later tutorials, I will extend this class with fonts support). For now, we have just one function called renderTexturedQuad2D(), which renders a quad at certain position with certain size. But wait, there's more! This function supports rendering from the left or the right of the screen as well as from the bottom or top of the screen! This function simply renders 2D Quad static mesh and you have to bind the texture beforehand.
To render HUD used in the 9th tutorial about orthographic 2D projection (as well as in this, 10th tutorial), we define a dedicated class HUD010 for it:
void HUD010::renderHUD() const
{
auto& shaderProgram = getOrtho2DShaderProgram();
auto& sampler = getHUDSampler();
shaderProgram.useProgram();
sampler.bind();
glDisable(GL_DEPTH_TEST);
glDepthMask(0);
if (_blendingEnabled)
{
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
}
shaderProgram[ShaderConstants::projectionMatrix()] = MatrixManager::getInstance().getOrthoProjectionMatrix();
shaderProgram[ShaderConstants::color()] = glm::vec4(1.0f, 1.0f, 1.0f, 1.0f);
// Render Christmas tree bottom left
const auto& christmasTreeTexture = getChristmasTreeTexture();
christmasTreeTexture.bind();
renderTexturedQuad2D(0, 0, christmasTreeTexture.getWidth(), christmasTreeTexture.getHeight());
// Render snowflake bottom right
const auto& snowflakeTexture = getSnowflakeTexture();
snowflakeTexture.bind();
renderTexturedQuad2D(0, 0, snowflakeTexture.getWidth(), snowflakeTexture.getHeight(), true);
if (_blendingEnabled) {
glDisable(GL_BLEND);
}
glDepthMask(1);
glEnable(GL_DEPTH_TEST);
}
The code is very similar to the code from 9th tutorial, except it's a bit nicer and more systematic. Instead of computing the coordiniates of our Christmas tree and snowflake, we can simply call the renderTexturedQuad2D method twice. Once we render from the left side of the screen, once from the right side of the screen and that's it!
There is one last important class and it's ShaderConstants. You might have seen, that many times, I am reusing the strings like "matrices.mainMatrix" or "matrices.projectionMatrix" This class simply holds all those commonly used uniform variable names, that we keep reusing among multiple shaders. It is basically just a storage of static methods, that return strings (I have made this class header only, that means - it's completely defined in header file and no cpp file is needed).
#define DEFINE_SHADER_CONSTANT(constantName, constantValue) \
static const std::string constantName() \
{ \
static std::string value = constantValue; \
return value; \
}
class ShaderConstants
{
public:
DEFINE_SHADER_CONSTANT(modelMatrix, "matrices.modelMatrix");
DEFINE_SHADER_CONSTANT(projectionMatrix, "matrices.projectionMatrix");
DEFINE_SHADER_CONSTANT(viewMatrix, "matrices.viewMatrix");
DEFINE_SHADER_CONSTANT(color, "color");
DEFINE_SHADER_CONSTANT(sampler, "sampler");
};
Then anywhere in the code, where we need the name of uniform variable for projection matrix or model matrix (or some other common shader names), we will refer to constants from this class from now on
There have been many refactorings and changes in this tutorial, so let's have a short reprise, what has been done:
Even though the code has been changed significantly, the visual result is same as in the previous tutorial:
But that does not mean we've done nothing! From now on, the readability of tutorials will improve. And especially, if we are to implement more advanced stuff, we have to come up with a systematic way to keep everything nice, maintanable and readable. So I hope that despite the fact we didn't see anything new this time, you've learned a lot anyway and enjoyed this tutorial . Next time, we will learn what is Indexed Rendering and we will learn, how to render cylinder with it.
Download 3.77 MB (1152 downloads)