OpenGL/공부

[OpenGL] Model Loading (Assimp)

ciel45 2024. 7. 13. 02:01

Assimp는 Open Asset Import Library의 약자로, fbx, obj, gltf 등 다양한 3d 모델 포맷을 지원한다.

 

OpenGL 프로그램에서 모델링 불러올 때는 웬만하면 이걸 사용하는 것 같다.

학교 수업에서도 사용했고, learnopengl.com에서도 이걸 소개하고 있다.

 

 

우선 얘가 사용하는 구조를 보자면 다음과 같다.

Assimp의 구조

 

다이어그램 전체가 하나의 모델이라고 생각하면 된다.

언뜻 보기에는 좀 복잡한데, 천천히 살펴보자면

  • 우선 중요한 점을 하나 짚고 가자면, 모델은 여러개의 메시로 이루어져있다.
  • 모델의 모든 데이터는 Scene 안에 담겨있다. (material, mesh 등)
  • 좌측을 보면 Scene에 Root node가 달려있고, 그 아래로 트리처럼 Child node들이 달려있는 것을 볼 수 있다.
    • 각 node들은 mMeshes[] 를 가지고 있는데, 여기 들어있는 것은 실제 메시 오브젝트는 아니고 메시의 ID이다.
    • 실제 메시는 항상 Scene이 들고 있다.
    • 따라서 프로그램이 node를 순회하면서, node가 가리키고 있는 메시들을 로드하도록 할 수 있다.
  • 우측을 보면 Scene에 Mesh가 달려있는데, 여기에 있는 것들이 실제 메시 오브젝트들이다.
  • Scene에 Material이 달려있는데, 여기서 메시에 맞는 텍스쳐를 뽑아올 수 있다.
  • Scene에 Face가 달려있는데, 각 폴리곤의 버텍스의 인덱스를 담고 있다.

 

node가 트리 구조로 되어있어, 재귀 구조로 함수를 호출함으로써 모델을 쉽게 로드할 수 있다.

 

 

 

이걸 프로젝트에 추가해주는 방법은,

https://learnopengl.com/Model-Loading/Assimp

 

LearnOpenGL - Assimp

Assimp Model-Loading/Assimp In all the scenes so far we've been extensively playing with our little container friend, but over time, even our best friends can get a little boring. In bigger graphics applications, there are usually lots of complicated and i

learnopengl.com

이 글의 하단 Building Assimp에서 확인할 수 있다.

 

 

Assimp를 추가한 뒤 모델의 로드와 렌더링을 맡을 클래스 Model을 새로 만들어주었다.

 

Model.h : 

#pragma once

#include <vector>
#include <string>

#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>

#include "Mesh.h"
#include "Texture.h"

class Model
{
public:
	void LoadModel(const std::string& fileName); // 모델을 메모리에 올린다.
	void RenderModel(); // 렌더링한다.
	void ClearModel(); // 메모리에서 내린다.

	~Model();

private:
	// 재귀호출되며 노드를 순회하기 위한 함수
	void LoadNode(aiNode* node, const aiScene* scene); 
    // 실제 메시 오브젝트를 참조하여 버텍스 정보를 로드한다.
	void LoadMesh(aiMesh* mesh, const aiScene* scene);
    // 텍스쳐를 로드한다.
	void LoadMaterials(const aiScene* scene);

	std::vector<Mesh*> meshList;
	std::vector<Texture*> textureList;
	std::vector<unsigned int> meshToTex; // 메시에 맞는 머티리얼의 인덱스를 저장한다.

	std::string modelName;
};

 

 

Model.cpp:

#include <iostream>

#include "Model.h"
#include <GL/glew.h>

void Model::LoadModel(const std::string& fileName)
{
	// 모델 이름 추출
	int firstSlashIdx = fileName.find('/', 0);
	modelName = fileName.substr(0, firstSlashIdx);

	Assimp::Importer importer;
    
    // 2번째 인자는 타입이 unsigned int인데, 비트 or 연산을 사용한다. 자세한 내용은 후술
	const aiScene* scene = importer.ReadFile("Models/" + fileName, 
		aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_GenSmoothNormals | aiProcess_JoinIdenticalVertices);

	if (!scene)
	{
		std::cout << fileName << " Model 로드 실패 : " << importer.GetErrorString() << std::endl;
		return;
	}

	LoadNode(scene->mRootNode, scene);
	LoadMaterials(scene);
}

