Post

Vulkan 튜토리얼: 디스크립터 셋 할당 및 커맨드 실행하기

이번 튜토리얼에서 소주제였던 버퍼 입출력을 완료할 것입니다. 이를 수행하기 위해, 다음의 네 과정을 순차적으로 수행해야 합니다:

  1. 컴퓨트 파이프라인을 완성했기 때문에, 이젠 이 파이프라인을 실행하기만 하면 됩니다. 다만 앞서 설명했듯 파이프라인은 사용할 디스크립터 셋에 대한 정보만을 담고 있을 뿐, 아직은 우리가 만든 버퍼를 파이프라인에 바인딩하지는 않았습니다. 이를 위해서는 실제 버퍼를 가르킬 디스크립터 셋을 할당할 디스크립터 풀 (descriptor pool)을 생성해야 합니다.
  2. 생성한 디스크립터 풀로부터 디스크립터 셋을 할당하고, 디바이스를 이용해 디스크립터 셋에 버퍼 정보를 기록하는 디스크립터 업데이트 과정을 거쳐야 합니다.
  3. 모든게 끝났다면 커맨드 풀 (command pool)로부터 커맨드 버퍼 (command buffer)를 할당하고, 파이프라인과 디스크립터 셋을 커맨드 버퍼에 바인딩 한 후, 파이프라인을 실행하는 디스패치 커맨드를 커맨드 버퍼에 기록해야 합니다.
  4. 커맨드 버퍼 기록이 끝났다면 커맨드 버퍼를 큐에 제출하고, 큐가 작업을 수행을 완료할 때까지 기다립니다.

한 가지 더, 이번에 다룰 디스크립터 풀-디스크립터 셋, 커맨드 풀-커맨드 버퍼의 관계는 Vulkan-Hpp의 RAII 패턴에서 조금 특이한 소유권을 가지고 있습니다. 이에 대해서도 알아볼 것입니다.

디스크립터 풀 생성 및 디스크립터 셋 할당하기

디스크립터 풀 생성하기

디스크립터 셋을 만들기 위해서는 그 풀(pool)이 필요하며, 그 역할을 하는 것이 디스크립터 풀입니다. 디스크립터 풀은 해당 풀이 생성할 총 디스크립터의 정보와 디스크립터 셋의 개수를 풀 생성 시점에 명시해야 합니다. 생성할 총 디스크립터의 정보는 연속된 vk::DescriptorPoolSize로 명시될 수 있습니다.

Subject: [PATCH] Create descriptor pool.
---
Index: src/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/main.cpp b/src/main.cpp
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -184,5 +184,18 @@
         *pipelineLayout,
     } };
 
+    // Create descriptor pool.
+    const vk::raii::DescriptorPool descriptorPool = [&] {
+        constexpr vk::DescriptorPoolSize poolSize {
+            vk::DescriptorType::eStorageBuffer,
+            1,
+        };
+        return vk::raii::DescriptorPool { device, vk::DescriptorPoolCreateInfo {
+            {},
+            1,
+            poolSize,
+        } };
+    }();
+
     (*device).unmapMemory(*bufferMemory);
 }
\ No newline at end of file

디스크립터 풀 생성 정보 구조체의 두 번째 인수는 디스크립터 풀로부터 할당할 총 디스크립터 셋의 개수이며, 세 번째 인수는 생성할 디스크립터의 개수를 의미합니다. 우리는 한 개의 스토리지 버퍼 디스크립터만을 생성하므로 poolSize 상수 생성자로 eStorageBuffer와 1 (생성할 디스크립터 개수)을 전달했습니다.

만일 디스크립터 풀로부터 디스크립터 셋을 해당 개수 한계 이상으로 생성한다면 OutOfPoolMemory 오류를 던질 것이니, 사용할 디스크립터 개수와 디스크립터 셋의 개수를 충분히 어림잡아 만들거나, 여력이 된다면 디스크립터 풀에 대한 추상화와 같은 방법을 사용할 수도 있습니다. 지금으로써는 사용할 디스크립터 1개/디스크립터 셋 1개로 명백하니, 이러한 방법은 사용하지 않겠습니다.

