[Computer Graphics with DirectX 12] 01. Framework - Update, Render

2024. 2. 20. 18:41Programming/Computer Graphics

 

01. Framework 목표

  • 앞으로 진행할 프로젝트의 틀이 될 간단한 프레임워크 구성
  • 플레이어로 사용할 정육면체 렌더링
    • 플레이어 컨트롤러 구현
    • 플레이어를 추적하는 카메라 구현
  • 제자리에서 회전하는 7 * 7 * 7개의 정육면체 렌더링

 

프로젝트 1의 두 번째 파트에서는 렌더링 준비가 완료된 오브젝트들이 매 프레임 어떠한 과정을 거쳐 렌더링 되는지에 대해 작성해 보겠습니다.

 

 

GitHub - SH4MDEL/DirectX12-ComputerGraphics

Contribute to SH4MDEL/DirectX12-ComputerGraphics development by creating an account on GitHub.

github.com

 

위 리포지토리에서 전체 소스코드를 확인하실 수 있습니다.

이 프로젝트의 소스코드는 01. Framework입니다.

 


구현

 

wWinMain

    ...

    // 애플리케이션 초기화를 수행합니다:
    if (!InitInstance (hInstance, nCmdShow))
    {
        return false;
    }

    HACCEL hAccelTable = LoadAccelerators(hInstance, MAKEINTRESOURCE(IDC_MY01FRAMEWORK));

    MSG msg;

    // 기본 메시지 루프입니다:
    while (true)
    {
        if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
        {
            if (msg.message == WM_QUIT) break;
            if (!TranslateAccelerator(msg.hwnd, hAccelTable, &msg))
            {
                TranslateMessage(&msg);
                DispatchMessage(&msg);
            }
        }
        else
        {
            g_framework->FrameAdvance();
        }
    }

    ...

 

InitInstance 함수를 거쳐 렌더링에 필요한 모든 준비가 완료되었습니다. 이제 프로세스는 while문을 돌면서 게임을 진행시킵니다. GameFramework 인스턴스의 FrameAdvance 메소드가 한 번 호출될 때마다 한 프레임이 진행됩니다.

 

class GameFramework

void GameFramework::FrameAdvance()
{
    m_timer.Tick();
    Update();
    Render();
}

 

FrameAdvance 메소드에서는 우선 타이머의 Tick 메소드를 호출하여 저번 프레임과 이번 프레임간의 시간 차이를 계산합니다. 그리고 이번 프레임의 상태 변화를 처리하는 Update 메소드와, 변화된 상태를 반영하여 실제로 오브젝트를 화면에 렌더링 하는 Render 메소드가 차례로 실행됩니다.

 

Update

void GameFramework::Update()
{
    if (m_activate) {
        MouseEvent(m_hWnd, m_timer.GetElapsedTime());
        KeyboardEvent(m_timer.GetElapsedTime());
    }
    m_scene->Update(m_timer.GetElapsedTime());
}

 

Update 메소드는 저번 프레임에서부터 변화된 이번 프레임의 상태 변화를 처리해 주는 역할을 합니다. 먼저 마우스, 키보드 입력과 같은 사용자의 입력을 우선적으로 처리하고, 그 뒤 사용자의 입력과 독립적으로 진행되는 상태 변화를 처리해 줍니다.

이번 프로젝트에서 사용자의 입력을 통해 변화하는 요소는 다음 두 가지로 나눌 수 있습니다.

  • 키보드 입력(WASD)을 통한 플레이어 이동
  • 마우스 이동을 통한 플레이어(또는 카메라) 회전

 

 

플레이어의 이동을 구현하는 데 있어, 여러 방식이 있겠지만 저는 '원신' 게임의 플레이어 컨트롤러가 편하다고 생각하여 이 방식을 모방하여 구현해 보고자 했습니다.

첨부한 동영상을 참고하시면 대략 어떤 방식으로 플레이어와 카메라가 움직이는지 알 수 있을 것이라고 생각합니다.

