Scientia Conditorium

[OpenGL 006] 그래픽스 파이프 라인 및 간단한 쉐이더 프로그램 본문

프로그래밍/컴퓨터 그래픽스

[OpenGL 006] 그래픽스 파이프 라인 및 간단한 쉐이더 프로그램

크썸 2025. 1. 12. 02:48

이전글 : https://molonlabe.tistory.com/123

 

[OpenGL 005] Keyboard Callback 배경 색상 바꾸기

이전글 : https://molonlabe.tistory.com/122 [OpenGL 004] Keyboard Callback 등록이전글 : [OpenGL003] Refresh Callback 함수 등록 [OpenGL003] Refresh Callback 함수 등록이전글 : [OpenGL002] glew 설치 및 에러 수정 [OpenGL002] glew

molonlabe.tistory.com


3D 그래픽스 파이프라인

3D 그래픽스 파이프라인은 데이터를 입력받아 최종 화면에 출력되기 까지의 일련의 과정을 의미합니다. 여기서 매우 많은 데이터를 단계적으로 처리하는데 과정을 서술해보면 다음과 같습니다.

  • API 입력/호출 (Host, main board)
  • Primitive Processing (이 아래에서 부터는 GPU 단계, Graphics card)
  • Transform & Lighting
  • Primitive Assembly
  • Rasterizer
  • Texture Environment
  • Color Sum
  • Fog
  • Alpha Test
  • Depth Stencil
  • Color Buffer Blend
  • Dither
  • Frame Buffer

물론 더욱 세부적으로 나뉠수 있지만 이 모든 단계를 하나한 설명하며 진행하면 시간이 오래 걸립니다. 따라서 우선은 가장 중요한 부분은 간략하게 설명하고 점차 깊이 탐구하는 방식으로 진행하겠습니다. 위 단계에서 핵심적인 부분만 뽑으면 아래와 같습니다.

  • 단순화된 그래픽스 파이프라인
    • Vertex input
    • Vertex processing → (사용자의) vertex data 좌표 변환
    • primitive assembly → vertex들을 점, 선분, 삼각형 등 도형으로 결합
    • raterization → primitive에 포함되는 pixel만 선정
    • fragment processing → 각 pixel에 관련 데이터를 결합
    • blend → 후처리 단계 (post-processing), fragment 단위 처리로 다양한 효과
    • frame buffer → 최종 결과물을 저장하고 화면에 출력

여기서 fragment라는 말을 사용하는데, pixel(location)과 색상, 깊이 등등이 합친 포괄적인 개념으로 보시면 되겠습니다.

위 과정이 전통적인 컴퓨터 그래픽스 파이프라인 입니다. 현대에서는 병렬 처리(parallel processing)로 가속(acceleration을 하게 되는데, 이렇게 병렬 처리로 결합된 형태를 프로그래머블 파이프 라인이라고 합니다. 즉, 기존 파이프라인에서 Vertex/fragment processing 부분이 병렬적으로 엄청나게 많이 처리하게 됩니다. 이렇게 해서 전체적인 처리 속도를 높인형태입니다. 각 병렬처리 하는 부분을 GPU 전용 언어를 사용해서 처리하는데 이것이 vertex/fragment shader 입니다. 

 

OpenGL에서 사용하는 쉐이더 언어를 OpenGL Shader Language, GLSL 이라 합니다. 조금 더 풀어서 쓰면 Open Graphics Library Shader Language 입니다. 마치 C언어처럼 사용할 수 있게 되어있습니다. 반면 DirectX에서 사용하는 쉐이더 언어를 High-Level Shader Language, HLSL 이라 합니다. 이제 이 쉐이더 언어를 사용해보려 합니다.

 

 

Shapder 프로그램 구조

쉐이더 프로그램 순서는 크게 5단계로 나뉘어져 있습니다.

  1. glCreateProgram
  2. glAttachProgram → Vertex Shader
    1. glCreateShader
    2. glShaderSource
    3. glCompileShader
  3. glAttachProgram → Fragment Shader
    1. glCreateShader
    2. glShaderSource
    3. glCompileShader
  4. glLinkProgram
  5. glUseProgram

attachProgram에서 vertex와 fragment 쉐이더를 각각 만들어주어야 합니다. 쉐이더 프로그램에서 compile, link, use를 사용하여 vertex data를 제공하면 쉐이더 프로그램은 자동으로 실행됩니다.

 

vertex 쉐이더와 fragment 쉐이더를 만들어서 컴파일 해주면 각각 obj 파일이 생성되는데, 이를 쉐이더 프로그램에 붙여주어야 합니다. 그러면 쉐이더 프로그램에서 이 모든 obj 파일들을 다 합쳐서 연결(Link) 시킨 후 하나의 exe 파일로 만들어둡니다. 그러나 전체 OpenGL 프로그램 관점에서 보면 이러한 exe 쉐이더 프로그램이 무수히 많이 만들 수 있습니다. 이 중에 어느 프로그램을 사용할지 지정해주어야 하는데 이것이 glUseProgram 입니다. 어떤 프로그램을 사용할 지는 ID number를 이용하기 때문에 glCreateShader와 glCreateProgram 함수의 반환값이 uint로 ID number가 됩니다.

 

여기서 주의할 점은 glAttachProgram에서 반드시 vertex shader와 fragment shader 1개씩을 결합시켜야 합니다. vertex shader만 2개 결합시키거나, 둘 중 1개만 결합하면 오류가 발생합니다.

 

간단한 삼각형 Shader 프로그램 작성

먼저 삼각형 객체에 대한 클래스를 하나 추가하겠습니다. 그리고 이 삼각형을 쉐이더 프로그램을 이용해 그릴 것이기 때문에 shaderCompile 이라는 함수도 추가하겠습니다.

// Triangle.h
#pragma once
#include "GL/glew.h"
#include "GLFW/glfw3.h"

class Triangle
{
public:
	Triangle();
	~Triangle() = default;

	void shaderCompile();
};

 

이제 구현부에서 shaderCompile 함수를 작성하겠습니다. 앞서 설명드린대로 쉐이더 프로그램 작성 순서에 따라 프로그램을 만들고 붙이고 연결(링크)시키고 사용하면 됩니다. 그러나 편의를 위해 2-1,2,3과 3-1,2,3 순서를 먼저 진행하겠습니다. 즉, 쉐이더 소스를 먼저 만들고 컴파일 시킨 다음에 쉐이더 프로그램을 생성하고 붙이려 합니다. 무슨 말인지 조금 혼동이 온다면 코드를 보고 이해하시는게 더 쉬울 것 입니다.

void Triangle::shaderCompile()
{
	GLuint vert = glCreateShader(GL_VERTEX_SHADER);		// vertex 쉐이더 생성
	glShaderSource(vert, 1, &mVertexSource, nullptr);	// vertex 쉐이더 소스 입력
	glCompileShader(vert);					// vertex 쉐이더 코드 컴파일

	GLuint frag = glCreateShader(GL_FRAGMENT_SHADER);	// fragment 쉐이더 생성
	glShaderSource(frag, 1, &mFragSource, nullptr);		// fragment 쉐이더 소스 입력
	glCompileShader(frag);					// fragment 쉐이더 코드 컴파일

	mProgram = glCreateProgram();	// 쉐이더 프로그램 사용
	glAttachShader(mProgram, vert);	// vertex 쉐이더 입력
	glAttachShader(mProgram, frag);	// fragment 쉐이더 입력
	glLinkProgram(mProgram);	// 입력된 쉐이더 코드들 연결

	glUseProgram(mProgram);		// 연결된 쉐이더 코드 사용
}

 

이 삼각형 객체에서만 사용하는 쉐이더 코드와 프로그램이기 때문에 멤버 변수로 빼주었습니다. 다시 정리하자면 우리는 사용할 쉐이더 코드들을 입력해서 컴파일 시켜놓고, 쉐이더 프로그램에 입력하고 연결시킨 후 사용하면 됩니다! 이제 다시 멤버 변수로 빼놓은 부분을 정리하겠습니다. 즉, 삼각형 헤더 파일은 다음과 같이 됩니다.

#pragma once
#include "GL/glew.h"
#include "GLFW/glfw3.h"

class Triangle
{
public:
	Triangle();
	~Triangle() = default;

	void shaderCompile();

private:
	const char* mVertexSource =
		"#version 330 core \n\
		in vec4 vertexPos; \n\
		void main(void) { \n\
			gl_Position = vertexPos; \n\
		}";
	const char* mFragSource =
		"#version 330 core \n\
		out vec4 FragColor; \n\
		void main(void) { \n\
			FragColor = vec4(1.0, 0.0, 0.0, 1.0); \n\
		}";

	GLuint mProgram = 0;
};

 

쉐이더 코드들은 문자열이고 한번 입력해두면 변하지 않기 때문에 const char* 형태로 생성하였습니다. 각 명령어를 조금 자세히 설명해보겠습니다. 이 예제에서는 아주 간단한 삼각형 그리기이기 때문에 단순히 데이터가 들어오면 그대로 출력하도록 설정해주었습니다.

  • Vertex Shader Program
    • in : attribute register
    • out : varying register
    • gl_Position : pre-defined out, vertex position을 저장

vertexSource에서 사용자가 입력으로 attribute register에 무언가를 줄 때 이를 받아주는 용도로 사용하기 위해 자료형 앞에 in 키워드로 시작합니다. register를 4개 사용할 것이기 때문에 자료형은 vec4로 설정합니다. 여기에는 각각 좌표값을 의미하며 x, y, z, w가 되겠습니다. 그리고 메인 함수는 C언어와 마찬가지로 입력받은 vertexPos을 미리 정의되어 있는 gl_Position에 그대로 출력합니다. 물론 이 좌표값들은 바꿀 수 있지만 우선은 입력받은 그대로 출력하도록 진행하겠습니다.

  • Fragment Shader Program
    • in : varying registers
    • out : framebuffer update

fragment 쉐이더는 앞서 vertex 쉐이더에서 입력받은 좌표들을 토대로 그려질 객체 내부에 있는 영역들의 모든 fragment 마다 실행됩니다. 이 예제에서 출력되는 값은 FragColor라고 이름 짓고 4개가 묶인 자료형으로 vec4인데 여기서 fragment는 좌표가 아니라 R, G, B, A 색상을 의미하게 됩니다. 그래서 그 색상을 어떤 값으로 줄 것이냐는 1.0, 0.0, 0.0, 1.0이기 때문에 fragment 쉐이더 소스의 의미는 완전히 불투명한 빨간색으로 나타내라 입니다. 다시 말해 선택받은 모든 fragment 색상은 빨간색으로 바꾸어라 라는 의미입니다.

 

쉐이더 프로그램이 실행될 때에는 병렬적으로 처리됩니다. 삼각형을 예제로 들자면 3개의 좌표가 필요합니다. vertex shader 프로그램은 각 좌표마다 하나씩 실행됩니다. 쉐이더 프로그램 자체는 똑같지만 각 좌표마다 입력값이 다르기 때문에 다른 결과를 나타냅니다. 즉 vertex shader는 vertex 개수만큼 실행된다고 생각하면 됩니다.

fragment 쉐이더는 vertex shader로 만들어진 객체로 이루어진 영역에 포함된 fragment 개수 만큼 병렬로 실행됩니다. 일반적으로 수천개 쉐이더 프로그램이 실행됩니다.

 

vertex, fragment 쉐이더 소스코드 제일 첫 번째 줄에는 '#version 330 core' 라고 입력되어 있습니다. 이것은 일종의 프리프로세스 코드로 OpenGL 3.3 버전을 기준으로 작성되었다는 의미입니다. core라는 의미는 extension 즉, 확장 버전 말고 핵심 기능만 사용하겠다는 의미입니다. 3.3 이후의 모든 OpenGL 컴파일러들은 이 코드를 이해하고 사용할 수 있다는 의미입니다.

 

Renderer에 삼각형 객체 추가하기

이제 이전 포스팅에서 만들어둔 Renderer 에다가 삼각형 객체를 추가하겠습니다.

#pragma once
#include <iostream>
#include "GL/glew.h"
#include "GLFW/glfw3.h"

#include "Triangle.h"

class Renderer
{
public:
	Renderer();
	~Renderer();

	bool initialize();
	void run();

private:
	static void refreshFunc(GLFWwindow* window);
	static void keyFunc(GLFWwindow* window, int key, int scancode, int action, int mods);

	GLFWwindow* mWindow = nullptr;
	Triangle mTriangle;

	static float mColor[4];
};

 

이전에 만들어둔 Renderer 헤더에서 Triangle 헤더를 가져와 멤버 변수로 추가하였습니다. 이제 렌더링 루프를 돌기 전에 초기화 및 컴파일을 시키면 되겠습니다.

 

// Renderer.cpp

...

void Renderer::run()
{
	mTraingle.shaderCompile();

	while (!glfwWindowShouldClose(mWindow))
    
...

 

여기까지는 문제 없었습니다. 그러나 렌더링 루프에서 삼각형을 그리기 위한 작업이 빠졌군요. 다시 Triangle 클래스로 돌아가서 update() 함수를 추가하여 매 프레임마다 동작할 내용을 구현하겠습니다.

 

// Triangle.h

...
	const char* mFragSource =
		"#version 330 core \n\
		out vec4 FragColor; \n\
		void main(void) { \n\
			FragColor = vec4(1.0, 0.0, 0.0, 1.0); \n\
		}";

	GLuint mProgram = 0;
	GLfloat mVertPos[16] = {
	-0.5F, -0.5F, 0.0F, 1.0F,
	+0.5F, -0.5F, 0.0F, 1.0F,
	-0.5F, +0.5F, 0.0F, 1.0F,
	};
};

///////////////////////////////////////////////////

// Triangle.cpp

void Triangle::update()
{
	glClear(GL_COLOR_BUFFER_BIT);					// 화면 지우기
	GLuint loc = glGetAttribLocation(mProgram, "vertexPos");	// vertexPos 위치 찾기
	glEnableVertexAttribArray(loc);					// 찾은 위치 사용
	glVertexAttribPointer(loc, 4, GL_FLOAT, GL_FALSE, 0, mVertPos);	// C프로그램 array와 연결 및 데이터 입력

	glDrawArrays(GL_TRIANGLES, 0, 3);				// 좌표 3개를 연결하여 렌더링
	
	glFinish();
}

 

이제 Triangle::update() 함수를 매 프레임마다 호출할 것입니다. 먼저 화면을 지웁니다. 다음 앞서 만들어둔 쉐이더 프로그램에다가 데이터를 입력해주어야 합니다. shaderCompile() 함수를 통해 실행된 쉐이더 프로그램은 이미 GPU에서 동작하고 있는 상태입니다. 이 상태에서 데이터를 주기만 하면 결과값이 화면에 나오는 형태입니다. 우리는 vertex shader 입력값을 'vertexPos'라고 지었기 때문에 해당 이름에 값을 주겠다라고 함수를 호출하면 됩니다. glGetAttribLocation 함수는 현재 등록된 attribute 레지스터들 중에 몇 번 레지스터들에 가서 해당 값을 찾는 기능입니다.

찾은 값을 사용하겠다는 의미로 glEnableVertexAttribArray 함수를 사용하고, 이제 찾은 값에 데이터를 주는 함수로 glVertexAttribPointer를 이용합니다.

 

여기서 위치가 고정된 삼각형을 그리려고 하기 때문에 삼각형 위치 좌표를 멤버 변수로 선언해 두었습니다. 이제 삼각형 위치 좌표 mVertepos를 변수에 넣어줍니다. 이후 입력된 레지스터 값들을 GL_TRIANGLES 형태로 그리라고 명령(glDrawArrays)를 하게 되면 앞서 입력해둔 쉐이더 프로그램이 적용된 파이프라인을 돌면서 렌더링을 하게 됩니다. 마지막으로 glFinish()를 통해  OpenGL의 모든 실행을 완료시켰습니다.

 

이제 이 update() 함수를 Renderer 렌더링 루프에 집어넣으면 됩니다.

 

// Renderer.cpp

...

void Renderer::run()
{
	mTriangle.shaderCompile();

	while (!glfwWindowShouldClose(mWindow))
	{
		glClear(GL_COLOR_BUFFER_BIT);
		//glBegin(GL_TRIANGLES);
		//glColor3f(1.0f, 0.0f, 0.0f);
		//glVertex2f(-0.5f, 0.0f);
		//glVertex2f(0.0f, 0.5f);
		//glVertex2f(0.5f, 0.0f);
		//glEnd();
		mTriangle.update();

		glfwSwapBuffers(mWindow);
		glfwPollEvents();
	}
}
    
...

 

기존에 작성했던 glVertex 함수들은 전부 주석 처리해두고 mTriangle.update() 함수를 호출해줍니다. 이렇게 하고 프로그램을 실행하면 아래와 같은 결과를 얻을 수 있습니다.