Добро пожаловать в третью часть учебника «Изучаем 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. Следите за новостями!
Если статья показалась вам интересной, нажмите кнопку «Мне нравится» и подпишитесь на обновления.