void Model::RenderModel()
{
	// LoadMesh 함수에서 채워놓은 meshList를 순회하며 메시들을 렌더링한다.
	for (size_t i = 0; i < meshList.size(); i++)
	{
    	// 메시에 해당하는 머티리얼을 통해 텍스쳐를 가져와 사용한다.
		unsigned int materialIndex = meshToTex[i];
		if (materialIndex < textureList.size() && textureList[materialIndex])
			textureList[materialIndex]->UseTexture();

		meshList[i]->RenderMesh();
	}
}

void Model::ClearModel()
{
	for (size_t i = 0; i < meshList.size(); i++)
	{
		if (meshList[i])
		{
			delete meshList[i];
			meshList[i] = nullptr;
		}
	}

	for (size_t i = 0; i < textureList.size(); i++)
	{
		if (textureList[i])
		{
			delete textureList[i];
			textureList[i] = nullptr;
		}
	}
}

void Model::LoadNode(aiNode* node, const aiScene* scene)
{
	for (size_t i = 0; i < node->mNumMeshes; i++)
	{
		// node->mMeshes[i] : 메시 자체가 아니고, 메시의 ID를 의미한다.
		// 실제 메시는 scene에 저장되어있기 때문에 이렇게 참조하게 된다.
		LoadMesh(scene->mMeshes[node->mMeshes[i]], scene);
	}

	// 자식 노드들을 재귀호출을 통해 순회하며 메시를 쭉 로드한다.
	for (size_t i = 0; i < node->mNumChildren; i++)
	{
		LoadNode(node->mChildren[i], scene);
	}
}

// 실제로 VBO, IBO로 쏴줄 정보들을 구성한다.
void Model::LoadMesh(aiMesh* mesh, const aiScene* scene)
{
	std::vector<GLfloat> vertices;
	std::vector<unsigned int> indices;

	for (size_t i = 0; i < mesh->mNumVertices; i++)
	{
		// position
		vertices.push_back(mesh->mVertices[i].x);
		vertices.push_back(mesh->mVertices[i].y);
		vertices.push_back(mesh->mVertices[i].z);

		// texture
		if (mesh->mTextureCoords[0])
		{
			vertices.push_back(mesh->mTextureCoords[0][i].x);
			vertices.push_back(mesh->mTextureCoords[0][i].y);
		}
		else // 존재하지 않을 경우 그냥 0을 넣어주기
		{
			vertices.push_back(0.f);
			vertices.push_back(0.f);
		}

		// normal (aiProcess_GenSmoothNormals를 적용했기 때문에 없을 수가 없다.)
		vertices.push_back(mesh->mNormals[i].x);
		vertices.push_back(mesh->mNormals[i].y);
		vertices.push_back(mesh->mNormals[i].z);
	}

	// indices 채워주기
	for (size_t i = 0; i < mesh->mNumFaces; i++)
	{
		aiFace face = mesh->mFaces[i];
		for (size_t j = 0; j < face.mNumIndices; j++)
		{
			indices.push_back(face.mIndices[j]);
		}
	}

	Mesh* newMesh = new Mesh();
	newMesh->CreateMesh(vertices, indices); // GPU의 VBO, IBO로 버텍스 정보를 쏴준다.
	meshList.push_back(newMesh);
    
    // meshList에 mesh를 채워줌과 동시에, meshToTex에는 그 mesh의 materialIndex를 채워준다.
    // 이렇게 meshList와 meshToTex를 나란히 채워줌으로써 mesh와 맞는 material을 손쉽게 찾을 수 있다.
	meshToTex.push_back(mesh->mMaterialIndex);
}

