Как я написал библиотеку для игр на Rust, а затем — 3D‑движок для неё

Привет! Это моя первая статья на Хабре. Хочу рассказать, как я пришёл к созданию собственной библиотеки для игр на 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.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.