저는 원신 플레이어 컨트롤러 로직의 특징을 다음과 같이 정리해 보았습니다.

  • 마우스의 이동은 카메라의 회전을 의미함
    • 마우스의 이동에 의해 캐릭터의 회전은 발생하지 않음.
  • 키보드를 입력하면 카메라의 시선 벡터를 기준으로 플레이어가 이동함.
    • 플레이어가 현재 바라보는 방향이 어떠한지는 영향을 미치지 않음.
  • 플레이어는 오직 정면으로만 이동함.
    • 움직이려는 방향과 플레이어가 바라보는 방향이 다르다면, 먼저 움직이려는 방향으로 회전한 후 정면으로 이동함.

 

void Scene::MouseEvent(HWND hWnd, FLOAT timeElapsed)
{
    SetCursor(NULL);
    RECT windowRect;
    GetWindowRect(hWnd, &windowRect);

    POINT lastMousePosition{ 
        windowRect.left + static_cast<LONG>(g_framework->GetWindowWidth() / 2), 
        windowRect.top + static_cast<LONG>(g_framework->GetWindowWidth() / 2) };
    POINT mousePosition;
    GetCursorPos(&mousePosition);

    float dx = XMConvertToRadians(0.15f * static_cast<FLOAT>(mousePosition.x - lastMousePosition.x));
    float dy = XMConvertToRadians(0.15f * static_cast<FLOAT>(mousePosition.y - lastMousePosition.y));

    if (m_camera) {
        m_camera->RotateYaw(dx);
        m_camera->RotatePitch(dy);
    }
    SetCursorPos(lastMousePosition.x, lastMousePosition.y);

    m_player->MouseEvent(timeElapsed);
}

 

일단 마우스 이동은 오직 카메라의 회전만을 담당한다고 생각할 수 있습니다. 플레이어의 회전에는 전혀 영향을 주지 않기에 저번 프레임으로부터 마우스의 이동이 얼마나 발생했는지 확인한 후, 카메라만 그에 맞게 회전시켜 주었습니다.

 

void Player::KeyboardEvent(FLOAT timeElapsed)
{
    XMFLOAT3 front{ m_camera->GetN() }; front.y = 0.f; 
    front = Utiles::Vector3::Normalize(front);
    XMFLOAT3 back{ Utiles::Vector3::Negate(front) };
    XMFLOAT3 right{ m_camera->GetU() };
    XMFLOAT3 left{ Utiles::Vector3::Negate(right) };
    XMFLOAT3 direction{};

    if (GetAsyncKeyState('W') && GetAsyncKeyState('A') & 0x8000) {
        direction = Utiles::Vector3::Normalize(Utiles::Vector3::Add(front, left));
    }
    else if (GetAsyncKeyState('W') && GetAsyncKeyState('D') & 0x8000) {
        direction = Utiles::Vector3::Normalize(Utiles::Vector3::Add(front, right));
    }
    else if (GetAsyncKeyState('S') && GetAsyncKeyState('A') & 0x8000) {
        direction = Utiles::Vector3::Normalize(Utiles::Vector3::Add(back, left));
    }
    else if (GetAsyncKeyState('S') && GetAsyncKeyState('D') & 0x8000) {
        direction = Utiles::Vector3::Normalize(Utiles::Vector3::Add(back, right));
    }
    else if (GetAsyncKeyState('W') & 0x8000) {
        direction = front;
    }
    else if (GetAsyncKeyState('A') & 0x8000) {
        direction = left;
    }
    else if (GetAsyncKeyState('S') & 0x8000) {
        direction = back;
    }
    else if (GetAsyncKeyState('D') & 0x8000) {
        direction = right;
    }
    if (GetAsyncKeyState('W') || GetAsyncKeyState('A') ||
        GetAsyncKeyState('S') || (GetAsyncKeyState('D') & 0x8000)) {
        XMFLOAT3 angle{ Utiles::Vector3::Angle(m_front, direction) };
        XMFLOAT3 cross{ Utiles::Vector3::Cross(m_front, direction) };
        if (cross.y >= 0.f) {
            Rotate(0.f, XMConvertToDegrees(angle.y) * 10.f * timeElapsed, 0.f);
        }
        else {
            Rotate(0.f, -XMConvertToDegrees(angle.y) * 10.f * timeElapsed, 0.f);
        }
        Transform(Utiles::Vector3::Mul(m_front, m_speed * timeElapsed));
    }
}

 