void Model::LoadMaterials(const aiScene* scene)
{
	textureList.resize(scene->mNumMaterials);
	for (size_t i = 0; i < scene->mNumMaterials; i++)
	{
		aiMaterial* material = scene->mMaterials[i];

		textureList[i] = nullptr;

		// 텍스쳐가 존재하는 지 먼저 확인
		if (material->GetTextureCount(aiTextureType_BASE_COLOR))
		{
			aiString texturePath;
            // 텍스쳐 경로를 가져오는 데 성공했다면
			if (material->GetTexture(aiTextureType_BASE_COLOR, 0, &texturePath) == aiReturn_SUCCESS)
			{
				// 혹시나 텍스쳐 경로가 절대 경로로 되어있다면 그에 대한 처리
				int idx = std::string(texturePath.data).rfind("/");
				std::string textureName = std::string(texturePath.data).substr(idx + 1);

				std::string texPath = "Models/" + modelName + "/textures/" + textureName;

				textureList[i] = new Texture(texPath.c_str());

				// 텍스쳐를 디스크에서 메모리로 로드, GPU로 쏴준다.
				if (!textureList[i]->LoadTexture())
				{ // 실패 시
					std::cout << "텍스쳐 로드 실패 : " << texPath << std::endl;
					delete textureList[i];
					textureList[i] = nullptr;
				}
			}
		}

		// textureList에 텍스쳐를 담는데 실패했다면
		if (!textureList[i])
		{
			textureList[i] = new Texture("plain.png"); // 흰색 텍스쳐로 대체
		}
	}
}

Model::~Model()
{
	ClearModel();
}

 

16번째 줄을 보면 2번째 인자가 특이한 모양으로 들어갔는데, 실제 타입은 unsigned int이다.

enum, 즉 정수끼리 비트 OR 연산을 한 결과값을 인자로 받도록 만들어져있다.

 

여기서 넣은 4가지의 의미를 하나씩 살펴보자면,

  • Triangulate : 폴리곤의 모양을 triangle로 통일한다. quad 등이 나온다면 triangle로 쪼갠다.
  • FlipUVs : 텍스쳐를 임포트할 때 위 아래를 뒤집는다. 일반적인 이미지 파일과 OpenGL 텍스쳐의 y축이 서로 반대 방향이기 때문.
  • GenSmoothNormals : 모델링에 노멀 정보가 없을 경우, 자동으로 만들어준다.
  • JoinIdenticalVertices : 같은 위치에 두 개 이상의 버텍스가 있는 경우, 하나로 통합시킨다.

 

LoadNode() 함수는 상술한 트리 구조의 node들을 재귀적으로 순회하기 위한 함수이다.

 

LoadMesh() 함수는 실제로 버텍스 정보를 std::vector에 담아주는 함수이다.

결과적으로 vertices는 이렇게 채워질 것이다.

{ position.x, position.y, position.z,       texcoord.u, texcoord.v,        normal.x, normal.y, normal.z, (1번 버텍스)

 position.x, position.y, position.z,       texcoord.u, texcoord.v,        normal.x, normal.y, normal.z, (2번 버텍스)

 position.x, position.y, position.z,       texcoord.u, texcoord.v,        normal.x, normal.y, normal.z, (3번 버텍스) ...}

 

함수 하단에 보이다시피, vertices뿐만 아니라 indices도 채워준다.

각 mesh는 여러개의 face를 가지고있다.

이 face들을 하나씩 순회하면서, 각 face를 구성하는 index들을 indices에 push_back 해주는 것이다.

 

 

LoadMaterial() 함수는 Scene에 들어있는 텍스쳐들을 몽땅 로드해준다.

로드한 텍스쳐들은 textureList에 담아놓아서,

상단의 RenderModel() 함수에서 그 textureList를 통해 맞는 텍스쳐를 사용하도록 하였다.

 

 

 

 

이렇게 Model 클래스를 완성한 뒤, 바로 모델링을 하나 다운받아 띄워보기로 결심했다.

요즘 재밌게 하고 있는 게임인 니어: 오토마타의 주인공 2B 모델링을 사용해보기로 했다.

https://sketchfab.com/3d-models/2b-nier-automata-8b00fabb9d364c2da3c0c49ed79217ef

 

2b Nier automata - Download Free 3D model by kain - Sketchfab

hi here wip of my new 3d model i have been working on .2b from nier automata

sketchfab.com

 

 

압축을 풀고 프로젝트 폴더/Models 안에 배치

 

main.cpp:

#define STB_IMAGE_IMPLEMENTATION

#include <iostream>
#include <vector>

#include <GL/glew.h>
#include <GLFW/glfw3.h>

#include <glm/glm.hpp>
#include <glm\gtc\matrix_transform.hpp>
#include <glm\gtc\type_ptr.hpp>

#include <assimp/Importer.hpp>

#include "Camera.h"
#include "Mesh.h"
#include "Shader.h"
#include "Window.h"
#include "Model.h"

#define WIDTH 1280
#define HEIGHT 720

Window* mainWindow;
Camera* camera;

GLfloat deltaTime = 0.f;
GLfloat lastTime = 0.f;

// Vertex Shader
static const char* vShaderPath = "Shaders/VertexShader.glsl";

// Fragment Shader
static const char* fShaderPath = "Shaders/FragmentShader.glsl";

std::vector<Mesh*> meshList;
std::vector<Shader*> shaderList;

Model* model_2B;

// 쉐이더 변수 핸들
GLuint loc_modelMat = 0;
GLuint loc_PVM = 0;
GLuint loc_sampler = 0;

// 쉐이더 컴파일
void CreateShader()
{
	Shader* shader = new Shader;
	shader->CreateFromFiles(vShaderPath, fShaderPath);
	shaderList.push_back(shader);
}

void GetShaderHandles()
{
	// 핸들 얻어오기
	loc_modelMat = shaderList[0]->GetModelMatLoc();
	loc_PVM = shaderList[0]->GetPVMLoc();
	loc_sampler = shaderList[0]->GetSamplerLoc();
}

int main()
{
    // GLFW 초기화
    if (!glfwInit())
    {
        printf("GLFW 초기화 실패\n");
        glfwTerminate();
        return 1;
    }

    mainWindow = new Window(WIDTH, HEIGHT);
    mainWindow->Initialise();

	CreateShader();

	GLfloat initialPitch = 0.f;
	GLfloat initialYaw = -90.f; // 카메라가 -z축을 보고 있도록

	camera = new Camera(glm::vec3(0.f,0.f,0.f), glm::vec3(0.f, 1.f, 0.f), initialYaw, initialPitch, 5.f, 0.3f);

	model_2B = new Model();
	std::string modelPath = "2b_nier_automata/scene.gltf";
	model_2B->LoadModel(modelPath);

    // 창이 닫힐 때까지 반복
    while (!mainWindow->getShouldClose())
    {
		shaderList[0]->UseShader();

		// 쉐이더 내부 변수 위치들 가지고오기
		GetShaderHandles();
		glUniform1i(loc_sampler, 0); // sampler를 0번 텍스쳐 유닛과 연결

		GLfloat now = glfwGetTime();
		deltaTime = now - lastTime;
		lastTime = now;

		// Get + Handle User Input
		glfwPollEvents();

		camera->keyControl(mainWindow->getsKeys(), deltaTime);
		camera->mouseControl(mainWindow->getXChange(), mainWindow->getYChange());

		// Clear the window
		glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
		glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

		glm::mat4 modelMat(1.0f);
		modelMat = glm::translate(modelMat, glm::vec3(0.0f, -2.f, -5.f));
		glUniformMatrix4fv(loc_modelMat, 1, GL_FALSE, glm::value_ptr(modelMat));

		glm::mat4 view = camera->calculateViewMatrix();
		glm::mat4 projection = glm::perspective(glm::radians(45.0f), 
			(GLfloat)mainWindow->getBufferWidth() / mainWindow->getBufferHeight(), 0.1f, 100.0f);
		glm::mat4 PVM = projection * view * modelMat;

		glUniformMatrix4fv(loc_PVM, 1, GL_FALSE, glm::value_ptr(PVM));


		model_2B->RenderModel();

		std::cout << "rendered model" << std::endl;

		glUseProgram(0);

		mainWindow->swapBuffers();
    }
    return 0;
};

 

 

 

이렇게해서 실행을 해봤는데.. 뭔가 이상했다. 흰색이어야 할 일부분이 빨간색으로 나왔다.

 

 

텍스쳐에서 뭔가 문제가 생긴 것 같아 뒤져보던 중, 이 모델링의 텍스쳐 중 single channel 텍스쳐가 있다는 것을 발견했다.

 

텍스쳐를 로드하는 코드를 짤 때 싱글 채널은 RGBA 중 R만 쓰는 것으로 처리해놓았는데, 이게 실제로는 빨간색을 의미하는 것이 아니고 grayscale을 의미하는 것이기 때문에 생긴 문제이다.

