July 2023
Developed a 3D Render Engine utilizing C++, OpenGL, SDL2, Assimp and many other libraries. This project
implements the Phong Lighting Model with Directional
Light,
Omnidirectional Light, Point Light, SpotLight and Ambient Light.
I was curious how graphics works,
so first I learned Computer Graphics on YouTube from
Cem Yuksel
a top Graphics Professor from the University of Utah. Then I started working on this 3D Render Engine.
SDL2 Window and Input
Translation, Rotation, Scale
Camera Movement
Perspective Projection
Phong Lighting Model
Directional and Omnidirectional Lights
Pointlight and Spotlight
Importing Custom 3D Models
I programmed a 3D rendering engine using OpenGL and SDL2 to gain a deeper understanding of how graphics work from a low-level perspective.
This project provided me with a comprehensive understanding of how the GPU renders a model on the screen through the process of rasterization.
I loved learning about vertex, fragment, and geometry shaders, the implementation of perspective projection, and other key concepts that are essential to the
functionality of a game engine.
I have implemented the Phong lighting model, which is a simplified approximation of the more general rendering equation. The Phong lighting model
consists of three main components: ambient, diffuse, and specular lighting. I have implemented Perspective Projection which
gives the 3D perspective look in video games, and we can move around the world. Based on the camera's location, every model in the scene will
move and the models on the screen will be rasterized to display the 3D world in the window.
These are the libraries I have used, but not limited to these
I was deciding between using glfw and SDL2, and decided to stick with SDL2 since it has functions for various things like input, audio, even a simple 2D rendering features along with Window. Setting up the Window with SDL was pretty easy, it worked really great. Inspecting the header files was enough to understand what each function does, everything was document clearly. So I set up a basic window with SDL2. I still had some issues with fullscreen at desktop native resolution, but I never got around it since I didn't want to spend much time on the window alone. There were so many flags that you could pass to the CreateWindow, it had fun testing out different flags. Here is a small snippet of it.
int MainWindow::Initialize()
{
// NOTE : SOME CODE HAS BEEN OMITTED
Window = SDL_CreateWindow("Editor", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, WIDTH, HEIGHT, SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE);
SDL_GL_GetDrawableSize(Window, &BufferWidth, &BufferHeight);
GLContext = SDL_GL_CreateContext(Window);
}
For a clean input handling in the engine, I followed these two
We can bind any function to different inputs of input type like PRESSED and RELEASED. The InputHandlingComponent will poll the keyboard and mouse states with SDL, then updates the necessary axis values, and also does the callback to the functions that are bound to different inputs.
int MainWindow::Initialize() // Input callback functions
void W_Pressed() { }
void W_Released() { }
// Binding Inputs
InputHandling.BindInputAction(INP_KEYBOARD_W, INP_PRESSED, W_Pressed);
InputHandling.BindInputAction(INP_KEYBOARD_W, INP_RELEASED, W_Released);
Meshes and Lights can be created, and added to their respective scene array. I should have used some kind of class to represent the scene, but I stuck with just having different shared pointer arrays for holding different entities in the scene, to reduce complexity.
Meshes can be created either by directly specifying out the vertices and UV values of the texture, or by importing with Assimp library. The logic for load model was pretty hard, but I managed to do it. When we import a scene using Assimp, we need to recursively load all the nodes(parent-child attached meshes), and get the vertex and material data from each of the mesh and then create the mesh. For direct creation using vertex data, we can directly call CreateMesh passing the vertices and the UV values in the correct 1 dimensional array format.
// Create Mesh function that constructs the mesh
void Mesh::CreateMesh(GLfloat* Vertices, unsigned int* Indices, unsigned int VertexNum, unsigned int IndicesNum)
{
MeshIndexCount = IndicesNum;
// Generating a VAO and then binding it
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);
glGenBuffers(1, &IBO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, IBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(Indices[0]) * IndicesNum, Indices, GL_STATIC_DRAW);
// PRETTY BIG FUNCTION, OMITTED THE REST
//...
}
// Load Model Logic - simplified and omitted
void PrimitiveMesh::LoadModel(const std::string& FileName)
{
Assimp::Importer Importer;
const aiScene* Scene = Importer.ReadFile(FileName, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_GenSmoothNormals | aiProcess_JoinIdenticalVertices);
// Call the Recursive function Load Node which loads all the nodes (Parent Child Tree)
// All the nodes and their respective mesh will be loaded
LoadNode(Scene->mRootNode, Scene);
// Load the Textures
LoadMaterials(Scene);
}
There are different two types of light, each requiring their own shader for calculating shadows
Each light is a class, and have some properties. These properties are used by the shaders to calculate the lighting, rendering the ShadowMap texture etc. The properties include location, direction(for directional light), diffuse intensity, ambient intensity, diffuse color, ambient color, exponent, linear and constant values, edge angle for the Phong Lighting model to control the specular, light spread etc. Below is the constructor of different light functions initializing its properties which can be updated during runtime.
// Direction Light
DirectionalLight(GLsizei ShadowMapBufferWidth, GLsizei ShadowMapBufferHeight, GLfloat AmbientLightIntensity,
glm::vec3 AmbientColor, GLfloat DiffuseLightIntensity, glm::vec3 DiffuseColor, glm::vec3 LightDirection);
// Point Light
PointLight(GLsizei ShadowBufferWidth, GLsizei ShadowBufferHeight, glm::vec3 DiffuseColor, glm::vec3 AmbientColor, GLfloat AmbientLightIntensity,
GLfloat DiffuseLightIntensity, glm::vec3 LightLocation, GLfloat ExponentValue, GLfloat LinearValue, GLfloat ConstantValue);
// Spot Light
SpotLight(GLfloat ShadowBufferWidth, GLfloat ShadowBufferHeight, glm::vec3 DiffuseColor, glm::vec3 AmbientColor, GLfloat AmbientLightIntensity,
GLfloat DiffuseLightIntensity, glm::vec3 LightLocation, glm::vec3 LightDirection, GLfloat ExponentValue, GLfloat LinearValue,
GLfloat ConstantValue, GLfloat EdgeAngle);
There are totally 4 sets of shaders in the project, each for different purpose
The vertex shader transforms vertex positions to clip space, computes positions in light space for shadows, and passes UV coordinates and
normals to the fragment shader. It adjusts normals using the inverse transpose of the model matrix and calculates world-space vertex positions for lighting.
The fragment shader performs the lighting pass the scene, taking into account multiple light types, including directional,
point, and spotlights, along with detailed shadow mapping. It calculates the color of each fragment by blending ambient, diffuse, and specular lighting,
taking into account the effects of texture mapping and shadows. The shader leverages both directional and omnidirectional shadow mapping, ensuring that
shadows from all light types are accurately represented, with additional support for soft shadows using Percentage Closer Filtering (PCF)
for smoother, more realistic shadow edges. It also handles light attenuation for point and spotlights, reducing the light's intensity over distance to mimic realistic
falloff. This shader is highly optimized for complex lighting scenarios, allowing for multiple dynamic lights to cast shadows and interact with surfaces in
a physically plausible way, making it ideal for sophisticated real-time rendering in 3D applications.
// Vertex shader
void main()
{
gl_Position = Projection * View * Model * vec4(pos, 1.f);
DirectionalLightSpacePosition = DirectionalLightTransform * Model * vec4(pos, 1.f);
TextCoord = TextUV;
VertexNormal = mat3(transpose(inverse(Model))) * InterpedVertexNormal;
FragmentPosition = (Model * vec4(pos, 1.f)).xyz;
}
// Fragment Shader
void main()
{
vec4 FinalColor = CalculateDirectionalLight() + CalculatePointLights() + CalculateSpotLights();
color = texture(Texture2D, TextCoord) * FinalColor;
}
The game loop is abstracted well and it does the following
The render passes does most of the stuff, rendering every mesh and models with the lights which can be dynamic every loop, capturing and updating the ShadowMaps, drawing the skybox etc.
while (bGameLoop)
{
//Update the Delta Time
UpdateDeltaTime();
// Input
InputHandling.UpdateKeyboardStates(SDL_GetKeyboardState(NULL));
InputHandling.InputHandling(&bGameLoop);
MainCamera.CameraInput(InputHandling.GetKeyboardAxisValues(), InputHandling.GetMouseRelativeChange(), InputHandling.GetMouseStates(), DeltaTime);
/* [Render Passes] */
DirectionalShadowMapRenderPass(MainDirectionalLight);
for (size_t i = 0; i < PointLightCount; i++)
{
OmniDirectionalShadowMapRenderPass(PointLights[i]);
}
for (size_t i = 0; i < SpotLightCount; i++)
{
OmniDirectionalShadowMapRenderPass(SpotLights[i]);
}
RenderPass(MainDirectionalLight);
glUseProgram(0);
Window.SwapBuffers();
}