Всем привет! Меня зовут Саша и я backend разработчик. Нет, не на rust. Но раст мой любимый язык и недавно я задался целью портировать движок онлайн игры, написанный на C++. Первый месяц ушел на то, чтобы разобраться с бинарными ассетами, их чтением и управлением. Но статья будет не об этом, а о WGPU.

Что такое WGPU?

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

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

Приступим!

Я поделил исходный материал на условные уроки и первым из них будет урок по созданию окна. Так как они не очень большие, в этой статье я объединил 3 урока.

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

Сделайте новый проект с помощью 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. При выборе LowPowerwgpu выберет интегрированную видеокарту.

  • compatible_surface проверяем совместимость адаптера с созданным окном.

  • force_fallback_adapter если этот флаг установлен в truewgpu выберет адаптер, который с больше долей вероятности будет работать на любом железе, предпочтение будет отдано интегрированной карте

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
Создаем State

Тк метод 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:

Обработка события Resized & ScaleFactorChanged
Обработка события Resized & ScaleFactorChanged

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

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

render

Сделаем заливку цветом. Для этого нужно добавить несколько строк в метод State::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 и MainEventsCleared
Обработка событий RedrawRequested и MainEventsCleared

Вот, что должно получиться:

Синее окно
Синее окно

Задание

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

Ссылка на код урока

Урок 2

The Pipeline. Графический конвейер

Если вы работали с OpenGL, то наверняка знаете про шейдеры. Конвейер объединяет все операции, которые видеокарта будет делать с входными данными (в том числе и шейдеры) в одном месте.

Шейдер - это мини программы, которые выполняются на видеокарте. Выделяют три типа шейдеров: вершинный, фрагментный и вычислительный. Есть и геометрические шейдеры, но их мы не будем рассматривать в этом уроке.

Vertex (вершина) — это точка в 3D пространстве (или 2D). Вершины объединяются в группы по 3 или 2, формируя треугольники или отрезки. С помощью треугольников можно сделать модель любой сложности, начиная от куба и заканчивая человеком.

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

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

WGSL

Существует несколько языков для написания шейдеров: GLSL, HLSL, MSL, SPIR-V, которые используются соответственно в OpenGLDirectXMetalVulkan. Но в стандарте WebGPU определили новый язык чтобы управлять ими всеми для унификации. WGSL можно конвертировать во все описанные языки шейдеров.

Напишем наш первый шейдер!

// Вершинный шейдер

struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,            // 1
};

@vertex                                                     // 2
fn vs_main(
    @builtin(vertex_index) in_vertex_index: u32,            // 3
) -> VertexOutput {
    var out: VertexOutput;                                  // 4
    let x = f32(1 - i32(in_vertex_index)) * 0.5;            // 5
    let y = f32(i32(in_vertex_index & 1u) * 2 - 1) * 0.5;
    out.clip_position = vec4<f32>(x, y, 0.0, 1.0);          // 6
    return out;             
}

Довольно сильно напоминает rust, не находите?

  1. Структура VertexOutput определяет выходное значение шейдера. Пока что в нем будет только одно поле clip_position, помеченное аннотацией @builtin(position). Тк шейдер имеет входные и выходные параметры (результат вычисления), мы должны указать явным образом, какое именно поле в структуре VertexOutput представляет итоговые координаты (position), с помощью аннотации. В OpenGL для этого используется gl_Position.

  2. Точка входа в программу шейдеров WGSL помечена аннотацией @vertex. В WGSL вершинный и фрагментный шейдер могут находиться в одном файле, что является несомненным плюсом.

  3. На входе в функцию задается один параметр in_vertex_index, который задается в конфигурации pipeline. Аннотация @builtin(vertex_index) задает назначение аргумента. Это демонстрационный пример, далее будет использоваться другая аннотация. В реальных шейдерах чаще задаются непосредственно вершины, но в данном примере мы будем их вычислять динамически, опираясь только на in_vertex_index.

  4. Внутри мы видим объявление переменной с типом VertexOutput, которая будет возвращаться из функции.

  5. При вычислении координат x и y используются функции f32 & i32, они выполняют роль приведения (cast) типов. Так же, как и в расте, в WGSL есть изменяемый var переменные и не изменяемые let.

  6. Присваивание полей структуры делается аналогичны, задаем значение out.clip_position и возвращаем out.

