본문으로 바로가기

Vulkan) 3. 그래픽스 파이프라인 개요

category Graphics/Vulkan 2021. 5. 9. 20:13

모든 내용은 https://vulkan-tutorial.com/Development_environment 에서 발췌합니다.

Windows를 기본으로 합니다. 그 외 플랫폼의 개발 환경 구성은 튜토리얼 사이트를 확인해 주세요.

 

모든 완성된 코드는 github.com/kimduuukbae/VulkanTutorial에 업로드됩니다.


삼각형은 어떻게 그려질 수 있을까요?

삼각형은 각 모서리에 하나씩, 총 세 개의 점으로 정의될 수 있습니다.

2차원의 좌표에서 (x, y) 의 점 3개로 나타낼 수 있습니다.

점 3개가 어떻게 삼각형으로 그려지는지 간략하게 확인해 봅시다.

 

vulkan그래픽스 파이프라인(Graphics Pipeline)이라는 선형 구조에서 정점(입력)들을 픽셀(출력)로 변환하는 일련의 작업을 수행합니다.

 

 (단순하게 알아보기 위함이므로, WVP 같은 행렬연산이나 각 고정 단계에 대한 연산식을 따로 작성하진 않습니다. 나중에 다루겠습니다. )

 

각 단계는 그 전 단계의 '입력' 을 받아 다음 단계로 '출력' 을 한다.

 

Vertex란 수학적으로 '두 변이 만나는 점'입니다.

그러나 그래픽스에서 Vertex란 본질적으로 공간적 위치 이외의 정보 (normal, binormal, color 등등등..) 를 담을 수 있으며, 이를 통하여 좀 더 복잡한 렌더링 효과를 낼 수 있습니다.

 

Vertex들은 기본적으로 Vertex Buffer라는 특별한 자료구조 안에 담겨서 파이프라인에 묶이는데, 사실 Vertex들을 연속적인 메모리에 저장할 뿐이다. Buffer자체는 Vertex들을 어떤 식으로 조합해서 기본도형을 형성할 것인지 말해주지 않는다.

Vertex들이 어떤 도형으로 형성될지는 Input Assembler (입력 조립기)가 결정한다. (Point, Line, Triangle 등등..)

 

Input Assembler (입력 조립기) : 메모리에서 기하 자료 (Vertex와 Index)를 읽어 기본 도형을 형성한다.

 

Vertex Shader (정점 셰이더) : Input Assembler 에서 조립된 기본도형들에 대한 프로그래밍 가능한 셰이더 단계이다.

 

함수의 구체적인 내용은 프로그래머가 구현해서 GPU에 제출하여야 한다.

이곳에서 각 Vertex들에 대한 World, View, Projection 연산이 일어나게 된다.

 

DirectX 같은경우 local * world * view * proj 순으로 연산이 진행되고

OpenGL(Vulkan) 같은경우 proj * view * world * local 순으로 연산이 진행된다. (좌표계가 다름을 알아두자)

그러다보니 우리가 DirectX 에서 오브젝트에 연산하였던 스케일 * 자전 * 이동 * 공전 * 부모의 순서가 

Vulkan에서는 부모 * 공전 * 이동 * 자전 * 스케일 순이다.

 

Tessellation (테셀레이션) : Vertex Shader에서 넘어온 도형들의 내부를 '잘게 쪼개는' 작업을 실행한다. 

동적 LOD같은것에 쓰인다. ( 즉 카메라에 가까울수록 내부를 더 쪼개서 더 부드럽게 만든다는 의미.. )

Tessellation 자체는 '고정 단계' (물론 참여 안할 수 있지만) 이며, Hull ShaderDomain Shader 는 '프로그래밍 가능한 셰이더 단계' 이므로 어떻게 내부를 쪼갤 것인지는 프로그래머가 구현해야 한다.

 

Geometry Shader (기하 셰이더) : 하나의 온전한 기본도형을 받아 임의로 변형하는 '프로그래밍 가능한 셰이더 단계' 이다.

기하 구조를 생성하거나 파괴할 수 있으며, 입력 기본 도형을 여러 기본 도형들로 확장할 수도 있고, 기본 도형들을 출력하지 않고 폐기할 수도 있다.

 

Rasterization (래스터라이저) : 넘어온 도형은 래스터라이저에 의해 픽셀로 변환됩니다.

이때 원근 투영(z 나누기), Clipping과 Culling, Viewport mapping, Fragment Shader 선택 및 호출 의 작업을 거칩니다.

래스터라이저에서 처리된 도형은 '픽셀'로 변환되어 Fragment Shader로 넘어갑니다.

 

Fragment Shader (Pixel Shader, 프래그먼트 셰이더) : 넘어온 보간된 정점 특성들에 따라 색상을 출력합니다.

즉 각 각의 '픽셀'들은 자신의 도형 정보와 정점 특성 등에 따라 색상을 출력하게 됩니다.

 

Color Blending & Framebuffer ( Output Merger ) : 이 단계에 의해 일부 픽셀들이 폐기 됩니다. (깊이 판정 & 스텐실 판정)

폐기되지 않은 픽셀들은 Framebuffer에 기록됩니다,

Color Blending 또한 이때 일어납니다. ( 겹치는 픽셀을 일정한 공식에 따라 혼합 )

 

이중에서 필수적인 셰이더는 'Vertex Shader''Fragment Shader' 뿐이다. 나머지 셰이더들은 생략가능한 단계이므로 알아두자.

사실 간단하게 작성된 정보도 있고, 조금 더 줄일 수 있었는데 더 설명한 부분도 있다.

간단하게 이런게 있구나 라고 넘어가면 된다. 추후에 다시 다 설명할 예정이다.

 

이제 간단한 Vertex Shader와 Fragment Shader를 작성해 봅시다.

현재 작성된 Vertex Buffer가 없으므로 Input Assembler 에서 넘어올 Vertex가 없습니다. 하드코딩하여 Vertex들을 나타낼 것입니다.

'simple_vs.vert' 라는 파일을 만들어 Vertex Shader를 작성합시다.

//simple_vs.vert

#version 450

vec2 positions[3] = vec2[] (
	vec2(0.0f, -0.5f),
	vec2(0.5f, 0.5f),
	vec2(-0.5f, 0.5f)
);

void main() {
	gl_Position = vec4(positions[gl_VertexIndex], 0.0f, 1.0f);
}

#version 450을 통해 glsl의 버전을 나타냅니다. 450은 glsl버전 4.5에 해당합니다.

우리는 삼각형을 그려낼 것이기 때문에 3개의 Vertex가 필요합니다. 각 각의 Vertex는 Winding Order를 따르며, 다음과 같이 나타내기 위해 하드코딩합니다.

각 Vertex들은 이미 투영공간에 있어 -1 ~ 1 사이에 있다고 가정할 것 입니다. 그러므로 화면의 중앙을 기준으로 좌표를 잡습니다.

 

Vertex Shader는 gl_Position이라는 특별한 System Value (Semantics)에 값을 할당하여 출력합니다. 

이는 glsl에서는 Built-in Variable 이라고 합니다. www.khronos.org/opengl/wiki/Built-in_Variable_(GLSL)

gl_VertexIndex는 특별한 System Value이며 (gl_VertexID와 동일합니다.) 현재 Vertex의 인덱스를 나타냅니다.

gl_Position의 세 번째 0.0f는 z값입니다. 우리는 2D 상에서 삼각형을 나타낼 것이기 때문에 0.0f 로 나타 냅니다,

네 번째 1.0f은 w값입니다. 이 값을 통해 래스터라이저가 '원근 투영 (z 나누기)'을 처리하는데, 우리는 지금 상태 그대로를 원하므로 1.0f으로 나눕니다.

 

다음은 Fragment Shader를 작성합니다.

래스터라이저에서 넘어온 픽셀들에 색상을 나타낼 것입니다.

'simple_fs.frag' 라는 파일을 만들어 Fragment Shader를 작성합니다.

 

//simple_fs.frag

#version 450

layout (location = 0) out vec4 outColor;

void main() {
	outColor = vec4(1.0f, 0.0f, 0.0f, 1.0f);
}

 layout (location = 0)사용된 또는 사용 될 버퍼를 가르킵니다. 작성하지 않아도 무방합니다. 알아서 매핑됩니다.

각 셰이더는 그래픽 파이프라인을 설정한 방법에 따라 다른 위치 값을 받습니다. 이를 설정해주는 한정자인데, 지금은 0 위치만 사용합니다.

즉 셰이더 입/출력 변수의 인덱스를 지정할 때 자주 사용됩니다. 이 위치의 버퍼를 사용하는데 vec4와 같이 사용하겠다는 뜻입니다.

그리고 out 키워드를 통해 이 값을 출력으로 사용하겠다는 내용입니다.

 

Fragment Shader는 색상을 나타냅니다. 각 색상은 RGBA 에서 0.0 ~ 1.0 사이에 있어야 합니다.

다음 outcolor = vec4(1.0f, 0.0f, 0.0f, 1.0f) 은 완전히 불투명한 색상을 빨간색으로 칠하겠다는 뜻입니다.

어떤 픽셀이 기하구조에 포함되는지 알기 때문에, 그 픽셀을 다음과 같은 색상으로 나타냅니다. 

 

이제 작성된 셰이더들을 컴파일해야 합니다.

컴파일을 통해 Standard portable intermediate representation V (SPIR-V)라는 표준 바이너리 중간 파일을 만들어 내고 이를 통해 다양한 언어와 하드웨어 아키텍처에서 실행할 수 있도록 합니다.

이미 Vulkan SDK를 설치하며 Bin 폴더 내에 glslc.exe라는 파일이 있을 것입니다. 이를 통해 컴파일합니다.

 

C:\VulkanSDK\1.2.170.0\Bin32 (기본 설치경로) 를 들어가 glslc.exe를 찾고, Shift-오른쪽 클릭 후 '경로로 복사' 를 클릭합니다.

그런 후 compile_shader.bat이라는 파일을 생성한 후 다음과 같이 작성합니다.

 

//compile_shader.bat

C:\VulkanSDK\1.2.170.0\Bin32\glslc.exe shaders\simple_vs.vert -o shaders\simple_vs.vert.spv
C:\VulkanSDK\1.2.170.0\Bin32\glslc.exe shaders\simple_fs.frag -o shaders\simple_fs.frag.spv

경로 셰이더 위치  -o (출력 옵션) 이름

과같이 작성하여 각 셰이더들을 컴파일 하는 명령어를 작성합니다. 그리고 compile_shader.bat을 실행합니다.

 

그럼 다음과 같이 각 셰이더들이 컴파일 되어 .spv 확장자로 나타납니다.

 

이제 셰이더를 컴파일 하였으므로, 이 파일을 프로그램으로 읽어야 합니다.

파이프라인을 나타내는 코드를 작성합니다.

 

//Pipeline.h

#pragma once

#include <vector>
#include <string_view>

namespace Core {
	class Pipeline {
	private:
		static std::vector<char> ReadFile(const std::string_view& filePath);
		void CreateGraphicsPipeline(const std::string_view& vertFilePath, const std::string_view& fragFilePath);

	public:
		Pipeline(const std::string_view& vertFilePath, const std::string_view& fragFilePath);
	};
}
//Pipeline.cpp

#include "Pipeline.h"

#include <fstream>
#include <stdexcept>
#include <iostream>

namespace Core{
	std::vector<char> Pipeline::ReadFile(const std::string_view& filePath) {
		std::ifstream file{ filePath.data(), std::ios::ate | std::ios::binary };

		if (!file.is_open()) 
			throw std::runtime_error{ "failed to open file" };

		size_t fileSize{ static_cast<size_t>(file.tellg()) };
		std::vector<char> buffer(fileSize);

		file.seekg(0);
		file.read(buffer.data(), fileSize);

		return buffer;
	}

	void Pipeline::CreateGraphicsPipeline(const std::string_view& vertFilePath, const std::string_view& fragFilePath){
		auto vsCode{ ReadFile(vertFilePath) };
		auto fsCode{ ReadFile(fragFilePath) };

		std::cout << "Vertex Shader Code Size :" << vsCode.size() << std::endl;
		std::cout << "Fragment Shader Code Size:" << fsCode.size() << std::endl;
	}

	Pipeline::Pipeline(const std::string_view& vertFilePath, const std::string_view& fragFilePath){
		CreateGraphicsPipeline(vertFilePath, fragFilePath);
	}
}

아직 파이프라인을 완전히 구축할 수 없으므로, 실제로 컴파일된 셰이더 코드들이 제대로 컴파일 되었는지만 확인할 것 입니다.

std::cout을 통해 각 셰이더들의 코드 크기를 출력합니다.

 

#pragma once

#include "Window.h"
#include "Pipeline.h"

namespace App {
	class FirstApp {
	private:
		//...
		Core::Pipeline pipeline{"shaders/simple_vs.vert.spv", "shaders/simple_fs.frag.spv"};
	};
}

다음은 우리가 2장에서 작성했던 FirstApp에 새로운 Pipeline 객체를 추가합니다.

추가한 후 컴파일 했을 시, 제대로 컴파일 됐다면 콘솔창에 각 셰이더들의 크기가 나타납니다.

 

아직은 Modern 내용이 덜 추가가 되어있고, 그래픽스에 대한 더 많은 내용이 남아있습니다.

다음에 더 자세하게 알아보도록 합시다.

 

std::string_view같은 경우 C++17에서 등장하였으며 std::string <-> const char* 간의 암시적 변환에 의한 오버헤드를 막기 위한 상수 시퀀스 참조 객체 입니다.