Transform을 바꾸었는데 화면의 사각형이 움직이지 않습니다
삼각형을 직접 그리는 데 성공한 뒤 엔진의 SpriteRenderer를 연결하면 새로운 문제가 나타납니다. 객체의 Position은 바뀌는데 화면이 움직이지 않거나, 사각형의 한쪽만 그려질 수 있습니다. CPU의 Transform과 GPU 셰이더 사이, 정점 네 개와 삼각형 두 개 사이에 빠진 연결을 찾아야 합니다.
이 글은 Texture·RT를 완성하기 전, DX12 ConstantBuffer·IndexBuffer와 SpriteRenderer의 기초 경로를 복구한 단계입니다. 당시에는 텍스처 대신 정점 색으로 연결을 확인했습니다. 현재 엔진에서는 이 위에 Texture·Draw별 상수 구간·Scene/Game RT·렌더 모드별 PSO를 추가했으며 마지막에 해당 강의를 연결합니다.
ConstantBuffer / IndexBuffer DX12 구현 및 SpriteRenderer 파이프라인 연결
1. 무엇이 문제였는가?
전환 작업이 중단된 시점의 엔진 상태를 보면, 겉으로는 렌더링이 되는 것처럼 보였다. 삼각형이 화면에 나왔고 크래시도 없었다. 하지만 실제로는 많은 부분이 빈 껍데기였다.
가장 심각한 문제는 ConstantBuffer였다. DX11 시절에는 SetData()가 GPU 버퍼에 데이터를 올리고, Bind()가 셰이더 슬롯에 연결해줬다. 그런데 DX12로 전환하면서 이 두 함수의 내부가 전부 주석 처리되어, 빈 함수가 되어버렸다. 즉, 매 프레임 Transform::Bind()를 호출해 World / View / Projection 행렬을 열심히 계산해 구조체에 담아도, 그 데이터는 GPU에 단 한 바이트도 전달되지 않고 있었다.
당시에는 계산한 Transform 데이터가 의도한 GPU 바인딩으로 전달되지 않았습니다. 화면에 삼각형이 보였다는 사실만으로 TransformCB가 정상이라고 판단할 수 없습니다. 초기화되지 않은 메모리를 실제로 읽었는지와 구체적인 픽셀 결과는 셰이더·바인딩·검증 로그를 함께 확인해야 합니다.
IndexBuffer도 마찬가지였다. Create()는 인덱스 개수만 저장할 뿐 GPU 버퍼를 만들지 않았고, Bind()는 아무것도 하지 않았다. 그 상태에서 DrawInstanced(6, 1, 0, 0)을 호출했는데, 이 함수는 인덱스 버퍼 없이 버텍스 버퍼에서 순서대로 6개를 읽는다. 그런데 RectMesh의 유효 정점이 4개라면 이 Draw는 4번과 5번까지 요청해 데이터 범위를 벗어납니다. 화면의 구체적인 깨짐 모양을 항상 같은 결과로 단정하기보다, 요청한 정점 수와 실제 버퍼 범위가 맞지 않는 호출 자체를 고쳐야 합니다.
따라서 이 단계에서는 화면에 도형이 보이는지만 확인하지 않고, Transform 변경이 실제 GPU 상수 데이터에 도달하는지와 Draw가 유효한 정점만 참조하는지를 따로 확인해야 했습니다.
2. DX12에서 상수 버퍼는 어떻게 동작하는가
DX11에서 상수 버퍼는 비교적 단순했다. D3D11_USAGE_DYNAMIC으로 버퍼를 만들고, Map()으로 열어서 데이터를 쓰고, Unmap()으로 닫은 뒤, VSSetConstantBuffers()로 슬롯에 연결하면 됐다. API가 내부적으로 많은 것을 추상화해줬다.
DX12는 다르다. DX12는 CPU와 GPU가 메모리를 어떻게 공유할지를 개발자가 직접 선택해야 한다. 이를 위해 힙(Heap) 타입이라는 개념이 있다.
이 단계에서 사용하는 기본 Heap 타입은 DEFAULT·UPLOAD·READBACK입니다. DEFAULT는 GPU 작업 중심, UPLOAD는 CPU 쓰기·GPU 읽기, READBACK은 GPU 복사 결과의 CPU 읽기에 사용합니다. 실제 메모리 배치와 성능은 GPU 아키텍처에 따라 달라집니다.
상수 버퍼는 매 프레임 CPU에서 행렬 데이터를 올려야 하므로 UPLOAD 힙이 적합하다. 정적인 메시 데이터라면 DEFAULT 힙에 올리는 게 맞지만, 상수 버퍼처럼 동적으로 갱신되는 데이터는 UPLOAD 힙을 사용한다.
flowchart LR
T[CPU TransformCB: World·View·Projection] --> C[memcpy로 지정 구간에 쓰기]
C --> U[UPLOAD Resource]
U --> B[GPU 가상 주소를 root CBV로 바인딩]
B --> V[VS의 b0에서 행렬 읽기]Mermaid
복사
이 그림은 접근 흐름을 나타냅니다. UPLOAD·DEFAULT가 실제로 어느 물리 메모리에 놓이는지는 GPU 구조에 따라 달라질 수 있습니다. 아래의 이전 도식도 이 용도 구분으로 읽고, 당시 IndexBuffer가 UPLOAD로 구현된 점과 구별합니다.
그림 1. ConstantBuffer 흐름
UPLOAD 힙의 가장 큰 특징은 Map()을 한 번 호출하면, 버퍼가 살아있는 동안 포인터를 계속 유지할 수 있다는 것이다. DX11처럼 매번 Map/Unmap을 반복할 필요 없이, 포인터를 멤버 변수(mMappedData)로 저장해두고 memcpy로 데이터만 밀어 넣으면 된다. 이를 Persistent Map 방식이라고 한다.
// 당시 Persistent Map의 개념 예시입니다.
const D3D12_RANGE noCpuRead{0, 0};
ThrowIfFailed(buffer->Map(0, &noCpuRead, &mMappedData));
// GPU가 아직 읽고 있는 주소를 덮어쓰면 안 됩니다.
std::memcpy(mMappedData, data, mSize);
// 여러 Draw/여러 프레임에서 사용하려면 서로 다른 구간을 할당해야 합니다.
// 현재 구현은 프레임별 64KiB 페이지와 Draw별 256바이트 구간으로 확장됐습니다.
C++
복사
Persistent Map은 주소를 계속 보관하는 방식입니다. GPU가 값을 읽는 순간까지 해당 주소의 데이터가 보존되어야 한다는 조건은 그대로입니다. A의 World를 쓰고 Draw를 기록한 뒤 같은 자리에 B의 World를 쓰면 두 Draw가 B의 데이터를 읽을 수 있습니다. Map을 한 번만 했느냐보다 Draw마다 어느 구간을 사용했느냐가 중요합니다.
CBV 주소와 크기는 DX12의 256바이트 정렬 규칙을 맞춥니다. TransformCB의 행렬 3개는 192바이트이고 한 Draw용 구간은 256바이트로 올림합니다. HLSL 패킹의 16바이트 규칙과 구분하며 특정 하드웨어 캐시 라인 크기라고 단정하지 않습니다.
TransformCB 실제 데이터: 192 bytes
World (64B)
View (64B)
Projection (64B)
GPU 버퍼 실제 크기: 256 bytes
World (64B)
View (64B)
Projection (64B)
패딩 (64B) ← 하드웨어 정렬을 위한 여유 공간
Plain Text
복사
정렬 계산은 비트 연산으로 깔끔하게 처리할 수 있다.
UINT alignedSize = (size + 255) & ~255;
// 예: (192 + 255) = 447
// 447를 2진수로: 0001 1011 1111
// ~255 : 1111 1111 0000 0000
// AND 결과 : 0000 0001 0000 0000 = 256
C++
복사
192는 256보다 작으므로 첫 구간은 256바이트로 올립니다. 다음 물체의 상수를 같은 버퍼에 배치한다면 base+256, 그다음은 base+512처럼 경계를 맞춥니다. 192바이트 memcpy의 복사량과 한 Draw에 배정하는 구간 256바이트를 혼동하지 않습니다. 일반 크기를 받는 allocator에서는 덧셈 overflow와 버퍼 용량도 검사해야 합니다.
3. RootSignature — 셰이더와 리소스를 연결하는 계약서
DX12에서 셰이더가 어떤 리소스를 어떤 슬롯에서 받을지를 미리 선언하는 것이 RootSignature다. DX11에서는 VSSetConstantBuffers(slot, ...) 한 줄이면 됐지만, DX12에서는 그 전에 RootSignature를 통해 "이 파이프라인은 b0 슬롯에 CBV 하나를 받는다"고 계약을 맺어야 한다.
기존 코드에서는 RootSignature를 만들 때 파라미터를 0개로 초기화하고 있었다.
// Before: 파라미터 없음
rootSignatureDesc.Init(0, nullptr, 0, nullptr, D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);
C++
복사
이 상태에서 SetGraphicsRootConstantBufferView(0, gpuVA)를 호출하면 root parameter 0번이 존재하지 않으므로 오류가 발생한다. 따라서 CBV 파라미터를 추가해야 한다.
// After: b0 레지스터에 CBV 1개 선언
CD3DX12_ROOT_PARAMETER rootParams[1] = {};
rootParams[0].InitAsConstantBufferView(0); // register(b0)
rootSignatureDesc.Init(1, rootParams, 0, nullptr, D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);
C++
복사
InitAsConstantBufferView(0)의 0은 HLSL register(b0)이고 rootParams[0]의 0은 루트 파라미터 순서입니다. Bind에서는 SetGraphicsRootConstantBufferView(0, gpuAddress)로 이 순서의 슬롯에 실제 버퍼 주소를 줍니다. Descriptor Heap에 CBV를 만드는 table 방식과 달리 이 단계는 root CBV를 사용합니다.
빈 Root Signature에서 b0 하나를 받는 형태로 바꾸는 이유는 Transform을 소비하는 셰이더의 입력 계약이 달라졌기 때문입니다. 현재 후속 구현은 여기에 t0 텍스처 table과 s0 정적 sampler도 추가했으므로 아래 한 파라미터 예제는 이 단계의 변경으로 읽습니다.
RootSignature는 PSO(Pipeline State Object) 생성 시 함께 사용되므로, RootSignature를 변경하면 PSO도 새로 만들어야 한다. 다행히 현재 구조에서는 GraphicDevice_DX12::Initialize() → renderer::LoadShaders() → Shader::Load() 순서로 호출되기 때문에, RootSignature가 먼저 만들어지고 PSO가 그것을 참조하는 순서가 보장된다.
4. IndexBuffer — 왜 인덱스가 필요한가
사각형 하나를 그리려면 삼각형 두 개가 필요하고, 각 삼각형은 꼭짓점 3개를 사용하므로 총 6개의 꼭짓점 데이터가 필요하다. 하지만 사각형의 꼭짓점은 실제로 4개뿐이며, 두 삼각형이 모서리 2개를 공유한다.
인덱스 버퍼 없이 이 문제를 해결하려면 공유되는 꼭짓점을 중복으로 저장해야 한다. 기존 코드가 바로 그 방식이었다.
중복 버텍스 방식 (Before) 인덱스 방식 (After)
v0 (-0.5, 0.5) v0 (-0.5, 0.5) ← 한 번만 저장
v1 ( 0.5, 0.5) v1 ( 0.5, 0.5)
v2 ( 0.5, -0.5) v2 ( 0.5, -0.5)
v3 (-0.5, -0.5) v3 (-0.5, -0.5)
v4 (-0.5, 0.5) ← v0와 동일
v5 ( 0.5, -0.5) ← v2와 동일 Index: [0, 1, 2, 0, 2, 3]
DrawInstanced(6) DrawIndexedInstanced(6)
→ v0~v5 순서대로 읽기 → 인덱스 순서로 v0,v1,v2 / v0,v2,v3 읽기
Plain Text
복사
오른쪽 방식에서 첫 삼각형은 0·1·2, 두 번째는 0·2·3입니다. 두 삼각형이 공유하는 대각선의 정점 0과 2를 중복 저장하지 않습니다. DrawIndexedInstanced(6)의 6은 정점 배열의 길이 4가 아니라 읽을 인덱스 개수입니다. 인덱스가 가리키는 최대 번호는 3이어야 합니다.
그림 2. RectMesh 버텍스 및 인덱스
정점 공유가 많은 메시에서는 인덱스 방식이 메모리와 정점 처리량을 줄일 수 있습니다. 절약량을 계산할 때는 인덱스 버퍼 크기도 포함하고, UV·노멀 경계 때문에 정점을 분리해야 하는 경우도 고려합니다.
IndexBuffer도 ConstantBuffer와 마찬가지로 UPLOAD 힙에 생성한다. 다만 인덱스 데이터는 매 프레임 바뀌지 않으므로, Persistent Map 없이 생성 시 한 번만 데이터를 올리고 Unmap한다.
생성 시 (한 번만)
CPU: [0][1][2][0][2][3] → Map → memcpy → Unmap
GPU UPLOAD Heap: [0][1][2][0][2][3] (고정)
매 프레임
IB.Bind() → IASetIndexBuffer(&view)
DrawIndexedInstanced(6, ...)
→ GPU가 index 0,1,2 읽어서 v0,v1,v2로 삼각형
→ GPU가 index 0,2,3 읽어서 v0,v2,v3로 삼각형
Plain Text
복사
5. Vertex 구조체와 UV
DX11 시절 주석 처리되어 있던 uv 필드를 살렸다. SpriteRenderer가 텍스처를 샘플링할 때 UV 좌표가 필요하기 때문이다.
struct Vertex {
Vector3 pos; // 12 bytes, offset 0
Vector4 color; // 16 bytes, offset 12
Vector2 uv; // 8 bytes, offset 28
}; // total: 36 bytes
C++
복사
이 구조에서는 정점 네 개가 144바이트이고 uint32_t 인덱스 여섯 개는 24바이트입니다. 합계 168바이트로, 정점 여섯 개를 중복 저장하는 216바이트보다 48바이트 작습니다. UV를 추가했다면 VB stride뿐 아니라 VS 입력과 InputLayout도 36바이트 구조에 맞게 함께 바꿔야 합니다.
셰이더 입력 레이아웃도 함께 업데이트했다. 입력 레이아웃은 GPU가 버텍스 버퍼의 바이트 덩어리를 어떻게 해석할지를 정의한다. offset 값이 Vertex 구조체의 각 필드 위치와 정확히 일치해야 한다.
버텍스 버퍼 메모리 레이아웃 (36 bytes per vertex)
[ pos.x ][ pos.y ][ pos.z ][ col.r ][ col.g ][ col.b ][ col.a ][ uv.x ][ uv.y ]
0 4 8 12 16 20 24 28 32
└──────────────────────┘└──────────────────────────────────────┘└────────────┘
POSITION (12B) COLOR (16B) TEXCOORD (8B)
Plain Text
복사
그림 3. 버텍스 메모리 레이아웃
UV 좌표는 텍스처의 어느 위치를 샘플링할지를 나타낸다. (0,0)이 텍스처의 좌상단, (1,1)이 우하단이다.
v0 (0,0) ──────── v1 (1,0)
│ │
│ 텍스처 공간 │
│ │
v3 (0,1) ──────── v2 (1,1)
Plain Text
복사
6. SpriteRenderer 파이프라인 연결
ConstantBuffer와 IndexBuffer가 동작하게 됐으니, 이제 SpriteRenderer가 올바른 셰이더를 사용하도록 연결할 차례였다.
기존 SpriteRenderer는 TriangleMaterial을 사용하고 있었다. TriangleShader는 학습용으로 만든 단순한 셰이더로, WVP 변환 없이 NDC 좌표를 그대로 출력한다. SpriteRenderer에 이것을 붙여두면, 아무리 ConstantBuffer를 구현해도 WVP 변환이 적용되지 않는다.
SpriteDefaultShader는 이미 ConstantBuffers.hlsli를 포함하고 WVP 변환을 적용하도록 작성되어 있었다. 로드만 활성화하고 Sprite-Default-Material을 만들어 SpriteRenderer에 연결하면 된다.
연결 구조
SpriteRenderer
├─ Mesh: RectMesh
│ ├─ VB: 4 vertices (pos + color + uv)
│ └─ IB: 6 indices [0,1,2, 0,2,3]
│
└─ Material: Sprite-Default-Material
└─ Shader: SpriteDefaultShader
├─ VS (SpriteDefaultVS.hlsl)
│ └─ ConstantBuffers.hlsli (b0)
│ World × View × Projection 적용
│
└─ PS (SpriteDefaultPS.hlsl)
└─ vertex color 출력 (텍스처 구현 전 임시)
TODO: sprite.Sample(anisotropicSampler, uv)
Plain Text
복사
TransformCB가 정상이어도 VS가 그 상수를 읽지 않으면 물체는 움직이지 않습니다. 그래서 버퍼 업로드만 고치는 것으로 끝내지 않고, Material이 선택하는 셰이더까지 SpriteDefault 경로로 연결했습니다. 처음에는 PS가 vertex color만 출력하게 해 행렬·기하 연결을 확인하고, 이후 텍스처 리소스와 sampler가 준비된 뒤 Sample을 복구합니다.
PS에서 텍스처 샘플링을 하려면 Sampler와 Texture SRV가 RootSignature에 등록되어 있어야 하고, 실제 Texture DX12 리소스도 생성되어 있어야 한다. 이는 다음 단계의 작업이므로, 현재는 vertex color를 그대로 출력하는 것으로 대체했다. TriangleVS는 학습용이므로 WVP 없이 원본 그대로 유지했다.
7. 완성된 프레임 흐름
지금까지의 변경을 합치면 한 프레임은 다음과 같이 흘러간다.
[당시 프레임 흐름 — 아래 후속 구현 표와 함께 읽기]
[재사용할 프레임 리소스의 GPU 완료 확인 후 시작]
│
▼
ResetCommandAllocator()
ResetCommandList() ← Reset에 전달한 초기 PSO 사용
SetBaseGraphicsRootSignature() ← b0 CBV 파라미터를 가진 RootSignature 설정
BindViewportAndScissor()
TransitionBarrier(PRESENT → RT)
BindFrameBuffer() ← swap chain RTV 바인딩 + Clear
│
▼
SceneManager::Render()
└─ Scene::Render()
└─ for each Camera (SceneCamera)
└─ RenderSceneFromCamera(scene, camera)
└─ Opaque 오브젝트 수집 → 거리순 정렬 → 렌더
└─ obj->Render(viewMatrix, projMatrix)
│
├─ Transform::Bind(view, proj)
│ ├─ TransformCB.World = worldMatrix
│ ├─ TransformCB.View = viewMatrix
│ ├─ TransformCB.Proj = projMatrix
│ ├─ CB.SetData() → memcpy to UPLOAD heap
│ └─ CB.Bind() → SetGraphicsRootCBV(0)
│
├─ Mesh::Bind()
│ ├─ VB.Bind() → IASetVertexBuffers
│ └─ IB.Bind() → IASetIndexBuffer
│
├─ Material::BindShader()
│ └─ (PSO는 이미 바인딩된 상태)
│
└─ BaseRenderer::Draw()
└─ DrawIndexedInstanced(6,1,0,0,0)
│
▼
CloseCommandList() ← RT → PRESENT barrier
ExcuteCommandList() ← GPU에 제출
Present()
MoveToNextFrame() ← fence signal, frameIndex 전진
Plain Text
복사
그림 4. 프레임 렌더링 흐름
8. Position을 바꾸어 원인과 결과를 확인합니다
같은 사각형의 World 위치를 바꾸었을 때 화면에서도 이동하는지 확인합니다. CPU Transform 값만 바뀌고 화면은 그대로라면 SetData의 memcpy, Bind의 GPU 주소, VS의 b0 사용 순서로 따라갑니다. 한쪽 삼각형만 보인다면 인덱스 여섯 개·IBV Format·DrawIndexedInstanced 호출을 확인합니다. UV는 텍스처를 아직 읽지 않는 이 단계에서도 메모리 레이아웃을 맞춰 두어야 합니다.
도형과 Transform이 연결되면 Texture를 붙일 때 문제 범위를 샘플링·SRV·sampler로 좁힐 수 있습니다. 다음은 이 기초 위에 추가한 현재 구현과 이어 읽을 위치입니다.
이 단계에서 다음 단계로
위 그림과 임시 vertex color 셰이더 설명은 Texture 구현 전 시점의 기록입니다. 다음 작업들은 후속 커밋에서 진행했습니다.
당시 과제 | 현재 구현과 이어 읽을 글 |
Texture DX12 생성·업로드·SRV | DEFAULT 컬러 리소스와 업로드·descriptor 할당 구현. 후속 1편 |
RenderTarget | Scene/Game 컬러·깊이 RT와 resize 구현. 후속 1편 |
카메라 출력 분리 | Game RT와 EditorCamera RT 분리. 등록 게임 카메라별 RT 자동 생성은 아님 |
ImGui Image 연결 | DX12 GPU SRV handle로 표시. 후속 1편 |
상수 버퍼 구간과 수명 | 같은 프레임의 Draw와 동시에 실행 중인 프레임 사이의 덮어쓰기 방지. 후속 1편 |
Shader별 PSO | Shader가 상태 조합별 PSO 캐시 소유, Material 모드에 맞게 선택. 후속 2편 |
렌더 순서·표시 알파·지연 해제 | Opaque/CutOut/Transparent 복구와 display SRV, 최종 Fence 기준 반환. 후속 2편 |







