Инициализация wgpu
Что уже должно быть понятно:
- окно, event loop,
ApplicationHandler
Что появится в этой главе:
Instance,Adapter,Device,Queue,SurfaceSurfaceConfigurationRenderPass,CommandEncoder,Queue::submit- обработка ошибок
get_current_texture
Итог: окно, залитое зелёным цветом
В прошлой главе мы создали окно и базовую структуру приложения с winit. Теперь инициализируем wgpu и зальём окно сплошным цветом — это наш первый кадр, отрисованный на GPU.
Как вообще работает wgpu
Видеокарта — отдельное устройство со своим процессором и памятью. Она работает параллельно с CPU, и данные для неё нужно явно копировать из оперативной памяти в видеопамять. Поэтому работа с GPU выглядит не как вызов функций, а как сбор и отправка команд, которые выполнятся когда-то потом.
Схема одного кадра:
Ключевой момент: на нативных платформах queue.submit не блокирует CPU. Мы можем готовить данные для следующего кадра, пока GPU рисует текущий. Это отличие от стандарта WebGPU, где submit блокирует поток.
Авторы WebGPU сознательно отказались от ручной синхронизации (как в Vulkan/DX12) — она сложна и приводит к ошибкам. Вместо этого wgpu на нативных платформах идёт путём Metal 3: автоматическая синхронизация с опциональными оптимизациями. На этом подходе работают Baldur's Gate 3, Death Stranding, Cyberpunk 2077 — производительности хватает для любых задач.
Подробнее о синхронизации
В старых API (OpenGL, DirectX 11) отправка команд блокировала CPU — просто и предсказуемо, но медленно. Vulkan, DirectX 12 и Metal 4 требуют ручной синхронизации — мощно, но сложно. WebGPU выбрал блокировку (проще для браузера). wgpu на нативных платформах — автоматическая синхронизация без блокировки, как Metal 3.
Ключевые сущности
Вот главные объекты wgpu:
- Instance — точка входа в wgpu
- Adapter — конкретная видеокарта (физическая или логическая)
- Device — логическое устройство, управляющее ресурсами GPU. Через него создаются буферы, текстуры, конвейеры
- Queue — очередь команд. Через неё отправляем записанные команды на выполнение
- Surface — поверхность, привязанная к окну. Именно на неё мы рисуем
Instance и Adapter хранить не нужно — они используются только при инициализации.
Сущности следующих глав
Ресурсы сцены (создаются один раз при загрузке):
ShaderModule— код шейдера (программа для GPU)Buffer— буфер данных (вершины, uniform-переменные)Texture— изображение в видеопамятиSampler— настройка чтения из текстурыRenderPipeline— графический конвейер (описывает состояние GPU при отрисовке)ComputePipeline— конвейер вычислений
Сущности кадра (создаются и уничтожаются каждый кадр):
SurfaceTexture— текстура, в которую рисуем текущий кадрTextureView— «ссылка» на текстуру, понятная GPUCommandEncoder— записывает команды для GPURenderPass— операция рендера внутри CommandEncoderCommandBuffer— готовый список команд (результат CommandEncoder)
События (реакция на изменения):
- Изменение размера окна → переконфигурация Surface
- Потеря поверхности → восстановление
- Изменение настроек → пересоздание конвейеров
Очистка — не нужна. Все ресурсы wgpu — это Arc внутри, Rust автоматически очистит при выходе из области видимости.
Переходим к коду
Зависимости
winit = "0.30"
tracing = "0.1"
tracing-subscriber = "0.3"
pollster = "0.4"
wgpu = "29.0"wgpu требует resolver 2 в Cargo.toml. Для edition 2021+ он стоит по умолчанию.
pollster — минималистичный async-рантайм. Его функция block_on выполняет future в текущем потоке — ровно то, что нужно для вызовов request_adapter и request_device. Никакого runtime, потоков и зависимостей.
Структура Renderer
Вынесем работу с GPU в отдельную структуру:
struct Renderer {
device: Device,
queue: Queue,
surface: Surface<'static>,
surface_config: SurfaceConfiguration,
}SurfaceConfiguration описывает параметры поверхности — размер и формат изображения. С её помощью мы реагируем на изменение размера окна. Формат пикселей доступен как surface_config.format — он нужен при создании конвейеров.
Обновим и состояние приложения:
enum App {
Loading,
Ready {
window: Arc<Window>,
renderer: Box<Renderer>,
need_to_resize_surface: bool,
},
}Renderer в Box, чтобы варианты перечисления не сильно различались по размеру — на это есть проверка в Clippy.
Методы Renderer:
impl Renderer {
fn new(window: Arc<Window>) -> Self;
fn resize_surface(&mut self, size: PhysicalSize<u32>);
fn render(&mut self, window: Arc<Window>);
}new— инициализация GPU (Instance, Adapter, Device, Queue, Surface)resize_surface— реакция на изменение размера окнаrender— отрисовка кадра. ПринимаетArc<Window>для вызоваpre_present_notify, нужного на Wayland
Метод new
fn new(window: Arc<Window>) -> Self {
let mut physical_size = window.inner_size();
physical_size.width = physical_size.width.max(1);
physical_size.height = physical_size.height.max(1);Получаем текущий размер окна и убеждаемся, что он не нулевой — нулевой размер вызовет ошибку при настройке поверхности.
Instance
let instance = Instance::new(InstanceDescriptor {
backends: Backends::PRIMARY,
..InstanceDescriptor::new_without_display_handle()
});Типичный паттерн wgpu: конструкторы принимают дескрипторы (struct с параметрами), обычно реализующие Default. Здесь мы задаём только backends: PRIMARY — это Metal (macOS), Vulkan (Linux/Windows/Android) и DirectX 12 (Windows), бэкенды с полными возможностями GPU. Backends::all() добавит ещё OpenGL/WebGL — запасной вариант для старых систем.
new_without_display_handle() создаёт дескриптор без привязки к дисплею — нам не нужно, потому что surface привязывается отдельно через create_surface.
Surface
let surface = instance
.create_surface(window)
.expect("Failed to create surface");Привязываем поверхность к нашему окну winit.
Двойная буферизация
Видеокарта рисует попиксельно. Если рендерить прямо в ту текстуру, что сейчас на экране, пользователь увидит процесс рисования — разорванный кадр. Поэтому используется минимум две текстуры: одна на экране, вторая — скрытая. Мы рисуем в скрытую, а когда кадр готов — меняем их местами. Отсюда термин «swapchain» — в старых API он был отдельным объектом, в wgpu скрыт внутри Surface.
Когда мы вызываем surface.get_current_texture(), получаем текущую свободную текстуру для отрисовки.
Adapter
let adapter = pollster::block_on(instance.request_adapter(&RequestAdapterOptions {
power_preference: PowerPreference::default(),
force_fallback_adapter: false,
compatible_surface: Some(&surface),
}))
.expect("Failed to request adapter");Запрашиваем адаптер (видеокарту). request_adapter — async-функция (так требует стандарт WebGPU для совместимости с браузерной средой), поэтому вызываем через pollster::block_on.
power_preference— дискретная или интегрированная видеокартаforce_fallback_adapter: false— не использовать программный растеризаторcompatible_surface— адаптер должен поддерживать нашу поверхность
Device и Queue
let (device, queue) = pollster::block_on(adapter.request_device(&DeviceDescriptor {
label: Some("Main device"),
required_features: adapter.features() - wgpu::Features::all_experimental_mask(),
required_limits: Limits::default().using_resolution(adapter.limits()),
memory_hints: MemoryHints::Performance,
trace: Default::default(),
experimental_features: ExperimentalFeatures::disabled(),
}))
.expect("Failed to request device");Device и Queue создаются вместе — это главные объекты для работы с GPU.
label— отладочное имя, появится в логах. У многих сущностей wgpu есть этот параметрrequired_features: adapter.features() - Features::all_experimental_mask()— запрашиваем все фичи, которые поддерживает адаптер, кроме экспериментальных. wgpu — нативная библиотека, и мы хотим использовать возможности GPU по полной. Если бы запрашивалиFeatures::empty(), получили бы только минимальный набор, определённый стандартом WebGPUrequired_limits— ограничения (размер текстур, количество буферов).using_resolutionучитывает разрешение адаптераmemory_hints: Performance— подсказка менеджеру памяти: оптимизировать скорость, а не потреблениеtrace— запись команд в файл для отладки через wgpu-playerexperimental_features: disabled— экспериментальные фичи требуют unsafe, мы их не запрашиваем и не включаем. Вычитаниеall_experimental_maskизrequired_featuresгарантирует, что адаптер не вернёт ошибку, если в его списке фич оказались экспериментальные
SurfaceConfiguration
let surface_capabilities = surface.get_capabilities(&adapter);
let surface_format = surface_capabilities
.formats
.iter()
.copied()
.find(TextureFormat::is_srgb)
.or_else(|| surface_capabilities.formats.first().copied())
.expect("Failed to get surface format");
let surface_config = SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
width: physical_size.width,
height: physical_size.height,
present_mode: PresentMode::AutoVsync,
desired_maximum_frame_latency: 2,
alpha_mode: CompositeAlphaMode::Auto,
view_formats: vec![],
};
surface.configure(&device, &surface_config);
Self {
device,
queue,
surface,
surface_config,
}
}Разберём по шагам:
get_capabilities— спрашиваем адаптер, какие форматы, режимы презентации и альфа-режимы поддерживает поверхность. Каждый монитор и ОС дают свой набор — поэтому нельзя захардкодить форматВыбираем формат: сначала ищем sRGB-формат, а если такого нет — берём первый доступный. На большинстве систем будет
Bgra8UnormSrgbПочему именно sRGB
GPU выполняет все вычисления в линейном цветовом пространстве — это нужно для корректного сложения цветов, освещения и смешивания. Но мониторы и операционная система ожидают изображение в sRGB. Если просто записать линейные значения в поверхность — цвета будут блёклыми или пересвеченными.
sRGB-формат поверхности решает эту проблему: GPU автоматически преобразует линейные значения в sRGB при записи в текстуру поверхности. Нам не нужно делать это вручную в шейдерах.
Если sRGB-формат недоступен (редко, но бывает на некоторых встроенных графических чипах), мы берём первый поддерживаемый формат как фоллбэк — в этом случае цвета будут менее точными, но приложение запустится.
Почему это важно понимать сейчас: в следующих главах мы столкнёмся с текстурами, и у каждой будет свой формат. Текстуры с цветами (diffuse-карты) — sRGB, потому что они создавались в sRGB-пространстве и GPU должен преобразовать их в линейное при чтении. Normal-карты — линейные, потому что хранят не цвет, а направления. HDR-текстуры используют форматы с плавающей точкой (
Rgba16Float), потому что стандартные 8 бит на канал не покрывают широкий диапазон яркости. Путаница в форматах — одна из частых причин «странных» цветов и артефактов.SurfaceConfigurationсобираем вручную:usage: RENDER_ATTACHMENT— мы будем рисовать в эту текстуруpresent_mode: AutoVsync— wgpu сам выберет режим с вертикальной синхронизацией (VSync), если доступенdesired_maximum_frame_latency: 2— сколько кадров может быть в очереди одновременно. Двойная буферизацияalpha_mode: Auto— wgpu сам выберет режим прозрачностиview_formats: vec![]— дополнительные форматы для TextureView, нам пока не нужны
surface.configure— применяем конфигурацию. Поверхность создаёт внутренние текстуры нужного размера и формата
Метод resize_surface
fn resize_surface(&mut self, size: PhysicalSize<u32>) {
let width = size.width.max(1);
let height = size.height.max(1);
self.surface_config.width = width;
self.surface_config.height = height;
self.surface.configure(&self.device, &self.surface_config);
}Защита от нулевых размеров, обновление конфигурации и её применение. Вызывается не сразу при событии Resized, а перед следующим кадром — чтобы не перестраивать поверхность несколько раз подряд при перетаскивании.
Метод render
Это главный метод — отрисовка кадра.
Получение текстуры
use wgpu::CurrentSurfaceTexture::{
Lost, Occluded, Outdated, Suboptimal, Success, Timeout, Validation,
};fn render(&mut self, window: Arc<Window>) {
let frame = match self.surface.get_current_texture() {
Success(frame) => frame,
Suboptimal(frame) => {
warn!("Surface suboptimal, reconfiguring");
self.surface.configure(&self.device, &self.surface_config);
frame
}
Outdated | Lost => {
warn!("Surface lost or outdated, reconfiguring");
self.surface.configure(&self.device, &self.surface_config);
return;
}
Timeout | Occluded => return,
Validation => {
warn!("Surface texture validation error");
return;
}
};get_current_texture возвращает перечисление CurrentSurfaceTexture:
Success— текстура готова, можно рисоватьSuboptimal— текстура готова, но конфигурация поверхности неоптимальна (например, изменилось разрешение или формат). wgpu рекомендует использовать этот кадр и сразу переконфигурировать поверхностьOutdated— поверхность устарела (например, после resize). Переконфигурируем и пропускаем кадрLost— поверхность потеряна. АналогичноTimeout— текстура ещё не готова. Пропускаем кадр, на следующем попытаемся сноваOccluded— окно скрыто или перекрыто. Не тратим GPU-время впустуюValidation— ошибка валидации, скорее всего баг в коде. Логируем и пропускаем
Occluded особенно полезен на мобильных — когда приложение свёрнуто, рисовать впустую не стоит.
Кодирование команд
let mut encoder = self
.device
.create_command_encoder(&CommandEncoderDescriptor {
label: Some("Main command encoder"),
});
let view = frame.texture.create_view(&TextureViewDescriptor::default());CommandEncoder записывает команды для GPU. TextureView — «ссылка» на текстуру, понятная видеокарте.
Render Pass
encoder.begin_render_pass(&RenderPassDescriptor {
label: Some("Clear render pass"),
color_attachments: &[Some(RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: Operations {
load: LoadOp::Clear(Color::GREEN),
store: StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});Render pass — операция рендера. Здесь мы:
color_attachments— указываем, куда рисовать (в нашу текстуру поверхности)LoadOp::Clear(Color::GREEN)— перед отрисовкой заливаем зелёнымStoreOp::Store— сохраняем результат- Остальные параметры (
depth_stencil_attachment,timestamp_writes, ...) — пока не нужны
Пока мы только заливаем экран цветом — других команд в проходе рендера нет. В следующей главе добавим отрисовку геометрии.
Отправка и отображение
self.queue.submit([encoder.finish()]);
window.pre_present_notify();
frame.present();
}queue.submit— отправляем команды на выполнение. Не блокирует CPU на нативных платформах. Если нужно дождаться выполнения —device.pollpre_present_notify— уведомление winit о скором выводе кадра. Критично на Wayland — без этого оконный сервер может заблокировать приложениеframe.present— отправляем кадр в очередь отображения. Тоже не блокирует — кадр выведется, когда GPU его отрисует
Оптимизации
На мобильных и Apple Silicon завершение
RenderPassдорого. Старайтесь переиспользовать проходы, где возможно.Queue::submitзатратна — она отслеживает ресурсы и синхронизирует их под капотом. Но принимает списокCommandBuffer. Лучше собрать буферы из разных частей программы и отправить одним вызовом, чем вызыватьsubmitна каждый буфер отдельно.
Обновление методов жизненного цикла
Метод resumed
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if let Self::Loading = self {
let window_attributes = WindowAttributes::default()
.with_title("WGPU Tutorial")
.with_visible(false);
let window = Arc::new(
event_loop
.create_window(window_attributes)
.expect("Failed to create window"),
);
center_window(window.clone());
event_loop.set_control_flow(ControlFlow::Wait);
let renderer = Renderer::new(window.clone());
*self = Self::Ready {
window,
renderer: Box::new(renderer),
need_to_resize_surface: false,
}
}
let Self::Ready {
window, renderer, ..
} = self
else {
return;
};
renderer.render(window.clone());
window.set_visible(true);
}Создаём Renderer и окно. Окно изначально скрыто — чтобы пользователь не увидел белую заглушку до первого кадра. После отрисовки снимаем скрытие через set_visible(true).
ControlFlow::Wait — event loop засыпает до нового события. Нам это подходит, потому что мы сами запрашиваем перерисовку через window.request_redraw().
Метод window_event
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: WindowId,
event: WindowEvent,
) {
let Self::Ready {
window,
renderer,
need_to_resize_surface,
..
} = self
else {
return;
};
match event {
WindowEvent::RedrawRequested => {
if *need_to_resize_surface {
let size = window.inner_size();
renderer.resize_surface(size);
*need_to_resize_surface = false;
}
renderer.render(window.clone());
window.request_redraw();
}
WindowEvent::Resized(_) => {
*need_to_resize_surface = true;
window.request_redraw();
}
WindowEvent::CloseRequested => {
event_loop.exit();
}
WindowEvent::KeyboardInput { event, .. } => handle_keyboard_input(event_loop, event),
_ => {}
}
}Resize помечается флагом, а обрабатывается перед следующим кадром — чтобы не переконфигурировать поверхность на каждое событие при перетаскивании окна.
Типичные ошибки
Нулевой размер окна
Если передать width = 0 или height = 0 в SurfaceConfiguration, вызов surface.configure завершится ошибкой. Всегда защищайтесь через size.width.max(1).
Потеря surface
События Lost и Outdated от get_current_texture() обязательно нужно обрабатывать — без переконфигурации surface приложение зависнет или упадёт. Не просто логируйте, а вызывайте surface.configure.
Забытый pre_present_notify
На Wayland вызов window.pre_present_notify() перед frame.present() критичен — без него оконный сервер может заблокировать приложение. На других платформах этот вызов безопасен и ничего не делает.
Попробуйте сами
Упражнение 1
Замените Color::GREEN на свой цвет: попробуйте Color::srgb(0.1, 0.2, 0.5) для тёмно-синего или вычислите цвет на основе времени через encase и uniform-буфер.
Упражнение 2
Измените present_mode на PresentMode::AutoNoVsync и сравните плавность анимации. Обратите внимание на разницу в количестве кадров в секунду.
Упражнение 3
Добавьте счётчик кадров: увеличивайте переменную каждый кадр в RedrawRequested и выводите в лог каждую секунду через info!("FPS: {fps}").
Что получилось
В центре экрана — окно, залитое зелёным цветом:

Сам факт зелёного экрана значит, что мы подключили wgpu, получили текстуру поверхности, очистили её и вывели на экран. Изменение размера без артефактов подтверждает, что обработка событий работает корректно.