반응형

이번에는 표준 포맷인 glTF 2.0 모델을 불러와서, 지오메트리와 PBR 머티리얼, 텍스처를 엔진 구조로 옮깁니다. 테스트 모델은 Khronose 공식 샘플인 DamagedHelemt입니다.

 

  • 왜 glTF인가
  • PBR metallic-rouoghness 머티리얼이 표준 사양에 들어 있습니다.
  • .gltf(JSON과 외부 파일)와 .glb(바이너리 하나로 묶음) 두 형태가 있습니다.
  • 파서로는 단일 헤더 라이브러리인 cgltf를 썼습니다.

전체흐름

cgltf_parse_file + cgltf_load_buffers
 ├─ 1) 텍스처: 실제로 쓰이는 이미지만, (이미지, sRGB 여부) 단위로 캐시
 ├─ 2) 머티리얼: glTF material → Material (Scene 소유)
 ├─ 3) 메시: 프리미티브마다 Mesh 생성 + 머티리얼과 짝지음
 └─ 4) 노드: 메시가 달린 노드마다 Entity 생성 (월드 행렬 → Transform)

Model 클래스는 메시와 텍스처를 소유하고, 씬에는 머티리얼과 엔티티를 만들어 넣었습니다. 씬이 모델의 메시를 참조하므로 씬을 쓰는 동안 Model이 살아 있어야 합니다.


1.에셋 경로 정리

경로가 하드코딩 되어 있어서 소스 포러 기준으로 절대 경로를 쓰도록 바꿨습니다.

inline std::string AssetPath(const std::string& relative)
{
    return std::string(BAEK_ROOT_DIR "Assets/") + relative;
}

2. 지오메트리

glTF의 구조

  • mesh 하나는 primitive 여러 개로 이루어집니다. 프리미티브는 머티리얼 하나를 쓰는 조각입니다.
  • node는 변환과 계층을 가지고 메시를 참조합니다. 같은 메시를 여러 노드가 공유할 수 있습니다.
  • 정점 데이터는 accessor를 통해 읽습니다. cgltf_accessor_read_float를 쓰면 저장 타입(float, 정규화된 정수 등)과 무관하게 float로 받을 수 있습니다.

 

32비트 인덱스

16비트 인덱스는 정점 65,536개까지만 가리킬 수 있습니다 실제 모델에는 부족해서 Mesh의 인덱스를 전부 32비트(DXGI_FORMAT_R32_UINT)로 통일했습니다.

 

오른손->왼손 좌표계 변환

glTF는 오른손 좌표계, 왼손은 왼손 좌표계입니다. 그대로 쓰면 모델이 거울상으로 나옵니다.

대상 변환 이유
위치, 노멀, 탄젠트 Z 성분 부호 반전 좌표계 뒤집기
탄젠트 w 부호 반전 거울 반전하면 외적 방향이 뒤집힘
인덱스 삼각형마다 두개 교환 Z를 뒤집으면 도는 방향이 반대가 됨
노드 행렬 S x N x S (S=Z 반전) 정점과 같은 기준으로 맞춤

노드 행렬 변환은 이렇게 유도됩니다. 원래 w = v·M 이고 변환 후 정점은 v' = v·S입니다.

변환된 월드 좌표 w' = w·S = v'·(S·M·S)가 되므로, 새 행렬은 S·M·S입니다.

 

행렬 -> Transform 분해

glTF노드는 변환을 행렬로 줍니다. 엔티티는 위치/회전/스케일을 들고 있으므로 분해가 필요합니다.

  • 위치: 마지막 행
  • 스케일: 앞 세 행 각각의 길이
  • 회전: 스케일을 나눈 행렬에서 오일러 각을 추출합니다. 이때 Transform::Matrix()와 같은 회전 순서 (X->Y->Z)로 분해해야 합니다.

3. 머티리얼과 텍스처

내장 이미지

.glb는 이미지가 파일 안에 PNG/JPG 바이트 그대로 들어 있습니다.

stbi_load_from_memory로 디코딩하는 경로를 추가했습니다.

.gltf는 옆에 있는 이미지 파일을 URI로 참조하므로, %20같은 인코딩을 풀고 모델 폴더 기준으로 경로를 만듭니다.

 

색 공간

데이터 glTF에서의 색 공간 처리
baseColor, emissive 텍스처 sRGB _SRGB 포맷으로 업로드
normal, metallic-roughness 텍스처 선형 일반 포맷
baseColorFactor 선형 엔진 Material은 sRGB 기준이라 로딩 시 변환
emissiveFactor 선형 그대로 사용

같은 이미지가 용도에 따라 다른 포맷이 될 수 있어서, 텍스처 캐시 키를 (이미지, sRGB 여부) 쌍으로 잡았습니다.

 

탄젠트 생성

DamagedHelment은 파일에 탄젠트가 없습니다. 노멀 맵을 쓰려면 직접 계산해야 합니다.

  1. 삼각형마다 두 변과 UV변화량으로 U방향(T)과 V방향(B)을 구해 세 정점에 누적합니다.
  2. 정점마다 T를 노멀에 수직이 되게 다듬고(그람-슈미트), 정규화합니다.
  3. cross(N, T)와 누적된 B를 비교해 비탄젠트 부호(w)를 정합니다.

