Vulkan - 12. 동적으로 여러 텍스처를 사용하기

2022. 7. 17. 10:07Vulkan

 

개요

벌칸에서 텍스처를 사용하는 것을 일부 소개했던 7번 글에서는 텍스처를 사용하는 기본적인 방법 중 하나인 combined image sampler를 기술자 집합으로 가리켰었습니다. 여기서는 (저보단) 숙련된 벌칸 개발자들이 제시한, 런타임에 임의의 객체별 텍스처를 바인드할 수 있는 몇 가지 접근을 하나씩 구현해 보겠습니다. (사샤 윌리엄스와 니콜 볼라스는 둘 다 벌칸 질문하면 바로 나오는 사람으로 보이는군요.. 아주 존경합니다)

 

목차

1. 필요한 것

2. 접근 1: 기술자 집합을 많이 만들기

3. 접근 2: arrayLayers를 사용하기

4. 접근 3: 기술자 집합 하의 기술자 여러 개를 모두 이미지를 위해 사용하기

요약

과제

 

본문

1. 필요한 것

GL에서와 달리 벌칸에서의 uniform의 핵심은 다음과 같습니다.

  • 그리기 명령이 큐에 제출되는 시점에 모든 이미지(샘플러)에 대한 기술자 집합이 살아 있어야 합니다.
  • 동시에 응용 개발자가 원하는 바에 따라 로드/언로드 시점이 결정되어도 문제 없는 구조를 갖고 있어야 합니다. (GL은 그리기가 끝날 때까지 그림 함수가 리턴하지 않으므로 애초에 고려 대상이 아니었죠.)

 

그런데 이미지 관련된 것들의 경우, 기대되는 데이터가 너무 커서 그런지는 몰라도 동적 기술자 집합이 없습니다. 그래서 가능하다면, 이런 구조를 쓰면 좋을 것 같습니다. (이건 제가 이제 막 만들어낸 겁니다. 딱히 정답 같은 게 아니니 어떻게든 다른 방법을 생각해 내는 것도 좋겠죠.)

 

 

객체가 이미지 핸들을 새로이 가리키게 되는 경우 새로운 기술자 집합이 풀에서 할당되거나 사용하지 않는 기술자 집합이 이미지 핸들(혹은 합쳐진 샘플러)을 쓰게 해야 할 겁니다. vkUpdateDescriptorsets 함수를 알고 있으니 그건 어렵지 않죠? 반대로 객체가 소멸될 때 그것이 쓰던 이미지가 더 이상 사용되지 않고 있다면(주로 shared_ptr를 사용하여 알 수 있습니다.), 혹은 더 고급스럽게 만들 경우 소멸되지 않더라도 화면에 오래 나오지 않는다면, 그것에 관한 기술자 집합은 사용되지 않음이라고 명시하여 다른 이미지를 나중에 쓰게 되면 자리를 내어 줄 필요가 있습니다. 개발자 성향에 따라서는 해제할 수도 있겠고요. 이러한 설계는 만들어야 할 모듈이 꽤 많아 이번 글에서는 다루지 않을 겁니다.

 

이 문단의 내용은 이게 끝입니다. 위의 2가지에 집중하여 각 접근법을 다루어 봅시다. 참고로 이 글에서 사용할 2번째 텍스처 이미지는 7번 글에 있었던 숫자 2입니다.

 

2. 접근 1: 기술자 집합을 많이 만들기

이 방법은 위 링크의 방식들 중에서는 상당히 GL스러운 접근으로 비춰집니다.

 

이것은 지금까지 가지게 된 지식으로 불가능한 일이 아닙니다. 아주 클래식한 방법이고, 특수한 테크닉이나 확장이 들어가지 않았기 때문에 벌칸을 쓸 수 있는 곳이라면 항상 가능한 방법이겠죠. 참고로 집합의 최대 수 제한은 없습니다. 파이프라인 그리기 안에서 동시에 쓸 수 있는 최대 이미지 집합 수는 96 x (파이프라인 셰이더 단계 수)라고 보는 게 합당하지만, 이미지가 아주 작은 게 아니면 메모리 대역폭 한계로 사실 4개 정도를 동시에 쓰는 것도 매우 비효율적입니다. 굳이 다른 텍스처의 객체를 인스턴스로 그리려면 참고할 필요가 있을지도?

 