디스크립터 셋 할당하기

디스크립터 풀을 생성했다면, 해당 풀로부터 이전에 만든 디스크립터 셋 레이아웃을 따르는 디스크립터 셋을 할당할 수 있습니다.

Subject: [PATCH] Allocate descriptor set from descriptorPool.
---
Index: src/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/main.cpp b/src/main.cpp
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -197,5 +197,11 @@
         } };
     }();
 
+    // Allocate descriptor set from descriptor pool.
+    const vk::DescriptorSet descriptorSet = (*device).allocateDescriptorSets(vk::DescriptorSetAllocateInfo {
+        *descriptorPool,
+        *descriptorSetLayout,
+    }).front();
+
     (*device).unmapMemory(*bufferMemory);
 }
\ No newline at end of file

디바이스의 allocateDescriptorSets 메서드를 이용해 디스크립터 셋을 할당했습니다. 이 메서드는 인수로 주어진 디스크립터 셋 레이아웃 (여기서는 한 개만이 전달되었으나, 여러 개를 전달할 수 있습니다) 각각에 대응하는 디스크립터 셋의 벡터를 반환합니다. 코드에서는 이 벡터에 front() 메서드를 호출해 맨 앞의 디스크립터 셋만을 변수로 선언했습니다. (어차피 우리는 하나의 디스크립터 셋 레이아웃만을 전달했으므로, 하나의 디스크립터 셋만으로 이루어진 벡터가 반환되겠죠!)

여기서 중요한 사실이 하나 있습니다: vk::DescriptorSetraii 네임스페이스 내에 없습니다! 또 생성이 아닌 할당이라는 용어가 사용됐는데, 이는 디스크립터 셋이 실제로는 독자적인 객체가 아닌 디스크립터 풀이 소유하고 있는 디스크립터 셋이 대한 참조임을 의미합니다. 그렇기에, 디스크립터 풀이 소멸하거나 초기화될 경우 해당 풀에서 할당된 디스크립터 셋 또한 소멸합니다 (댕글링 포인터가 됩니다). 예를 들어, 다음과 같은 코드는 오류입니다.

1
2
3
4
5
6
7
8
9
vk::DescriptorSet descriptorSet = [&] {
    vk::raii::DescriptorPool pool { ... };
    return (*device).allocateDescriptorSets(vk::DescriptorSetAllocateInfo {
        *pool,
        ...
    })[0];
    // pool is destroyed at here.
}();
// Now descriptor set is dangling pointer.

이는 마치 디바이스와 큐의 관계와 유사합니다. 큐는 디바이스가 생성되면서 만들어지고, 우리는 단지 getQueue 메서드를 이용해 디바이스가 소유한 큐의 포인터만을 돌려받는 것이니, 디바이스가 소멸되면 큐 또한 같이 소멸할 것입니다. 디바이스는 Vulkan 애플리케이션 전체 생명 주기에 존재하니 소멸한 뒤 큐를 사용할 가능성이 낮으나, 디스크립터 풀은 여러번 생명과 소멸을 반복할 수 있으므로 생명 주기에 대한 이해가 필요합니다.

디스크립터 셋에 버퍼 정보 기록하기

디스크립터 셋을 할당했다면, 이제 버퍼의 정보를 기록할 차례입니다. 어떤 디스크립터 셋에 리소스 정보를 기록하기 위해서는 디바이스의 updateDescriptorSets 메서드를 실행해야 하며, 이 메서드는 기록 정보를 나타내는 vk::WriteDescriptorSet의 배열과 vk::CopyDescriptorSet의 배열을 인수로 받습니다. 지금으로썬 vk::WriteDescriptorSet만을 다루어 보겠습니다.

Subject: [PATCH] Write buffer info to descriptorSet.
---
Index: src/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/main.cpp b/src/main.cpp
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -203,5 +203,20 @@
         *descriptorSetLayout,
     }).front();
 
+    // Write buffer info to descriptorSet.
+    const vk::DescriptorBufferInfo bufferInfo {
+        *buffer,
+        0,
+        vk::WholeSize,
+    };
+    (*device).updateDescriptorSets(vk::WriteDescriptorSet {
+        descriptorSet,
+        0,
+        0,
+        vk::DescriptorType::eStorageBuffer,
+        {},
+        bufferInfo,
+    }, {});
+
     (*device).unmapMemory(*bufferMemory);
 }
\ No newline at end of file

우리는 먼저 buffer 버퍼를 디스크립터 버퍼로 사용할 영역을 정의하는 vk::DescriptorBufferInfo 객체를 생성했습니다. 이전 표기와 마찬가지로 오프셋 0, 크기 vk::WholeSize (버퍼의 전체 크기를 나타냄)를 전달하여 버퍼 전체 영역이 디스크립터 버퍼로 사용됨을 명시하였습니다.

이후 vk::WriteDescriptorSet 객체를 사용해 디스크립터 셋에 디스크립터 버퍼를 어떻게 기록할 것인지를 명시했습니다. 생성자의 인수는 다음과 같습니다:

  • dstSet: 기록 대상 디스크립터 셋. descriptorSet에 기록합니다.
  • dstBinding: 기록 대상 디스크립터 셋 바인딩. 우리는 0번째 바인딩에 기록하므로 0을 전달했습니다.
  • dstArrayElement: 해당 바인딩에 대응하는 디스크립터 배열의 원소 인덱스. 앞서 설명했듯 한 바인딩에 여러 개의 디스크립터를 배열 형식으로 사용할 수 있다고 했습니다. 우리는 하나의 디스크립터만을 사용하므로 0을 전달했습니다.
  • descriptorType: 디스크립터 타입입니다. eStorageBuffer를 전달했습니다.
  • imageInfo: 만일 이미지 디스크립터를 기록하고자 할 경우 이 인수로 전달해야 합니다. 우리는 버퍼 디스크립터를 기록하므로 nullptr로 전달하였습니다 ({}nullptr을 나타냅니다).
  • bufferInfo: 기록할 버퍼 디스크립터.
  • texelBufferView (위 코드에는 표시되지 않음): 텍셀 버퍼 뷰 디스크립터를 전달할 때 사용됩니다. 여기서는 사용하지 않았습니다.

마지막으로 이 객체를 디바이스의 updateDescriptorSets 메서드 인수로 전달함으로써, 성공적으로 descriptorSet의 0번째 바인딩이 buffer 버퍼를 가르키게 하였습니다.

커맨드 버퍼 할당 및 커맨드 기록하기

커맨드 풀 생성하기

파이프라인과 디스크립터 셋 둘 다 준비했으니, 이제 최종적으로 파이프라인을 실행할 차례입니다. 파이프라인을 실행하기 위해서는 기존 디바이스의 메서드와는 별개로 커맨드 버퍼를 만들어서, 커맨드 버퍼에 커맨드를 기록한 후 큐에 제출해야 합니다. 디스크립터 셋과 마찬가지로, 커맨드 버퍼 또한 커맨드 풀로부터 할당될 수 있습니다.

Subject: [PATCH] Create command pool.
---
Index: src/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/main.cpp b/src/main.cpp
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -218,5 +218,11 @@
         bufferInfo,
     }, {});
 
+    // Create command pool.
+    const vk::raii::CommandPool commandPool { device, vk::CommandPoolCreateInfo {
+        {},
+        computeQueueFamilyIndex,
+    } };
+
     (*device).unmapMemory(*bufferMemory);
 }
\ No newline at end of file

