Simple GPU Path Tracing, Part. 2.0 : Scene Representation

 

 In the previous posts, I covered : 

  • How to create a simple windowed app
  • How to create an OpenGL Texture image
  • How to write into this image from a gl compute shader or a cuda kernel
  • How to show it on the screen

That's nice, but our ultimate goal is to build a path tracer that's able to draw some 3d scenes onto an image! 

To do that, we'll need some data structures to represent a 3D scene. So in this post, I'll show what those data structures will be, and I'll eventually write a function that creates a 3d scene that will be the input for our renderer.


 

Here's the branch that contains the code of this part.

Most of the code will be contained in Scene.h and Scene.cpp. Note that this will be a very basic setup that will evolve throughout the series as we add more features.

Scene structs :

shape

The most basic structure that we need is the "shape" struct. It simply defines a 3d shape. For now, we'll only be using triangle meshes as shapes, but a shape could be composed of lines, points, quads... We may add those in the future.

a triangle mesh is defined by a set of triangle, each triangle being defined by 3 vertices in 3D space.
 
Each vertex not only has a position, but also holds some other properties that are useful for shading :
- Normal : The normal of the triangle, defining its orientation
- Tangent : The tangent of the triangle, that is orthonormal to the normal orientation
- TexCoords : The texture coordinates of the triangle, useful for texturing (We'll see that later)
- Colour : The colour of the vertex
 

 
For example here we have 4 points, each with a 3d position and a 2d texture coordinate, that we can store in a vertex buffer like that : 
 
 
(I ommitted the 2 other attributes (Tangent and Colour) in the schema)
 
It's important to note that all these properties are defined per vertex. We will see later how we use them.

So we have a list of vertex position, normal, tangent, texCoords, Colour. 
That's great, but how do we know how to link those informations together to create actual triangles ? 
A triangle is composed of 3 points, so what we need is a way of indexing those vertex properties 3 by 3.
Therefore, a shape will have an array of indices as well. An index in this case will be a ivec3 type. 
Index.x will be the index of the first vertex in the triangle, Index.y will be the second vertex, and Index.z will be the third.
We will then have an index buffer that looks like that : 
 

Each item in the buffer corresponds to a triangle. The first triangle is composed of the vertices number 1, 0 and 2 in the vertex buffer, and the second triangle is composed of the vertices number 3, 1 and 2 in the vertex buffer.



Here's the shape struct definition : 
struct shape
{

    std::vector<glm::vec3> Positions;
    std::vector<glm::vec3> Normals;
    std::vector<glm::vec2> TexCoords;
    std::vector<glm::vec4> Colours;
    std::vector<glm::vec4> Tangents;
   
    std::vector<glm::ivec3> Triangles;
};

Surprisingly, we're not storing a vertex buffer as described earlier, but we're rather storing multiple array, each one storing a set of vertex attributes.

That's a more cache friendly way of storing meshes, see here for more infos.

Instance

Having a shape struct isn't sufficient to represent an object in 3d world. We also need a way of defining where the shape is in the world, and how it looks (What kind of material is it made of)

To do that we'll use an instance struct, that represents an instantiation of a shape in the world.

Here's the struct definition : 


struct instance
{
    glm::vec3 Position;
    glm::vec3 Rotation;
    glm::vec3 Scale = glm::vec3(1);
    int Shape = InvalidID;

    glm::mat4 GetModelMatrix() const;
}; 

An instance has a position, rotation and scale. It also has a shapeID, which points to the shape that this instance is instantiating.

Later on, it will also have a MaterialID that will point to the material that the instance is using, but for the base implementation of the path tracer, we'll only use a single matte material by default.

camera

Next, we need to define a camera object.The camera will represent the eye in the scene : the viewpoint and the direction we look towards.

The camera therefore holds a 4x4 matrix that represents its position and orientation in 3d space.

We'll also use some physical camera properties to define it that I will explain in a later post when we actually do the ray generation.

Here's the camera struct definition : 

struct camera
{
    glm::mat4 Frame  = glm::mat4(1);
   
    float Lens = 0.050f;
    float Film = 0.036f;
    float Aspect = 1.5f;
    float Focus = 1000;
   
    glm::vec3 Padding0;
    float Aperture = 0;
   
    int Orthographic = 0;
    glm::ivec3 Padding;
}; 

 Note that we add some padding members. That's because we will be uploading this struct on to the gpu, and we need to comply with the struct aligning requirements for openGL uniform buffers, but we'll see that later when we create those buffers.


Scene

Lastly, we will create the main scene struct that holds everything together.

it will contain an array of cameras, an array of shapes, and an array of instances.

It will also contain array of string for the naming of each of those elements : 


struct scene
{
    std::vector<camera> Cameras = {};
   
    std::vector<instance> Instances = {};
    std::vector<shape> Shapes = {};
   
   
    std::vector<std::string> CameraNames = {};
    std::vector<std::string> InstanceNames = {};
    std::vector<std::string> ShapeNames = {};
};

Cornell Box

Let's now write a function that creates a scene. We will create the famous Cornell box scene, composed of 3 walls, a floor, a ceiling and 2 boxes.
we will create a shape for each of those elements, and an instance of the shape as well.

I created a function called CreateCornellBox() that returns a pointer to a scene struct.

I create each element of the scene with the following kind of code ; 
    Scene->Shapes.emplace_back();
    shape &Floor = Scene->Shapes.back();
    Floor.Positions = { {-1, 0, 1}, {1, 0, 1}, {1, 0, -1}, {-1, 0, -1} };
    Floor.Triangles = { {0, 1, 2}, {2, 3, 0} };
    Floor.TexCoords = {{0, 1}, {1, 1}, {1, 0}, {0, 0}};
    Scene->Instances.emplace_back();
    instance &FloorInstance = Scene->Instances.back();
    FloorInstance.Shape = (int)Scene->Shapes.size()-1;
    Scene->ShapeNames.push_back("Floor");
    Scene->InstanceNames.push_back("Floor");

So I first create the shape, then the instance, then associate the instance with the shape, and then add the names to the name lists.

At the end of the function, there's a bit of code that does a little check up for all the vertex attributes. We need all the vertices to have a value for each attribute (Colour, Normal, Texture coordinates...) so if they haven't been defined, we define default values.

It also calculates the tangents of each vertex if they haven't been defined. We will need tangent for normal mapping, which is quite an advanced feature, but we will calculate them now anyway and see later how to use them.

The calculation of the tangent is quite a mathematically involved piece of code that uses the texture coordinates and the normal information to calculate the tangent space. Here's a good tutorial on why we need tangents for normal mapping, and how to calculate them.

We can then create the scene in the Application in the Init() function : 

void application::Init()
{
    Window = std::make_shared<window>(800, 600);
    InitImGui();
    Scene = CreateCornellBox();
    InitGpuObjects();
}

To the GPU !

Now that we've created all these structs, we will need to access all the scene data from the kernels on the gpu.
But there's a trap here : we will not be sending the shape and instances data as they are laid out in the scene struct! Instead, we will create an "acceleration structure" from this data that will allow for fast ray tracing, but we will see that in a later post. 
 
For now, we will only upload the camera data to the gpu. We need to create a gpu buffer and to upload the camera data into this buffer. Then we will send this buffer to the gpu kernels so they can access it.

In the scene struct, we will add members for this buffer, for both openGL and cuda backends : 

#if API==API_GL
    std::shared_ptr<bufferGL> CamerasBuffer;
#elif API==API_CU
    std::shared_ptr<bufferCu> CamerasBuffer;
#endif    


and in the CreateCornellBox function, we actually create those buffers : 

#if API==API_GL
    Scene->CamerasBuffer = std::make_shared<bufferGL>(Scene->Cameras.size()  
                                            * sizeof(camera), Scene->Cameras.data());
#elif API==API_CU
    Scene->CamerasBuffer = std::make_shared<bufferCu>(Scene->Cameras.size()  
                                            * sizeof(camera), Scene->Cameras.data());
#endif    

For Cuda, we already have a bufferCu class so this should compile.
 
However for openGL, we need to create this class. It's a simple class that wraps an Shader Storage Buffer Object . This allows to store arbitrarily large data in a buffer and access it in the kernel.

Here's a link to the BufferGL class. Nothing too complicated there, just creates a buffer object and keeps track of its ID, plus some helper functions to upload data to the buffer.

Conclusion

So now we've created a scene representation, and uploaded some of its data to the gpu. 
In the next post, we will create another representation of this scene that will be optimized for ray tracing, and we will upload it to the gpu as well. We will then have all the required information to start tracing the scene with rays!

Links



Commentaires

Articles les plus consultés