I'm learning OpenGL and started work on a small 3D game (like Sokoban) for practice. I want to know if the design of my OpenGL code reflects a good understanding of the technology. For example, one problem I ran into was how to handle drawing multiple objects with different shaders, which I fixed thus far by using a separate VAO for each type of object. I'm not sure if that's an optimal/scalable solution, hence why I'm here.
Here's a screenshot of what the final application looks like (also includes controls to spherically orbit the camera around the scene):
Currently, my code is organized into 3 parts: main.cpp
, game.cpp
, and world.cpp
. main.cpp
only contains the main function, which just creates the Game
singleton and calls its core procedures. game.cpp
contains the code for initializing the app (using GLFW), loading shaders, and running the game loop, while world.cpp
does all of the actual work. The total LOC is about 500, but I'll do my best to abbreviate that. Without further ado, the code:
main.cpp
#include "game.hpp"
int main()
{
Game& game = Game::Instance();
game.Initialize();
game.Run();
game.Quit();
return 0;
}
That's it for main.cpp
, not much to say here. Game
is implemented as a Meyer's singleton, which I mention because I would additionally like guidance/opinion about its use here:
game.hpp
struct Game
{
static Game& Instance()
{
static Game game;
return game;
}
Game(const Game&) = delete;
Game& operator =(const Game&) = delete;
GLFWwindow* window;
std::unordered_map<std::string, unsigned int> shaderFileToId {};
World world {};
void Initialize();
void Run();
void Quit();
private:
void InitializeGLFW();
void InitializeShaders();
void CreateShader(std::string filename);
void ReadShaderFromFile(std::string& shader, std::string file);
void Start();
void DeinitializeGLFW();
Game() { };
~Game() { };
};
The singleton implementation starts at the top and also includes the private constructor and destructor at the bottom. I use it because generally when I make games in C++ I make a couple of system-level classes (like Game
and World
) that contain data used throughout the program. I've been advised to perhaps just use a namespace and have thought about static classes, but this solution seems nice to me. Thoughts?
Anyway, the implementation of the header is mostly unimportant, so I'll omit the .cpp file, but will mention the shaderFileToId
member. I use this map to access shader program IDs by the root name of the shader files. The main problem I find with this method is that the code (as you'll see below) gets pretty long-winded combined with the singleton calls. Any suggestions there would also be appreciated. Next is the world
header:
world.hpp // World struct
struct World
{
GLuint VAOfloor;
GLuint VAOcube;
glm::mat4 projection;
glm::mat4 view;
glm::vec3 eyePolar;
Floor floor {};
std::vector<Cube> cubes;
void Initialize();
void Update();
void RenderUpdate();
};
The main part of world.hpp
is the World
struct, which contains the VAO containers for a Floor
object and all of the Cube
objects that will be placed. So I'm using one VAO for each type of object I'm drawing, and that's because they use different shaders. Before I used multiple cubes, I did have just one VAO, but to make it work I needed to make the layout locations for all attributes between the two shaders unique, otherwise one would overwrite the other. But because both needed a layout for the positions of each vertex, I would have rather used the same location for the position attribute for each shader. That's mostly what motivated me to use separate VAOs, but I'm still not sure of what the proper way to handle the multiple shader/VAO situation is.
The rest of the class isn't super relevant, some variables to control the world transform and camera, containers for the objects, and some basic procedures. However, world.hpp
also contains the Floor
and Cube
structs:
world.hpp // Floor & Cube structs
struct Floor
{
float cornerBuffer[30] = {
// x y z s t
-10.0f, 0.0f, -10.0f, 0.0f, 0.0f,
10.0f, 0.0f, 10.0f, 10.0f, 10.0f,
10.0f, 0.0f, -10.0f, 10.0f, 0.0f, // triangle 1
-10.0f, 0.0f, -10.0f, 0.0f, 0.0f,
-10.0f, 0.0f, 10.0f, 0.0f, 10.0f,
10.0f, 0.0f, 10.0f, 10.0f, 10.0f, // triangle 2
};
GLuint VAO;
GLuint buffer;
GLuint texture;
glm::mat4 model;
GLint transformLocation;
GLint textureLocation;
void Initialize(GLuint VAOfloors);
void Render(glm::mat4 renderTransform);
};
struct Cube
{
float cornerPositions[24] = {
// x y z
-1.0f, -1.0f, -1.0f,
-1.0f, -1.0f, 1.0f,
1.0f, -1.0f, 1.0f,
1.0f, -1.0f, -1.0f, // bottom 4 corners
-1.0f, 1.0f, -1.0f,
-1.0f, 1.0f, 1.0f,
1.0f, 1.0f, 1.0f,
1.0f, 1.0f, -1.0f // top 4 corners
};
GLuint indexBuffer[36] = {
0, 1, 2, 0, 2, 3, // bottom
4, 5, 6, 4, 6, 7, // top
0, 1, 5, 0, 5, 4, // left
3, 2, 6, 3, 6, 7, // right
1, 5, 6, 1, 6, 2 // front
0, 4, 7, 0, 7, 3, // back
};
GLuint VAO;
GLuint buffers[2];
glm::mat4 model;
GLint transformLocation;
void Initialize(GLuint VAOcubes);
void Place(glm::vec3 position);
void Render(glm::mat4 renderTransform);
};
The important parts here are the GL data they contain: they each hold a reference to the VAO that are used to create their renders, as well as the required Buffer Objects and buffer data. One thing to note is that the VAOs aren't created within the struct, but is created by World
and then passed to their Initialize
methods as arguments. It will become more clear how they are used, but I would also like to know if storing the GL objects within the structures is a good way of storing the data.
Okay, lastly, and the most important part, the implementation of how the objects are created and rendered:
world.cpp // Initialize methods
void World::Initialize()
{
glGenVertexArrays(1, &VAOfloor);
floor.Initialize(VAOfloor);
glGenVertexArrays(1, &VAOcube);
for (int i = -2; i < 3; i++)
{
Cube cube;
cube.Initialize(VAOcube);
cubes.emplace_back(cube);
}
// ...
}
void Floor::Initialize(GLuint VAOfloors)
{
VAO = VAOfloors;
glBindVertexArray(VAO);
int width, height, channelCount;
unsigned char* data = stbi_load("Assets/Tile.png", &width, &height, &channelCount, 0);
if (data)
{
glGenTextures(1, &texture);
// ...
}
stbi_image_free(data);
glBindVertexArray(0);
transformLocation = glGetUniformLocation(Game::Instance().shaderFileToId.at("floor"), "transform");
textureLocation = glGetUniformLocation(Game::Instance().shaderFileToId.at("floor"), "textureSampler");
}
void Cube::Initialize(GLuint VAOcubes)
{
VAO = VAOcubes;
glBindVertexArray(VAO);
glGenBuffers(2, buffers);
// ...
glBindVertexArray(0);
transformLocation = glGetUniformLocation(Game::Instance().shaderFileToId.at("cube"), "transform");
}
The Initialize
methods do basically the same thing, although Floor
has the added texture details, where they bind the VAO passed from World
, fill in the VAO using the buffer data (omitted), and then I get the uniform locations of the shaders for later purposes. I honestly don't know if/how there could be a better way of doing this procedure, although you can see here the verbose nature of retrieving the shader program (might I just accept the wordiness?).
I'd also show the rendering code, but it's super straightforward (bind VAO and program, draw transformed object, unbind) and I think there's enough code here.
Conclusion
That's the gist of the whole program. To summarize, here are some of the concerns I have with the code:
- Am I correctly/sufficiently making use of multiple VAOs/shaders and is my reason for using them (multiple layouts with the same index) valid?
- Is it a good idea to contain the VAOs in a larger struct (
World
) and pass the particular ones to the objects that need them? - Is my implementation of the Meyer's Singleton (useful/unnecessary), and could there be a better way of accessing my shader program?
- More generally, is the organization and design of the program as a whole reasonable?