본문 바로가기
Opengles 3.0 with Android

Chapter 4. OBJ Loader

by SimonLee 2025. 3. 8.

https://github.com/dlgmlals3/OpenGLES3.0_Example/blob/main/Chapter_4/app/src/main/cpp/Scene/ObjLoader.cpp

 

OpenGLES3.0_Example/Chapter_4/app/src/main/cpp/Scene/ObjLoader.cpp at main · dlgmlals3/OpenGLES3.0_Example

Contribute to dlgmlals3/OpenGLES3.0_Example development by creating an account on GitHub.

github.com

ObjLoader.cpp 참고하시면 됩니다.

OBJ Loader 사용하여 렌더링된 모델들

OBJ Loader 란

OBJ Loader는 Wavefront OBJ 파일을 읽어 들여서 3D 모델 데이터를 프로그램 내에서 활용할 수 있도록 로딩해주는 기능을 의미한다.

최근에는 크로노스에서 표준으로 지정한 glTF 포맷을 사용하지만, 모델링의 원리를 파악하기 위해 OBJ Loader 학습해 보는 것도 나쁘지 않다.

간단 명료하게 핵심만 비교하면 다음과 같다.

OBJ Format VS glTF Format

파일 형식 텍스트 기반 텍스트(JSON) 및 바이너리 지원(.glb)
데이터 기하 정보 중심 (정점, 노멀, UV)만 포함 기하 정보 외에 재질, 뼈대(스킨), 애니메이션 포함
구조 복잡도 단순한 구조로 파싱 쉬움 구조가 복잡하지만 최적화 및 확장성 좋음
재질 표현 기본적인 MTL 파일 연계, 매우 제한적 PBR(물리 기반 렌더링) 지원, 고급 재질 표현 가능
애니메이션 지원하지 않음 스킨 애니메이션, 모핑 지원
압축성능 없음 바이너리(.glb)로 효율적 압축 가능
사용처 간단한 모델 데이터 교환, 빠른 로딩용 게임, AR/VR, 웹 등 최신 환경에 적합
표준화 오래되었으나 비표준적 확장 많음 Khronos Group의 공식 표준 (표준화 강력함)

 

추후에 glTF Format에 대해서 살펴 보기로 하고 OBJ Format 을 살펴 본다.

 

 

 

1) OBJ Format 파일 Parse

OBJ Format

v 정점 (Vertex) 좌표 v 0.1 0.2 0.3
vt 텍스처 좌표 (UV) vt 0.5 1.0
vn 정점 법선 (Vertex Normal) vn 0.0 1.0 0.0
vp 공간상의 정점 (Parameter vertex, 드물게 사용) vp 0.1 0.2 0.3

 

f 면 (정점/텍스처/법선 인덱스) f 1/1/1 2/2/2 3/3/3
l 선 (Line) 정의 l 1 2 3 4
p 점 (Point) 정의 p 1 2 3
  • 정점 / 텍스처 / 법선 모두: f 1/1/1
  • 정점 // 법선: f 1//1
  • 정점 / 텍스처: f 1/1
  • 정점만 : f 1
o 오브젝트 이름(Object) 정의 o Cube
g 그룹 이름(Group) 정의 g FrontFaces
s 스무딩 그룹 설정 (부드럽게 표현) s 1 or s off

s: 숫자 값이 같으면 부드럽게 연결됨.

  • s off 또는 s 0: 날카로운 경계
cstype 곡선 유형 정의 cstype bezier
curv 곡선 정의 curv 0.0 1.0 1 2 3 4
parm 파라미터 정의 parm u 0.0 1.0
deg 곡선 차수 정의 deg 3
surf 곡면 정의 surf 0.0 1.0 0.0 1.0 1 2 3 4
end 자유 곡면 종료 end

 

OBJ 파일을 열어보면 다음과 같다.

버텍스, 노멀,  UV 값의 중복을 막기 위해 f(면) 정보를 활용하여 OBJ 파일을 구성한다.

# cube.obj
#
o cube
#mtllib cube.mtl
 
v -0.500000 -0.500000 0.500000
v 0.500000 -0.500000 0.500000
v -0.500000 0.500000 0.500000
v 0.500000 0.500000 0.500000
v -0.500000 0.500000 -0.500000
v 0.500000 0.500000 -0.500000
v -0.500000 -0.500000 -0.500000
v 0.500000 -0.500000 -0.500000
 
vt 0.000000 0.000000
vt 1.000000 0.000000
vt 0.000000 1.000000
vt 1.000000 1.000000
 
vn 0.000000 0.000000 1.000000
vn 0.000000 1.000000 0.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 -1.000000 0.000000
vn 1.000000 0.000000 0.000000
vn -1.000000 0.000000 0.000000
 
