Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mini renderer

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 2.0 로딩 (asset/Model)

.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 재질을 불투명하게 그리는 건 "아직 라이팅이 없는" 게 아니라 그냥 틀린 그림이기 때문이다.

GL 래퍼 (gfx/)

전부 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.exe

VS 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() 뒤로 분리)과 스카이박스 패스, 그 위에 섀도 매핑.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages