Pull to refresh

Пишем свой упрощенный OpenGL на Rust — часть 3 (растеризатор)

Reading time 13 min
Views 14K
Продолжаю свой цикл статей про упрощенный аналог OpenGL на Rust, в котором уже вышло 2 статьи:
  1. Пишем свой упрощенный OpenGL на Rust — часть 1 (рисуем линию)
  2. Пишем свой упрощенный OpenGL на Rust — часть 2 (проволочный рендер)

Напоминаю, что основой моего цикла статей является «Краткий курс компьютерной графики» от haqreu. В предыдущих статьях я шел не очень быстро. Фактически на одну статью курса у меня получилось 2 статьи. Это связанно с тем, что в своих статьях я сосредоточиваюсь главным образом на нюансах работы с Rust, а когда только изучаешь новый язык, сталкиваешься с большим количеством новых для тебя нюансов, нежели чем когда программируешь на нем уже некоторое время. Думаю дальше Rust будет подбрасывать меньше граблей, и я выровняю соотношение своих статей к статьям оригинального курса.

Пока же традиционно предостерегаю, что поскольку я не являюсь профессионалом ни в Rust ни в 3D-графике, а изучаю эти вещи прямо по ходу написания статьи, то в ней может быть немало глупостей. Если замечаете такое, пишите комментарий — я поправлю ошибку. Ну и конечно в статье будет немало личных впечатлений, с которыми вы можете оказаться несогласными. Конструктивная критика приветствуется.


То, что мы получим по итогам данной статьи

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


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

А вот картинка, которая у меня получилась.


Плоская тонировка


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

pub struct Vector3D {
    pub x: f32,
    pub y: f32,
    pub z: f32,
}
impl Vector3D {
    pub fn new(x: f32, y: f32, z: f32) -> Vector3D {
        Vector3D {
            x: x,
            y: y,
            z: z,
        }
    }
    pub fn norm(self) -> f32 {
        return (self.x*self.x+self.y*self.y+self.z*self.z).sqrt();
    }
    pub fn normalized(self, l: f32) -> Vector3D {
        return self*(l/self.norm());
    }
}
impl fmt::Display for Vector3D {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({},{},{})", self.x, self.y, self.z)
    }
}
impl Add for Vector3D {
    type Output = Vector3D;
    fn add(self, other: Vector3D) -> Vector3D {
        Vector3D { x: self.x + other.x, y: self.y + other.y, z:  self.z + other.z}
    }
}
impl Sub for Vector3D {
    type Output = Vector3D;
    fn sub(self, other: Vector3D) -> Vector3D {
        Vector3D { x: self.x - other.x, y: self.y - other.y, z:  self.z - other.z}
    }
}
impl Mul for Vector3D {
    type Output = f32;
    fn mul(self, other: Vector3D) -> f32 {
        return self.x*other.x + self.y*other.y + self.z*other.z;
    }
}
impl Mul<f32> for Vector3D {
    type Output = Vector3D;
    fn mul(self, other: f32) -> Vector3D {
        Vector3D { x: self.x * other, y: self.y * other, z:  self.z * other}
    }
}
impl BitXor for Vector3D {
    type Output = Vector3D;
    fn bitxor(self, v: Vector3D) -> Vector3D {
        Vector3D { x: self.y*v.z-self.z*v.y, y: self.z*v.x-self.x*v.z, z: self.x*v.y-self.y*v.x}
    }
}

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

let x = Vector3D::new(1.0, 1.0, 1.0);
let y = x*2.0;
do_something_else(x);  // error!

Право владения переменной x перешло в функцию умножения, переменная была перемещена в локальную переменную функции и удалена после выхода из функции, поскольку назад мы ее не вернули. Собственно такого типа ошибка у меня и возникала в функции normalized()
, поскольку там, как видите, self стоит и справа и слева от оператора умножения. То есть мы пытаемся переместить его 2 раза подряд. По умолчанию все пользовательские структуры в Rust перемещаемые.
Решения два: или сделать переменную копируемой по умолчанию, или сделать наши реализации операторов принимающими ссылку, а не значение. Я выбрал 2-й вариант. Для его реализации достаточно написать перед объявлением структуры #[derive(Copy, Clone)] . Это говорит компилятору, что наша структура копируемая и скопировать ее можно простым побайтовым дублированием. Теперь в вызовах, подобных приведенному выше, в наши операторы будет передаваться копия данных, а оригинал оставаться доступным и после вызова. Выглядит сложно, но благодаря этому дополнительному усложнению компилятор не позволяет мне написать код с ошибками работы с памятью (например Use After Free ).

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

Дальше код рисования модели с освещением был написан без особых приключений.

Вот, что он нам нарисовал


А вот соответствующий снимок репозитория.

Z-buffer


Когда начал программировать Z-buffer понял, что мне таки нужно, чтобы мой класс Vector3D мог быть как с целочисленными координатами, так и с вещественными. Засучив рукава взялся за его переписывание с использованием обобщенных типов и типажей. Вот тут и пошла самая жара. Я уже говорил, что у типажей в Rust сложный синтаксис? Взгляните сами на код:

use std::fmt;
use std::ops::Add;
use std::ops::Sub;
use std::ops::Mul;
use std::ops::BitXor;
use num::traits::NumCast;

#[derive(Copy, Clone)]
pub struct Vector3D<T> {
    pub x: T,
    pub y: T,
    pub z: T,
}
impl<T> Vector3D<T> {
    pub fn new(x: T, y: T, z: T) -> Vector3D<T> {
        Vector3D {
            x: x,
            y: y,
            z: z,
        }
    }
}
impl<T: NumCast> Vector3D<T> {
    pub fn to<V: NumCast>(self) -> Vector3D<V> {
        Vector3D {
            x: NumCast::from(self.x).unwrap(),
            y: NumCast::from(self.y).unwrap(),
            z: NumCast::from(self.z).unwrap(),
        }
    }
}
impl Vector3D<f32> {
    pub fn norm(self) -> f32 {
        return (self.x*self.x+self.y*self.y+self.z*self.z).sqrt();
    }
    pub fn normalized(self, l: f32) -> Vector3D<f32> {
        return self*(l/self.norm());
    }
}
impl<T: fmt::Display> fmt::Display for Vector3D<T> {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({},{},{})", self.x, self.y, self.z)
    }
}
impl<T: Add<Output = T>> Add for Vector3D<T> {
    type Output = Vector3D<T>;
    fn add(self, other: Vector3D<T>) -> Vector3D<T> {
        Vector3D { x: self.x + other.x, y: self.y + other.y, z:  self.z + other.z}
    }
}
impl<T: Sub<Output = T>> Sub for Vector3D<T> {
    type Output = Vector3D<T>;
    fn sub(self, other: Vector3D<T>) -> Vector3D<T> {
        Vector3D { x: self.x - other.x, y: self.y - other.y, z:  self.z - other.z}
    }
}
impl<T: Mul<Output = T> + Add<Output = T>> Mul for Vector3D<T> {
    type Output = T;
    fn mul(self, other: Vector3D<T>) -> T {
        return self.x*other.x + self.y*other.y + self.z*other.z;
    }
}
impl<T: Mul<Output = T> + Copy> Mul<T> for Vector3D<T> {
    type Output = Vector3D<T>;
    fn mul(self, other: T) -> Vector3D<T> {
        Vector3D { x: self.x * other, y: self.y * other, z:  self.z * other}
    }
}
impl<T: Mul<Output = T> + Sub<Output = T> + Copy> BitXor for Vector3D<T> {
    type Output = Vector3D<T>;
    fn bitxor(self, v: Vector3D<T>) -> Vector3D<T> {
        Vector3D { x: self.y*v.z-self.z*v.y, y: self.z*v.x-self.x*v.z, z: self.x*v.y-self.y*v.x}
    }
}

Кратко объясню, что здесь и зачем. После двоеточия за T пишутся привязки (bounds): какие типажи (traits) должен реализовывать T. Например, в случае операции BitXor, T, как видите, обязан реализовывать умножение, вычитание и копирование. С первыми двумя понятно, в коде функции мы умножаем и вычитаем, логично, что это должно быть допустимо с T. Зачем копирование, спросите вы? Дело в уже описанной выше ситуации, что мы не можем повторно использовать переменную, которая была перемещена в другую функцию. Поэтому нам надо или переписывать арифметику x: self.y*v.z-self.z*v.y, y: self.z*v.x-self.x*v.z, z: self.x*v.y-self.y*v.x с использованием ссылок либо же удостовериться, что T допускает копирование. Все элементарные типы в Rust допускают копирование. Мы в общем-то и не рассчитываем на то, что кто-то попытается запихать в x, y, z списки или файлы или еще что-то сложное, поэтому нам подходит вариант с копированием. «Стоп, а что это еще за <Output = T> ?» — спросит внимательный читатель. И будет прав, потому-что без этой записи код не заработает. Дело в том, что Rust не гарантирует, что результат сложения или умножения будет того же типа, что и операнды. Поэтому мы здесь дополнительно уточняем, что нам нужен T, реализующий умножение именно таким образом, чтобы результат умножения тоже был типа T. Сложно? Я вас предупреждал.

Отдельного упоминания заслуживает метод to(), который позволяет преобразовать вектор одного типа в другой. Например вещественный в целочисленный. Как видите здесь любой тип, реализующий NumCast, может быть преобразован в любой тип, реализующий NumCast. Все примитивные типы в Rust реализуют его, так что мы совершенно безболезненно (не считая времени, затраченного на то, чтобы узнать об этом типаже) получили преобразование типов для нашего вектора.
Остальные модификации не были слишком сложными. В итоге я получил код, который вы можете увидеть в соответствующем снимке репозитория.

И вот какую картинку он рисует


TGA-canvas


Поскольку для текстурирования нам надо уметь читать TGA-файлы, т. к. именно в них хранятся текстуры в интересующих нас моделях, то пришло время вернуться к тому, что я пропустил в самом начале цикла — чтению TGA-файлов. А раз уж мы все равно учимся их читать, то почему бы заодно не сделать и альтернативную реализацию Canvas, которая пишет результат в TGA. Естественно для этого нужно сделать Canvas абстрактным, а потом подготовить 2 его реализации: SdlCanvas и TgaCanvas. В Java я бы сделал базовый класс, от которого унаследовал 2 других. В Rust эта функциональность реализуется при помощи примесей. Взгляните сами на код. Вот сама примесь Canvas:

pub trait Canvas {
    fn canvas(&mut self) -> &mut Vec<Vec<u32>>;
    fn zbuffer(&mut self) -> &mut Vec<Vec<i32>>;
    fn xsize(&self) -> usize;
    fn ysize(&self) -> usize;

    fn new(x: usize, y: usize) -> Self;
    fn show(&mut self);
    fn wait_for_enter(&mut self); 

    fn set(&mut self, x: i32, y: i32, color: u32) {
        if x < 0 || y < 0 {
            return;
        }
        if x >= self.xsize() as i32 || y >= self.ysize() as i32{
            return; 
        }
        self.canvas()[x as usize][y as usize] = color;
    }

    fn triangle(&mut self, mut p0: Vector3D<i32>, mut p1: Vector3D<i32>, mut p2: Vector3D<i32>, color: u32) {
        //...
    }

}

Вот SdlCanvas:

pub struct SdlCanvas {
    sdl_context: Sdl,
    renderer: Renderer,
    canvas: Vec<Vec<u32>>,
    zbuffer: Vec<Vec<i32>>,
    xsize: usize,
    ysize: usize,
}

impl Canvas for SdlCanvas {
    fn new(x: usize, y: usize) -> SdlCanvas {
        //...
        SdlCanvas {
            sdl_context: sdl_context,
            renderer: renderer,
            canvas: vec![vec![0;y];x],
            zbuffer: vec![vec![std::i32::MIN; y]; x],
            xsize: x,
            ysize: y,
        }
    }

    fn show(&mut self) {
        let mut texture = self.renderer.create_texture_streaming(PixelFormatEnum::RGB24, 
                                       (self.xsize as u32, self.ysize as u32)).unwrap();
        // ...
        self.renderer.present();
    }
    
    fn wait_for_enter(&mut self) {
        //...
    }

    fn canvas(&mut self) -> &mut Vec<Vec<u32>>{
        &mut self.canvas
    }
    fn zbuffer(&mut self) -> &mut Vec<Vec<i32>>{
        &mut self.zbuffer
    }
    fn xsize(&self) -> usize{
        self.xsize
    }
    fn ysize(&self) -> usize{
        self.ysize
    }
}

Чтобы не затруднять понимание сути, я удалил часть кода, не имеющего прямого отношения к примесям и их реализации, заменив его на // ... . Интересующиеся могут увидеть полный код в соответствующем снимке репозитория. Как видите, это все выглядит немного непривычно, но по сути очень похоже на привычное нам наследование и интерфейсы. По объему кода даже не сильно отличается. Единственный момент, примеси не позволяют требовать наличия каких-то переменных в структурах. Так что пришлось создать getter'ы для некоторых необходимых нам в универсальной реализации переменных и в SdlCanvas, в свою очередь, их реализовать. В реализации всего вышенаписанного очень помогла соответствующая статья Rust by Example.
Теперь собственно к чтению картинок. Первая трудность была в том, чтобы прочитать заголовок TGA-файла. В оригинальном коде от haqreu это делается довольно простым изящным кодом:

#pragma pack(push,1)
struct TGA_Header {
	char idlength;
	char colormaptype;
        // ...
	short width;
	short height;
	char  bitsperpixel;
	char  imagedescriptor;
};
#pragma pack(pop)
// ...
        TGA_Header header;
        in.read((char *)&header, sizeof(header));

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

const HEADERSIZE: usize = 18; // 18 = sizeof(TgaHeader)
#[repr(C, packed)]
struct TgaHeader {
	idlength: i8,
	colormaptype: i8,
        // ...
	width: i16,
	height: i16,
	bitsperpixel: i8,
	imagedescriptor: i8,
}
// ...
        let mut file = File::open(&path).unwrap();
        let mut header_bytes: [u8; HEADERSIZE] = [0; HEADERSIZE];
        file.read(&mut header_bytes);
        let header = unsafe { mem::transmute::<[u8; HEADERSIZE], TgaHeader>(header_bytes) };

Директива препроцессора перед структурой задает ее способ хранения. Обычно поля структуры в Rust (и в C++, кстати, тоже) выравниваются в соответствии с архитектурой. Например, на моем компьютере структура, содержащая i8 и i16 заняла бы 4 байта, а не 3. Потому-что i8 был бы выровнен, чтобы занимать одну двухбайтовую ячейку. В C++ это работает аналогично. Это подробно описал k06a в своей статье на эту тему. Чтобы данные в структуре следовали байт за байтом, без пропусков, мы и задали #[repr(C, packed)] . Таким образом наша структура теперь размещается в памяти так, как они размещались в суровые стародавние времена, когда был изобретен TGA. Кроме того здесь у нас используется unsafe-код. Оно и понятно, трактовка в участка в памяти как некоей структуры без всяких проверок полностью ломает идею статической типизации. К счастью чаще всего этот код будет работать. (но не всегда, есть еще там всякие нюансы с порядком байтов) И еще вы, конечно, заметили, что я задаю размер буфера константой. А как же sizeof, спросите вы? Ну он в Rust есть, но не рассчитывается как константное выражение, а считается в runtime. Размер массива же должен быть известен еще на стадии компиляции. Вот такие пироги.

Дальше самое интересное. Когда я попытался научиться читать простые TGA-файлы типа RGBA (4 байта на пиксель) без RLE-сжатия, происходила какая-то мистика. Простое вобщем-то изображение моя программа обрабатывала, выдавая вот такую кашу:



Вот снимок кода на данном этапе. Кому интересно, можете попробовать найти в нем ошибку самостоятельно до того, как продолжить дальше чтение статьи. Она где-то в функции, осуществляющей чтение файла:

    fn read(path: &str) -> TgaCanvas{
        let path = Path::new(path);
        let mut file = BufReader::new(File::open(&path).unwrap());
        let mut header_bytes: [u8; HEADERSIZE] = [0; HEADERSIZE];
        file.read(&mut header_bytes);
        let header = unsafe { mem::transmute::<[u8; HEADERSIZE], TgaHeader>(header_bytes) };
        let xsize = header.width as usize;
        let ysize = header.height as usize;
        debug!("read header: width = {}, height = {}", xsize, ysize);
        let bytespp = header.bitsperpixel>>3;
        debug!("bytes per pixel - {}", bytespp);
        let mut canvas = vec![vec![0;ysize];xsize];
        for iy in 0..ysize{
            for ix in 0..xsize{
                if bytespp == 1 {
                    let mut bytes: [u8; 1] = [0; 1];
                    file.read(&mut bytes);
                    let intensity = bytes[0] as u32;
                    canvas[ix][iy] = intensity + intensity*256 + intensity*256*256;
                } else if bytespp == 3 {
                    let mut bytes: [u8; 3] = [0; 3];
                    file.read(&mut bytes);
                    canvas[ix][iy] = bytes[2] as u32 + bytes[1] as u32*256 + bytes[0] as u32*256*256;
                } else if bytespp == 4 {
                    let mut bytes: [u8; 4] = [0; 4];
                    file.read(&mut bytes);
                    if ix == 0 { debug!("{} {} {} {}", bytes[0], bytes[1], bytes[2], bytes[3]); }
                    canvas[ix][iy] = bytes[2] as u32 + ((bytes[1] as u32) << (8*1)) + ((bytes[0] as u32) << (8*2));
                    //debug!("{}", canvas[ix][iy]);
                }
            }
            debug!("{}", canvas[0][iy]);
        }
        TgaCanvas {
            canvas: canvas,
            zbuffer: vec![vec![std::i32::MIN; ysize]; xsize],
            xsize: xsize,
            ysize: ysize,
        }
    }