더보기

VkDescriptorPool은 처음 만들 때 할당 가능한 set의 크기를 명시하고, 그 뒤에 바꿀 방법은 없습니다. 따라서 최대 몇 개의 기술자 집합을 쓸 수 있게 할지는 미리 충분한 값으로 정해 두는 게 좋을 겁니다. 

 

기껏 글을 분리했지만, 동적 공유 버퍼랑 크게 다를 바는 없는 모양인데?
아니, 동적 공유 버퍼는 집합이 하나였고, 버퍼 내 원소 하나의 크기를 정할 뿐 전체 크기를 정하지 않아. 더 많은 게 필요하면 버퍼 메모리를 추가 확보하여 그 버퍼 정보를 기술자 집합 하나에 업데이트하면 된다고. 하지만 이건 내가 지금 아는 지식 아래에서는 기술자 집합을 정해진 수만큼 할당할 수밖에 없어.

그럼 코드를 작성해 봅시다. 7번 글에서 작성한 createTex0을 기억하실 겁니다. 여기서 파일 이름만 바꾸면 다른 이미지를 받아올 수 있기 때문에, 딱히 일반화로 수정하진 않고 복사해서 붙이겠습니다. 어차피 이제 곧 하나씩 밀고 새로 만들어야 하니까요.

멤버를 만들어 줍시다. 복사는 따로 다루지 않을 겁니다. 하다가 문제가 생기면 아래에 있는 전체 코드 링크를 참고해 주세요.

// VkPlayer.h
// 0이 붙은 것들은 원래 있었던 것
static VkImage tex0, tex1;
static VkDeviceMemory texmem0, texmem1;
static VkImageView texview0, texview1;

 샘플러는 방식을 달리하지 않거나, Y'CbCr 전환을 쓰지 않는 이상 여러 개 만들 필요가 없습니다. combined sampler는 그래서 기존의 샘플러를 그대로 넣어 주면 됩니다. 여기서는 단순히 텍스처가 2종류이므로 combined sampler를 위한 기술자 집합의 할당 수를 2로 늘리겠습니다.

먼저 멤버변수 선언 부분에서 텍스처 2개를 위해 배열 크기를 늘립니다.

// VkPlayer.cpp
VkDescriptorSet VkPlayer::samplerSet[2] = {}; // 1에서 2로 바뀌었습니다.

그 다음 기술자 풀에서 샘플러 집합을 그만큼 할당하게 만듭니다.

