[DX11] Obj 파일 Parsing후 Pipe line Binding
1. DX에서 오브젝트를 Rendering하는 것은 매우 번거롭다.
우리는 DX에서 물체를 렌더링해 주기 위해서는 정점 정보를 하나하나 직접 입력을 해주어야 했습니다.
이것은 매우 번거로운 작업이고 단순히 정육면체를 렌더링하기 위해서도 수많은 정점이 필요합니다.
심지어 Texture를 Mapping 해야 해서 UV 좌표까지 들어가게 된다면 정점 양은 더욱 늘어나게 됩니다.
심지어 좌표계까지 고려해서 정점들의 순서를 맞추거나 Index 버퍼를 만들어주어야 합니다.
이렇듯 정점을 수작업으로 입력해서 오브젝트를 그리는 것은 매우 번거롭습니다.
아래 사진과 같이 단순한 사과 오브젝트에 들어가는 정점만 해도 6000개가 넘습니다.
이러한 오브젝트를 수작업으로 정점을 넘겨주는 것은 사실상 불가능에 가깝습니다.
2. OBJ Parsing을 통한 문제 해결
문제 해결의 열쇠는 바로 .obj 형식을 가진 파일에 있습니다.
OBJ(Wavefront Object)는 Wavefront Technologies에서 개발한 개방형 3D 모델 파일 형식입니다.
쉽게 말해서 3D 파일들의 다양한 형식 중 하나입니다.
이러한 파일들은 우리가 흔히 알고 있는 다양한 3D 프로그램에서 읽을 수 있습니다. (UE5, Blender 등)
그렇다면 우리가 만든 엔진에서는 어떻게 이 obj 파일을 읽어서 즉시 렌더링 되도록 할 수 있을까요?
Parsing을 위해서 우리는 Obj 파일이 어떻게 구성되어 있는지 알아야 합니다.
우리는 OBJ 파일에서 Vertex, Index, Normal, UV, Matarial 등의 값을 적절하게 뽑아내서
우리가 엔진에서 구현한 Rendering Pipe Line의 구조에 맞게 데이터를 가공하는 작업이 필요합니다.
그렇기에 우리는 OBJ 파일에 대한 이해가 필요합니다.
3. OBJ 파일 분석
이 간단한 Cube OBJ 파일을 통해서 분석을 해보도록 하겠습니다.
OBJ 파일을 메모장으로 열게 되면 정보를 볼 수 있습니다.
크게 나누면
v, vn, vt, f로 나뉘어 있습니다.
정리하자면
v : Vertex 정보
vn : Normal 정보 (빛 계산에 사용)
vt : UV좌표
f : Index 정보
위와 같습니다.
이것 외에도 mtlib 이라는 키워드도 있는데 뒤에는 어떤 matrial 파일을 사용할 것인지 나타냅니다.
이 예시에 경우에는 mtllib cube.mtl 이렇게 되어있습니다.
newmtl texture
Ka 0.0 0.0 0.0
Kd 0.5 0.5 0.5
Ks 0.0 0.0 0.0
Ns 10.0
illum 2
map_Kd texture.png
이것이 mtl 파일의 전문입니다.
이곳에는 Matrial 들의 다양한 값이 들어있습니다.
map_Kd가 어떠한 텍스처 이미지를 사용할 것인지에 대한 값입니다.
4. 솔직히 많이 어렵다. (예외 사항, 어려움)
제가 작성한 설명만으로는 OBJ Parser를 제작하는 게 어려움이 분명히 있을 것입니다.
엮여 있는 부분도 매우 많고 어디를 Binding 해주어야 할지도 헷갈리고 많은 어려움이 있을 것이라고 예상됩니다.
그렇게 읽어온 값을 Pipe Line에 연결하고 쉬운 작업은 아닙니다.
또한 이것은 단일 텍스처를 쓰는 OBJ 파일 이지만 다중 텍스처를 쓰는 OBJ 파일도 많이 존재합니다.
이런 것들에 대해서도 전부 대응을 해주어야 합니다.
f 3/12/6 1/8/6 2/11/6
이것은 cube obj 파일의 index 정보를 담고 있는 부분입니다.
앞에서부터 Vertex/UV/Normal입니다.
만약 텍스처가 없는 OBJ 파일이라면
3//6 이런 식으로 되어있습니다.
네 그렇습니다.
Parser를 제작할 때 이러한 부분도 전부 대응을 해주어야만 합니다.
심지어 단순히 Vertex 정보만 기록하고 있는 f도 존재합니다.
우리가 해야 하는 것은
1. Matarial별로 사용되는 정점 정보들 Pipe Line에 넣을 수 있도록 데이터가공
2. 가공된 데이터가 Rendering 되도록 Render 로직 수정 (다중 Matarial대응을 위해 필수)
이렇게 말로만 쓰면 간단하지만 사실상 정말 복잡한 작업이 필요합니다.
아래는 Obj 파일을 Parsing 하는 함수입니다.
파일 Path를 통해 파일에 접근하고 한 줄씩 읽으며 적절하게 정보를 저장합니다.
심지어 이것을 통해 mtl파일의 이름도 string으로 가지고 있기 때문에
그것을 통해 mtl파일에도 접근해서 정보를 뽑아오고 정보들과 적절히 Mapping 시켜주어야 합니다. - 어렵습니다.
bool FObjImporter::ParseObjFile(const std::string& InPath, const std::string& InFileName, FObjInfo& OutObjInfo)
{
std::string BinaryFileName = InFileName.substr(0, InFileName.size() - 4) + ".bin";
std::string BinaryPathName = "Binary/";
std::ifstream BinFile(BinaryPathName+BinaryFileName, std::ios::binary);
///If binary file is open read that;
if (BinFile.is_open())
{
LoadObjFromBinary(BinaryPathName + BinaryFileName, OutObjInfo);
return true;
}
std::string FileName = InPath + "/" + InFileName;
std::ifstream ObjFile(FileName.c_str());
FString CurrentMaterial; // 현재 활성화된 텍스처 이름
std::string line;
FVector TempVector;
FVector2 TempVector2;
if (!ObjFile.is_open()) {
throw std::runtime_error("Failed to open OBJ file.");
return false;
}
OutObjInfo.PathFileName = UGTLStringLibrary::StringToWString(FileName);
while (std::getline(ObjFile, line))
{
std::stringstream ss(line);
std::string token;
// Skip empty lines and comment lines (lines starting with #)
if (line.empty() || line[0] == '#') continue;
line = UGTLStringLibrary::StringRemoveNoise(line);
// Handle vertices (v)
if (line.substr(0, 2) == "v ") {
static FVector Vertex;
static FVector4 Color;
Color = { 0.0f,0.0f, 0.0f, 1.0f }; // Default color: white
// Parse vertex coordinates
ss >> token >> TempVector.X >> TempVector.Y >> TempVector.Z;
OutObjInfo.Vertices.push_back({ TempVector });
// Check if color (RGBA) is provided (i.e., 4 components)
if (ss >> Color.X >> Color.Y >> Color.Z >> Color.W) {
// If we successfully read 4 components, this means we have RGBA color
OutObjInfo.Colors.push_back(Color);
}
else
OutObjInfo.Colors.push_back(Color);
// If no color was provided, use default color (white)
}
// Handle normals (vn)
else if (line.substr(0, 3) == "vn ") {
static FVector normal;
ss >> token >> normal.X >> normal.Y >> normal.Z;
OutObjInfo.Normals.push_back(FVector{ normal }); // 0 is a placeholder for index
}
// Handle texture coordinates (vt)
else if (line.substr(0, 3) == "vt ") {
static FVector2 uv;
ss >> token >> uv.X >> uv.Y;
uv.Y = -uv.Y;
OutObjInfo.UV.push_back({ uv }); // 0 is a placeholder for index
}
// Handle face (f) which contains vertex indices
// Handle material library (mtllib)
else if (line.substr(0, 7) == "mtllib ") {
static std::string MtlFileName; // std::string으로 먼저 읽고
ss >> token >> MtlFileName;
MtlFileName = InPath + "/" + MtlFileName;
static FObjMaterialInfo NewMtlInfo;
if (!ParseMtlFile(InPath, MtlFileName, OutObjInfo.Materials))
{
return false;
}
}
// Handle material usage (usemtl)
else if (line.substr(0, 7) == "usemtl ")
{
// "usemtl " 이후의 모든 텍스트를 메터리얼 이름으로 사용
std::string MtlName = line.substr(7); // "usemtl " 이후부터 끝까지
// 메터리얼 이름을 WString으로 변환
CurrentMaterial = UGTLStringLibrary::StringToWString(MtlName);
// FaceMap에 메터리얼이 없으면 새로 추가
if (!OutObjInfo.FaceMap.contains(CurrentMaterial)) {
OutObjInfo.FaceMap.insert(make_pair(CurrentMaterial, TArray<FFace>()));
}
}
else if (line.substr(0, 2) == "f ") {
TArray<int> faceVertices;
TArray<int> faceTexCoords;
TArray<int> faceNormals;
// 면 정보 파싱
std::stringstream faceStream(line);
std::string faceToken;
faceStream >> faceToken; // f 날리기
while (faceStream >> faceToken) {
std::vector<std::string> parts;
std::stringstream tokenStream(faceToken);
std::string part;
while (std::getline(tokenStream, part, '/')) {
parts.push_back(part);
}
if (parts.size() == 1) {
// 정점만 있는 경우
faceVertices.push_back(std::stoi(parts[0]) - 1);
}
else if (parts[1] == "") {
// 정점 + 텍스처
faceVertices.push_back(std::stoi(parts[0]) - 1);
faceNormals.push_back(std::stoi(parts[2]) - 1);
}
else if (parts.size() == 3) {
// 정점 + 텍스처 + 노멀
faceVertices.push_back(std::stoi(parts[0]) - 1);
faceTexCoords.push_back(std::stoi(parts[1]) - 1);
faceNormals.push_back(std::stoi(parts[2]) - 1);
}
}
// 활성화된 텍스처에 해당하는 face 추가
FFace newFace;
newFace.Vertices = TArray<int>(faceVertices.begin(), faceVertices.end());
newFace.TexCoords = TArray<int>(faceTexCoords.begin(), faceTexCoords.end());
newFace.Normals = TArray<int>(faceNormals.begin(), faceNormals.end());
// if (CurrentMaterial.length() != 0)
OutObjInfo.FaceMap[CurrentMaterial].push_back(newFace);
}
TempVector.X = TempVector.Y = TempVector.Z = 0;
TempVector2.X = TempVector2.Y = 0;
}
ObjFile.close();
SaveObjToBinary(BinaryFileName,OutObjInfo);
return true;
}
5. OBJ파일 읽어 오는 속도는 매우 느리다.
우리가 정말 열심히 Parser를 만들고 Pipe Line에도 적절하게 넘어가서
Rendering이 됐다고 가정해 봅시다.
정말 고생 많았고 기쁜 일입니다.
그러나 위의 방식에는 아주 치명적인 단점이 있습니다.
그것은 바로 속도가 엄청나게 느리다는 것입니다.
위에서 언급한 단순한 큐브를 Rendering 한다면 큰 체감이 되지 않을 것입니다.
그러나 OBJ 파일들은 우리의 생각보다 강한 녀석들이 많습니다.
이 OBJ 파일은 라인은 48만 줄에 가깝고 f 정보들을 보면 정점도 어마어마하게 많은 파일입니다.
이러한 파일들을 Disk에서 읽어서 하나하나씩 읽다 보면 시간 소모가 어마어마합니다.
그렇다면 시간을 비교해 보도록 합시다.
OBJ : 13초
Binary : 3초
무려 10초나 차이가 납니다.
6. OBJ파일 읽어 오는게 Binary보다 느린 이유
1. 파일 크기 차이
- Obj파일은 사람이 읽을 수 있도록 데이터를 문자로 저장합니다.
예를 들어, 객체의 정점이나 면 정보가 텍스트로 저장되면
각 숫자나 값에 대해 추가적인 문자와 공백이 포함되며, 파일 크기가 커질 수 있습니다.
2. 파싱 및 변환 비용 차이
- 파일 내용을 문자열로 읽고, 그 후에 이를 정수, 부동소수점 숫자 등으로 변환해야 합니다.
이 과정은 시간이 걸리고 CPU 자원을 소비합니다.
예를 들어, .obj 파일에서 "1.23 4.56 7.89"라는 텍스트를 읽고 이를 실제 수치로 변환해야 하는 작업이 추가됩니다.
3. 파일 처리 방식
- 텍스트 파일을 읽을 때는 파일을 한 줄씩 읽고 처리하는 과정에서 각 줄을 파싱하고 데이터를 처리하는 데 추가적인 시간이 걸립니다.
또한, 텍스트 파일은 보통 UTF-8이나 ASCII 같은 인코딩 방식을 사용하여 각 문자와 숫자를 처리하는 방식에도 시간이 소요됩니다.
- 바이너리 파일은 데이터가 바로 연속적인 이진 형식으로 존재하므로
파일에서 데이터를 읽어 메모리에 바로 올리는 작업이 훨씬 더 효율적입니다.
인코딩이나 문자열 파싱 과정이 없기 때문에 데이터 읽기 시간이 상대적으로 짧습니다.
4. 디스크 I/O 효율성
- 바이너리 형식은 연속적인 메모리 공간에 데이터를 저장하기 때문에
디스크에서 한 번에 더 많은 양의 데이터를 읽어올 수 있습니다.
즉, 디스크 I/O 효율성이 더 높습니다.
텍스트 형식은 텍스트를 처리하는 과정에서 불필요한 연산을 추가로 요구할 수 있고
파일을 읽는 데 걸리는 시간도 늘어날 수 있습니다.
7. OBJ파일 Parsing로직
아래와 같은 로직으로 진행됩니다.
BIN 파일의 존재 여부를 확인하고 있으면 BIN을 로드하고
없으면 OBJ를 로드한 뒤 BIN으로 만들어줍니다.
소스 코드는 위에 올린 소스 코드가 BIN Save까지 구현된 버전이므로 참고 바랍니다.
그러나!! 이것에는 아주 큰 문제가 있습니다.
만약에…. OBJ 파일의 형태가 바뀌게 되어도
BIN 파일을 LOAD 하기 때문입니다.
왜냐하면 이 로직은 OBJ 파일의 변화를 감지하지 않고
단순히 BIN 파일의 여부만 확인하고 LOAD 하기 때문입니다.
그러므로 이 로직에서 OBJ 파일이 바뀌었을 경우에
강제로 BIN 파일을 지워서 LOAD 해 줘야 하는 문제가 있습니다.
저는 이 문제를 아래와 같은 로직으로 해결하였습니다.
바로 수정시간을 비교해서 OBJ 파일이 BIN 파일보다 업데이트 시간이 최근일 경우엔
BIN 파일을 부르지 않도록 구현할 것입니다.
auto binTime = std::filesystem::last_write_time(BinaryPathName + BinaryFileName);
auto objTime = std::filesystem::last_write_time(InPath + "/" + InFileName);
bool IsOBJUpdated = binTime <= objTime;
///If binary file is open read that;
if (!IsOBJUpdated&&BinFile.is_open())
{
LoadObjFromBinary(BinaryPathName + BinaryFileName, OutObjInfo);
return true;
}
물론 좀더 치밀한 설계가 필요하긴 하지만 이렇게 구현하여도 해결 가능합니다.
마지막으로 Render부분 보여드리고 글 마무리 하겠습니다.
여기서 Section이란것은 TArray로 되어있고
Matarial별로 Mapping되어 있는 정점 정보들이 들어 있습니다.
즉 Matarial별로 따로 렌더링을 해주는 것이지요
OBJ파일을 읽어와서 저장할 때 다중 Mesh를 고려해서 저장했기에
Rendering부분도 그림처럼 바뀐 것입니다.