2026년에도 OpenGL ES 3.0을 소개 컴퓨터 그래픽스 수업에서 계속 사용하는 이유와, 수업에서 다루는 핵심 개념·추상화, ES 3.0에서 빠진 기능, 현대 API로 넘어갈 때의 트레이드오프를 정리한다.
Elias Farhan- [x]
Jan 27, 2026
Loading...
emscripten으로 웹에 컴파일한 작은 C++ OpenGL ES 3.0 샘플
SAE Institute Geneva에서 ‘컴퓨터 그래픽스 입문’ 모듈(한 학기/트라이메스터)을 몇 년째 진행하고 있다. 그러는 동안 내 수업을 OpenGL ES 3.0에서 더 현대적인 API로 언제, 그리고 정말 옮겨야 하는지 계속 고민해 왔다.
지난 11월, Graphics Programming Conference에 참석했는데(관련 글은 여기), 업계 사람들과의 대화와 여러 발표를 통해 이 주제가 다시 떠올랐다. Teardown 발표(컨퍼런스 발표는 모두 웹사이트에 공개되어 있다)에서는 원작 게임이 OpenGL 3.3으로 동작했고, X-Plane 발표에서는 렌더링 엔진이 OpenGL 2.1을 사용하고 있었다. 다만 두 발표 모두 더 나은 그래픽을 위해 현대 API로 어떻게 전환했는지에 대한 내용이었다. 이런 분위기만 보면 OpenGL은 저물고 있는 것처럼 보인다.
교육 측면에서 특히 흥미로웠던 발표는 두 개였다. 하나는 Matthieu Delaere의 _Bridging Pixels and Code: Teaching Computer Graphics to Technical Artists_로, 학생들이 처음부터 직접 래스터라이저를 만드는 데 초점을 둔다. 다른 하나는 Mike Shah의 발표로, 튜토리얼 이후 다음 단계에 초점을 둔다. 특히 후자의 발표는 내 수업을 현대 API로 전환하겠다는 생각이 여전히 타당한지 다시 생각하게 만들었다.
전환을 고려할 때 반드시 감안해야 할 점은, 모든 강의 자료를 새로운 API/프레임워크에 맞게 번역(이식)하고 새로 추가되는 개념에 따라 업데이트해야 한다는 것이다. 그래서 전환을 한다면 OpenGL ES 3.0과 비교해 ‘정말 그럴 가치가 있어야’ 한다.
이 ‘컴퓨터 그래픽스 입문’ 모듈의 목표는 학생들이 그래픽스 API로 3D 씬을 구현하도록 하는 것이다. 중요한 점은 ‘범용 3D 렌더러’를 구현하는 것이 아니라 ‘특정 3D 씬’을 구현하는 것이라는 점이다. 즉 최종 실행 파일은 데이터 드리븐일 필요가 없다. 예를 들어 아래처럼 구현해도 전혀 문제 없다:
Cvoid DrawScene(const Pipeline& pipeline, const Camera& camera) { //Cull the scene from the camera POV //Draw each 3d object by hand }
이 모듈을 처음 시작했을 때부터 나는 learnopengl.com의 구조를 따르되 OpenGL ES 3.0을 사용했다. 각 장은 구현하고 테스트해야 하는 샘플 목록(Hello Triangle, Hello Texture 등)처럼 구성된다.
그런데 왜 하필 이 OpenGL 버전인가? OpenGL ES 3.0은 모바일 그래픽스 API로, 2012년 8월에 출시되었다. 그러니까 지금으로부터 약 14년 전인데(컴퓨터 그래픽스에서는 엄청나게 긴 시간이다). 내가 이 버전을 선택한 이유는 내가 보기에 OpenGL 중 가장 크로스 플랫폼이기 때문이다. 이 버전이면 (약간의 #ifdef만으로) 같은 C++ 프로그램과 같은 셰이더를 다음에서 돌릴 수 있다:

같은 데모를 Switch에서 실행한 모습
더 현대적인 API와 비교하면, OpenGL에서는 동기화가 드라이버 쪽에서 처리된다. 덕분에 많은 보일러플레이트 초기화 설정을 건너뛸 수 있다. 이는 편하지만, 그 대가로 드라이버 오버헤드가 추가된다.
다만 첫 번째 수업에서 반복해서 부딪히는 골칫거리가 하나 있는데, 학생들 노트북에서 잘못된 GPU를 선택하는 문제(예: Nvidia GPU 대신 Intel 내장 GPU 사용)다. 작년까진 Nvidia 설정에서 전체를 “고성능(Performant)”으로 바꾸면 됐는데, Windows 업데이트 이후로는 Nvidia 설정이 아니라 Windows 설정에서 앱별로 고성능을 지정해야 한다. 그리고 Windows에서는 이런 트릭도 있다:
#ifdef __cplusplus
extern "C" {
#endif
__declspec(dllexport) DWORD NvOptimusEnablement = 1;
__declspec(dllexport) int AmdPowerXpressRequestHighPerformance = 1;
#ifdef __cplusplus
}
#endif
이렇게 하면 (이론상) 내장 GPU가 아니라 외장 GPU를 쓰도록 유도할 수 있다(현대 API인 Vulkan에서는 이런 문제가 해결되어 있어 다행이다). 다른 주제들은 어떻게 다루는지 보자!
매주 학생들은 특정 기능에 대한 샘플 2~3개 정도를 완성해야 한다(예: 디퍼드 렌더링 샘플에서 다뤘던 개념을 바탕으로 SSAO를 사용하는 샘플 구현). 샘플을 만들다 보면 보통 학생들은 자신만의 추상화를 만들기 시작한다. 올해는 함께 작업하기로 하고, 공용 git 저장소에서 추상화를 같이 만들어 클래스의 모든 학생이 구현되는 즉시 사용할 수 있도록 했다. 이 방식은 앞으로도 계속하고 싶다.
나는 OpenGL의 네이밍을 정말 싫어한다… 파이프라인을 program이라고 부른다. 그래서 learnopengl.com에서 넘어온 학생들은 파이프라인 이름을 보통
plaintextshaderProgram
이라고 짓는다. 어쨌든 우리는 정점 셰이더와 프래그먼트 셰이더(별도의 추상화)를 로드하는
plaintextPipeline
추상화를 만들었고, 형태는 아래와 같다:
class Shader
{
public:
//shader target being GL_VERTEX_SHADER or GL_FRAGMENT_SHADER
void Load(std::string_view path, GLenum shader_target);
//[...]
class Pipeline
{
public:
void Load(const Shader& vertex_shader, const Shader& fragment_shader);
//[...]
셰이더는 CMake로 설정한 _glslangValidator_로 “컴파일”한다. 이렇게 하면 드라이버가 GLSL을 런타임 컴파일할 때 이상한 에러를 보는 대신, 컴파일 타임에 문법 오류를 잡을 수 있다.
OpenGL의 uniform buffer 구현 방식 때문에, 우리는 uniform을 설정하고 uniform location을 가져오는(그리고 이후를 위해 캐시하는) 함수들을 여러 개 추가했다:
class Pipeline
{
public:
//[...]
void SetInt(std::string_view uniform_name, int new_value);
void SetFloat(std::string_view uniform_name, float new_value);
template<typename T>
requires core::IsVector3<T, float>
void SetVec3(const std::string_view uniform_name, const T& v) {
const GLint loc = GetUniformLocation(uniform_name);
glUniform3f(loc, v.x, v.y, v.z);
}
private:
GLint GetUniformLocation(std::string_view name);
std::unordered_map<std::string, GLint, StringHash, StringEqual> uniform_location_map_;
여기서 몇 가지를 짚자면:
plaintextundefined
SetVec3
는 타입이 올바른 타입의 x, y, z 세 속성을 갖는지 확인하는
```plaintext
IsVector3
컨셉을 사용한다.
plaintextundefined
StringHash
와
```plaintext
StringEqual
은 맵에서 uniform location을 찾을 때
plaintextstd::string_view
를
plaintextstd::string
으로 굳이 만들지 않아도 되게 해준다.
OpenGL에 처음 들어갈 때 이상하다고 느낀 점 중 하나는 메모리 할당 방식이다(glGen_, glBind_를 하고, 그 다음 데이터를 업로드하는데, 어쩌면 업로드와 할당이 동시에 일어나는 느낌). 예를 들어 정점 입력 버퍼는 이렇게 한다:
int vbo_;
glGenBuffers(vboCount, &vbo_);
glBindBuffer(GL_ARRAY_BUFFER, vbo_);
glBufferData(GL_ARRAY_BUFFER, vertex_buffer.size, vertex_buffer.data, GL_STATIC_DRAW);
정점 입력의 경우, Vertex Array Object에 버퍼들을 설정한다. 이는 일종의 정점 버퍼 컨테이너처럼 동작하며, 파이프라인이 정점 버퍼를 어떻게 읽을지 정의한다. 예를 들어 아래처럼:
int vao_;
glGenVertexArrays(1, &vao_);
glBindVertexArray(vao_);
glBindBuffer(GL_ARRAY_BUFFER, vbo_);
glVertexAttribPointer(
vertex_input_location, // in the shader (location = 0)
vertexAttribData.size, // how many of the primitive: float => 1, vec2 => 2, vec3 => 3, vec4 => 4
vertexAttribData.type,
GL_FALSE,
vertexAttribData.stride,
(void*)vertexAttribData.offset);
glEnableVertexAttribArray(vertexAttribData.index);
//when using instancing
glVertexDivisor(vertexAttribData.index, 1);
이 모든 것은 Buffer 추상화와 함께 VertexInput 클래스에 숨겼다:
template<GLenum target>
class Buffer {
public:
void Bind() const;
void Load();
/**
* @brief Allocate and upload the data in the buffer
*/
template<typename T>
void UploadRange(std::span<const T> range, GLenum usage = GL_STATIC_DRAW);
/**
* @brief Update the data in the buffer (to be used after using UploadRange
*/
template <typename T>
void UpdateRange(std::span<const T> range, GLintptr offset = 0, GLenum usage = GL_STATIC_DRAW);
};
using VertexBuffer = Buffer<GL_ARRAY_BUFFER>;
using IndexBuffer = Buffer<GL_ELEMENT_ARRAY_BUFFER>;
struct VertexBufferAttribute {
GLuint location;
GLint size;
GLenum type;
GLsizei stride;
size_t offset;
bool is_instanced = false;
};
class VertexInput {
public:
void Load();
void BindVertexBuffer(const VertexBuffer& vbo, std::span<const VertexBufferAttribute> attributes);
void BindIndexBuffer(const IndexBuffer& ebo);
void Bind();
};
plaintextundefined
VertexBufferAttribute::is_instanced
는 SSBO 지원이 없는 OpenGL ES 3.0에서(아래 섹션) 버퍼를 per-instance 버퍼로 바인드할 수 있게 해준다.
### Textures
OpenGL의 텍스처 로직은 직관적이지 않다.
```plaintext
glGenTextures
가 주는 텍스처 이름(핸들)과 텍스처 유닛(예:
plaintextGL_TEXTURE0
)의 차이를 이해해야 한다. 이 부분이 매년 학생들을 많이 헷갈리게 한다. 그래서 uniform sampler에 텍스처를 바인딩하는 코드는 대략 이렇게 된다:
CglUniform1i(uniform_location, 0); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE2D, my_texture);
우리는 이를 파이프라인의 함수로 감싸 아래처럼 보이게 했다:
Cpipeline_.SetTexture("uniform_name", my_texture, 0); //with implementation looking like this: void Pipeline::SetTexture(std::string_view uniform_name, const Texture& texture, int texture_unit) { const auto uniformLocation = GetUniformLocation(uniformName); glUniform1i(uniformLocation, textureUnit); glActiveTexture(GL_TEXTURE0 + textureUnit); texture.Bind(); }
로딩은 모듈 초반에 stb_image를 사용해 JPG, PNG, BMP 등을 로드한다. 이 헤더 온리 라이브러리는 이미지를 디코딩해 픽셀을 얻을 수 있게 해 주며, 그 결과를 GPU로 업로드하는 코드는 이런 형태다:
Cimage.pixels = stbi_load(imagePath.data(), &image.width, &image.height, &image.comp, 0); glGenTextures(1, &my_texture); glBindTexture(GL_TEXTURE2D, name_); switch (image.comp) { case 3: glTexImage2D(GL_TEXTURE2D, 0, GL_RGB, image.width, image.height, 0, GL_RGB, GL_UNSIGNED_BYTE, image.pixels); break; case 4: glTexImage2D(GL_TEXTURE2D, 0, GL_RGBA, image.width, image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image.pixels); break;
이 또한
plaintextTexture
추상화 내부에
plaintextLoad
와
plaintextBind
로 감췄다. 그리고 큐브맵을 위해서는 다음을 추가한다:
Cclass Texture { public: //[...] void LoadCubemaps(std::span<const std::string_view, 6> paths); }
plaintextTexture
는 “타겟”(
plaintextGL_TEXTURE2D
또는
plaintextGL_TEXTURE_CUBE_MAP
, 왜 Texture3D는…?)을 보관하고,
plaintextBind
가 올바른 타겟을 바인드한다.
우리는 Assimp를 사용한다. 3D 모델 로딩 설정을 단순화해 주고, 매우 다양한 포맷을 임포트/익스포트할 수 있기 때문이다. 코드는 대략 이렇게 생겼다:
Cvoid Model::LoadModel(std::string_view path) { Assimp::Importer importer; scene = importer.ReadFile(path.data(), aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_GenNormals | aiProcess_CalcTangentSpace); if (!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode) { ... } ProcessNode(scene->mRootNode, scene); } void Model::ProcessNode(aiNode* node, const aiScene* scene) { // process all the node's meshes (if any) for (unsigned int i = 0; i < node->mNumMeshes; i++) { aiMesh* mesh = scene->mMeshes[node->mMeshes[i]]; meshes_.push_back(ProcessMesh(mesh, scene)); } // then do the same for each of its children for (unsigned int i = 0; i < node->mNumChildren; i++) { ProcessNode(node->mChildren[i], scene); } } Mesh Model::ProcessMesh(aiMesh* mesh, const aiScene* scene) { // Processing the meshes
learnopengl.com에서도 같은 방식으로 사용되기 때문에, 학생들은 실수했을 때 참고할 수 있는 동작하는 구현을 갖게 된다.
OpenGL에서는 드라이버가 우리의 커맨드를 자신의 커맨드 버퍼에 추가한다. 이 때문에(현대 API와 달리) 드로우 커맨드 생성의 멀티스레딩이 불가능하고, 그리기가 즉시 일어나는 듯한 느낌을 준다. learnopengl.com의 영향으로 대부분의 학생들은 드로우 콜을
plaintextModel
안의
plaintextMesh
에 넣는다. 예를 들면:
Cvoid Model::Draw(const Pipeline& pipeline) { pipeline.Bind(); for(auto& mesh: meshes) { mesh.Draw(pipeline); } } void Mesh::Draw(const Pipeline& pipeline) { material_.Bind(pipeline); //binding all textures to their samples glDrawElements(GL_TRIANGLES, indices_count, GL_UNSIGNED_INT, nullptr); //or depending on the abstraction vao_.Draw(); }
내 컴퓨터 그래픽스 에디터를 만들면서(관련 글은 여기), 나는 씬이 머티리얼(파이프라인 + 텍스처 참조)과 메쉬를 갖고, 모델은 씬 추상화로 존재하지 않는 분리된 접근을 더 선호하게 되었다. 하지만 다시 말하지만, 우리는 데이터 드리븐 렌더러를 만드는 것이 아니라 3D 씬을 구현하는 것이다.
마지막으로 OpenGL의 큰 핵심 개념은 멀티 패스다. 모듈 전반부는 원 패스에서 진행한다. 멀티 패스의 첫 예시는 포스트 프로세싱인데, 학생들은 프레임버퍼를 사용해 씬을 그린 다음, 다른 파이프라인과 렌더 타깃을 샘플드 텍스처로 사용해야 한다. 첫 예시는 여러 변형의 포스트 프로세싱(그레이스케일, 색 반전 등)이다. OpenGL에서 특정 프레임버퍼를 바인딩하는 것은 새로운 렌더 패스를 시작하는 것과 같다(RenderDoc에서는 보통 Color Pass라고 표시).
학생들과 함께
plaintextFramebuffer
추상화를 만들었는데, Vulkan과 매우 비슷한
plaintextFramebufferCreateInfo
구조체로 컬러 어태치먼트와 depth/stencil을 지정한다:
Cstruct ColorAttachmentInfo { GLenum target = GL_TEXTURE_2D; GLint internal_format = GL_RGB8; bool is_active = false; }; struct DepthAttachmentInfo { GLenum target = GL_TEXTURE_2D; GLenum internal_format = GL_DEPTH24_STENCIL8; bool is_active = false; bool is_rbo = true; }; struct FramebufferCreateInfo { //OpenGL has maximum 16 color attachments std::array<ColorAttachmentInfo, 16> color_attachment_infos{}; DepthAttachmentInfo depth_stencil_attachment_info{}; core::Vec2I size{}; }; class Framebuffer { void Load(const FramebufferCreateInfo& info); void Bind(); static void BindBackbuffer(); const Texture& color_attachments(int index); const Texture& depth_attachment(); };
보통 우리는 하나 또는 여러 개의 컬러 어태치먼트는
plaintextTexture
로 두고, depth-buffer는 RBO로 둔다. 다만 섀도우 패스에서는 depth-only 패스를 원하므로(출력이
plaintextTexture
로 나가야 하므로) 예외가 된다.
이 모듈에서 프레임버퍼를 쓰는 큰 단계는 HDR(High-Dynamic Range) 소개와 함께 온다. 즉 [0 - 255]보다 더 큰 범위의 색을 사용할 수 있다는 것이다. 대표적 응용으로 디퍼드 렌더링과 G-Buffer(예: position, normal, baseColor 등을 담는 Geometry Buffer) 생성이 있다. 이 방식은 픽셀당 라이팅 계산량을 줄이기 위해, 먼저 여러 컬러 어태치먼트를 가진 G-Buffer에 기하 정보를 렌더링한 뒤, 라이트 패스에서 픽셀 단위로 조명을 계산한다. 화면에 많은 라이트가 있을 때 큰 성능 향상을 준다. 우리의 추상화에서는 HDR용 internal format(예:
plaintextGL_RGB16F
)을 쓰기만 하면 된다.
물론 모든 모니터가 HDR 출력이 가능한 것은 아니므로, LDR(Low-Dynamic Range, [0 - 255])로 돌아오는 방법이 필요하다. 이는 라이트 패스 프래그먼트 셰이더 마지막에서 수행한다:
//Reinhard tonemapping
vec3 result = lighting / (lighting + vec3(1.0));
FragColor = vec4(result, 1.0);
WebGL 2.0을 사용할 수 있게 해 주는 OpenGL ES 3.0을 고수하는 데에도 비용이 있다. 현대 렌더링의 핵심 개념 중 일부는 수업에서 설명은 하지만 구현은 하지 않는다(학생들이 WebGL2 호환을 버리고 싶다면 구현할 수는 있다).
OpenGL 4.3 및 OpenGL ES 3.1부터는 SSBO(Shader Storage Buffer Object)를 정의할 수 있다. 이는 인스턴싱을 할 때 정점 입력을 거칠 필요를 줄여준다. 물론 uniform에 바인딩할 수 있는 또 다른 종류의 버퍼가 필요해진다. 예를 들면:
C//in C++ glBufferData(GL_SHADER_STORAGE_BUFFER, instancing_buffer.size, instancing_buffer.data, GL_DYNAMIC_DRAW); glBindBufferBase(GL_SHADER_STORAGE_BUFFER, binding_point, ssbo); //in shader layout(std430, binding = 3) buffer layoutName { vec4 data_SSBO[]; }; void main() { vec4 instancing_data = data_SSBO[gl_InstanceID]; }
컴퓨트 셰이더는 OpenGL ES 3.1에서야 등장했다(즉 WebGL2에는 없다). 앞서 설명한 포스트 프로세싱 예로 돌아가면, 씬의 컬러 어태치먼트를
plaintextsampler2D
로 전달하는 대신
plaintextimage2D
로 전달하고, 실제 포스트 프로세싱을 컴퓨트에서 수행할 수 있다. 일반적으로 화면 전체를 처리하는 종류의 셰이더는, NDC(Normal Device Coordinate)에서 [-1, 1] 범위를 덮는 평면을 그려서(정점 셰이더에서 전체 화면을 복원하고 프래그먼트에서 변환을 구현하는 방식) 처리하는 것보다, 단일 셰이더를 가진 컴퓨트 파이프라인이 구현하기 더 쉬운 경우가 많다.
OpenGL 4.6부터(심지어 OpenGL ES 3.2에도 없다) 텍스트 GLSL 대신 SPIR-V를 로드할 수 있다. 이는 바이너리 포맷이라 셰이더 로딩이 빠르고, 셰이더에 컴파일 단계를 추가할 수 있게 해준다(우리는 어쨌든 glslangValidator로 비슷한 일을 하고 있긴 하다).
현대 데스크톱 OpenGL은 “bindless” 렌더링을 지원한다. 여기서는 텍스처를 텍스처 유닛에 바인딩하지 않고, 버퍼에 저장된 64비트 핸들을 통해 참조한다(비싼 상태 변경 없이 사실상 무제한 텍스처 접근 가능). 이는
plaintextARB_bindless_texture
확장으로 제공되며(Nvidia와 AMD는 지원, Intel은 미지원) 가능하다.
버퍼 측면에서는
plaintextARB_buffer_storage
(OpenGL 4.4에서 코어, OpenGL ES 3.1부터는
plaintextEXT_buffer_storage
확장)로 영속적으로 매핑된 버퍼를 가능하게 해 GPU 포인터를 유지할 수 있어, 반복적인 map/unmap 사이클을 피할 수 있다.
이들은 OpenGL ES 3.0에서는 사용할 수 없다. 그래서 보통 텍스처 아틀라스나 텍스처 배열을 쓰고, 배칭(정점 입력은 다르지만 텍스처는 같은 드로우 콜)과 결합해 비싼 텍스처 스위칭을 피하는 방식으로 우회한다.
OpenGL ES 3.0에서 사용할 수 없는 또 다른 강력한 기능은 인다이렉트 드로잉이다. 우리 모듈에서는 CPU가
plaintextglDrawArrays
,
plaintextglDrawElements
(또는 인스턴스 버전)로 모든 드로우 콜을 발행한다.
plaintextglDrawArraysIndirect
,
plaintextglDrawElementsIndirect
는 OpenGL 4.0 및 OpenGL ES 3.1에서 코어로, 드로우 파라미터를
plaintextGL_DRAW_INDIRECT_BUFFER
에서 가져올 수 있다.
더 나아가 Multi-Draw Indirect(OpenGL 4.3에서 코어, OpenGL ES 3.1부터는
plaintextEXT_multi_draw_indirect
확장)는
plaintextglMultiDrawArraysIndirect
,
plaintextglMultiDrawElementsIndirect
로 드로우 커맨드 배열 전체를 디스패치할 수 있다. 이를 컴퓨트 셰이더와 결합하면 GPU가 프러스텀 컬링, LOD 선택, 오클루전 컬링을 수행하는 완전한 GPU 드리븐 렌더링 파이프라인을 구성할 수 있다.
plaintextARB_indirect_parameters
는 드로우 카운트 자체를 GPU가 결정하도록도 해 준다.
학생들이 예전에는 RenderDoc에 가장 많은 시간을 썼다. 요즘은 LLM으로 코드를 점검하고 무엇이 잘못됐는지 확인하는 학생도 더 보인다(심지어 한 학생은 “저한테 거기에 오류가 있다고 알려줬어요”라고 말하기도 했다). 하지만 시험에서는 LLM을 이용해 풀어서는 안 된다고 명시하고 있으므로, 합격하려면 학생들은 여전히 스스로 디버깅 과정을 거쳐야 한다.
OpenGL 4.3부터
plaintextKHR_debug
가 코어에 포함되어, 콜백을 통해 드라이버가 에러/경고/디버그 메시지를 보내게 할 수 있다. OpenGL 4.3이 완전히 호환이므로, 이 글을 쓰면서 데스크톱에서는 이를 활용해 더 나은 디버그 기능을 얻을 수 있겠다는 걸 깨달았다(예: 매크로로
plaintextglObjectLabel
을 이용해 OpenGL 오브젝트에 이름을 붙이기).
OpenGL을 고수한다는 것은 Vulkan이나 DX12 같은 현대 API의 많은 ‘새로운 기능들’도 놓친다는 의미다. 물론 현대 API는 기능만의 문제가 아니라, 완전히 다른 멘탈 모델이기도 하다. 하지만 여기서는 기능에 집중해 보자.
RTX 마케팅 덕분에 Nvidia는 레이트레이싱을 컴퓨터 그래픽스를 이야기할 때 누구나 언급하는 주제로 만들었다. 물론 레이트레이싱은 그래픽카드가 등장하기 전부터 존재했다. 하지만 2018년에 DXR이 나오면서, GPU에서 하드웨어 레이트레이싱을 할 수 있게 되었고(closest-hit, miss, raygen 등의 새로운 종류의 셰이더), Vulkan도 Nvidia 확장을 거쳐 이후 범용 확장으로 따라왔다.
여전히 많은 게임은 하이브리드 렌더링(일부는 래스터라이제이션, 일부는 레이트레이싱)으로 살아가며, 플레이어에게 여러 옵션을 제공한다. Dmytro “Boolka” Bulatov는 State of GPU Hardware에서 Indiana Jones and the Great Circle(2024년 말 출시) 같은 게임만이 DXR을 요구 사항으로 둔다고 언급한다. 그래서 교사로서 나는 하드웨어 레이트레이싱 API, 장단점, 그리고 미래 방향을 보여주긴 하지만, ‘컴퓨터 그래픽스 입문’에서 모든 학생에게 하드웨어 레이트레이싱 파이프라인 구현을 요구할 수는 없다.
현대 CPU 아키텍처를 생각하면 우리는 멀티코어 시대에 살고 있지만, 게임에서 프로그래밍하는 대부분의 코드는 여전히 한 코어의 한 스레드에서 돌아간다. CPU의 성능을 제대로 끌어내려면 작업을 여러 스레드로 나눌 수 있어야 한다. 흔한 패턴은 CPU에서 Game/Cull/Render 스레딩 모델을 쓰거나, 로딩을 비동기로 처리하는 것이다. 이런 것들은 OpenGL에서도 가능하다.
하지만 어떤 게임(예: Halo Infinite)은 GPU 커맨드를 생성하기 위해 여러 스레드를 사용하는 것이 필요하다. 이는 DX12와 Vulkan에서는 가능하지만, OpenGL에서는 렌더 상태가 드라이버의 한 스레드 구현에 묶여 있어 불가능하다.
이 모듈을 통해 수년간 학생들이 만든 결과물 일부는 다음과 같다:
나는 SDL GPU API를 정말 좋아한다. 현재 내 컴퓨터 그래픽스 에디터를 거기로 포팅 중이고, Vulkan/DirectX/Metal을 동시에 타겟할 수 있다. 하지만 입문 과정에서 SDL GPU를 쓰기엔 다소 과한(부담스러운) 부분이 있다. bgfx와 raylib도 좋다는 얘기를 들었다. WebGPU는 새로운 셰이더 언어를 추가하는 데다가 Vulkan 1.0과 매우 비슷해서, 나에게는 즐거움보다 고통이 더 컸다.
학생들이 샘플을 만들다가 OpenGL에서 실수했을 때는 왜 문제가 생겼는지가 꽤 명확한 경우가 많다. 하지만 프레임워크를 쓰면 라이브러리 코드까지 함께 고려해야 한다. 내 목표는 그래픽스 API 자체를 가르치고 그 위에 추상화를 구축하게 하는 것이다.
OpenGL ES 3.0은 2026년에도 여전히 가르칠 만한 장점이 많다(특히 WebGL2). 다만 이 API가 가진 레거시와 씨름해야 하는 비용이 따른다. 14년은 컴퓨터 그래픽스에서 정말 긴 시간이다. 예를 들어 Adam Sawicki는 자신의 graphics API 블로그 글 마지막에서 OpenGL을 선택지로 추천하지 않는다(요즘은 거의 쓰이지 않기 때문이다).
이 수업을 통해 수년간 내 모든 학생들은 그래픽스 프로그래밍이 어떻게 동작하는지 처음으로 경험할 수 있었다. 그중 일부에게는 컴퓨터 그래픽스 커리어의 첫걸음이었고, 그 과정에서 내가 도움이 되었다는 사실이 기쁘다.
동기화, PSO, 드라이버 오버헤드 등(Vulkan에 대해 할 말이 몇 가지 있다)을 다루며 왜 아직 Vulkan으로 전환하지 않는지에 대한 또 다른 글을 쓰고 싶다. 소셜 미디어에 댓글을 달거나 이메일을 보내 달라(다른 교사들은 어떻게 하는지, 업계에서는 그래픽스 프로그래밍 학생에게 무엇을 보고 싶은지 궁금하다). 아래는 Android에서 실행 중인 회전 큐브 데모다:

Elias Farhan
Elias Farhan은 인디 게임 개발자이자 프로그래머, 교사, 스피커다. 게임보이, 컴퓨터 그래픽스, 게임 엔진 프로그래밍, C++, Unity로 프로토타이핑, 온라인 멀티플레이어 게임 프로그래밍을 즐긴다.