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

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


Текстуры

Пока что мы визуализировали треугольник и даже раскрасили его разными цветами. Но что, если мы хотим отрендерить более реалистичное изображение, скажем, Феррис, неофициальный талисман Раста. Потребуется значительное количество времени и ресурсов, чтобы воссоздать изображение с вершинами и указать атрибут цвета для каждой вершины.

Texture обычно используется в компьютерной графике для этой цели. Текстура обычно представляет собой изображение, используемое для добавления деталей к объекту. Мы можем вставить много деталей в изображение и создать иллюзию, что объект чрезвычайно детализирован без необходимости добавлять дополнительные вершины. OpenGL может использовать различные типы текстур: 1D, 3D, кубические текстуры и т. д. В этой статье мы будем рассматривать только двумерные текстуры.

Подобно VBO и VAO, текстуры являются объектами в OpenGL и требуют сгенерированного id прежде чем мы сможем их использовать. Мы создаем специальный тип для текстуры:

pub struct Texture {
    pub id: GLuint,
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Чтобы создать новую текстуру id мы используем gl::GenTextures функция:

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

Выйти из полноэкранного режима

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

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

Выйти из полноэкранного режима

Точно так же, как и другие объекты в OpenGL, текстуры должны быть привязаны для применения к ним операций. Поскольку изображения представляют собой двумерные массивы пикселей, мы используем gl::BindTexture функция с gl::TEXTURE_2D в качестве первого параметра:

impl Texture {
    pub unsafe fn bind(&self) {
        gl::BindTexture(gl::TEXTURE_2D, self.id)
    }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима


Рендеринг прямоугольника

Чтобы нарисовать текстуру, нам сначала нужно научиться рисовать прямоугольник. Мы можем нарисовать прямоугольник, используя два треугольника, потому что OpenGL в основном работает с треугольниками:

Описание изображения

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

Element buffer objects (EBO) призваны помочь нам решить проблему. EBO — это буфер, в котором хранятся индексы, которые OpenGL использует, чтобы решить, какие вершины рисовать. Мы будем повторно использовать наш Buffer реализацию, которую мы определили на предыдущих уроках с gl::ELEMENT_ARRAY_BUFFER тип.

Для начала нам сначала нужно указать вершины и индексы в соответствии с рисунком выше:

type Pos = [f32; 2];

#[repr(C, packed)]
struct Vertex(Pos);

#[rustfmt::skip]
const VERTICES: [Vertex; 4] = [
    Vertex([-0.5, -0.5]),
    Vertex([ 0.5, -0.5]),
    Vertex([ 0.5,  0.5]),
    Vertex([-0.5,  0.5]),
];

#[rustfmt::skip]
const INDICES: [i32; 6] = [
    0, 1, 2,
    2, 3, 0
];

Войти в полноэкранный режим

Выйти из полноэкранного режима

Далее нам нужно создать объект буфера элементов и скопировать индексы в буфер:

let index_buffer = Buffer::new(gl::ELEMENT_ARRAY_BUFFER);
index_buffer.set_data(&INDICES, gl::STATIC_DRAW);
Войти в полноэкранный режим

Выйти из полноэкранного режима

Последнее, что осталось сделать, это заменить gl::DrawArrays позвонить с gl::DrawElements чтобы указать, что мы хотим визуализировать треугольники из индексного буфера, указав, что мы хотим отрисовать всего 6 вершин:

gl::DrawElements(gl::TRIANGLES, 6, gl::UNSIGNED_INT, ptr::null());
Войти в полноэкранный режим

Выйти из полноэкранного режима


Координаты текстуры

Теперь, когда мы знаем, как рисовать прямоугольник, давайте посмотрим, как мы можем использовать наши знания для рендеринга текстуры. Сначала нам нужно сопоставить текстуру с прямоугольником и сообщить каждой вершине прямоугольника, какой части текстуры она соответствует. Следовательно, каждая вершина должна иметь texture coordinate связанные с ним. Эти координаты находятся в диапазоне от 0,0 до 1,0, где (0,0) — обычно нижний левый угол, а (1,1) — верхний правый угол изображения текстуры. Новый массив вершин теперь будет включать текстурные координаты для каждой вершины:

type Pos = [f32; 2];
type TextureCoords = [f32; 2];

#[repr(C, packed)]
struct Vertex(Pos, TextureCoords);

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

Выйти из полноэкранного режима

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

#version 330
in vec2 position;
in vec2 vertexTexCoord;

out vec2 texCoord;

void main() {
    gl_Position = vec4(position, 0.0, 1.0);
    texCoord = vertexTexCoord;
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Затем фрагментный шейдер должен принять texCoord выходная переменная как входная переменная:

out vec4 FragColor;

in vec2 texCoord;

uniform sampler2D texture0;

void main() {
    FragColor = texture(texture0, texCoord);
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Фрагментный шейдер объявляет uniform sampler2D которому мы позже назначим нашу текстуру. Чтобы сэмплировать цвет текстуры, мы используем встроенную функцию GLSL. texture функция, которая возвращает цвет texture в texture coordinate.


Загрузить данные текстуры

Теперь, когда объект текстуры настроен, пришло время загрузить изображение текстуры. Изображения текстур могут храниться в десятках файловых форматов, каждый из которых имеет свою собственную структуру и порядок данных. Скорее всего для нас есть ящик изображение который может обрабатывать все для нас, просто добавьте его как зависимость в Cargo.toml файл.

Чтобы загрузить данные текстуры, мы сначала загружаем изображение по заданному пути. Затем мы конвертируем изображение в общий RGBA отформатируйте и загрузите данные изображения в GPU, используя gl::TexImage2D:

impl Texture {
    pub unsafe fn load(&self, path: &Path) -> Result<(), ImageError> {
        self.bind();

        let img = image::open(path)?.into_rgba8();
        gl::TexImage2D(
            gl::TEXTURE_2D,
            0,
            gl::RGBA as i32,
            img.width() as i32,
            img.height() as i32,
            0,
            gl::RGBA,
            gl::UNSIGNED_BYTE,
            img.as_bytes().as_ptr() as *const _,
        );
        Ok(())
    }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

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

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_array = VertexArray::new();
vertex_array.bind();

let vertex_buffer = Buffer::new(gl::ARRAY_BUFFER);
vertex_buffer.set_data(&VERTICES, gl::STATIC_DRAW);

let index_buffer = Buffer::new(gl::ELEMENT_ARRAY_BUFFER);
index_buffer.set_data(&INDICES, gl::STATIC_DRAW);

let pos_attrib = program.get_attrib_location("position")?;
set_attribute!(vertex_array, pos_attrib, Vertex::0);
let color_attrib = program.get_attrib_location("vertexTexCoord")?;
set_attribute!(vertex_array, color_attrib, Vertex::1);

let texture = Texture::new();
texture.load(&Path::new("assets/ferris.png"))?;

gl::BlendFunc(gl::SRC_ALPHA, gl::ONE_MINUS_SRC_ALPHA);
gl::Enable(gl::BLEND);
Войти в полноэкранный режим

Выйти из полноэкранного режима

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

Код в цикле рендеринга:

gl::ClearColor(0.5, 0.5, 0.5, 1.0);
gl::Clear(gl::COLOR_BUFFER_BIT);
texture.bind();
program.apply();
vertex_array.bind();
gl::DrawElements(gl::TRIANGLES, 6, gl::UNSIGNED_INT, ptr::null());
Войти в полноэкранный режим

Выйти из полноэкранного режима

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

Описание изображения

Поздравляем! Вы только что отрендерили Ферриса!


Текстурные блоки

Что делать, если мы хотим использовать несколько текстур одновременно? Вы могли заметить, что мы объявили uniform sampler2D во фрагментном шейдере. На самом деле мы можем присвоить значение местоположения сэмплеру текстуры, используя этот юниформ-параметр. Расположение текстуры более известно как texture unit.

Мы должны указать OpenGL, к какому текстурному блоку принадлежит каждый сэмплер шейдера, установив каждый сэмплер. Мы добавляем set_int_uniform функция для ShaderProgram тип:

impl ShaderProgram {
    pub unsafe fn set_int_uniform(&self, name: &str, value: i32) -> Result<(), ShaderError> {
        self.apply();
        let uniform = CString::new(name)?;
        gl::Uniform1i(gl::GetUniformLocation(self.id, uniform.as_ptr()), value);
        Ok(())
    }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

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

let texture0 = Texture::new();
texture0.load(&Path::new("assets/logo.png"))?;
program.set_int_uniform("texture0", 0)?;

let texture1 = Texture::new();
texture1.load(&Path::new("assets/rust.jpg"))?;
program.set_int_uniform("texture1", 1)?;
Войти в полноэкранный режим

Выйти из полноэкранного режима

Мы можем активировать модуль текстуры, используя gl::ActiveTexture и передавая блок текстуры, который мы хотели бы использовать. Для этого мы добавляем activate функция для Texture тип:

impl Texture {
    pub unsafe fn activate(&self, unit: GLuint) {
        gl::ActiveTexture(unit);
        self.bind();
    }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

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

texture0.activate(gl::TEXTURE0);
texture1.activate(gl::TEXTURE1);
Войти в полноэкранный режим

Выйти из полноэкранного режима

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

#version 330
out vec4 FragColor;

in vec2 texCoord;

uniform sampler2D texture0;
uniform sampler2D texture1;

void main() {
    vec4 color0 = texture(texture0, texCoord);
    vec4 color1 = texture(texture1, texCoord);
    FragColor = mix(color0, color1, 0.6) * color0.a;
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Окончательный выходной цвет теперь представляет собой комбинацию двух текстур.

Описание изображения

Например, сочетание этих двух текстур вы можете увидеть на следующем скриншоте:

Описание изображения


Резюме

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

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