커맨드 풀의 생성 정보 구조체로는 큐 패밀리 인덱스가 들어갑니다. 해당 큐 패밀리로부터 커맨드 버퍼가 생성될 수 있으며, 이 커맨드 버퍼는 해당 큐 패밀리에 대응하는 큐로 제출되어야만 합니다 (예를 들어, 서로 다른 두 큐 패밀리로부터 생성된 큐 A와 B가 있고, 각각의 큐 패밀리 인덱스로부터 생성된 커맨드 풀 C와 D가 있을 때, C에서 할당된 커맨드 버퍼는 A로만, D에서 할당된 커맨드 버퍼는 B로만 제출될 수 있습니다). 우리는 컴퓨트 큐 패밀리와 이에 대응하는 컴퓨트 큐가 있으므로, 컴퓨트 큐 패밀리 인덱스를 이용해 커맨드 풀을 생성했습니다.

디스크립터 풀과 다르게, 여기서는 총 생성할 커맨드 버퍼의 개수를 명시하지 않아도 됩니다.

커맨드 버퍼 할당하기

커맨드 풀을 생성했다면 커맨드 버퍼를 할당하는 것은 디스크립터 셋을 할당하는 것과 비슷합니다.

Subject: [PATCH] Allocate command buffer from commandPool.
---
Index: src/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/main.cpp b/src/main.cpp
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -224,5 +224,12 @@
         computeQueueFamilyIndex,
     } };
 
+    // Allocate command buffer from commandPool.
+    const vk::CommandBuffer commandBuffer = (*device).allocateCommandBuffers(vk::CommandBufferAllocateInfo {
+        *commandPool,
+        vk::CommandBufferLevel::ePrimary,
+        1,
+    }).front();
+
     (*device).unmapMemory(*bufferMemory);
 }
\ No newline at end of file

커맨드 버퍼 할당 정보 구조체 vk::CommandBufferAllocateInfo 생성자 인수는 다음을 의미합니다:

  1. 커맨드 버퍼를 할당할 커맨드 풀, commandPool을 전달했습니다.
  2. 커맨드 버퍼의 레벨. 이 레벨은 primary와 primary가 존재하는데, secondary는 primary 커맨드 버퍼에 종속된, 멀티스레드에서 기록될 수 있는 특수한 커맨드 버퍼입니다. 이 튜토리얼에서는 primary 커맨드 버퍼만을 다룹니다.
  3. 할당할 커맨드 버퍼의 개수. 이 개수는 커맨드 버퍼에 기록할 커맨드의 개수가 아닙니다. 하나의 커맨드 버퍼는 여러 개의 커맨드를 기록할 수 있으며, 할당해야 할 커맨드 버퍼의 개수는 큐에 제출할 커맨드 버퍼의 개수와 같으므로 우리는 오직 한 개만이 필요합니다.

마찬가지로 디바이스의 allocateCommandBuffers 메서드는 커맨드 버퍼의 벡터를 반환하므로, front 메서드를 호출해 처음 1개만을 commandBuffer로 선언했습니다.

커맨드 버퍼에 커맨드 기록하기

커맨드 버퍼에 커맨드를 기록하는 과정 이전 커맨드 버퍼는 시작(begin)되어야 하고, 기록 과정 이후 종료(end)되어야 합니다. 이 시작 과정에서 커맨드 버퍼의 용도(vk::CommandBufferUsageFlags)가 정해집니다. 몇 가지 용도가 있으나, 우리는 그중에서 이 커맨드 버퍼가 큐에 오직 1회 제출된다는 eOneTimeSubmit 열거형을 이용해 커맨드 버퍼를 시작하겠습니다.

Subject: [PATCH] Record commands that invoke computePipeline.
---
Index: src/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/main.cpp b/src/main.cpp
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -231,5 +231,12 @@
         1,
     }).front();
 
+    // Record commands that invoke computePipeline.
+    commandBuffer.begin({ vk::CommandBufferUsageFlagBits::eOneTimeSubmit });
+    commandBuffer.bindPipeline(vk::PipelineBindPoint::eCompute, *computePipeline);
+    commandBuffer.bindDescriptorSets(vk::PipelineBindPoint::eCompute, *pipelineLayout, 0, descriptorSet, {});
+    commandBuffer.dispatch(1024 / 256, 1, 1);
+    commandBuffer.end();
+
     (*device).unmapMemory(*bufferMemory);
 }
