Изучаем OpenGL с Rust: первый треугольник

Добро пожаловать в третью часть учебника «Изучаем OpenGL с Rust». В прошлый раз мы узнали, как работает графический конвейер современного OpenGL и как мы можем использовать шейдеры для его настройки.

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

Вершинные данные

Прежде чем начать рисовать что-то на экране, OpenGL необходимо передать некоторые входные вершинные данные. Обычно эти данные приходят в виде атрибутов вершин. Одним из таких атрибутов является мировая position вершины, которая определяет, где объект или фигура в конечном итоге окажется на экране. Поскольку мы собираемся нарисовать простой треугольник, мы будем использовать следующее представление позиции:

type Pos = [f32; 2];
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы используем тип f32 для каждого из двух компонентов позиции: x и y. Когда вершинное зелье будет обрабатываться в вершинном шейдере, оно должно быть в нормализованных координатах устройства и варьироваться между -1.0 и 1.0.

Другим атрибутом является цвет вершины. Мы будем представлять цвет как массив из 3 значений: красной, зеленой и синей составляющей, обычно сокращенно RGB. При определении цвета мы устанавливаем силу каждого компонента в значение между 0.0 и 1.0.

type Color = [f32; 3];
Вход в полноэкранный режим Выход из полноэкранного режима

Наконец, мы можем представить нашу вершину в виде кортежа из позиции и цвета:

#[repr(C, packed)]
struct Vertex(Pos, Color);
Войти в полноэкранный режим Выйти из полноэкранного режима

Треугольник, который мы собираемся нарисовать, будет состоять из 3 вершин, расположенных на (-0.5, -0.5), (0.5, -0.5) и (0.0, 0.5) по часовой стрелке с красным, зеленым и синим цветами соответственно:

#[rustfmt::skip]
const VERTICES: [Vertex; 3] = [
    Vertex([-0.5, -0.5], [1.0, 0.0, 0.0]),
    Vertex([0.5,  -0.5], [0.0, 1.0, 0.0]),
    Vertex([0.0,   0.5], [0.0, 0.0, 1.0])
];
Войти в полноэкранный режим Выход из полноэкранного режима

Объект буфера вершин

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

Для этого мы будем использовать объекты буфера вершин (VBO), которые могут хранить большое количество вершин в памяти GPU. Отправка данных на видеокарту из CPU относительно медленная. Используя VBO, мы можем отправлять большие порции данных на видеокарту одновременно и хранить их там.

Сначала мы определим struct для объекта буфера. Мы будем хранить уникальный id, соответствующий буферу, и target для типа буфера. OpenGL имеет много типов буферных объектов, и тип буфера объекта вершинного буфера — gl::ARRAY_BUFFER.

pub struct Buffer {
    pub id: GLuint,
    target: GLuint,
}
Вход в полноэкранный режим Выход из полноэкранного режима

Для генерации нового буфера id мы будем использовать функцию gl::GenBuffers. Метод new для буфера выглядит следующим образом:

impl Buffer {
    pub unsafe fn new(target: GLuint) -> Self {
        let mut id: GLuint = 0;
        gl::GenBuffers(1, &mut id);
        Self { id, target }
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Перед загрузкой фактических данных в буфер мы сначала должны сделать его активным, вызвав функцию gl::BindBuffer:

impl Buffer {
    pub unsafe fn bind(&self) {
        gl::BindBuffer(self.target, self.id);
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь у нас есть объект буфера и мы можем сделать вызов функции gl::BufferData, которая копирует ранее определенные данные вершин в память буфера:

impl Buffer {
    pub unsafe fn set_data<D>(&self, data: &[D], usage: GLuint) {
        self.bind();
        let (_, data_bytes, _) = data.align_to::<u8>();
        gl::BufferData(
            self.target,
            data_bytes.len() as GLsizeiptr,
            data_bytes.as_ptr() as *const _,
            usage,
        );
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

Перед загрузкой данных с помощью gl::BufferData мы преобразуем данные вершин, так как gl::BufferData получает данные в виде массива байтов.

Чтобы удалить буфер, когда он нам больше не нужен, реализуем признак Drop и вызываем функцию gl::DeleteBuffers с буфером id в качестве аргумента:

impl Drop for Buffer {
    fn drop(&mut self) {
        unsafe {
            gl::DeleteBuffers(1, [self.id].as_ptr());
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Объект вершинного массива

В прошлый раз мы говорили о том, что вершинные шейдеры позволяют нам задавать любые входные данные в виде вершинных атрибутов. Для этого мы должны вручную указать, какая часть наших входных данных идет на какой вершинный атрибут в вершинном шейдере. Но перед этим мы должны создать и связать vertex array object (VAO). После этого любая конфигурация вершинных атрибутов будет храниться внутри VAO. Это позволяет переключаться между различными конфигурациями вершинных данных и атрибутов так же просто, как привязать другой VAO.

Структура для VAO выглядит аналогично VBO:

pub struct VertexArray {
    pub id: GLuint,
}
Вход в полноэкранный режим Выход из полноэкранного режима

Для генерации нового VAO id мы используем gl::GenVertexArrays:

impl VertexArray {
    pub unsafe fn new() -> Self {
        let mut id: GLuint = 0;
        gl::GenVertexArrays(1, &mut id);
        Self { id }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Как и для VBO, мы реализуем Drop для трейта VertexArray, чтобы очистить неиспользуемые ресурсы:

impl Drop for VertexArray {
    fn drop(&mut self) {
        unsafe {
            gl::DeleteVertexArrays(1, [self.id].as_ptr());
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Чтобы использовать VAO, все, что вам нужно сделать, это привязать его с помощью gl::BindVertexArray:

impl VertexArray {
    pub unsafe fn bind(&self) {
        gl::BindVertexArray(self.id);
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь мы можем указать OpenGL, как он должен интерпретировать данные вершин для каждого атрибута, используя gl::VertexAttribPointer:

impl VertexArray {
    pub unsafe fn set_attribute<V: Sized>(
        &self,
        attrib_pos: GLuint,
        components: GLint,
        offset: GLint,
    ) {
        self.bind();
        gl::VertexAttribPointer(
            attrib_pos,
            components,
            gl::FLOAT,
            gl::FALSE,
            std::mem::size_of::<V>() as GLint,
            offset as *const _,
        );
        gl::EnableVertexAttribArray(attrib_pos);
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Мы определяем set_attribute с общим типом V, который представляет вершинную компоновку. Первый параметр указывает, какой атрибут вершины мы хотим настроить. Следующий аргумент указывает количество компонентов в вершинном атрибуте. Последний параметр — это смещение начала позиционных данных в буфере. Для простоты мы предполагаем, что тип данных в вершинном атрибуте всегда f32.

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

impl ShaderProgram {
    pub unsafe fn get_attrib_location(&self, attrib: &str) -> Result<GLuint, NulError> {
        let attrib = CString::new(attrib)?;
        Ok(gl::GetAttribLocation(self.id, attrib.as_ptr()) as GLuint)
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Настраивать всю эту информацию вручную для каждого из вершинных атрибутов может быть утомительно и чревато ошибками. Было бы неплохо автоматически вычислять количество компонентов и смещение поля в типе данных, который представляет вершинную компоновку, зная имя поля. Для этого мы собираемся использовать немного макросов Rust:

#[macro_export]
macro_rules! set_attribute {
    ($vbo:ident, $pos:tt, $t:ident :: $field:tt) => {{
        let dummy = core::mem::MaybeUninit::<$t>::uninit();
        let dummy_ptr = dummy.as_ptr();
        let member_ptr = core::ptr::addr_of!((*dummy_ptr).$field);
        const fn size_of_raw<T>(_: *const T) -> usize {
            core::mem::size_of::<T>()
        }
        let member_offset = member_ptr as i32 - dummy_ptr as i32;
        $vbo.set_attribute::<$t>(
            $pos,
            (size_of_raw(member_ptr) / core::mem::size_of::<f32>()) as i32,
            member_offset,
        )
    }};
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь мы можем использовать наш макрос следующим образом:

set_attribute!(vertex_array, 0, Vertex::position);
Войти в полноэкранный режим Выйти из полноэкранного режима

Внутри макроса мы вычисляем смещение позиции поля в типе Vertex, размер поля и передаем эту информацию в функцию set_attribute массива вершин.

Рендеринг треугольника

Наконец, мы можем собрать все вместе для рендеринга нашего первого треугольника. Сначала мы скомпилируем наши шейдеры и соединим их в программу. Затем мы создаем объект vertex buffer и загружаем в него данные вершин для нашего треугольника. После этого мы создаем объект массива вершин и настраиваем атрибуты вершин:

let vertex_shader = Shader::new(VERTEX_SHADER_SOURCE, gl::VERTEX_SHADER)?;
let fragment_shader = Shader::new(FRAGMENT_SHADER_SOURCE, gl::FRAGMENT_SHADER)?;
let program = ShaderProgram::new(&[vertex_shader, fragment_shader])?;
let vertex_buffer = Buffer::new(gl::ARRAY_BUFFER);
vertex_buffer.set_data(&VERTICES, gl::STATIC_DRAW);
let vertex_array = VertexArray::new();
let pos_attrib = program.get_attrib_location("position")?;
set_attribute!(vertex_array, pos_attrib, Vertex::0);
let color_attrib = program.get_attrib_location("color")?;
set_attribute!(vertex_array, color_attrib, Vertex::1);
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь, когда вершинные данные загружены, шейдерная программа создана и данные для атрибутов связаны, остается только использовать нашу программу и VAO и вызвать gl::DrawArrays в главном цикле:

gl::ClearColor(0.3, 0.3, 0.3, 1.0);
gl::Clear(gl::COLOR_BUFFER_BIT);
self.program.apply();
self.vertex_array.bind();
gl::DrawArrays(gl::TRIANGLES, 0, 3);
Вход в полноэкранный режим Выход из полноэкранного режима

Первый параметр gl::DrawArrays указывает вид примитива, который мы хотим нарисовать, второй параметр указывает начальный индекс массива вершин, а последний параметр указывает количество вершин, которые мы хотим нарисовать.

Теперь, если мы запустим нашу программу с помощью cargo run, мы должны увидеть следующий результат:

Поздравляем! Вы только что нарисовали свой первый треугольник с помощью OpenGL.

Резюме

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

В следующий раз мы узнаем, что такое текстура и как рисовать картинки в OpenGL. Следите за новостями!

Если статья показалась вам интересной, нажмите кнопку «Мне нравится» и подпишитесь на обновления.

Оцените статью
devanswers.ru
Добавить комментарий