주의할 점: glTF 노멀 맵은 초록 채널이 이미지 위쪽입니다. 즉, V가 줄어는 방향입니다. 엔진의 절차적 테스트 텍스처는 반대 규약(V가 늘어나는 방향)을 썼습니다. 두 규약이 공존할 수 있는 건 셰이더가 B = cross(N, T) x w로 계산하고, w로 방향을 정하기 때문입니다.

 

Emissive

자체 발광 맵(t3)을 추가했습니다.

emissiveFactor x emissiveTexture를 조명 결과에 더합니다. 기본 factor가 0이라서, 맵이 없을 때 흰색 텍스처를 바인딩해도 발광하지 않습니다.

반응형
반응형

cgltf는 단일 헤더 라이브러리입니다. 구현은 .cpp 파일 딱 하나에서만 포함해야 합니다. 두 군데서 CGLTF_IMPLEMENTATION을 정의하면 링크 단계에서 중복 정의 에러가 납니다.

#define CGLTF_IMPLEMENTATION   // 한 파일에서만
#include "cgltf.h"

1.파일 열기와 해제

함수 역할
cgltf_parse_file(options, path, &data) .gltf(JSON) 또는 .glb를 파싱해 cgltf_datta 구조를 만듭니다. 외부 .bin 버퍼는 아직 읽지 않습니다.
cgltf_load_buffers(options, data, path) 정점과 인덱스가 담긴 버퍼를 메모리에 올립니다. 외부 .bin, data URL, .glb의 BIN 청크를 처리합니다. 세 번째 인자는 상대 경로의 기준이 되는 원본 파일 경로입니다.
cgltf_validate(data) (선택) 인덱스 범위 등 데이터 무결성을 검사합니다. 이번에는 사용하지 않았습니다.
cgltf_free(data) 파싱한 데이터와 버퍼를 모두 삭제(메모리 해제)합니다.

 

모든 함수는 cgltf_result를 반환하고, 성공하면 cgltf_result_success입니다.

cgltf_optioins를 0으로 초기화하면 파일 형식(.gltf/.glb)을 자동으로 판별합니다.

cgltf_options options{};
cgltf_data* data = nullptr;

if (cgltf_parse_file(&options, path.c_str(), &data) != cgltf_result_success)
    throw std::runtime_error("glTF parse failed");
if (cgltf_load_buffers(&options, data, path.c_str()) != cgltf_result_success)
{
    cgltf_free(data);   // 파싱은 성공했으므로 실패해도 해제는 필요
    throw std::runtime_error("glTF buffer load failed");
}

// ... 데이터 사용 ...

cgltf_free(data);

cgltf_data에서 꺼낸 포인터들은모두 cgltf_free 이후에는 무효입니다. 필요한  값은 그 전에 엔진 구조로 복사해 둬야 합니다.


2. 전체 구조(cgltf_data)

필드 내용
meshes, meshes_count 메시 목록, 개수
materials, materials_count 머티리얼 목록, 개수
nodes, nodes_count 노드 목록, 개수. 계층과 변환을 가짐
images, images_count 이미지 목룍, 개수
textures, textures_count 텍스처(이미지+샘플러) 목록, 개수
scene, scenes 기본 씬과 씬 목록

 

glTF안에서는 서로를 포인터로 참조합니다. 배열에서의 인덱스가 필요하면 포인터 뺄셈으로 구합니다.

size_t meshIndex     = node.mesh - data->meshes;
size_t materialIndex = prim.material - data->materials;

3. 메시와 정점 데이터

 

cgltf_mesh

필드 내용
name 메시 이름 (없으면 nullptr)
primitives, primitives_count 프리미티브 목록, 개수. 머티리얼 하나를 쓰는 조각 단위

 

cgltf_primitive

필드 내용
type 그리기 방식, 삼각형 목록은
cgltf_primitive_type_triangles
attributes, attributes_count 정점 속성 목록(위치, 노멀 등), 개수
indices 인덱스 accessor. 없으면 nullptr(정점 순서대로 그림)
material 사용하는 머티리얼. 없으면 nullptr

 

cgltf_attribute

필드 내용
type 속성 종류: cgltf_attribute_type_position, _normal, _tangent, _texcoord, _color, _joints, _weights
index 같은 종류의 몇 번째 세트인지. TEXCOORD_0이면 0, TEXCOORD_1이면 1
data 실제 값을 읽을 accessor
const cgltf_accessor* posAcc = nullptr;
for (cgltf_size a = 0; a < prim.attributes_count; ++a)
{
    const cgltf_attribute& attr = prim.attributes[a];
    if (attr.type == cgltf_attribute_type_position) posAcc = attr.data;
    if (attr.type == cgltf_attribute_type_texcoord && attr.index == 0) uvAcc = attr.data;
}

 

accessor 읽기

함수 역할
cgltf_accessor_read_float(acc, i, out, n) i번째 요소를 float n개로 읽습니다. 저장 타입이 정규화된 정수
(예: UV를 16비트로 압축)여도 float로 변환해 줍니다. sparse accessor도 처리합니다.
cgltf_accessor_read_index(acc, i) i번째 인덱스를 정수로 읽습니다. 저장 타입이 8/16/32비트 중 무엇이든 같은 함수로 읽습니다.
acc-->count 요소 개수. 정점 수나 인덱스 수가 됩니다.
for (cgltf_size v = 0; v < posAcc->count; ++v)
{
    float p[3];
    cgltf_accessor_read_float(posAcc, v, p, 3);
}

for (cgltf_size i = 0; i < prim.indices->count; ++i)
    idx[i] = (uint32_t)cgltf_accessor_read_index(prim.indices, i);

버퍼의 바이트 오프셋, stride, 컴포넌트 타입을 직접 계산하지 않아도 된다는 게 이 두 함수의 장점입니다.


4. 노드와 변환

cgltf_node

필드 내용
name 노드 이름
mesh 이 노드가 그리는 메시. 없으면 nullptr(빈 노드, 카메라, 라이트 등)
parent, children, children_count 계층 구조
has_matrix, matrix[16] 행렬로 지정된 로컬 변환
has_translation, translation[3] 위치
has_rotation, rotation[4] 회전 쿼터니언(x, y, z, w 순서)
has_scale, scale[3] 스케일

 

변환 계산 함수

함수 역할
cgltf_node_transform_local(node, out16) 이 노드 자신의 로컬 행렬, TRS로 지정돼 있어도 행렬로 합쳐줍니다.
cgltf_node_transform_world(node, out16) 부모까지 모두 곱한 월드 행렬

 

결과는 열 우선(column-major) float[16]입니다. 메모리 배치가 DirectXMath의 XMFLOAT4X4(행 우선, 행 벡터 규약)와 같아서 그대로 쓸 수 있습니다. 이동 성분이 [12], [13], [14]에 있습니다.

cgltf_float wm[16];
cgltf_node_transform_world(&node, wm);

XMFLOAT4X4 f;
memcpy(&f, wm, sizeof(f));

5. 머티리얼

cgltf_material

필드 내용
name 머티리얼 이름
has_pbr_metallic_roughness 아래 PBR 블록이 유효한지
pbr_metallic_roughness PBR 값과 텍스처 (아래 표)
normal_texture 노멀 맵(scale 필드에 세기)
occlusion_texture AO 맵(scale 필드에 세기)
emissive_texture 발광 맵
emissive_factor[3] 발광 색(선형)
alpha_mode, alpha_cutoff 불투면 / 마스크 / 블렌드
double_sided 양면 렌더링 여부

 

pbr_metallic_roughness

필드 내용
base_color_factor[4] 기본 색RGBA (선형)
base_color_texture 기본 색 텍스처 (sRGB)
metallic_factor 금속성 계수
roughness_factor 거칠기 계수
metallic_roughness_texture G채널 = 거칠기, B채널 = 금속성 (선형)

계수(factor)와 텍스처는 곱해서 씁니다. 텍스처가 있는 모델은 보통 계수가 1.0이고, 실제 값은 텍스처가 픽셀 단위로 정합니다.


6. 텍스처와 이미지

참조는 세 단계로 이어집니다.

cgltf_texture_view  →  cgltf_texture  →  cgltf_image
  (머티리얼의 슬롯)      (이미지 + 샘플러)    (실제 픽셀 데이터 위치)
구조체 주요 필드
cgltf_texture_view texture: 참조하는 텍스처(없으면 nullptr)
texcoord: 사용할 UV 세트 번호
scale: 노멀/AO 맵의 세기
cgltf_texture image: 참조하는 이미지
sampler: 필터와 래핑 설정
cgltf_image uri: 외부 파일 경로 또는 data: URI
buffer_view: .glb 내장 이미지의 위치
mime_type: "image/png" 등
cgltf_buffer_view buffer, offset, size
cgltf_buffer data: cgltf_load_buffers 이후 메모리에 올라온 바이트

 

이미지 바이트를 얻는 두 경로

const cgltf_image* img = view.texture->image;

if (img->buffer_view)   // .glb: 파일 안에 PNG/JPG 바이트가 그대로 들어 있음
{
    const cgltf_buffer_view* bv = img->buffer_view;
    const uint8_t* bytes = (const uint8_t*)bv->buffer->data + bv->offset;
    // bytes, bv->size 를 stbi_load_from_memory에 전달
}
else if (img->uri)      // .gltf: 옆에 있는 이미지 파일
{
    std::string uri = img->uri;
    cgltf_decode_uri(uri.data());          // "%20" 같은 URL 인코딩을 제자리에서 해제
    uri.resize(strlen(uri.c_str()));
    // 모델 폴더 경로 + uri 로 파일 로딩
}
함수 역할
cgltf_decode_uri(char* uri) URI의 퍼센트 인코딩을 문자열 안에서 바로 풀고, 새 길이를 반환합니다.
cgltf_buffer_view_data(bv) 최신 버전에 있는 헬퍼입니다. buffer->data + offset 계산을 대신해 줍니다.

7. 한눈에 보는 로딩 순서

cgltf_parse_file          파일 구조 파싱
cgltf_load_buffers        정점/인덱스/내장 이미지 바이트 로드
  │
  ├─ materials[i]          → pbr_metallic_roughness, normal/emissive_texture
  │     └─ texture_view → texture → image → (buffer_view | uri)
  │
  ├─ meshes[m].primitives[p]
  │     ├─ attributes[a]   → cgltf_accessor_read_float
  │     ├─ indices         → cgltf_accessor_read_index
  │     └─ material        → 포인터 뺄셈으로 인덱스
  │
  └─ nodes[n]
        ├─ mesh            → 포인터 뺄셈으로 인덱스
        └─ cgltf_node_transform_world
