Привет! Это моя первая статья на Хабре. Хочу рассказать, как я пришёл к созданию собственной библиотеки для игр на Rust, а затем написал 3D‑движок для неё. Библиотека называется FRGLib, а её исходники можно посмотреть на GitHub.

Всё началось с игрового движка

О собственном игровом движке — пусть даже без графического редактора — я мечтал лет с десяти. Но между мной и этой мечтой постоянно возникала стена: сложные API, в которых я тогда ещё не умел разбираться. Осенью 2025 года я наконец начал изучать OpenGL, а уже к февралю 2026-го написал простую копию Flappy Bird и выложил исходники на GitHub. В этой статье будет ещё много моего open‑source софта — предупреждаю сразу.

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

Довольно быстро выяснилось, что Vulkan — это пока слишком низкий уровень для меня. Вместо создания игры приходилось бы разбираться с большим количеством деталей, поэтому идею написать на нём свой движок я отложил. И тут, где‑то в марте 2026 года, я спокойно листаю YouTube и нахожу видео: какой‑то парень рассказывает про wgpu и показывает код для вывода треугольника. Для меня это было настоящим открытием: по первому впечатлению, wgpu выглядел сложнее OpenGL, но гораздо доступнее Vulkan. Главное — я чувствовал, что смогу в нём разобраться.

К началу мая 2026 года у меня уже был готов вот такой проект. Его задача была простой: загружать OBJ‑модели и отображать их с текстурами.

Казалось бы, обычный просмотрщик моделей. Но затем примерно за пять часов я переделал движок под 2D — без вайбкодинга — и даже сделал на нём игру. Так появилась CYS, мой первый серьёзный проект. Игру я выложил на GitHub, в RuStore и на Game Jolt.

Всё лето я изучал разные библиотеки для Rust и успел сделать ещё пару интересных проектов. Но главным для этой истории стало знакомство с SDL2. Я посмотрел целый курс по SDL2 — огромное спасибо его автору!

Хотя примеры были на C++, это не помешало мне разобраться в материале. Постепенно захотелось взяться за второй проект, сопоставимый по масштабу с CYS. На этот раз — не за игру, а за библиотеку для их создания. На эту идею меня во многом вдохновила Pygame, с которой я раньше провёл немало времени. Мне захотелось сделать свой инструмент для разработки игр, но уже на Rust. Так появилась FRGLib — моя библиотека, использующая под капотом SDL2.

Эпоха 2 д для frglib

Историю развития FRGLib я разделю на два этапа. На первом я закладывал основы: создание окна, отрисовку простых фигур и другие базовые возможности. За графику тогда отвечала исключительно SDL. Второй этап — масштабная переработка библиотеки: роль SDL меняется, на первый план выходит wgpu, появляются инструменты для работы с 3D и шейдерами. Но об этом чуть позже.

При этом в FRGLib по‑прежнему можно выбрать один из двух вариантов: создать окно через SDL и использовать её же для отрисовки либо оставить SDL только создание окна, а графику доверить wgpu. Знаю, звучит немного запутанно, но не спешите закрывать статью — сейчас всё объясню. Если вы делаете 2D‑игру, можно начать с отрисовки средствами SDL. Чтобы создать окно и нарисовать в нём простой красный квадрат, достаточно такого кода:

use frglib::{Color, Event, FrameClock, Object, Window};

fn main() -> Result<(), frglib::Error> {
    let mut window = Window::builder("FRGLib draw")
        .size(800, 600)
        .background(Color::FON)
        .build()?;

    let margin = 60.0;

    let mut clock = FrameClock::new(60.0);

    loop {
        for event in window.poll()? {
            match event {
                Event::Quit | Event::CloseRequested => return Ok(()),
                Event::KeyDown { key, .. } => println!("key {key:?}"),
                _ => {}
            }
        }

        let (w, h) = window.size();
        let (w, h) = (w as f32, h as f32);

        let avail_w = (w - margin * 2.0).max(1.0);
        let avail_h = (h - margin * 2.0).max(1.0);
        let (mut side, mut other) = (avail_w, avail_w);
        if other > avail_h {
            other = avail_h;
            side = avail_h;
        }

        // Объект задаёт центр, поэтому вписываем квадрат по центру окна, а не по
        // левому верхнему углу -- иначе при ресайзе он уезжал бы за край.
        let square = Object::from_xywh(w / 2.0, h / 2.0, side, other);

        window.frame_with(|d| {
            // Непрозрачный квадрат по центру окна.
            d.fill_object(&square, Color::rgb(255, 0, 0))?;
            Ok(())
        })?;

        clock.tick();
    }
}

Если сейчас вы смотрите на этот код как фронтендер на ассемблер — спокойно, сейчас разберём всё по порядку. Первая строка — это импорт основных компонентов FRGLib:

  • Color — для задания цветов.

  • Event — для обработки событий.

  • FrameClock — для управления временем кадров.

  • Object — для описания положения и размеров объекта.

  • Window — для создания окна и работы с ним.

