본문 바로가기
카테고리 없음

쉐이더 심화 1-openframeworks로 shader 작성

by mazayong 2024. 1. 19.

openframeworks로 vertex shader과 fragment shader를 기본 템플릿 형태로 만들어보겠다.

 

 

 

0. openframeworks 설치

1. 기본 프로젝트 세팅

2. mesh 생성

3. vertex shader 생성

4. fragment shader 생성

5. mesh와 shader 연결

(필자는 맥북에 vscode를 사용했다.)

 

 

 

<이 코드들은 아래 깃허브에 기록되어 있다.>

책은 셰이더 코딩 입문(카일 할러데이)를 참고했다.

https://github.com/AcornPublishing/practical-shader-develop

0. openframeworks 설치


0.1. 파일 다운로드

https://openframeworks.cc/download/ 해당 사이트에서 자신에게 맞는 OS 버전을 설치한다.
0.2. 기본 세팅

https://openframeworks.cc/setup/vscode/

위 사이트에서 setup 설정에 맞게 적절한 플러그인을 설치하고, Build Release를 통해 아래와 같은 화면을 확인한다.

 

 

 

 

1. 새 프로젝트 생성

projectGenerator -> projectGenerator.app을 키면, 필자는 addons와 libs폴더 경로 오류가 발생했다.

그래서 아래 링크를 참조해 주소를 바꾼 후 다시 키고, 현재 프로젝트 폴더의 위치를 수정해서 설정했다.

https://forum.openframeworks.cc/t/set-up-of-path-for-project-generator-on-mac-os/29679

"defaultOfPath": "/OFPATH/of_v0.10.0_osx_release"

 

이후 프로젝트 조건을 설정할 때 Verbose output, Advanced options를 전부 체크표시를 한 뒤 프로젝트를 생성해야 한다.

프로젝트 이름과 경로, 플랫폼이 설정되어있는지 확인하고 template에 사용하는 IDE를 설정한다.

(해당 조건을 하지 않을 경우 프로젝트의 .vscode의 properties에서 파일을 찾는 경로가 활성화되지 않아서 ofMain.h, ofApp.h 파일을 찾을 수 없다.)

 

 

 

 

 

 

2. Mesh 생성

2.1. 기본 코드 분석

openframeworks를 실행할 경우 main.cpp, ofApp.cpp, ofApp.h 소스코드 파일을 기본적으로 사용한다.

기본 화면 렌더링 코드는 아래와 같이 작성되어 있다.

int main( ){

	//Use ofGLFWWindowSettings for more options like multi-monitor fullscreen
	ofGLWindowSettings settings;
	settings.setSize(1024, 768);
	settings.windowMode = OF_WINDOW; //can also be OF_FULLSCREEN
	settings.setGLVersion(4, 1);

	auto window = ofCreateWindow(settings);

	ofRunApp(window, make_shared<ofApp>());
	ofRunMainLoop();

}

 

전반적으로 코드를 보면 윈도우를 세팅하고 ofCreateWindow로 세팅에 맞게 ofApp 객체를 생성하고, ofRunApp으로 생성한 객체를 실행한다.

이 때, openframeworks 0.10 버전은 OPENGL 2를 기본적으로 실행하게 설정되어 있고, 윈도우 세팅에 관한 내용이 추가되어 있지 않았지만 현재 사용하는 0.12 버전은 자동으로 OPENGL 4를 사용하도록 설정되어있다. 

(OPENGL : 렌더링 API 중 하나로, GPU와 정보를 주고받는 기능을 가진 함수이다. 여러 렌더링 API가 있지만, 가장 하드웨어 지원을 많이 하는 API이고 openframeworks를 사용해서 해당 프로그램을 채택했다.)

 

 

셰이더 = 렌더링 파이프라인의 단계 제어.

렌더링 파이프라인 = 메쉬를 대상으로 작성.

--> Mesh를 먼저 생성해야 함.

 

 

 

 

2.2. Mesh 생성

openframeworks에서는 메쉬 클래스가 설정되어 있다.

그래서 기본 생성자로 ofMesh 객체를 생성하고, 객체의 addVertex()함수에 3-component vector로 된 vertex 위치 정보를 전달한다.

(쉐이더를 사용하지 않고 메쉬 객체를 그리는 방식으로 생성할 경우, 정규화된 값이 아닌 버텍스 위치 정보를 직접 가져와야 한다. )

