[Vulkan Tutorial] 13-Shader Modules on Graphics Pipeline

목차: 01-Overview (Link)
이전 글: 12-Introduction of Graphics Pipeline (Link)
다음 글: 14-Fixed Functions on Graphics Pipeline (Link)


SPIR-V (Bytecode Format) and GLSL/HLSL (Human-Readable Format)

기존 Graphics API는 Shader 코드를 Human-Readable Syntax 형태의 GLSL, HLSL로 작성하였다. Vulkan의 경우 기존 Graphics API와 달리 Bytecode Format 형태의 SPIR-V 코드로 작성해야 한다. Vulkan은 Graphics Shader 코드와 Compute Shader 코드 모두 같은 Bytecode Format 형태를 사용한다. 우리의 경우 Graphics 코드만 작성할 예정이다.

Bytecode Format을 사용하면 기존 Human-Readable Syntax 대비 몇 가지 장점이 있다고 한다. 가장 먼저 GPU Native Code로 변경하는 데 있어서 Complexity (어려움, 복잡도)가 감소한다. 기존 Human-Readable Syntax를 GPU Native Code로 변경하는 방법은 GPU Vendor 마다 차이가 발생할 수밖에 없다고 한다. 그래서 동일한 코드가 GPU Vendor 마다 다르게 동작하고, 성능에 큰 차이가 발생할 수 있다. 반면, Bytecode Format으로 작성한 코드의 경우 GPU Native Code로 변경하는 데 있어서 Complexity가 상대적으로 낮아서 위와 같은 문제가 발생하지 않을 가능성이 높다고 한다 (발생하지 않는다고 설명하지 않았다. 단지 그럴 가능성이 높다고 한다. 다시 말해서 여전히 다르게 동작하고 성능 차이가 발생할 수 있다는 의미로 해석된다).

그럼 우리가 Bytecode Format인 SPIR-V로 직접 코드를 작성해야 하는가 하면 그렇지는 않다. Khronos에서 Human-Readable Format (GLSL)로 작성된 코드를 SPIR-V로 변경하는 컴파일러(Compiler)를 제공한다. 우리는 Android 단말을 기준으로 코드를 작성하고 있다. 그래서 출처 2에서 제공하는 GLSLtoSPV(…) 함수를 사용해서 컴파일 및 Loading 작업을 수행할 예정이다. GLSLtoSPV(…) 함수 코드를 살펴보면 Android 단말의 경우 glslang (출처 3, 4)를 사용해서 SPIR-V Format으로 코드를 변경하는 것으로 판단된다. 

MK: glslang 컴파일러 사용 방법에 대해서는 정리하지 않을 계획이다. 추가로 컴파일러 사용 방법을 공부하게 되면 따로 정리할 예정이다.

우리는 컴파일러를 사용해서 GLSL을 SPIR-V Format으로 변경할 예정이기 때문에 Shader 코드를 GLSL로 작성할 것이다. GLSL은 C-Style Syntax를 가진다. 모든 Shader 프로그램은 main(…) 함수에서 시작한다. GLSL은 Parameter(인자), Return 값을 사용하지 않고 미리 정의된(Pre-defined) Input/Output Variable(변수)을 사용한다. 또한 GLSL은 Graphics 연산에 필요한 Metrics, Vector 등의 연산을 기본적으로 지원한다.

이번 장에서는 Vertex, Fragment Shader 코드를 GLSL로 작성하여 해당 코드를 SPIR-V로 변경하는 코드를 작성할 예정이다. 아래 코드 1은 Vertex/Fragment Shader 관련 코드를 추가한 코드이다. 역시 앞의 장과 동일하게 새로 작성한 코드만 남기고 나머지 부분은 …으로 변경하였다. 전체 코드는 출처 5에서 확인할 수 있다.

코드 1: Vertex/Fragment Shader 관련 코드

#include <iostream>
#include <stdexcept>
#include <functional>
#include <cstdlib>
#include <util_init.hpp>
#include <set>

#define APP_SHORT_NAME "mkVulkanExample"

#define MK_GET_INSTANCE_PROC_ADDR(inst, entrypoint)												\
{																								\
	fp##entrypoint = (PFN_vk##entrypoint)vkGetInstanceProcAddr(instance, "vk" #entrypoint);		\
	if (fp##entrypoint == NULL) {																\
		std::cout << "vkGetDeviceProcAddr failed to find vk" #entrypoint;						\
		exit(-1);																				\
	}																							\
}


class mkTriangle{
	public:
		void run(){
			...
		}

