Skip to content

abritov/rust-wgpu-tutorial

Repository files navigation

Разработка графики на языке rust

Я решил разобраться в теме рендеринга 3D сцен, поэтому решил записать цикл уроков по этой теме. Во многом это будет перевод официального туториала по wgpu + мои комментарии.

Мотивация

Почему именно wgpu, а не OpenGL, Vulkan или DirectX? Я за кроссплатформенную разработку, а wgpu поддерживает несколько графический backend-ов, операционных систем и даже компилируется в webgl (то-есть, мы можем сделать игру в браузере). Кроме того, стандарт WebGPU (на чем основан wgpu) мне видится многообещающим, за ним будущее.

Что такое WGPU?

Wgpu это реализация спецификации WebGPU на языке rust, целью которой является предоставить более безопасный и удобный доступ к функционалу видео карты из браузера (замена webgl). Во многом, API перекликается с таковым у Vulkan API, предоставляя также возможность трансляции в другие backend-ы (DirectX, Metal, Vulkan).

Приступим!

Как правило, любая игра начинается с окна, именно в нем в дальнейшем можно отрисовывать результаты работы видеокарты.

Сделайте новый проект с помощью cargo:

cargo new rust_wgpu_tutorial --bin

Я буду использовать следующие зависимости:

[dependencies]
winit = "0.26"
env_logger = "0.9"
log = "0.4"
wgpu = "0.13"

Теперь сам код:

use winit::{
    event::*,
    event_loop::{ControlFlow, EventLoop},
    window::WindowBuilder,
};

fn main() {
    env_logger::init();
    let event_loop = EventLoop::new();
    let window = WindowBuilder::new().build(&event_loop).unwrap();

    event_loop.run(move |event, _, control_flow| match event {
        Event::WindowEvent {
            ref event,
            window_id,
        } if window_id == window.id() => match event {
            WindowEvent::CloseRequested | WindowEvent::KeyboardInput {
                input:
                KeyboardInput {
                    state: ElementState::Pressed,
                    virtual_keycode: Some(VirtualKeyCode::Escape),
                    ..
                },
                ..
            } => *control_flow = ControlFlow::Exit,
            _ => {}
        },
        _ => {}
    });
}

Помимо самого окна я добавил еще логгер, чтобы в дальнейшем видеть детализацию ошибок wgpu, если они произойдут. Если вы работали с растом, то этот код не вызывает много вопросов, кроме разве что конструкции внутри match. Там говорится следующее: для всех событий в event_loop, отбери только те, которые относятся к текущему окну. Если событие WindowEvent::CloseRequested, либо WindowEvent::KeyboardInput, тогда происходит деструктуризация структуры KeyboardInput. Если поле virtual_keycode внутри равно Some(VirtualKeyCode::Escape), тогда установи событие ControlFlow::Exit (закрой окно). Напоминает продвинутый pattern-matching в haskell. Вот за что я люблю rust.

Отлично, окно отображается! Сделаем небольшой рефакторинг, добавив файл state.rs в папку src, со следующим содержимым:

use winit::window::Window;
use winit::{
    event::*,
};

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

impl State {
    pub async fn new(window: &Window) -> Self {
        todo!()
    }

    pub fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
        todo!()
    }

    pub fn input(&mut self, event: &WindowEvent) -> bool {
        todo!()
    }

    pub fn update(&mut self) {
        todo!()
    }

    pub fn render(&mut self) -> Result<(), wgpu::SurfaceError> {
        todo!()
    }
}

Начнем с метода new:

async fn new(window: &Window) -> Self {
    let size = window.inner_size();

    // instance - объект для работы с wgpu
    // Backends::all => OpenGL + Vulkan + Metal + DX12 + Browser WebGPU
    let instance = wgpu::Instance::new(wgpu::Backends::all());
    let surface = unsafe { instance.create_surface(window) };
    let adapter = instance.request_adapter(
        &wgpu::RequestAdapterOptions {
            power_preference: wgpu::PowerPreference::LowPower,
            compatible_surface: Some(&surface),
            force_fallback_adapter: false,
        },
    ).await.unwrap();
}

Instance и Adapter

Для работы с видеокартой нам понадобиться Adapter и Surface, которые можно создать через методы instance. Мне нравится, что прежде чем работать с видеокартой, нужно создать instance, а не работать с глобальным изменяемым состоянием, как это делается в OpenGL, например.

При выборе адаптера, мы руководствуемся следующими опциями:

  • power_preference в этом свойстве можно задать приоритет выбора GPU. При выборе LowPower, wgpu выберет интегрированную видеокарту.
  • compatible_surface проверяем совместимость адаптера с созданным окном.
  • force_fallback_adapter если этот флаг установлен в true, wgpu выберет адаптер, который с больше долей вероятности будет работать на любом железе, предпочтение будет отдано интегрированной карте.