// VkPlayer::createDescriptorSet
... // 풀 사이즈 지정하는 부분
smsize.descriptorCount = sizeof(samplerSet) / sizeof(samplerSet[0]);
... // 기술자 집합 write하는 부분
for (size_t i = 0; i < sizeof(samplerSet) / sizeof(samplerSet[0]); i++) {
    VkDescriptorImageInfo imageInfo{};
    if (i == 0) { imageInfo.imageView = texview0; }
    else if (i == 1) { imageInfo.imageView = texview1; }
...

2번째 사각형을 그리는 타임에 samplerSet[1]을 바인드하게 하면 되겠네요.

// VkPlayer::fixedDraw
... // 2번째 정사각형 그리기 직전
dynamicOffs[0] = minUniformBufferOffset;
bindDs[1] = samplerSet[1]; // 나머지 그대로에 이 행만 추가되었습니다.
vkCmdBindDescriptorSets(commandBuffers[commandBufferNumber], VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout0, 0, 2, bindDs, sizeof(dynamicOffs) / sizeof(dynamicOffs[0]), dynamicOffs);
...

 

 

정말 새로울 것 없는 방법입니다. 하지만 이 역시 동적으로 여러 개를 사용하기에는 충분히 가용한 방법이 되겠죠. 이미지는 제법 무겁지만 기술자 집합은 그에 비하면 훨씬 가볍잖아요. 풀에서 기술자 집합을 할당하는 수에는 제한이 걸리지 않고요. 제대로 했다면 이렇게 2종류의 그림이 회전하게 됩니다. 전체 코드는 여기를 참고하세요.

 

 

결국 1개 그리던 때와 크게 다를 바 없이 고정적으로 2개를 그리고 있습니다. 일반 용도로 이 기법을 사용하려면 중간 교체를 위한 관리자를 만들어야 합니다. 중간 교체는 스테이징 버퍼에서 이미지 장벽으로 변환시키는 과정을 다시 해야 한다는 말입니다. 지금은 구현하지 않겠지만 실전에서는 사실상 필수적인 과정인 점을 기억해 주세요. (이후 모듈화에서는 구현할 것입니다.)

 

3. 접근 2: arrayLayers를 사용하기

지금까지 여러 번 링크했었던 이 코드를 참고하여 작성됩니다.

 

이것은 이미지를 쌓아 올려 하나의 기술자 집합을 이용하는 방식입니다. 즉, 개별 이미지 크기는 같아야 합니다. 이미지들의 크기를 맞추기 위해 stb_image_resize 같은 라이브러리를 써서 확대하는 경우, 사람의 눈으로 확실히 알아챌 만한 문제가 발생하지 않을 겁니다. 하지만 축소를 하면 아무리 리사이즈 라이브러리가 뛰어나도 사람의 눈으로 쉽게 알아챌 만한 질적 저하가 일어나지 않을 수 없습니다.

 

즉 확대만을 해야 한다는 말인데, 모든 걸 하나의 크기에 맞춘다고 치면 응용 개발자 역량에 따라 쓸데없이 메모리 소모가 커질 가능성이 있습니다. 저의 짧은 지식만 가지고 말하자면, 1번 접근과 병용하는 것을 권장합니다. 예를 들어 128x128 안에 들어갈 수 있는 이미지는 패딩(패딩은 텍스처 좌표의 재조정이 필요함) 또는 리사이즈를 통해 128x128로 만들고, 256x256, 512x512, 1024x1024, 2048x2048, ...도 그런 식으로 하는 겁니다. 당연히 개별 배열 길이는 컴파일 단계에서 적정량을 알아내어 정하는 게 좋습니다.

 

배열의 최대 길이의 최솟값은?
256.

 

더보기

아무리 모두 밀어 버릴 거라지만 학습에서 있을 수 있는 최소한의 혼란을 막기 위해 이 절의 코드의 시작점은 11장의 직후로 하겠습니다. 다시 말해 위의 접근 1과 시작점이 동일합니다.

 

지금 사용하는 이미지 2개(1, 2)는 해상도가 128x128로 같습니다. 전처리의 경우 방법도 다양하고 어렵지도 않기 때문에 과정은 생략하도록 하고, 여기서는 그냥 저 2개를 단일 샘플러에서 사용하도록 할게요.

 

먼저 이미지 2개를 같이 읽어 옵니다.

 

// VkPlayer::createTex0
unsigned char* pix = readImageFile("no1.png", &w, &h, &ch);
unsigned char* pix2 = readImageFile("no2.png", &w, &h, &ch);
if (!pix) {
    fprintf(stderr, "Failed to read image file\n");
    return false;
}
if (!pix2) {
    fprintf(stderr, "Failed to read image file\n");
    free(pix);
    return false;
}

 

이미지를 쌓아 올린다는 위에서의 설명에 맞게, 이미지 객체는 1개를 쓰게 됩니다. createTex0 함수로 가서 arrayLayers 부분을 수정해 줍니다.

 

// VkPlayer::createTex0
...
imgInfo.arrayLayers = 2; // 이 라인은 함수 초반에 있습니다.
...

 

스테이징 버퍼도 그만큼의 용량을 받도록 수정합니다.

 

...
VkBufferCreateInfo info{};
info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
info.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
info.size = static_cast<VkDeviceSize>(w) * h * 4 * 2; // 픽셀당 4바이트
...

 

매핑 이후 2번째 이미지에 대한 데이터도 복사해야겠죠? 여기의 경우는 이미지가 2개인 걸 아니까 

 

// vkMapMemory 바로 아래
memcpy(data, pix, info.size / 2);
memcpy((char*)data + info.size / 2, pix2, info.size / 2);
free(pix);
free(pix2);

 

그 다음 배치 변환을 위한 메모리 장벽, 그리고 복사 명령 입력에도 레이어 수를 수정합니다.

 

...
barrier.subresourceRange.layerCount = 2;
...
copyRegion.imageSubresource.layerCount = 2;
...

 

마지막으로 이미지 뷰 생성 정보에서도 똑같이 해 주면 됩니다. 덧붙여 레이어 수가 2이려면 2D_ARRAY라고 주어야 합니다.

 

...
viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D_ARRAY;
...
viewInfo.subresourceRange.layerCount = 2;
...

 

이제 조각 셰이더에서 이 기술자를 2D 텍스처의 배열로 쓸 수 있도록 고쳐야 합니다.

 

// tri.frag
...
layout(set = 1, binding = 1) uniform sampler2DArray tex;

void main() {
    outColor = color*texture(tex, vec3(texc,0));
}

 

sampler2DArray에서 샘플링하는 texture 함수는 vec3를 받는가 보는군. 그럼 저기다가 정수 인덱스가 아닌 그 사이의 값을 주면 어떻게 되는 거야? 섞이나?
스펙 상으로는 그냥 벡터의 마지막 요소가 배열 인덱스라고만 나오고... 공식적으로 관련된 내용은 여기 있군. 규칙은, 일단 범위를 벗어나면 범위로 자르고 아니면 반올림하는 거야. 그런데 저 식에 따르면 0.5를 넣었을 때 레이어 인덱스는 1이 돼야 하는데 내 기기에서는 0으로 취급되더라. 그러니 그런 어중간한 값을 쓸 생각 말고 정수에 해당하는 float만 넣는다고 생각해. 어차피 섞이는 기능은 없어.

 

이제 접근 방법을 알았으니 푸시 상수로 텍스처 번호를 넘기기만 하면 됩니다. 그걸 사용할 수 있게 다시 푸시 상수 레이아웃을 수정합시다.

 

// tri.frag
...
layout(std140, push_constant) uniform ui{
    vec4 color;
    float tIndex;
};

void main() {
    outColor = color*texture(tex, vec3(texc,tIndex));
}
...
//==========================================
// VkPlayer::createPipeline0
...
VkPushConstantRange pushRange{};
pushRange.offset = 0;
pushRange.size = 20;
pushRange.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
...

 

그 다음 푸시 상수에 대한 실제 데이터를 수정하면 끝입니다.

 

// VkPlayer::fixedDraw
...
float clr[5] = { 1.0f,0,0,1,0.0f };
vkCmdPushConstants(commandBuffers[commandBufferNumber], pipelineLayout0, VK_SHADER_STAGE_FRAGMENT_BIT, 0, 20, clr);
vkCmdDrawIndexed(commandBuffers[commandBufferNumber], 6, 1, 0, 0, 0);
dynamicOffs[0] = minUniformBufferOffset;
vkCmdBindDescriptorSets(commandBuffers[commandBufferNumber], VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout0, 0, 2, bindDs, sizeof(dynamicOffs) / sizeof(dynamicOffs[0]), dynamicOffs);
clr[1] = 1.0f; clr[4] = 1.0f;
vkCmdPushConstants(commandBuffers[commandBufferNumber], pipelineLayout0, VK_SHADER_STAGE_FRAGMENT_BIT, 0, 20, clr);
vkCmdDrawIndexed(commandBuffers[commandBufferNumber], 6, 1, 0, 4, 0);
...

  

 

정상적으로 잘 했다면 위 이미지와 같은 결과를 보여야 합니다. 전체 코드는 여기 있습니다. 위와 마찬가지로, 일반 용도로 이 기법을 사용하려면 중간 교체를 위한 관리자를 만들어야 합니다. 중간 교체는 스테이징 버퍼에서 이미지 장벽으로 변환시키는 과정을 다시 해야 한다는 말입니다. (이미지 배열에서 하나만 수정하려고 한다면 복사 범위와 이미지 장벽에서 baseArrayLayer를 조정하면 되지 않을까 싶습니다.) 지금은 구현하지 않겠지만 실전에서는 사실상 필수적인 과정인 점을 기억해 주세요. (이후 모듈화에서는 구현할 것입니다.)

 

1번째 접근법과 다르게 이미지와 기술자 집합은 (사이즈별)한 개만 만들었습니다. 배열이기 때문에 이론상으로는 셰이더를 그대로 두고 가능한 배열의 길이만큼의 텍스처에 바로바로 접근할 수가 있겠죠. VMA를 쓰지 않고 데이터를 그대로 이어붙여서 넣는다는 점도 장점으로 볼 수도 있겠네요. 다만 컴파일 타임 전에 텍스처 리사이징을 하는 등의 노력이 필요한 건 있습니다. 다시 말하지만 어지간해선 이미지를 확대할 일 밖에 없으니까요. 응용 개발자 측이 그에 딱 맞게 준비해 주지 않는 이상 메모리적으로는 조금 불리하겠습니다.

 

4. 접근 3: 기술자 집합 하의 기술자 여러 개를 이미지를 위해 사용하기

여기의 말을 따라서 해 보았습니다. (링크의 경우 이미지와 샘플러를 combine하지는 않았지만 여기서는 지금까지 하던 대로 combine합니다) 3절과 마찬가지로 접근 1과 동일하게 11장 직후가 시작점입니다.

 

더보기

일단 기술자 집합 레이아웃을 만들 때 집합당 기술자의 수를 늘려야 합니다. 기술자의 수를 늘리면 셰이더에서는 이를 배열로 접근할 수 있습니다.

 

// VkPlayer::createDescriptorSet
...
samplerBinding.descriptorCount = 2;
...
smsize.descriptorCount = 2;
...

 

이 집합 하나 아래에는 이제 (sampler2D를 위한) 기술자가 2개씩 있을 겁니다. 이들에 작성하려면 이렇게 해야겠죠.

 

...
descriptorWrite.descriptorCount = 2;
...

 

이러고 보니까 imageInfo의 배열로 이미지 뷰를 전달해야겠는데, 이미지 뷰 2개를 만드는 방식은 접근 1에서와 같습니다. 혹시 처음부터 소스를 그대로 따라오고 있었다면 편의상 여기서 createTex0이랑 destroyTex0을 복사해 옵시다. 어차피 특별한 테크닉이 들어가진 않았기 때문에 여기에 코드를 또 적어 드리진 않겠습니다. 멤버는 다시 만들어 줍시다.

 

// VkPlayer.h
static VkImage tex0, tex1;
static VkDeviceMemory texmem0, texmem1;
static VkImageView texview0, texview1;

 

그럼 기술자 집합을 갱신하는 for문 부분은 이렇게 되겠죠.

 

// VkPlayer::createDescriptorSet
...
for (size_t i = 0; i < sizeof(samplerSet) / sizeof(samplerSet[0]); i++) {
    VkDescriptorImageInfo imageInfo[2]={};
    imageInfo[0].imageView = texview0;
    imageInfo[0].sampler = sampler0;
    imageInfo[0].imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
    imageInfo[1].imageView = texview1;
    imageInfo[1].sampler = sampler0;
    imageInfo[1].imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
    
    VkWriteDescriptorSet descriptorWrite{};
    descriptorWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
    descriptorWrite.dstSet = samplerSet[i];
    descriptorWrite.dstBinding = 1;
    descriptorWrite.dstArrayElement = 0;
    descriptorWrite.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
    descriptorWrite.descriptorCount = 2;
    descriptorWrite.pImageInfo = imageInfo;
    vkUpdateDescriptorSets(device, 1, &descriptorWrite, 0, nullptr);
}
...

 

그 다음 조각 셰이더에서는 이런 식으로 접근하게 해 줍니다.

 

// tri.frag
...
layout(set = 1, binding = 1) uniform sampler2D tex[2];

void main() {
    outColor = color*texture(tex[1], texc);
}
...

 

이제 어떻게 할지 딱 보이는군요. 푸시 상수로 인덱스를 넘겨 주면 됩니다. 먼저 레이아웃.

 

// tri.frag
...
layout(std140, push_constant) uniform ui{
    vec4 color;
    int tIndex;
};

void main(){
    outColor = color*texture(tex[tIndex], texc);
}

// VkPlayer::createPipeline0
...
pushRange.size = 20;
...

 

그 다음 실제로 값을 써 줍니다.

 

// VkPlayer::fixedDraw
...
struct {
    float clr[4] = { 1.0f,0,0,1 };
    int idx = 0;
}psh;
vkCmdPushConstants(commandBuffers[commandBufferNumber], pipelineLayout0, VK_SHADER_STAGE_FRAGMENT_BIT, 0, 20, &psh);
vkCmdDrawIndexed(commandBuffers[commandBufferNumber], 6, 1, 0, 0, 0);
dynamicOffs[0] = minUniformBufferOffset;
vkCmdBindDescriptorSets(commandBuffers[commandBufferNumber], VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout0, 0, 2, bindDs, sizeof(dynamicOffs) / sizeof(dynamicOffs[0]), dynamicOffs);
psh.clr[1] = 1.0f; psh.idx = 1;
vkCmdPushConstants(commandBuffers[commandBufferNumber], pipelineLayout0, VK_SHADER_STAGE_FRAGMENT_BIT, 0, 20, &psh);
vkCmdDrawIndexed(commandBuffers[commandBufferNumber], 6, 1, 0, 4, 0);
...

 

정상적으로 잘 했다면 위 이미지와 같은 결과를 보여야 합니다. 전체 코드는 여기 있습니다. 위와 마찬가지로, 일반 용도로 이 기법을 사용하려면 중간 교체를 위한 관리자를 만들어야 합니다. 중간 교체는 스테이징 버퍼에서 이미지 장벽으로 변환시키는 과정을 다시 해야 한다는 말입니다. 지금은 구현하지 않겠지만 실전에서는 사실상 필수적인 과정인 점을 기억해 주세요. (이후 모듈화에서는 구현할 것입니다.)

 

링크에도 나왔듯 이 방법은 사용할 수 있는 수가 확실히 제한됩니다. 작지 않은 게임을 만든다고 생각하면, 결국 기술자 집합의 수를 늘리게 되겠네요.

 

요약

벌칸에서 텍스처 여러 개를 두고, 드로잉 타임에 각각 원하는 것을 바인딩하는 것 중에 3가지 방법을 알아보았습니다. 

  • 텍스처마다 기술자 집합 하나를 할당
  • 같은 크기의 이미지를 가지고 이미지 배열을 사용
  • 기술자 집합 하나에서 여러 텍스처를 가리킴.

 

니콜님이 달아 둔 답변을 보시면 알겠지만 특별히 사용성 외의 장/단점이 어떻다는 말이 없습니다. 개인적으로는 메모리 양과 배열 외의 요소에서 오는 CPU/GPU 상의 성능 차이는 크지 않을 거라고 추측해 봅니다. (우리 코드 밑바닥에서 돌아가는 원리를 잘 생각해 보자구요)

 

과제

보시면 알겠지만 위 3개는 대척되거나 상충하는 기법이 아닙니다. 그러니 3개를 올바르게 이해했는지 확인하고 싶다면 8개의 텍스처 이미지를 가지고 각 2개의 기술자로 구성된 2개의 기술자 집합에 대하여 각각 2겹의 array인 이미지를 참조하게 하여 기초 도형에 입혀 봅시다.

 

...

 

이 다음 글부터 당분간은 말 그대로 게임 엔진을 위해 객체지향이나 응용에 해당하는 이야기를 주로 할 겁니다. 일부는 그 과정에서 새로이 배우게 되기도 하지만, 벌칸 자체를 배움에 있어서 아직 남은 소재가 이제껏 한 것의 100배는 됩니다. (이를 테면 테셀레이션/컴퓨트 셰이더 등은 최소한 이번 모듈화 단계에서 바로 하지는 않을 겁니다.) 전 현재로서는 그걸 내려놓을 생각이 없고 언젠가는 꼭 다룰 생각입니다.

 

- 코드를 작성한 사람으로서 말하건대 지금(Commit number 46)까지의 코드는 MIT라고 붙어 있긴 하지만 퍼블릭 도메인입니다.

 

- 디스크립터에 대한 오류가 수정되고 linux make 지원을 추가한 버전을 새로 만들었습니다. 현 시점 기준 최신 커밋이 더 맞는 코드입니다.