В данном примере мы можем задать выходное значение напрямую, без структуры VertexOutput:

@vertex
fn vs_main(
    @builtin(vertex_index) in_vertex_index: u32
) -> @builtin(position) vec4<f32> {
    // ...
}

Пока что я оставлю эту структуру, тк позже добавлю туда еще полей.

Фрагметный шейдер

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
    return vec4<f32>(0.3, 0.2, 0.1, 1.0);
}

Здесь мы задаем выходной цвет заливки пространства внутри вершин (треугольников) в коричневый. По аналогии с VertexOutput@location(0) задает индекс выходного фрагментного буфера, только там использовалось именованное поле position, а здесь используются числовые индексы. Позже добавим структуру.

Новый State

Настало время обновить структуру State!
Добавим pipeline:

pub struct State {
    surface: wgpu::Surface,
    device: wgpu::Device,
    queue: wgpu::Queue,
    mouse_x: Option<f64>,
    mouse_y: Option<f64>,
    config: wgpu::SurfaceConfiguration,
    pub(crate) size: winit::dpi::PhysicalSize<u32>,
    // NEW!
    render_pipeline: wgpu::RenderPipeline,
}

Перейдем к методу State::new:

let shader = device.create_shader_module(wgpu::include_wgsl!("shader.wgsl"));

Макрос include_wgsl! читает указанный файл и преобразует его в ShaderModuleDescriptor.

Создадим render_layout:

let render_pipeline_layout =
    device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
        label: Some("Render Pipeline Layout"),
        bind_group_layouts: &[],
        push_constant_ranges: &[],
    });

Пока что здесь ничего интересного, позже мы подробнее рассмотрим, что может быть внутри bind_group_layouts.

let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
    label: Some("Render Pipeline"),
    layout: Some(&render_pipeline_layout),
    vertex: wgpu::VertexState {                      // 1
        module: &shader,
        entry_point: "vs_main",                      // 2.
        buffers: &[],                                // 3.
    },
    fragment: Some(wgpu::FragmentState {             // 4.
        module: &shader,
        entry_point: "fs_main",                      // 5
        targets: &[Some(wgpu::ColorTargetState {     // 6.
            format: config.format,
            blend: Some(wgpu::BlendState::REPLACE),
            write_mask: wgpu::ColorWrites::ALL,
        })],
    }),
    // ...

Ранее я говорил, что WGSL умеет хранить разные типы шейдеров в одном файле. Именно здесь задаются точки входа для вершинного и фрагментных шейдеров. Рассмотрим, что еще здесь происходит:

  1. Структура вершинного шейдера

  2. Точка входа вершинного шейдера

  3. Буфер, который будет отправлен на обработку в вершинный шейдер. Обычно, здесь находяться вершины, но в данном примере вершины вычисляются прямо в шейдере (x и y), поэтому здесь буфер пустой.

  4. Структура фрагментного шейдера

  5. Точка входа фрагментного шейдера

  6. ColorTargetState указывает, какой цветовой буфер использовать. Сейчас мы берем значение из surfaceColorWrites::ALL говорит использовать все цвета: красный, синий, зеленый и альфа канал. BlendState::REPLACE задает поведение при смешевании, мы указываем, что новый цвет должен перезаписать старый.

    // ...
    primitive: wgpu::PrimitiveState {
        topology: wgpu::PrimitiveTopology::TriangleList, // 1.
        strip_index_format: None,
        front_face: wgpu::FrontFace::Ccw,                // 2.
        cull_mode: Some(wgpu::Face::Back),
        polygon_mode: wgpu::PolygonMode::Fill,
        unclipped_depth: false,
        conservative: false,
    },
    // ...

