Notice
Recent Posts
Recent Comments
Link
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
Archives
Today
Total
관리 메뉴

게임공장

[Learn OpenGL 번역] 4-3. 모델 불러오기 - Model 본문

OpenGL

[Learn OpenGL 번역] 4-3. 모델 불러오기 - Model

짱승_ 2018. 7. 27. 10:54

Model

모델 불러오기/Model

  이제 Assimp로 노가다를 할 시간입니다. 그리고 실제 로딩, 변환 코드를 생성할 것입니다. 이 강좌의 목표는 전체적인 모델(여러 mesh들을 가지고 있는)을 나타내는 또 다른 클래스를 생성하는 것입니다. 나무로 된 발코니, 타워, 수영장을 가지고 있는 집은 여전히 하나의 모델로 로드될 수 있습니다. 우리는 Assimp를 통해 모델을 로드하고 이 것을 이전 강좌에서 생성한 여러 Mesh 객체들로 변환할 것입니다.


  더이상 고민하지 않고 여러분에게 Model 클래스의 구조를 보여드리도록 하겠습니다.


class Model 
{
    public:
        /*  함수   */
        Model(char *path)
        {
            loadModel(path);
        }
        void Draw(Shader shader);	
    private:
        /*  Model 데이터  */
        vector<Mesh> meshes;
        string directory;
        /*  함수   */
        void loadModel(string path);
        void processNode(aiNode *node, const aiScene *scene);
        Mesh processMesh(aiMesh *mesh, const aiScene *scene);
        vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, 
                                             string typeName);
};

  이 Model 클래스는 Mesh 객체들의 vector를 가지고 있고 생성자에서 파일의 위치를 요구합니다. 그런 다음 loadModel 함수를 생성자에서 호출하여 파일을 불러옵니다. private 함수들은 Assimp의 import 루틴의 일부분을 처리합니다. 우리는 곧 이것들을 다룰 것입니다. 또한 우리는 파일 경로의 디렉터리를 저장합니다. 나중에 텍스처를 로드할 때 필요하기 때문입니다.


  Draw 함수는 특별한 것은 없고 기본적으로 반복문을 이용하여 각 mesh들의 Draw 함수를 호출시킵니다.


void Draw(Shader shader)
{
    for(unsigned int i = 0; i < meshes.size(); i++)
        meshes[i].Draw(shader);
}  

OpenGL에 3D 모델을 불러오기

  모델을 불러오고 그 것을 우리만의 구조로 변환하기 위해서 먼저 Assimp의 적절한 헤더파일을 포함해야합니다.


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

  우리가 호출할 첫 번째 함수는 loadModel 함수이고 이 함수는 생성자로부터 직접적으로 호출됩니다. loadModel 함수 내부에서 우리는 scene 객체라고 불리는 Assimp의 데이터 구조에 모델을 불러오기 위해 Assimp를 사용합니다. 모델 불러오기의 첫 번째 강좌에서 이 객체가 Assimp 데이터 인터페이스의 루트 객체라고 설명했던 것을 기억할 것입니다. scene 객체를 가지게되면 불러온 모델로부터 우리가 원하는 모든 데이터를 얻을 수 있습니다.


  Assimp의 대단한 점은 모든 각기 다른 파일 포멧들을 불러오는 것에 대한 기술적인 상세사항들을 깔끔하게 추상화했다는 점입니다.


