[vk] Drawing triangle - graphics pipeline basics - Shader modules

2021. 8. 9. 05:11그래픽스/vk

1. Shader modules

- 이전의 다른 API가 달리 Vulkan의 shader 코드는  GLSL  HLSL 같은 사람이 읽을 수 있는 구문과 달리

- 바이트 코드 형식으로 지정해야함

- 이 바이트 코드 형식은 SPIR-V 이며, Vulkan 과 OpenCL(둘다 Khronos API)을 위해 설계된것이다.

- 그래픽을 작성하고, 셰이더를 계산하는데 사용할 수 있는 format이지만,

- 이 튜토리얼에선, 그래픽 파이프라인에서 사용되는 shaders에만 중점을 둠.

 

 

 

 

1.1 bytecode format의 장점

- GPU 회사가 제공하는 compilers 가 덜 복잡함 (shader code를 native code로 변환하는게)

- 과거는 GLSL과 같은 사람이 읽을 수 있는 구문을 사용, GPU공급업체가 좀더 표준해석에 다소 유연했음

- 이로인해 non-trivial shader가 서로 다른 공급업체의 GPU에서 구문 오류, 컴파일러 버그. 다른 동작을 보여줄 가능성이있었음.

- SPIR-V같은 format은 이런 상황들을 피할 수 있음.

 

 

 

1.2 GLSL to SPIR-V 컴파일러

- 바이트 코드를 손으로 작성하지 않고

- Khronos가 출시한 vendor-independent compiler를 사용할 수 있음.

- 이 컴파일러는 쉐이더 코드가 완전히 표준을 준수하는지 확인하고,

- 프로그램에서 사용할 수 있는 하나의 SPIR-V 바이너리를 생성하도록 설계됨.

- 이 컴파일러를 라이브러리로 포함하여 런타임에 SPIR-V를 생성할 수도 있지만

- 튜토리얼에선 그렇게 사용하지 않음

- glslangValidator.exe를 통해 직접 컴파일러를 사용할 수 있지만

- 대신 구글의 glslc.exe 를 사용할것임.

- glslc은 GCC, Clang 컴파일러랑 똑같은 파라미터 포맷을 사용하고 있어 사용하기 편함

- 그리고 몇몇 includes 와 같은 부가적인 기능이 포함되어있음

- 이 두가지는 이미 Vulkan SDK에 포함되어있으므로 따로 다운안해도 사용가능하다.

 

 

 

1.3 GLSL

- c 스타일의 shading language

- 이 프로그램은 main function 이 있어야하며, 모든 객체에의해 호출됨.

- 입력에 매개변수를 사용하고, 출력으로 반환값을 사용하는대신

- GLSL은 전역 변수를 사용하여 입력 및 출력을 처리함.

- 많은 그래픽스 프로그래밍을 도울 기능들을 내장하고있음( like built-in vector and matrix primitives )

- Functions for operations like cross products, matrix-vector products and reflections around a vector are included

- vector 타입 - vec + n , vec3(vec2(1.0, 2.0), 3.0) 같은 생성자, vec3(1.0, 2.0, 3.0).xy 와 같은 새로운 벡터 생성

- 삼각형을 만들기 위해 vertex shader 와 fragment shader 를 작성해야한다.

- 이 다음 섹션에서는 GLSL 코드를 다루고, SPIR-V 바이너리를 생성하고, 프로그램을 로드하는 방법을 다룸.

 

 

2. Vertex shader

2.1 vertex shader

- 들어오는 모든 vertex를 처리함.

- Attributes 입력 : world position, color, nomal, texture coordinates

- 출력 : final position in clip coordinates, fragment shader에 넘겨야할 attributes 

- 출력값들은 smooth gradient를 생성하기위해 rasterization에 의해 fragments 전체에 걸쳐 보간되어짐

 

2.2 clip coordinate (절단 좌표)

- " 동차좌표 (homogeneous coordinates)"

- 4차원 벡터이며, 전체벡터를 벡터의 마지막 요소로 나누어 normalized device coordinate로 변환함.

  (절단 좌표에서의 원근 분할 - > 정규화 장치 좌표)

