[Перевод] Разработка WebGPU-приложений
WebGPU — это один из современных API, предназначенных для работы с компьютерной графикой. Среди других подобных API можно отметить Vulkan, DirectX 12 и Metal. То, что в сфере веб-графики появляются подобные решения, даёт пользователям веб-приложений те же возможности, которые есть у пользователей обычных приложений. А именно, это повышение скорости работы программ благодаря использованию видеоускорителей, это сокращение числа проблем, вызываемых графическими драйверами, это появление новых возможностей веб-приложений. Подобные возможности могут опираться как на расширенные функции браузеров, так и на спецификацию.
Надо сказать, что сейчас разработка под WebGPU — это занятие не для слабонервных. Это — один из самых сложных графических API, доступных в вебе. Но неудобства, связанные с разработкой, сглаживает то, что применение WebGPU означает рост производительности, и то, что это — стандарт, а значит можно рассчитывать на то, что в будущем он никуда не денется. Обратите внимание на то, что спецификация WebGPU всё ещё находится в разработке. Поэтому то, о чём пойдёт речь ниже, со временем может измениться.
Здесь мы, осваивая возможности WebGPU, займёмся разработкой приложения Hello Triangle на TypeScript.
Вот репозиторий, в котором можно найти всё необходимое для начала работы с WebGPU.
Подготовка к работе
Для того чтобы подготовиться к разработке WebGPU-приложений, нужно установить следующее:
- Canary-сборку любого браузера, основанного на Chromium (например — Microsoft Edge или Google Chrome). Установив браузер, нужно посетить страницу
(например —://flags chrome://flags
илиedge://flags
) и включить флагunsafe-webgpu
. - Git
- Node.js
- Какой-нибудь редактор кода, вроде VS Code
Затем в любом терминале, да хотя бы во встроенном терминале VS Code, нужно ввести следующие команды:
# Клонировать репозиторий
git clone https://github.com/alaingalvan/webgpu-seed
# Перейти в папку
cd webgpu-seed
# Запустить сборку проекта
npm start
Вот, если нужно, материал, в котором можно найти подробности о Node.js, о пакетах и о прочем подобном.
Архитектура проекта
По мере того, как растёт сложность проекта, его файлы может понадобиться организовать так, как обычно организуют файлы движков компьютерных игр или средств рендеринга реального времени. Вот как устроен наш проект:
├─ node_modules/ # Зависимости
│ ├─ gl-matrix # Линейная алгебра
│ └─ ... # Другие зависимости (TypeScript, Webpack, и так далее.)
├─ src/ # Файлы с исходным кодом
│ ├─ renderer.ts # Код рендеринга треугольника
│ └─ main.ts # Главный файл приложения
├─ .gitignore # Файл, задающий правила игнорирования материалов в git-репозитории
├─ package.json # Файл настроек Node.js-проекта
├─ license.md # Лицензия
└─ readme.md # Файл readme
Зависимости:
- gl-matrix — JavaScript-библиотека, которая позволяет писать JS-код, похожий на код, написанный на GLSL. Она предоставляет типы для векторов, матриц и других объектов. Хотя эта библиотека в данном примере и не используется, она чрезвычайно полезна при программировании более серьёзных приложений. Например — при создании матриц для управления камерой.
- TypeScript — типизированное надмножество языка JavaScript, использование которого упрощает создание веб-приложений благодаря возможностям по автозавершению кода и проверке типов.
- Webpack — средство сборки JavaScript-проектов, которое позволяет создавать компактные файлы с кодом и упрощает тестирование приложений.
Обзор проекта
В приложении, которое мы создадим, нужно выполнить следующие действия:
- Инициализация API. Нужно проверить, существует ли объект
navigator.gpu
, и если это так — запросить у негоGPUAdapter
, затем — запроситьGPUDevice
, а после этого получить объектGPUQueue
имеющегося устройства, используемый по умолчанию. - Настройка вспомогательных механизмов формирования кадра. Создание
GPUSwapchain
для полученияGPUTexture
для текущего кадра, а также — для получения любых других прикреплений (наподобие текстур глубины). Создание объектовGPUTextureView
для соответствующих текстур. - Инициализация ресурсов. Создание буферов
GPUBuffer
для вершин и индексов, выполнение предварительной компиляции шейдеров в формат SPIR-V и загрузка двоичных данных SPIR-V-шейдеров в видеGPUShaderModule
. Создание конвейераGPURenderPipeline
путём описания каждой стадии графического конвейера. И, наконец, созданиеGPUCommandEncoder
на основе того, что нужно отрендерить, а затем — созданиеGPURenderPassEncoder
на основе тех команд рисования, которые мы намереваемся выполнить в текущем проходе рендеринга. - Рендеринг. Передача в обработку
GPUCommandEncoder
с помощью метода этого объекта .finish()
и передача егоGPUQueue
. Подготовка системы к работе над следующим кадром путём вызоваrequestAnimationFrame
. - Освобождение ресурсов. Уничтожение использованных структур данных после завершения работы с API.
Перейдём к рассмотрению примера. Тут мы будем пользоваться фрагментами кода, полную версию которого можно найти в этом репозитории. Здесь, кроме того, переменные членов классов (вроде this.memberVariable
) объявлены внутри классов без использования ключевого слова .this
. Благодаря этому легче выяснить их типы. Кроме того, это приводит к тому, что примеры, приведённые здесь, могут работать сами по себе.
Инициализация API
▍Входная точка
Для того чтобы получить доступ к API WebGPU, нужно проверить существование объекта gpu
в глобальном объекте navigator
. Вот соответствующий TypeScript-код:
// Начало работы с WebGPU
const entry: GPU = navigator.gpu;
if (!entry) {
throw new Error('WebGPU is not supported on this browser.')
}
▍Адаптер
Объект GPUAdapter
описывает физические свойства конкретного GPU, такие, как имя, расширения, ограничения устройства.
// Объявляем ссылку на адаптер
let adapter: GPUAdapter = null;
// В асинхронной функции...
// Адаптер физического устройства
adapter = await entry.requestAdapter();
▍Устройство
Объект типа GPUDevice
олицетворяет логическое устройство, даёт доступ к основному функционалу API WebGPU и позволяет создавать необходимые структуры данных.
// Объявляем ссылку на устройство
let device: GPUDevice = null;
// В асинхронной функции...
// Логическое устройство
device = await adapter.requestDevice();
▍Очередь
Очередь, объект GPUQueue
, позволяет отправлять GPU задания в асинхронном режиме. Сейчас, во время написания этого материала, работать можно только с очередью defaultQueue
конкретного устройства GPUDevice
.
// Объявляем ссылку на очередь
let queue: GPUQueue = null;
// Очередь
queue = device.defaultQueue;
Вспомогательные механизмы формирования кадра
▍Цепочка буферов
Для того чтобы увидеть на экране то, что мы будем рисовать, нам нужен HTML-элемент canvas
. Кроме того, нужно создать для этого элемента цепочку буферов, представленную объектом GPUSwapChain
.
// Объявляем ссылку на цепочку буферов
let swapchain: GPUSwapchain = null;
const context: GPUCanvasContext = canvas.getContext('gpupresent') as any;
// Создаём цепочку буферов
const swapChainDesc: GPUSwapChainDescriptor = {
device: device,
format: 'bgra8unorm',
usage: GPUTextureUsage.OUTPUT_ATTACHMENT | GPUTextureUsage.COPY_SRC
};
swapchain = context.configureSwapChain(swapChainDesc);
▍Буферы, прикрепляемые к базовому буферу кадра
При выполнении различных проходов системы рендеринга нужны выходные текстуры, в которые можно записывать данные. Речь идёт о текстурах глубины для теста глубины или для расчёта теней. Это могут быть прикрепления, реализующие различные аспекты отложенного рендеринга, такие, как применение нормалей и PBR-эффектов, например — реализация формирования отражений и фактур поверхностей.
// Объявляем ссылки на прикрепления
let depthTexture: GPUTexture = null;
let depthTextureView: GPUTextureView = null;
// Создаём вспомогательный механизм для работы с глубиной
const depthTextureDesc: GPUTextureDescriptor = {
size: {
width: canvas.width,
height: canvas.height,
depth: 1
},
arrayLayerCount: 1,
mipLevelCount: 1,
sampleCount: 1,
dimension: '2d',
format: 'depth24plus-stencil8',
usage: GPUTextureUsage.OUTPUT_ATTACHMENT | GPUTextureUsage.COPY_SRC
};
depthTexture = device.createTexture(depthTextureDesc);
depthTextureView = depthTexture.createView();
// Объявляем ссылки текстур для цепочки буферов
let colorTexture: GPUTexture = null;
let colorTextureView: GPUTextureView = null;
colorTexture = swapchain.getCurrentTexture();
colorTextureView = colorTexture.createView();
Инициализация ресурсов
▍Буферы
Буфер — это массив с данными, например, с данными о позициях вершин сетки, с цветовыми данными, с данными об индексах и так далее. При рендеринге треугольников с использованием графического конвейера, формирующего растровое изображение, нужен 1 буфер с данными о вершинах (или большее количество таких буферов, которые обычно называют объектами буфера вершин — Vertex Buffer Object — VBO). Так же нужен 1 буфер индексов, содержащий указатели для буферов вершин тех треугольников, которые планируется вывести (такие буферы называют объектами буфера индекса — Index Buffer Object — IBO).
// Буфер с данными вершин
const positions = new Float32Array([
1.0, -1.0, 0.0,
-1.0, -1.0, 0.0,
0.0, 1.0, 0.0
]);
// Буфер с цветовыми данными вершин
const colors = new Float32Array([
1.0, 0.0, 0.0, // красный
0.0, 1.0, 0.0, // зелёный
0.0, 0.0, 1.0 // синий
]);
// Буфер с индексными данными
const indices = new Uint16Array([ 0, 1, 2 ]);
// Ссылки на буферы
let positionBuffer: GPUBuffer = null;
let colorBuffer: GPUBuffer = null;
let indexBuffer: GPUBuffer = null;
// Вспомогательная функция для создания объектов GPUBuffer из типизированных массивов
let createBuffer = (arr: Float32Array | Uint16Array, usage: number) => {
let desc = { size: arr.byteLength, usage };
let [ buffer, bufferMapped ] = device.createBufferMapped(desc);
const writeArray =
arr instanceof Uint16Array ? new Uint16Array(bufferMapped) : new Float32Array(bufferMapped);
writeArray.set(arr);
buffer.unmap();
return buffer;
};
positionBuffer = createBuffer(positions, GPUBufferUsage.VERTEX);
colorBuffer = createBuffer(colors, GPUBufferUsage.VERTEX);
indexBuffer = createBuffer(indices, GPUBufferUsage.INDEX);
▍Компиляция шейдеров
В нашем примере вершинный шейдер описан следующим GLSL-кодом:
#version 450
layout (location = 0) in vec3 inPos;
layout (location = 1) in vec3 inColor;
layout (location = 0) out vec3 outColor;
void main()
{
outColor = inColor;
gl_Position = vec4(inPos.xyz, 1.0);
}
Вот — код фрагментного шейдера, он тоже написан на GLSL:
#version 450
// Варьирование
layout (location = 0) in vec3 inColor;
// Возвращаемый вывод
layout (location = 0) out vec4 outFragColor;
void main()
{
outFragColor = vec4(inColor, 1.0);
}
Учитывая то, что у вас должен быть установлен glslang, и то, что пути к соответствующим инструментам находятся в переменной PATH
, для компиляции шейдеров выполните следующие команды в терминале:
glslangValidator -V triangle.vert -o triangle.vert.spv
glslangValidator -V triangle.frag -o triangle.frag.spv
▍Модули шейдеров
Модули шейдеров — это заранее скомпилированные бинарные файлы шейдеров, которые выполняются на GPU при выполнении конкретного конвейера.
Так как шейдеры должны быть предварительно скомпилированы для использования их в WebGPU, вам понадобится средство для компиляции шейдеров в формат SPIR-V. Я работаю сейчас над новой версией CrossShader — средства, которое позволяет выполнить эту операцию. С этим средством пока не очень удобно работать. Например, ему не помешала бы загрузка шейдеров с использованием Webpack и другие подобные улучшения.
// Объявляем ссылки на модули шейдеров
let vertModule: GPUShaderModule = null;
let fragModule: GPUShaderModule = null;
// Вспомогательная функция для создания модулей GPUShaderModule из SPIR-V-файлов
let loadShader = (shaderPath: string) =>
fetch(new Request(shaderPath), { method: 'GET', mode: 'cors' }).then((res) =>
res.arrayBuffer().then((arr) => new Uint32Array(arr))
);
// В асинхронной функции...
// Обратите внимание на то, что эти двоичные файлы можно включить в JavaScript-код в виде значений переменных.
const vsmDesc: any = { code: await loadShader('triangle.vert.spv') };
vertModule = device.createShaderModule(vsmDesc);
const fsmDesc: any = { code: await loadShader('triangle.frag.spv') };
fragModule = device.createShaderModule(fsmDesc);
▍Группировка
Часто нужно передавать данные напрямую в модули шейдеров. Для того чтобы это сделать, нужен uniform-буфер. Для того чтобы создать такой буфер в шейдере, добавьте в его код, до функции main
, следующее:
// Это нужно добавить в файл вершинного шейдера
layout (set = 0, binding = 0) uniform UBO
{
mat4 modelViewProj;
vec4 primaryColor;
vec4 accentColor;
};
// В файле вершинного шейдера замените предпоследнюю строчку на следующую
gl_Position = modelViewProj * vec4(inPos, 1.0);
Затем в JavaScript-коде создадим uniform-буфер, поступив так же, как поступали, создавая вершинные и индексные буферы:
// Uniform-данные
const uniformData = new Float32Array([
// Матрица ModelViewProjection
1.0, 0.0, 0.0, 0.0
0.0, 1.0, 0.0, 0.0
0.0, 0.0, 1.0, 0.0
0.0, 0.0, 0.0, 1.0
// Основной цвет
0.9, 0.1, 0.3, 1.0
// Акцентный цвет
0.8, 0.2, 0.8, 1.0
]);
// Объявление ссылки на буфер
let uniformBuffer: GPUBuffer = null;
uniformBuffer = createBuffer(uniformData, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
Для того чтобы лучше справиться с матричными вычислениями, вроде умножения матриц, вам может пригодиться специализированная библиотека — наподобие вышеупомянутой gl-matrix.
// Объявление ссылок
let uniformBindGroupLayout: GPUBindGroupLayout = null;
let uniformBindGroup: GPUBindGroup = null;
let layout: GPUPipelineLayout = null;
// Компоновка привязок
uniformBindGroupLayout = device.createBindGroupLayout({
bindings: [{
binding: 0,
visibility: GPUShaderStage.VERTEX,
type: "uniform-buffer"
}]
});
// Группа привязки
uniformBindGroup = device.createBindGroup({
layout: uniformBindGroupLayout,
bindings: [{
binding: 0,
resource: {
buffer: uniformBuffer
}
}]
});
// Компоновка конвейера
layout = device.createPipelineLayout({bindGroupLayouts: [sceneUniformBindGroupLayout]}),
Графический конвейер
▍Растровый графический конвейер
Графический конвейер — это описание всех данных, которые будут переданы в обработку. Он включает в себя следующее:
- Входная сборка. Как выглядит каждая вершина? Где находится каждый атрибут и как атрибуты выравниваются в памяти?
- Модули шейдеров. Какие модули шейдеров будут использоваться при выполнении данного графического конвейера?
- Дескриптор глубины. Нужно ли выполнять тест глубины? Если да — то какую функцию следует для этого использовать.
- Дескриптор смешивания цветов. Как должно производиться смешивание текущих и уже записанных цветов?
- Растеризация. Как производится растеризация? Осуществляется ли отсечение граней? Если да — то в каком направлении оно должно осуществляться?
- Uniform-данные. Поступления каких uniform-данных должны ожидать шейдеры?
Ответы на все эти вопросы при разработке WebGL-приложений даются в ходе компоновки конвейера.
// Объявление ссылки на конвейер
let pipeline: GPURenderPipeline = null;
// Графический конвейер
// Входная сборка
const positionAttribDesc: GPUVertexAttributeDescriptor = {
shaderLocation: 0, // [[attribute(0)]]
offset: 0,
format: 'float3'
};
const colorAttribDesc: GPUVertexAttributeDescriptor = {
shaderLocation: 1, // [[attribute(1)]]
offset: 0,
format: 'float3'
};
const positionBufferDesc: GPUVertexBufferLayoutDescriptor = {
attributes: [ positionAttribDesc ],
arrayStride: 4 * 3, // sizeof(float) * 3
stepMode: 'vertex'
};
const colorBufferDesc: GPUVertexBufferLayoutDescriptor = {
attributes: [ colorAttribDesc ],
arrayStride: 4 * 3, // sizeof(float) * 3
stepMode: 'vertex'
};
const vertexState: GPUVertexStateDescriptor = {
indexFormat: 'uint16',
vertexBuffers: [ positionBufferDesc, colorBufferDesc ]
};
// Модули шейдеров
const vertexStage = {
module: vertModule,
entryPoint: 'main'
};
const fragmentStage = {
module: fragModule,
entryPoint: 'main'
};
// Дескриптор глубины
const depthStencilState: GPUDepthStencilStateDescriptor = {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus-stencil8'
};
// Дескриптор смешивания цветов
const colorState: GPUColorStateDescriptor = {
format: 'bgra8unorm',
alphaBlend: {
srcFactor: 'src-alpha',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
colorBlend: {
srcFactor: 'src-alpha',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
writeMask: GPUColorWrite.ALL
};
// Растеризация
const rasterizationState: GPURasterizationStateDescriptor = {
frontFace: 'cw',
cullMode: 'none'
};
// Uniform-данные
const pipelineLayoutDesc = { bindGroupLayouts: [] };
const layout = device.createPipelineLayout(pipelineLayoutDesc);
const pipelineDesc: GPURenderPipelineDescriptor = {
layout,
vertexStage,
fragmentStage,
primitiveTopology: 'triangle-list',
colorStates: [ colorState ],
depthStencilState,
vertexState,
rasterizationState
};
pipeline = device.createRenderPipeline(pipelineDesc);
▍Кодировщик команд
Кодировщики команд выполняют кодирование команд рисования, которые планируется выполнить в ходе рендеринга. После завершения кодирования команд в вашем распоряжении окажется буфер команд, который можно передать в очередь.
В этом смысле буфер команд аналогичен коллбэку, который, будучи отправленным в очередь, выполняет функции рисования на GPU.
// Объявление ссылок на кодировщики
let commandEncoder: GPUCommandEncoder = null;
let passEncoder: GPURenderPassEncoder = null;
// Запись команд для отправки GPU
function encodeCommands() {
let colorAttachment: GPURenderPassColorAttachmentDescriptor = {
attachment: colorTextureView,
loadValue: { r: 0, g: 0, b: 0, a: 1 },
storeOp: 'store'
};
const depthAttachment: GPURenderPassDepthStencilAttachmentDescriptor = {
attachment: depthTextureView,
depthLoadValue: 1,
depthStoreOp: 'store',
stencilLoadValue: 'load',
stencilStoreOp: 'store'
};
const renderPassDesc: GPURenderPassDescriptor = {
colorAttachments: [ colorAttachment ],
depthStencilAttachment: depthAttachment
};
commandEncoder = device.createCommandEncoder();
// Кодирование команд рисования
passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
passEncoder.setPipeline(pipeline);
passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
passEncoder.setScissorRect(0, 0, canvas.width, canvas.height);
passEncoder.setVertexBuffer(0, positionBuffer);
passEncoder.setVertexBuffer(1, colorBuffer);
passEncoder.setIndexBuffer(indexBuffer);
passEncoder.drawIndexed(3, 1, 0, 0, 0);
passEncoder.endPass();
queue.submit([ commandEncoder.finish() ]);
}
Рендеринг
Рендеринг в WebGPU — это всего лишь обновление любых uniform-данных, которые вы намереваетесь обновить, получение следующего прикрепления из цепочки буферов, отправка закодированных команд на выполнение, и вызов requestAnimationFrame
для того, чтобы снова проделать все эти операции.
let render = () => {
// Получение следующего изображения из цепочки буферов
colorTexture = swapchain.getCurrentTexture();
colorTextureView = colorTexture.createView();
// Запись команд и отправка их в очередь
encodeCommands();
// Обновление элемента canvas
requestAnimationFrame(render);
};
Итоги
Возможно, писать под WebGPU сложнее, чем под другие графические API, но этот API лучше других соответствует возможностям современных видеокарт. Это, как результат, должно привести не только к тому, что с использованием WebGPU можно будет писать более быстрые приложения, но и к тому, что сама технология WebGPU ещё долго не устареет.
Надо отметить, что здесь не были рассмотрены некоторые продвинутые вопросы по работе с WebGPU. В частности, речь идёт о следующем:
- Использование матриц для работы с камерой.
- Подробный обзор всех возможных состояний графического конвейера.
- Вычислительные конвейеры.
- Загрузка текстур.
Уважаемые читатели! Планируете ли вы использовать WebGPU в своих проектах?