	private:
		void initVulkan(){
			createInstance();
			createSurface();
			pickPhysicalDevice();
			searchQueue();
			createLogicalDevice();
			createSwapChain();
			createImageViews();

			//MK: (코드 1-2) Graphics Pipeline을 생성하는 함수 호출
			createGraphicsPipeline();
		}

		void mainLoop(){
		}

		void cleanup(){
			...
		}

		void createInstance(){

			...

		}

		void createSurface(){
			
			...

		}

		void pickPhysicalDevice(){

			...

		}

		void printPhysicalDeviceInfo(VkPhysicalDevice device){

			...

		}

		void searchQueue(){
			
			...

		}

		void createLogicalDevice(){

			...

		}

		void createSwapChain(){

			...

		}

		void createImageViews(){
			
			...

		}

		//MK: (코드 1-1) Graphics Pipeline을 생성하기 위한 함수 추가
		void createGraphicsPipeline(){
			LOGI("MK: (Begin) createGraphicsPipeline Function");

			//MK: (코드 1-3) Vertex Shader 코드 작성
			static const char *vertShaderText=
			"#version 450\n"
			"#extension GL_ARB_separate_shader_objects : enable\n"
			"vec2 positions[3] = vec2[](vec2(0.0, -0.5), vec2(0.5, 0.5), vec2(-0.5, 0.5));\n"
			"vec3 colors[3] = vec3[](vec3(1.0, 0.0, 0.0), vec3(0.0, 1.0, 0.0), vec3(0.0, 0.0, 1.0));\n"
			"layout(location = 0) out vec3 fragColor;\n"
			"void main() {\n"
			"gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);\n"
			"fragColor = colors[gl_VertexIndex];}\n";

			//MK: (코드 1-4) Fragment Shader 코드 작성
			static const char *fragShaderText=
			"#version 450\n"
			"#extension GL_ARB_separate_shader_objects : enable\n"
			"layout(location = 0) in vec3 fragColor;\n"
			"layout(location = 0) out vec4 outColor;\n"
			"void main() {\n"
			"outColor = vec4(fragColor, 1.0);}\n";

			//MK: (코드 1-5) GLSL Format을 SPIR-V Format으로 변경하는 코드
			std::vector<unsigned int> vtxSpv;
			std::vector<unsigned int> fragSpv;

			init_glslang();

			bool retVal;
			retVal = GLSLtoSPV(VK_SHADER_STAGE_VERTEX_BIT, vertShaderText, vtxSpv);
			assert(retVal == true);
			LOGI("\tMK: Vertex Code is converted to SPV");

			retVal = GLSLtoSPV(VK_SHADER_STAGE_FRAGMENT_BIT, fragShaderText, fragSpv);
			assert(retVal == true);
			LOGI("\tMK: Fragment Code is converted to SPV");

			//MK: (코드 1-6) VkShaderModule Object 생성 코드
                        VkShaderModule vertShaderModule;
			VkShaderModule fragShaderModule;

			VkShaderModuleCreateInfo createInfo = {};
			createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
			createInfo.pNext = NULL;
			createInfo.flags = 0;
			createInfo.codeSize = vtxSpv.size() * sizeof(unsigned int);
			createInfo.pCode = vtxSpv.data();

			VkResult result = vkCreateShaderModule(device, &createInfo, NULL, &vertShaderModule);
			assert(result == VK_SUCCESS);
			LOGI("\tMK: Vertex Module is created\n");
			
			createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
			createInfo.pNext = NULL;
			createInfo.flags = 0;
			createInfo.codeSize = fragSpv.size() * sizeof(unsigned int);
			createInfo.pCode = fragSpv.data();

			result = vkCreateShaderModule(device, &createInfo, NULL, &fragShaderModule);
			assert(result == VK_SUCCESS);
			LOGI("\tMK: Fragment Module is created\n");

			//MK: (코드 1-9) VkPipelineShaderStageCreateInfo Struct에 필요한 값 저장
			shaderStages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
			shaderStages[0].pNext = NULL;
			shaderStages[0].flags = 0;
			shaderStages[0].stage = VK_SHADER_STAGE_VERTEX_BIT;
			shaderStages[0].module = vertShaderModule;
			shaderStages[0].pName = "main";
			shaderStages[0].pSpecializationInfo = NULL;

			shaderStages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
			shaderStages[1].pNext = NULL;
			shaderStages[1].flags = 0;
			shaderStages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT;
			shaderStages[1].module = fragShaderModule;
			shaderStages[1].pName = "main";
			shaderStages[1].pSpecializationInfo = NULL;

			//MK: (코드 1-7) VkShaderModule을 제거하는 코드
			vkDestroyShaderModule(device, fragShaderModule, NULL);
			vkDestroyShaderModule(device, vertShaderModule, NULL);

			LOGI("MK: (End) createGraphicsPipeline Function");
		}