Assimp::Importer importer;
const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs); 

  먼저 Assimp 네임스페이스의 실제 Importer 객체를 선언합니다. 그런 다음 이 객체의 ReadFile 함수를 호출합니다. 이 함수는 파일의 경로를 요구하고 두 번째 파라미터로 여러 post-processing(전처리) 옵션들을 받습니다. Assimp는 간단히 파일을 불러오는 것 외에도 불러온 데이터에 추가적인 계산/연산을 하는 여러 옵션들을 지정할 수 있도록 해줍니다. aiProcess-Triangulate 를 설정함으로써 Assimp에게 모델이 삼각형으로만 이루어지지 않았다면 모델의 모든 primitive 도형들을 삼각형으로 변환하라고 말해줍니다. aiProcess-FlipUVs 는 텍스처 좌표를 y 축으로 뒤집어줍니다(텍스처 강좌에서 OpenGL에서 대부분의 이미지들은 y 축을 중심으로 거꾸로 된다는 것을 기억할 것입니다. 이 문제를 전처리 옵션으로 간단히 해결할 수 있습니다). 다음은 약간의 유용한 다른 옵션들입니다.


  • aiProcess_GenNormals : 모델이 법선 벡터들을 가지고 있지 않다면 각 vertex에 대한 법선을 실제로 생성합니다.
  • aiProcess_SplitLargeMeshes : 큰 mesh들을 여러개의 작은 서브 mesh들로 나눕니다. 렌더링이 허용된 vertex 수의 최댓값을 가지고 있을 때 유용하고 오직 작은 mesh들만 처리할 수 있습니다.
  • aiProcess_OptimizeMeshes : 반대로 여러 mesh들을 하나의 큰 mesh로 합칩니다. 최적화를 위해 드로잉 호출을 줄일 수 있습니다.

&nbps; Assimp는 많은 훌륭한 전처리 지시어들을 제공하고 여기에서 모두 찾을 수 있습니다. 실제로 Assimp를 통해 모델을 불러오는 것은 (보시다시피) 놀라울정도로 쉽습니다. 어려운 작업은 반환된 scene 객체를 사용하여 불러온 데이터를 Mesh 객체들의 배열로 변환하는 것입니다.


  완성된 loadModel 함수는 다음과 같습니다.


void loadModel(string path)
{
    Assimp::Importer import;
    const aiScene *scene = import.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);	
	
    if(!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode) 
    {
        cout << "ERROR::ASSIMP::" << import.GetErrorString() << endl;
        return;
    }
    directory = path.substr(0, path.find_last_of('/'));

    processNode(scene->mRootNode, scene);
}  

  모델을 불러온 후에 scene과 scene의 루트 노드가 null이 아닌지 확인하고 데이터가 불완전하다는 플래그가 세워져있는지 확인합니다. 이러한 에러 조건 중 어떠한 것이라도 만족한다면 importer의 GetErrorString 함수를 통해 에러를 출력하고 리턴합니다. 또한 주어진 파일 경로의 디렉터리 경로를 얻습니다.


  잘못된게 없다면 scene의 노드들을 처리하기 위해 첫 번째 노드(루트 노드)를 재귀적으로 동작하는 processNode 함수로 전달합니다. 각 노드는 (아마도) 자식들을 가지고 있을 것이기 때문에 먼저 노드를 처리하고 그런 다음 계속해서 이 노드의 모든 자식들을 처리합니다. 이는 재귀적인 구조에 적합하므로 재귀적인 함수를 정의할 것입니다. 재귀적 함수는 어떠한 처리를 하고 특정한 조건을 만족할때까지 recursively(재귀적으로) 다른 파라미터로 동일한 함수를 호출합니다. 우리의 경우에 exit condition(종료 조건)은 모든 노드들이 처리되었을 때 만족합니다.


  Assimp의 구조로부터 기억할 수 있듯이 각 노드는 mesh index들의 모음을 가지고 있습니다. 각 index는 scene 객체 내부의 특정한 mesh를 가리킵니다. 따라서 이러한 mesh index들을 얻고 각 mesh들을 얻고 그 후 각 mesh들을 처리하고 나서 각 노드의 자식 노드들에게도 이 작업을 반복합니다. processNode 함수의 내용은 다음과 같습니다.


