셰이더 드로잉: 환경(b)

이전 글에서 이어집니다.

***

GPU 초기화

창을 생성했다면 GPU를 이용해 색을 채워야 한다. 이를 위해 Rust 라이브러리인 wgpu를 이용한다. wgpu를 사용하기 위한 기본 엔트리 포인트는 Instance 객체다. 이를 통해 Adapter, Device, Surface 등을 생성할 수 있다. Surface는 렌더링 결과를 실제로 보여줄 대상을 나타낸다. 지금의 경우엔 생성한 창을 그 대상으로 지정해야 할 테다. Surface를 지정했다면 해당 표면에 어떤 장치(GPU)를 사용할지 정해야 한다. 그 역할을 담당하는 것이 Adapter다. 해당 Surface에 출력할 수 있는 GPU를 정해 실제 장치를 가져오는 것이다. 해당 장치로부터 Device를 요청할 수 있다. Device는 장치와 프로그램의 논리적 연결이며, GPU 자원과 CommandEncoder를 생성하는 창구다. 렌더링 명령은 RenderPass를 통해 CommandEncoder에 기록한다. 기록을 마쳐 CommandBuffer로 만든 명령을 GPU에 제출할 때 사용하는 것이 Queue다. Queue에 CommandBuffer를 제출하면 GPU가 해당 명령을 실행하도록 예약된다.

struct GpuState {
    surface: wgpu::Surface<'static>,
    device: wgpu::Device,
    queue: wgpu::Queue,
    config: wgpu::SurfaceConfiguration,
    size: winit::dpi::PhysicalSize<u32>,
}

관련 값들을 담을 구조체를 정의한다. 구조체를 초기화하는 과정에서 Window의 소유권을 App과 함께 가질 수 있도록 Arc를 사용한다. 따라서 Surface는 외부 참조 수명에 종속되지 않으므로 ‘static을 사용할 수 있다.

async fn new(window: Arc<Window>) -> anyhow::Result<Self> {
    let instance = wgpu::Instance::default();

    let surface = instance
        .create_surface(window.clone())
        .context("Surface 생성 실패")?;

    let adapter = instance
        .request_adapter(&wgpu::RequestAdapterOptions {
            compatible_surface: Some(&surface),
            ..Default::default()
        })
        .await
        .context("Adapter 생성 실패")?;

    let (device, queue) = adapter
        .request_device(&wgpu::DeviceDescriptor::default())
        .await
        .context("Device 생성 실패")?;
}

기본 설정으로 Instance를 생성하고, Surface, Adapter, Device, Queue를 생성한다. request_adapter와 request_device 메서드를 각각 Instance와 Adapter로부터 사용한다. RequestAdapterOptions과 DeviceDescriptor 등의 설정을 해야 한다. 어떤 Surface에는 출력이 불가한 GPU도 존재한다. RequestAdapterOptions의 compatible_surface는 기본적으로 None으로 되어있다. 따라서 출력 가능 여부와 무관하게 GPU를 요청한다. 해당 설정 값으로 Option<&Surface>를 입력하면 해당 Surface에 출력 가능한 GPU를 요청하게 만들 수 있다. 나머지 값들은 기본으로 설정한다.

let size = window.inner_size();
let config = surface
    .get_default_config(&adapter, size.width, size.height)
    .ok_or_else(|| anyhow!("호환되는 SurfaceConfig 찾지 못함"))?;

if size.width > 0 && size.height > 0 {
    surface.configure(&device, &config);
}

Ok(GpuState {
    surface: surface,
    device: device,
    queue: queue,
    config: config,
    size: size,
})

Surface를 만들었다고 바로 그림을 그릴 수 있는 게 아니다. Surface의 get_default_config 메서드로 해당 Surface와 Adapter에 맞는 설정인 SurfaceConfiguration을 얻는다. 이를 configure 메서드를 통해 실제로 적용해야 준비가 끝난다. 이때 화면 크기가 0이면 panic이 발생하기 때문에 조건 처리를 해 준다. 마지막으로 완성된 구조체를 반환하면 된다.

Ok(window) => {
    // Window 초기화
    let window = Arc::new(window);
    self.window = Some(window.clone());

    // GPU 초기화
    let gpu_state = block_on(GpuState::new(window.clone()));

    match gpu_state {
        Ok(state) => self.gpu_state = Some(state),
        Err(err) => eprintln!("{err}: GpuState 생성 실패"),
    }
}

Arc로 Window의 소유권을 여러 곳에서 사용한다. request_adapter와 request_device는 비동기 함수지만 현재 프로그램에서 비동기 처리가 필수적이지 않기에 pollster 라이브러리의 block_on 함수로 처리한다. 이렇게 App에 GpuState까지 초기화했다.

***

단색 렌더링

Window의 request_redraw로 발생하는 RedrawRequested 이벤트와 함께 작동시킬 render와 Resized 시 작동할 resize 메서드를 작성한다. SurfaceConfiguration은 렌더링할 이미지의 크기 등의 정보를 포함하고 있다. 그렇기에 창 크기가 변경된다면 이를 갱신하여 다시 configure 메서드를 호출해 적용시킬 필요가 있다. 이러한 작동을 구현하면 된다.

fn resize(&mut self, width: u32, height: u32) {
    self.size.width = width;
    self.size.height = height;
    if width > 0 && height > 0 {
        self.config.width = width;
        self.config.height = height;
        self.surface.configure(&self.device, &self.config);
    }
}

size에는 0을 포함한 현재 창 크기를 저장한다. 너비와 높이가 모두 0보다 클 때만 config의 크기를 갱신하고 Surface에 다시 적용한다.

WindowEvent::Resized(size) => {
    println!("{}×{}", size.width, size.height);
    if let Some(state) = &mut self.gpu_state {
        state.resize(size.width, size.height);
        if let Some(window) = &self.window {
            window.request_redraw();
        }
    }
},

창 크기가 바뀔 때 호출한다. 창 크기가 변해도 실제 렌더링 크기가 창 크기와 동일해진다. 이후 redraw를 요청해 화면을 다시 그린다.

한편 render 메서드는 보다 다단하다. 우선 Surface로부터 렌더링할 SurfaceTexture를 얻고, 해당 텍스처의 TextureView를 생성한다. 이후 Device로 CommandEncoder를 생성하고, TextureView를 색상 출력 대상으로 사용하는 RenderPass를 시작한다. RenderPass를 통해 렌더링 명령을 기록한 다음 패스를 끝낸다. 이후 CommandEncoder의 finish를 호출해 CommandBuffer를 생성한다. 이를 Queue에 제출하면 GPU가 명령을 실행하도록 예약된다. 마지막으로 present를 호출해 렌더링된 프레임을 화면에 표시하도록 요청한다.

구체적인 구현은 Learn Wgpu와 공식 문서를 확인하여 진행하면 된다.

enum RenderResult {
    Done,
    Retry,
}

RenderResult라는 enum 타입을 정의한다. Done과 Retry라는 variant가 있다. Done은 현재 처리가 끝났으며 재시도할 필요가 없음을 의미한다. Retry는 그 반대를 의미한다. 아래 render 코드에서는 먼저 self.size를 확인한다. 너비나 높이가 0이면 텍스처를 얻지 않고 Ok(RenderResult::Done)을 반환한다.

if self.size.width == 0 || self.size.height == 0 {
    return Ok(RenderResult::Done);
}

let current_texture = self.surface.get_current_texture();
let surface_texture = match current_texture {
    Success(surface_texture) => surface_texture,
    Suboptimal(surface_texture) => {
        drop(surface_texture);
        self.surface.configure(&self.device, &self.config);
        return Ok(RenderResult::Retry);
    }
    Timeout => {
        return Ok(RenderResult::Retry);
    }
    Occluded => {
        return Ok(RenderResult::Done);
    }
    Outdated => {
        self.surface.configure(&self.device, &self.config);
        return Ok(RenderResult::Retry);
    }
    Lost => {
        return Err(anyhow!("Lost 에러: SurfaceTexture 생성 실패"));
    }
    Validation => {
        return Err(anyhow!("Validation 에러: SurfaceTexture 생성 실패"));
    }
};

Surface의 get_current_texture 메서드로 Surface가 다음에 표시할 텍스처(SurfaceTexture)를 가져온다. 반환 값은 CurrentSurfaceTexture라는 enum으로 텍스처를 얻어 왔는지 각 variant로 판단한다. Success는 성공적으로 SurfaceTexture를 반환하고, Suboptimal은 SurfaceTexture를 반환하지만 Surface의 속성과 불일치한 상태로, 설정의 재적용이 권장된다. 이때 얻은 SurfaceTexture가 살아 있는 상태에서 configure를 호출하면 panic이 발생하므로 먼저 drop한다. Timeout은 텍스처를 불러오는 시간이 초과된 상태로, 이번 프레임을 건너뛰고 다시 시도해야 한다. Occluded는 화면이 가려진 상태로, 우선 건너뛴 뒤 화면이 드러났을 때 재시도해야 한다. Outdated는 Surface가 변경되어 기존 설정이 더 이상 맞지 않는 상태로, 설정을 재적용한 뒤 재시도해야 한다. Suboptimal과 달리 SurfaceTexture를 얻는 데 실패한 상태다. Lost는 Surface를 잃어버린 상태다. Surface를 얻은 뒤 다시 시도하는 것이 권장되지만, 현 상황에선 에러를 출력하고 프로그램을 멈추는 방향으로 구현한다. Validation은 뭔가 API 오류가 발생한 것이며, 마찬가지로 에러를 출력하고 프로그램을 멈추는 방향으로 구현한다.

let view = surface_texture
    .texture
    .create_view(&wgpu::TextureViewDescriptor::default());

let mut command_encoder = self
    .device
    .create_command_encoder(&wgpu::CommandEncoderDescriptor::default());

{
    let _render_pass = command_encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
        label: Some("asdf"),
        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
            view: &view,
            depth_slice: None,
            resolve_target: None,
            ops: wgpu::Operations {
                load: wgpu::LoadOp::Clear(wgpu::Color {
                    r: 0.0,
                    g: 0.0,
                    b: 1.0,
                    a: 1.0,
                }),
                store: wgpu::StoreOp::Store,
            },
        })],
        depth_stencil_attachment: None,
        timestamp_writes: None,
        occlusion_query_set: None,
        multiview_mask: None,
    });
}

let command_buffer = command_encoder.finish();
self.queue.submit([command_buffer]);
self.queue.present(surface_texture);

Ok(RenderResult::Done)

Texture는 SurfaceTexture의 필드다. Texture의 create_view 메서드를 이용해 TextureView를 생성한다. 명령을 기록할 CommandEncoder를 Device의 create_command_encoder 메서드로 생성한다. CommandEncoder는 GPU 명령을 기록하는 객체이며, RenderPass는 그중 한 렌더링 패스의 명령을 기록하는 객체다. CommandEncoder의 begin_render_pass를 호출하면 CommandEncoder를 가변 참조하는 RenderPass를 얻는다. RenderPassDescriptor로 출력 대상과 패스의 동작을 설정한다. 여기서는 LoadOp::Clear를 지정했으므로 GPU가 이 패스를 실행할 때 색상 출력 대상을 지정한 파란색으로 채운다. 별도의 그리기 명령이 없어도 이 설정으로 단색 화면을 만들 수 있다.

중괄호는 RenderPass의 수명을 제한한다. 중괄호를 벗어나면 _render_pass가 drop되어 패스가 끝나고 CommandEncoder의 가변 참조가 해제된다. 그 뒤 CommandEncoder의 finish를 호출해 기록을 마친 CommandBuffer를 얻는다. 이를 담은 배열처럼 Iterator로 변환 가능한 값을 Queue의 submit에 전달하면 GPU가 명령을 실행하도록 예약된다. 마지막으로 Queue의 present에 텍스처를 입력해 표면에 이를 표시하도록 예약하면 된다.

Ok(window) => {
    // window 초기화
    let window = Arc::new(window);
    self.window = Some(window.clone());

    // gpu 초기화
    let gpu_state = block_on(GpuState::new(window.clone()));

    match gpu_state {
        Ok(state) => {
            self.gpu_state = Some(state);
            // redraw 호출
            if let Some(window) = &self.window {
                window.request_redraw();
            }
        }
        Err(err) => {
            eprintln!("{err}: GpuState 생성 실패");
            event_loop.exit();
        }
    }
}

resumed에서 gpu_state 초기화 시 Window의 request_redraw를 호출한다. request_redraw는 RedrawRequested 이벤트를 요청하는 메서드다.

WindowEvent::RedrawRequested => {
    if let Some(state) = &self.gpu_state {
        match state.render() {
            Ok(RenderResult::Done) => {
                event_loop.set_control_flow(ControlFlow::Wait);
            }
            Ok(RenderResult::Retry) => {
                let deadline = Instant::now() + Duration::from_millis(200);
                event_loop.set_control_flow(ControlFlow::WaitUntil(deadline));
            }
            Err(err) => {
                eprintln!("{err}");
                event_loop.exit();
            }
        }
    }
}

RedrawRequested 이벤트가 발생하면 render 메서드의 결과를 패턴 매칭 분기 처리한다. Done과 Retry 둘 다 ControlFlow를 사용한다. ControlFlow는 AboutToWait 이벤트가 발생한 후 이벤트 루프의 동작을 정의하며, EventLoop의 set_control_flow 메서드를 통해 설정할 수 있다. 여기서 EventLoop가 어떤 흐름으로 진행되는지 이해할 필요가 있다.

이벤트가 시작되면 ApplicationHandler의 내장 메서드인 new_events가 발생한다. new_events는 기본적으로 아무 동작도 정의되어있지 않다. 이후 여러 WindowEvent가 처리된다. about_to_wait는 새로운 이벤트를 기다리기 직전에 발생한다. 여기서 새로운 이벤트는 ControlFlow의 설정에 따른다. ControlFlow가 Wait라면 WindowEvent를 포함한 새로운 이벤트가 발생할 때까지 대기한다. WaitUntil(deadline)이라면 설정한 Instant에 도달하거나 그 전에 새로운 이벤트가 발생할 때 대기를 마치고 new_events를 다시 호출한다. 이때 이미 지난 deadline을 그대로 유지하면 deadline 조건이 계속 충족되어 이벤트 루프가 대기 없이 반복될 수 있다. Poll은 기다림 없이 곧바로 new_events를 호출하며 과정을 반복한다.

fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: StartCause) {
    if let StartCause::ResumeTimeReached { .. } = cause {
        if let Some(state) = &self.gpu_state {
            match state.render() {
                Ok(RenderResult::Done) => {
                    event_loop.set_control_flow(ControlFlow::Wait);
                }
                Ok(RenderResult::Retry) => {
                    let deadline = Instant::now() + Duration::from_millis(200);
                    event_loop.set_control_flow(ControlFlow::WaitUntil(deadline));
                }
                Err(err) => {
                    eprintln!("{err}");
                    event_loop.exit();
                }
            }
        }
    }
}

다시 RedrawRequested로 돌아가서, 결과가 Err(err)라면 오류를 출력하고 프로그램을 종료한다. 결과가 RenderResult::Retry라면 현재 시각에서 일정 시간 뒤에 EventLoop가 다시 작동하도록 ControlFlow를 WaitUntil로 설정한다. 이후 StartCause가 ResumeTimeReached일 때 작동하도록 new_events를 정의한다. 결과가 RenderResult::Done이라면 ControlFlow를 다시 Wait으로 되돌린다.

WindowEvent::Occluded(false) => {
    if let Some(state) = &self.gpu_state {
        match state.render() {
            Ok(RenderResult::Done) => {
                event_loop.set_control_flow(ControlFlow::Wait);
            },
            Ok(RenderResult::Retry) => {
                let deadline = Instant::now() + Duration::from_millis(200);
                event_loop.set_control_flow(ControlFlow::WaitUntil(deadline));
            },
            Err(err) => {
                eprintln!("{err}");
                event_loop.exit();
            },
        }
    }
},
WindowEvent::Occluded(true) => {
    event_loop.set_control_flow(ControlFlow::Wait);
}

창이 가려지면 ControlFlow를 Wait으로 설정한다. 창이 드러나면 render 메서드를 호출하고 결과를 앞서와 같이 분기 처리한다.

***

다음 글로 이어집니다.

I'm looking for an English tutor (⸝⸝◜▿◝⸝⸝ゝ)