Создание окна
Что уже должно быть понятно:
- базовое владение Rust
Что появится в этой главе:
- библиотека winit
- окно, event loop, обработчики событий
Итог: пустое окно, корректно закрывающееся по Esc или системной кнопке
Первое, что нам необходимо сделать, прежде чем погружаться в чудесный мир графики — создать окно приложения, в которое мы будем выводить нашу картинку.
Примечание
Мы можем использовать графическое API и без окна, рендеря в текстуру и сохраняя ее на диск как изображение. Пример подобного подхода есть в репозитории wgpu.
Сложность заключается в том, что у каждой операционной системы для этого надо использовать отдельное API, а у некоторых даже более одного (например, Linux, где у каждого оконного сервера оно своё). Вдобавок, нам будет необходимо считывать пользовательский ввод, такой как движения мыши, нажатия клавиш, а также касания экрана (в случае мобильного приложения), что несёт с собой аналогичные проблемы.
К счастью, с этой проблемой уже многие сталкивались, и существуют кроссплатформенные решения. В экосистеме Rust для этого есть как привязки к популярным библиотекам C++, таким как GLFW и SDL, так и собственное, написанное исключительно на Rust решение под названием winit.
Мы будем использовать winit, поскольку это одновременно и упростит нам настройку и портирование благодаря отсутствию кода на C++ в зависимостях, и приведет к лучшей совместимости с экосистемой ввиду его подавляющей распространенности. Все популярные проекты на Rust, так или иначе связанные с графикой, в первую очередь поддерживают winit, вне зависимости от используемого графического API. Также дизайн API winit упрощает написание кроссплатформенного кода, поддерживающего как десктопные, так и мобильные платформы, с нюансами их работы вроде реконфигураций.
Использование именно winit необязательно для освоения данного руководства — можно использовать любую библиотеку или работать без неё. wgpu поддерживает не только winit, но и библиотеку raw-window-handle от его авторов, с которой совместимы все используемые оконные решения в Rust экосистеме.
Зависимости проекта
Для начала обойдемся простым списком библиотек. Ниже приведена секция зависимостей нашего Cargo.toml:
winit = "0.30"
tracing = "0.1"
tracing-subscriber = "0.3"Кроме уже упомянутого winit, мы подключаем tracing и tracing-subscriber. Они не только позволяют нам выводить логи и снимать метрики приложения, но и включают вывод отладочной информации и ошибок зависимостей, включая саму wgpu.
Обратите внимание
Если не настроить tracing или какой-либо его аналог, например env-logger, то при критической ошибке внутри wgpu мы не увидим подробных сообщений в логах, лишь краткое описание паники.
Наконец-то код
Для использования winit, мы должны определить свой тип обработчика событий приложения (перечисление или структуру), а затем реализовать для него трейт ApplicationHandler, содержащий необходимые методы жизненного цикла.
Это позволяет удобно смоделировать состояние приложения через перечисление:
enum App {
Loading,
Ready { window: Arc<Window> },
}Здесь каждый вариант соотносится с текущим состоянием приложения, то есть в Ready мы будем вкладывать необходимые для работы ресурсы. Весь жизненный цикл выглядит так:
На десктопных платформах resumed() вызывается ровно один раз при запуске. Затем приложение переходит в состояние Ready и обрабатывает события окна в цикле, пока не будет вызван event_loop.exit().
Трейт ApplicationHandler имеет следующие обязательные для реализации методы, наравне с множеством опциональных:
trait ApplicationHandler {
fn resumed(&mut self, event_loop: &ActiveEventLoop);
fn window_event(&mut self, event_loop: &ActiveEventLoop, window_id: WindowId, event: WindowEvent);
}Здесь resumed отвечает за инициализацию нашего приложения. Название именно такое, поскольку на мобильных платформах операционная система может множество раз приостанавливать и возобновлять приложение в ходе его работы, требуя от нас повторной инициализации ресурсов. На десктопных же платформах этот метод отвечает за событие запуска приложения.
Метод window_event является нашим основным обработчиком, в котором мы будем реагировать на события, переданные операционной системой или winit в окна нашего приложения.
Все методы жизненного цикла принимают ссылку на event loop, позволяющий управлять приложением. Он представляет собой объект цикла обработки событий, исполняемого внутри winit. Он же далее используется при запуске приложения для старта данного цикла.
Реализация выглядит стандартно:
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
// ...
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: WindowId,
event: WindowEvent,
) {
// ...
}
}Итак, приступим к заполнению обработчиков:
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if let Self::Loading = self {
let window_attributes = WindowAttributes::default().with_title("WGPU Tutorial");
let window = Arc::new(
event_loop
.create_window(window_attributes)
.expect("Failed to create window"),
);
center_window(&window);
event_loop.set_control_flow(ControlFlow::Wait);
*self = Self::Ready { window }
}
}fn center_window(window: &Window) {
if let Some(monitor) = window.current_monitor() {
let screen_size = monitor.size();
let window_size = window.outer_size();
window.set_outer_position(winit::dpi::PhysicalPosition {
x: screen_size.width.saturating_sub(window_size.width) as f64 / 2.0
+ monitor.position().x as f64,
y: screen_size.height.saturating_sub(window_size.height) as f64 / 2.0
+ monitor.position().y as f64,
});
}
}Здесь мы проверяем, что приложение в данный момент не инициализировано, и приступаем к инициализации:
- Создаем атрибуты окна, такие как его заголовок.
- Создаем само окно через
event loopс указанными атрибутами, и оборачиваем его вArcдля передачи по приложению. - Центрируем окно, получив текущий монитор, выяснив его размеры и изменив позицию окна с их учетом.
- Меняем состояние приложения, показывая таким образом его готовность к работе.
Касательно оптимизаций
Данное руководство сосредоточено на освоении графики с нуля, а не написании максимально оптимизированного графического решения. Поэтому мы будем использовать Arc повсеместно вместо обычных ссылок и явных времен жизни, а также применим другие упрощения, вроде работы в одном потоке там, где можно было бы распараллелить код.
Простота, читаемость и понятность концепций в данном случае важнее, чем оптимизация кода для реального проекта.
Теперь мы можем переходить к реализации главного обработчика событий окна:
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: WindowId,
event: WindowEvent,
) {
let Self::Ready { window, .. } = self else {
return;
};
match event {
WindowEvent::RedrawRequested => {
debug!("Rendering");
window.request_redraw();
}
WindowEvent::Resized(_) => {
debug!("Resized");
window.request_redraw();
}
WindowEvent::CloseRequested => {
event_loop.exit();
}
WindowEvent::KeyboardInput { event, .. } => handle_keyboard_input(event_loop, event),
_ => {}
}
}fn handle_keyboard_input(event_loop: &ActiveEventLoop, event: KeyEvent) {
match (event.physical_key, event.state) {
(PhysicalKey::Code(KeyCode::Escape), ElementState::Pressed) => {
event_loop.exit();
}
_ => {}
}
}Мы исполняем данный код только если приложение уже проинициализировано. В таком случае мы извлекаем необходимые нам данные из перечисления и переходим к обработке событий.
winit предоставляет нам возможность обрабатывать множество самых разнообразных событий, связанных с окнами, устройствами и операционной системой. В дальнейшем мы будем использовать это для обработки нажатий клавиш и движений мыши (аналогично можно обрабатывать касания экрана, жесты тачпада, и другие события). Но для начала обойдемся главными событиями окна:
RedrawRequested- у нас запросили перерисовку содержимого окна. Пока что здесь лишь отладочный вывод, но в будущем именно тут мы будем производить отрисовку нового кадра через графическое API. Также в конце обработчика события мы вручную запрашиваем перерисовку этого же окна, чтобы оно постоянно обновлялось без действий со стороны пользователя (в реальном приложении этот момент нужно оптимизировать, например, избегая перерисовки статичной картинки, или снижая частоту кадров при работе в фоне).Resized- размеры окна были изменены действиями пользователя или операционной системой. В будущем мы будем получать здесь новые размеры, чтобы обновить графический контекст. На данный момент мы снова лишь выводим отладочное сообщение и запрашиваем перерисовку содержимого.CloseRequested- у нас запросили закрытие окна, что равносильно закрытию приложения при единственном окне. Так как мы на данный момент работаем только с одним окном, то просто вызываем выход из цикла winit черезevent loop.KeyboardInput- пользователь нажал кнопку на клавиатуре. В данном случае мы закрываем приложение, если нажата клавиша Esc. Обработка данного события вынесена в отдельную функцию, которую можно удобно дополнять по ходу работы.
Приложение будет выполнять данный цикл обработки событий до тех пор, пока не будет закрыто, рисуя все новые кадры на экране. По этой причине этот цикл еще называют циклом отрисовки (render loop).
Ключевой механизм — самоподдерживающийся цикл перерисовки: внутри RedrawRequested мы вызываем window.request_redraw(), что генерирует новое событие RedrawRequested на следующей итерации. Этот цикл не прекращается, пока приложение работает:
Дополнительная информация
Важно понимать разницу между PhysicalKey и LogicalKey. Первое означает положение клавиши на клавиатуре, вне зависимости от текущей раскладки. Второе же означает именно логическую клавишу на текущей активной раскладке.
Чаще всего нас интересуют именно физические клавиши (например, клавиши WASD удобны для перемещения именно из-за своего положения, а не активной раскладки). Однако, из-за разницы в клавиатурах и их прошивках, в реальной игре мы обязаны дать пользователю переназначать клавиши.
Совет
Рекомендуется в будущем ознакомиться как с другими событиями окна, так и с другими возможными методами обработки событий жизненного цикла приложения. Там есть множество удобных и важных вещей, например обработка предупреждений от операционной системы о слишком большом потреблении оперативной памяти, что критически важно делать на мобильных устройствах во избежание принудительного закрытия приложения.
Наконец, связываем всё это воедино в main:
fn main() {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
let event_loop = EventLoop::new().expect("Failed to create event loop");
let mut app = App::Loading;
event_loop
.run_app(&mut app)
.expect("Failed to run event loop");
}Сначала мы инициализируем tracing для вывода логов, затем создаем event loop, создаем объект нашего приложения в неинициализированном состоянии, и передаем его в event loop для исполнения.
winit сначала вызовет наш обработчик события resumed для инициализации приложения, а затем будет в цикле вызывать window_event для обработки событий окна, которые мы сами же и создаем внутри данного обработчика.
Типичные ошибки
Отсутствие tracing
Без tracing_subscriber::fmt().init() ошибки внутри wgpu не будут видны — при панике вы получите лишь краткое сообщение без подробностей. Всегда настраивайте логирование в main до создания event loop.
Нет обработки CloseRequested
Если забыть обработать WindowEvent::CloseRequested, окно не закроется при нажатии на системную кнопку — приложение продолжит работать. Также не забудьте event_loop.exit() для завершения цикла.
Использование ControlFlow::Poll
ControlFlow::Poll заставляет event loop крутиться без остановки, загружая CPU на 100%. Для графических приложений обычно достаточно ControlFlow::Wait — event loop засыпает до следующего события, а перерисовку мы запрашиваем вручную через request_redraw().
Попробуйте сами
Упражнение 1
Измените заголовок окна через .with_title("Мой заголовок") и задайте начальный размер через .with_inner_size(winit::dpi::LogicalSize::new(1024, 768)).
Упражнение 2
Добавьте обработку клавиши F11 для переключения полноэкранного режима: window.set_fullscreen(Some(Fullscreen::Borderless(None))) и обратно — window.set_fullscreen(None).
Упражнение 3
Выведите в лог размер окна при каждом событии Resized через info!("Window resized: {:?}", window.inner_size()) — убедитесь, что событие приходит при перетаскивании рамки.
Результаты работы
В результате выполнения данного кода мы должны увидеть в центре экрана пустое окно. Содержимое и внешний вид будут различаться в зависимости от операционной системы, ниже приведен скриншот с Windows 11:

Окно корректно закрывается по нажатию на системную кнопку в рамке или клавишу Esc, а также позволяет менять его размер.
Дополнительная информация
Если сейчас собрать наше приложение и запустить из файла вместо IDE, то на системах семейства Windows одновременно с окном приложения откроется еще отдельное окно консоли. Это происходит потому, что по умолчанию Rust предполагает, что мы делаем консольное приложение, следовательно, инструктирует систему открыть нам консоль при запуске.
Для предотвращения подобного поведения в релизной сборке, нужно добавить следующую строчку в самый верх файла, содержащего нашу main функцию:
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]Это скажет Rust, что мы делаем графическое приложение и не нуждаемся в консоли для его работы, но только в релизной сборке, так как во время разработки нам может понадобиться отладочный вывод.