ashen-aetna-ko

잿빛 에트나 (Ashen Aetna)

— 재로 뒤덮인 화산 위에서 서툴게 비틀거리기

(3D 그래픽스, Rust, Vulkan, ash에 대한/속의/관한/함께하는 튜토리얼)

첫걸음 (Beginnings)

그래픽스 프로그래밍의 “Hello World”는 화면에 삼각형 하나를 띄우는 것입니다. Vulkan은 우리에게는 다소 불행한, 아주 중요한 설계상의 결정을 하나 내렸습니다. 바로 Vulkan이 정보를 미리 받기를 원한다는 점입니다. 이는 첫 삼각형이 보이기 전에 많은 것을 준비하고 기술(describe)해야 한다는 것을 의미합니다. 즉, 첫 단계들이 가장 어렵고 지루한 단계라는 뜻입니다. (Vulkan을 배우기 시작하는 사람에게는 안타까운 일이지만, 그 이후에는 그렇게 나쁘지 않습니다 (오히려 꽤 괜찮습니다). - 삼각형이 나타날 때까지, 겉보기에는 아무런 진전도 없는 것처럼 보이는 시간을 많이 보낼 각오를 하세요. 하지만 그 고비만 넘기면 최악의 부분은 끝난 겁니다.)

자, 시작해 봅시다.

삼각형을 만들려면 무엇이 필요할까요? 음, 우리는 Vulkan을 필요로 하기로 결정했으니(“Vulkan 인스턴스”를 만들 것입니다) 그리고 아마도 GPU가 필요하겠죠(GPU가 없다면 그래픽스 프로그래밍을 하는 의미가 없으니까요). 사실, GPU에 대해서는 몇 페이지 뒤, 그리고 그 사이의 다른 단계를 거친 후에 다룰 것입니다. 그러니, 우선 ash가 우리에게 제공하는 Vulkan 인스턴스부터 만들어 봅시다. (Ash는 Vulkan API(C 코드로 되어 있습니다)를 매우 얇은 Rust 코드 계층으로 감싸서(wrap), 우리가 Rust에서 해당 함수들을 호출할 수 있도록 해줍니다. 이는 최소한의 작업량이며 추가적인 편의성은 거의 제공하지 않지만, 이는 ash 호출이 다른 언어로 작성된 Vulkan 튜토리얼이나 Vulkan 명세에서 볼 수 있는 해당 호출과 상당히 유사하다는 것을 의미합니다.) 새 프로젝트를 시작하고 (cargo new vulkanrenderer --bin), Cargo.toml에 ash를 포함시킵니다 (ash="0.30.0"). 그런 다음 엔트리(entry, 모든 Vulkan 관련 작업의 시작점 - 혹은 화산(Vulkan의 어원인 ‘vulcan’을 가리키는 유머)이 담긴 동적 라이브러리를 로드하는 것)와 인스턴스를 만듭니다:

fn main() {
    let entry = ash::Entry::new();
    let instance = unsafe { entry.create_instance(&Default::default(), None) };
}

unsafe는 뭘까요? unsafe는 나쁜 것 아닌가요? 음, 아닙니다. unsafe는 Rust 컴파일러가 이 코드 블록 안의 내용에 대해 어떠한 보장도 하지 않는다는 것을 의미합니다. 그리고 여기서는 당연히 그럴 수 없습니다. 결국, 우리는 외부 (C) 라이브러리를 사용하고 있으니까요. 경험 법칙: 우리가 실제로 Vulkan 함수를 호출할 때마다 unsafe가 있을 것입니다. 다음 문제: 엔트리 생성은 실패할 수 있으며 (따라서 entry는 실제로는 Result 타입이라, entry.create_instance를 호출할 수 없습니다), 인스턴스 생성 또한 마찬가지입니다. main() 함수를 수정하는 편이 좋겠습니다:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let entry = ash::Entry::new()?;
    let instance = unsafe { entry.create_instance(&Default::default(), None)? };
    Ok(())
}

(물론, ‘?’ 대신 ‘.expect("엔트리 생성 중 문제가 발생했습니다")’ (그리고 인스턴스에 대해서도 비슷한) 코드를 작성한다면, ‘Ok(())‘를 생략하고 좀 더 멋진 반환 타입 없이 ‘fn main(){‘을 유지할 수도 있습니다.)

이 코드는 아직 작동하지 않는데, ash가 어떤 버전의 Vulkan 라이브러리를 로드해야 할지 모르기 때문입니다. use 구문을 포함하여 알려줍니다:

use ash::version::EntryV1_0;

(만약 Vulkan 버전 1.1을 사용하고 싶다면, ash::version::EntryV1_1이 필요합니다, 등등.) 같은 방식으로, 인스턴스도 처리합니다: “use ash::version::InstanceV1_0”.

우리에겐 처리해야 할 또 다른 문제가 있습니다: 정리(Cleanup). 더 정확히 말하면, Vulkan 쪽에서의 정리 작업입니다. 우리는 인스턴스를 생성했으니, 이제 그것을 파괴(destroy)해야 합니다.

use ash::version::EntryV1_0;
use ash::version::InstanceV1_0;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let entry = ash::Entry::new()?;
    let instance = unsafe { entry.create_instance(&Default::default(), None)? };
    unsafe { instance.destroy_instance(None) };
    Ok(())
}

우리는 인스턴스 생성 및 파괴 함수의 인자에 대해 아직 이야기하지 않았습니다. 두 함수에 있는 “None”은 메모리 (할당) 해제와 관련이 있습니다. 만약 우리가 커스텀 할당 전략을 구현하고 싶다면, 여기에 함수 콜백을 제공할 수 있습니다. 우리에게 더 흥미로운 것은 제가 간결함을 위해 사용한 ‘&Default::default()‘입니다. 우리가 Vulkan에서 무언가를 생성할 때, 몇 가지 커스터마이징 옵션이 있을 수 있습니다. 자동차를 살 때, 색상을 결정하고 싶을 수 있습니다. Vulkan 예제는 아니지만, 뭐 어쩔 수 없죠. Vulkan에서는 사전에 목록을 작성해서 (자동차 예시라면: “색상: 빨강, 바퀴 수: 4, ...”) 생성 함수에 전달합니다. 이 “목록”은 특정 구조체(struct)의 형태를 띠는데, 여기서는 (참조 형태의) ash::vk::InstanceCreateInfo가 될 것입니다. Ash는 고맙게도 이 모든 타입에 대해 기본(Default) 구현을 가지고 있습니다. 보통은 여기서 했던 것처럼 기본값을 인자 전체로 그냥 사용할 수는 없을 것입니다(그리고 우리가 어떤 옵션들을 결정해야 하는지 살펴보는 것이 우리에게 좋을 수 있습니다). 하지만 그 구조체에는 항상 자동으로 채워질 수 있는 필드들이 일부 있으며, Default를 사용하는 것은 바로 그런 경우에 완벽합니다. 사실, 우리는 어차피 곧 InstanceCreateInfo를 살펴봐야 하므로, 이제 우리의 프로그램으로 돌아갑시다. 이 프로그램은 이제 컴파일되고 실행되지만, 눈에 보이는 것은 아무것도 하지 않습니다. 흥미진진한 프로그램이죠. 하지만 적어도 Vulkan 인스턴스는 확보했습니다.

계속