g cube
usemtl cube
s 1
f 1/1/1 2/2/1 3/3/1
f 3/3/1 2/2/1 4/4/1
s 2
f 3/1/2 4/2/2 5/3/2
f 5/3/2 4/2/2 6/4/2
s 3
f 5/4/3 6/3/3 7/2/3
f 7/2/3 6/3/3 8/1/3
s 4
f 7/1/4 8/2/4 1/3/4
f 1/3/4 8/2/4 2/4/4
s 5
f 2/1/5 8/2/5 4/3/5
f 4/3/5 8/2/5 6/4/5
s 6
f 7/1/6 1/2/6 5/3/6
f 5/3/6 1/2/6 3/4/6

2) Interleaved (연속된) 방식으로 재배치 한다.

이후에 렌더링을 하기 위하여 데이터를 재구성하게 되는데,

Interleaved (연속된) 방식으로 재배치 한다.

 

버텍스, Uv, Normal, TangentPlane의 순서로 데이터를 합쳐야 하기 때문에

Vertex Class를 구성하고, 파싱된 데이터를 넣어주게 된다.

class Vertex
{
public:
	//! Store the vertex position's X, Y and X coordinate
	glm::vec3 position;
	//! Store texture UV coordinates
	glm::vec2 uv;
	//! Store the normal X, Y, Z components
	glm::vec3 normal;
	//! Store the tangent's X, Y, Z, W components
	glm::vec4 tangent;
	//! Vertex class constructor
    Vertex()
    {
        position.x  = position.y = position.z = 0.0f;
        uv.x = uv.y = 0.0f;
        normal.x    = normal.y  = normal.z = 0.0f;
        tangent.x   = tangent.y = tangent.z = 0.0f;
    }
};
bool OBJMesh::CreateInterleavedArray()
{
    int faceIdxSize = (int)objMeshModel.vecFaceIndex.size();
        objMeshModel.vertices.resize( faceIdxSize );
        objMeshModel.indices.resize( faceIdxSize );
    
    // Get the total number of indices.
    IndexCount = (int)objMeshModel.indices.size();
    
    //Create the interleaved vertex information for Vertex containing position, uv and normal.
    for(int i = 0; i < faceIdxSize; i++)
    {
        //Position information must be avilable always
        int index = objMeshModel.vecFaceIndex.at(i + 0).vertexIndex;
        objMeshModel.vertices[i].position = objMeshModel.positions.at(index);
        objMeshModel.indices[i] = (GLushort)objMeshModel.vecFaceIndex.at(i).vertexIndex;
        
        // If UV information is avilable.
        if(objMeshModel.uvs.size()){
            index = objMeshModel.vecFaceIndex.at(i).uvIndex;
            objMeshModel.vertices[i].uv = objMeshModel.uvs.at(index);
        }
        
        // If Normal information is avilable.
        if(objMeshModel.normals.size())
        {
            index = objMeshModel.vecFaceIndex.at(i ).normalIndex;
            objMeshModel.vertices[i].normal = objMeshModel.normals.at(index);
        }
    }
}

 

Interleaved 하는 이유는 다음과 같다.

  • GPU는 연속된(interleaved) 정점 데이터(position, uv, normal)를 한 번에 읽는 게 효율적이다.
  • 정점 배열을 GPU에서 빠르게 처리하려면 이 방식이 필수적이다.

연속하게 배치하게 되면 아래처럼 stride, offset을 사용하여 아래처럼 구성할 수 있다.

stride          = (2 * sizeof(vec3) )+ sizeof(vec2) + sizeof(vec4);
offset          = ( GLvoid*) ( sizeof(glm::vec3) + sizeof(vec2) );
offsetTexCoord  = ( GLvoid*) ( sizeof(glm::vec3) );
.....

glEnableVertexAttribArray(VERTEX_POSITION);
glEnableVertexAttribArray(TEX_COORD);
glEnableVertexAttribArray(NORMAL_POSITION);
glVertexAttribPointer(VERTEX_POSITION, 3, GL_FLOAT, GL_FALSE, stride, 0);
glVertexAttribPointer(TEX_COORD, 2, GL_FLOAT, GL_FALSE, stride, offsetTexCoord);
glVertexAttribPointer(NORMAL_POSITION, 3, GL_FLOAT, GL_FALSE, stride, offset);

 

3) Normal Calculation

OBJ format에 노멀이 들어가 있다면, 계산을 할 필요가 없지만,

노멀 데이터가 없는 경우, 정점 데이터로 노멀을 계산할 수 있다.

glm::vec3 a = objMeshModel.positions[index0];
glm::vec3 b = objMeshModel.positions[index1];
glm::vec3 c = objMeshModel.positions[index2];

glm::vec3 faceNormal = glm::cross(b - a, c - a);
faceNormal = glm::normalize(faceNormal);

 

