OpenGL 4.5 DSA 기반 멀티패스 렌더러.
씬은 화면에 직접 그려지지 않는다. HDR(RGBA16F) 렌더 타깃에 그린 뒤, 톤매핑 패스가 그 텍스처를 읽어 화면에 한 번 쓴다. 새 기능은 이 패스 목록에 클래스 하나를 끼워 넣는 것으로 추가된다.
renderer.exe # 내장 큐브
renderer.exe path/to/model.glb # glTF 2.0 모델ForwardPass ──▶ [sceneColor: RGBA16F + DEPTH32F] ──▶ TonemapPass ──▶ 화면
- 패스 기반 렌더러 —
Renderer가vector<unique_ptr<RenderPass>>를 순서대로 실행한다. 목록의 순서가 곧 프레임의 순서이고, 패스마다enabled플래그로 런타임에 켜고 끌 수 있다. - 이름 기반 리소스 배선 — 패스는 서로를 가리키지 않는다. 각자
setup()에서 자기 출력을createTarget("sceneColor", ...)로 등록하고, 자기 입력을target("sceneColor")로 조회한다. 생산자가 바뀌어도 소비자 코드는 그대로다. - HDR + 톤매핑 — 씬 컬러는 RGBA16F라 1.0을 넘는 밝기가 잘리지 않는다. 톤매핑 패스에서 exposure → ACES(Narkowicz 근사) → sRGB 인코딩 순으로 한 번만 처리한다.
- 리니어 워크플로 — 색 텍스처는
GL_SRGB8/GL_SRGB8_ALPHA8로 올려서 하드웨어가 샘플할 때마다 선형화한다. 디코드를 위한pow()는 셰이더 어디에도 없고, 감마 인코딩은 파이프라인 끝의 톤매핑 패스 한 곳에서만 일어난다. 그 한 곳은pow(x, 1/2.2)근사가 아니라 IEC 61966-2-1의 조각별 sRGB 곡선이다 — 근사식은 미드톤에서만 비슷하고 가장 눈에 띄는 어두운 쪽에서 어긋난다(순수 거듭제곱은 0에서 기울기가 무한대라 검정이 들뜬다). 위에서 리니어를 지킨 노력이 마지막 한 줄에서 새는 걸 막는 부분이다. - 셰이더 핫 리로드 — 실행 중에
shaders/의 파일을 저장하면 0.25초 안에 다시 컴파일돼 반영된다. 컴파일이 실패하면 콘솔에 에러를 찍고 기존 프로그램을 그대로 유지하므로 화면이 깨지지 않고, 고쳐서 다시 저장하면 이어서 반영된다. 같은 에러를 초당 몇 번씩 반복 출력하지 않도록, 실패한 버전은 파일이 다시 바뀔 때까지 재시도하지 않는다. - 디버그 UI (Dear ImGui) — 프레임 시간, 패스별 on/off 체크박스, 카메라 상태.
패널은 패스 목록을 그대로 순회하면서 각 패스에게 자기 컨트롤을 그리게 하므로
(
RenderPass::drawUi()), 새 패스는 UI 코드를 건드리지 않고도 자기 파라미터를 들고 온다.Tab으로 커서 소유권을 카메라 ↔ UI 사이에서 넘긴다. - RenderDoc 통합 — 패스마다
glPushDebugGroup으로 감싸서 캡처가 draw call 나열이 아니라 패스별 트리로 보인다. FBO·텍스처·VAO에는glObjectLabel로 이름이 붙는다.
.gltf(JSON + 외부 파일)와 .glb(단일 바이너리) 둘 다 확장자로 구분해 읽는다.
- 노드 계층을 로드 시점에 평탄화 —
parent * local체인을 따라 내려가며 각 메시에 월드 행렬을 구워 넣는다. 트리를 유지하는 건 애니메이션이 필요해질 때의 문제고, 그때는 어차피 별도 표현이 필요하다. 순환 참조가 있는 파일에도 스택이 터지지 않도록 방문 표시를 둔다. - 정점은 하나의 레이아웃으로 역인터리브 — glTF는 속성마다 다른 버퍼·다른 stride를
쓸 수 있다. VAO가 그걸 그대로 가리킬 수도 있지만, 그러면 모델마다 속성 배선이
달라지고 셰이더가 거기 맞춰야 한다. 로드 때 한 번 복사해서
scene::Vertex(position/normal/uv) 하나로 통일한다. - 인덱스는 항상
GL_UNSIGNED_INT로 확장 — 파일은 u8/u16/u32를 섞어 쓴다. 인덱스 버퍼가 없는 프리미티브에는 0..N-1을 만들어 준다. - 정규화 정수 속성도 읽는다 — 스펙은
TEXCOORD_0을 float뿐 아니라 정규화된 u8/u16으로도 허용하고, 용량을 줄인 에셋에서 흔하다. float만 받으면 그런 모델은 UV가 전부 0이 되어 텍스처 좌상단 텍셀 하나만 샘플되고, 화면에는 그냥 단색으로 보인다 — 로그를 보지 않으면 "텍스처가 안 먹었네"에서 진단이 멈춘다. 반대로 정규화 아닌 정수는 거부한다: 그건KHR_mesh_quantization이고 그냥 나누면 메시가 엉뚱한 스케일로 선다. - 색 공간은
(이미지, 색공간)쌍으로 관리 — 이미지 파일 자체는 자기가 색인지 데이터인지 모르고, 한 파일 안에서 양쪽으로 쓰일 수도 있다. baseColor·emissive는 sRGB, metallicRoughness·normal·occlusion은 리니어다. 그래서 키가 텍스처 인덱스가 아니라 그 쌍이고, 두 용도로 쓰이는 이미지는 GL 텍스처 두 벌로 올라간다. 필요할 때 만들기 때문에 아무 재질도 참조하지 않는 이미지는 아예 업로드되지 않는다. 로드 로그가 실제 할당된 포맷을 찍는다:[Model] texture 0: 512x512 4ch SRGB8_ALPHA8 - 알파 모드 3종 —
OPAQUE/MASK/BLEND를 그대로 지킨다.MASK는 셰이더에서alphaCutoff미만을discard하는 하드 컷아웃이라 깊이를 정상적으로 쓰고,BLEND는 불투명 지오메트리를 다 그린 뒤 뒤에서 앞으로 정렬해 깊이 쓰기를 끈 채 합성한다. 모르는 모드 문자열은OPAQUE로 읽는다 — 잘려야 할 면이 사각형째 보이는 건 눈에 띄지만, 사라지는 건 원인을 찾기 어렵다. - 바운딩 박스로 카메라 자동 배치 — 모델은 1cm일 수도 1km일 수도 있다. 시작 위치와
near/far를 바운드에서 유도하지 않으면 절반은 지오메트리 안쪽에서, 절반은 점으로
열린다. 회전이 걸린 노드 때문에 min/max만 변환하지 않고 여덟 꼭짓점을 전부 변환한다.
스펙을 어기고
POSITION에 min/max가 없는 파일은boundsValid()가 false가 되고, 호출자는 그 사실을 알고 폴백한다 — 센티넬을 그대로 넘기면 extent가 무한대가 되어 프로젝션이 통째로 NaN이 되는데, 증상은 검은 화면 하나뿐이다. - 파일이 주는 인덱스를 믿지 않는다 — bufferView·accessor의 offset/stride/count는
JSON에 적힌 숫자일 뿐이고, 잘린
.bin이나 손댄 파일은 버퍼 밖을 가리킬 수 있다. 그건 glTF 에러가 아니라 프로세스 메모리 OOB 읽기다. 정점 배열 밖을 가리키는 인덱스도 마찬가지로 걸러낸다 — GPU가 버퍼 밖을 읽는 건 GL 에러가 아니라 미정의 동작이고, 드라이버에 따라 잘못된 삼각형이 아니라 디바이스 리셋이다.
Material은 glTF의 pbrMetallicRoughness를 그대로 담되 상당 부분이 잠들어 있다 —
metallic/roughness, 노멀맵, emissive, occlusion은 읽어서 저장만 하고 아무도 샘플하지
않는다. PBR을 넣는 날 로더는 한 줄도 바뀌지 않는다. 알파만은 예외로 지금 쓰인다:
BLEND/MASK 재질을 불투명하게 그리는 건 "아직 라이팅이 없는" 게 아니라 그냥 틀린
그림이기 때문이다.
전부 DSA + RAII. move-only이고, 소멸자가 glDelete*를 책임진다.
| 클래스 | 기능 |
|---|---|
Buffer |
glNamedBufferStorage 기반 불변 버퍼. GL_DYNAMIC_STORAGE_BIT로 갱신 가능 버퍼도 지원 |
VertexArray |
어트리뷰트 포맷과 바인딩 포인트를 분리해 설정. 정수 어트리뷰트(setAttribI)도 지원 |
Shader |
컴파일/링크, 에러 로그 출력, 유니폼 위치 캐시. checkReload()가 소스 파일 mtime을 보고 바뀐 것만 다시 빌드 |
Texture2D |
stb_image 디코딩(파일/메모리/생픽셀), 밉맵 생성, 색 공간 지정 |
Framebuffer |
컬러(포맷 지정) + 깊이(DEPTH32F) 어태치먼트, 완전성 체크, 리사이즈 |
GLDebug |
glDebugMessageCallback 설치. HIGH severity에서 디버거 중단 |
- 유니폼은
glProgramUniform*—glUseProgram없이 프로그램을 이름으로 지정해 올린다. 바인딩 순서에 얽히지 않는다. bind()가 뷰포트까지 설정 — 프레임버퍼 해상도와 뷰포트가 어긋날 수 없다. 멀티패스에서 가장 흔한 버그를 구조적으로 막는다.- 컬러 없는 FBO —
colorFormat = GL_NONE이면 draw/read 버퍼를GL_NONE으로 설정해 깊이 전용 타깃(섀도 맵)이 완전성 체크를 통과한다. - 리사이즈는 재할당 —
glTextureStorage2D는 불변 스토리지라 크기가 바뀌면 삭제 후 재생성한다. 그래서 패스는 텍스처 id가 아니라Framebuffer*를 보관해야 한다. - 색 공간은 인자로 강제 —
Texture2D의 모든 로드 경로가ColorSpace를 필수 인자로 받는다. 파일만 봐서는 알 수 없는 정보이기 때문이다(같은 8비트 PNG가 알베도면 색이고 노멀맵이면 숫자다). 로드 시 GL에서 실제 할당된 포맷을 되읽어 찍는다:[Texture2D] loaded: uv_grid.png 256x256 3ch SRGB8 - 3채널 업로드 정렬 — GL 기본값인 4바이트 행 정렬은 폭이 4의 배수가 아닌 RGB 이미지에서
행마다 몇 바이트씩 밀려 이미지가 사선으로 찢어진다. 업로드 전에
GL_UNPACK_ALIGNMENT를 1로 바꾸고, 끝나면 원래 값으로 되돌린다 — 기본값을 다시 쓰는 게 아니라 읽어둔 값을 쓴다. 지금은 같은 숫자지만, 전역 상태를 "복원"과 "기본값으로 리셋"으로 헷갈리게 두면 누군가 이 값을 바꾸는 날 조용히 남의 업로드를 망친다. - 깊이는
DEPTH_COMPONENT32F, 다만 아직 이득은 아니다 — float 깊이가 값을 하는 건 reversed-Z와 같이 쓸 때다. 현재는 기본[0,1]매핑에GL_LESS이고, 그 조건에서 32F는 24비트 unorm보다 원거리 정밀도가 약간 나쁘다(부동소수 정밀도가 이미 여유 있는 near 쪽에 몰린다). 그래도 32F로 두는 이유는 reversed-Z가glClipControl+ 뒤집은 프로젝션 +GL_GREATER+ clear 0의 묶음이라, 포맷이 미리 맞아 있으면 그날 할당 코드는 건드릴 일이 없기 때문이다. 현재 near/far 비(바운드에서 유도된 약 2000:1)는 차이가 이론에 머무는 범위다.
Camera는 위치 + 쿼터니언을 저장하고 view/proj는 매번 계산한다. view 행렬을 저장하지 않으므로 월드 좌표 위치를 역산할 필요가 없다.- fov·near·far·width·height를 스칼라로 보관 — 파라미터 하나만 바꿔도 되고, 나중에
intrinsic 행렬(
K)을 뽑을 때 행렬 역산 없이 그대로 쓴다. lookAt()은 퇴화 입력(eye == target, up ∥ view dir)을 거부한다. 그냥 두면 쿼터니언에 NaN이 박히고, 증상은 "모델이 사라짐" 하나뿐인데 GL 디버그 출력에는 아무 것도 안 잡힌다 — NaN이 GL 호출이 아니라 CPU 쪽 glm 연산에서 생기기 때문이다.app::FlyCameraController— WASD/QE + 마우스룩, shift 스프린트, dt 스케일링, ±89° 피치 클램프.scene/이 GLFW를 모르도록app/레이어에 둔다.
| 키 | 동작 |
|---|---|
W A S D |
전후좌우 이동 |
Q E |
아래/위 이동 |
Shift |
스프린트 (×3) |
| 마우스 | 시점 회전 |
Tab |
커서 잠금 토글 — 잠금: 카메라 조작 / 해제: UI 조작 |
[ ] |
exposure 조절 (커서 잠금 상태에서만; UI에서는 슬라이더로) |
Esc |
종료 |
커서를 놓아준 동안에는 카메라 입력이 통째로 멈춘다. 마우스 주인이 항상 한 명이라 카메라가 UI 조작을 따라 흔들리는 일이 없다.
src/
├─ core/ Window 창, GL 4.5 디버그 컨텍스트, 입력 폴링
├─ gfx/ Buffer, VertexArray DSA 버퍼/VAO 래퍼
│ Shader, Framebuffer 셰이더 프로그램, 렌더 타깃
│ Texture2D 이미지 로딩 + 색 공간
│ GLDebug 디버그 콜백
├─ scene/ Camera, Scene, Mesh 씬 데이터 (glm + gfx에만 의존)
│ Material, Vertex 재질 슬롯, 공용 정점 레이아웃
├─ asset/ Model glTF 2.0 -> GPU 리소스 + 씬 메시
├─ render/ RenderPass, Renderer 패스 인터페이스, 실행기 + 타깃 레지스트리
│ ForwardPass 씬 -> sceneColor (HDR, 불투명 후 투명)
│ TonemapPass sceneColor -> 화면
├─ app/ FlyCameraController 입력을 씬에 연결
│ DebugUi ImGui 컨텍스트 + 디버그 패널
└─ main.cpp 패스 목록 조립 + 프레임 루프
shaders/
├─ basic.vert / basic.frag 텍스처 입힌 큐브를 HDR 타깃에 그림
└─ fullscreen.vert / tonemap.frag 풀스크린 삼각형 + exposure/ACES/sRGB
assets/
├─ uv_grid.png 디버그용 UV 그리드 (빨강=U, 초록=V, 체커=필터/밉맵 확인)
└─ models/ 테스트 모델 (git에 포함하지 않음, 아래 참조)
레이어는 아래에서 위로만 의존한다: gfx → core → scene → {asset, render} → app.
asset/이 따로 있는 이유는 파서를 씬 레이어 밖에 두기 위해서다 — 손으로 조립한 씬
(내장 큐브)은 glTF 라이브러리 없이도 성립해야 한다.
scene/이 GLFW를 모르는 것이 핵심이다 — 카메라를 사람 대신 스크립트가 몰 수 있다.
render/가 아는 유일한 외부 UI 라이브러리는 ImGui이고, 그건 drawUi() 훅 하나
때문이다 — 패스 타입을 일일이 아는 UI를 만드는 것보다 이쪽 결합이 싸다.
| 항목 | 설치 | 확인 |
|---|---|---|
| MSVC 컴파일러 | Visual Studio Build Tools 2022 → "Desktop development with C++" | — |
| CMake | Build Tools에 포함되거나 cmake.org | cmake --version ≥ 3.20 |
| Git | git-scm.com | git --version |
| Python 3 | python.org (GLAD 생성기가 빌드 중에 돌아감) | python --version |
GLFW·GLM·GLAD·Dear ImGui·stb_image·tinygltf는 CMake FetchContent가 자동으로 받는다.
Visual Studio IDE 전체는 필요 없고 Build Tools만으로 충분하다.
cmake -S . -B build
cmake --build build --config Debug
.\build\bin\Debug\renderer.exeVS Code로 한다면 C/C++ + CMake Tools 확장을 깔고, 킷으로
Visual Studio Build Tools 2022 - amd64를 선택한 뒤 F7 → 실행.
첫 configure는 의존성을 받느라 1~3분 걸린다.
저장소에는 모델을 넣지 않는다. Khronos 샘플에서 받아 assets/models/에 두면 된다
(이 폴더는 gitignore 대상):
$base = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models"
mkdir assets/models
curl -L -o assets/models/BoxTextured.glb "$base/BoxTextured/glTF-Binary/BoxTextured.glb"
curl -L -o assets/models/Duck.glb "$base/Duck/glTF-Binary/Duck.glb"
.\build\bin\Debug\renderer.exe assets/models/Duck.glb경로를 못 읽거나 파일이 깨졌으면 내장 큐브로 넘어가고 이유를 콘솔에 찍는다 — 빈 창이 뜨는 일은 없다.
glad_add_library에러 → Python 3가 PATH에 없다. 설치 후build/를 지우고 재설정.- 창 생성 실패 → GPU 드라이버가 GL 4.5 미만.
dxdiag로 GPU 확인 후 드라이버 업데이트. - 셰이더를 못 찾음 → 경로가
SHADER_DIR매크로에 절대경로로 박혀 있다. 프로젝트를 옮겼으면 CMake를 다시 configure 할 것. - 한글 주석 C4819 경고 → CMakeLists의
/utf-8이 처리한다. 파일은 UTF-8로 저장. - LNK4098 (CRT 충돌) → CMakeLists에 GLAD 타깃의 런타임 라이브러리를
/MD로 맞추는 우회가 들어 있다. 자세한 이유는 해당 주석 참조.
Phong 라이팅(교체 가능하도록 shade() 뒤로 분리)과 스카이박스 패스,
그 위에 섀도 매핑.