\ No newline at end of file

커맨드 버퍼를 시작하고 난 후, 커맨드 버퍼에 파이프라인·디스크립터 셋을 바인딩하고, 파이프라인 디스패치 커맨드를 기록했습니다.

  • bindPipeline 메서드의 첫 번째 인수 파이프라인 바인드 포인트 (pipeline bind point)는 이 파이프라인이 컴퓨트 파이프라인이라는 뜻과 더불어 앞으로 명시적으로 파이프라인 바인드 포인트가 변하지 않는 한 후속 커맨드 역시 이 해당 파이프라인에 종속된다는 뜻입니다.
  • bindDescriptorSets의 세 번째 인수 0은 네 번째 인수로 전달한 디스크립터 셋 배열 (여기서는 하나만이 사용됨)이 순차적으로 바인딩될 첫 번째 인덱스로, 이 코드에서는 0번째 디스크립터 셋부터 하나만이 바인딩됩니다.
  • 마지막으로 dispatch 메서드에는 컴퓨트 파이프라인을 실행할 워크 그룹의 개수를 3차원으로 나타냈는데, 우리는 워크 그룹의 크기가 256x1x1이고 전체 작업은 (3차원 기준) 1024x1x1개이므로 워크 그룹은 4x1x1로 생성해야 합니다.

커맨드 버퍼 기록이 완료됐으면 end 메서드를 호출해 기록이 종료됨을 명시하면 됩니다. 기록이 끝났으니 이제 커맨드 버퍼를 큐에 제출하면 됩니다.

커맨드 버퍼를 큐에 제출하기

큐의 submit 메서드를 사용하여 커맨드 버퍼의 배열 (여기서는 1개)를 동시에 제출할 수 있습니다.

Subject: [PATCH] Submit commandBuffer to computeQueue and wait for it to finish.
---
Index: src/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/main.cpp b/src/main.cpp
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -238,5 +238,13 @@
     commandBuffer.dispatch(1024 / 256, 1, 1);
     commandBuffer.end();
 
+    // Submit commandBuffer to computeQueue and wait for it to finish.
+    computeQueue.submit(vk::SubmitInfo {
+        {},
+        {},
+        commandBuffer,
+    });
+    computeQueue.waitIdle();
+
     (*device).unmapMemory(*bufferMemory);
 }
\ No newline at end of file

submit 메서드는 vk::SubmitInfo라는 이름의 제출 정보 구조체와 선택적으로(optional) 펜스(fence)를 인수로 받습니다. 또 제출 정보 구조체 생성자의 각 인수는 순서대로

  • 제출한 커맨드 버퍼가 실행되기 전 신호(signal)되야 할 세마포어(semaphore) 배열 (달리 말하면, 제출한 커맨드가 실행되기 전까지 대기(wait)할 세마포어 배열)
  • 위 세마포어가 현재 커맨드 버퍼의 스테이지 실행 이전까지 기다려야 하는 시점 배열
  • 제출할 커맨드 버퍼 배열
  • 이 커맨드 버퍼의 실행이 종결된 후 신호(signal)될 세마포어 배열

을 명시해야 합니다.

세마포어와 펜스에 대해서는 향후 이어질 동기화(synchronization)를 설명하며 다룰 것이고, 우리가 제출할 커맨드 버퍼는 다른 실행에 종속적으로 이어질 필요가 없으므로 일단은 둘 다 기본 인수(nullptr을 나타냄)를 전달하겠습니다.

commandBuffersubmit 메서드에 전달한 후, (대기 세마포어가 없는 한) 이 명령은 큐가 작업을 수행할 수 있을 시점이 됐을 때 실행될 것입니다. 지금 코드에서는 이전까지 큐에 제출한 명령이 없으므로 아마 바로 실행될 것입니다. 실행되면서 실제 GPU에서는 앞서 기록한 커맨드를 수행하게 됩니다.

