본문 바로가기

개발/Rust

Rust에서 DirectX12 개발하기(1) - 윈도우 창 생성하기

이전 편에서는 Rust에서 DirectX12 개발을 하기 위한 기본적인 프로젝트 설정에 대해서 알아보았습니다.

 

2021.06.06 - [개발/Rust] - Rust에서 DirectX12 개발하기(0) - 개발환경 준비하기

 

Rust에서 DirectX12 개발하기(0) - 개발환경 준비하기

최근 Rust라는 언어가 부상하고 있고, 구글, MS와 같은 글로벌 IT회사에서 자사의 제품에 부분적으로 Rust를 도입하고 있습니다. 특히 MS는 자사의 OS인 Windows에서 지난 12년간 발견한 오류중 70% 정도

honey-balm.tistory.com

 

이번 편에서는 간단한 윈도우 창을 생성하는 프로그램을 만들겠습니다.

 

 


 

 

지난 편에서 만들었던 프로젝트의 src 폴더에 d3d12.rs라는 이름의 파일을 추가해줍니다. 이 파일에는 윈도우 및 DirectX12 초기화 코드를 작성할 것입니다. 파일을 추가했으면 프로젝트 구조는 아래와 같을 것입니다.

 

+---bindings
|   |   build.rs
|   |   Cargo.toml
|   \---src
|           lib.rs
+---src
|       d3d12.rs
|       main.rs
|   .gitignore
|   Cargo.lock
|   Cargo.toml

 

d3d12.rs 라는 파일을 추가하는 것은 프로젝트의 d3d12라는 이름의 모듈을 추가하는 것과 동일합니다. Rust의 모듈은 C++의 네임스페이스와 비슷한 개념이라고 생각하시면 됩니다. 이제 d3d12 모듈을 main.rs에 추가하여, 모듈 트리를 생성해줍니다. main.rs 파일에 아래와 같은 코드를 추가합니다.

 

mod d3d12;

fn main() {
	// ....
}

 

 


 

 

d3d12 모듈을 추가한 다음에는 이 프로젝트에 쓰일 Win32 API 메타데이터를 bindings/build.rs에 추가합니다. 프로젝트 완성에 필요한 모든 메타데이터를 한번에 추가합니다. 코드는 아래와 같습니다.

 

fn main() {
    windows::build!(
        // Windows
        Windows::Win32::UI::WindowsAndMessaging::{HWND, WPARAM, LPARAM, WNDCLASSW, 
        WNDCLASSEXW, MSG, CREATESTRUCTW},
        Windows::Win32::UI::WindowsAndMessaging::{RegisterClassW, CreateWindowExW, ShowWindow, 
        PeekMessageW, TranslateMessage, DispatchMessageW, PostQuitMessage, DefWindowProcW, 
        LoadCursorW, GetWindowLongPtrW, SetWindowLongPtrW,RegisterClassExW},
        
        Windows::Win32::UI::WindowsAndMessaging::{WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, SW_SHOW,
        WM_DESTROY, PM_REMOVE, WM_QUIT, CS_HREDRAW, CS_VREDRAW, IDC_ARROW, WM_PAINT, 
        GWLP_USERDATA, WM_CREATE},
        Windows::Win32::System::SystemServices::{LRESULT, PSTR, PWSTR},
        Windows::Win32::System::SystemServices::{GetModuleHandleW},
        Windows::Win32::UI::DisplayDevices::{RECT},
        
        // Threading
        Windows::Win32::System::Threading::{CreateEventA, WaitForSingleObject},
        Windows::Win32::System::WindowsProgramming::{INFINITE},
        
        //Graphics
        Windows::Win32::Graphics::Direct3D11::{
            D3D_FEATURE_LEVEL_12_1,
            D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST
        },
        
        // D3D12
        Windows::Win32::Graphics::Direct3D12::*,
        Windows::Win32::Graphics::Dxgi::*,
        Windows::Win32::Graphics::Hlsl::*,
    );
}

 

 


 

 

메타데이터를 추가했으면, d3d12 모듈에 윈도우와 DirectX 초기화 코드를 작성합니다. 복잡한 프로젝트가 아니기 때문에, 모든 코드를 한 파일에 작성할 것입니다. 아래의 사진은 아주 간단한 클래스 다이어그램입니다. 

 

그림 1. BareBoneGame 클래스 다이어그램

 

BareBoneGame은 DirectX12 프로그램을 초기화해주는 구조체입니다. 실제 게임 엔진 혹은 게임을 제작할 때는 이렇게 하면 안되지만, 샘플을 간단하게 하기 위해서 모든 코드를 BareBoneGame 구조체에 작성합니다. 

 