- 이러한 normalized device coordinate는 frame buffer를 다음과 같이 [-1, 1] x [-1, 1] 좌표계에 매핑함

 

openGL 에서의 정규화 장치 좌표계 - LearnOpenGL - Hello Triangle

- OpenGL의 Y좌표가 뒤집혀진것을 알 수 있음.

- z 좌표는 0에서 1까지로, Direct3D에서와 동일한 범위를 사용한다.

- 이 튜토리얼에서 그릴 첫번째 삼각형의 경우, 변환을 적용하지 않고,

- 세 정점의 위치를 정규화된 장치 좌표로 직접 지정하여 다음 모양을 만들것임.

- 마지막 구성요소(w)가 1로 설정된 vertex shader에서 절단 좌표로 출력하여

- 정규화된 장치 좌표를 직접 출력할 수 있음

- 이렇게 하면 절단 좌표를 정규화 장치 좌표로 변환하는 부분에서 아무것도 바뀌지않음

  (변환은 w값을 나눠주는 원근 분할이므로)

- 일반적으로 Vulkan에선 이러한 좌표들은 vertex buffer에 저장되어지지만

- 이 과정은 복잡하다 (vertex buffer를 만들고, 데이터로 채우는것)

- 아래와 같이 비전통적인(unorthodox) 방법으로 삼각형 좌표를 내부에 포함시킬것임

#version 450

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

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

 

- main의 기능은 모든 정점에 대해 호출됨

- 내장변수 gl_VertexIndex 는 현재 정점의 인덱스를 포함함.

- 이것은 일반적으로 vertex buffer에 대한 인덱스이지만, 이 경우 하드 코딩된 배열에 대한 인덱스가 됨

- 각각 정점의 position은 상수배열에 접근하고, z(0.0), w(1.0) 의 요소와 결합하여 절단좌표를 나타냄

- 내장 변수 gl_Position 는 출력으로 작동

 

 

3. Fragment shader

- vertex shader에서 형성되는 삼각형은 화면상의을 영역을 fragments로 채움.

- 이 fragment shader 는 이러한 fragments에서 호출되어

- framebuffer(or framebuffers)의 색상과 깊이를 생성함.

- 아래는 전체 삼각형에 대해 빨간색을 출력하는 간단한 셰이더

#version 450

layout(location = 0) out vec4 outColor;

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

- main 함수는 모든 fragment에서 호출됨

- Colors 는 RGB, alpha channels로 vec4, [0,1] range 이다.

- gl_Position 과 달리 현재 fragment에 대한 내장 출력변수가 없으므로 따로 명시해야함

- layout(location = 0) 수정자에 명시된 인덱스를 사용(0)하여  각 framebuffer에 대해 출력변수를 명시해야함.

- vec4(1.0, 0.0, ..) 으로 지정된 빨강색은 결국 framebuffer에 연결된 이 outColor에 쓰여짐

 

 

 

 

4. Per-vertex colors

- 위와 같은 삼각형을 만드려면, 두 쉐이더를 조금 수정해야함.

- 먼저 아래와 같은 배열에 색상을 저장하고, 각 정점에 대해 고유한 색상을 지정해야함.

- vertex shader에 다음 배열을 추가해야함.

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)
);

이제 이러한 정점별 색상을 fragment shader 에 전달하면 보간된 값을 framebuffer에 출력할 수 있음

- vertex shader에 색상 출력을 추가하고 main함수에 대입해야함,

layout(location = 0) out vec3 fragColor;

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

- 다음으로 fragment shader에 일치하는 입력을 추가해야함 

layout(location = 0) in vec3 fragColor;

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

- 입력 변수의 이름은 반드시 일치시킬 필요는 없음

- location 지시문에 지정된 인덱스를 사용하여 함께 연결하면됨.

- main 함수에서 알파값과 함께 출력하는 색을 변형하고 있는것을 볼 수 있음.