void ofApp::setup()
{
 	ofMesh triangle;
    
  	triangle.addVertex(glm::vec3(0.0, 0.0, 0.0));
	triangle.addVertex(glm::vec3(0.0, 768.0, 0.0));
	triangle.addVertex(glm::vec3(1024.0, 768.0, 0.0));
    
    
    /*
    shader 적용 후 NDC로 변환했을 때
    triangle.addVertex(glm::vec3(-1.0f, 1.0f, 0.0f));
    triangle.addVertex(glm::vec3(-1.0, -1.0f, 0.0f));
    triangle.addVertex(glm::vec3(1.0f, -1.0f, 0.0f));
    */
}

 

여기서 openframeworks는 GLM 수학 라이브러리로 벡터 연산을 처리하므로 glm 네임스페이스를 사용한다.

 

 

 

 

2.3. 스크린에 Mesh Rendering

ofApp의 draw()를 수정해서 렌더링 파이프라인에서 삼각형이 렌더링되도록 한다.

//frame 단위로 호출
void ofApp::draw()
{
	triangle.draw();
}

 

 

++ofMesh의 draw() ? 

= GPU로 하여금 특정 프레임에 해당 메쉬를 그리도록 지시할 때 사용된다.

 

해당 함수가 겹치는 문제 해결을 위해 삼각형 선언을 헤더파일로 옮기고, ofApp의 draw()에서 ofMesh의 draw를 호출했다.

결과는 다음과 같다.

 

 

++ 스크린 픽셀 좌표 시작점

화면 좌측 상단이 0.0

(1024, 0)은 화면의 우측 상단 꼭짓점.

 

(그림 좌표 그려준 거 보여주기)         

 

 

3. vertex shader 생성

GLSL에서 .vert라는 확장자 파일에 생성 + 메쉬/이미지 같은 에셋들이 있는 디렉터리에 저장. (다른 에셋처럼 런타임에서 불러오기 때문.)

openframeworks에서는 bin/data 폴더 안에서 파일을 만들어서 사용한다.

 

기본 쉐이더 형식을 보기 위해 first_shader.vert 파일을 만들고 아래 코드를 작성한다.

#version 410 
//opengl version

in vec3 position;

void main()
{
    gl_Position = vec4(position, 1.0);
}

 

3.1. in 

- GLSL만의 키워드로, 셰이더가 렌더링 파이프라인 앞단계로부터 전달받게 될 데이터를 구체적으로 명시할 때 사용.

- 3-component vector 데이터를 가진 vertex를 처리해서 처리된 결과인 vec3를 저장한다.

 

++ 선언하는 이유?

mesh만 선언한 상태 : 위치 데이터(3-component vector값)을 가진 상태여서 셰이더가 처리할 데이터를 구체적으로 명시하기 위해 사용.

 

++GLSL에서 벡터의 차원 변환 예시

vec4(vec3, w-component)는 3-component vector를 4-component vector로 변환하는 코드이다.

 

 

 

3.2. gl_Position

- vertex shader 이후 파이프라인으로 전달될 위치 데이터 저장.

 

++ in/out 페어로 out을 사용해 이후 파이프라인으로 전달될 데이터를 저장할 수도 있지만, GLSL에서 제공하는 gl_Position으로도 수행할 수 있다.

 

 

 

3.3. NDC(정규화 장치 좌표계)

- 다양한 화면을 공통 좌표계에 매핑해서 화면 중앙 좌표를 (0, 0)으로 고정한다.

(렌더링 API에 따라 좌표 처리가 다를 수 있음)

 

사용하는 이유?

- openframeworks에는 vertex position을 screen pixel position에 맞추기 위한 별도 처리 과정을 거치지만, vertex는 해당 과정 대신 좌표 정규화를 통해 위치 데이터를 출력하기 때문.

 

vertex shader이 NDC를 사용하는 이유?

- screen pixel coodrinates는 화면 해상도에 영향을 받아서 기기별로 동일한 픽셀 위치 데이터를 출력하기 어렵기 때문.

 

--> 그러므로 처음에 mesh 생성을 위해 선언한 버텍스를 NDC를 적용해서 변환한 값으로 변경한다.

++ screen pixel coordinates <->  NDC (Y축 부호 반대)

 

 