flat normal 의 경우, 세 점중 하나의 faceNormal을 사용하게 되고,

smooth normal 의 경우, 삼각형 렌더링할때마다 겹치는 인덱스에 해당하는 노말을

더해주고, 최종적으로 정규화 후 노멀을 사용하게 된다.

if (flatShading) {
    // Flat shading: 각 정점에 같은 법선 벡터 적용 (면 단위로 고정된 법선 사용)
    objMeshModel.normals[idx0] = faceNormal;
    objMeshModel.normals[idx1] = faceNormal;
    objMeshModel.normals[idx2] = faceNormal;
} else {
    // Smooth shading: 법선을 누적한 후 정규화 (각 정점에서 부드러운 쉐이딩)
    objMeshModel.normals[idx0] += faceNormal;
    objMeshModel.normals[idx1] += faceNormal;
    objMeshModel.normals[idx2] += faceNormal;
}
if (!flatShading) {
    for (auto& normal : objMeshModel.normals) {
        normal = normalize(normal);
    }
}

 

4) Tangent Plane 생성

 

탄젠트 플레인은 각 정점의 표면 위에 존재하는 접선평면이다.
이 평면 위에서 정점에 대한 **Tangent(접선)**과 Binormal(이항선) 벡터를 정의할 수 있다.

탄젠트 공간은 다음의 3축으로 정의:

  • Tangent(접선) 축: 텍스처 가로방향
  • Bitangent (Binormal, 이항선): 세로 방향
  • Normal: 법선 방향

탄젠트 플레인이 필요한 이유는 다음과 같다.

  • Normal Mapping(법선 매핑)을 하기 위해 반드시 필요.
  • Normal map의 픽셀은 탄젠트 공간(Tangent Space)에서 법선을 정의.

즉, 탄젠트 플레인은 정점에서의 로컬 좌표계를 구성한다

 

삼각형마다 위 공식을 적용해 **Tangent(tan)**와 **Bitangent(bitan)**를 계산.

각 정점마다 누적(tan1Accum, tan2Accum)한다.

정점은 여러 삼각형에서 공유되므로, 평균적 Tangent를 얻기 위해 각 정점에서 여러 번 누적된다.

 

 

 

코드

for (int i=0; i<indices.size(); i+=3) {
    idx0 = indices[i];
    idx1 = indices[i+1];
    idx2 = indices[i+2];

    vec3 p0 = positions[idx0];
    vec3 p1 = positions[idx1];
    vec3 p2 = positions[idx2];

    vec2 uv0 = uvs[idx0];
    vec2 uv1 = uvs[idx1];
    vec2 uv2 = uvs[idx2];

    vec3 Q1 = p1 - p0;
    vec3 Q2 = p2 - p0;

    float s1 = uv1.x - uv0.x;
    float t1 = uv1.y - uv0.y;
    float s2 = uv2.x - uv0.x;
    float t2 = uv2.y - uv0.y;

    float r = 1.0f / (s1 * t2 - s2 * t1);

    vec3 tan = (Q1 * t2 - Q2 * t1) * r;
    vec3 bitan = (Q2 * s1 - Q1 * s2) * r;

    tan1Accum[idx0] += tan;
    tan1Accum[idx1] += tan;
    tan1Accum[idx2] += tan;

    tan2Accum[idx0] += bitan;
    tan2Accum[idx1] += bitan;
    tan2Accum[idx2] += bitan;
}

 

계산된 탄젠트가 법선 벡터와 직교(orthogonal)하도록 정규화한다.

탄젠트 좌표계의 방향성(handedness)을 결정하는 w 값을 구한다.

for(int i = 0; i < positions.size(); i++) {
    vec3 n = normals[i];
    vec3 t = tan1Accum[i];

    // Orthogonalize tangent against normal
    vec3 tangent = normalize(t - n * dot(n, t));

    // Calculate handedness (w)
    float w = (dot(cross(n, t), tan2Accum[i]) < 0.0f) ? -1.0f : 1.0f;

    tangents[i] = vec4(tangent, w);
}

 

탄젠트, 바이탄젠트는 텍스처(UV)의 가로세로 방향을 3D공간에서 정확히 표현하기 위한 벡터다.

이 코드가 사용하는 원리와 이유는, 정확한 Normal Mapping 렌더링을 위해
텍스처 좌표와 정점 위치로부터 탄젠트 공간을 계산하여 사용하는 것이다.



참고

https://terathon.com/blog/tangent-space.html

 

Computing Tangent Space Basis Vectors for an Arbitrary Mesh - Eric Lengyel

Computing Tangent Space Basis Vectors for an Arbitrary Mesh Eric Lengyel   •   March 15, 2004 Modern bump mapping (also known as normal mapping) requires that tangent plane basis vectors be calculated for each vertex in a mesh. This article presents

terathon.com