Дальше объявляем божественную функцию main — точку входа в нашу программу. В этом примере она возвращает Result<(), frglib::Error>, чтобы мы могли передавать ошибки наружу через ?, а не разбирать каждую вручную. Затем создаём окно и сохраняем его в переменную window. Настройки задаём цепочкой вызовов — выглядит немного необычно, но читается довольно просто:

let mut window = Window::builder("FRGLib draw")
    .size(800, 600)
    .background(Color::FON)
    .build()?;

Буквально говорим библиотеке: «Хочу окно с названием FRGLib draw, размером 800 × 600 и фоном Color::FON. Всё настроили? Теперь создавай!» Дальше важный момент: переменные margin, w, h, avail_w, avail_h, side и other нужны лишь для того, чтобы квадрат подстраивался под размер окна и оставался по центру. Если такая роскошь вам не нужна, можно просто задать фиксированные размеры и положение:

let square = Object::from_xywh(100.0, 100.0, 50.0, 50.0);

Получится квадрат размером 50 × 50 с центром в точке (100, 100). С геометрией разобрались, но про события чуть не забыли — вернёмся к началу цикла:

for event in window.poll()? {
    match event {
        Event::Quit | Event::CloseRequested => return Ok(()),
        Event::KeyDown { key, .. } => println!("key {key:?}"),
        _ => {}
    }
}

Здесь мы получаем события через window.poll() и по очереди разбираем их с помощью match:

  • Event::Quit или Event::CloseRequested — завершаем программу через return Ok(()).

  • Event::KeyDown { key, .. } — выводим нажатую клавишу в консоль. Остальные поля события нам пока не нужны, поэтому пропускаем их с помощью ...

  • _ => {} — все остальные события игнорируем.

После обработки событий переходим к отрисовке. Внутри frame_with задаём команды рисования для текущего кадра. Здесь команда всего одна: fill_object рисует наш квадрат и заливает его красным цветом.

window.frame_with(|d| {
    // Рисуем непрозрачный красный квадрат.
    d.fill_object(&square, Color::rgb(255, 0, 0))?;
    Ok(())
})?;

В конце каждой итерации вызываем clock.tick(). На этом с базовым примером разобрались: создали окно, обработали события и нарисовали квадрат. Заодно познакомились с основами работы библиотеки — вот что получилось на экране.

Двигаемся дальше: теперь давайте натянем на наш объект текстуру. Большая часть кода уже знакома, поэтому разберём только то, что изменилось. Первое нововведение — функция asset:

fn asset(name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("Базовый/путь/для/всех/текстур")
        .join(name)
}

Она собирает путь к файлу: берёт каталог проекта, добавляет путь к папке с текстурами, а затем — имя нужного файла. Так нам не придётся каждый раз прописывать полный путь вручную. Строку "Базовый/путь/для/всех/текстур" здесь нужно заменить на путь к своей папке относительно каталога проекта.

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

let texture = Image::from_file(asset("текстурка.png"))?;

let (lw, lh) = texture.size();
let ratio = lw as f32 / lh as f32;

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

let sprite = Object::from_xywh(
    x + lw / 2.0,
    y + lh / 2.0,
    lw,
    lh,
);

Здесь x и y — координаты левого верхнего угла изображения. Но Object::from_xywh принимает координаты центра, поэтому прибавляем половину ширины и высоты. И вот важный момент: текстуру мы в объект не «запихиваем». Объект задаёт положение и размеры, а изображение передаётся отдельно, уже при отрисовке:

d.fill_textured_object(&sprite, &texture)?;

То есть буквально говорим: «Нарисуй вот этот объект, используя вот эту текстуру». Советую запомнить это разделение, чтобы дальше не запутаться: объект отвечает за то, где и какого размера рисовать, а текстура — за то, какое изображение использовать. В результате получаем вот такую картинку.

На этом возможности 2D‑графики не заканчиваются. Другие примеры можно посмотреть в репозитории FRGLib на GitHub, в папке examples/basic.

Матеша

Прежде чем переходить к 3D, давайте немного затронем математику. Для удобства в FRGLib есть готовые инструменты для работы с векторами, матрицами и AABB. Так что писать всё с нуля не придётся — часть математической рутины библиотека берёт на себя. Например, вот так можно объединить перенос и поворот с помощью матриц:

let mat_3 = Mat3::translation(center)
    * Mat3::rotation(45.0);

А вот пример с синусом и косинусом:

let x = center.x + cos(time * 2.0) * radius;
let y = center.y + sin(time * 2.0) * radius;

Другие примеры можно посмотреть в репозитории FRGLib на GitHub, в папке examples/math.

Наконец‑то 3 д

Теперь давайте перейдём к 3D. Как и в случае с 2D, начнём с базовой отрисовки: создадим окно, добавим сетку и куб, а камеру пустим по орбите вокруг сцены. В отличие от предыдущих примеров, здесь используем WgpuWindow и Renderer3D — переходим к отрисовке через wgpu.

use frglib::gpu::glam::{Mat4, Vec3};
use frglib::gpu::{Camera3D, Renderer3D, WgpuWindow};
use frglib::{Color, Event, FrameClock, Keycode};

fn main() -> Result<(), frglib::gpu::Error> {
    let mut window = WgpuWindow::builder("FRGLib wgpu")
        .size(900, 600)
        .background(Color::FON)
        .build()?;

    let info = window.gpu().adapter_info();
    println!("{} ({:?} {})", info.name, info.backend, info.driver);

    let mut renderer = Renderer3D::new(window.gpu());
    let mut clock = FrameClock::new(60.0);
    let mut time = 0.0f32;
    let yaw = 0.7f32;
    let pitch = 0.35f32;

    loop {
        for event in window.poll()? {
            match event {
                Event::Quit | Event::CloseRequested => return Ok(()),
                Event::KeyDown {
                    key: Keycode::Escape,
                    ..
                } => return Ok(()),
                _ => {}
            }
        }

        time += clock.dt();

        let Some(mut frame) = window.begin_frame()? else {
            // Свёрнутое окно или поверхность не готова: кадр пропускается.
            clock.tick();
            continue;
        };

        let camera = Camera3D::orbit(
            Vec3::new(0.0, 0.7, 0.0),
            6.0,
            yaw + time * 0.5,
            pitch,
        );

        renderer.begin(&camera);
        renderer.draw_grid(20, 1.0, Color::rgb(70, 82, 94));

        let cube = Mat4::from_translation(Vec3::new(0.0, 1.0, 0.0))
            * Mat4::from_rotation_y(15.0)
            * Mat4::from_scale(Vec3::splat(1.5));
        renderer.draw_cube_model(cube, Color::rgb(146, 197, 141));

        renderer.finish(&mut frame);
        window.present(frame)?;

        clock.tick();
    }
}

Кода стало больше, но структура уже знакома: создаём окно, обрабатываем события, рисуем кадр и вызываем clock.tick(). Разберём новые части. Сначала создаём окно: настройки задаются всё той же цепочкой вызовов, только теперь вместо Window используем WgpuWindow.

let mut window = WgpuWindow::builder("FRGLib wgpu")
    .size(900, 600)
    .background(Color::FON)
    .build()?;

Следом выводим информацию о графическом адаптере. Для самого примера это необязательно, но полезно посмотреть, какой адаптер, бэкенд и драйвер используются:

let info = window.gpu().adapter_info();
println!("{} ({:?} {})", info.name, info.backend, info.driver);

Дальше создаём рендерер и задаём начальные параметры. renderer будет отвечать за рисование сцены, переменная time нужна для движения камеры, а yaw и pitch задают её начальные углы.

let mut renderer = Renderer3D::new(window.gpu());
let mut clock = FrameClock::new(60.0);
let mut time = 0.0f32;
let yaw = 0.7f32;
let pitch = 0.35f32;

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

time += clock.dt();

let Some(mut frame) = window.begin_frame()? else {
    // Свёрнутое окно или поверхность не готова: кадр пропускается.
    clock.tick();
    continue;
};

Теперь создаём камеру. Здесь задаём точку, вокруг которой она движется, расстояние до неё и углы. Благодаря time * 0.5 горизонтальный угол постепенно меняется — камера облетает сцену.

let camera = Camera3D::orbit(
    Vec3::new(0.0, 0.7, 0.0),
    6.0,
    yaw + time * 0.5,
    pitch,
);

Передаём камеру рендереру и рисуем сетку. После этого можно добавлять объекты сцены — в нашем случае это будет куб.

let camera = Camera3D::orbit(
    Vec3::new(0.0, 0.7, 0.0),
    6.0,
    yaw + time * 0.5,
    pitch,
);

Положение, поворот и масштаб куба задаём матрицей. Вот и пригодилась математика из предыдущего раздела: вместо отдельных параметров передаём готовое преобразование объекта.

let cube = Mat4::from_translation(Vec3::new(0.0, 1.0, 0.0))
    * Mat4::from_rotation_y(15.0)
    * Mat4::from_scale(Vec3::splat(1.5));

renderer.draw_cube_model(cube, Color::rgb(146, 197, 141));

В конце завершаем работу рендерера, показываем кадр и вызываем clock.tick(). На этом текущая итерация заканчивается, и цикл начинается заново.

renderer.finish(&mut frame);
window.present(frame)?;

clock.tick();

В результате получаем простую 3D‑сцену: куб над сеткой и камеру, которая движется вокруг него. Уже не просто красный квадрат, но до собственного игрового мира ещё есть куда расти. Вот как это выглядит.

Конечно, одним кубом всё не ограничивается: можно добавить скайбокс и загрузить свою модель в формате OBJ или GLB. Примеры всего этого есть в репозитории FRGLib на GitHub — пробуйте, экспериментируйте и собирайте свои сцены.

Что дальше?

На этом пока всё! Буду рад вашим идеям по улучшению библиотеки, сообщениям об ошибках и, конечно, пул‑реквестам. Если попробуете FRGLib в своём проекте — обязательно покажите, что получилось. А если библиотека вам понравится, поддержите её звездой на GitHub.

Спасибо, что дочитали. Всем пока!