++ vertex shader에서 NDC 처리를 통해 화면 해상도와 관계없이 vertex position 위치 유지하는 방법?

아래 식과 같이 openframeworks의 기본 셰이더가 NDC로 변환하는 방식으로 계산한다.

float x = (position.x / x축 해상도값) * 2.0 - 1.0;

 

 

 

 

 

 

4. fragment shader 생성

4.1. fragment shader 특징

- 한 번에 1개 fragment만 처리.

- 주로 fragment 색상 결정.

- 화면의 특정 픽셀을 채우기 위한 필요한 정보 처리.

 

.frag 확장자를 가진 파일을 만들고, 기본 코드는 아래와 같다.

#version 410
//opengl version

out vec4 outColor;

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

vertex shader는 in을 사용해 데이터를 입력받을 기본 변수를 생성할 수 있지만, fragment shader는 이러한 기본 변수가 없어서 out 키워드를 선언해 직접 변수를 만들어야 한다.

 

 

4.2. out 

- 데이터 정보를 담아 파이프라인 다음 단계로 전달하기위해 필요한 한정자.

- OpenGL 구버전에서는 해당 역할을 하는 기본 변수가 제공되었지만, 더 많은 유연성을 위해 이후 버전에서 제거했다.

 

 

 

 

 

5. mesh와 shader 연결

이제 mesh에 vertex, fragment shader들을 연결해야 한다.

openframeworks에서 제공하는 ofShader 클래스(셰이더 코드 저장 및 제어)를 선언하고, 해당 셰이더를 로드한 뒤 draw()에서 해당 셰이더가 실행되게 하도록 한다.

ofShader shader;


void ofApp::setup(){
    triangle.addVertex(glm::vec3(-1.0f, 1.0f, 0.0f));
    triangle.addVertex(glm::vec3(-1.0, -1.0f, 0.0f));
    triangle.addVertex(glm::vec3(1.0f, -1.0f, 0.0f));
    shader.load("first_vertex.vert", "first_fragment.frag");
}


void ofApp::draw(){
    shader.begin();
    triangle.draw();
    shader.end();
}

 

+++ load()에 전달하는 파일 경로는 프로젝트의 bin/data 디렉터리를 기준으로 한 상대 경로이다.

 

5.1. Mesh Rendering

- draw call : GPU에게 특정 대상을 그리게 하는 명령. (draw()에서 GPU는 다음 프레임에 해당 메쉬를 그리라는 지시를 받는다.)

- shader binding : 특정 셰이더로 메쉬를 렌더링하기 위해 mesh의 드로우콜을 보내기 전 사용하는 셰이더를 알려주는 과정.

(openframeworks : begin()으로 바인딩 시작.

해당 과정이 끝나야 셰이더를 사용하려는 모든 메쉬에 대해 드로우콜을 실행할 수 있다.)

 

 

 

 

 

6. vertex attribute

fragment shader : 어떤 프레그먼트가 각 vertex로부터 멀리 있는가에 따라 색을 섞어 표현할 수 있ek.

- vertex attribute : 버텍스에 어떤 데이터를 저장하는 것.

 

아래와 같이 메쉬에 vertex attribute를 추가한다.

void ofApp::setup(){
    triangle.addVertex(glm::vec3(-1.0f, 1.0f, 0.0f));
    triangle.addVertex(glm::vec3(-1.0, -1.0f, 0.0f));
    triangle.addVertex(glm::vec3(1.0f, -1.0f, 0.0f));
    triangle.addColor(ofFloatColor(1.0f, 0.0f, 0.0f, 1.0f));
    triangle.addColor(ofFloatColor(0.0f, 1.0f, 0.0f, 1.0f));
    triangle.addColor(ofFloatColor(1.0f, 0.0f, 1.0f, 1.0f));
    
    shader.load("first_vertex.vert", "first_fragment.frag");
}

 

++ 이 때 addColor() 호출 순서와 vertex 정의 순서가 일치해야 한다.

 

-> OpenGL에서 메쉬는 숫자 데이터의 집합이므로, 위와 같이 코드를 작성하면 GPU 입장에서는 무엇이 위치이고 색상인지 알지 못한다.

--> openframeworks에서 vertex attribute의 순서는 정해주고, 데이터를 셰이더들이 이해할 수 있도록 처리해서 전송한다.

