모델
이제 Assimp를 사용하여 실제 로딩 및 변환 코드를 작성해 볼 시간입니다. 이 장의 목표는 모델 전체를 나타내는 새로운 클래스를 만드는 것입니다. 이 클래스는 여러 개의 메시와 여러 개의 텍스처를 포함하는 모델을 표현할 수 있습니다. 예를 들어, 나무 발코니, 탑, 그리고 수영장이 있는 집이라도 하나의 모델로 로드할 수 있습니다. Assimp를 통해 모델을 로드하고 이전 장에서 생성한 여러 개의 메시 객체로 변환할 것입니다.
그럼 바로 Model 클래스의 구조를 보여드리겠습니다.
class Model
{
public:
Model(char *path)
{
loadModel(path);
}
void Draw(Shader &shader);
private:
// model data
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 객체들의 벡터를 포함하며, 생성자에서 파일 위치를 지정해 주어야 합니다. 그러면 생성자에서 호출되는 loadModel 함수를 통해 해당 파일을 바로 불러옵니다. 프라이빗 함수들은 모두 Assimp의 가져오기 루틴의 일부를 처리하도록 설계되었으며, 잠시 후에 자세히 살펴보겠습니다. 또한, 나중에 텍스처를 불러올 때 필요한 파일 경로의 디렉터리도 저장합니다.
Draw 함수는 특별한 것이 없으며 기본적으로 각 메시를 순회하면서 해당 메시의 Draw 함수를 호출합니다.
void Draw(Shader &shader)
{
for(unsigned int i = 0; i < meshes.size(); i++)
meshes[i].Draw(shader);
}
3D 모델을 OpenGL로 가져오기
모델을 가져와서 자체 구조로 변환하려면 먼저 Assimp의 적절한 헤더 파일을 포함해야 합니다.
#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>
우리가 가장 먼저 호출하는 함수는 생성자에서 직접 호출되는 loadModel입니다. loadModel 함수 안에서는 Assimp를 사용하여 모델을 장면 객체(scene object)라는 Assimp 데이터 구조에 로드합니다. 모델 로딩 시리즈의 첫 번째 장에서 설명했듯이, 이 장면 객체는 Assimp 데이터 인터페이스의 최상위 객체입니다. 장면 객체를 얻으면 로드된 모델에서 필요한 모든 데이터에 접근할 수 있습니다.
Assimp의 가장 큰 장점은 다양한 파일 형식을 로드하는 데 필요한 모든 기술적인 세부 사항을 깔끔하게 추상화하여 단 한 줄의 코드로 모든 작업을 처리한다는 점입니다.
Assimp::Importer importer;
const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
먼저 Assimp 네임스페이스에서 Importer 객체를 선언한 다음 ReadFile 함수를 호출합니다. 이 함수는 파일 경로와 두 번째 인수로 여러 후처리(post-processing) 옵션을 기대합니다. Assimp는 가져온 데이터에 대해 추가 계산/연산을 수행하도록 하는 여러 옵션을 지정할 수 있습니다. aiProcess_Triangulate를 설정하면 모델이 (전체적으로) 삼각형으로 구성되어 있지 않은 경우 모델의 모든 기본 도형을 먼저 삼각형으로 변환하도록 Assimp에 지시합니다. aiProcess_FlipUVs는 처리 중에 필요한 경우 텍스처 좌표를 y축을 기준으로 반전합니다(텍스처 챕터에서 OpenGL의 대부분 이미지가 y축을 기준으로 반전되어 있다는 것을 기억하실 것입니다. 이 작은 후처리 옵션은 이를 수정해 줍니다). 몇 가지 유용한 옵션은 다음과 같습니다.
- aiProcess_GenNormals: 모델에 법선 벡터가 없는 경우 각 정점에 대한 법선 벡터를 생성합니다.
- aiProcess_SplitLargeMeshes: 큰 메시를 더 작은 하위 메시로 분할합니다. 렌더링에 허용되는 최대 정점 수에 제한이 있어 더 작은 메시만 처리할 수 있는 경우에 유용합니다.
- aiProcess_OptimizeMeshes: 여러 메쉬를 하나의 더 큰 메쉬로 결합하려고 시도하여 최적화를 위한 드로잉 호출 횟수를 줄입니다.
Assimp는 다양한 후처리 옵션을 제공하며, 모든 옵션은 여기에서 확인할 수 있습니다. Assimp를 통해 모델을 불러오는 과정은 (보시다시피) 놀라울 정도로 간단합니다. 핵심 작업은 반환된 씬 객체를 사용하여 불러온 데이터를 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);
}
모델을 로드한 후, 장면과 장면의 루트 노드가 null이 아닌지 확인하고, 반환된 데이터가 불완전한지 확인하기 위해 해당 플래그 중 하나를 검사합니다. 이러한 오류 조건 중 하나라도 충족되면, 임포터의 GetErrorString 함수에서 가져온 오류 메시지를 보고하고 반환합니다. 또한, 주어진 파일 경로의 디렉터리 경로도 함께 가져옵니다.
만약 아무런 문제가 발생하지 않았다면, 우리는 장면의 모든 노드를 처리하려고 합니다. 첫 번째 노드(루트 노드)를 재귀 함수인 processNode에 전달합니다. 각 노드는 (있을 경우) 자식 노드들을 포함하고 있기 때문에, 먼저 해당 노드를 처리한 다음, 그 노드의 모든 자식 노드들을 순차적으로 처리하는 방식이 필요합니다. 이는 재귀적인 구조에 적합하므로, 재귀 함수를 정의할 것입니다. 재귀 함수는 특정 조건이 충족될 때까지 어떤 처리를 수행하고, 다른 매개변수를 사용하여 동일한 함수를 재귀적으로 호출하는 함수입니다. 우리의 경우, 모든 노드가 처리되면 종료 조건이 충족됩니다.
Assimp의 구조에서 기억하시겠지만, 각 노드는 씬 객체 내의 특정 메시를 가리키는 메시 인덱스 집합을 포함합니다. 따라서 우리는 이러한 메시 인덱스를 가져와 각 메시를 추출하고, 각 메시를 처리한 다음, 이 모든 과정을 해당 노드의 자식 노드들에 대해서도 반복해야 합니다. processNode 함수의 내용은 아래와 같습니다.
void processNode(aiNode *node, const aiScene *scene)
{
// 노드의 메시(있는 경우)를 모두 처리합니다.
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);
}
}
먼저 각 노드의 메시 인덱스를 확인하고 장면의 mMeshes 배열에서 해당 메시를 인덱싱하여 가져옵니다. 반환된 메시는 processMesh 함수에 전달되고, 이 함수는 meshes 리스트/벡터에 저장할 수 있는 Mesh 객체를 반환합니다.
모든 메시 처리가 완료되면 해당 노드의 모든 자식 노드를 순회하며 각 자식 노드에 대해 동일한 processNode 함수를 호출합니다. 노드에 더 이상 자식 노드가 없으면 재귀 호출이 종료됩니다.
주의 깊게 읽으신 분이라면 노드를 처리하는 과정을 생략하고 인덱스를 이용한 복잡한 작업 없이 장면의 모든 메시를 직접 순회할 수도 있다는 것을 눈치채셨을 겁니다. 이렇게 하는 이유는 노드를 사용하는 본래 목적이 메시 간의 부모-자식 관계를 정의하는 것이기 때문입니다. 이러한 관계를 재귀적으로 순회함으로써 특정 메시를 다른 메시의 부모로 지정할 수 있습니다.
이러한 시스템의 예시 사용 사례는 자동차 메쉬를 이동시키면서 엔진 메쉬, 스티어링 휠 메쉬, 타이어 메쉬와 같은 모든 자식 메쉬도 함께 이동시키려는 경우입니다. 이러한 시스템은 부모-자식 관계를 이용하여 쉽게 구현할 수 있습니다.
하지만 현재 저희는 그러한 시스템을 사용하고 있지 않습니다. 그러나 메시 데이터에 대한 더 세밀한 제어가 필요할 때는 일반적으로 이러한 접근 방식을 고수하는 것이 좋습니다. 이러한 노드와 같은 관계는 결국 모델을 만든 아티스트가 정의한 것이기 때문입니다.
다음 단계는 Assimp의 데이터를 이전 장에서 다룬 Mesh 클래스로 처리하는 것입니다.
Assimp에서 Mesh로
aiMesh 객체를 우리의 메쉬 객체로 변환하는 것은 그다지 어렵지 않습니다. 메쉬의 관련 속성에 접근하여 자체 객체에 저장하기만 하면 됩니다. 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;
// 정점 위치, 법선 벡터 및 텍스처 좌표를 처리합니다.
[...]
vertices.push_back(vertex);
}
// 인덱스 처리
[...]
// 머티리얼 처리
if(mesh->mMaterialIndex >= 0)
{
[...]
}
return Mesh(vertices, indices, textures);
}
메쉬 처리는 세 단계로 이루어집니다. 먼저 모든 정점 데이터를 추출하고, 메쉬의 인덱스를 추출한 다음, 관련 머티리얼 데이터를 추출합니다. 처리된 데이터는 이 세 벡터 중 하나에 저장되며, 이 벡터들을 이용하여 메쉬가 생성되어 함수 호출자에게 반환됩니다.
정점 데이터를 가져오는 것은 매우 간단합니다. 각 루프 반복 후 정점 배열에 추가하는 Vertex 구조체를 정의합니다. 메쉬에 존재하는 모든 정점(mesh->mNumVertices를 통해 가져옴)에 대해 루프를 실행합니다. 각 반복에서는 이 구조체에 모든 관련 데이터를 채워야 합니다. 정점 위치의 경우 다음과 같이 수행합니다.
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는 정점 위치 배열을 mVertices라고 부르는데, 이는 그다지 직관적인 이름은 아닙니다.
일반적인 절차이므로 이제 다들 익숙하시겠죠.
vector.x = mesh->mNormals[i].x;
vector.y = mesh->mNormals[i].y;
vector.z = mesh->mNormals[i].z;
vertex.Normal = vector;
텍스처 좌표는 대략적으로 동일하지만, Assimp는 모델이 정점당 최대 8개의 서로 다른 텍스처 좌표를 가질 수 있도록 허용합니다. 우리는 8개를 모두 사용하지 않고 첫 번째 텍스처 좌표 세트만 필요로 합니다. 또한 메시가 실제로 텍스처 좌표를 포함하고 있는지 확인해야 합니다(항상 포함되어 있는 것은 아닐 수 있습니다).
if(mesh->mTextureCoords[0]) // 메시에 텍스처 좌표가 포함되어 있습니까?
{
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);
이제 정점 구조체는 필요한 정점 속성으로 완전히 채워졌으므로 반복이 끝날 때 정점 벡터의 맨 뒤에 추가할 수 있습니다. 이 과정은 메시의 각 정점에 대해 반복됩니다.
인덱스
Assimp의 인터페이스는 각 메시를 면 배열로 정의하며, 각 면은 하나의 기본 도형을 나타냅니다. 이 경우 (aiProcess_Triangulate 옵션 덕분에) 항상 삼각형이 됩니다. 면에는 해당 기본 도형을 그리는 데 필요한 정점의 인덱스가 순서대로 저장됩니다. 따라서 모든 면을 순회하면서 각 면의 인덱스를 인덱스 벡터에 저장하면 됩니다.
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를 통해 메시를 그리는 데 필요한 모든 정점 및 인덱스 데이터를 얻게 됩니다. 하지만 논의를 마무리하고 메시에 세부적인 내용을 추가하기 위해 메시의 머티리얼도 처리해 보겠습니다.
머티리얼
노드와 마찬가지로 메시는 머티리얼 객체에 대한 인덱스만 포함합니다. 메시의 머티리얼을 가져오려면 장면의 mMaterials 배열을 인덱싱해야 합니다. 메시의 머티리얼 인덱스는 mMaterialIndex 속성에 설정되어 있으며, 이 속성을 쿼리하여 메시가 머티리얼을 포함하는지 여부를 확인할 수도 있습니다.
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());
}
먼저 장면의 mMaterials 배열에서 aiMaterial 객체를 가져옵니다. 그런 다음 메시의 확산 및 반사 텍스처를 로드하려고 합니다. 머티리얼 객체는 내부적으로 각 텍스처 유형에 대한 텍스처 위치 배열을 저장합니다. 서로 다른 텍스처 유형은 모두 aiTextureType_ 접두사로 시작합니다. loadMaterialTextures라는 헬퍼 함수를 사용하여 머티리얼에서 텍스처를 가져오고 로드하고 초기화합니다. 이 함수는 Texture 구조체 벡터를 반환하며, 이 벡터를 모델의 텍스처 벡터 끝에 저장합니다.
loadMaterialTextures 함수는 주어진 텍스처 유형의 모든 텍스처 위치를 순회하며, 텍스처 파일 위치를 가져온 다음 텍스처를 로드하고 생성하여 해당 정보를 Vertex 구조체에 저장합니다. 함수의 형태는 다음과 같습니다.
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 함수를 통해 머티리얼에 저장된 텍스처의 개수를 확인합니다. GetTexture 함수를 사용하여 각 텍스처의 파일 위치를 가져오고, 결과를 aiString에 저장합니다. 그런 다음, stb_image.h 헤더 파일을 사용하여 텍스처를 불러오고 텍스처 ID를 반환하는 TextureFromFile이라는 헬퍼 함수를 사용합니다. 이러한 함수의 작성 방법을 잘 모르겠다면, 마지막에 있는 전체 코드 목록을 참조하십시오.
모델 파일의 텍스처 파일 경로는 실제 모델 객체가 위치한 디렉터리, 즉 모델 객체 자체의 디렉터리에 있다고 가정합니다. 따라서 텍스처 위치 문자열과 이전에 (loadModel 함수에서) 가져온 디렉터리 문자열을 연결하여 전체 텍스처 경로를 얻을 수 있습니다 (GetTexture 함수에도 디렉터리 문자열이 필요한 이유입니다).
인터넷에서 찾은 일부 모델은 텍스처 위치에 절대 경로를 사용하는데, 이는 모든 컴퓨터에서 제대로 작동하지 않을 수 있습니다. 그런 경우에는 (가능하다면) 텍스처에 로컬 경로를 사용하도록 파일을 수동으로 편집하는 것이 좋습니다.
Assimp를 이용해 모델을 불러오는 방법은 이게 전부입니다.
최적화
아직 완전히 끝난 것은 아닙니다. 큰 최적화 작업이 하나 더 남아 있기 때문입니다. 대부분의 장면에서는 여러 메시에 동일한 텍스처를 재사용합니다. 예를 들어 벽에 화강암 텍스처가 적용된 집을 생각해 보세요. 이 텍스처는 바닥, 천장, 계단, 테이블, 심지어 근처의 작은 우물에도 적용될 수 있습니다. 텍스처 로딩은 비용이 많이 드는 작업이며, 현재 구현에서는 동일한 텍스처가 이미 여러 번 로드되었더라도 메시마다 새로운 텍스처를 로드하고 생성합니다. 이로 인해 모델 로딩 구현에서 병목 현상이 발생하기 쉽습니다.
이제 모델 코드에 작은 수정을 하나 추가하여 로드된 모든 텍스처를 전역 변수에 저장하겠습니다. 텍스처를 로드하려는 모든 위치에서 먼저 해당 텍스처가 이미 로드되었는지 확인합니다. 이미 로드되었다면 해당 텍스처를 가져와 전체 로딩 과정을 건너뛰어 처리 능력을 크게 절약할 수 있습니다. 텍스처를 비교하려면 텍스처의 경로도 저장해야 합니다.
struct Texture {
unsigned int id;
string type;
string path; // 다른 텍스처와 비교하기 위해 텍스처의 경로를 저장합니다.
};
그런 다음 로드된 모든 텍스처를 모델 클래스 파일 맨 위에 비공개 변수로 선언된 다른 벡터에 저장합니다.
vector<Texture> textures_loaded;
loadMaterialTextures 함수에서는 텍스처 경로를 textures_loaded 벡터에 있는 모든 텍스처와 비교하여 현재 텍스처 경로가 해당 경로 중 하나와 일치하는지 확인합니다. 일치하는 텍스처가 있다면 텍스처 로딩/생성 과정을 건너뛰고 찾은 텍스처 구조체를 메시의 텍스처로 바로 사용합니다. (업데이트된) 함수는 아래와 같습니다.
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 클래스의 전체 소스 코드는 여기에서 확인할 수 있습니다.
더 이상 컨테이너가 필요 없어요!
자, 이제 제가 만든 작품이 아닌, 진짜 아티스트들이 만든 모델을 불러와서 저희 구현 방식을 제대로 테스트해 보겠습니다. 제가 너무 과하게 칭찬받고 싶지는 않으니, 가끔은 다른 아티스트들의 작품도 소개하는 시간을 갖겠습니다. 이번에는 Berk Gedik의 멋진 '서바이벌 기타 배낭' 모델을 불러올 겁니다. 모델 로딩 방식에 맞게 재질과 경로를 약간 수정했습니다. 모델은 .obj 파일과 함께 모델의 확산, 반사, 노멀 맵(나중에 자세히 설명하겠습니다)에 연결된 .mtl 파일로 내보내집니다. 이 장에서 사용할 수정된 모델은 여기(또는 여기)에서 다운로드할 수 있습니다. 참고로, 아직 사용하지 않을 몇 가지 추가 텍스처 유형이 있으며, 텍스처가 제대로 로드되려면 모든 텍스처 파일과 모델 파일이 같은 디렉토리에 있어야 합니다.
수정된 배낭 버전은 로컬 상대 텍스처 경로를 사용하며, 알베도 및 메탈릭 텍스처의 이름을 각각 확산 및 반사로 변경했습니다.
이제 Model 객체를 선언하고 모델 파일 위치를 전달하세요. 그러면 모델이 자동으로 로드되고 (오류가 없으면) 렌더링 루프에서 Draw 함수를 사용하여 객체를 렌더링합니다. 더 이상 버퍼 할당, 속성 포인터, 렌더링 명령이 필요 없고 간단한 한 줄 코드로 모든 것이 해결됩니다. 프래그먼트 셰이더가 객체의 디퓨즈 텍스처만 출력하는 간단한 셰이더 세트를 만들면 결과는 다음과 같습니다.