- 위 이미지에서 볼 수 있듯이 이 색상에 대한 값 fragColor 은 3정점 사이의 fragment에 대해 자동으로 보간되어

- smooth gradient가 된다.

 

 

 

 

5. Compiling the shaders

- shaders 폴더를 생성하고 vertex shader, fragment shader를 

- shader.vert, shader.frag로 쉐이더를 파일에 저장한다.

- GLSL에는 공식 확장이 없지만, 일반적으로 이 두 셰이더를 구분하는데 사용

- 쉐이더의 전체 코드는 다음과 같음

#version 450

layout(location = 0) out vec3 fragColor;

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

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)
);

void main() {
    gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
    fragColor = colors[gl_VertexIndex];
}
#version 450

layout(location = 0) in vec3 fragColor;

layout(location = 0) out vec4 outColor;

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

- 이제 이 두 파일을 glslc 프로그램을 사용하여 SPIR-V 바이트 코드로 컴파일 해야함

 

5.1 windows

- compile.bat 파일을 생성하고 다음 내용으로 저장하여, 파일을 두번 클릭하여 실행하면 된다 (경로는 Vulkan sdk)

C:/VulkanSDK/x.x.x.x/Bin32/glslc.exe shader.vert -o vert.spv
C:/VulkanSDK/x.x.x.x/Bin32/glslc.exe shader.frag -o frag.spv
pause

5.2 linux

- compile.sh

- 스크립트를 실행가능하게 chmod +x compile.sh 명령어를 사용, 실행

/home/user/VulkanSDK/x.x.x.x/x86_64/bin/glslc shader.vert -o vert.spv
/home/user/VulkanSDK/x.x.x.x/x86_64/bin/glslc shader.frag -o frag.spv
이 두 명령은 컴파일러에게 GLSL 소스파일을 읽고
-o (출력) 플래그를 사용하여 SPIR-V 바이트코드 파일로 출력하도록 지시한것
만약 구문오류가 포함된 경우 컴파일러는 줄번호와 문제를 알려줌 (ex. 세미클론생략)
    (도움말, 지원하는 플래그 종류확인 : 인수없이 컴파일러 실행, 바이트 코드를 사람이 읽을 수 있는 형식으로 출력 가능, 이      단계에서 적용할 수 있는 최적화 기능)
- 명령줄에서 쉐이더를 컴파일하는것은 가장 간단한 옵션중 하나이며 튜토리얼에서 사용할 옵션
- 자신의 코드에서 직접 쉐이더를 컴파일하는것도 가능.
- Vulkan SDK에는 프로그램 내에서 GLSL코드를 SPIR-V로 컴파일하는 라이브러리인  libshaderc 가 포함됨.

 

6. Loading a shader

- 이제 쉐이더를 생성하였으니 프로그램에 로드하여 그래픽 파이프라인에 적절히 연결해야한다.

- 먼저 이진데이터를 로드하는 간단한 helper function을 작성해야함

 

#include <fstream>

...

static std::vector<char> readFile(const std::string& filename) {
    std::ifstream file(filename, std::ios::ate | std::ios::binary);

    if (!file.is_open()) {
        throw std::runtime_error("failed to open file!");
    }
}
readFile 함수
- 지정된 파일에서 모든 바이트를 읽고, 바이트 배열을 std::vector 로 반환
- 두개의 플래그로 파일을 열었음
  • ate: Start reading at the end of the file
  • binary: Read the file as binary file (avoid text transformations)
- 파일 끝에서 읽기 시작하는것은 읽기 위치를 사용하여 파일 크기를 결정하고, 버퍼를 할당할 수 있는 장점이 있음.
- tellg() : 객체의 입력 위치 지정자를 반환 (현재 getpointer 가 가리키고 있는 위치를 리턴, 실패시 -1)

size_t fileSize = (size_t) file.tellg();
std::vector<char> buffer(fileSize);

그 후 파일의 시작 부분으로 되돌아가서 모든 바이트를 한번에 읽을 수 있음

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

- 마지막으로 파일을 닫고 바이트를 반환

file.close();

return buffer;