키보드 입력 처리는 조금 더 복잡합니다. 먼저 이동하고자 하는 방향벡터를 구하고, 플레이어를 그 방향으로 회전시키고, 플레이어를 플레이어가 바라보는 방향으로 전진시키는 과정으로 이뤄집니다. 코드를 세 부분으로 나누어 설명드리겠습니다.

 

    XMFLOAT3 front{ m_camera->GetN() }; front.y = 0.f; 
    front = Utiles::Vector3::Normalize(front);
    XMFLOAT3 back{ Utiles::Vector3::Negate(front) };
    XMFLOAT3 right{ m_camera->GetU() };
    XMFLOAT3 left{ Utiles::Vector3::Negate(right) };
    XMFLOAT3 direction{};

 

먼저 '카메라'의 시선 벡터(플레이어의 시선과는 무관합니다!!)를 기준으로 front, back, left, right 벡터를 구해주는 과정입니다.

front 벡터는 카메라 기저 행렬의 Z축에서 (월드 행렬의) Y축 성분을 제거한 후 정규화해 주었습니다. right 벡터는 카메라 기저 행렬의 X축에 해당합니다. back 벡터와 left 벡터는 각각 front와 right 벡터의 역벡터입니다.

 

    if (GetAsyncKeyState('W') && GetAsyncKeyState('A') & 0x8000) {
        direction = Utiles::Vector3::Normalize(Utiles::Vector3::Add(front, left));
    }
    else if (GetAsyncKeyState('W') && GetAsyncKeyState('D') & 0x8000) {
        direction = Utiles::Vector3::Normalize(Utiles::Vector3::Add(front, right));
    }
    else if (GetAsyncKeyState('S') && GetAsyncKeyState('A') & 0x8000) {
        direction = Utiles::Vector3::Normalize(Utiles::Vector3::Add(back, left));
    }
    else if (GetAsyncKeyState('S') && GetAsyncKeyState('D') & 0x8000) {
        direction = Utiles::Vector3::Normalize(Utiles::Vector3::Add(back, right));
    }
    else if (GetAsyncKeyState('W') & 0x8000) {
        direction = front;
    }
    else if (GetAsyncKeyState('A') & 0x8000) {
        direction = left;
    }
    else if (GetAsyncKeyState('S') & 0x8000) {
        direction = back;
    }
    else if (GetAsyncKeyState('D') & 0x8000) {
        direction = right;
    }

 

다음은 키보드의 입력을 확인하여 실제 플레이어가 회전할 목적지가 되는 방향벡터를 구해주는 과정입니다.

W, A, S, D 입력의 경우 front, left, back, right 벡터를 각각 할당해 주면 되고, 두 키가 동시에 입력된 경우에는 벡터를 합한 후 정규화하여 그 사이 벡터를 구해주면 됩니다. 이는 front, left, back, right 벡터가 전부 정규화되어 있기에(크기가 같기에) 가능한 방법입니다.

 

    if (GetAsyncKeyState('W') || GetAsyncKeyState('A') ||
        GetAsyncKeyState('S') || (GetAsyncKeyState('D') & 0x8000)) {
        XMFLOAT3 angle{ Utiles::Vector3::Angle(m_front, direction) };
        XMFLOAT3 cross{ Utiles::Vector3::Cross(m_front, direction) };
        if (cross.y >= 0.f) {
            Rotate(0.f, XMConvertToDegrees(angle.y) * 10.f * timeElapsed, 0.f);
        }
        else {
            Rotate(0.f, -XMConvertToDegrees(angle.y) * 10.f * timeElapsed, 0.f);
        }
        Transform(Utiles::Vector3::Mul(m_front, m_speed * timeElapsed));
    }

 

마지막으로 플레이어 direction 방향으로 회전시킨 후, 정면을 향해 전진시키는 과정입니다.

플레이어가 현재 바라보는 방향(m_front)과 앞서 구한 목적지 벡터(direction) 간의 각도를 계산한 후, 그 방향으로 회전시켜 주는 원리입니다.