		VkInstance instance;

		VkSurfaceKHR surface;
		PFN_vkCreateAndroidSurfaceKHR fpCreateAndroidSurfaceKHR;

		VkPhysicalDevice physicalDevice = VK_NULL_HANDLE;

		uint32_t graphicQueueFamilyIndex = 0;
		uint32_t presentQueueFamilyIndex = 0;

		VkDevice device; 

		VkQueue graphicsQueue;
		VkQueue presentQueue;

		VkSurfaceCapabilitiesKHR surfaceCapabilities;
		std::vector<VkSurfaceFormatKHR> surfaceFormats;
		std::vector<VkPresentModeKHR> presentModes;

		VkSurfaceFormatKHR selectedSurfaceFormat;
		VkPresentModeKHR selectedPresentMode;
		VkExtent2D selectedExtent;
		uint32_t selectedImageCount;

		VkSwapchainKHR swapChain;

		std::vector<VkImage> swapChainImages;

		std::vector<VkImageView> swapChainImageViews;

		//MK: (코드 1-8) VkPipelineShaderStageCreateInfo 저장을 위한 변수 추가 (Vertex/Fragement 2개)
		VkPipelineShaderStageCreateInfo shaderStages[2];
};

int sample_main(int argc, char *argv[]) {

	...

}

이번 장은 Swap Chain 부분과 같이 여러 Sub-Section을 통해서 코드를 설명할 예정이다. 가장 먼저 코드 1-1과 같이 createGraphicsPipeline(…) 함수를 하나 추가한다. 해당 함수는 코드 1-2 initValkan(…) 함수에서 호출한다. 


Vertex Shader

Vertex Shader는 모든 Vertex Input에 대한 연산을 수행한다. Vertex Shader는 보통 Vertex World Position, Color 값, Normal Vector 값, Texture Coordinate 등을 Input으로 받는다. 이렇게 받은 Input은 Vertex Shader 코드에 따라 연산을 수행한다. 연산이 완료된 결과 값은 Fragment Shader의 Input으로 전달된다. Fragment로 전달되는 Input 값은 Fragment 위치에 따라 Interpolation 연산을 수행한 값이다.

그림 1: NDC vs. Framebuffer Coordinate 차이 (출처 1)

Vertex Shader에서 연산을 수행하면 World Position의 Vertex값이 Normalized Device Coordinate (NDC)로 변경된다. NDC로 변경되면 X, Y 좌표 값은 [-1, 1] 사이에 값으로 변경된다. Z의 경우 [0, 1]로 표현한다. NDC 값은 Rasterization 단계를 거치면서 Framebuffer Coordinate로 변경된다. 위 그림 1은 Framebuffer Coordinate와 NDC의 차이를 보여준다. 

Vertex Shader에 대해서 간단히 설명하였다. OpenGL과 같이 다른 Graphics API를 사용해 보았다면 큰 어려움 없이 이해할 것이다. 

MK: 출처 6에 Vertex Shader에서 Coordinate 값을 변경하는 순서에 대해서 정리하였다. Vertex Shader에서 Coordinate 값을 변경하는 방법에 대해서 읽어보면 Vertex Shader가 어떤 연산을 수행하는지 조금은 쉽게 이해할 수 있을 거라고 판단된다.

우리는 간단히 삼각형(Triangle) 한 개를 그릴 예정이다. 코드 1-3은 Vertex Shader 코드를 String에 저장하는 부분이다. 우리가 작성하는 Vertex Shader 코드는 NDC 값을 바로 Output으로 보내도록 작성하였다. position[3] 변수에 삼각형 위치 값을 먼저 저장한다. 그 다음 해당 Position 값을 사용해서 gl_Position 값을 Output으로 내보낸다. gl_Position의 제일 마지막 값은 1.0으로 설정하였다. 그 이유는 NDC 값으로 변경하는 과정에서 X, Y, Z값은 W값(제일 마지막에 위치한 값)으로 나누게 된다. W값을 1.0으로 설정하면 우리가 직접 NDC 좌표 값을 설정할 수 있다.

