Skip to content

Инициализация wgpu

Полный код главы

Что уже должно быть понятно:

  • окно, event loop, ApplicationHandler

Что появится в этой главе:

  • Instance, Adapter, Device, Queue, Surface
  • SurfaceConfiguration
  • RenderPass, 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 — «ссылка» на текстуру, понятная GPU
  • CommandEncoder — записывает команды для GPU
  • RenderPass — операция рендера внутри CommandEncoder
  • CommandBuffer — готовый список команд (результат CommandEncoder)

События (реакция на изменения):

  • Изменение размера окна → переконфигурация Surface
  • Потеря поверхности → восстановление
  • Изменение настроек → пересоздание конвейеров

Очистка — не нужна. Все ресурсы wgpu — это Arc внутри, Rust автоматически очистит при выходе из области видимости.

Переходим к коду

Зависимости

toml
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 в отдельную структуру:

rust
struct Renderer {
    device: Device,
    queue: Queue,
    surface: Surface<'static>,
    surface_config: SurfaceConfiguration,
}

SurfaceConfiguration описывает параметры поверхности — размер и формат изображения. С её помощью мы реагируем на изменение размера окна. Формат пикселей доступен как surface_config.format — он нужен при создании конвейеров.

Обновим и состояние приложения:

rust
enum App {
    Loading,
    Ready {
        window: Arc<Window>,
        renderer: Box<Renderer>, 
        need_to_resize_surface: bool, 
    },
}

Renderer в Box, чтобы варианты перечисления не сильно различались по размеру — на это есть проверка в Clippy.

Методы Renderer:

rust
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

rust
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

rust
    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

rust
    let surface = instance
        .create_surface(window)
        .expect("Failed to create surface");

Привязываем поверхность к нашему окну winit.

Двойная буферизация

Видеокарта рисует попиксельно. Если рендерить прямо в ту текстуру, что сейчас на экране, пользователь увидит процесс рисования — разорванный кадр. Поэтому используется минимум две текстуры: одна на экране, вторая — скрытая. Мы рисуем в скрытую, а когда кадр готов — меняем их местами. Отсюда термин «swapchain» — в старых API он был отдельным объектом, в wgpu скрыт внутри Surface.

Двойная буферизация: Front/Back swap

Когда мы вызываем surface.get_current_texture(), получаем текущую свободную текстуру для отрисовки.

Adapter

rust
    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

rust
    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(), получили бы только минимальный набор, определённый стандартом WebGPU
  • required_limits — ограничения (размер текстур, количество буферов). using_resolution учитывает разрешение адаптера
  • memory_hints: Performance — подсказка менеджеру памяти: оптимизировать скорость, а не потребление
  • trace — запись команд в файл для отладки через wgpu-player
  • experimental_features: disabled — экспериментальные фичи требуют unsafe, мы их не запрашиваем и не включаем. Вычитание all_experimental_mask из required_features гарантирует, что адаптер не вернёт ошибку, если в его списке фич оказались экспериментальные

SurfaceConfiguration

rust
    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,
    }
}

Разберём по шагам:

  1. get_capabilities — спрашиваем адаптер, какие форматы, режимы презентации и альфа-режимы поддерживает поверхность. Каждый монитор и ОС дают свой набор — поэтому нельзя захардкодить формат

  2. Выбираем формат: сначала ищем sRGB-формат, а если такого нет — берём первый доступный. На большинстве систем будет Bgra8UnormSrgb

    Почему именно sRGB

    GPU выполняет все вычисления в линейном цветовом пространстве — это нужно для корректного сложения цветов, освещения и смешивания. Но мониторы и операционная система ожидают изображение в sRGB. Если просто записать линейные значения в поверхность — цвета будут блёклыми или пересвеченными.

    sRGB-формат поверхности решает эту проблему: GPU автоматически преобразует линейные значения в sRGB при записи в текстуру поверхности. Нам не нужно делать это вручную в шейдерах.

    Если sRGB-формат недоступен (редко, но бывает на некоторых встроенных графических чипах), мы берём первый поддерживаемый формат как фоллбэк — в этом случае цвета будут менее точными, но приложение запустится.

    Почему это важно понимать сейчас: в следующих главах мы столкнёмся с текстурами, и у каждой будет свой формат. Текстуры с цветами (diffuse-карты) — sRGB, потому что они создавались в sRGB-пространстве и GPU должен преобразовать их в линейное при чтении. Normal-карты — линейные, потому что хранят не цвет, а направления. HDR-текстуры используют форматы с плавающей точкой (Rgba16Float), потому что стандартные 8 бит на канал не покрывают широкий диапазон яркости. Путаница в форматах — одна из частых причин «странных» цветов и артефактов.

  3. SurfaceConfiguration собираем вручную:

    • usage: RENDER_ATTACHMENT — мы будем рисовать в эту текстуру
    • present_mode: AutoVsync — wgpu сам выберет режим с вертикальной синхронизацией (VSync), если доступен
    • desired_maximum_frame_latency: 2 — сколько кадров может быть в очереди одновременно. Двойная буферизация
    • alpha_mode: Auto — wgpu сам выберет режим прозрачности
    • view_formats: vec![] — дополнительные форматы для TextureView, нам пока не нужны
  4. surface.configure — применяем конфигурацию. Поверхность создаёт внутренние текстуры нужного размера и формата

Метод resize_surface

rust
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

Это главный метод — отрисовка кадра.

Получение текстуры

rust
use wgpu::CurrentSurfaceTexture::{
    Lost, Occluded, Outdated, Suboptimal, Success, Timeout, Validation,
};
rust
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 особенно полезен на мобильных — когда приложение свёрнуто, рисовать впустую не стоит.

Кодирование команд

rust
    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

rust
    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, ...) — пока не нужны

Пока мы только заливаем экран цветом — других команд в проходе рендера нет. В следующей главе добавим отрисовку геометрии.

Отправка и отображение

rust
    self.queue.submit([encoder.finish()]);
window.pre_present_notify();
frame.present();
}
  • queue.submit — отправляем команды на выполнение. Не блокирует CPU на нативных платформах. Если нужно дождаться выполнения — device.poll
  • pre_present_notify — уведомление winit о скором выводе кадра. Критично на Wayland — без этого оконный сервер может заблокировать приложение
  • frame.present — отправляем кадр в очередь отображения. Тоже не блокирует — кадр выведется, когда GPU его отрисует

Оптимизации

  1. На мобильных и Apple Silicon завершение RenderPass дорого. Старайтесь переиспользовать проходы, где возможно.

  2. Queue::submit затратна — она отслеживает ресурсы и синхронизирует их под капотом. Но принимает список CommandBuffer. Лучше собрать буферы из разных частей программы и отправить одним вызовом, чем вызывать submit на каждый буфер отдельно.

Обновление методов жизненного цикла

Метод resumed

rust
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

rust
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}").

Что получилось

В центре экрана — окно, залитое зелёным цветом:

Window

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

Полный код главы

Опубликовано под лицензией CC-BY-4.0