// 텍스쳐의 채널 수에 따라 맞는 포맷으로 쏴주기
	GLenum const textureFormat[] = { GL_RED, GL_RG, GL_RGB, GL_RGBA };
	glTexImage2D(
		GL_TEXTURE_2D, 0, textureFormat[bitDepth - 1],
		width, height, 0,
		textureFormat[bitDepth - 1], GL_UNSIGNED_BYTE, texData);

 

문제가 되는 코드이다. bitDepth가 1인 경우, 즉 single channel인 경우 GL_RED 포맷으로 렌더링하게 되고, 쉐이더는 그걸 RGBA 중 R만 0이 아닌 것으로 판단하여 빨간색으로 렌더링하고 있던 것이다.

 

그래서 이 부분을 일종의 예외처리를 해주는 코드를 추가해주었다.

	// 1채널 텍스처인 경우 3채널로 다시 로드
	if (bitDepth == 1)
	{
		stbi_image_free(texData);  // 기존 데이터 해제
		texData = stbi_load(fileLocation, &width, &height, &bitDepth, STBI_rgb);
		if (!texData)
		{
			std::cerr << "Failed to reload texture as 3-channel: " << fileLocation << std::endl;
			return false;
		}
		bitDepth = 3;  // 채널 수를 3로 설정
	}

텍스쳐가 싱글 채널인 경우 강제로 3채널로 바꿔 다시 로드하도록 하였다.

 

Texture 클래스 전문:

더보기

Texture.h

#pragma once

#include <GL\glew.h>

#include "stb_image.h"

class Texture
{
public:
	Texture(const char* fileLoc);

	/// <summary>
	/// 텍스쳐 메모리로 로드, GPU로 쏴주기
	/// </summary>
	bool LoadTexture();  

	/// <summary>
	/// 0번 텍스쳐 유닛에 텍스쳐 물려주기
	/// </summary>
	void UseTexture(); 

	void ClearTexture(); // 메모리에서 텍스쳐 내리기

	~Texture();

private:
	GLuint textureID;
	int width, height, bitDepth;

	const char* fileLocation;
};

 

Texture.cpp

#include "Texture.h"

#include "stb_image.h"
#include <stdexcept>
#include <iostream>

Texture::Texture(const char* fileLoc)
{
	textureID = 0;
	width = 0;
	height = 0;
	bitDepth = 0;
	fileLocation = fileLoc;
}

bool Texture::LoadTexture()
{
	unsigned char* texData = stbi_load(fileLocation, &width, &height, &bitDepth, 0);
	if (!texData)
	{
		printf("Failed to find: %s\n", fileLocation);
		return false;
	}

	glGenTextures(1, &textureID);
	glBindTexture(GL_TEXTURE_2D, textureID);

	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);


	// 1채널 텍스처인 경우 3채널로 다시 로드
	if (bitDepth == 1)
	{
		stbi_image_free(texData);  // 기존 데이터 해제
		texData = stbi_load(fileLocation, &width, &height, &bitDepth, STBI_rgb);
		if (!texData)
		{
			std::cerr << "Failed to reload texture as 3-channel: " << fileLocation << std::endl;
			return false;
		}
		bitDepth = 3;  // 채널 수를 3로 설정
	}

	// 텍스쳐의 채널 수에 따라 맞는 포맷으로 쏴주기
	GLenum const textureFormat[] = { GL_RED, GL_RG, GL_RGB, GL_RGBA };
	glTexImage2D(
		GL_TEXTURE_2D, 0, textureFormat[bitDepth - 1],
		width, height, 0,
		textureFormat[bitDepth - 1], GL_UNSIGNED_BYTE, texData);
	

	//glGenerateMipmap(GL_TEXTURE_2D);

	glBindTexture(GL_TEXTURE_2D, 0);

	stbi_image_free(texData);
	return true;
}

void Texture::UseTexture()
{
	glActiveTexture(GL_TEXTURE0);
	glBindTexture(GL_TEXTURE_2D, textureID);
}

void Texture::ClearTexture()
{
	glDeleteTextures(1, &textureID);
	textureID = 0;
	width = 0;
	height = 0;
	bitDepth = 0;
	fileLocation = "";
}


Texture::~Texture()
{
	ClearTexture();
}

 

수정 결과:

 

 

 

색깔이 제대로 나와주는 것을 볼 수 있었다.