Здесь мы задаем как преобразовывать входной буфер из вершин в треугольники.

    depth_stencil: None,                      // 1.
    multisample: wgpu::MultisampleState {
        count: 1,                             // 2.
        mask: !0,                             // 3.
        alpha_to_coverage_enabled: false,     // 4.
    },
    multiview: None, // 5.
});
  1. Пока мы не будем использовать depth_stencil

  2. Величина сглаживания

  3. Определяет, какие выборки будут активны. Значение !0 значит "все". Подробнее про сглаживания читайте здесь

  4. Пока что не будем это использовать

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

Self {
    surface,
    device,
    queue,
    config,
    size,
    // NEW!
    render_pipeline,
}

Теперь у нас есть пайплайн!

Render

Если сейчас запустить программу, ничего не измениться, потому что рендеринг остался старый и в нем не используется новый пайплайн. Сейчас мы это исправим!

Добавьте следующий код в метод State::render:

// ...
{
    // 1.
    let mut render_pass = 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: self.mouse_y.unwrap_or(0.1),
                            g: 0.2,
                            b: self.mouse_x.unwrap_or(0.3),
                            a: 1.0,
                        }
                    ),
                    store: true,
                }
            })
        ],
        depth_stencil_attachment: None,
    });

    // NEW!
    render_pass.set_pipeline(&self.render_pipeline);    // 2.
    render_pass.draw(0..3, 0..1);                       // 3.
}
  1. Сохраним результат вызова begin_render_pass в render_pass

  2. Связываем render_pass и render_pipeline

  3. Вызываем отрисовку

Обратите внимание, я обрамил код в еще одни фигурные скобки. Это нужно, чтобы обойти ограничение на наличие двух изменяемых ссылок в одной области видимости. Без них код не скомпилируется.

Должен получиться такой треугольник: 

Домашнее задание

Добавьте второй пайплайн, который будет вычислять цвет во фрагментном шейдере исходя из позиции в вершинном шейдере (VertexOutput). Сделайте переключение между пайплайнами по нажатии на кнопку space.

Ссылка на код урока

Урок 3

Загрузка вершин в GPU

В этом уроке мы будем работать с вершинным и индексным буфером.

Что такое буфер?

Под буфером имеется в виду любой последовательный массив данных, как например тип Vec в расте. Обычно в буфере храниться массив простых структур, но также там могут быть и сложные структуры данных (граф, например), при условии, что они будут иметь плоскую (последовательную) структуру. Мы будем часто использовать буферы. Начнем изучение с вершинного и индексного буфера.

Вершинный буфер

В прошлом уроке мы динамически вычисляли вершины прямо в шейдере, поэтому обошлись без вершинного буфера. Но это не лучшая практика, тк теряется возможность переиспользования шейдера и установки других вершин без перезапуска программы. Сейчас мы перепишем код, чтобы вершинный буфер формировался на стороне программы и потом отправим его в шейдер.

Прежде всего, нужно объявить структуру в файле state, которая будет описывать вершины:

#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct Vertex {
    position: [f32; 3],
    color: [f32; 3],
}

Эта структура описывать вершину в трехмерных координатах, ее положение (x, y, z) и цвет (red, green, blue). Обратите внимание на аннотацию #[repr(C)], она говорит компилятору использовать правила выравнивания из языка С. Если ее убрать, будет достаточно трудно найти ошибку. Как правило, все структуры, которые будут отправляться в GPU, должны быть помечены #[repr(C)].

Еще я добавил bytemuck в Cargo.toml:

bytemuck = { version = "1.4", features = ["derive"] }

Теперь можно сделать сами вершины:

const VERTICES: &[Vertex] = &[
    Vertex { position: [0.0, 0.5, 0.0], color: [1.0, 0.0, 0.0] },
    Vertex { position: [-0.5, -0.5, 0.0], color: [0.0, 1.0, 0.0] },
    Vertex { position: [0.5, -0.5, 0.0], color: [0.0, 0.0, 1.0] },
];

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

Указатель на буфер будет храниться в структуре State:

struct State {
    // ...
    render_pipeline: wgpu::RenderPipeline,

    // NEW!
    vertex_buffer: wgpu::Buffer,

    // ...
}

Создаем буфер:

let vertex_buffer = device.create_buffer_init(
    &wgpu::util::BufferInitDescriptor {
        label: Some("Vertex Buffer"),
        contents: bytemuck::cast_slice(VERTICES),
        usage: wgpu::BufferUsages::VERTEX,
    }
);

Чтобы у вас появился метод create_buffer_init, нужно импортировать трейт use wgpu::util::DeviceExt. Для contents я использую приведение типа через bytemuck, именно для этого Vertex был помечен bytemuck::Pod & bytemuck::Zeroable. Аннотация Pod это аббревиатура, которая расшифровывается "Plain Old Data", которая включает преобразование структуры в &[u8]Zeroable значит, что мы можем безопасно использовать std::mem::zeroed() на структуре.

И в конце добавляем:

Self {
    surface,
    device,
    queue,
    config,
    size,
    render_pipeline,
    // NEW!
    vertex_buffer,
}

Отправка буфера в GPU

Я сделал буфер, но пока что он не используется в render_pipeline. Чтобы его задействовать, нужно создать разметку данных (VertexBufferLayout), потому что данные в буфере передаются в GPU в сыром виде, без типизации. То-есть, мы не сможем понять, где в буфере будут находиться position, а где color.

VertexBufferLayout определяет представление (layout) данных буфера в памяти:

impl Vertex {
    fn description<'a>() -> wgpu::VertexBufferLayout<'a> {
        wgpu::VertexBufferLayout {
            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,      // 1
            step_mode: wgpu::VertexStepMode::Vertex,                                 // 2
            attributes: &[                                                           // 3
                wgpu::VertexAttribute {
                    offset: 0,                                                       // 4
                    shader_location: 0,                                              // 5
                    format: wgpu::VertexFormat::Float32x3,                           // 6
                },
                wgpu::VertexAttribute {
                    offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress,
                    shader_location: 1,
                    format: wgpu::VertexFormat::Float32x3,
                }
            ]
        }
    }
}
  1. array_stride определяет размер структуры Vertex вместе с выравниванием. В нашем случае размер будет примерно 24 байта.

  2. step_mode задает режим индексации. Помните входной аргумент в вершинном шейдере @builtin(vertex_index) in_vertex_index: u32? От заданного параметра VertexStepMode зависит, с какими индексами будет вызываться вершинный шейдер. Vertex режим работы по-умолчанию, кроме него есть еще Instance, мы рассмотрим, как с ним работать позже.

  3. attributes нужен для описания полей структуры Vertex. В нашем случае структура имеет два поля: позицию и цвет. Соответственно, в atrributes мы имеем две записи для позиции и цвета.

  4. offset задает смещение относительно array_stride для каждого поля в структуре Vertex. Первое поле — position имеет смещение 0 (тк перед ним ничего нет). Для вычисления смещения следующего поля, нужно прибавить к нулю размер position, то-есть std::mem::sizeof::<[f32; 3]>() или 4 * 3 = 12 байт.

  5. shader_location задает индекс для location, через который можно будет обращаться к полю position, например так @location(0) position: vec3<f32>.

  6. format задает тип параметра (поля) структуры. В данном случае, positon имеет тип [f32; 3], ему соответсвует тип vec3<f32> в шейдере, а в поле format мы указываем VertexFormat::Float32x3.

Вот так это выглядит (взял картинку из оригинальной статьи): 

Теперь мы можем задать описание нашего буфера в render_pipeline:

let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
    // ...
    vertex: wgpu::VertexState {
        // ...
        buffers: &[
            Vertex::description(),
        ],
    },
    // ...
});

И отправить буфер в render_pass:

render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));

set_vertex_buffer принимает два аргумента, первый — это слот, в который попадет буфер, второй сам буфер. При необходимости, мы можем отправлять данные порционно, но в данном случае я отправляю весь буфер.

Мне нужно отрисовать сейчас три вершины, поэтому я вызываю:

render_pass.draw(0..3, 0..1);

Но для большей гибкости, здесь лучше использовать переменную величину, которая будет соответствовать реальному размеру текущего буфера:

render_pass.draw(0..VERTICES.len() as u32, 0..1);

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

// Вершинный шейдер

struct VertexInput {
    @location(0) position: vec3<f32>,
    @location(1) color: vec3<f32>,
};

struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,
    @location(0) color: vec3<f32>,
};

@vertex
fn vs_main(
    model: VertexInput,
) -> VertexOutput {
    var out: VertexOutput;
    out.color = model.color;
    out.clip_position = vec4<f32>(model.position, 1.0);
    return out;
}

// Фрагментный шейдер

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
    return vec4<f32>(in.color, 1.0);
}

Теперь готово: 

Индексный буфер

В них нет строгой необходимости, но они могут сократить количество используемое вершинным буфером памяти для больших моделей. Рассмотрим такую фигуру (картинка из оригинальной статьи): 

В ней 5 вершин и три треугольника. Вершинный буфер для нее будет выглядеть следующим образом:

const VERTICES: &[Vertex] = &[
    Vertex { position: [-0.0868241, 0.49240386, 0.0], color: [0.5, 0.0, 0.5] },     // A
    Vertex { position: [-0.49513406, 0.06958647, 0.0], color: [0.5, 0.0, 0.5] },    // B
    Vertex { position: [0.44147372, 0.2347359, 0.0], color: [0.5, 0.0, 0.5] },      // E

    Vertex { position: [-0.49513406, 0.06958647, 0.0], color: [0.5, 0.0, 0.5] },    // B
    Vertex { position: [-0.21918549, -0.44939706, 0.0], color: [0.5, 0.0, 0.5] },   // C
    Vertex { position: [0.44147372, 0.2347359, 0.0], color: [0.5, 0.0, 0.5] },      // E

    Vertex { position: [-0.21918549, -0.44939706, 0.0], color: [0.5, 0.0, 0.5] },   // C
    Vertex { position: [0.35966998, -0.3473291, 0.0], color: [0.5, 0.0, 0.5] },     // D
    Vertex { position: [0.44147372, 0.2347359, 0.0], color: [0.5, 0.0, 0.5] },      // E
];

Можно заметить, что вершины C и B повторяются дважды, а E трижды. Если взять размер каждой вершины в 24 байта, то из 216 байт, 96 будут повторяться. Было бы здорово, если мы могли бы перечислить каждую из вершин только один раз?

Здесь приходит на помощь индексный буфер!

const VERTICES: &[Vertex] = &[
    Vertex { position: [-0.0868241, 0.49240386, 0.0], color: [0.5, 0.0, 0.5] },     // A
    Vertex { position: [-0.49513406, 0.06958647, 0.0], color: [0.5, 0.0, 0.5] },    // B
    Vertex { position: [-0.21918549, -0.44939706, 0.0], color: [0.5, 0.0, 0.5] },   // C
    Vertex { position: [0.35966998, -0.3473291, 0.0], color: [0.5, 0.0, 0.5] },     // D
    Vertex { position: [0.44147372, 0.2347359, 0.0], color: [0.5, 0.0, 0.5] },      // E
];

const INDICES: &[u16] = &[
    0, 1, 4,    // треугольник A, B, E
    1, 2, 4,    // треугольник B, C, E
    2, 3, 4,    // треугольник C, D, E
];

В таком сценарии VERTICES будет занимать всего 120 байт, а INDICES 18 байт + 2 на выравнивание. Мы сэкономили 76 байт! Это кажется мало, но для больших фигур разница будет больше.

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

struct State {
    surface: wgpu::Surface,
    device: wgpu::Device,
    queue: wgpu::Queue,
    config: wgpu::SurfaceConfiguration,
    size: winit::dpi::PhysicalSize<u32>,
    render_pipeline: wgpu::RenderPipeline,
    vertex_buffer: wgpu::Buffer,
    // NEW!
    index_buffer: wgpu::Buffer, // положим индексный буфер сюда
    num_indices: u32,
}

Начнем с создания нового буфера для индексов в методе State::new:

let index_buffer = device.create_buffer_init(
    &wgpu::util::BufferInitDescriptor {
        label: Some("Index Buffer"),
        contents: bytemuck::cast_slice(INDICES),
        usage: wgpu::BufferUsages::INDEX,
    }
);
let num_indices = INDICES.len() as u32;

Еще я добавил переменную num_indices, которая также будет находиться в State:

Self {
    surface,
    device,
    queue,
    config,
    size,
    render_pipeline,
    vertex_buffer,
    // NEW!
    index_buffer,
    num_indices,
}

Теперь обновим метод State::render:

// render()
render_pass.set_pipeline(&self.render_pipeline);
render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16); // 1
render_pass.draw_indexed(0..self.num_indices, 0, 0..1);                               // 2

Несколько вещей, которые нужно отметить:

  1. Единовременно вы можете использовать только один индексный буфер, о чем говорит название метода set_index_buffer

  2. Когда вы используете индексный буфер, для отрисовки нужно вызывать draw_indexed. Также не забудьте, что self.num_indices равно количеству элементов в индексном буфере, но не количеству вершин. Ошибки в этом параметре приведут к неправильной отрисовке или экстренному завершению процесса.

  3. Код шейдера не требуется менять для поддержки индексного буфера

Вот, что получилось: 

Цветовая коррекция

Если вы проверите цвет пентагона, то получите значение #BC00BC. Сконвертировав значение в RGB получаем (188, 0, 188). Разделив значения на 255, чтобы получить цвет в промежутке [0, 1], получим значение (0.737254902, 0, 0.737254902), вместо указанного (0.5, 0.0, 0.5).

Мы можем вычислить аппроксимацию значения цвета по формуле srgb_color = (rgb_color / 255) ^ 2.2. Для цвета (188, 0, 188) мы получим значение (0.511397819, 0.0, 0.511397819), что уже гораздо ближе к исходному.

Кроме этого, можно использовать текстуры, тк в них цвета уже в нужном формате.

Домашнее задание

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

Ссылка на код урока

Заключение

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

Комментарии (4)


  1. shushu
    28.09.2022 08:25
    +2

    Как человеку, не знакомому з WGPU, интересно, каков оверхед на сложных сценах?

    Какова разница в FPS, если сравнивать с реализацией той же сцены, но на Vulkan или OpenGL


    1. lain8dono
      28.09.2022 10:04
      +2

      Это лучше измерять для конкретной сцены. По крайней мере CPU часть поверх Vulkan точно ощутимо быстрее реализации OpenGL. Где-то 5-30% разницы точно должно быть. Когда-то портировал nanovg на wgpu-rs (с некоторыми твиками). В тот момент, когда там было два параллельных бекенда (момент отладки порта) оно было ощутимо быстрее на wgpu-rs даже без специальных измерений (среднее время отрисовки кадра только).

      Вот такая сцена. Её особенность - много-много мелких дроуколов. Сотни штук. При этом шейдеры примитивные. Так что по крайней мере использование явного буфера команд точно приносит свои плоды. С другой стороны при транспиляции шейдеров может быть оверхед на смене пространства координат и проверке границ при доступе к буферам из шейдеров. Но опять же, является ли это чем-то критичным или несущественным надо смотреть на конкретном коде.

      Вообще рекомендую поспрашивать в https://t.me/rust_gamedev_ru. Или даже в https://matrix.to/#/#wgpu-users:matrix.org, может кому-то будет не лень измерить. Честно говоря wgpu-rs ценен не производительностью, а в первую очередь портабельностью и удобством использования.


  1. mayorovp
    28.09.2022 12:21
    +1

    Меня одного смущает блокирующий цикл event_loop.run внутри асинхронной функции? Конкретно в данном примере это неважно, но так-то этот цикл тайком забирает один из потоков у tokio.


    1. lain8dono
      28.09.2022 13:32
      +4

      Меня смущает использование tokio для этого примера. Здесь будет достаточно pollster или чего-то такого. И разумеется нет смысла оборачивать всё-всё-всё в async, только инициализацию. Выглядит глупо при том, что тут нет попыток разобраться с тем, как работает асинхронная загрузка данных на GPU.