Surface

Surface это область окна (как canvas в html), которая будет использоваться для отрисовки. Чтобы получить surface, окно должно реализовать HasRawWindowHandle из пакета raw-window-handle. В нашем случае, winit подходит под эти требования.

Device & Queue

Как и в случае с instance, мы работаем не с глобальными объектами, а сами создаем нужные структуры. Чтобы создать device и queue, я добавил следующим код:

let (device, queue) = adapter.request_device(
    &wgpu::DeviceDescriptor {
        features: wgpu::Features::empty(),
        limits: wgpu::Limits::default(),
        label: None,
    },
    None, // Trace path
).await.unwrap();

Мы можем указать конкретные возможности видеокарты, которые хотим использовать в свойстве features. Чтобы задать пороговые значения для свойств, используется поле limits. Например, там есть свойства max_vertex_attributes или max_vertex_buffer_array_stride. Посмотреть список всех свойств можно здесь.

Указание этих свойств может быть полезно, чтобы расширить спектр поддерживаемых GPU.

Последнее, что нужно добавить в метод State::new, это создание конфига:

let config = wgpu::SurfaceConfiguration {
    usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
    format: surface.get_supported_formats(&adapter)[0],
    width: size.width,
    height: size.height,
    present_mode: wgpu::PresentMode::Fifo,
};
surface.configure(&device, &config);
// Формируем структуру State
Self {
    surface,
    device,
    queue,
    config,
    size
}

Посмотрим подробнее на поля конфига:

  • usage показывает, в каком режиме должна работать видеокарта.
  • format определяет, в каком формате будут храниться текстуры SurfaceTextures. У разных дисплеев могут быть свои требования, поэтому здесь выбирается первый подходящий формат.
  • present_mode определяет, как будет синхронизироваться Surface с экраном. wgpu::PresentMode::Fifo означает VSYNC.

Осталось создать структуру State в методе main: Создаем структуру State в main

Тк метод State::new асинхронный, вызвать его мало, нужно еще дождаться его выполнения, для чего используется .await. Чтобы этот код работал, нужен Executor. Я воспользуюсь tokio, указав его в зависимостях: tokio = { version = "1", features = ["full"] }.
Метод main тоже нужно сделать асинхронным и пометить аннотацией #[tokio::main].

Теперь все работает!

resize

Продолжим нашу работу, реализовав метод resize, который будет вызываться при изменении размеров окна.

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

main.rs: Добавляем resize в State

Так гораздо удобнее!
Мне сильно нехватает возможности перетаскивания окна, поэтому добавил обработку события Moved:

WindowEvent::Moved(_) => {
    window.request_redraw();
}

render

Сделаем заливку цветом. Для этого нужно добавить несколько строк в метод render.

let output = self.surface.get_current_texture()?;

Сначала мы получаем SurfaceTexture (результат работы строки выше, фрейм), чтобы потом отрисовывать туда наш фон.

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

create_view возвращает TextureView, то, куда будем рендерить.

let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
    label: Some("Render Encoder"),
});

Нам также понадобиться CommandEncoder, чтобы подготовить команды, который будет выполнять GPU.

Теперь самый большой кусок:

encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
    label: Some("Render Pass"),
    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
        view: &view,
        resolve_target: None,
        ops: wgpu::Operations {
            load: wgpu::LoadOp::Clear(wgpu::Color {
                r: 0.1,
                g: 0.2,
                b: 0.3,
                a: 1.0,
            }),
            store: true,
        },
    })],
    depth_stencil_attachment: None,
});

Подготавливаем RenderPass, который содержит в себе методы для рисования. В данном примере мы сделаем заливку экрана в синий цвет.

Поле color_attachments.view указывает, куда отрисовывать изображение (переменная view).

color_attachments.ops.load определяем, что делать с изображением из предыдущего кадра. В данном примере мы очищаем экран и делаем заливку синим цветом.

И последние строки в методе render:

self.queue.submit(std::iter::once(encoder.finish()));
output.present();

Ok(())

Отправляем RenderPass на выполнение и отрисовываемым результат на экран.

Здесь все, но нужно еще обработать два новых события в главном цикле: Обработчик RedrawRequested

Вот, что должно получиться: Заливка экрана

Задание

Добавить в метод input обработчик событий движения мышки и менять цвет заливки в соответствии с полученными координатами. (Вам понадобиться WindowEvent::CursorMoved)

Следующий урок

About

Серия уроков по WGPU

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages