Company
교육 철학

DX12 Texture·RenderTarget 구현과 Scene / Game 뷰 연결

이전 강의에서 프레임 동기화를 마쳤다면 ImGui 창과 삼각형은 그릴 수 있습니다. 이제 삼각형이 있던 자리에 게임 장면을 넣고, 그 옆에는 같은 장면을 자유롭게 살펴볼 Scene 화면을 만들고 싶습니다. 여기서 첫 번째 질문이 생깁니다. “게임을 그린 결과를 어떻게 ImGui 창 안으로 가져올까?”
답을 찾으려면 이미지가 저장되는 곳부터 따라가야 합니다. 파일에서 읽은 픽셀을 GPU 텍스처에 올리고, 카메라가 그리는 목적지도 텍스처로 만든 다음, 그 결과를 ImGui가 읽게 연결합니다. 두 카메라가 서로 다른 화면을 만들도록 Draw마다 행렬을 저장할 공간도 나눕니다. 이 글에서는 이 연결을 Texture → descriptor → 상수 버퍼 → RenderTarget → ImGui::Image 순서로 직접 따라가겠습니다.
기준 코드는 49e04e5 — 프레임 동기화·에디터 렌더링ce56f16 — PSO·표시용 SRV입니다. 아래 코드 블록은 두 번째 커밋의 실제 소스에서 필요한 부분을 발췌했습니다. 생략된 주변 코드는 파일 링크에서 확인할 수 있습니다.

1. 우리가 만들려는 에디터

게임을 제작할 때는 물체를 배치하는 화면과 플레이어가 보는 화면이 필요합니다. Scene에서는 편집용 카메라로 장면을 살펴보고, Game에서는 게임 카메라의 결과를 확인합니다. 두 화면을 같은 프로그램 안에서 다루는 것이 이번 작업의 목표입니다.

참고 화면: Godot의 편집기

Godot 공식 문서의 편집기 화면
출처: Godot 공식 문서 — First look at Godot’s interface, 2026-09-15 열람. 외부 엔진의 참고 화면입니다. 중앙 작업 화면, Scene 트리, Inspector, FileSystem과 하단 도구의 역할을 비교하기 위해 사용했습니다. Godot의 Scene 도크는 노드 목록이며, 우리 엔진의 Scene 뷰와 이름만 보고 같은 기능으로 보면 안 됩니다.

현재 화면: YamYam Engine

2026-09-15 실제 Debug 실행 화면. Game 패널을 띄워 Scene과 함께 확인했습니다. 두 검은 영역 안의 스프라이트는 각각의 카메라 렌더 결과입니다. 작은 스프라이트는 현재 테스트 씬의 배치이며, 완성된 게임 장면을 뜻하지 않습니다.
사용 목적
Godot에서 참고할 부분
YamYam의 현재 상태와 목표
장면 편집
중앙 2D / 3D 작업 화면
Scene 패널 + 독립 EditorCamera + 전용 렌더 타깃 연결
실행 결과 확인
Game 화면
게임 카메라 결과를 Game 패널에 표시
오브젝트 선택·속성 편집
Scene 도크 + Inspector
Hierarchy·Inspector 패널은 있으나 전체 편집 기능의 완성을 뜻하지 않음
파일·로그 관리
FileSystem + 하단 패널
Project·Console을 확장해 제작 흐름을 갖추는 것이 목표
비교 기준은 화면의 역할입니다. Godot의 내부 렌더러가 아래 DX12 코드와 같은 구조라고 가정하지 않습니다.

2. 화면을 먼저 텍스처에 그린다

렌더 타깃(Render Target, RT)은 GPU가 그림을 써 넣을 목적지입니다. 백버퍼도 렌더 타깃이며 viewport를 나누면 그 일부에만 그릴 수 있습니다. 다만 편집기에서는 패널을 옮기거나 크기를 바꾸고, 같은 결과를 다른 UI와 겹쳐 표시해야 합니다. 그래서 카메라의 결과를 별도 텍스처에 먼저 저장하고, ImGui에게 그 텍스처를 하나의 이미지처럼 배치하도록 맡깁니다.
예를 들어 게임 카메라는 플레이어 앞을 보고, 편집용 카메라는 장면 전체를 멀리서 봅니다. 물체 데이터는 하나지만 바라보는 카메라와 그림을 저장할 목적지는 둘입니다. 아래 그림에서 두 경로가 마지막 에디터 백버퍼에서 만나는 이유도 여기에 있습니다.
flowchart LR
    S[같은 게임 장면] --> GC[게임 카메라]
    S --> EC[편집용 카메라]
    GC --> G[Game 컬러 RT + 깊이 버퍼]
    EC --> E[Scene 컬러 RT + 깊이 버퍼]
    G --> GI[Game ImGui::Image]
    E --> EI[Scene ImGui::Image]
    GI --> B[에디터 백버퍼]
    EI --> B
    UI[메뉴와 편집 도구] --> B
    B --> P[Present]
Mermaid
복사
현재 코드의 분리 단위는 Scene 뷰와 Game 뷰입니다. 등록된 게임 카메라 각각에 RT를 하나씩 만드는 구조는 아닙니다. 여러 게임 카메라가 있다면 Game용 타깃에 순서대로 렌더링합니다.

3. Texture와 descriptor는 무엇이 다른가?

ID3D12Resource에는 실제 픽셀 데이터가 있습니다. Descriptor는 GPU에게 그 리소스를 어떤 용도로 사용할지 설명하는 정보입니다. 같은 컬러 텍스처를 그릴 때는 RTV로, 읽을 때는 SRV로 참조합니다.
이름
하는 일
이번 코드에서 사용
RTV
컬러 렌더링 출력 대상으로 연결
Scene / Game 컬러 텍스처에 그리기
DSV
깊이·스텐실 출력 대상으로 연결
앞뒤 가림을 판단하는 깊이 버퍼
SRV
셰이더에서 리소스 읽기
스프라이트 샘플링, ImGui 이미지 표시
UAV
셰이더의 일반적인 읽기·쓰기
생성 인터페이스는 있으나 이 화면 표시 경로에서는 사용하지 않음
GraphicDevice_DX12는 엔진과 ImGui가 함께 쓰는 shader-visible SRV/UAV 힙을 관리합니다. 슬롯 수는 4096개이며, 오프스크린 RTV와 DSV는 각각 256개입니다. DescriptorAllocator가 빈 인덱스를 배정하고 반납된 인덱스를 재사용합니다. 핸들 간격은 하드코딩하지 않고 디바이스에서 얻습니다.
CPU 핸들은 descriptor를 만들거나 RTV·DSV를 바인딩할 때 사용합니다. GPU 핸들은 셰이더가 사용할 descriptor 위치를 가리킵니다. ImGui::Image에 넘겨야 하는 것은 GPU SRV 핸들이며, 텍스처 포인터나 CPU RTV 핸들이 아닙니다.
파일 텍스처는 DirectXTex로 WIC/DDS/TGA를 읽고, 현재 지원 범위인 2D 이미지의 mip 0을 RGBA8로 준비합니다. Upload 버퍼에서 Default 힙 텍스처로 복사한 뒤 PIXEL_SHADER_RESOURCE 상태로 전환합니다. 이 로더는 업로드 완료를 기다리므로 임시 Upload 버퍼가 먼저 해제되지 않습니다. 비동기 스트리밍 로더를 완성한 단계는 아닙니다.

파일의 픽셀을 GPU가 읽는 텍스처로 옮기기

DirectXTex가 읽은 image->pixels는 CPU 메모리입니다. 이 주소를 ImGui::Image에 넣어도 GPU 텍스처가 되지 않습니다. 현재 엔진은 Default 힙에 실제 텍스처를 만들고, CPU가 쓸 수 있는 Upload 버퍼를 경유해 픽셀을 복사합니다.
flowchart LR
    F[PNG · DDS · TGA 파일] --> C[DirectXTex: CPU의 RGBA8 픽셀]
    C --> U[Upload 버퍼: 복사에 맞는 행 간격]
    U -->|GPU 복사 명령| T[Default 힙 텍스처]
    T --> V[SRV: 이 텍스처를 읽는 방법]
    V --> S[픽셀 셰이더에서 샘플링]
Mermaid
복사
다음은 포맷 변환을 마친 뒤 실행하는 실제 Texture::Load()의 끝부분입니다. 여기서 image는 mip 0의 RGBA8 이미지입니다. Create()는 텍스처와 SRV를 준비하고, UploadTexture()는 그 텍스처에 넣을 데이터를 전달받습니다.
if (!Create(UINT(image->width), UINT(image->height), DXGI_FORMAT_R8G8B8A8_UNORM)) return E_FAIL; D3D12_SUBRESOURCE_DATA data = { image->pixels, LONG_PTR(image->rowPitch), LONG_PTR(image->slicePitch) }; GetDevice()->UploadTexture(mTexture.Get(), data, mState, D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE); mState = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE; return S_OK;
C++
복사
rowPitch는 원본 이미지에서 한 행을 건너가는 바이트 수입니다. slicePitch는 이미지 한 면의 데이터 크기입니다. 원본 행 간격과 GPU 복사용 버퍼의 행 간격은 다를 수 있으므로 둘 다 무조건 너비 × 4라고 취급하면 안 됩니다. 엔진의 UploadTexture()GetRequiredIntermediateSize()UpdateSubresources()를 사용해 복사 공간과 배치를 처리합니다. 이 정렬과 복사 방식은 Microsoft의 텍스처 업로드 설명에서도 확인할 수 있습니다.
현재 컬러 텍스처는 생성 시 PIXEL_SHADER_RESOURCE 상태입니다. 업로드 함수는 이를 COPY_DEST로 바꿔 복사하고, 다시 셰이더 읽기 상태로 되돌립니다. 별도 allocator와 command list로 업로드를 제출하고 완료를 기다리므로, 현재 기록 중인 프레임 명령을 Reset하지도 않고 임시 Upload 버퍼가 복사보다 먼저 사라지지도 않습니다.

픽셀 저장소에 읽기용 descriptor 만들기

다음 실제 함수는 새 픽셀을 복사하지 않습니다. 이미 만든 mTexture에 대해 “2D RGBA 텍스처의 mip 하나를 이렇게 읽어라”라는 SRV를 힙의 한 슬롯에 써 넣습니다.
bool Texture::CreateSRV() { if (!mTexture || (mTexture->GetDesc().Flags & D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL)) return false; if (!mSrv) mSrv = GetDevice()->AllocateDescriptor(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV); D3D12_SHADER_RESOURCE_VIEW_DESC desc = {}; desc.Format = mFormat; desc.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING; desc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D; desc.Texture2D.MipLevels = 1; GetDevice()->GetID3D12Device()->CreateShaderResourceView(mTexture.Get(), &desc, mSrv.Cpu); return true; }
C++
복사
마지막 줄이 mSrv.Cpu를 받는 이유는 CPU가 descriptor를 작성하기 때문입니다. 반면 Draw 명령에는 같은 슬롯의 mSrv.Gpu를 연결합니다. 두 핸들은 이미지가 두 벌이라는 뜻이 아니라, 같은 descriptor 슬롯을 CPU와 GPU에서 각각 참조하는 값입니다. 힙의 시작 핸들에 슬롯 인덱스 × 디바이스가 알려 준 간격을 더해 위치를 계산합니다. Microsoft의 descriptor heap 설명

셰이더에 전달할 자리를 정한다

CD3DX12_DESCRIPTOR_RANGE textureRange; textureRange.Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 1, 0); CD3DX12_ROOT_PARAMETER rootParams[2] = {}; rootParams[0].InitAsConstantBufferView(0); // b0: per-draw transform rootParams[1].InitAsDescriptorTable(1, &textureRange, D3D12_SHADER_VISIBILITY_PIXEL); // t0 CD3DX12_STATIC_SAMPLER_DESC sampler(0, D3D12_FILTER_MIN_MAG_MIP_POINT, D3D12_TEXTURE_ADDRESS_MODE_CLAMP, D3D12_TEXTURE_ADDRESS_MODE_CLAMP, D3D12_TEXTURE_ADDRESS_MODE_CLAMP);
C++
복사
b0에는 변환 행렬, t0에는 텍스처, s0에는 샘플러를 연결합니다. Root parameter 번호와 HLSL 레지스터 번호는 별도 개념입니다. 이 코드에서는 root parameter 0이 b0, root parameter 1이 t0 테이블을 담당합니다. 텍스처를 실제로 연결하는 부분은 다음과 같습니다.
void Texture::Bind(eShaderStage, UINT) { assert(mSrv && mState == D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE); auto heap = GetDevice()->GetSrvHeap(); auto list = GetDevice()->GetCommandList(); list->SetDescriptorHeaps(1, heap.GetAddressOf()); list->SetGraphicsRootDescriptorTable(1, mSrv.Gpu); }
C++
복사
SetDescriptorHeaps()는 사용할 힙을 선택하고, SetGraphicsRootDescriptorTable(1, …)은 그 안에서 이 Draw가 읽을 SRV 슬롯을 지정합니다. 힙만 선택하고 테이블 위치를 설정하지 않으면 어떤 텍스처를 읽을지 연결이 끝나지 않습니다. 현재 함수의 stage·slot 인자는 사용하지 않으며, 스프라이트 경로인 픽셀 셰이더의 t0 한 자리에 고정되어 있습니다.

4. 상수 버퍼는 프레임별·Draw별로 공간을 나눈다

World / View / Projection 행렬은 오브젝트와 카메라마다 다릅니다. 하나의 GPU 주소에 값을 계속 덮어쓰면, CPU가 여러 Draw를 기록한 후 GPU가 읽을 때 마지막 값만 남을 수 있습니다. Fence로 이전 프레임을 기다리는 것만으로는 같은 프레임 안에서의 덮어쓰기가 해결되지 않습니다.
현재 TransformCB 데이터는 행렬 3개로 192바이트입니다. 각 Draw의 시작 주소는 256바이트 간격으로 배치합니다. 두 프레임 슬롯이 각각 Upload 페이지를 소유하고, 한 페이지를 채우면 다음 페이지를 추가합니다. 이 데이터 크기에서는 64 KiB 페이지 하나에 256번의 Draw가 들어갑니다.
flowchart TB
    F0[프레임 슬롯 0] --> A[Draw 0: Game 오브젝트 A]
    F0 --> B[Draw 1: Game 오브젝트 B]
    F0 --> C[Draw 2: Scene 오브젝트 A]
    F1[프레임 슬롯 1] --> D[다음 프레임의 별도 저장 공간]
    A --> X[b0에 서로 다른 GPU 주소 바인딩]
    B --> X
    C --> X
Mermaid
복사
오브젝트 A와 B를 Game에서 한 번씩, Scene에서 한 번씩 그린다고 해 봅시다. 아래 주소는 페이지 시작점으로부터의 설명용 오프셋입니다.
Draw
대상
저장할 행렬
바이트 오프셋
0
Game의 A
A의 World + Game의 View / Projection
0
1
Game의 B
B의 World + Game의 View / Projection
256
2
Scene의 A
A의 World + Scene의 View / Projection
512
3
Scene의 B
B의 World + Scene의 View / Projection
768
A의 World는 같아도 카메라 행렬이 다르므로 Draw 0과 Draw 2가 같은 주소를 덮어써서는 안 됩니다. 아래 코드의 pageIndex가 사용할 페이지를 고르고, offset이 그 안의 Draw 위치를 고릅니다.
void ConstantBuffer::SetData(const void* data) { if (!data || !mSize) throw std::runtime_error("Invalid constant buffer data"); auto& frame = mFrames[GetDevice()->GetFrameIndex()]; const size_t pageIndex = frame.NextDraw / mDrawsPerPage; const size_t offset = (frame.NextDraw % mDrawsPerPage) * mStride; if (pageIndex == frame.Pages.size() && !AddPage(frame)) throw std::runtime_error("Constant buffer upload page allocation failed"); auto& page = frame.Pages[pageIndex]; memcpy(page.Mapped + offset, data, mSize); mCurrentAddress = page.Resource->GetGPUVirtualAddress() + offset; ++frame.NextDraw; }
C++
복사
NextDraw = 255일 때는 첫 페이지의 마지막 256바이트 구간을 씁니다. NextDraw = 256이면 pageIndex = 1, offset = 0이 되어 다음 페이지를 사용합니다. 첫 페이지의 내용을 덮어쓰는 것이 아닙니다. memcpy가 복사하는 데이터는 192바이트이며, 다음 Draw의 시작점을 256바이트에 맞추기 위해 남는 간격을 둡니다.
현재 HLSL은 세 행렬을 row_major matrix로 선언하고, CPU의 World / View / Projection을 전치하지 않고 복사합니다. 정점 셰이더는 위치에 World → View → Projection 순으로 곱합니다. 예전 강의의 다른 행렬 저장 관례를 섞어 CPU에서 다시 전치하면 Scene과 Game의 화면이 잘못될 수 있습니다.
BeginFrame() 자체가 GPU를 기다리지는 않습니다. 주 루프가 재사용할 프레임 슬롯의 GPU 완료를 먼저 기다리고, 그 뒤 호출한 BeginFrame()이 해당 슬롯의 NextDraw를 0으로 돌립니다. 완료를 확인했으므로 그 슬롯의 페이지를 다시 써도 되는 것입니다. Bind()는 방금 할당한 mCurrentAddress를 root CBV에 기록합니다. 여기서 말하는 상수 버퍼 분리는 메모리 영역과 수명의 분리입니다. World·카메라 데이터가 서로 다른 HLSL cbuffer로 나뉜 것은 아니며, 현재는 하나의 TransformCB에 함께 들어갑니다.

5. Scene 카메라로 별도 RT에 그리기

RenderTarget은 컬러 텍스처와 깊이 텍스처를 묶습니다. Bind()에서 컬러를 RENDER_TARGET으로 전환하고, RTV·DSV를 설정하고, 화면을 지우고, 타깃 크기에 맞는 viewport와 scissor를 지정합니다. 렌더링이 끝나면 Unbind()에서 컬러를 PIXEL_SHADER_RESOURCE로 바꿉니다.
void RenderTarget::Unbind() { GetDevice()->GetCommandList()->OMSetRenderTargets(0, nullptr, FALSE, nullptr); for (auto* texture : mAttachments) texture->Transition(D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE); }
C++
복사
OMSetRenderTargets(0, …)는 그리기 대상으로 연결했던 RTV를 해제하는 호출입니다. 이것만으로 텍스처의 상태가 바뀌지는 않으므로 각 컬러 attachment에 별도의 transition barrier도 기록합니다. 그래야 뒤에 오는 ImGui 픽셀 셰이더가 완성된 이미지를 읽는 순서가 성립합니다. 이 과정은 CPU가 GPU 전체를 기다리는 작업이 아닙니다.
Scene 패널은 독립적인 EditorCamera를 소유합니다. 편집용 카메라를 게임 씬의 카메라 목록에 등록하지 않으므로, Scene을 움직였다고 게임 카메라 구성이 바뀌지 않습니다. 카메라 투영 행렬의 화면 비율도 현재 RT의 크기를 사용합니다.
auto* frameBuffer = mEditorCamera->GetRenderTarget(); frameBuffer->RequestResize(UINT(panelSize.x), UINT(panelSize.y)); frameBuffer->Bind(); auto* scene = ya::SceneManager::GetActiveScene(); ya::renderer::RenderSceneFromCamera(scene, mEditorCamera); ya::renderer::RenderSceneFromCamera(ya::SceneManager::GetDontDestroyOnLoad(), mEditorCamera); frameBuffer->Unbind(); const auto texture = frameBuffer->GetDisplaySRV(); ImGui::Image(ImTextureID(texture.ptr), panelSize);
C++
복사
RenderSceneFromCamera()는 같은 씬의 물체를 mEditorCamera의 View / Projection으로 다시 그립니다. 별도의 게임 오브젝트 복사본을 만드는 것이 아닙니다. GetDisplaySRV()가 돌려주는 GPU 핸들의 ptr 값을 현재 ImGui DX12 백엔드가 texture ID로 사용합니다. ImGui::Image 호출 시점에는 UI 그리기 정보가 쌓이며, 실제 이미지를 읽는 GPU 명령은 뒤의 ImGui 렌더링 단계에서 기록됩니다. 따라서 Image를 호출했다고 텍스처나 descriptor를 바로 해제하면 안 됩니다.
이 짧은 구간에 연결 순서가 모두 있습니다. 패널 크기를 요청하고 → RT에 그린 뒤 → 읽기 상태로 바꾸고 → GPU SRV 핸들을 ImGui::Image에 넘깁니다. 화면에 보이지 않는 Scene 패널은 앞부분의 검사에서 렌더링을 생략합니다.

6. Game 뷰와 크기 변경의 시점

게임은 Application::Render()에서 먼저 renderer::FrameBuffer에 그립니다. 이후 에디터 UI가 그 결과를 표시합니다. 따라서 Game 패널을 그리는 순간 RT를 즉시 교체하면 방금 그린 텍스처를 잃을 수 있습니다. RequestResize()로 크기만 저장하고 다음 Bind()에서 반영하도록 바꿨습니다.
// Game was rendered earlier in this frame: apply the new size on // its next Bind, keeping this frame's displayed texture intact. FrameBuffer->RequestResize(UINT(panelSize.x), UINT(panelSize.y)); ImGui::Image(ImTextureID(FrameBuffer->GetDisplaySRV().ptr), panelSize);
C++
복사
Game은 보통 다음 프레임의 Bind에서, Scene은 자기 렌더 패스 직전의 Bind에서 크기를 적용합니다. 너비·높이가 0이거나 8192를 넘는 요청은 무시합니다. 교체된 이전 텍스처를 GPU가 읽고 있을 수 있으므로 해제 시점도 Fence와 연결해야 합니다. 이 부분은 다음 글에서 이어집니다.
Game 입력은 패널이 보이고, 포커스가 있고, 마우스가 올라왔을 때 활성화합니다. 에디터에서 메뉴나 Scene을 조작하는 동안 게임 오브젝트까지 동시에 움직이는 일을 줄이기 위한 연결입니다.

7. 직접 움직여 보며 연결 확인하기

두 패널이 보이면 먼저 Scene의 편집용 카메라만 움직여 봅니다. Scene의 구도만 변하고 Game은 그대로라면 카메라가 분리된 것입니다. 이어서 같은 오브젝트의 Transform을 바꾸면 두 화면에서 그 물체의 위치가 함께 바뀌어야 합니다. 두 카메라가 같은 장면 데이터를 사용하기 때문입니다.
Game 패널의 너비를 늘리면 크기 요청은 다음 Game 렌더 패스에 반영됩니다. Scene은 자기 렌더 직전에 크기를 반영합니다. 이때 RT 해상도뿐 아니라 Projection의 화면 비율도 함께 바뀌어야 원이 타원처럼 늘어나지 않습니다. 두 패널이 항상 같은 구도로 보이면 카메라 행렬과 CB 주소를, 한 패널에 다른 패널의 그림이 나오면 RT와 GPU SRV 핸들을 차례로 확인합니다.

여기까지 완성된 연결

Scene / Game의 독립된 렌더 결과, 텍스처 샘플링, Draw별 상수 버퍼, ImGui GPU SRV 연결이 동작합니다. 게임 전용 실행 경로는 백버퍼에 직접 렌더링하며, 에디터 모드에서 오프스크린 Game RT를 사용합니다.
다음 글에서는 화면이 보이는 것에 더해 불투명·컷아웃·반투명의 결과가 올바른지, ImGui가 완성된 색을 다시 섞지 않는지, 창 크기를 바꿔도 GPU 리소스가 안전한지를 확인합니다.