6 minutes
🍪 Cookie Engine - 2. Graphics Basics
1. Framebuffer, Color Buffer, Depth Buffer
화면에 보이는 이미지는 결국 픽셀들의 배열이다. OpenGL에서는 렌더링 결과가 frame buffer에 기록되는데, 기본적으로 SDL window에 연결된 기본 frame buffer가 있다.
기본 framebuffer에는 보통 다음 buffer들이 있다.
Color Buffer
Depth Buffer
Stencil Buffer
Color Buffer는 최종 색상이 저장되는 곳이고, Depth Buffer는 각 픽셀에 가장 가까운 물체의 깊이값이 저장되는 곳이다. Stencil Buffer는 마스크 처리 등에 사용된다.
Sphere와 같은 3D 메시들은 앞쪽 삼각형과 뒤쪽 삼각형이 겹쳐 보일 수 있기 때문에 Depth Test 기능은 켜놓아야 한다.
glEnable(GL_DEPTH_TEST);
또한 매 프레임 시작 시에는 이전 프레임의 color & depth 값이 남아 있기 때문에 매 프레임 시작 시 이 버퍼들을 clear해야 한다.
glClearColor(0.1f, 0.15f, 0.25f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
2. Viewport
Viewport는 NDC 좌표를 실제 화면 픽셀 좌표로 변환할 때 사용하는 창 영역이다.
glViewport(0, 0, width, height);
이는 NDC 결과를 window의 왼쪽 아래 (0, 0)부터 width * height 영역에 그린다는 의미다.
OpenGL의 viewport 원점은 기본적으로 왼쪽 아래지만, SDL window size나 일반 UI 좌표는 왼쪽 위를 원점처럼 생각하는 경우가 많다. 처음에는 크게 문제 되지 않지만, 나중에 마우스 picking이나 ImGui viewport를 붙이면 좌표계 차이를 조심해야 한다.
창 크기가 바뀌면 viewport도 다시 맞춰주어야 한다.
int width = 0;
int height = 0;
SDL_GetWindowSize(window, &width, &height);
glViewport(0, 0, width, height);
고해상도 디스플레이에서는 window size와 실제 drawable size가 다를 수 있기 때문에, 첫 단계에서는 window size로 시작하지만 나중에는 SDL의 drawable / pixel size 관련 API를 확인해야 한다.
3. Coordinate Space와 NDC
게임 엔진에서 3D 점은 여러 좌표계를 거친다. 일반적인 렌더링에서는 다음 순서를 탄다.
Local Space
-> World Space
-> View Space
-> Clip Space
-> NDC
-> Screen Space
Local Space
오브젝트 자기 자신 기준의 좌표계. 만약 Sphere 중심을 원점으로 둔다고 하면 Sphere의 정점들은 대략 다음 범위에 있다.
x: -radius ~ +radius y: -radius ~ +radius z: -radius ~ +radiusWorld Space
게임 월드 전체 기준의 좌표계. Actor Transform을 적용하면 Local Space 점이 World Space로 변환된다.
View Space
카메라 기준 좌표계. 카메라 앞에 있는 물체를 카메라 기준으로 재배치한다.
Clip Space
GPU clipping이 일어나는 좌표계다. Vertex Shader는 최종적으로
gl_Position에 clip space 좌표를 써야 한다.void main() { gl_Position = vec4(aPosition, 1.0); }NDC
Normalized Device Coordinates의 약자다. Clip Space의
(x, y, z, w)는 perspective divide를 거쳐 NDC가 된다.ndc.x = clip.x / clip.w ndc.y = clip.y / clip.w ndc.z = clip.z / clip.wOpenGL NDC 범위는 다음과 같다.
x: -1 ~ +1 y: -1 ~ +1 z: -1 ~ +1이 범위를 벗어나면 clipping되어 화면에 보이지 않는다.
정점 위치를 이미 NDC에 들어갈 수 있는 범위로 만들고, Vertex Shader에서 거의 그대로
gl_Position으로 보낸다고 가정해보자. Sphere 반지름을0.5로 만들면 정점들은 대략 다음 범위에 있다.x: -0.5 ~ +0.5 y: -0.5 ~ +0.5 z: -0.5 ~ +0.5이 값은 OpenGL NDC 범위인
-1 ~ +1안에 들어가기 때문에 별도의 projection matrix 없이도 화면 중앙에 Sphere가 보일 수 있다. vertex shader는 개념적으로 다음처럼 단순할 수 있다.#version 460 core layout (location = 0) in vec3 aPosition; void main() { gl_Position = vec4(aPosition, 1.0); }여기서
w = 1.0이므로 perspective divide 후에도 좌표가 변하지 않기 때문에 원근감이 없다. 즉 멀리 있는 점이 작아지지 않고, 가까운 점이 커지지도 않는다.
4. Vertex, Triangle, Mesh
GPU는 Sphere라는 개념을 직접 알지 못한다. GPU가 기본적으로 그릴 수 있는 것은 점, 선, 삼각형 같은 primitive다. 게임 엔진에서 대부분의 3D 모델은 삼각형들의 집합으로 표현되는데, 예를 들어 sphere를 그릴 때는 실제 수학적 구면을 그대로 그리는 것이 아니라 많은 삼각형으로 근사한 mesh를 그린다.
정점 하나에는 보통 다음과 같은 정보가 들어갈 수 있다.
struct Vertex
{
float x, y, z; // position
float nx, ny, nz; // normal
float u, v; // texture coordinate
};
5. Sphere Mesh를 만드는 대표 방식
이건 중요한 내용은 아니지만 재미있는 내용이므로 잠깐 추가로 다뤄본다. 만약 sphere mesh를 별도의 asset을 사용하지 않고 계산을 통해 만들겠다고 한다면 방법은 크게 두 방식이 있다.
UV Sphere
위도와 경도처럼 sphere를 나누는 방식이다.
latitude: 가로 방향 고리 longitude: 세로 방향 분할이는 구현과 이해가 쉽다는 장점이 있지만, 극점 근처에 삼각형이 몰린다는 단점이 있다. 쉽다는 장점 덕분에 보통 첫 실습에서는 UV sphere를 많이 사용한다.
Icosphere
정이십면체에서 시작해 삼각형을 계속 세분화하고, 정점들을 구면 위로 normalize하는 방식이다.
장점은 삼각형 분포가 비교적 고르다는 것이고, 단점은 당연히도(…) 구현이 UV Sphere보다 어렵다는 것이다.
6. Index Buffer
geometry는 수많은 삼각형 primitive로 이루어지기 때문에 삼각형마다 정점 3개를 전부 중복 저장하면 데이터가 심하게 낭비된다. index buffer를 쓰는 방식은, 정점 배열은 각 정점을 하나 씩만 저장하고, 삼각형은 index buffer의 정점 번호를 참조해서 만드는 방식이다.
예를 들어 정점 배열이 다음과 같다고 하자.
vertices[0]
vertices[1]
vertices[2]
vertices[3]
삼각형 두 개는 다음처럼 만들 수 있다.
triangle 0: 0, 1, 2
triangle 1: 2, 1, 3
OpenGL에서는 정점 데이터를 VBO에 넣고, index 데이터는 EBO 또는 IBO에 넣는다. index buffer를 사용할 때와 사용하지 않을 때는 draw call 또한 달라지는데, 정점을 순서대로 사용하는 draw call은 glDrawArrays를 사용하고, index buffer를 사용하는 draw call은 glDrawElements를 사용한다.
sphere처럼 정점 공유가 많은 메시에는 glDrawElements가 적합하다.
7. Shader Program
OpenGL core profile에서는 shader가 필수다. shader program에는 최소한 vertex shader와 fragment shader가 필요하다.
7.1. Vertex Shader
vertex shader는 정점마다 실행되는 GPU 프로그램으로, 각 정점의 최종 위치를 gl_Position에 쓰는 것이 목적이다. 아주 단순한 예시는 다음과 같다.
#version 460 core
layout (location = 0) in vec3 aPosition;
void main()
{
gl_Position = vec4(aPosition, 1.0);
}
여기서 layout (location = 0)은 C++ 코드의 glVertexAttribPointer(0, ...)와 연결된다.
7.2. Fragment Shader
DirectX를 사용해봤다면 pixel shader라는 이름이 더 익숙할 것이다. fragment shader는 화면에 찍힐 픽셀 후보마다 실행된다. 최소한 색상 하나를 출력하면 된다.
#version 460 core
out vec4 FragColor;
void main()
{
FragColor = vec4(1.0, 0.6, 0.2, 1.0);
}
7.3. Shader Compile과 Program Link
OpenGL에서 shader는 문자열 소스코드로 전달되고 런타임에 컴파일된다. 개념적 흐름은 다음과 같다.
glCreateShader
-> glShaderSource
-> glCompileShader
-> compile 성공 확인
glCreateProgram
-> glAttachShader(vertex)
-> glAttachShader(fragment)
-> glLinkProgram
-> link 성공 확인
-> glUseProgram
C++ 코드 예시는 다음과 같다.
GLuint shader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint success = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
if (!success)
{
char log[1024];
glGetShaderInfoLog(shader, sizeof(log), nullptr, log);
SDL_Log("Shader compile failed: %s", log);
}
shader 에러 로그를 반드시 확인하는 습관을 들여야 한다. OpenGL 초반 디버깅의 상당 부분은 shader compile error와 program link error다.
8. Draw Call
draw call은 CPU가 GPU에게 이제 그리라고 명령하는 지점이다. index buffer로 그릴 때는 보통 glDrawElements를 사용한다.
glUseProgram(shaderProgram);
glBindVertexArray(sphereVao);
glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_INT, nullptr);
각 인자의 의미는 다음과 같다.
GL_TRIANGLES
-> 인덱스 3개를 하나의 삼각형으로 해석
indexCount
-> 사용할 인덱스 개수
GL_UNSIGNED_INT
-> index buffer의 원소 타입
nullptr
-> 현재 bind된 EBO의 시작 위치부터 읽기
draw call이 호출되면 GPU는 현재 바인딩된 VAO, shader program, buffer, 렌더링 상태를 기준으로 작업한다. 때문에 draw call 직전에 어떤 상태가 bind되어 있는지가 중요하다.
9. Depth Test
3D mesh는 깊이 값을 가지고 있고, depth test가 꺼져 있으면 나중에 그려진 삼각형이 앞쪽 삼각형을 덮어버릴 수 있다. 그러니 3D 메시를 그릴 때는 다음을 켜는 것이 좋다.
glEnable(GL_DEPTH_TEST);
그리고 매 프레임 depth buffer를 clear해주어야 한다.
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
OpenGL에서 기본 depth 비교는 일반적으로 더 작은 depth가 가까운 것으로 취급되는 방식이다.
10. Face Culling과 Winding
face culling은 삼각형의 앞면 / 뒷면 중 일부를 그리지 않는 기능이다. 대부분의 static mesh들은 닫힌 mesh이므로, 보통 삼각형의 뒷면(back-face)은 화면에 보이지 않기에 그리지 않아도 된다.
하지만 winding order가 잘못되면 Sphere가 통째로 안 보일 수 있다. winding order는 삼각형 정점 순서가 시계 방향인지, 반시계 방향인지를 의미한다. OpenGL에서는 기본적으로 반시계 방향(CCW, Counter-Clock Wise)을 front face로 보는 경우가 많다.
glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);
glFrontFace(GL_CCW);