SSISO Community

시소당

obj viewer 구현 및 정리

obj viewer 구현
구현한 프로그램 동작 예

obj loader로 파일을 불러온 뒤 텍스쳐(마테리얼?) 여러장을 Modern OpenGL로 출력하는 깔끔한 예제가 없어서 만들어 봤습니다. 구현하면서 공부한 내용들을 정리합니다.

전체 소스 코드는 아래 GitHub저장소에서 받을 수 있습니다.
Win10, VS2017에서 따로 설치하지 않고 바로 돌려볼 수 있습니다.
https://github.com/hyunjun529/WIN_VS_GL_Setups/tree/master/6_obj_viewer

쉐이더(Shader)와 VAO, VBO를 효율적으로 사용하는 방법과 관심사의 분리(SoC)를 최대한 고려했습니다. 저장소에 올린 예제 코드 중 이벤트 처리와 파일 입출력 부분은 더 손댔다가 나중에 알아보기 힘들어질 것 같고 양적인 작업이라 미뤄뒀습니다.

MMD의 .pmx 파일에서 .obj 파일로 변경

MMD는 MikuMikuDance의 약어입니다. 유튜브에 찾아보면 많습니다.
http://www.geocities.jp/higuchuu4/index.htm

MMD에서 캐릭터 모델링에 .pmx 파일을 사용합니다.
나중에 다시 살펴보겠지만 pmx 형식은 캐릭터 모델과 강체, 관절을 포함하고 있습니다.
https://gist.github.com/felixjones/f8a06bd48f9da9a4539f

버츄얼 유튜버 키즈나 아이로 테스트해보고 싶습니다.
https://kizunaai.com/download-page/

Blender에 pmx 파일을 불러오기 위해선 아래 글을 참고합니다.
http://mmdguide.tistory.com/698

Blender mmd_tools 최신 버전은 여기서 받을 수 있습니다.
https://github.com/sugiany/blender_mmd_tools


위 과정을 통해서 블랜더에서 불러올 수 있게 됬습니다.
이걸 그대로 obj 파일로 추출(Import)해보면...


윈도우 10의 3D 그림판이나 뷰어가 있으니 그걸로 열어볼 수 있습니다.
이렇게 하면 위와 같이 좀 괴상하게 나옵니다.

.pmx 파일은 블랜더에서 위와 같이 보입니다,
joints는 관절 부분, rigidbodies는 강체를 나타냅니다.
obj로 바로 추출할 경우 강체(rigid body)가 시각화되어 같이 출력됩니다.

관절과 강체를 제거하고 다시 추출하면 위와 같이 깔끔한 obj 파일을 얻을 수 있습니다.

그리고 obj 파일을 생성하면 mtl 파일과 텍스쳐들이 같이 생성되는데 일단 텍스쳐를 obj로 생성된 물체에 입히기 위한 것이라고 생각하고 넘어갑니다.

윈도우10 그림판3D로 열어보면 위와 같이 obj / mtl 파일이 생성됬음을 확인할 수 있습니다.

이제 이 키즈나 아이의 obj 파일을 직접 코딩한 프로그램으로 출력해봅니다.

Wavefront .obj / .mtl 파일 분석

obj는 object, mtl은 material의 약어입니다.

.obj 파일의 포맷
http://paulbourke.net/dataformats/obj/

.mtl 파일의 포맷
http://paulbourke.net/dataformats/mtl/


위 두 포맷을 설명하는 간단한 캡슐 예제가 같이 있습니다.
http://paulbourke.net/dataformats/obj/minobj.html

이런 표준을 읽으려면 각 단어가 무엇을 의미하는지 정확히 파악하는게 중요합니다.
https://en.wikipedia.org/wiki/3D_computer_graphics

여기서 키즈나 아이 모델과 캡슐에서 사용되는 statement call 들을 위주로 살펴봅니다.

.obj 관련

mtllib (filename1 filename2 . . .)
mtl + lib의 약어
mtl 파일을 불러오는데 사용한다.
이후 usemtl (name) 형식으로 f와 함께 사용한다.
버텍스 표면의 마테리얼(material)과 텍스쳐(Texture)를 정의할 때 사용한다.
마테리얼은 버텍스로 표현된 물체 표면에 빛이 부딪힐 때 처리하는 방법을 표시한다.
텍스쳐는 마테리얼의 색상이나 반사율(albedo) 표시하거나 bump 또는 normal map의 표면을 제공하는데 사용한다.