Что меня особенно сбивало с толку, так это то, что если заменить BufReader::new(File::open(&path).unwrap()); на просто File::open(&path).unwrap(); , то баг не проявлялся. Я даже подумал, что это баг в BufReader, ведь по идее он должен только обеспечивать буферизацию, никак не вмешиваясь в поток байтов.

В чем же была ошибка
Оказывается это немного неожиданное поведение функции read в стандартной библиотеке. read() не гарантирует, что будет прочитано именно buffer.len() байтов, хотя часто это именно так. Но не всегда. В BufReader заканчивается буфер и он возвращает столько байт, сколько в нем осталось и, между тем, начинает в фоне снова заполнять буфер. Если я правильно уловил суть. В итоге, в какой-то момент, я пропускал несколько байтов и дальше изображение получалось поврежденное. Такое поведение со стороны read() документировано, но, на мой взгляд, нарушает принцип наименьшего удивления. Хотя может только я не читаю всей документации к используемым методам…

Далее вопрос с TGA-файлами был закрыт быстро. Код с финальной версией TgaCanvas как всегда можно увидеть в снимке репозитория.

Текстуры


Беда пришла откуда не ждали. Оказывается в Rust просто нельзя сделать конкатенацию строк и вернуть результат как str (это примитивный тип — строка). У str просто нет метода конкатенации. Он есть у String, но преобразовать String потом в str нельзя. UPDATE: Все, что под спойлером — неправда. Сохранено здесь просто для истории. Спасибо Googolplex, что объяснил мне мою ошибку в своем комментарии.
Старый текст, который я написал не разобравшись
Соответствующий метод as_str() — unstable (нестабильный). Что означает, что использовать его в стабильных релизах Rust нельзя. Собственно есть разумное решение — передавать имена файлов как String, а не str. Что-то вроде new(file_path: &String) -> Model , однако проблема здесь в том, что тогда придется во всех вызовах писать не Model::new("african_head.obj"); , а Model::new("african_head.obj".to_string()); . Я обиделся на язык за это извращение и решил, в свою очередь, тоже извратиться. Вот какой у меня вышел код для конкатенации:

let texture_path_string = file_path.rsplitn(2, '.').last().unwrap().to_string() + "_diffuse.tga";
let texture_path_str = texture_path_string.split("*").next().unwrap();

Сначала собственно конкатенация с использованием методов класса String, а потом идет преобразование String в str. Как? Оказывается метод split() у String возвращает итератор на коллекцию str. Вот такая вот ерунда. Нормальным образом str почему-то из String получить нельзя, но если сильно извратиться… Вобщем язык еще сыроват, раз приходится применять такой жуткий хак, чтобы просто преобразовать String в str. Объявляется конкурс на самый симпатичный хак для преобразования String в str. Пишите свои варианты в комментах. (примечание: использовать unstable feature или unsafe-код нельзя).

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

Послесловие


Думаю это последняя статья цикла. Изначально я ставил перед собой 2 цели: изучить Rust и разобраться, как работает современная 3D-графика. C 3D-графикой я еще не закончил, но Rust уже не является для меня тем незнакомым языком, которым был около полутора месяцев назад. Большая часть недавних «открытий», касающихся языка — это просто мелкие нюансы. Ничего фундаментально нового для меня я не обнаруживал уже несколько недель. Так что по сути мне уже и не о чем писать в статьях. Я планирую еще дописать для своего рендерера перспективные искажения, движения камеры, может быть, тонировку Гуро и нарисовать ту машинку из 1-й статьи, поэтому интересующиеся все равно могут продолжать следить за моим прогрессом в соответствующем репозитории. Спасибо всем, кто читает. И особенное спасибо тем, кто дает полезные комментарии.
Tags:
Hubs:
+22
Comments 18
Comments Comments 18

Articles