추가로 Color 값을 Output으로 내보내도록 코드를 작성하였다. gl_Position의 경우 Predefined (미리 정의된) 변수이다. 하지만, Fragment 색상을 전달하기 위해서 layout(location = 0) out vec3 fragColor 코드를 추가하여서 Output 변수를 설정한다. 해당 코드는 fragColor라는 변수를 Output으로 사용한다는 의미이다. 추가로 fragColor는 Vector(X, Y, Z) (Vec3)임을 의미한다. 각 삼각형 위치마다 R, G, B 값만 설정하였다. 해당 값을 기준으로 Color Output 값은 Interpolation 연산을 수행한다. 그 결과가 Fragment Shader의 Input으로 사용된다. 

Vertex Shader 코드를 모두 작성하였다. 다음은 Fragment Shader 코드를 작성한 차례이다. 


Fragment Shader

코드 1-4는 Fragment Shader 코드를 String 변수에 저장하는 코드이다. Fragment Shader의 경우 Vertex Shader와 달리 미리 정의된 (Pre-defined) Output이 없다. 그래서 먼저 layout(location = 0) out vec4 outColor 와 같이 Output 변수를 하나 정의한다. 그리고 Vertex Shader에서 색상값을 Fragment Shader로 보내주도록 코드를 작성하였다. Vertex Shader의 Output 값을 Fragment Shader의 Input 값으로 사용하기 위해서 layout(location = 0) in vec3 fragColor 와 같이 Input 변수를 하나 정의한다. Input 값을 기준으로 최종 Output 값을 결정한다. Output 값은 R(Red), Green(G), B(Blue), A(Alpha) 값 순서로 저장한다. 

그림 2. 삼각형 색상값 (출처 1)

현재 작성한 Fragment Shader는 그림 2와 같이 R, G, B 값을 Interpolation한 색상으로 삼각형을 그리게 된다.


Compiling Shaders and Loading Shaders

위에 작성한 내용과 같이 Compiler을 사용해서 GLSL/HLSL Format을 SPIR-V Format으로 변경하는 방법에 대해서는 정리하지 않을 계획이다. 대신 출처 2에서 제공하는 GLSLtoSPV(…) 함수를 사용할 예정이다. 해당 함수는 VulkanSamples/API-Samples/utils/util.cpp에 구현되어 있다. 구현 코드를 확인해보면 GLSL/HLSL Format을 SPIR-V로 변경하기 위해서 glslang이란 컴파일러를 사용하는 것으로 판단된다 (출처 3, 4). 코드 1-5는 GLSLtoSPV(…) 함수를 사용해서 SPIR-V Format으로 코드를 변경하는 부분이다. 

가장 먼저 vtxSpv, fragSpv 변수를 하나씩 추가한다. 해당 변수는 SPIR-V Format으로 변경된 코드를 저장한 변수이다. 그 다음 GLSLtoSPV(…) 함수를 사용해서 코드를 변경하면 된다. GLSLtoSPV(…) 함수는 아래와 같이 3개의 인자를 받는다. 

GLSLtoSPV(…) (출처 2)

  • shader_type (const VkShaderStageFlagBits): Shader Type (Vertex Shader 코드인지, Fragment Shader 코드인지 등을 알려주기 위해 사용)
  • pshader (const char *): GLSL Format의 Shader 코드
  • spirv (std::vector<unsigned int> &): SPIR-V Format 코드를 저장하기 위한 변수

컴파일이 문제없이 완료되면 True 값을 Return한다. 만약 컴파일에 문제가 발생하면 프로그램이 멈추도록 코드를 작성하였다. 


Creating Shader Modules

컴파일한 코드를 Pipeline에 보내기 이전에 VkShaderModule Object에 Wrap(포장?)해야 한다. VkShaderModule Object를 생성하는 과정은 간단한 편이다. 코드 1-6은 VkShaderModule을 생성하는 코드이다. VkShaderModule 생성을 위해서 먼저 VkShaderModuleCreateInfo Sturct에 몇 가지 값을 저장한다. VkShaderModuleCreateInfo Struct은 아래와 같이 5개의 변수를 가진다. 

VkShaderModuleCreateInfo Struct (출처 7)

  • sType (VkStructureType): 생성하고자 하는 Struct의 Type을 나타냄
  • pNext (const void*): Extension Struct Pointer
  • flags (VkShaderModuleCreateFlags): 미래에 사용하기 위해서 미리 만들어 두었다고 함
  • codeSize (size_t): Shader 코드 사이즈
  • pCode (const uint32_t*): Shader 코드 포인터

총 2개의 VkShaderModuleCreateInfo Struct를 생성해야 한다. 하나는 Vertex Shader, 다른 하나는 Fragment Shader를 Wrapping 하기 위해서 사용된다. VkShaderModuleCreateInfo에 모든 값을 저장하고 나면 vkCreateShaderModule(…) 함수를 사용해서 VkShaderModule을 생성할 차례이다. vkCreateShaderModule(…) 함수는 아래와 같이 4개의 인자를 가진다.