전체 소스 코드는 여기에서 확인할 수 있습니다. 모델을 로드하기 전에 stb_image.h 파일에 텍스처를 세로로 뒤집도록 지시한다는 점에 유의하세요. 그렇지 않으면 텍스처가 제대로 표시되지 않을 수 있습니다.
또한, 조명 챕터에서 배운 것처럼 포인트 라이트를 렌더링 방정식에 도입하고 스페큘러 맵과 함께 사용하면 더욱 창의적인 결과를 얻을 수 있습니다.

솔직히 말해서, 지금까지 사용해 온 컨테이너보다 훨씬 고급스러운 것 같습니다. Assimp를 사용하면 인터넷에서 찾을 수 있는 수많은 모델을 불러올 수 있습니다. 무료 3D 모델을 다양한 파일 형식으로 다운로드할 수 있도록 제공하는 웹사이트가 꽤 많습니다. 다만, 일부 모델은 제대로 불러오지 못하거나, 텍스처 경로가 작동하지 않거나, Assimp에서 읽을 수 없는 형식으로 내보내진 경우도 있다는 점에 유의하세요.
추가 자료
- How-To Texture Wavefront (.obj) Models for OpenGL: 매튜 얼리가 제작한 이 훌륭한 비디오 가이드는 블렌더에서 3D 모델을 설정하여 현재 사용 중인 모델 로더와 바로 연동되도록 하는 방법을 알려줍니다 (저희가 선택한 텍스처 설정이 기본적으로 작동하지 않는 경우가 있기 때문입니다).
확인해 본 결과 최신 버전인 6버전의 Assimp에서도 위의 코드가 모두 정상적으로 작동하는 것 같습니다.
오늘의 결론!
시간이 지나도 API는 같다.