- 이제 이 함수를 호출하는 createGraphicsPipeline 를 만들어 두 쉐이더를 로드

void createGraphicsPipeline() {
    auto vertShaderCode = readFile("shaders/vert.spv");
    auto fragShaderCode = readFile("shaders/frag.spv");
}

- 버퍼의 크기를 터미널에 출력하여 실제 파일크기(바이트)와 일치하는지 확인하여 쉐이더가 올바르게 로드되었는지 확인해야함

- 바이너리 코드이기 때문에 null로 종료될 필요가 없으며, 나중에 크기에 대해 따로 명시할 것임.

 

 

 

 

7. Creating shader modules

- 코드를 파이프라인에 전달하기 전에 VkShaderModule 객체로 래핑해야함

- 이를 위해 또다른 helper function인 createShaderModule 를 생성

VkShaderModule createShaderModule(const std::vector<char>& code) {

}

- 이 함수는 바이트 코드가 매개변수로 포함된 버퍼를 가져와서 VkShaderModule를 생성함.

- shader module을 생성하는것은 간단함. 

- 바이트 코드가 담긴 버퍼에 대한 포인터와, 그것의 길이만 구조체에 넘겨주면됨

- 이러한 구조체는  VkShaderModuleCreateInfo이며, 이때까지와 비슷한흐름이다.

- 한가지 문제점: 바이트 코드의 크기가 바이트로 지정되지만, 바이트코드 포인터는 uint32_t가 아니라 char포인터라는것

- 따라서 reinterpret_cast 로 포인터를 타입 캐스팅해야한다.

- 이처럼 타입캐스팅을 할때는 데이터가  the alignment requirements of uint32_t를 충족하는지 확인해야함

    (정렬 요구사항)

- 운 좋게도 데이터는 std::vector에 저장되어있기 때문에 컨테이너가 자체의 기본 할당자가 이미 입력되는 데이터가 적합한 데이터 정렬 요구 형식 사항에 충족하는지 확인할것임.

When you perform a cast like this, you also need to ensure that the data satisfies the alignment requirements of uint32_t. Lucky for us, the data is stored in an std::vector where the default allocator already ensures that the data satisfies the worst case alignment requirements.
VkShaderModuleCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
createInfo.codeSize = code.size();
createInfo.pCode = reinterpret_cast<const uint32_t*>(code.data());

-  vkCreateShaderModule 함수를 호출하여 VkShaderModule를 생성

- 파라미터 :  the logical device, pointer to create info structure, optional pointer to custom allocators and handle output variable

VkShaderModule shaderModule;
if (vkCreateShaderModule(device, &createInfo, nullptr, &shaderModule) != VK_SUCCESS) {
    throw std::runtime_error("failed to create shader module!");
}

The buffer with the code can be freed immediately after creating the shader module.

- Don't forget to return the created shader module

return shaderModule;

- Shader modules 은 그저 shader bytecode를 얇게 둘러싼 래퍼이다.

- GPU에서 실행하기 위해 SPIR-V 바이트코드를 기계어 코드로 컴파일하고 연결하는 작업은

- 그래픽 파이프라인이 생성될때까지 발생하지 않음.

- 즉, 파이프 라인 생성이 완료되는 즉시 쉐이더 모듈을 바로 파괴할 수 있음.

- 그래서 그냥 shader module을 지역변수로 생성한것. 

void createGraphicsPipeline() {
    auto vertShaderCode = readFile("shaders/vert.spv");
    auto fragShaderCode = readFile("shaders/frag.spv");

    VkShaderModule vertShaderModule = createShaderModule(vertShaderCode);
    VkShaderModule fragShaderModule = createShaderModule(fragShaderCode);

- vkDestroyShaderModule를 통해 아래와 같이 함수끝에서 정리를해줘야한다.

  (앞으로 나올 코드들은 이 코드 앞에 삽입)

    ...
    vkDestroyShaderModule(device, fragShaderModule, nullptr);
    vkDestroyShaderModule(device, vertShaderModule, nullptr);
}

 

8. Shader stage creation

- 실제로 쉐이더를 사용하려면, VkPipelineShaderStageCreateInfo 구조체를 통해 특정 파이프 라인 단계로 할당해야함.

- (이 구조체는 pipline을 생성하는 프로세스의 일부)

- createGraphicsPipeline 함수에 쉐이더 모듈을 만든 다음줄에 이 구조체를 작성해야함.

VkPipelineShaderStageCreateInfo vertShaderStageInfo{};
vertShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
vertShaderStageInfo.stage = VK_SHADER_STAGE_VERTEX_BIT;

vertShaderStageInfo.module = vertShaderModule;
vertShaderStageInfo.pName = "main";

- sType을 제외하고 나머지 멤버들은, Vulkan에 쉐이더가 사용될 파이프라인의 단계를 알려주는것임.

- stage : 이전 장에서 설명한 각 프로그래밍 가능한 단계에 대한 열거형 값이 있음(stage vertex)

- module : code를 포함한 shader module을 지정해야함.

- pName : 호출할 함수(entry-point)를 지정, 종류가 같은 여러 shader을 하나의 shader module로 결합하는게 가능해짐.

- 다른 진입점을 가지게하여 동작들을 구별할 수 있음 

- 여기선 흔히 사용하는 main 함수를 사용. 

- fragment shader 또한 쉽게 작성할 수 있음.

VkPipelineShaderStageCreateInfo fragShaderStageInfo{};
fragShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
fragShaderStageInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT;
fragShaderStageInfo.module = fragShaderModule;
fragShaderStageInfo.pName = "main";

- pSpecializationInfo : 선택적으로 사용

- - - - 여기에선 사용하지않지만, 논의할 가치가 있음

- - - - 이것을 통해 쉐이더 상수의 값을 지정할 수 있음

- - - - 파이프라인 생성 시 사용되는 상수에 대해 다른 값을 지정하여

- - - - 동작을 구성할 수 있는 단일 쉐이더 모듈을 사용할 수 있다.

- - - - 이것은 값에 의존하는 if문의 제거와 같은 최적화를 수행할 수 있기 때문에

- - - - 렌더링시 이러한 변수를 사용하여 쉐이더를 설정하는것은 좀더 효율적이다.

- - - -  이런 상수가 없으면, nullptr로 설정하여 무시할 수 있으며, 구조체 초기화작업에서 자동으로 할당됨.

 

 

마지막으로 두 구조체를 배열에 담아, 나중에 이 두값을 실제 파이프라인을 생성할 때 참조하도록할 것임.

VkPipelineShaderStageCreateInfo shaderStages[] = {vertShaderStageInfo, fragShaderStageInfo};

 

다음장은 fixed-function stage에 대해 다룸.

 void createGraphicsPipeline() {
        auto vertShaderCode = readFile("shaders/vert.spv");
        auto fragShaderCode = readFile("shaders/frag.spv");

        VkShaderModule vertShaderModule = createShaderModule(vertShaderCode);
        VkShaderModule fragShaderModule = createShaderModule(fragShaderCode);

        VkPipelineShaderStageCreateInfo vertShaderStageInfo{};
        vertShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
        vertShaderStageInfo.stage = VK_SHADER_STAGE_VERTEX_BIT;
        vertShaderStageInfo.module = vertShaderModule;
        vertShaderStageInfo.pName = "main";

        VkPipelineShaderStageCreateInfo fragShaderStageInfo{};
        fragShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
        fragShaderStageInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT;
        fragShaderStageInfo.module = fragShaderModule;
        fragShaderStageInfo.pName = "main";

        VkPipelineShaderStageCreateInfo shaderStages[] = {vertShaderStageInfo, fragShaderStageInfo};

        vkDestroyShaderModule(device, fragShaderModule, nullptr);
        vkDestroyShaderModule(device, vertShaderModule, nullptr);
    }

C++ code / Vertex shader / Fragment shader

 

https://vulkan-tutorial.com/Drawing_a_triangle/Graphics_pipeline_basics/Shader_modules