Vulkan은 비동기적 실행 모델(asynchronous execution model)을 사용하기 때문에, 위와 같이 GPU에서 명령이 실행되는 동안 submit 메서드는 블로킹(blocking)되지 않습니다. 따라서 우리가 실제로 버퍼의 float들이 두 배가 됐는지 확인하고 싶다면, 이 큐가 작업을 완료할 때까지 기다려야 합니다. 이는 큐의 waitIdle 메서드에 대응하여, 큐가 모든 제출한 작업을 완료하고 대기 상태(idle)일 때가지 실행 흐름을 블록합니다. 해당 메서드 호출 이후, 우리는 버퍼의 값이 두 배가 됐음을 보증할 수 있습니다.

지금은 간단한 상황이므로 큐를 동기화하는 방법으로 waitIdle 메서드를 사용하지만, 이는 실제로 매우 비효율적인 작업입니다. 실제 큐에는 여러 개의 커맨드 버퍼가 제출된 상태일 수 있습니다. 우리가 어떤 커맨드 버퍼를 실행함으로써 호스트 단에서 정보를 얻어내고 할 경우 큐가 대기 상태일 때까지 블록하는 것은 해당 커맨드 버퍼 제출 이후 제출된 추가적인 커맨드 버퍼까지 모두 실행이 종결될 때까지 블록하는 것이며, 멀티스레드 환경일 경우 블록이 끝나지 않을 수도 있습니다. 이 경우 펜스를 사용하거나, Vulkan 1.2에 추가된 타임라인 세마포어(timeline semaphore)를 이용하여 제출한 커맨드 버퍼 실행이 끝나는 시점까지만 블록하세요.

첫 번째 튜토리얼을 끝내며…

GPU가 올바르게 작업을 수행했는지 확인하기 위해, buffer 버퍼의 데이터에 대응하는 nums를 예상치와 같은지 비교해봅시다. std::views::iota을 이용해 오름차순으로 나열된 정수 범위를 만들고, std::views::transform을 이용해 이 범위의 모든 정수를 입력으로 받아 두 배된 결과값의 범위를 만들 수 있습니다. 이 예상치를 std::ranges::equal을 이용해 nums와 비교해봅시다.

Subject: [PATCH] Check if result is expected.
---
Index: src/main.cpp
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/main.cpp b/src/main.cpp
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,3 +1,5 @@
+#include <cassert>
+
 import std;
 import vulkan_hpp;
 
@@ -246,5 +248,9 @@
     });
     computeQueue.waitIdle();
 
+    // Check if the result is correct.
+    constexpr auto expected = std::views::iota(0, 1024) | std::views::transform([](float x) { return x * 2.f; });
+    assert(std::ranges::equal(nums, expected) && "Unexpected result.");
+
     (*device).unmapMemory(*bufferMemory);
 }
\ No newline at end of file

디버그 모드에서 프로젝트를 빌드하고 실행해보세요 (assert 매크로는 릴리즈 빌드에서는 없는 코드와 마찬가지입니다). 만일 버퍼의 실수들이 두 배 되지 않은 경우 Unexpected result.가 출력되며 비정상 종료할 것입니다.


축하합니다. 이렇게 우리는 첫 번째 Vulkan 애플리케이션을 만들었습니다. 지금은 비록 어떠한 이미지도 삼각형도 없지만, 이 과정은 Vulkan이 표방하는 GPGPU 작업을 대변하는 대표적인 예시입니다. 단순한 이 애플리케이션에도 혼동이 올만한 여러가지 어려운 개념이 포함되니 (디스크립터, 디스크립터 셋, 디스크립터 셋 레이아웃, 디스크립터 셋 레이아웃 바인딩, 디스크립터 풀…), 정상 작동하는데 큰 의의를 두셨으면 좋겠습니다. 이 튜토리얼 소주제를 시작하며 던진 네 물음에 답해보세요:

다음 튜토리얼에는 앞서 예고했듯 본격적으로 “볼 수 있는” 이미지를 다루어 보겠습니다. 이번 튜토리얼의 전체 코드 변경 사항은 다음과 같습니다.

This post is licensed under CC BY 4.0 by the author.