그런데 DirectXMath에서 두 벡터의 각도를 구해주는 XMVector3AngleBetweenNormals 함수는 항상 양수를 반환하기에 어느 방향으로 회전해야 할지 정해줘야 합니다. 플레이어의 시선 벡터와 목적지 벡터를 외적 하여 나온 y 좌표가 양수라면, 왼손 법칙에 따라 시계 방향으로 플레이어를 회전시켜줘야 합니다. 반대로 y 좌표가 음수라면 반시계 방향으로 회전시켜 주면 됩니다.

 

void Scene::Update(FLOAT timeElapsed)
{
    m_player->Update(timeElapsed);
    for (auto& object : m_objects) {
        object->Update(timeElapsed);
    }
}

 

사용자에 입력에 따른 상태 변화 처리가 끝나면, Scene::Update 메소드를 실행하여 사용자의 입력과 독립적으로 처리되는 상태 변화를 처리해 줍니다.

 

void Player::Update(FLOAT timeElapsed)
{
    if (m_camera) m_camera->UpdateEye(GetPosition());
}

 

플레이어 이동은 이미 처리했으니 Player::Update에서는 카메라의 위치만 최신화해 주면 됩니다.

플레이어의 이동은 키보드 입력을 처리해 줄 때 끝내는데, 왜 플레이어의 이동에 따른 카메라의 위치 변화는 Update에서 처리하냐면, 카메라의 이동이 키보드 입력에 종속되면 안 된다고 생각하기 때문입니다. 카메라 이동을 키보드 입력 시 처리해 준다면 키보드 입력 외 수단으로 플레이어가 이동할 일이 생길 경우에 카메라가 제대로 이동하지 않는 문제가 생길 수 있습니다.

 

void RotatingObject::Update(FLOAT timeElapsed)
{
    Rotate(0.f, m_rotatingSpeed * timeElapsed, 0.f);
}

 

그리고 모든 RotatingObject를 돌면서 자신에게 주어진 속도에 맞게 오브젝트를 회전시켜 준다면 사용자의 입력과 무관한 상태 변화의 처리도 모두 끝나게 됩니다.

 

Render

void GameFramework::Render()
{
    Utiles::ThrowIfFailed(m_commandAllocator->Reset());
    Utiles::ThrowIfFailed(m_commandList->Reset(m_commandAllocator.Get(), nullptr));

    m_commandList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(m_renderTargets[m_frameIndex].Get(), 
        D3D12_RESOURCE_STATE_PRESENT, D3D12_RESOURCE_STATE_RENDER_TARGET));

    m_commandList->SetGraphicsRootSignature(m_rootSignature.Get());
    m_commandList->RSSetViewports(1, &m_viewport);
    m_commandList->RSSetScissorRects(1, &m_scissorRect);

    CD3DX12_CPU_DESCRIPTOR_HANDLE rtvHandle{ m_rtvHeap->GetCPUDescriptorHandleForHeapStart(), 
        static_cast<INT>(m_frameIndex), m_rtvDescriptorSize };
    CD3DX12_CPU_DESCRIPTOR_HANDLE dsvHandle{ m_dsvHeap->GetCPUDescriptorHandleForHeapStart() };
    m_commandList->OMSetRenderTargets(1, &rtvHandle, true, &dsvHandle);

    const FLOAT clearColor[]{ 0.f, 0.f, 0.f, 1.0f };
    m_commandList->ClearRenderTargetView(rtvHandle, clearColor, 0, nullptr);
    m_commandList->ClearDepthStencilView(dsvHandle, 
        D3D12_CLEAR_FLAG_DEPTH | D3D12_CLEAR_FLAG_STENCIL, 1.0f, 0, 0, nullptr);

    m_scene->Render(m_commandList);

    m_commandList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(m_renderTargets[m_frameIndex].Get(),
        D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_PRESENT));

    Utiles::ThrowIfFailed(m_commandList->Close());
    ID3D12CommandList* ppCommandList[] = { m_commandList.Get() };
    m_commandQueue->ExecuteCommandLists(_countof(ppCommandList), ppCommandList);

    Utiles::ThrowIfFailed(m_swapChain->Present(1, 0));

    WaitForGpuComplete();
}

 

