Simple GPU Path Tracing, Part. 1 : Project Setup

 

In this part, we'll be setting up the project. 

The final goal is to have a window that displays the result image of a simple compute shader, with both OpenGL and Cuda backends.

Expected result for this post
 

Here's the commit containing all the code in this post.

CMake configuration

I'm using a single and simple CMake file that will links all the libraries, sets all the include directories, and more importantly sets all the source files for compiling.
 
Remember we're going to have a cuda backend so it's important to call find_package(CUDA REQUIRED) and enable_language(cuda).
Also, instead of add_executable, we use add_cuda_executable. this will compile with nvcc instead of msvc, and therefore compile all the .cu files that contain cuda kernels correctly.

Here's a link to the final CMakeFile of this part.

Le'ts also create a little .bat file that will build the project in debug mode :
BuildDebug.bat : 
msbuild build/INSTALL.vcxproj /p:Configuration=Debug /m /p:CL_MPCount=5
 

Now, if we run BuildDebug.bat, it should build the project and install the needed files into the install folder that we set in cmake.

Application Wrapper + Window Creation

Cool, let's get coding now. First thing to do is getting a window up and running.

I'm using an "application" class that will manage the application lifetime.
This allows us to have a simple main() function, and the application class will hold all the required  variables and functions for running our app as class members : 
    application *App = application::Get();
    App->Init();
    App->Run();
    App->Cleanup();     
As you can see, it's a singleton class. We get the instance, and then start the application.
 
Let's quickly see what's inside each function : 
void application::Init()
{
    Window = std::make_shared<window>(800, 600);
}
 This just creates a window

void application::Run()
{
    while(!Window->ShouldClose())
    {
        Window->PollEvents();
    }
}
 
Run() just contains the main window loop, and check for events. 
I'm using a wrapper class for the window. It creates a window with GLFW library, and also initializes openGL with glew.

Dear ImGui

Now we will start using ImGui. The final goal for this chapter is to get an image on the screen, and ImGui will be handy for this task. We could otherwise draw the image on the screen using a GL quad mesh and texture it, but why bother when it can be a one liner with ImGui.

Working with the dearimgui library is a really nice experience. Everything is carefully designed and explained in the example files. So, to initialize ImGui, I just checked the example included in their source code for opengl3 and glfw, and pasted some code in my project.

Here's the init code :
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO(); (void)io;
ImGui::StyleColorsDark();
ImGui_ImplGlfw_InitForOpenGL(Window->Handle, true);
ImGui_ImplOpenGL3_Init("#version 460");    

Then at the start of each frame, we call StartFrame() : 
void application::StartFrame()
{
    glViewport(0, 0, Window->Width, Window->Height);
    glClearColor(0, 0, 0, 0);
    glClear(GL_COLOR_BUFFER_BIT);
   
    ImGui_ImplOpenGL3_NewFrame();
    ImGui_ImplGlfw_NewFrame();
    ImGui::NewFrame();
}
This will clear the openGL back buffers, and call some ImGui functions to tell it that we're starting a new frame

At the end of each frame, we call EndFrame():
void application::EndFrame()
{
    ImGui::Render();
    ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
    Window->Present();
}
Nothing spectacular here too. Window->Present() will call glfwSwapBuffers to swap the openGL back buffers.
 

Writing the shader for OpenGL

Now, we're ready to write some openGL shader code. Remember, we just want a simple shader that writes into an image, and we want to display that image on the screen.

To achieve that, I'll add two helper classes to the project : 
- shaderGL, that wraps an openGL shader object. Nothing fancy here, just basic boilerplate openGL code.
- textureGL that wraps an openGL texture object. It's a very minimal wrapper too, but I don't really need more than that at this point.

I've added a new function called InitGpuObjects(), that gets called in app::Init().
that's where we can create our texture and our shader : 
void application::InitGpuObjects()
{
    PathTracingShader = std::make_shared<shaderGL>("resources/shaders/PathTrace.glsl");
    RenderTexture = std::make_shared<textureGL>(Window->Width, Window->Height, 4);
}
 
Alright, now that we have that, we need to run that shader, and display the result on the screen !
 
To run the shader :
PathTracingShader->Use();
PathTracingShader->SetTexture(0, RenderTexture->TextureID, GL_READ_WRITE);
PathTracingShader->Dispatch(Window->Width / 16 + 1, Window->Height / 16 +1, 1);
Note here that we're binding the render texture to the shader. It's calling glBindImageTexture under the hood.
 
And to display the texture on the screen :
ImGui::SetNextWindowPos(ImVec2(0,0), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(Window->Width, Window->Height), ImGuiCond_Always);
ImGui::Begin("RenderWindow", nullptr
    ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoResize  
|ImGuiWindowFlags_NoTitleBar);
ImGui::Image((ImTextureID)RenderTexture->TextureID
             ImVec2(Window->Width, Window->Height));
ImGui::End();
 This will show the image on the full extent of the window. We create an ImGui window of the size of our main window, and display an image onto it.
 
And now, here's the compute shader : 
#version 460

layout (local_size_x = 16, local_size_y = 16, local_size_z = 1) in;

layout (binding = 0, rgba32f) uniform image2D RenderImage;


void main()
{
    ivec2 ImageSize = imageSize(RenderImage);
    vec2 UV = vec2(gl_GlobalInvocationID.xy) / vec2(ImageSize);
    imageStore(RenderImage, ivec2(gl_GlobalInvocationID.xy), vec4(UV, 0, 1));    
}
That's the most simple compute shader possible. 
If you don't know about compute shaders, they're "general purpose" programs that are useful for computing massively parallel tasks, like setting pixels on an image for example.
Compute shaders are executed by 1d, 2d, or 3d "blocks" or "groups" of threads. 
Each block can be of an arbitrary size. For filling up a 2d image, we will logically use 2d blocks, and in our case we use 16x16 blocks. (see local_size_x and local_size_y in the above code).
we then "Dispatch" the shader to run it, giving it a number of groups to execute.
so if we have a 2d image of size 256x256, and our block size is 16, we need to dispatch 16 groups in the x dimension, 16 groups in the y dimension, and 1 group in the z dimension.
Here's a very useful schema for understanding the execution of compute shaders (It's based on d3d compute shaders, but can be applied to openGL too).
Here's a more in depth introduction to openGL compute shaders.
 
In this shader, we find the UV coordinate of the current pixel using gl_GlobalInvocationID, and set that uv value as the colour for this pixel.

Here's the result :
 
Amazing ! now we can view the result of our compute shader, live on the window.

In the next post, we'll see how to add a cuda backend to the project, because... why not ?

Links

 

 

 next Post : Simple GPU Path Tracing, Part. 1.1 : Adding a cuda backend to the project

Commentaires

Articles les plus consultés