Loading 3d models

Rendering triangles and parametric meshes is nice, but an engine loads 3d models made in dedicated programs. For that, we are going to implement basic OBJ format loading.

The OBJ format is a very simple format, which is understood by almost all software that deals with 3d models. We will be using the library tiny_obj_loader to load a Blender monkey mesh (on the assets folder) and render it.

The code that we have right now can render any arbitrary mesh as long as the vertices array is filled, and we can move that mesh in 3d space using the push-constants matrix.

We will begin by adding a new Mesh object to VulkanEngine class, to hold the newly loaded monkey mesh.

class VulkanEngine {
//other code ....
Mesh _monkeyMesh;

Next, we are going to add a function to our Mesh object, to initialize it from an obj file.

struct Mesh {
	// other code .....

	bool load_from_obj(const char* filename);
//make sure that you are including the library
#include <tiny_obj_loader.h>
#include <iostream>
bool Mesh::load_from_obj(const char* filename)
    return false;

The OBJ format

In a OBJ file, the vertices are not stored together. Instead, it holds a separated arrays of positions, normals, UVs, and Colors, and then an array of faces that points to those. A given obj file also has multiple shapes, as it can hold multiple objects, each of them with separate materials. In this tutorial, we load a single obj file into a single mesh, and all of the obj shapes will get merged.

Let’s continue filling the load function

bool Mesh::load_from_obj(const char* filename)
    //attrib will contain the vertex arrays of the file
	tinyobj::attrib_t attrib;
    //shapes contains the info for each separate object in the file
	std::vector<tinyobj::shape_t> shapes;
    //materials contains the information about the material of each shape, but we won't use it.
    std::vector<tinyobj::material_t> materials;

    //error and warning output from the load function
	std::string warn;
	std::string err;

    //load the OBJ file
	tinyobj::LoadObj(&attrib, &shapes, &materials, &warn, &err, filename, nullptr);
    //make sure to output the warnings to the console, in case there are issues with the file
	if (!warn.empty()) {
		std::cout << "WARN: " << warn << std::endl;
    //if we have any error, print it to the console, and break the mesh loading.
    //This happens if the file can't be found or is malformed
	if (!err.empty()) {
		std::cerr << err << std::endl;
		return false;

With that code, we use the library to load an obj file into structures that we can use to convert into our mesh format. There are some structures we have to declare that the LoadObj function uses, and then we error check.

Continue with the load function, to put the meshes from the file into our vertex buffer

    // Loop over shapes
	for (size_t s = 0; s < shapes.size(); s++) {
		// Loop over faces(polygon)
		size_t index_offset = 0;
		for (size_t f = 0; f < shapes[s].mesh.num_face_vertices.size(); f++) {

            //hardcode loading to triangles
			int fv = 3;

			// Loop over vertices in the face.
			for (size_t v = 0; v < fv; v++) {
				// access to vertex
				tinyobj::index_t idx = shapes[s].mesh.indices[index_offset + v];

                //vertex position
				tinyobj::real_t vx = attrib.vertices[3 * idx.vertex_index + 0];
				tinyobj::real_t vy = attrib.vertices[3 * idx.vertex_index + 1];
				tinyobj::real_t vz = attrib.vertices[3 * idx.vertex_index + 2];
                //vertex normal
            	tinyobj::real_t nx = attrib.normals[3 * idx.normal_index + 0];
				tinyobj::real_t ny = attrib.normals[3 * idx.normal_index + 1];
				tinyobj::real_t nz = attrib.normals[3 * idx.normal_index + 2];

                //copy it into our vertex
				Vertex new_vert;
				new_vert.position.x = vx;
				new_vert.position.y = vy;
				new_vert.position.z = vz;

				new_vert.normal.x = nx;
				new_vert.normal.y = ny;
                new_vert.normal.z = nz;

                //we are setting the vertex color as the vertex normal. This is just for display purposes
                new_vert.color = new_vert.normal;

			index_offset += fv;

    return true;

The TinyOBJ conversion loop can be quite tricky to get right. This one is derived from their sample code and simplified a bit. You can see the original at: README page. In here, we are hardcoding the number of vertices per face to 3. If you use this code with a model that hasn’t been triangulated, you will have issues. Loading models that have faces with 4 or more vertices is something more complicated so we will leave it for other time.

With the code added, we can now load objs into our Mesh struct, so let’s load the monkey mesh into our triangle mesh, and see if something happens.

Loading the mesh

on the load_meshes function of VulkanEngine, we are going to load the monkey mesh alongside the triangle

void VulkanEngine::load_meshes()

	_triangleMesh._vertices[0].position = { 1.f,1.f, 0.5f };
	_triangleMesh._vertices[1].position = { -1.f,1.f, 0.5f };
	_triangleMesh._vertices[2].position = { 0.f,-1.f, 0.5f };

	_triangleMesh._vertices[0].color = { 0.f,1.f, 0.0f }; //pure green
	_triangleMesh._vertices[1].color = { 0.f,1.f, 0.0f }; //pure green
	_triangleMesh._vertices[2].color = { 0.f,1.f, 0.0f }; //pure green

    //load the monkey

    //make sure both meshes are sent to the GPU

The monkey mesh is now loaded, so we can use it in our draw loop to display it. It’s the same as the triangle, but we now use the monkey instead of the triangleMesh

 //bind the mesh vertex buffer with offset 0
	VkDeviceSize offset = 0;
    vkCmdBindVertexBuffers(cmd, 0, 1, &_monkeyMesh._vertexBuffer._buffer, &offset);

    //we can now draw the mesh
    vkCmdDraw(cmd, _monkeyMesh._vertices.size(), 1, 0, 0);

You should be seeing a rotating monkey head. But with a glitch, some faces draw on top of each other. That’s caused by the lack of a depth buffer that we have right now, so let’s fix that on the next article.


Next: Setting up Depth Buffer