이제 화면에 실제로 물체를 렌더링 할 차례입니다. 현재 Scene이 어떤지에 상관없이 항상 처리되어야 하는 렌더링 로직들이 GameFramework::Render 메소드에서 실행됩니다.

 

void Scene::Render(const ComPtr<ID3D12GraphicsCommandList>& commandList) const
{
    m_camera->UpdateShaderVariable(commandList);
    m_shader->UpdateShaderVariable(commandList);
    for (auto& object : m_objects) {
        object->Render(commandList);
    }
    m_player->Render(commandList);
}

 

Scene에서는 가장 먼저 Camera, Shader 인스턴스의 UpdateShaderVariable 메소드를 호출합니다. 앞으로도 계속 사용될 UpdateShaderVariable 함수는 렌더링을 위해 필요한 정보를 HLSL로 전송하는 역할을 합니다.

 

void Camera::UpdateShaderVariable(const ComPtr<ID3D12GraphicsCommandList>& commandList)
{
    XMStoreFloat4x4(&m_viewMatrix, XMMatrixLookAtLH(XMLoadFloat3(&m_eye), XMLoadFloat3(&m_at), XMLoadFloat3(&m_up)));

    XMFLOAT4X4 viewMatrix;
    XMStoreFloat4x4(&viewMatrix, XMMatrixTranspose(XMLoadFloat4x4(&m_viewMatrix)));
    commandList->SetGraphicsRoot32BitConstants(1, 16, &viewMatrix, 0);

    XMFLOAT4X4 projectionMatrix;
    XMStoreFloat4x4(&projectionMatrix, XMMatrixTranspose(XMLoadFloat4x4(&m_projectionMatrix)));
    commandList->SetGraphicsRoot32BitConstants(1, 16, &projectionMatrix, 16);
}

 

예를 들면 Camera 클래스에서 가지고 있는 렌더링을 위한 정보는 뷰 변환 행렬(View Matrix)과 투영 변환 행렬(Projection Matrix)입니다. 이 두 정보를 SetGraphicsRoot32BitConstants 함수를 통해 HLSL에 전송해 줍니다.

참고로 위 코드를 자세히 보시면 행렬을 전치 행렬로 변환(XMMatrixTranspose)하고 있음을 확인하실 수 있습니다. 이는 DirectX에서는 행벡터 표현법을, HLSL에서는 열벡터 표현법을 사용하기 때문입니다. 그렇기에 DirectX에서는 행렬이 HLSL로 전송될 때 자동으로 전치 행렬로 전환되는 과정이 포함되는데, 저는 HLSL에서도 행벡터 표현법을 사용하고 싶기 때문에 DirectX의 표현법을 그대로 사용할 수 있도록 전치 행렬로 변환하는 과정을 한 번 거쳐 주었습니다.

앞으로도 DirectX에서 HLSL로 행렬을 전송할 때는 항상 전치 행렬로 변환하는 과정을 거쳐 주겠습니다.

 

void Shader::UpdateShaderVariable(const ComPtr<ID3D12GraphicsCommandList>& commandList)
{
    commandList->SetPipelineState(m_pipelineState.Get());
}

 

Shader 클래스에서 가지고 있는 렌더링을 위한 정보는 물론 파이프라인 상태 객체입니다. SetPipelineState 함수를 통해 Shader 인스턴스가 소유 중인 파이프라인 객체를 설정해 주었습니다.

 

void GameObject::Render(const ComPtr<ID3D12GraphicsCommandList>& commandList) const
{
    UpdateShaderVariable(commandList);
    m_mesh->Render(commandList);
}

void GameObject::UpdateShaderVariable(const ComPtr<ID3D12GraphicsCommandList>& commandList) const
{
    XMFLOAT4X4 worldMatrix;
    XMStoreFloat4x4(&worldMatrix, XMMatrixTranspose(XMLoadFloat4x4(&m_worldMatrix)));
    commandList->SetGraphicsRoot32BitConstants(0, 16, &worldMatrix, 0);
}

 