cgltf_free                전부 해제

 

반응형

'DirectX12 > 엔진 만들어보기' 카테고리의 다른 글

glTF 모델 로딩  (0) 2026.10.09
라이팅에서 PBR 머티리얼까지  (0) 2026.10.06
그리드, Scene/Entity, Hierarchy·Inspector, 기즈모  (0) 2026.10.05
서브모듈 정리, 카메라  (0) 2026.10.03
Viewport 만들기  (0) 2026.10.02
반응형
  1. 노멀과 방향광 (Blinn-Phong)
  2. HDR 렌더 타깃, 톤매핑, 감마 보정
  3. PBR (Cook-Torrance)
  4. 텍스처 로딩과 밉맵
  5. 머티리얼, 탄젠트, 노멀 맵

1. 노멀과 방향광

정점과 상수 버퍼

조명을 계산하려면 표면이 어느 쪽을 향하는지 알아야 합니다. 정점에 노멀을 추가했고, 큐브는 면마다 노멀이 달라야 해서 정점이 8개에서 24개로 늘었습니다.

보내 데이터가 늘면서 루트 상수 16개로는 부족해졌습니다. 상수 버퍼를 두 개로 나눴습니다.

버퍼 갱신 주기 내용
PerFrame(b0) 프레임당 1회 ViewProj, 카메라 위치, 라이트 방향/색, amblient
PerObject(b1) 드로우당 1회 World, WorldInvTranspose, 베이스 컬러, 머티리얼 값

 

UploadRing: 프레임별 상수 버퍼 할당기

CPU가 다음 프레임의 상수를 쓰는 동안 GPU는 아직 이전 프레임을 그리고 있을 수 있습니다. 같은 버퍼를 쓰면 GPU가 읽는 도중에 값이 바뀝니다. 그래서 프레임 인덱스별로 업로드 버퍼를 따로 두고, 256바이트 단위로 잘라 순서대로 나눠 씁니다. 자기 차례가 돌아오면(GPU가 이미 다 쓴 뒤) 처음부터 다시 씁니다.

 

WorldInvTranspose

노멀을 월드 행렬로 그대로 변환하면 비균등 스케일에서 방향이 틀어집니다. 노멀에는 월드 행렬의 역전치 행렬을 써야 표면에 수직인 상태가 유지됩니다.


2. HDR과 톤매핑

문제

조명 계산 결과는 1.0을 쉽게 넘습니다. 8비트 렌더 타켓에 바로 그리면 쓰는 순간 1.0에서 잘려서, 밝은 면이 색 구분 없이 하얗게 뭉개집니다.

구조

씬 렌더링 → [sceneRT: R16G16B16A16_FLOAT] → 톤매핑 패스 → [viewportRT: R8G8B8A8_UNORM] → ImGui
  sceneRT viewportRT
담는 값 실제 빛의 양 (1.0초과 가능) 모니터로 내보낼 최종 색
깊이 버퍼 있음 없음

같은 텍스처를 읽으면서 동시에 쓸 수느 없어서 입력과 출력이 분리돼야 합니다.

블룸이나 안티앨리어싱 같은 포스트프로세스도 이 사이에 단계가 하나씩 끼어드는 구조로 늘어납니다.

 

톤 매핑 패스

  • 풀 스크린 삼각형: 버텍스 버퍼 없이 SV_VertexID(0,1,2)만으로 화면보다 큰 삼각형 하나를 만들어 덮습니다.
  • ACES: 밝은 값을 잘라 내지 않고 곡선으로 눌러서 계조를 남깁니다.
  • 감마 보정: 모니터는 입력을 약2.2제곱해서 표시합니다. 계산은 선형 공간에서 하고, 마지막에 1/2.2제곱을 해서 내보냅니다. 반대로 색상 피커에서 고른 색은 셰이더에서 선형으로 바꾼 뒤 계산합니다.

3. PBR (Cook-Torrance)

표면을 아주 작은 거울 조각(미세면)이 모인 것으로 보는 모델입니다.

항 묻는것 화면에서 보이는 효과
D (GGX) 미세면 중 몇 개가 반사 방향을 향하는가 거칠기가 낮으면 작고 강한 하이라이트, 높으면 넓고 흐린 하이라이트
G (Smith) 미세면끼리 서로 가리는 정도 비스듬한 각도에서 과하게 밝아지는 걸 억제
F (Schlick) 보는 각도에 따른 반사율 변화 가장자리로 갈수록 반사가 강해짐
float3 F0       = lerp(0.04, albedo, metallic);              // 비금속 4%, 금속은 자기 색
float3 specular = (D * G * F) / max(4 * NdotV * NdotL, 1e-4);
float3 diffuse  = (1 - F) * (1 - metallic) * albedo / PI;    // 금속은 난반사 없음
  • 금속성은 두 가지를 바꿉니다. 금속은 난반사가 없고, 반사광에 자기 색이 묻어납니다.
  • 난반사는 π로 나누는 것은 에너지 보존을 위한 것입니다. Blinn-Phong 때보다 약 3배 어두워져서 기본 라이트 세기를 3.0으로 올렸습니다.
  • 현재 한계: 금속은 주변 환경을 반사해서 보이는 재질인데, 지금은 방향과 하나뿐이라 하이라이트 주변 말고는 어둡습니다. 환경 조명(IBL)이 들어가야 제대로 보입니다.

