Первый треугольник
Что уже должно быть понятно:
- окно, event loop,
ApplicationHandler Instance,Adapter,Device,Queue,SurfaceRenderPass,LoadOp::Clear
Что появится в этой главе:
- учебный каркас (
framework) - язык шейдеров WGSL
- графический конвейер (
RenderPipeline)
Итог: окно с зелёным фоном и тёмно-красным треугольником в центре
В прошлой главе мы инициализировали wgpu и залили окно зелёным цветом с помощью RenderPass. Теперь мы нарисуем настоящую геометрию — треугольник. Для этого нам понадобятся новые сущности: шейдеры и графический конвейер (render pipeline).
Учебный каркас
В предыдущих главах мы написали значительное количество кода для создания окна, инициализации wgpu, обработки событий и ошибок. Этот код будет одинаковым во всех следующих примерах, поэтому мы вынесли его в учебный каркас (framework) и дальше не будем каждый раз переписывать.
Это не движок: здесь нет ECS, сцен, материалов, загрузчика уровней и архитектуры игры. Это только обвязка, чтобы главы были про графику.
trait Example
Каркас определяет трейт Example, который мы будем реализовывать в каждой главе:
pub trait Example: 'static {
fn init(ctx: &GpuContext) -> Self;
fn resize(&mut self, ctx: &GpuContext, new_size: PhysicalSize<u32>);
fn render(&mut self, ctx: &GpuContext, view: &TextureView, encoder: &mut CommandEncoder);
}Важно
В дальнейших главах трейт Example будет расширяться — в нём появятся методы update (для обработки ввода и логики) и Input (для доступа к состоянию клавиатуры и мыши). В этой главе нам достаточно трёх методов: init, resize и render.
init— вызывается один раз при запуске. ПолучаетGpuContext, содержащийdevice,queue,surface,surface_configиsurface_format. Здесь мы создаём ресурсы, живущие всё время работы примераresize— вызывается при изменении размера окнаrender— вызывается каждый кадр. Получает готовыйTextureView(представление кадра поверхности) иCommandEncoder(кодировщик команд). Нам остаётся только записать команды отрисовки
GpuContext
GpuContext содержит всё, что мы настраивали в прошлых главах вручную:
pub struct GpuContext {
pub device: Device,
pub queue: Queue,
pub surface: Surface<'static>,
pub surface_config: SurfaceConfiguration,
pub surface_format: TextureFormat,
}Все поля публичны — каркас не скрывает типы wgpu, а лишь убирает рутину по их созданию.
Запуск примера
Для запуска достаточно вызвать одну функцию:
fn main() {
framework::run::<Triangle>("Hello Triangle");
}Каркас создаст окно, инициализирует wgpu, обработает event loop, resize, ошибки get_current_texture, вызовет pre_present_notify и queue.submit. Всё, что было описано в предыдущих главах, работает под капотом.
Что именно скрывает каркас
- окно — создание, скрытие до первого кадра, центрирование
- event loop —
ApplicationHandler,ControlFlow::Wait,request_redraw - инициализация wgpu —
Instance,Adapter,Device,Queue,Surface - обработка ошибок —
Outdated/Lost/Suboptimal→ переконфигурация,Occluded/Timeout→ пропуск кадра - получение кадра —
get_current_texture, созданиеTextureViewиCommandEncoder - отправка —
queue.submit,pre_present_notify,frame.present - выход по Escape
Графический конвейер: что происходит внутри
Разберёмся, как GPU превращает три точки в заполненный треугольник на экране. Этот процесс называется графическим конвейером (render pipeline) — данные проходят через несколько стадий, каждая из которых выполняет свою задачу:
- Вершинный шейдер — наша программа на WGSL, вызывается один раз для каждой вершины. Принимает данные вершины (позицию, цвет, и т.д.) и возвращает позицию в clip space — координатах от -1 до 1. Это единственная обязательная стадия — без неё GPU не знает, где рисовать
- Сборка примитивов — GPU группирует вершины в геометрические примитивы. Мы указываем
TriangleList, поэтому каждые три вершины образуют один треугольник - Растеризация — GPU определяет, какие пиксели экрана находятся внутри треугольника. Каждый такой пиксель называется фрагментом. На этом этапе также выполняется интерполяция — значения, заданные в вершинах (например, цвет), плавно размазываются по всем фрагментам
- Фрагментный шейдер — наша программа на WGSL, вызывается один раз для каждого фрагмента. Возвращает цвет пикселя. Если фрагмент находится за другим объектом (depth test) или за пределами экрана — он отбрасывается
- Framebuffer — результат записывается в текстуру поверхности, которая выводится на экран
Эта диаграмма будет расти вместе с нашим пониманием — в следующих главах мы будем добавлять к ней новые элементы (индексные буферы, uniform-буферы, текстуры, depth test), чтобы каждый раз видеть полную картину.
Всё остальное, что мы настраиваем при создании конвейера — это параметры этих стадий: какой шейдер использовать, в каком формате выводить цвет, как обрабатывать геометрию.
Шейдеры
Шейдеры — программы, выполняемые на GPU. Пишутся на WGSL (WebGPU Shading Language) — языке с синтаксисом, похожим на Rust.
Для треугольника нужны два шейдера. Создадим shader.wgsl рядом с main.rs:
@vertex
fn vs_main(@builtin(vertex_index) in_vertex_index: u32) -> @builtin(position) vec4<f32> {
let x = f32(i32(in_vertex_index) - 1) / 2;
let y = f32(i32(in_vertex_index & 1u) * 2 - 1) / 2;
return vec4<f32>(x, y, 0.0, 1.0);
}
@fragment
fn fs_main() -> @location(0) vec4<f32> {
return vec4<f32>(0.5, 0.0, 0.0, 0.5);
}Вершинный шейдер
Функция vs_main, отмеченная атрибутом @vertex, является вершинным шейдером. Она вызывается видеокартой один раз для каждой вершины. В нашем случае мы рисуем 3 вершины (треугольник), поэтому функция будет вызвана 3 раза.
Параметр @builtin(vertex_index) — это встроенный индекс текущей вершины, передаваемый GPU автоматически. Для трёх вершин он принимает значения 0, 1 и 2.
Результатом функции является позиция вершины — @builtin(position). Это четырёхмерный вектор vec4<f32>, где x и y задают координаты экрана (от -1 до 1), z — глубину (от 0 до 1), а w используется для перспективного деления (пока всегда 1.0).
Вычислим позиции трёх вершин:
| vertex_index | x | y |
|---|---|---|
| 0 | (0 - 1) / 2 = -0.5 | (0 × 2 - 1) / 2 = -0.5 |
| 1 | (1 - 1) / 2 = 0 | (1 × 2 - 1) / 2 = 0.5 |
| 2 | (2 - 1) / 2 = 0.5 | (0 × 2 - 1) / 2 = -0.5 |
Получаем треугольник с вершинами в точках (-0.5, -0.5), (0, 0.5) и (0.5, -0.5) — расположенный в центре экрана:
Координаты от -1 до 1 — это clip space. Левый нижний угол экрана = (-1, -1), правый верхний = (1, 1). GPU автоматически переводит их в пиксели экрана.
Примечание
Здесь мы вычисляем позиции вершин прямо в шейдере, используя только vertex_index. Такой подход подходит для простых демонстраций, но в реальных приложениях позиции вершин обычно передаются из CPU через вершинные буферы. Мы познакомимся с ними в следующих главах.
Фрагментный шейдер
Функция fs_main, отмеченная @fragment, является фрагментным шейдером. Она вызывается видеокартой один раз для каждого пикселя (точнее, фрагмента) внутри треугольника.
Результат функции — @location(0) vec4<f32> — это цвет пикселя. Значение vec4<f32>(0.5, 0.0, 0.0, 0.5) задаёт тёмно-красный цвет с полупрозрачностью (каналы RGBA). @location(0) указывает, что результат записывается в первое цветовое вложение (color attachment), которое мы настроим при создании конвейера.
Графический конвейер
Графический конвейер (render pipeline) — это главная сущность в процессе рендера. Он описывает состояние, которое должна иметь GPU во время отрисовки: какие шейдеры использовать, в каком формате выводить цвет, как обрабатывать геометрию и многое другое.
Конвейер создаётся один раз в Example::init и хранится в структуре Triangle:
struct Triangle {
pipeline: RenderPipeline,
}Создание шейдерного модуля
let shader_module = ctx.device.create_shader_module(include_wgsl!("shader.wgsl"));Макрос include_wgsl! встраивает файл в бинарник на этапе компиляции и проверяет синтаксис WGSL.
Вершинная стадия
vertex: VertexState {
module: &shader_module,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: PipelineCompilationOptions::default(),
},module— ссылка на шейдерный модуль, из которого берётся функцияentry_point— имя функции-входной точки в шейдере, то есть нашейvs_mainbuffers— описание вершинных буферов, из которых шейдер будет читать данные. Пока у нас нет вершинных буферов, поэтому массив пуст — все позиции вычисляются в шейдереcompilation_options— опции компиляции шейдера, оставляем по умолчанию
Фрагментная стадия
fragment: Some(FragmentState {
module: &shader_module,
entry_point: Some("fs_main"),
targets: &[Some(ColorTargetState {
format: ctx.surface_format,
blend: Some(BlendState {
color: BlendComponent::REPLACE,
alpha: BlendComponent::REPLACE,
}),
write_mask: ColorWrites::ALL,
})],
compilation_options: PipelineCompilationOptions::default(),
}),Фрагментная стадия обёрнута в Some, поскольку она может отсутствовать (например, при рендере только в глубину).
moduleиentry_point— те же, что и в вершинной стадии, но указывают наfs_maintargets— массив описаний цветовых целей, в которые будет выводиться результат. У нас одна цель — поверхность окнаformat— формат цвета, должен совпадать с форматом поверхностиblend— режим смешивания.REPLACEозначает, что новый цвет полностью заменяет предыдущий без смешиванияwrite_mask— какие каналы цвета записывать.ALL— все (красный, зелёный, синий, альфа)
Примитивы
primitive: PrimitiveState {
topology: PrimitiveTopology::TriangleList,
front_face: wgpu::FrontFace::Ccw,
polygon_mode: PolygonMode::Fill,
..Default::default()
},topology— тип примитивов.TriangleListозначает, что каждые 3 вершины образуют отдельный треугольник. Существуют и другие типы:LineList,PointList,TriangleStripи так далееfront_face— правило определения передней грани.Ccw(Counter-Clockwise) — передней считается грань, вершины которой расположены против часовой стрелкиpolygon_mode— режим отрисовки.Fill— заливка полигона. Также можно использоватьLineдля отображения только рёбер илиPointдля отображения только вершин
Мультисэмплирование и остальные поля
multisample: MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
depth_stencil: None,
cache: None,
multiview_mask: None,multisample— настройки сглаживания (MSAA).count: 1— без мультисэмплированияdepth_stencil— настройки буфера глубины и трафарета. Пока не используемcache— кэш скомпилированного конвейера. Позволяет ускорить создание pipeline при повторных запусках приложения — GPU не перекомпилирует шейдеры, а читает их из кэша. Пока не используем: сериализация кэша зависит от драйвера и платформы, что усложняет код примеровmultiview_mask— маска для рендеринга в несколько слоёв. Не используем
Отрисовка
Метод render из трейта Example получает от каркаса готовые TextureView и CommandEncoder. Нам остаётся только записать команды отрисовки:
fn render(&mut self, _ctx: &GpuContext, view: &TextureView, encoder: &mut CommandEncoder) {
let mut rpass = encoder.begin_render_pass(&RenderPassDescriptor {
label: Some("Main Render Pass"),
color_attachments: &[Some(RenderPassColorAttachment {
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,
});
rpass.set_pipeline(&self.pipeline);
rpass.draw(0..3, 0..1);
}Каркас уже создал TextureView (представление текущего кадра поверхности) и CommandEncoder. После возврата из render каркас вызовет queue.submit, pre_present_notify и frame.present.
Два ключевых вызова на RenderPass:
set_pipeline— указывает GPU, какой конвейер использовать для последующих команд отрисовкиdraw(0..3, 0..1)— отрисовывает 3 вершины (индексы 0, 1, 2), 1 экземпляр. Первый параметр — диапазон вершин, второй — диапазон экземпляров (instancing будет рассмотрен позже)
Что происходит на GPU
- Вершинный шейдер вызывается 3 раза — по одному для каждой вершины. Он вычисляет позиции вершин треугольника
- GPU определяет, какие пиксели (фрагменты) находятся внутри треугольника
- Фрагментный шейдер вызывается для каждого такого пикселя, возвращая тёмно-красный цвет
- Результаты записываются в текстуру поверхности через color attachment
Фон остаётся зелёным, потому что перед отрисовкой мы очищаем поверхность цветом Color::GREEN через LoadOp::Clear.
Что получилось
Render pipeline immutable
После создания нельзя изменить шейдер, формат, blend mode. Нужно создать новый pipeline.
Перепутаны параметры draw
draw(0..3, 0..1) — первый параметр это вершины, второй экземпляры. Перепутать — пустой экран.
Окно с зелёным фоном и тёмно-красным треугольником в центре. Внешний вид будет различаться в зависимости от операционной системы.
Попробуем
- Поменяем цвет треугольника в
fs_main— подставим другие значения RGBA - Изменим координаты вершин в
vs_main— сдвинем треугольник в сторону или сделаем его больше - Поменяем
Color::GREENна другой цвет фона - Попробуем
PolygonMode::LineвместоFill— увидим только рёбра треугольника