그리고 플레이어를 포함한 모든 오브젝트를 순회하며 Render 메소드를 호출해 주면 됩니다. GameObject에서 가지고 있는 렌더링을 위한 정보는 그 오브젝트의 월드 변환 행렬(World Matrix)입니다. 먼저 이 정보를 HLSL로 전송해 주고, GameObject가 소유한 메쉬에 렌더링 명령을 내립니다.

 

void Mesh::Render(const ComPtr<ID3D12GraphicsCommandList>& commandList) const
{
    commandList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
    commandList->IASetVertexBuffers(0, 1, &m_vertexBufferView);
    commandList->DrawInstanced(m_vertices, 1, 0, 0);
}

 

메쉬는 프리미티브 토폴로지(Primitive Topology)를 설정하고, 자신의 정점 버퍼 뷰를 전송하고, 정점의 개수를 제출하여 실제 렌더링을 실시합니다.

 

HLSL

cbuffer GameObject : register(b0)
{
    matrix g_worldMatrix : packoffset(c0);
};

cbuffer Camera : register(b1)
{
    matrix g_viewMatrix : packoffset(c0);
    matrix g_projectionMatrix : packoffset(c4);
};

struct VS_INPUT
{
    float3 position : POSITION;
    float4 color : COLOR;
};

struct PS_INPUT
{
    float4 position : SV_POSITION;
    float4 color : COLOR;
};

PS_INPUT VERTEX_MAIN(VS_INPUT input)
{
    PS_INPUT output;
    output.position = mul(float4(input.position, 1.0f), g_worldMatrix);
    output.position = mul(output.position, g_viewMatrix);
    output.position = mul(output.position, g_projectionMatrix);
    output.color = input.color;

    return output;
}

float4 PIXEL_MAIN(PS_INPUT input) : SV_TARGET
{
    return input.color;
}

 

정점은 VS_INPUT 구조체에 담겨 버텍스 셰이더로 들어갑니다. 여기서의 input.position은 모델 좌표계(Model Space) 상의 정점입니다. 이 정점에 GameObject로부터 전송받은 월드 변환 행렬(g_worldMatrix), Camera로부터 전송받은 뷰 변환 행렬(g_viewMatrix), 투영 변환 행렬(g_projectionMatrix)을 차례로 곱해 투영 변환까지 완료해 줍니다.

 

투영 변환이 완료되어 버텍스 셰이더에서 반환된 정점은 래스터라이저 단계로 들어갑니다. 래스터라이저 단계에서는 먼저 클리핑을 통해 2X2X1 크기의 직육면체 뷰 볼륨 바깥의 정점을 잘라내는 작업을 하게 되며, 원근 나눗셈을 통해 동차 좌표계상의 정점을 정규화된 장치 좌표(NDC)로 바꿉니다. 그리고 은면 제거(Back-Face Culling)를 통해 카메라를 바라보지 않는 폴리곤을 제거하고, 뷰포트 변환을 통해 NDC 상의 정점을 뷰포트의 크기에 맞게 변환합니다. 마지막으로 스캔 변환을 통해 정점을 보간하여 프래그먼트를 생성시키면, 이 프래그먼트가 픽셀 셰이더의 입력으로 들어갑니다.

 

프래그먼트는 PS_INPUT 구조체에 담겨 픽셀 셰이더로 들어갑니다. 버텍스 셰이더의 입력 형식과 비슷하지만, VS_INPUT 구조체의 position은 모델 좌표계 상의 정점이고, PS_INPUT 구조체의 position은 스캔 변환까지 완료된 화면 좌표계 상의 정점(프래그먼트)이라는 큰 차이가 있습니다. 예를 들어 뷰포트의 Width, Height가 1920, 1080이라고 가정한다면 PS_INPUT 구조체의 position.x는 0~1920 사이의 값을 가질 것이고, position.y는 0~1080 사이의 값을 가질 것입니다.

픽셀 셰이더에서는 보간 된 input.color 값을 그대로 출력해 주었습니다.

 


결과

 

 

목표한 대로 잘 동작합니다!!

 

다음 프로젝트에서는 오브젝트들에 텍스처를 입히고 배경이 될 스카이박스를 구현해 보겠습니다.