vkCreateShaderModule(…) (출처 8)

  • device (VkDevice): Logical Device 변수 (Create Logical Device에서 생성)
  • pCreateInfo (const VkShaderModuleCreateInfo*): 앞에서 작성한 VkShaderModuleCreateInfo Struct 변수
  • pAllocator (const VkAllocationCallbacks*): Pointer to custom allocator callbacks
  • pShaderModule (VkShaderModule*): VkShaderModule을 저장할 변수

vkCreateShaderModule(…) 함수를 사용해서 생성한 VkShaderModule은 코드를 감싸고 있는 아주 간단한 Wrapper 역할만 수행한다고 한다. 해당 VkShaderModule은 Graphics Pipeline 생성이 완료되면 vkDestroyShaderModule(…) 함수를 사용해서 제거하면 된다. 코드 1-7은 VkShaderModule을 제거하는 코드이다. Graphics Pipeline 생성에 필요한 코드를 이 사이에 계속 작성해 나갈 예정이다.


Creating Shader Stage

앞에서 생성한 VkShaderModule을 사용해서 Graphics Pipeline을 생성하기 이전에 해당 Object를 VkPipelineShaderStageCreateInfo Struct에 먼저 저장해야 한다. 먼저 코드 1-8과 같이 VkPipelineShaderStageCreateInfo 변수를 선언한다. VkShaderModule이 2개인 관계로 (Vertex Shader/Fragment Shader) VkPipelineShaderStageCreateInfo 변수 역시 2개를 생성한다. 다음으로 코드 1-9에서 VkPipelineShaderStageCreateInfo Struct에 필요한 값을 저정한다. VkPipelineShaderStageCreateInfo Struct는 아래와 같이 7개의 변수를 가진다. 

VkPipelineShaderStageCreateInfo Struct (출처 9)

  • sType (VkStructureType): 생성하고자 하는 Struct의 Type을 나타냄
  • pNext (const void*): Extension Struct Pointer
  • flags (VkPipelineShaderStageCreateFlags): 미래에 사용하기 위해서 미리 만들어 두었다고 함
  • stage (VkShaderStageFlagBits): Pipeline의 Stage를 정의함 (예를 들어 Vertex Shader 또는 Fragment Shader인지 나타내기 위해서 사용함)
  • module (VkShaderModule): VkShaderModule 변수 (앞에서 생성한 Vertex/Fragment VkShaderModule 변수를 사용)
  • pName (const char*): 함수 시작 위치 (우리는 main 함수를 사용하였기 때문에 main으로 설정함)
  • pSpecializationInfo (const VkSpecializationInfo*): VkSpecializationInfo struct 포인터 

pSpecializationInfo는 Shader의 Constant 값을 변경해서 Shader의 Behavior(연산 순서)를 변경하고 싶을 때 사용한다. 해당 변수를 사용해서 Shader의 연산 순서를 변경하는 경우 If 등의 불필요한 연산을 제거할 수 있다고 한다. 아마도 컴파일러 Optimization을 위해서 사용한다는 의미로 파악된다. 우리는 사용하지 않을 예정이라 NULL 값을 사용한다. 드디어 Shader Module 관련된 모든 코드 작성을 마무리하였다. 


Result/Output

그림 3: Logcat 결과

위 코드를 컴파일 해서 실행하면 여전히 아무 결과 화면도 나타나지 않는다. 위 그림 3은 코드를 빌드해서 실행하면 나타나는 Logcat 결과 화면이다. 빌드에 사용된 코드는 출처 5에서 다운로드할 수 있다.


출처

  1. https://vulkan-tutorial.com/
  2. https://developer.android.com/ndk/guides/graphics/getting-started
  3. https://github.com/google/shaderc
  4. https://github.com/KhronosGroup/glslang
  5. https://github.com/mkblog-cokr/androidVulkanTutorial
  6. https://mkblog.co.kr/2019/11/17/gpu-coordinate-system/
  7. https://www.khronos.org/registry/vulkan/specs/1.2-extensions/man/html/VkShaderModuleCreateInfo.html
  8. https://www.khronos.org/registry/vulkan/specs/1.2-extensions/man/html/vkCreateShaderModule.html
  9. https://www.khronos.org/registry/vulkan/specs/1.2-extensions/man/html/VkPipelineShaderStageCreateInfo.html

Leave a Comment