В этой статье я продолжу перевод и исследование WGPU, библиотеки языка Rust для работы с графикой.

Для тех, кто не читал первую статью небольшая вводная информация.

WGPU реализует современный стандарт работы с видео подсистемами — WebGPU и компилируется в разные backend-ы (OpenGL, DirectX12, Metal, Vulkan, WebGL). Он одновременно проще для освоения, чем Vulkan и имеет более продуманное апи, чем OpenGL.

Приступим!

Урок 4. Текстуры

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

Загрузка изображения

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

Для этого, я сделал папку assets и положил туда картинку:

Для чтения файла я буду использовать image crate:

image = "0.24"

Далее, я сделаю отдельный модуль — texture:

use std::error::Error;

pub struct Texture {
   pub texture: wgpu::Texture,
   pub view: wgpu::TextureView,
   pub sampler: wgpu::Sampler,
}

Сделаем новый метод Texture::from_image:

pub fn from_image(
   device: &wgpu::Device,
   queue: &wgpu::Queue,
   img: &image::DynamicImage,
   label: Option<&str>
) -> Self {
   let diffuse_rgba = diffuse_image.to_rgba8();                            // 1
  
   use image::GenericImageView;
   let dimensions = diffuse_image.dimensions();
  
   let size = wgpu::Extent3d {                                             // 2
       width: dimensions.0,
       height: dimensions.1,
       // Мы будем работать в 2D пространстве, поэтому используем только один слой
       depth_or_array_layers: 1,
   };
   let texture = device.create_texture(                                    // 3
       &wgpu::TextureDescriptor {
           label,
           size,
           mip_level_count: 1,
           sample_count: 1,
           dimension: wgpu::TextureDimension::D2,
           // Указываем режим преобразования цвета из формата sRGB в линейное float [0, 1] значение
           format: wgpu::TextureFormat::Rgba8UnormSrgb,
           // TEXTURE_BINDING флаг устанавливается, если нужно использовать текстуру в шейдере
           // COPY_DST говорит, что позже мы будем копировать позже изображение в эту текстуру
           usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
       }
   );
...
  1. Преобразуем изображение в массив rgba байтов

  2. Задаем (эмулируем) размерность в 3D пространстве

  3. Создаем тектуру

Сейчас текстура пустая и нужно скопировать туда изображение:

...
   // Копируем изображение в созданную выше текстуру
   queue.write_texture(
       // Задаем, куда копировать изображение
       wgpu::ImageCopyTexture {
           aspect: wgpu::TextureAspect::All,
           texture: &texture,
           mip_level: 0,
           origin: wgpu::Origin3d::ZERO,
       },
       // Массив rgba пикселей
       &rgba,
       // Схема расположения данных
       wgpu::ImageDataLayout {
           offset: 0,
           bytes_per_row: std::num::NonZeroU32::new(4 * dimensions.0),
           rows_per_image: std::num::NonZeroU32::new(dimensions.1),
       },
       size,
   );

   Self { texture, view, sampler }
}

Фильтрация текстур

Эту часть я долго не знал, как перевести, потому что не имел дело с Sampler-ами. Прежде чем разбираться со следующим фрагментом кода, дам небольшую вводную информацию.

Текстурные координаты не зависят от разрешения самой текстуры или экрана. Это означает, что где-то есть механизм, отвечающий за сопоставление текстурных пикселей (иногда их называют тексели) с текстурными координатами. И Sampler как раз и является этим механизмом, у которого есть несколько режимов работы:

let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let sampler = device.create_sampler(
   &wgpu::SamplerDescriptor {
       address_mode_u: wgpu::AddressMode::ClampToEdge,     // 1
       address_mode_v: wgpu::AddressMode::ClampToEdge,
       address_mode_w: wgpu::AddressMode::ClampToEdge,
       mag_filter: wgpu::FilterMode::Linear,               // 2
       min_filter: wgpu::FilterMode::Nearest,              // 3
       mipmap_filter: wgpu::FilterMode::Nearest,
       ..Default::default()
   }
);
  1. Мы можем задать разные режимы для каждой из координат address_mode_*

  2. Какой фильтр использовать, когда нужно растянуть (magnify) текстуру

  3. Какой фильтр использовать, когда нужно сжать (minify) текстуру

FilterMode предоставляет две опции:

  • Linear смешивает цвет ближайших к текстурной координате текселей. Чем ближе тексель к заданой координате, тем больший вклад он внесет в итоговый цвет. Этот метод еще называют билинейной интерполяцией

  • Nearest берет цвет ближайшего к текстурной координате текселя. В этом режиме при близком рассмотрении можно увидеть угловатые узоры и пикселизацию.

Рассмотрим режимы работы Sampler (AddressMode):

  • ClampToEdge любой тексель, выходящий за пределы текстурных координат, примет цвет ближайшего к границе текстуры пикселя

  • Repeat по-русски — замостить, текстура будет повторяться по всей площади фигуры

  • MirrorRepeat похоже на Repeat, только изображение будет перевернуто, при выходе за границы текстуры

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

pub fn from_bytes(
   device: &wgpu::Device,
   queue: &wgpu::Queue,
   bytes: &[u8],
   label: &str
) -> anyhow::Result<Self> {
   let img = image::load_from_memory(bytes)?;
   Ok(Self::from_image(device, queue, &img, Some(label)))
}

Здесь я использовал anyhow::Result из крейта anyhow = &quot;1.0.64&quot;, для удобной работы с ошибками.

Теперь можно создать текстуру в методе State::new:

surface.configure(&device, &config);

// Создаем текстуру
let diffuse_bytes = include_bytes!("../assets/tree.png");
let diffuse_texture = Texture::from_bytes(&device, &queue, diffuse_bytes, "tree.png").unwrap();

Мы создали текстуру, view, sampler, но они пока не выполняют никакой работы. Давайте объедению их вместе!

BindGroup

BindGroup описывает набор ресурсов и схему данных для шейдеров. Мы объеденим ресурсы, сделанные выше.

Перед созданием BindGroup, нужно сделать BindGroupLayout:

...
let texture_bind_group_layout =
   device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
       entries: &[
           wgpu::BindGroupLayoutEntry {
               binding: 0,                                 // 1
               visibility: wgpu::ShaderStages::FRAGMENT,
               ty: wgpu::BindingType::Texture {
                   multisampled: false,
                   view_dimension: wgpu::TextureViewDimension::D2,
                   sample_type: wgpu::TextureSampleType::Float { filterable: true },
               },
               count: None,
           },
           wgpu::BindGroupLayoutEntry {
               binding: 1,                                 // 2
               visibility: wgpu::ShaderStages::FRAGMENT,
               // Здесь указываем SamplerBindingType::Filtering, потому что выше в sample_type указано filterable: true
               ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
               count: None,
           },
       ],
       label: Some("texture_bind_group_layout"),
   });

Эта схема данных содержит 2 связывания: первое для текстуры, второе для Sampler. Они будут видны только во фрагментном (ShaderStages::FRAGMENT) шейдере. 

Поле visibility принимает битовую маску из значений NONE, VERTEX, FRAGMENT, или COMPUTE. Для текстур чаще всего используется FRAGMENT.

Теперь можно сделать BindGroup:

...
let diffuse_bind_group = device.create_bind_group(
   &wgpu::BindGroupDescriptor {
       layout: &texture_bind_group_layout,
       entries: &[
           wgpu::BindGroupEntry {
               binding: 0,
               resource: wgpu::BindingResource::TextureView(&diffuse_texture.view),    // 1
           },
           wgpu::BindGroupEntry {
               binding: 1,
               resource: wgpu::BindingResource::Sampler(&diffuse_texture.sampler),     // 2
           }
       ],
       label: Some("diffuse_bind_group"),
   }
);

Я использую diffuse_texture.view и diffuse_texture.sampler, сделанные выше. BindGroup является частным случаем BindGroupLayout, поэтому между ними есть некоторое сходство. В WGPU их разделили для того, чтобы переключать их динамически (во время выполнения программы), но при этом, чтобы у них была одна схема данных (BindGroupLayout). Позднее, мы будем хранить каждую текстуру в своем BindGroup.

Осталось добавить поля в структуру State:

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,
   index_buffer: wgpu::Buffer,
   num_indices: u32,
   // NEW!
   diffuse_bind_group: wgpu::BindGroup,
   diffuse_texture: texture::Texture,
}

И вернуть diffuse_bind_group и diffuse_texture:

impl State {
   async fn new() -> Self {
       // ...
       Self {
           surface,
           device,
           queue,
           config,
           size,
           render_pipeline,
           vertex_buffer,
           index_buffer,
           num_indices,
           // NEW!
           diffuse_bind_group,
           diffuse_texture,
       }
   }
}

Мы сделали подготовительные действия, теперь можно воспользоваться diffuse_bind_group и добавить его в рендеринг:

// render()
// ...
render_pass.set_pipeline(&self.render_pipeline);
render_pass.set_bind_group(0, &self.diffuse_bind_group, &[]); // NEW!
render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);

PipelineLayout

Помните PipelineLayout, который мы сделали во втором уроке? В нем задаются все BindGroupLayout, которые будут использоваться в пайплайне.

async fn new(...) {
   // ...
   let render_pipeline_layout = device.create_pipeline_layout(
       &wgpu::PipelineLayoutDescriptor {
           label: Some("Render Pipeline Layout"),
           bind_group_layouts: &[&texture_bind_group_layout], // NEW!
           push_constant_ranges: &[],
       }
   );
   // ...

Теперь можно будет использовать texture_bind_group_layout в пайплайне!

Новый вершинный буфер

Посмотрим на структуру Vertex:

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

Мы напрямую задавали цвет в вершинном буфере. Заменим поле color на tex_coords, так как теперь цвет будет вычисляться во фрагментном шейдере с помощью текстуры:

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

Так как текстурные координаты кодируются двумя значениями f32, нужно внести соответствующие изменения и в Vertex::description:

...
wgpu::VertexAttribute {
   offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress,
   shader_location: 1,
   format: wgpu::VertexFormat::Float32x2,      // было Float32x3
}
...

Теперь обновим сам вершинный буфер:

const VERTICES: &[Vertex] = &[
   Vertex { position: [-0.0868241, 0.49240386, 0.0], tex_coords: [0.4131759, 0.99240386], },     // A
   Vertex { position: [-0.49513406, 0.06958647, 0.0], tex_coords: [0.0048659444, 0.56958647], }, // B
   Vertex { position: [-0.21918549, -0.44939706, 0.0], tex_coords: [0.28081453, 0.05060294], },  // C
   Vertex { position: [0.35966998, -0.3473291, 0.0], tex_coords: [0.85967, 0.1526709], },        // D
   Vertex { position: [0.44147372, 0.2347359, 0.0], tex_coords: [0.9414737, 0.7347359], },       // E
];

Шейдеры

Заключительный этап, перед тем, как мы увидим результат — текстуру на нашем пентагоне. Мы изменили структуру Vertex, эти же изменения нужно отразить в вершинном шейдере:

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

struct VertexInput {
   @location(0) position: vec3<f32>,
   @location(1) tex_coords: vec2<f32>,             // NEW!
};

struct VertexOutput {
   @builtin(position) clip_position: vec4<f32>,
   @location(0) tex_coords: vec2<f32>,             // NEW!
};

@vertex
fn vs_main(
   model: VertexInput,
) -> VertexOutput {
   var out: VertexOutput;
   out.tex_coords = vec2<f32>(model.tex_coords.x, 1.0 - model.tex_coords.y);   // NEW!
   out.clip_position = vec4<f32>(model.position, 1.0);
   return out;
}

Обратите внимание на строку out.tex_coords = vec2<f32>(model.tex_coords.x, 1.0 - model.tex_coords.y).

В ней я инвертировал координату y, чтобы картинка не была перевернутая. Это связано тем, что в WGPU координата y направлена вверх, а в текстурах она направлена вниз.

Обновим также фрагментный шейдер:

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

@group(0) @binding(0)
var t_diffuse: texture_2d<f32>;
@group(0) @binding(1)
var s_diffuse: sampler;

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
   return textureSample(t_diffuse, s_diffuse, in.tex_coords);
}

Должно получиться вот так:

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

Добавьте возможность менять текстуру по нажатию на кнопку space.

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

Ссылка на оригинал

Урок 5. Uniform буфер и 3D камера

Хотя до этого мы рисовали только двухмерные фигуры, на самом деле, мы работали в трехмерном пространстве. Именно поэтому структура Vertex::position имеет 3 координаты, а не 2. Но мы никак не можем увидеть трехмерное пространство. Чтобы это исправить, добавим камеру.

Создаем перспективу

Этот туториал больше про WGPU, поэтому здесь не будет в деталях рассматриваться математические аспекты работы с камерой, только практика. Если же вы хотите лучше разобраться в этой теме вам сюда и сюда. Мы будем использовать cgmath, который возьмет на себя всю работу с вычислениями. Добавьте его в зависимости проекта:

cgmath = "0.18.0"

Теперь можно сделать структуру для работы с камерой. Я сделаю отдельный файл camera.rs:

#[rustfmt::skip]
pub const OPENGL_TO_WGPU_MATRIX: cgmath::Matrix4<f32> = cgmath::Matrix4::new(
    1.0, 0.0, 0.0, 0.0,
    0.0, 1.0, 0.0, 0.0,
    0.0, 0.0, 0.5, 0.0,
    0.0, 0.0, 0.5, 1.0,
);

pub struct Camera {
    pub eye: cgmath::Point3<f32>,
    pub target: cgmath::Point3<f32>,
    pub up: cgmath::Vector3<f32>,
    pub aspect: f32,
    pub fovy: f32,
    pub znear: f32,
    pub zfar: f32,
}

impl Camera {
    pub fn build_view_projection_matrix(&self) -> cgmath::Matrix4<f32> {
        let view = cgmath::Matrix4::look_at_rh(self.eye, self.target, self.up);                     // 1
        let proj = cgmath::perspective(cgmath::Deg(self.fovy), self.aspect, self.znear, self.zfar); // 2
        return OPENGL_TO_WGPU_MATRIX * proj * view;                                                 // 3
    }
}

Самое интересное происходит в build_view_projection_matrix:

  1. Матрица view перемещает мир в позицию камеры. Она будет преобразовывать мировые координаты в координаты пространства окна

  2. Матрица proj создает эффект перспективы. Без нее близкие и далекие объекты были бы одного размера

  3. В WGPU используется координатная система DirectX & Metal. Это значит, что в нормализованных координатах (независимых от размера экрана) WGPU оси x и y находятся в промежутке [-1.0, +1.0], а ось z в [0.0, +1.0]. В то же время cgmath сделан для координатной системы OpenGL. Поэтому я использую матрицу OPENGL_TO_WGPU_MATRIX для трансляции координат.

    Добавим теперь камеру в метод State::new:

struct State {
    // ...
    camera: Camera,
    // ...
}

async fn new(window: &Window) -> Self {
    // let diffuse_bind_group ...

    let camera = Camera {
        // координаты камеры
        eye: (0.0, 1.0, 4.0).into(),
        // смотрим на центр
        target: (0.0, 0.0, 0.0).into(),
        up: cgmath::Vector3::unit_y(),
        aspect: config.width as f32 / config.height as f32,
        fovy: 45.0,
        znear: 0.1,
        zfar: 100.0,
    };

    Self {
        // ...
        camera,
        // ...
    }
}

Теперь у нас есть камера и проекция, нужно каким-то образом отправить ее в шейдер.

Uniform буфер

До этого момента мы использовали буферы для хранения вершинного, индексных массивов и даже текстур. Рассмотрим специальный буфер — uniform. Он отличаются тем, что доступен в любом месте в шейдерах (как глобальные переменные). На самом деле, мы уже использовали его для текстуры и sampler-а. Давайте сделаем еще один буфер для хранения матрицы проекции!

Добавим в файл camera.rs следующий код:

// Данные, которые будут отправляться в шейдер нужно пометить специальной аннотацией
#[repr(C)]
// Pod & Zeroable для удобного приведения типов перед отправкой в WGPU
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct CameraUniform {
    // We can't use cgmath with bytemuck directly so we'll have
    // to convert the Matrix4 into a 4x4 f32 array
    view_proj: [[f32; 4]; 4],
}

impl CameraUniform {
    fn new() -> Self {
        use cgmath::SquareMatrix;
        Self {
            view_proj: cgmath::Matrix4::identity().into(),
        }
    }

    fn update_view_proj(&mut self, camera: &Camera) {
        self.view_proj = camera.build_view_projection_matrix().into();
    }
}

Теперь сделаем сам буфер в методе State::new:

// создание камеры

let mut camera_uniform = CameraUniform::new();
camera_uniform.update_view_proj(&camera);

let camera_buffer = device.create_buffer_init(
    &wgpu::util::BufferInitDescriptor {
        label: Some("Camera Buffer"),
        contents: bytemuck::cast_slice(&[camera_uniform]),
        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
    }
);
// ...

Uniform буфер и BindGroup

Чтобы мы могли отправить буфер в видеокарту, нужно создать разметку (схему данных, layout):

// ...
let camera_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
    entries: &[
        wgpu::BindGroupLayoutEntry {
            binding: 0,
            visibility: wgpu::ShaderStages::VERTEX,     // 1
            ty: wgpu::BindingType::Buffer {
                ty: wgpu::BufferBindingType::Uniform,
                has_dynamic_offset: false,              // 2
                min_binding_size: None,
            },
            count: None,
        }
    ],
    label: Some("camera_bind_group_layout"),
});
// ...
  1. Буфер камеры нужен только в вершинном шейдере

  2. Поле dynamic обозначает, будет ли изменяться размер этого буфера. Это полезно, если вы храните массивы