o (object_name)
object의 약어.
default도 아니고 자주 사용되는 문법도 아니지만 Blender로 obj파일을 생성하면 해당 mesh object 이름을 명명할 때 사용되기도 한다.

v (x y z w)
vertex의 약어
버텍스 각 정점의 좌표.
이 글의 예제에선 x y z 만 사용한다.
w를 지정하지 않은 경우 default 1.0으로 지정된다.

vt (u v w)
vertex texture의 약어
texture vertex의 좌표를 나타낸다.
u v w는 각각 다음과 같은 의미를 갖고 있다.
u 텍스쳐의 평행 방향(horizontal direction)
v 텍스쳐의 수직 방향(vertical direction), 기본 값은 0.
w 텍스쳐의 깊이(depth of the texture), 범위는 0-1.
깊이는 이 예제에서 사용되지 않았다.

vn (i , j, k)
vertex nomals의 약어
좌표가 아니라 3차원 벡터의 형식(i, j, k)으로 표현된다.
Vertex Normal에 대한 개념은 위키피디아에 잘 설명되어있다.
https://en.wikipedia.org/wiki/Normal_mapping

usemtl (material_name)
use + mtl의 약어
이제부터 나오는 f는 해당 mtl 객체를 사용한다고 명시하는 용도다.

f (v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3 . . .)
face의 약어
v1/vt1/vn1에서 1은 각 버텍스의 레퍼런스(reference) 번호인데, 이 번호는 각 v, vt, vn이 선언된 순서에 따른다.
f는 표면에 텍스쳐를 어떻게 입혀야 하는가 좌표를 담고 있다. 자세한 원리는 .mtl에서 다룬다.
f의 arg1, 2, 3은 vertex indices를 의미한다.

그리고 counterclockwise라는 용어가 등장하는데 이는 역시계방향을 의미한다.

참고) 왼손/오른손 좌표계, 시계/반시계 방향, xyz/xzy 와 신난다!

capsule은 f의 v1/vt1/vn1이 모두 동일한 숫자를 사용한다.
하지만 키즈나아이의 경우 다른 숫자를 사용하기도 한다.
키즈나아이의 obj 파일에서 v, vt, vn의 reference의 수는 다음과 같다.
v 5 - 47555 = 47550
vt 47556 - 95106 = 47550
vn 95107 - 142349 = 47242
vn의 경우 obj 파일의 용량을 줄이기 위해서 중복되는 벡터를 생략한 결과 수가 줄어든 것이다.
v와 vt가 다른건 나와 비슷한 의문을 가진 사람이 StackOverflow에 있었다.
vertex와 vertex texture의 reference call 순서가 동일할 필요가 없기 때문이다.
https://stackoverflow.com/questions/29867926/why-does-the-number-of-vt-and-v-elements-in-a-blender-obj-file-differ

.mtl 관련

newmtl
new + mtl의 약어,
새 마테리얼을 정의한다.

Ns (exponent)
specular의 초점(exponent)를 나타낸다.

Ka (r g b)
ambient를 나타낸다.
여기서 접두어 K는 RGB로 이뤄진 값을 의미한다.
mtl 파일은 RGB를 0-1의 실수로 정의한다.

Kd (r g b)
diffuse를 나타낸다.

Ks (r g b)
specular를 나타낸다.

Ke (r g b)
coeficient를 나타낸다.
주의) 1995년 표준에는 적혀있지 않는 이후 추가된 feature다.
http://paulbourke.net/dataformats/mtl/

Ni (optical_density)
광학적 밀도, 굴절(refraction)을 의미하기도 한다.

d (factor)
해당 물체의 흩어짐 정도(dissolve)를 표현한다.

illum
illumination model, 마테리얼에 적용될 조명 모델을 결정한다.
 0  Color on and Ambient off
 1  Color on and Ambient on
 2  Highlight on
 3  Reflection on and Ray trace on
 4  Transparency: Glass on
   Reflection: Ray trace on
 5  Reflection: Fresnel on and Ray trace on
 6  Transparency: Refraction on
   Reflection: Fresnel off and Ray trace on
 7  Transparency: Refraction on
   Reflection: Fresnel on and Ray trace on
 8  Reflection on and Ray trace off
 9  Transparency: Glass on
   Reflection: Ray trace off
 10  Casts shadows onto invisible surfaces

map_kd (-options args filename)
color texture file이나 color procedural texture file을 나타낸다.
이미지 파일이라고 생각하면 편하다.
이 파일은 렌더링 될 때 kd 값을 곱해서 사용한다.

map_d (-options args filename)
scalar texture file, scalar procedural texture file을 나타낸다.
이 파일은 렌더링 될 때 d 값을 곱해서 사용한다.

Tr
투명도(Transparency)이긴한데,
d의 역할과 중복된다.
해당 이슈는 다른 GitHub 저장소 이슈에서도 발생했었다.
https://github.com/syoyo/tinyobjloader/issues/43

obj loader 분석

obj, mtl 파일은 text/plain로 저장됩니다. 이런 파일을 읽기 위해서 loader는 사실상 parser와 비슷하게 구현됩니다. parser는 구문 분석인데.. PL이나 파싱을 검색해보는게 빠릅니다.

obj loader로는 tinyobjloader를 썼습니다. 이런 라이브러리를 선택할 때는 GtiHub에 star가 많은 순서로 정하는데, 일단 쓴 사람이 많으면 버그가 적고 예제가 많아서 좋습니다.

loader가 해야되는 가장 중요한 작업은 obj에서 f로 indicies를 재구성하는 작업입니다.
https://github.com/syoyo/tinyobjloader#usage

tinyobjloader는 vertex data를 attrib로, material을 material 클래스로 만들어줍니다.
그리고 각 obj의 o단위로 shape를 구성합니다. 사용 예제는 아래 코드를 참고합니다.
https://github.com/syoyo/tinyobjloader/blob/master/examples/viewer/viewer.cc

텍스쳐에 쓰이는 이미지 파일은 stb lib 중 stbi로 불러옵니다.
https://github.com/nothings/stb


DrawObject.h

DrawObjec를 위와 같이 구현합니다.
OpenGL Buffer에 넣기 위한 데이터들을 직접 들고 있습니다.
나중에 DrawObject는 VAO로 사용하며 VBO에 numTriangles를 통째로 넣으며
Texture, Material을 적용하는 단위를 SubMesh로 정의해서 순차적으로 glDrawArray합니다.
이에 대해서 자세한 설명은 뒤에서 다시하고 obj 파일을 tinyobjloader를 통해 DrawObject로 넣기 위한 방법을 살펴봅니다.


위는 tinyobjloader를 통해서 obj, mtl, texture image 파일을 읽고 저장하는 설명을 위한 이미지 입니다.
1. obj file을 tinyobjloader에 넣습니다. shape, attrib, material이 이에 맞춰 생성됩니다.
2. obj file에서 mtl 파일을 자동으로 읽습니다. mtl이 없을 경우 예외처리가 필요합니다.
3. shape 단위로 attrib에서 Vertex Position, UV, Normal을 Buffer 하나에 저장합니다.
4. Texture/Material 정보를 저장합니다. Vertex Buffer와는 별개로 저장합니다.
5. obj의 f가 어느 usemtl에 속했는지 체크한 후, 이 단위로 SubMesh를 저장합니다.
6. 이미지 파일은 파일 입출력 작업이니 경로만 갖고 몰아서 처리하는 편이 빠릅니다.

obj파일을 읽는 부분의 코드는 아래 링크에 있습니다.
https://github.com/hyunjun529/WIN_VS_GL_Setups/blob/master/6_obj_viewer/Engine/render/OBJLoader.h#L27

구현 개요

위와 같이 솔루션을 구성합니다.
Engine은 동적 라이브러리(lib)으로 만들어서 사용합니다.
실제 exe파일을 생성하는 프로젝트는 SingleWindowScene입니다.

Engine/component는 크게 input, physics, graphics로 나눠서 메인 루프를 순차적으로 처리하고 메시징할 때 각 컴포넌트가 서로의 객체를 참조하는 방식을 사용합니다. 컴포넌트 묶음의 포인터는 Scene이 관리합니다.

Engine/render는 OpenGL, GLFW 등과 관련있는 기능들 입니다.

SceneViewer는 예전 블로그 글에 설명했습니다.
https://3dshovel.blogspot.kr/2018/02/opengl.html

SingleWindowScene은 Scene 하나만 띄워서
이 프로그램에서 Scene은 여러 Componenet를 갖고 있는 클래스입니다.

입력과 렌더링을 분리하자

Input Component와 Graphics(Render) Component를 분리한 구조는 여러가지 이점을 가질 수 있습니다. 특히 기능을 조금씩 구현해나갈 때 유용합니다.

막상 적으려니 코딩하는 팁 밖에 없습니다.
1. 처음에 구조를 잘 잡으면 나중에 편합니다.
2. 여러 기능을 동시에 테스트 하지 맙시다.
2. Vertex Position이나 glEnable을 테스트할 때 color를 position으로 비례시키면 편합니다.

자세한 작업 과정은 GitHub 저장소의 Commit Log로 확인할 수 있습니다.
Git을 사용할 때 Commit은 기능 단위로, 반드시 빌드가 가능하도록 하면 좋습니다.

ImGui로 디버깅


ImGui를 사용하면 console이나 GLFW callback 등을 사용하지 않고 더 손쉽게, 실시간으로 테스트할 수 있습니다. 그리고 사용 또한 직접 콜백을 구현하는 것 보다 더 간단합니다.
https://github.com/ocornut/imgui

이 프로젝트에선 Camera와 ImGui Component를 Input Component로 만들어서 사용합니다. 이 경우 각 컴포넌트가 서로에 대해서 의존성을 갖지만 이것까지 분리하려고 하면 ECS라는 Entity-Components-System을 구현해야합니다.
https://en.wikipedia.org/wiki/Entity%E2%80%93component%E2%80%93system

OpenGL 바인딩 순서(ShaderProgram, VAO/VBO Buffer)

이 부분의 코드는 아래 링크에 있습니다.
https://github.com/hyunjun529/WIN_VS_GL_Setups/blob/master/6_obj_viewer/Engine/component/RenderComponenet/OBJRenderComponent.h#L27

VAO와 VBO를 언제 어떻게 쓰는지에 대해서 간략히 설명합니다.
VBO(VertexBufferObject)는 직접 Buffer를 bind합니다.
그리고 VAO(VertexArrayObject)는 bind한 VBO들의 현재 상태를 저장합니다.
이로써 여러 객체를 그릴 때 VAO만 바꿔주면 VBO 집합을 다시 Bind할 필요가 없어집니다.

DrawObject는 obj 파일 하나당 하나씩 생성하며 VAO를 하나씩 할당합니다.
그리고 한 DrawObject에서 VBO에 Position, UV, Normal을 통째로 Buffer에 bind합니다.
glDrawArray를 할 때는 각 SubMesh의 시작 index와 size로 그립니다.
이 때 SubMesh 순서에 따라서 Texture Uniform을 바꿔서 bind합니다.

VAO와 VBO에 크로노스 그룹의 OpenGL의 Vertex 명세는 아래 링크에 있습니다.
https://www.khronos.org/opengl/wiki/Vertex_Specification

하지만 실제 사용법은 아래 스택오버플로 질답이 더 도움됬습니다.
https://stackoverflow.com/questions/23314787/use-of-vertex-array-objects-and-vertex-buffer-objects

Texture는 Fragment Shader의 Uniform으로 넘기는 부분이 Vertex와 다릅니다.
Uniform으로 넘기기 위한 TextureId를 SubMesh의 textureId와 별개로 관리합니다.

엄밀히 따지면 이 예제는 여러 텍스쳐를 사용할 뿐 다중 텍스쳐(Multi Texture)는 아닙니다.
다중 텍스쳐는 TextureUnit으로 glActiveTexture와 관련있습니다. 이 경우는 diffuse, ambient, specular, normal, alpha 등을 각각 따로 unit 별로 저장하고 호출하면 됩니다.

Setup 단계에서 DrawObject의 Texture와 Vertex 정보들을 Buffer에 등록합니다.

Render 단계에서는 각 DrawObjects의 SubMesh 순서에 따라서 glDrawArray를 하면 됩니다.

이를 통해서 VAO, VBO, Vertex Shader, Fragment Shader를 나쁘지 않게 사용하는 obj viewer 예제를 만들 수 있었습니다.

1304 view

4.0 stars