void processNode(aiNode *node, const aiScene *scene)
{
    // 노드의 모든 mesh들을 처리(만약 있다면)
    for(unsigned int i = 0; i < node->mNumMeshes; i++)
    {
        aiMesh *mesh = scene->mMeshes[node->mMeshes[i]]; 
        meshes.push_back(processMesh(mesh, scene));			
    }
    // 그런 다음 각 자식들에게도 동일하게 적용
    for(unsigned int i = 0; i < node->mNumChildren; i++)
    {
        processNode(node->mChildren[i], scene);
    }
}  

  우선 이 노드의 mesh index들을 확인하고 scene의 mMeshes 배열을 인덱싱하여 그에 따른 mesh들을 얻습니다. 반환된 mesh는 processMesh 함수로 전달됩니다. 이 함수는 meshes vector에 저장할 수 있는 Mesh 객체를 리턴합니다.


  모든 mesh들이 처리되면 노드의 모든 자식들에게 반복하고 동일한 processNode 함수를 호출합니다. 노드가 더 이상의 자식을 가지고 있지 않다면 이 함수는 실행을 멈춥니다.

  섬세한 독자들은 아무런 노드들을 처리하지 않고 이러한 복잡한 작업 없이 간단히 scene의 모든 mesh들을 루프문으로 접근할 수 있다는 것을 알아차릴 것입니다. 하지만 우리가 이렇게 하는 이유는 노드를 이런식으로 사용하는 것에 대한 초기 아이디어가 mseh들에 대해 부모-자식 관계를 정의하는 것이라는 점입니다. 이러한 관계들을 재귀적으로 반복함으로 인해 특정 mesh들을 다른 mesh들의 부모로 정의할 수 있습니다.
  이러한 시스템의 사용 사례는 자동차 mesh를 변환할 때 모든 자식(engine mesh, steering wheel mesh, tire mesh)들 또한 변환하는 것을 생각할 수 있습니다. 이러한 시스템은 부모-자식 관계를 사용하여 쉽게 생성할 수 있습니다.

  하지만 지금 당장 우리는 이러한 시스템을 사용하지는 않습니다. 하지만 일반적으로 mesh 데이터들에 관한 추가적인 관리를 위해서 권장됩니다. 이러한 노드 관계는 모델을 만든 아티스트들에 의해 정의됩니다.

  다음 단계는 실제로 Assimp 데이터를 처리하여 마지막 강좌에서 생성했던 Mesh 클래스 형태로 변환하는 것입니다.

Assimp에서 Mesh로

  aiMesh 객체를 우리의 mesh 객체로 변환하는 것은 그렇게 어렵지 않습니다. 우리가 해야할 일은 각 mesh들의 관련된 속성들에 접근하여 우리만의 객체에 저장하는 것입니다. processMesh 함수의 일반적인 구조는 다음과 같습니다.


Mesh processMesh(aiMesh *mesh, const aiScene *scene)
{
    vector<Vertex> vertices;
    vector<unsigned int> indices;
    vector<Texture> textures;

    for(unsigned int i = 0; i < mesh->mNumVertices; i++)
    {
        Vertex vertex;
        // vertex 위치, 법선, 텍스처 좌표를 처리
        ...
        vertices.push_back(vertex);
    }
    // indices 처리
    ...
    // material 처리
    if(mesh->mMaterialIndex >= 0)
    {
        ...
    }

    return Mesh(vertices, indices, textures);
}  

  Mesh를 처리하는 것은 기본적으로 세 부분으로 이루어집니다. 모든 vertex 데이터를 얻고, mesh의 indices를 얻고, 마지막으로 연관된 material 데이터를 얻는 것입니다. 처리된 데이터는 하나의 3 벡터에 저장되고 함수를 호출한 곳으로 이 벡터가 리턴됩니다.


  Vertex 데이터를 얻는 것은 꽤 간단합니다. 각 루프를 돌때마다 vertices 배열에 삽입할 Vertex struct를 정의합니다. mesh에 존재하는 vertex의 갯수(mesh->mNumVertices 로 얻을 수 있습니다)만큼 반복문을 실행합니다. 그런 다음 반복문 내부에서 모든 관련된 데이터로 이 struct를 채워넣어야 합니다. vertex 위치는 다음과 같은 방법으로 수행할 수 있습니다.


glm::vec3 vector; 
vector.x = mesh->mVertices[i].x;
vector.y = mesh->mVertices[i].y;
vector.z = mesh->mVertices[i].z; 
vertex.Position = vector;

  Assimp 데이터를 변환하기 위해 vec3의 자리표시자를 정의한다는 것을 알아두세요. Assimp는 벡터, 행렬, 문자열 등을 자신들만의 데이터 타입으로 관리하고 glm의 데이터타입으로 정상적으로 변환되지 않기 때문에 자리표시자가 필요합니다.

  Assimp는 직관적이지 않은 vertex 위치 배열 mVertices 를 호출합니다.

  법선을 위한 작업도 특별한 것이 없습니다.