После создания схемы данных, создадим BindGroup для буфера камеры:

// ...
let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
    layout: &camera_bind_group_layout,
    entries: &[
        wgpu::BindGroupEntry {
            binding: 0,
            resource: camera_buffer.as_entire_binding(),
        }
    ],
    label: Some("camera_bind_group"),
});
// ...

Так же, как и с текстурой, нужно зарегистрировать схему данных буфера (для если вдруг вы захотите поменять буфер во время выполнения программы):

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

Добавим новые поля в структуру State:

struct State {
    // ...
    camera: Camera,
    camera_uniform: CameraUniform,
    camera_buffer: wgpu::Buffer,
    camera_bind_group: wgpu::BindGroup,
}

async fn new(window: &Window) -> Self {
    // ...
    Self {
        // ...
        camera,
        camera_uniform,
        camera_buffer,
        camera_bind_group,
    }
}

Теперь у нас есть все для того, чтобы использовать новую камеру в методе State::render:

render_pass.set_pipeline(&self.render_pipeline);
render_pass.set_bind_group(0, &self.diffuse_bind_group, &[]);
// NEW!
render_pass.set_bind_group(1, &self.camera_bind_group, &[]);
render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);

render_pass.draw_indexed(0..self.num_indices, 0, 0..1);

Обращение к uniform буферу в шейдере

Добавьте в ваш шейдер структуру CameraUniform (1), uniform буфер (2) и обновите clip_position (3) следующим образом:

// Вершинный шейдер
struct CameraUniform {
    view_proj: mat4x4<f32>,
};

// 1
@group(1) @binding(0)
var<uniform> camera: CameraUniform;

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

struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,
    @location(0) tex_coords: vec2<f32>,
}

@vertex
fn vs_main(
    model: VertexInput,
) -> VertexOutput {
    var out: VertexOutput;
    out.tex_coords = vec2<f32>(model.tex_coords.x, 1.0 - model.tex_coords.y);
    out.clip_position = camera.view_proj * vec4<f32>(model.position, 1.0);      // 2
    return out;
}
  1. Так как мы добавили новую BindGroup, нужно явно указать индекс, какой именно BindGroup использовать для данной переменной, который определяется в render_pipeline_layout. texture_bind_group_layout идет в списке первой, поэтому имеет индекс 0. Поэтому camera_bind_group будет идти под индексом 1

  2. Порядок умножения важен при использовании матриц. matrix_a * matrix_b != matrix_b * matrix_a

Контроллер камеры

Такой результат должен получиться. Мы как будто смотрим издалека, поэтому картинка уменьшилась. Но пока что все еще не понятно, что мы в 3D пространстве.

Добавим возможность вращать картинку:

pub struct CameraController {
    speed: f32,
    is_forward_pressed: bool,
    is_backward_pressed: bool,
    is_left_pressed: bool,
    is_right_pressed: bool,
}

impl CameraController {
    pub fn new(speed: f32) -> Self {
        Self {
            speed,
            is_forward_pressed: false,
            is_backward_pressed: false,
            is_left_pressed: false,
            is_right_pressed: false,
        }
    }

    pub fn process_event(&mut self, event: &WindowEvent) -> bool {
        match event {
            WindowEvent::KeyboardInput {
                input: KeyboardInput {
                    state,
                    virtual_keycode: Some(keycode),
                    ..
                },
                ..
            } => {
                let is_pressed = *state == ElementState::Pressed;
                match keycode {
                    VirtualKeyCode::W | VirtualKeyCode::Up => {
                        self.is_forward_pressed = is_pressed;
                        true
                    }
                    VirtualKeyCode::A | VirtualKeyCode::Left => {
                        self.is_left_pressed = is_pressed;
                        true
                    }
                    VirtualKeyCode::S | VirtualKeyCode::Down => {
                        self.is_backward_pressed = is_pressed;
                        true
                    }
                    VirtualKeyCode::D | VirtualKeyCode::Right => {
                        self.is_right_pressed = is_pressed;
                        true
                    }
                    _ => false,
                }
            }
            _ => false,
        }
    }

    pub(crate) fn update_camera(&self, camera: &mut Camera) {
        use cgmath::InnerSpace;
        let forward = camera.target - camera.eye;
        let forward_norm = forward.normalize();
        let forward_mag = forward.magnitude();

        if self.is_forward_pressed && forward_mag > self.speed {
            camera.eye += forward_norm * self.speed;
        }
        if self.is_backward_pressed {
            camera.eye -= forward_norm * self.speed;
        }

        let right = forward_norm.cross(camera.up);

        // Корректировка на случай, если нажаты кнопки вперед/назад
        let forward = camera.target - camera.eye;
        let forward_mag = forward.magnitude();

        if self.is_right_pressed {
            // Нужно пересчитать расстояние между target и eye чтобы оно не менялось
            // Таким образом eye будет находиться в диапазоне между target и eye
            camera.eye = camera.target - (forward + right * self.speed).normalize() * forward_mag;
        }
        if self.is_left_pressed {
            camera.eye = camera.target - (forward - right * self.speed).normalize() * forward_mag;
        }
    }
}

Этот код не идеален, но все же он работает. Можете допилить его под свои нужды!

И, как обычно, нужно добавить контроллер камеры в структуру State:

struct State {
    // ...
    camera: Camera,
    // NEW!
    camera_controller: CameraController,
    // ...
}
// ...
impl State {
    async fn new(window: &Window) -> Self {
        // ...
        let camera_controller = CameraController::new(0.2);
        // ...

        Self {
            // ...
            camera_controller,
            // ...
        }
    }
}

Теперь мы можем добавить обработку событий камеры в метод State::input:

fn input(&mut self, event: &WindowEvent) -> bool {
    self.camera_controller.process_events(event)
}

Но, как ни странно, камера все еще не двигается! По факту, мы только меняем состояние камеры в памяти процесса, поэтому итоговая картинка никак не меняется. Чтобы это исправить, нужно обновить uniform буфер камеры. Есть несколько способов, как это сделать:

  1. Создать отдельный буфер и скопировать туда содержимое camera_buffer. Новый буфер называют staging буфер. Обычно, применяется именно этот способ, потому что ‘camera_buffer’ доступен только внутри видеокарты, что позволяет сделать GPU оптимизацию скорости

  2. Использовать асинхронные методы буфера map_read_async и map_read_async. В целом, это более сложный подход (потому что асинхронный), вместе с которым нужно будет еще использовать BufferUsages::MAP_READ и/или BufferUsages::MAP_WRITE

  3. Использовать метод queue.write_buffer

Я выбираю номер три:

fn update(&mut self) {
    self.camera_controller.update_camera(&mut self.camera);
    self.camera_uniform.update_view_proj(&self.camera);
    self.queue.write_buffer(&self.camera_buffer, 0, bytemuck::cast_slice(&[self.camera_uniform]));
}

Теперь, когда все готово, нужно вызвать метод State::update в главном цикле main.rs:

Event::RedrawRequested(window_id) if window_id == window.id() => {
    // NEW!
    state.update();
    match state.render() {
        Ok(_) => {}
        Err(wgpu::SurfaceError::Lost) => state.resize(state.size),
        Err(wgpu::SurfaceError::OutOfMemory) => *control_flow = ControlFlow::Exit,
        // Все остальные ошибки будут обработаны в следующем кадре
        Err(e) => eprintln!("{:?}", e),
    }
}

Это все, что нужно было сделать. Теперь вы можете управлять камерой с помощью кнопок wsad. Этот пентагон напоминает мне карточки из игры Гарри Поттер, сравните:

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

Сделайте так, чтобы модель вращалась отдельно от камеры (как на гифке). Подсказка: вам понадобится еще одна матрица.

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

Ссылка на оригинал

Урок 6. Instancing

Сейчас наша сцена очень простая, один объект в точке (0, 0, 0). Как сделать больше объектов? Техника клонирования (отрисовки одного и того же объекта в разных местах) называется instancing.

Есть много способов, как можно размножить объекты. Например, можно включить в uniform буфер изменяющиеся параметры (позиция и вращение) и обновлять их каждый раз, когда отрисовывается объект. 

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

Как можно сделать лучше?

Давайте посмотрим на сигнатуру draw_indexed:

pub fn draw_indexed(
    &mut self,
    indices: Range<u32>,
    base_vertex: i32,
    instances: Range<u32> // <-- Вот сюда
)

Аргумент instances принимает список инстансов, которые нужно отрисовать. Это и есть решение! Если я укажу 0..5, тогда будет отрисовано 5 объектов.

Тип Range<u32> выглядит странно, почему просто не использовать u32, если этот аргумент определяет количество объектов? 

Причина в том, что иногда нужно отрисовать только некоторые фигуры, чего нельзя было бы достичь с параметром u32

Хорошо, теперь мы знаем, как отрисовать несколько инстансов объекта. Как указать, как именно они должны быть отрисованы? Мы используем instance буфер.

Instance буфер

Instance буфер создается тем же способом, как и uniform буфер.

Первым делом, сделаем структуру Instance:

// instance.rs

struct Instance {
    position: cgmath::Vector3<f32>,
    rotation: cgmath::Quaternion<f32>,
}

impl Instance {
   pub fn new(position: cgmath::Vector3<f32>, rotation: cgmath::Quaternion<f32>) -> Self {
       Self {
           position, rotation
       }
   }
}

Здесь я использую новый математический структуру — Quaternion. Их часто используют для управления вращением. Здесь вы можете подробнее изучить эту тему.

WGSL шейдеры не имеют структур для работы с кватернионами, поэтому сделаем дополнительную структуру, где будет разметка схемы данных:

#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct InstanceRaw {
    model: [[f32; 4]; 4],
}

impl InstanceRaw {
   pub(crate) fn description<'a>() -> wgpu::VertexBufferLayout<'a> {
       use std::mem;
       wgpu::VertexBufferLayout {
           array_stride: mem::size_of::<InstanceRaw>() as wgpu::BufferAddress,
// Переключаем режим работы с VertexStepMode::Vertex на VertexStepMode::Instance
// Это значит, что шейдер будет итерироваться по инстансам, а не векторам
           step_mode: wgpu::VertexStepMode::Instance,
           attributes: &[
               // mat4 имеет размер 4 векторов vec4, поэтому я делаю 4 слота для каждого вектора
               wgpu::VertexAttribute {
                   offset: 0,
                   // Пока что в вершинном шейдере я использую 0 и 1 location.
                   // 2, 3 и 4 зарезервирую на будущее, поэтому здесь shader_location = 5
                   shader_location: 5,
                   format: wgpu::VertexFormat::Float32x4,
               },
               wgpu::VertexAttribute {
                   offset: mem::size_of::<[f32; 4]>() as wgpu::BufferAddress,
                   shader_location: 6,
                   format: wgpu::VertexFormat::Float32x4,
               },
               wgpu::VertexAttribute {
                   offset: mem::size_of::<[f32; 8]>() as wgpu::BufferAddress,
                   shader_location: 7,
                   format: wgpu::VertexFormat::Float32x4,
               },
               wgpu::VertexAttribute {
                   offset: mem::size_of::<[f32; 12]>() as wgpu::BufferAddress,
                   shader_location: 8,
                   format: wgpu::VertexFormat::Float32x4,
               },
           ],
       }
   }
}

Данные из этой структуры попадут в wgpu::Buffer. Дополнительное разделение Instance и InstanceRaw играет нам на руку, тк можно будет менять отдельно Instance без возни с матрицами. Нужно только не забыть обновить данные в InstanceRaw перед отрисовкой.

Давайте сделаем метод для конвертации Instance в InstanceRaw:

impl Instance {
    // …
    fn to_raw(&self) -> InstanceRaw {
        InstanceRaw {
            model: (cgmath::Matrix4::from_translation(self.position) * cgmath::Matrix4::from(self.rotation)).into(),
        }
    }
}

Добавим теперь инстансы в структуру State:

struct State {
    // …
    instances: Vec<Instance>,
    instance_buffer: wgpu::Buffer,
}

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

use cgmath::prelude::*;

Чтобы создать множество объектов, используем двумерный массив 10 х 10, определив в начале  константы:

const NUM_INSTANCES_PER_ROW: u32 = 10;
const INSTANCE_DISPLACEMENT: cgmath::Vector3<f32> = cgmath::Vector3::new(NUM_INSTANCES_PER_ROW as f32 * 0.5, 0.0, NUM_INSTANCES_PER_ROW as f32 * 0.5);

В методе State::new создаем двумерный массив:

// Instancing
let instances = (0..NUM_INSTANCES_PER_ROW).flat_map(|z| {
   (0..NUM_INSTANCES_PER_ROW).map(move |x| {
       let position = cgmath::Vector3 { x: x as f32, y: 0.0, z: z as f32 } - INSTANCE_DISPLACEMENT;

       let rotation = if position.is_zero() {
           // Кватернионы могут изменять масштаб, поэтому здесь дополнительная проверка
           // на случай, если объект находится в точке (0, 0, 0)
           // Так мы избежим ситуации, когда масштаб объекта стал нулевой
           cgmath::Quaternion::from_axis_angle(cgmath::Vector3::unit_z(), cgmath::Deg(0.0))
       } else {
           cgmath::Quaternion::from_axis_angle(position.normalize(), cgmath::Deg(45.0))
       };

       Instance::new(position, rotation)
   })
}).collect::<Vec<_>>();