BareBoneGame 구조체는 윈도우를 초기화하기 위한 최소한의 정보인 윈도우의 이름(title), 윈도우의 폭(width), 윈도우의 높이(height)만을 new 연관 함수의 매개변수로 받아서 구조체를 초기화합니다.

 

윈도우 초기화 및 DirectX12 각 단계의 초기화는 private 메소드를 통해서 이루어집니다. 각 단계의 초기화에서 생성된 리소스 중에 BareBoneGame이 가지고 있어야하는 리소스들은 Resources라는 구조체로 대표됩니다. 이 Resources 구조체는 BareBoneGame의 resources 필드에 Option<Resources> 형태로 저장됩니다. Option 열거형은 Rust에서 null을 대신하기 위한 열거형으로 값을 가지고 있는 경우에는 Some(T)로 표현하고, 자료가 없는 경우에는 None으로 표현합니다. resources는 BareBoneGame 구조체가 처음 초기화될 때에는 값을 가질 수 없기 때문에, Option<Resources>의 타입을 가집니다.

 

위 클래스 다이어그램을 코드로 작성하면 아래와 같습니다. 

 

use std::intrinsics::transmute;

use bindings::Windows::Win32::{
    Graphics::{Direct3D11::*, Direct3D12::*, Dxgi::*, Hlsl::*},
    System::{
        SystemServices::*,
        Threading::{CreateEventA, WaitForSingleObject},
        WindowsProgramming::INFINITE,
    },
    UI::{DisplayDevices::RECT, WindowsAndMessaging::*},
};

use windows::*;

pub struct Resources {
    hwnd: HWND,
}

pub struct BareBoneGame {
    pub title: String,
    pub width: u32,
    pub height: u32,
    pub resources: Option<Resources>,
}

impl BareBoneGame {
    pub fn new(title: String, width: u32, height: u32) -> Result<BareBoneGame> {
        let mut ret = BareBoneGame {
            title,
            width,
            height,
            resources: None,
        };

        Ok(ret)
    }

    fn create_window(&self) -> Result<HWND> {
        todo!()
    }
}

 

 


 

 

이제 create_window 메소드 내부를 작성하겠습니다. create_window 메소드는 윈도우를 생성하고 해당 윈도우의 핸들을 반환하는 함수입니다. 우선 아래와 같은 코드를 작성해서, 현재 프로그램의 인스턴스를 얻습니다.

 

let hinstance = unsafe { GetModuleHandleW(None) };
debug_assert!(!hinstance.is_null())

 

원래 Win32 API에서는 WinMain 함수를 진입점으로 하기 때문에 프로그램이 시작할 때 현재 프로그램의 인스턴스를 매개변수로 얻을 수 있지만, Rust에서는 WinMain 함수를 사용하지 않기 때문에, GetModuleHandleW[각주:1] 함수를 통해 직접 현재 프로그램의 인스턴스를 얻습니다.  이 함수는 매개변수로 인스턴스를 얻을 프로세스의 이름을 전달해주는데, null(Rust에서는 None)을 전달하면, 현재 프로세스의 인스턴스를 얻을 수 있습니다. 

 

debug_assert! 매크로를 통해서 디버그 환경에서만 assert 검사를 하는 코드를 추가할 수 있습니다. 이 곳에서는 hinstance를 제대로 얻어왔는지 확인합니다. 

 

그 다음에는 WNDCLASSEX[각주:2] 구조체를 통해서 생성할 윈도우의 정보를 담는 구조체를 생성하고 등록해야합니다. WNDCLASSEX 구조체는 아래와 같은 필드를 가집니다. 

 

pub struct WNDCLASSEXW {
    pub cbSize: u32,
    pub style: WNDCLASS_STYLES,
    pub lpfnWndProc: Option<WNDPROC>,
    pub cbClsExtra: i32,
    pub cbWndExtra: i32,
    pub hInstance: HINSTANCE,
    pub hIcon: HICON,
    pub hCursor: HCURSOR,
    pub hbrBackground: HBRUSH,
    pub lpszMenuName: PWSTR,
    pub lpszClassName: PWSTR,
    pub hIconSm: HICON,
}

 

이 구조체의 필드 중에서 주의해야하는 필드는 lpfnWndProclpszClassName 필드입니다. lpfnWndProc 필드는 윈도우의 메시지 이벤트를 처리하는 함수를 등록하는 필드이고, lpszClassName은 등록할 윈도우 클래스의 이름을 지정해야하는 필드입니다. 

 

우선 WndProc는 콜백함수로 코드에서 직접 호출하는 함수가 아니라, 코드 외부에서 호출되는 함수입니다. 그렇기 때문에 stdcall[각주:3] 함수 호출 규칙을 따라야합니다. 이를 Rust에서는 extern "system"[각주:4]을 문법을 통해서 stdcall 함수 호출 규칙을 따르도록 만들 수 있습니다. 아래의 코드는 WndProc 콜백함수입니다. 

 

impl BareBoneGame {
	// ....
 
    unsafe extern "system" fn wnd_proc(
        hwnd: HWND,
        msg: u32,
        wparam: WPARAM,
        lparam: LPARAM,
    ) -> LRESULT {
        match msg {
            WM_DESTROY => {
                PostQuitMessage(0);
            }
            _ => (),
        }

        DefWindowProcW(hwnd, msg, wparam, lparam)
    }
}

 

PostQuitMessage 함수[각주:5]는 프로그램을 종료하는 함수이고, DefWindowProcW[각주:6] 함수는 위의 match 구문에서 처리되지 않은 메시지를 처리하는 함수입니다. 위 함수들에 대한 자세한 설명은 구글 검색을 통해서 쉽게 찾아볼 수 있습니다.

 

그 다음 알아볼 필드는 lpszClassName입니다. 이 필드는 등록할 윈도우 클래스의 이름을 지정해주는 필드입니다. 이 필드를 주의해야하는 이유는 Rust에서의 문자열을 처리하는 방식과 Win32 API에서 문자열을 처리하는 방식이 다르기 때문입니다. Rust에서는 기본적으로 UTF-8 인코딩을 사용하지만 Win32 API는 ANSIUTF-16을 혼용하기 때문입니다. 

 

Win32 API에서 W가 붙는 함수(DefWindowProcW), 구조체(WNDCLASSEXW), 타입(PWSTR) 등은 UTF-16 인코딩을 사용하고 W대신 A가 붙거나 붙지 않는 함수(DefWindowProcA), 구조체(WNDCLASSA), 타입(PSTR) 등은 ANSI 인코딩을 사용합니다. 

 

이 프로젝트에서는 UTF-16 문자열을 기본적으로 사용하지만, 가끔 ANSI 문자열을 사용해야 할 때가 있습니다. 프로젝트 내에서 ANIS 문자열을 사용할 때는 영어만 사용하기 때문에, ASCII 코드를 사용해도 됩니다. 왜냐하면 ANSI 문자열의 앞부분은 ASCII 코드와 동일하기 때문입니다. Rust에서 기본 UTF-8 문자열을 ASCII 문자열로 표현할 때에는 문자열 앞에 b를 붙여서 표현할 수 있습니다.

 

let title = b"Sample";

 

하지만 UTF-8 인코딩을 UTF-16 인코딩으로 변환하기 위해서는 몇가지 절차[각주:7]가 필요합니다. 하지만 이 변환 코드를 직접 구현하는 것보다 간단하게 크레이트를 통해서 가져오겠습니다. rust-d3d12 폴더의 Cargo.toml의 [dependencies] 부분을 아래와 같이 수정합니다.

 

[dependencies]
bindings = {path = "bindings"}
windows = "0.10.0"
utf16_literal = "0.2.1"

 

그 다음에는 d3d12.rs 파일의 상단에 아래와 같은 use 구문을 추가해줍니다.

 

use utf16_literal::utf16;

 

이제 utf16!이라는 매크로를 통해서 UTF-16 인코딩을 가진 문자열을 생성할 수 있습니다. 

 

 


 

 

lpfnWndProc 필드와 lpszClassName 필드에 대해서 알아보았으니, 이제는 WNDCLASSEX 구조체를 생성하고 등록해봅시다. 코드는 아래와 같습니다.

 

fn create_window(&self) -> Result<HWND> {
		// .....

        let wc = WNDCLASSEXW {
            cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
            style: CS_HREDRAW | CS_VREDRAW,
            lpfnWndProc: Some(Self::wnd_proc),
            hInstance: hinstance,
            hCursor: unsafe { LoadCursorW(None, IDC_ARROW) },
            lpszClassName: PWSTR(utf16!("Barebone-Game\0").as_ptr() as _),
            ..Default::default()
        };

        unsafe { RegisterClassExW(&wc) };
    }

 

lpszClassName은 PWSTR[각주:8]이란 구조체를 통해서 문자열의 주소를 전달받습니다.  PWSTR 구조체는 아래와 같습니다. 

 

#[repr(transparent)]
pub struct PWSTR(pub *mut u16);

 

위 형식에 맞춰서 전달하기 위해 utf16! 매크로를 통해서 생성한 문자열을 as_ptr() 함수를 통해서 포인터 형으로 변환해주고, as _ 문법을 통해 적절한 형으로 변환해줍니다. ..Default::default() 구문은 나머지 명시되지 않은 필드는 기본값으로 설정한다는 뜻입니다.  cbSize는 윈도우를 생성할 때 추가적으로 사용할 메모리의 크기를 지정하는 필드입니다. 나중에 필요할 때를 위해서 미리 예약해놓았습니다. 자세한 설명을 다음에 하겠습니다. 

 

 

그 다음은 RegisterClassExW[각주:9] 함수를 통해 생성한 윈도우 클래스 구조체를 등록해줍니다. 

 

 


 

 

이제는 윈도우의 핸들을 생성해줍니다. 윈도우의 핸들을 생성해주는 함수는 CreateWindowExW[각주:10]이고 코드는 아래와 같습니다. 

 

fn create_window(&self) -> Result<HWND> {
	// .....

	let hwnd = unsafe {
		CreateWindowExW(
			Default::default(),
			"Barebone-Game",
			self.title.as_str(),
			WS_OVERLAPPEDWINDOW,
			0,
			0,
			self.width as _,
			self.height as _,
			None,
			None,
			hinstance,
			std::ptr::null_mut(),
			)
		};

	debug_assert!(!hwnd.is_null());

	Ok(hwnd)
}

 

각 필드의 자세한 내용은 구글 검색을 통해서 쉽게 찾아볼 수 있습니다. 하지만 주의해야할 점은 lpclassname 필드와 lpwindowname 필드입니다. 분명 아까 윈도우 클래스를 생성했을 때는 utf16! 매크로를 통해서 UTF-16 인코딩의 문자열을 생성하고 이의 포인터를 전달해줬지만, 이 함수는 W가 붙은 UTF-16 인코딩을 사용하는 함수임에도 UTF-16 인코딩의 문자열을 사용하지 않았습니다. 

 

이 차이는 타입에 있습니다. WNDCLASSEXW의 문자열을 전달받는 필드의 타입은 PWSTR 타입인 반면, CreateWindowExW 함수의 lpclassname과 lpwindowname 필드는 impl IntoParam<'a, PWSTR> 입니다. 

 

pub lpszClassName: PWSTR

lpclassname: impl IntoParam<'a, PWSTR>, 
lpwindowname: impl IntoParam<'a, PWSTR>, 

 

즉 CreateWindowExW의 두 문자열은 PWSTR로 변환하는 IntoParam 트레이트를 구현한 타입을 매개변수로 받는다는 의미입니다. 그리고 Rust의 기본 String형이 이 트레이트를 구현하고 있습니다. 내부 구조는 정확히 모르겠으나, 아마 windows 크레이트 내부에서 구현해놓은 것 같습니다. 그렇기 때문에, CreateWindowExW의 두 문자열 매개변수는 간단하게 String형을 통해서 전달할 수 있습니다. 

 

이렇게 윈도우의 hwnd을 생성하고 hwnd이 잘 생성되었는지 확인한 후 hwnd을 Ok결과와 함께 반환해줍니다. 

 

 


 

 

윈도우의 hwnd도 얻었으니, 이제는 윈도우 창을 화면에 띄우기만 하면 됩니다. 이 코드는 아래와 같습니다. 

 

let hwnd = ret.create_window()?;
		// ....
        
        unsafe { ShowWindow(hwnd, SW_SHOW) };

        loop {
            let mut msg = MSG::default();

            if unsafe { PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE) }.as_bool() {
                unsafe {
                    TranslateMessage(&msg);
                    DispatchMessageW(&msg);
                }

                if msg.message == WM_QUIT {
                    break;
                }
            }
        }

        Ok(ret)
}

 

ShowWindow[각주:11] 함수를 통해서 윈도우를 화면에 표시해주고, PeekMessageW[각주:12] 함수를 통해서 윈도우의 이벤트를 처리할 수 있습니다. GetMessageW[각주:13] 함수와 다르게 이벤트 큐가 비어있을 경우에는 대기하지 않고 바로 리턴하기 때문에, 백그라운드 작업을 할 때 유용합니다. 

 

 

 


 

 

마지막으로 main 함수에서 BareBoneGame 구조체를 초기화하면, 간단한 윈도우를 띄우는 프로그램이 완성입니다. 코드는 아래와 같습니다.

 

mod d3d12;

fn main() {
    let game = d3d12::BareBoneGame::new(String::from("샘플"), 1280, 720).unwrap();
}

 

 

프로그램 실행 결과는 아래와 같습니다.

 

그림 2. 프로그램 실행 결과

 

 

샘플 프로젝트 코드는 github에 올렸습니다.

 

seonhwi07jp/rust-d3d12 (github.com)

 

seonhwi07jp/rust-d3d12

Contribute to seonhwi07jp/rust-d3d12 development by creating an account on GitHub.

github.com

 

해당 코드는 ver2 브랜치로 이동하면 볼 수 있습니다.