그러므로, 이 때 전달받은 데이터를 vertex shader이 해석할 수 있도록 수정해야 한다.

 

vertex shader

#version 410

layout (location = 0) in vec3 pos;
layout (location = 1) in vec4 color;

out vec4 fragCol;

in vec3 position;

void main()
{
    gl_Position = vec4(pos, 1.0);
    fragCol = color;
}

++ layout : GLSL 고유 한정자, 셰이더에게 버텍스 데이터가 저장된 순서를 알려준다.

++ 위치 속성을 먼저 저장하고 색상 속성 저장 -> 메쉬를 정의할 때 저장한 버텍스 데이터와 일치시키기 위해.

++ out -> fragment shader이 사용할 다른 데이터를 내보내기 때문에 해당 대상을 특정해야 한다.

 

 

이제 vertex shader에서 넘겨받은 값을 처리하기 위해 fragment shader 값을 수정한다.

#version 410
in vec4 fragCol;
out vec4 outColor;

void main(){
    outColor = fragCol;
}

++ vertex shader에서 새로운 변수를 내보냈을 때, 해당 변수를 받기 위해 해당 변수와 같은 이름을 가진 변수를 선언해야 한다. (다를 경우 GPU가 연결 불가하다.)

 

그러면 아래와 같은 결과가 나온다.

 

 

 

 

 

 

7.  Fragment Interpolation

- GPU가 버텍스 데이터로 메쉬를 렌더링할 때, 메쉬에서 주어진 버텍스 데이터 이외의 버텍스 값을 계산하기 위해 페이스를 구성하는 모든 버텍스로부터 데이터를 받아서 그 값을 블렌딩하는 과정.

- 페이스 상에서 선형적으로 이루어짐.

- 한 번에 한 페이스에서만 이루어짐 = 다른 페이스의 vertex attribute들을 이용하지 않는다.

 

= vertex / fragment마다 값이 다른 데이터의 경우 주로 사용하는 효율적인 처리 방법.

 

++ 달라질 필요가 없는 데이터?

= 모든 vertex에 동일한 색상을 담을 경우 위와 같이 모든 vertex에 색상 정보를 넣는 것은 비효율적.

-> 위와 같은 상황에서의 데이터는 uniform 변수를 사용.

 

 

 

8.  Uniform

- c++에서 버텍스 데이터를 거치지 않고 바로 값을 저장해 메쉬 렌더링시 쉽고 빠르게 지정할 수 있다.

(API에 따라 셰이더 상수 / 셰이더 속성 등 다양한 이름으로 부른다.)

- 메쉬의 모든 vertex, fragment에서 도일하게 유지.

 

++ in/out과 차이

in/out : 파이프라인의 이전 단계에 전달된 데이터에 따라 값이 달라짐.

uniform : 파이프라인 위치와 관계없이 값이 변하지 않고 interpolation이 이루어지지 않음.

 

 

무지개 삼각형이 아닌, 단일 삼각형을 렌더링할 때 uniform 변수를 사용해서 더 효율적으로 작업하기 위해 fragment shader를 다음과 같이 수정한다.

#version 410
uniform vec4 fragCol;
out vec4 outColor;

void main(){
    outColor = fragCol;
}

fragCol을 uniform 변수로 선언함

= 변수값이 모든 fragment에서 동일.

= 렌더링 파이프라인 밖에서 값이 선언됨.

= vertex shader이 fragment shader에게 색상 데이터 전달의 필요가 없어짐.

 

 

그러므로 vertex shader도 다음과 같이 수정한다.

#version 410

layout (location = 0) in vec3 pos;

in vec3 position;

void main()
{
    gl_Position = vec4(pos, 1.0);
}

 

 

 

fragment shader로 값을 전달하는 부분을 삭제하고, draw()에서 uniform 변수로 전송된 값을 설정한다.

void ofApp::draw(){
    shader.begin();
    shader.setUniform4f("fragCol", glm::vec4(0, 1, 1, 1));
    triangle.draw();
    shader.end();
}

++ 셰이더 코드로 전달하는 데이터 타입에 따라 setUniform()를 다르게 사용한다.

++ setUniform()은 begin() 뒤에 있어야 GPU가 어떤 셰이더로 값을 보내는지 알 수 있다.