Теперь, когда есть массив объектов, можем записать их в буфер:

let instance_data = instances.iter().map(Instance::to_raw).collect::<Vec<_>>();
let instance_buffer = device.create_buffer_init(
   &wgpu::util::BufferInitDescriptor {
       label: Some("Instance Buffer"),
       contents: bytemuck::cast_slice(&instance_data),
       usage: wgpu::BufferUsages::VERTEX,
   }
);

Буфер готов, обновим render_pipeline:

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

Последний штрих в методе State::new, добавим новые поля в структуру:

Self {
    // ...
    // NEW!
    instances,
    instance_buffer,
}

Рендеринг

Мы сделали множество инстансов, которые можно отрисовать. В методе State::render я добавлю новый instance_buffer в render_pass и укажу количество инстансов для отрисовки:

// NEW!
render_pass.set_vertex_buffer(1, self.instance_buffer.slice(..));
render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);

// UPDATED!
render_pass.draw_indexed(0..self.num_indices, 0, 0..self.instances.len() as _);

Если вы будете добавлять новые инстансы, не забудьте создать ещё раз instance_buffer и camera_bind_group, иначе инстансы не будут отображаться корректно.

Обновим шейдер. Добавьте следующий код в начало файла:

struct InstanceInput {
    @location(5) model_matrix_0: vec4<f32>,
    @location(6) model_matrix_1: vec4<f32>,
    @location(7) model_matrix_2: vec4<f32>,
    @location(8) model_matrix_3: vec4<f32>,
};

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

fn vs_main(
   model: VertexInput,
   instance: InstanceInput,
) -> VertexOutput {
   var out: VertexOutput;

   let model_matrix = mat4x4<f32>(
       instance.model_matrix_0,
       instance.model_matrix_1,
       instance.model_matrix_2,
       instance.model_matrix_3,
   );

   out.tex_coords = vec2<f32>(model.tex_coords.x, 1.0 - model.tex_coords.y);
   out.clip_position = camera.view_proj * model_matrix * vec4<f32>(model.position, 1.0);
   return out;
}

Я в начале умножаю camera.view_proj, потому что camera.view_proj меняет координаты в пространство камеры, а model_matrix находится в мировом пространстве. Если их поменять местами, то получим некорректный результат (я проверил!).

Итоговый результат:

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

Добавьте вращение инстансов.

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

Ссылка на оригинал статьи

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


  1. kovserg
    09.11.2022 20:55

    WGPU реализует современный стандарт работы с видео подсистемами...

    Это означает что оно будет работать только в браузере google chome?


    1. domix32
      09.11.2022 21:46
      +2

      FF инициировал это в первую очередь. А вот за всякие сафари не ручаюсь


    1. Safort
      10.11.2022 01:18

      Нет, это значит, что это будет работать во всех браузерах, поддерживающих WebGPU. А это кроме Chromium based ещё и Firefox и Safari. Это уже можно всё протестить, если включить флаги.


    1. lain8dono
      10.11.2022 01:33

      wgpu является реализацией WebGPU для Mozilla Firefox. Если быть точным, то данная серия статей о использовании биндингов к wgpu-core для языка Rust. Кроме биндингов для Rust существуют и другие: https://github.com/gfx-rs/wgpu-native#bindings

      Для использования биндингов браузер совершенно не обязателен. Если не считать отсутствия WebGPU в стабильных версиях браузеров, то "будет работать" не корректно. Работает и далеко не один год.


      1. kovserg
        10.11.2022 18:24
        +1

        А без браузера как-то можно запучтить?


        1. lain8dono
          11.11.2022 03:30
          +1

          Эта и предыдущая статьи вполне неплохо отвечают на этот вопрос. Если без деталей, то просто берёте и запускаете, как любую другую программу на Rust. В случае нативной программы wgpu будет напрямую использовать wgpu-core и остальные части реализации. В случае программы для браузера (компиляция в таргет WebAssembly) есть два варианта.

          Если браузер реализует WebGPU, то wgpu использует эту реализацию. Должно работать в ночных сборках браузеров с соответственными настройками. Например Firefox Nightly использует wgpu-core для реализации WebGPU.

          Если браузер НЕ реализует WebGPU, но реализует WebGL 2, то wgpu также использует wgpu-core, но только ту часть реализации WebGPU, которая работоспособна на WebGL 2.

          Путаница из похожих названий, есть такое. WebGPU это стандарт, wgpu-core это реализация, wgpu это биндинги к реализации.