Привет! Это моя первая статья на Хабре. Хочу рассказать, как я пришёл к созданию собственной библиотеки для игр на 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 { 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, чтобы мы могли передавать ошибки наружу через ?, а не разбирать каждую вручную. Затем создаём окно и сохраняем его в переменную 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 { 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.
Спасибо, что дочитали. Всем пока!
Источник: habr.com