4. 텍스처와 밉맵

업로드 경로

CPU는 UPLOAD힙에만 쓸 수 있고, GPU는 DEFAULT힙에서 읽는게 빠릅니다. 그래서 두 단계를 거칩니다.

픽셀 데이터 → 업로드 버퍼(UPLOAD 힙) → CopyTextureRegion → 텍스처(DEFAULT 힙) → SRV
  • Renderer::Immediate: 초기화 시점에는 열린 커맨드 리스트가 없어서, 람다를 받아 즉시 실행하고 완료까지 기다리는 함수를 만들었습니다.
  • RowPitch: GPU가 요구하는 행 간격이 width x 4 보다 클 수 있습니다.
  • sRGB포맷: 색상 텍스처는 _SRGB포맷으로 만들면 샘플링할 때 GPU가 선형으로 바꿔 줍니다. 노멀이나 러프니스 같은 데이터 텍스처는 일반 포맷을 씁니다.
  • 기본 텍스처: 텍스처가 없는 슬롯에는 1x1흰색을 바인딩해서 셰이더를 한가지 경로로 유지합니다.

 

이미지 포맷

  일반 이미지(PNG, JPG, TGA) GPU전용(DDS, KTX2)
로딩 CPU에서 압축을 풀어 RGBA로 변환 거의 그대로 업로드
VRAM 큼 BC압축으로 4분의 1에서 8분의 1
밉맵 없음, 직접 생성 파일에 포함
용도 제작과 교환 런타임

지금은 stb_image로 일반 이미지를 읽습니다. 나중에 에셋 파이프라인에서 BC압축 DDS로 변환해 캐시하는 구조로 갈 계획입니다.

 

밉맵

멀리 있는 표면은 화면의 한 픽셀이 텍스처의 여러 칸을 덮습니다. 그중 한 점만 읽으면 자글자글하게 깨지고, 움직일 때 깜빡입니다. 절반씩 줄인 사본을 미리 만들어 두고 거리에 맞는 사본을 읽게 합니다.

  • DX12에는 밉맵 자동 생성 함수가 없어서 CPU에서 2x2평균으로 만들었습니다.
  • 밉 레벨 하나하나가 서브리소스이고, 복사 명령은 서브리소스 단위로 내립니다.
  • sRGB는 선형으로 바꾼 뒤 평균해야 합니다. 검정과 흰색을 바이트 그대로 평균하면 128이 되는데, 실제 밝기로는 약 22%입니다. 올바른 값은 약 188입니다.
  • 메모리는 원본의 약 1.33배가 됩니다.

5. 머티리얼과 노멀 맵

Material 구조

엔티티가 낱개로 들고 있던 값을 묶었습니다. Scene이 머티리얼을 소유하고, 엔티티는 포인터로 참조합니다.

여러 엔티티가 같은 머티리얼을 공유할 수 있습니다.

슬롯 내용 기본값
t0 Albedo 색 (sRGB) 흰색
t1 Normal 표면 요철 방향(선형) (128,128,255)
t2 Metallic-Roughness G = 거칠기, B = 금속성 (glTF 규약) 흰색

텍스처마다 SRV가 힙의 다른 위치에 있어서, 디스크립터 테이블을 슬롯별로 하나씩 따로 뒀습니다.

 

탄젠트 공간

노멀 맵의 RGB는 방향 벡터인데, 월드 기준이 아니라 표면에 붙은 좌표계 기준입니다.

  • T: 텍스처의 U가 증가하는 방향(정점 속성으로 저장)
  • B: V가 증가하는 방향. cross(N, T) x w로 계산
  • N: 표면 노멀
float3 nTS = gNormalMap.Sample(gSampler, uv).xyz * 2 - 1;
N = normalize(nTS.x * T + nTS.y * B + nTS.z * N);

이 좌표계를 쓰기 때문에 같은 노멀 맵을 큐브의 어느 면에든, 구에든 그대로 쓸 수 있습니다.

실제 지오메트리는 바뀌지 않고 조명 계산에 쓰는 노멀만 바뀌므로, 실루엣은 여전히 매끈합니다.


결과

반응형

'DirectX12 > 엔진 만들어보기' 카테고리의 다른 글

glTF 모델 로딩  (0) 2026.10.09
cgltf API 정리 (사용한것 기준)  (0) 2026.10.09
그리드, Scene/Entity, Hierarchy·Inspector, 기즈모  (0) 2026.10.05
서브모듈 정리, 카메라  (0) 2026.10.03
Viewport 만들기  (0) 2026.10.02
반응형

바닥 그리드로 공간 기준을 잡습니다.

하드코딩된 큐브를 Scene/Entity 구조로 바꿉니다.

Hierarchy/Inspector 패널에서 선택하고 값을 편집합니다.

InGuizmo로 뷰포트 안에서 직접 이동/회전/스케일을 조정합니다.


그리드 바닥

DX12의 PSO에는 PrimitiveTopologyType이 들어갑니다. TRIANGLE로 만든 PSO로는 선을 그릴 수 없습니다. 그래서 같은 셰이더와 루트 시그니처를 쓰더라도 PSO는 하나 더 만들어야 합니다.

auto linePso = pso;   // 삼각형 PSO 설정을 복사
linePso.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_LINE;
device->CreateGraphicsPipelineState(&linePso, IID_PPV_ARGS(&mLinePso));

드로우할 때는 IASetPrimitiveTopology(LINELIST)로 맞춥니다. PSO의 타입과 커맨드 리스트의 토폴로지(LIST/STRIP 등)는 별개 설정이고, 둘이 서로 호환돼야 합니다.

 


Scene / Entity 구조

라이트, 카메라, 스크립트처럼 컴포넌트가 늘어나는 시점에 옮길 계획입니다.

struct Entity
{
    std::string name;
    Transform   transform;
    const Mesh* mesh = nullptr;   // 소유하지 않음
    bool        visible = true;
};

모듈구성

모듈 역할
Mesh VB/IB와 뷰를 소유하고 Draw(cmd)를 제공. CreateBue()는 1x1x1 단위 큐브
Transform position, rotation(오일러, 도 단위), scale을 들고 Matrix()를 계산
Scene std::vector<Entity>와 Create(name)
SceneRenderer 그리드를 그린 뒤, 씬의 엔티티를 순회하며 mesh->Draw 호출

핵심은 메시와 인스턴스의 분리입니다. 큐브 버퍼는 하나인데, 엔티티 세 개가 각자 다른 Transform으로 그 메시를 그립니다.


Transform 행렬 순서

S * Rx * Ry * Rz * T
  • S->R->T 순서여야 물체가 제자리에서 돌고 커집니다(DX12에서는 SRT순으로 곱해짐). 순서가 바뀌면 원점을 중심으로 공전합니다.
  • Transform은 항상 메시 원점(피벗) 기준으로 적용됩니다. 큐브는 중심 피벗이라 scale을 키우면 위 아래로 같이 커집니다. 바닥에 붙이려면 position.y = 0.5 x scale.y로 맞춰야 합니다.
  • 피벗 위치는 Transform이 아니라 메시 정점을 어떻게 만들었느냐로 결정됩니다.

주의: vector 재할당과 참조

Scene::Create가 Entity&를 반환하는데, 다음 Create에서 vector가 재할당되면 이전 참조는 무효가 됩니다. 그래서 선택 상태는 포인터가 아니라 인덱스로 저장했습니다. 엔티티 삭제 기능이 들어가면 안정적인 ID방식으로 바꿔야 합니다.


Hierarchy / Inspector 패널

Hierarchy: 엔티티 목록을 ImGui::Selectable로 표시합니다. PushID(i)를 써서 이름이 같은 엔티티가 있어도 ID가 충돌하지 않게 했습니다. 빈 공간을 클릭하면 선택이 해제 됩니다.

Inspector: 선택한 엔티티의 이름(InputText), 표시 여부(Checkbox), Transform(DragFloat3 세 개)을 편집합니다.

 

DragFloat3에는 &transform.position.x를 넘기는데, 이유는 float* 구조체이기에 다음 참조로 넘어가면 x->y->z 순으로 넘어가기 때문에 x 원소만 넘겨주면 된다.


도킹 레이아웃

DockBuilder로 4분할 기본 배치를 만들었습니다. Hierarchy는 왼쪽, Viewport는 가운데, Inspector는 오른쪽 위, Stats는 오른쪽 아래입니다.

 


ImGuizmo

연결방식

ImGuizmo::SetDrawlist();
ImGuizmo::SetRect(min.x, min.y, size.x, size.y);   // Viewport 이미지 영역

if (ImGuizmo::Manipulate(view, proj, op, mode, world))
    ImGuizmo::DecomposeMatrixToComponents(world, &t.position.x, &t.rotation.x, &t.scale.x);
  • DirectXMath의 XMFLOAT4X4 메모리 배치는 ImGuizmo가 기대하는 float[16]과 같습니다. 그래서 변환 없이 포인터만 넘깁니다.
  • Mainpulate는 조작이 있었던 프레임에만 true를 반환합니다. 수정된 월드 행렬을 위치/회전/스케일로 분해해서 Transform으로 되돌립니다.
  • 회전 순서 일치: ImGuizmo는 X->Y->Z 순서로 오일러 각을 분해합니다. Transform::Matrix도 같은 순서로 맞춰야 기즈모를 놓을 때 물체가 튀지 않습니다.
  • 스케일은 로컬 축에서만 의미가 있어서 LOCAL 모드를 쓰고, 이동 회전은 WORLD 모드를 씁니다.

단축키

W(이동), E(회전), R(회전)로 전환합니다.

우클릭 중(카메라 플라이)이거나 텍스트 입력 중에는 무시해서, 카메라의 WASD나 이름 입력과 충돌하지 않습니다.


에디터 설정 파일 위치

imgui.ini는 기본적으로 작업 디렉터리에 저장됩니다. launch.vs.json의 currentDir이 Assets로 잡혀 있어서 콘텐츠 폴더에 설정 파일이 섞였습니다. 언리얼의 Saved/처럼 분리했습니다.

std::filesystem::create_directories(BAEK_ROOT_DIR "Saved");   // ImGui는 폴더를 만들어주지 않음
io.IniFilename = BAEK_ROOT_DIR "Saved/imgui.ini";

결과

 

반응형

'DirectX12 > 엔진 만들어보기' 카테고리의 다른 글

cgltf API 정리 (사용한것 기준)  (0) 2026.10.09
라이팅에서 PBR 머티리얼까지  (0) 2026.10.06
서브모듈 정리, 카메라  (0) 2026.10.03
Viewport 만들기  (0) 2026.10.02
엔진 구상  (0) 2026.10.01
반응형

서브모듈 정리

 

d3dx12.h는 2023년경부터 한 파일 구조에서 여러 파일로 분할됐습니다(d3dx12_core.h, d3dx12_barriers.h, d3dx12_root_signature.h 등). 파일을 손으로 복사해 쓰면 버전이 섞이거나 일부가 빠지기 쉽습니다. 게다가 신버전은 최신 d3d12.h를 전제로 작성돼 있어서, Windows SDK 버전과도 어긋날 수 있습니다.

해당 문제를 해결할 방법은 총 두가지입니다.

방식 내용 한계
DirectX-Headers 서브모듈 d3d12.h와 d3dx12를 같은 버전으로 통일 헤더만 최신, 런타임은 OS것(옛날 버전과 호환이 안될 수 있음)
Agility SDK 헤더와 D3D12Core.dll을 앱에 포함 DLL 배포와 export 심볼 선언이 필요

 

현재 상황에서는 기본 런타임으로 충분하기 때문에 서브모듈로 적용하여 다음과 같이 적용하여 수정했다.

 

bash.

git submodule add https://github.com/microsoft/DirectX-Headers.git ThirdParty/DirectX-Headers

 

CMakeLists.

# ThirdParty/CMakeLists.txt
add_subdirectory(DirectX-Headers)

# Engine/CMakeLists.txt
target_link_libraries(Engine PUBLIC DirectX-Headers DirectX-Guids d3d12 dxgi dxguid d3dcompiler imgui)

 

DxUtils.h 로 통일 후 기존 다른 파일의  <d3d12.h>, "d3dx12.h" 삭제.

// DxUtil.h
#include <directx/d3d12.h>     // 서브모듈 것, 반드시 맨 먼저
#include <directx/d3dx12.h>
#include <dxgi1_6.h>           // DXGI는 Windows SDK 것

 

이후 다른 환경에서 .git을 가져올때 재귀적으로 pull.

git pull
git submodule update --init --recursive

카메라

조작방식

입력 동작
우클릭 + 마우스 이동 시점 회전
우클릭 + WASD 플라이 이동 (Shift를 누르면 3배 가속)
Alt + 좌클릭 드래그 피벗 중심 오비트
휠 줌 (전진 / 후퇴)

 


Camera 클래스 (Engine/Scene/Camera)

카메라 상태는 위치와 yaw/pitch 두 각도로만 저장합니다. 행렬은 필요할 때마다 계산합니다.

멤버 의미
mPos 월드 위치.
mYaw 좌우 각도. 0이면 +Z를 봄
mPicth 상하 각도. 음수면 아래를 봄. ±89로 제한
mOrbitDist 오비트 피벗까지의 거리
mFovY, mAspect, mNear, mFar 투영 파라미터

 

전방 벡터는 구면좌표로 구합니다(왼손 좌표계 기준).

forward = (cos(pitch) * sin(yaw), sin(pitch), cos(pitch) * cos(yaw))
  • View: XMMatrixLookToLH(pos, forward, up). 목표 지점이 아니라 방향을 넘김
  • Right: normalize(cross(worldUp, forward)). 왼손 좌표계에서는 upxforward가 right입니다.
  • Pitch 제한: ±90을 넘으면 forward가 up과 평행해져서 화면이 뒤짚히므로 89로 제한합니다.

오비트는 피벗을 고정한 채 카메라를 돌린다는 뜻입니다. 총 세 단계로 구현됐습니다.

pivot = pos + forward * dist;   // 1. 현재 보고 있는 지점을 피벗으로
Rotate(dYaw, dPitch);           // 2. 각도 변경
pos = pivot - forward * dist;   // 3. 새 방향 기준으로 위치 재계산

줌을 전방으로 이동하면 mOrbitDist도 같이 줄어듭니다. 피벗을 뚫고 지나가지 않도록 최소 거리(0.2)를 둡니다.

 


입력 처리: Viewport 위에서만

에디터 카메라에서 가장 중요한 규칙은 다른 패널을 조작할때 카메라가 움직이면 안 된다는 것입니다.

// 시작은 Viewport 위에서만, 유지는 버튼을 누르고 있는 동안
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) flying = true;
if (!ImGui::IsMouseDown(ImGuiMouseButton_Right))              flying = false;
  • Camera 인스턴스는 main에 생성합니다. 그 후 ImGui 바인딩을 호출하며 Camera를 업데이트 합니다.
  • 시작 조건은 ImGui::IsWindowHovered() 로 Viewport 패널 위에서 클릭했는지 봅니다.
  • 유지 조건은 버튼을 누르고 있느지만 봅니다. 드래그하다 커서가 패널 밖으로 나가면 조작이 끊기지 않습니다.
  • 이동 속도에는 io.DeltaTime을 곱해서 프레임률과 무관하게 만듭니다.
  • 회전은 io.MouseDelta(프레임 간 이동량)에 감도(0.004rad/pixel)를 곱합니다.
  • io.ConfigWindowsMoveFromTitleBarOnly = true을 통해 Viewport를 플로팅 창으로 쓸때 Alt+좌클릭 드래그가 창 이동 되는것을 막습니다.

SceneRenderer 변경

Render(cmd, aspect, time)을 Render(cmd, viewProj)로 바꿨습니다.

카메라 행렬을 밖에서 받습니다. 렌더러가 카메라를 소유하지 않고 입력으로 받기 때문에, 나중에 뷰포트가 여러 개가 되어도 같은 렌더러를 재사용할 수 있습니다.

반응형
반응형

이번 단계에서는 씬을 별도 텍스처(오프스크린 RT)에 렌더링하고, 그 텍스처를 ImGui의 Viewport 패널 안에 이미지로 표시합니다.


프레임 흐름

[매 프레임]
 ├─ (패널 크기 변경 요청 시) WaitIdle → RT Resize
 ├─ ImGui NewFrame + UI 구성     ← Viewport 패널 크기를 읽고 ImGui::Image(SRV) 기록
 ├─ Renderer::BeginFrame         ← 백버퍼 PRESENT → RT, Clear
 ├─ ViewportRT.Begin             ← 텍스처 PSR → RT, Clear(color/depth), Viewport/Scissor
 │    └─ SceneRenderer::Render   ← 큐브 Draw
 ├─ ViewportRT.End               ← 텍스처 RT → PSR (셰이더에서 읽을 수 있게)
 ├─ Renderer::BindBackBuffer     ← 다시 백버퍼를 타깃으로
 ├─ ImGui Render                 ← Viewport 패널이 RT 텍스처를 샘플링
 └─ Renderer::EndFrame           ← RT → PRESENT, Execute, Present

 

핵심은 같은 텍스처가 한 프레임 안에서 두 가지 역할을 한다는 점입니다.
씬 패스에서는 RenderTarget으로 쓰이고, UI패스에서는 ShaderResource로 읽힙니다.

그래서 두 패스 사이에 리소스 배리어(RT<->PSR)가 반드시 필요합니다.


RenderTarget (Engine/RHI/RenderTarget)

항목 내용
소유 리소스 컬러 텍스처(R8G8B8A8_UNORM), 깊이 텍스처(D32_FLOAT)
디스크립터 RTV(RTV힙), DSV(DSV힙), SRV(shader-visible CBV_SRV_UAV 힙)
기본 상태 컬러는 PIXEL_SHADER_RESOURCE, 깊이는 DEPTH_WRITE
Init 디스크립터를 한 번만 Allocate해 두고 재사용
Resize(w, h) 텍스처 재생성 후 같은 슬롯에 RTV/DSV/SRV를 다시 기록
Begin / End 배리어, 바인딩, Clear,Viewport / Scissor 설정

Optimized Clear Value: 리소스 생성 시 D3D12_CLEAR_VALUE에 넣은 색과 실제 Clear 색을 맞춰야 경고가 나지 않고 Fast Clear 최적화가 적용됩니다.

리사이즈 시 동기화: GPU가 아직 사용 중인 텍스처를 해제하면 안 되므로, 현재는 WaitIdle 후 재 생성합니다.


Renderer 확장

RTV힙(64개)과 DSV힙(16개)을 추가했습니다. 둘 다 non-shader-visible입니다.

BindBackBuffer()는 씬 패스가 끝난 뒤 다시 백버퍼를 렌더 타킷으로 지정합니다.


SceneRenderer (Engine/Renderer/SceneRenderer)

루트 시그니처: Root Constants 16개 (b0=MVP행렬)만 사용합니다(가장 단순한 형태).

PSO: 기본 Rasterizer/Blend/Depth, RTV는 R8G8B8A8, DSV는 D32입니다.

지오메트리: 큐브 8개 정점에 정점 색을 넣고, 인덱스 36개(R16_UINT)를 씁니다.

MVP: Wolrd(회전) * LookAtLH * PerspectiveFovLH를 Transpose해서 전달합니다.aspect는 RT 크기 기준으로 계산합니다.


Shader 컴파일 헬퍼 (Engine/RHI/Shader)

D3DCompileFromFile을 래핑했습니다. Debug 빌드에서는 DEBUG | SKIP_OPTIMIZATION 을 켭니다.

에러 메시지를 OutputDebugStringA로 출력 창에 남기고, 예외로도 던집니다.

셰이더 경로는 CMake에서 BAEK_SHADER_DIR="${CMAKE_SOURCE_DIR}/Shaders/" 로 주입합니다. 이렇게 하면 실행 위치(working dir)와 무관하게 셰이더를 찾을 수 있습니다.


에디터 UI

Viewport 패널: 패딩을 0으로 두고, GetContentRegionAvail()로 얻은 크기를 다음 프레임 RT 크기로 요청합니다. 표시는 ImGui::Image(SRV GPU handle)로 합니다.

DockBuilder 기본 레이아웃: imgui.ini가 없을 때만 Viewport를 가운데, Status를 오른쪽 22%에 배치합니다.이후 사용자가 바꾼 배치는 ini에 저장돼 유지됩니다.

 

 


트러블 슈팅

 

Basic.hlsl(1,1): error X3000: Illegal character -> .editorconfig가 hlsl을 UTF-8 BOM으로 저장. FXC는 BOM을 처리하지 못함

해결(.editorconfig으로 hlsl만 utf-8로 저장)

root = true

[*.{h,hpp,cpp,txt,json}]
charset = utf-8-bom

[*.hlsl]
charset = utf-8

 

결과

반응형

+ Recent posts