vector.x = mesh->mNormals[i].x;
vector.y = mesh->mNormals[i].y;
vector.z = mesh->mNormals[i].z;
vertex.Normal = vector;  

  텍스처 좌표는 거의 비슷하지만 Assimp가 각 vertex마다 최대 8개의 텍스처를 허용합니다. 우리는 하나의 텍스처만 사용하기 때문에 첫 번째 텍스처 좌표만 신경쓰면 됩니다. 또한 mesh가 실제로 텍스처 좌표를 가지고 있는지 확인해야합니다(항상 가지고 있는 것이 아닙니다).


if(mesh->mTextureCoords[0]) // mesh가 텍스처 좌표를 가지고 있는가?
{
    glm::vec2 vec;
    vec.x = mesh->mTextureCoords[0][i].x; 
    vec.y = mesh->mTextureCoords[0][i].y;
    vertex.TexCoords = vec;
}
else
    vertex.TexCoords = glm::vec2(0.0f, 0.0f);  

  vertex struct는 이제 필요한 vertex 속성들로 완전히 채워졌습니다. 이 것을 vertices vector의 끝에 삽입할 수 있습니다. 이 처리는 mesh의 각 vertex 마다 수행됩니다.

Indices

  Assimp의 인터페이스는 각 mesh들이 face의 배열을 가지고 있도록 정의했습니다. 각 face들은 하나의 primitive를 나타냅니다. 우리의 경우에 (aiProcess_Triangulate 옵션 때문에) 항상 삼각형입니다. face는 우리가 어떠한 순서로 vertex들을 그려야하는지를 정의하는 indices를 가지고 있습니다. 그래서 우리는 모든 face에 대해 반복문을 돌려 모든 face의 indices를 indices vector에 저장해야 합니다.


for(unsigned int i = 0; i < mesh->mNumFaces; i++)
{
    aiFace face = mesh->mFaces[i];
    for(unsigned int j = 0; j < face.mNumIndices; j++)
        indices.push_back(face.mIndices[j]);
}  

  바깥의 루프가 끝나면 이제 glDrawElements 함수를 통해 mesh를 그리기 위한 vertex, index 데이터가 완벽히 설정된 것입니다. 하지만 mesh의 material 또한 처리해야 합니다.

Material

  노드와 마찬가지로 mesh는 오직 material 객체의 index만 가지고 있습니다. 실제 mesh의 material을 얻기위해서는 scene의 mMaterial 배열을 인덱싱해야 합니다. mesh의 material index는 mMaterialIndex 속성에 설정되어 있습니다. 이 속성으로 mesh가 실제로 material을 가지고 있는지 아닌지 확인할 수도 있습니다.


if(mesh->mMaterialIndex >= 0)
{
    aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex];
    vector<Texture> diffuseMaps = loadMaterialTextures(material, 
                                        aiTextureType_DIFFUSE, "texture_diffuse");
    textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end());
    vector<Texture> specularMaps = loadMaterialTextures(material, 
                                        aiTextureType_SPECULAR, "texture_specular");
    textures.insert(textures.end(), specularMaps.begin(), specularMaps.end());
}  

  먼저 scene의 mMaterials 배열로부터 aiMaterial 객체를 얻습니다. 그런 다음 mesh의 diffuse, specular 텍스처들을 불러와야 합니다. material 객체는 내부적으로 각 텍스처 타입에 대한 텍스처 위치의 배열을 저장합니다. 여러 텍스처 타입들은 aiTextureType_ 접두사로 분류됩니다. 우리는 loadMaterialTextures 함수의 도움을 받을 것입니다. 이 함수는 material에서 텍스처를 얻습니다. Texture struct의 vector를 리턴하고 우리는 이 것을 model의 textures vector의 끝에 저장합니다.


  loadMaterialTextures 함수는 주어진 텍스처 타입의 모든 텍스처 위치들에 대해 반복문을 돌리고 텍스처 파일의 위치를 얻은 다음 불러오고 텍스처를 생성하며 이 정보를 Vertex struct에 저장합니다. 이는 다음과 같습니다.


vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{
    vector<Texture> textures;
    for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
    {
        aiString str;
        mat->GetTexture(type, i, &str);
        Texture texture;
        texture.id = TextureFromFile(str.C_Str(), directory);
        texture.type = typeName;
        texture.path = str;
        textures.push_back(texture);
    }
    return textures;
}  

  먼저 GetTextureCount 함수를 통해 이 material에 저장된 텍스처의 갯수를 확인합니다. 이 함수는 텍스처 타입 중 하나를 파라미터로 받습니다. 그런 다음 결과를 aiString에 저장하는 GetTexture 함수를 통해 각 텍스처 파일의 위치를 얻습니다. 다음에 TextureFromFile 함수의 도움을 받습니다. 이 함수는 (SOIL과 함께) 텍스처를 불러오고 이 텍스처의 ID를 리턴합니다. 여러분이 이 함수가 어떻게 작성되어있는지 확신할 수 없다면 이 강좌의 마지막에 있는 전체 소스 코드에서 확인하세요.

  우리가 model 파일의 텍스처 파일 경로가 model 파일 경로와 동일하다고 가정했다는 것을 알아두세요. 우리는 간단히 텍스처 위치 문자열과 (loadModel 함수에서 얻은) 디렉터리 문자열을 결합하여 완전한 텍스처 경로를 얻을 수 있습니다(이 것이 GetTexture 함수도 디렉터리 문자열을 필요로 하는 이유입니다).

  어떠한 모델들은 그들의 텍스처 위치에 대해서 여전히 절대 경로를 사용합니다. 이는 일부 기기에서 작동하지 않을 수 있습니다. 이 경우에 텍스처에 대한 로컬 경로를 사용하기 위해 파일을 수작업으로 수정해야 합니다.

  그리고 이것으로 Assimp를 사용하여 모델을 불러오는 작업이 끝났습니다.

최적화

  아직 완전히 끝난것이 아닙니다. 우리가 원하는 큰 최적화(필수적인 것은 아닙니다)가 남아있기 때문이죠. 대부분의 scene들은 여러 mesh들에 여러가지 텍스처들을 재사용합니다. 다시 집을 생각해보세요. 이 집은 벽이 화강암 텍스처로 이루어져있습니다. 이 텍스처는 바닥, 천장, 계단, 테이블에도 적용됩니다. 텍스처를 불러오는 것은 비용이 많이 드는 연산입니다. 현재 우리가 구현한 상태로는 각 mesh 마다 새로운 텍스처가 불러와지고 생성됩니다. 완전히 동일한 텍스처가 여러번 불러와짐에도 불구하고 말이죠. 이는 보틀넥 현상이 쉽게 발생할 수 있습니다.


  그래서 우리는 model 코드에 약간의 변형을 줄 것입니다. 불러온 모든 텍스처들을 전역으로 저장하고 텍스처를 불러오고 싶을때마다 먼저 그 텍스처가 이미 불러와졌는지 확인합니다. 이미 불러온 텍스처라면 이 텍스처를 가져오고 전체적인 텍스처를 불러오는 과정은 생략하여 많은 프로세싱 파워를 절약할 수 있습니다. 이러한 텍스처 비교를 가능하게 하기 위해서 그들의 경로 또한 저장해야합니다.


struct Texture {
    unsigned int id;
    string type;
    string path;  // 다른 텍스처와 비교하기 위해 텍스처의 경로를 저장
};

  그런 다음 모델 클래스의 맨 위에 private 변수로 선언된 또 다른 vector에 불러온 모든 텍스처를 저장합니다.


vector<Texture> textures_loaded; 

  그런 다음 loadMaterialTextures 함수에서 텍스처 경로를 textures_loaded vector에 있는 모든 텍스처의 경로와 비교하여 현재 텍스처 경로가 다른 것들과 같은지를 확인합니다. 같다면 텍스처를 불러오고 생성하는 부분을 생략하고 간단히 texture struct에 존재하는 것을 사용하기만 하면 됩니다. (수정된) 함수는 다음과 같습니다.


vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{
    vector<Texture> textures;
    for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
    {
        aiString str;
        mat->GetTexture(type, i, &str);
        bool skip = false;
        for(unsigned int j = 0; j < textures_loaded.size(); j++)
        {
            if(std::strcmp(textures_loaded[j].path.data(), str.C_Str()) == 0)
            {
                textures.push_back(textures_loaded[j]);
                skip = true; 
                break;
            }
        }
        if(!skip)
        {   // 텍스처가 이미 불러와져있지 않다면 불러옵니다.
            Texture texture;
            texture.id = TextureFromFile(str.C_Str(), directory);
            texture.type = typeName;
            texture.path = str.C_Str();
            textures.push_back(texture);
            textures_loaded.push_back(texture); // 불러온 텍스처를 삽입합니다.
        }
    }
    return textures;
}  

  그리고 이제 우리는 다재다능한 모델 로딩 시스템을 가지고 있습니다. 하지만 오브젝트를 빠르게 로드하기 위한 최적화 또한 필요합니다.

  Assimp의 일부 버전들은 IDE 디버그 버전이나 모드를 사용할 때 model을 꽤 느리게 로드하는 경향이 있습니다. 그러므로 로딩하는 시간이 느리다면 릴리즈 버전으로 테스트를 해보세요.

  최적화된 Model 클래스의 전체 소스 코드는 여기에서 확인할 수 있습니다.

더이상 컨테이너가 아닙니다!

  이제 진짜 아티스트들에 의해 만들어진 회전하는 실제 모델을 사용해봅시다. 많은 돈을 쓰고 싶지 않기 이번엔 Crytek의 게임인 Crysis에서 사용된 nanosuit 모델을 사용할 것입니다(tf3dm.com에서 다운로드할 수 있고 다른 모델들도 사용될 수 있습니다). 이 모델은 .obj 파일로 추출되었습니다. 또한 모델의 diffuse, specular, normal map(이 것은 나중에 다룰 것입니다)을 포함하는 .mtl 파일도 함께 존재합니다. 불러오기 쉽게 (약간 수정된) 모델을 여기에서 다운받을 수 있습니다. 모든 텍스처 파일들과 모델 파일들은 동일한 디렉터리에 있어야한다는 것을 알아두세요.

  이 웹사이트에서 다운로드할 수 있는 버전은 수정된 버전입니다. 각 텍스처 파일의 경로가 상대 경로로 수정된 것입니다. 원본 소스를 통해 다운로드 하면 절대 경로로 되어있을 것입니다.

  이제 코드에서 Model 객체를 선언하고 모델 파일의 위치를 전달합니다. 그런 다음 이 모델은 자동적으로 불러와지고 (에러가 발생하지 않는다면) 게임 루프에서 Draw 함수를 사용하여 오브젝트가 그려져야 합니다. 더 이상의 버퍼 할당과 attribute pointer와 렌더링 명령은 필요 없습니다. 그런 다음 간단한 shader(fragment shader가 오직 오브젝트의 diffuse texture 컬러만 출력하는)들을 생성했다면 결과는 다음과 같을 것입니다.



  전체 소스 코드는 여기에서 확인할 수 있습니다.


  좀 더 창의적이게 만들 수 있습니다. 두 개의 point light와 specular map들을 사용하면 놀라운 결과를 얻을 수 있습니다.



  이는 지금까지 사용해왔던 컨테이너들보다 좀 더 품질 높은 모델임에 분명합니다. Assimp를 사용하여 여러분은 인터넷에 있는 아주 많은 모델들을 로드할 수 있습니다. 무료의 3D 모델들을 여러 파일 포멧으로 다운로드할 수 있는 웹사이트들이 꽤 많이 있습니다. 일부 모델들은 잘 로드되지 않을 수 있습니다. 이 경우 텍스처 경로가 잘못되었거나 Assimp가 불러오지 못하는 파일 포멧으로 추출된 것입니다.



출처 : https://learnopengl.com, Joey de Vries

반응형