- PVSM.RU - https://www.pvsm.ru -

Разработка WebGPU-приложений

WebGPU — это один из современных API [1], предназначенных для работы с компьютерной графикой. Среди других подобных API можно отметить Vulkan, DirectX 12 и Metal. То, что в сфере веб-графики появляются подобные решения, даёт пользователям веб-приложений те же возможности, которые есть у пользователей обычных приложений. А именно, это повышение скорости работы программ благодаря использованию видеоускорителей, это сокращение числа проблем, вызываемых графическими драйверами, это появление новых возможностей веб-приложений. Подобные возможности могут опираться как на расширенные функции браузеров, так и на спецификацию [2].

Разработка WebGPU-приложений - 1 [3]

Надо сказать, что сейчас разработка под WebGPU — это занятие не для слабонервных. Это — один из самых сложных графических API, доступных в вебе. Но неудобства, связанные с разработкой, сглаживает то, что применение WebGPU означает рост производительности, и то, что это — стандарт, а значит можно рассчитывать на то, что в будущем он никуда не денется. Обратите внимание на то, что спецификация WebGPU всё ещё находится в разработке. Поэтому то, о чём пойдёт речь ниже, со временем может измениться.

Здесь мы, осваивая возможности WebGPU, займёмся разработкой приложения Hello Triangle на TypeScript.

Вот репозиторий [4], в котором можно найти всё необходимое для начала работы с WebGPU.

Подготовка к работе

Для того чтобы подготовиться к разработке WebGPU-приложений, нужно установить следующее:

  • Canary-сборку любого браузера, основанного на Chromium (например — Microsoft Edge [5] или Google Chrome [6]). Установив браузер, нужно посетить страницу <browsername>://flags (например — chrome://flags или edge://flags) и включить флаг unsafe-webgpu.
  • Git [7]
  • Node.js [8]
  • Какой-нибудь редактор кода, вроде VS Code [9]

Затем в любом терминале, да хотя бы во встроенном терминале VS Code, нужно ввести следующие команды:

# Клонировать репозиторий
git clone https://github.com/alaingalvan/webgpu-seed

# Перейти в папку
cd webgpu-seed

# Запустить сборку проекта
npm start

Вот [10], если нужно, материал, в котором можно найти подробности о Node.js, о пакетах и о прочем подобном.

Архитектура проекта

По мере того, как растёт сложность проекта, его файлы может понадобиться организовать так, как обычно организуют файлы движков компьютерных игр [11] или средств рендеринга [12] реального времени. Вот как устроен наш проект:

├─ node_modules/   # Зависимости
│  ├─ gl-matrix      # Линейная алгебра
│  └─ ...            # Другие зависимости (TypeScript, Webpack, и так далее.)
├─ src/            # Файлы с исходным кодом
│  ├─ renderer.ts    # Код рендеринга треугольника
│  └─ main.ts        # Главный файл приложения
├─ .gitignore      # Файл, задающий правила игнорирования материалов в git-репозитории
├─ package.json    # Файл настроек Node.js-проекта
├─ license.md      # Лицензия
└─ readme.md       # Файл readme

Зависимости:

  • gl-matrix [13] — JavaScript-библиотека, которая позволяет писать JS-код, похожий на код, написанный на GLSL. Она предоставляет типы для векторов, матриц и других объектов. Хотя эта библиотека в данном примере и не используется, она чрезвычайно полезна при программировании более серьёзных приложений. Например — при создании матриц для управления камерой.
  • TypeScript [14] — типизированное надмножество языка JavaScript, использование которого упрощает создание веб-приложений благодаря возможностям по автозавершению кода и проверке типов.
  • Webpack [15] — средство сборки JavaScript-проектов, которое позволяет создавать компактные файлы с кодом и упрощает тестирование приложений.

Обзор проекта

В приложении, которое мы создадим, нужно выполнить следующие действия:

  1. Инициализация API. Нужно проверить, существует ли объект navigator.gpu, и если это так — запросить у него GPUAdapter, затем — запросить GPUDevice, а после этого получить объект GPUQueue имеющегося устройства, используемый по умолчанию.
  2. Настройка вспомогательных механизмов формирования кадра. Создание GPUSwapchain для получения GPUTexture для текущего кадра, а также — для получения любых других прикреплений (наподобие текстур глубины). Создание объектов GPUTextureView для соответствующих текстур.
  3. Инициализация ресурсов. Создание буферов GPUBuffer для вершин и индексов, выполнение предварительной компиляции шейдеров в формат SPIR-V и загрузка двоичных данных SPIR-V-шейдеров в виде GPUShaderModule. Создание конвейера GPURenderPipeline путём описания каждой стадии графического конвейера. И, наконец, создание GPUCommandEncoder на основе того, что нужно отрендерить, а затем — создание GPURenderPassEncoder на основе тех команд рисования, которые мы намереваемся выполнить в текущем проходе рендеринга.
  4. Рендеринг. Передача в обработку GPUCommandEncoder с помощью метода этого объекта .finish() и передача его GPUQueue. Подготовка системы к работе над следующим кадром путём вызова requestAnimationFrame.
  5. Освобождение ресурсов. Уничтожение использованных структур данных после завершения работы с API.

Перейдём к рассмотрению примера. Тут мы будем пользоваться фрагментами кода, полную версию которого можно найти в этом [4] репозитории. Здесь, кроме того, переменные членов классов (вроде this.memberVariable) объявлены внутри классов без использования ключевого слова .this. Благодаря этому легче выяснить их типы. Кроме того, это приводит к тому, что примеры, приведённые здесь, могут работать сами по себе.

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

▍Входная точка

Разработка WebGPU-приложений - 2

Для того чтобы получить доступ к API WebGPU, нужно проверить существование объекта gpu в глобальном объекте navigator. Вот соответствующий TypeScript-код:

// Начало работы с WebGPU
const entry: GPU = navigator.gpu;
if (!entry) {
    throw new Error('WebGPU is not supported on this browser.')
}

▍Адаптер

Разработка WebGPU-приложений - 3

Объект GPUAdapter описывает физические свойства конкретного GPU, такие, как имя, расширения, ограничения устройства.

// Объявляем ссылку на адаптер
let adapter: GPUAdapter = null;

// В асинхронной функции...

// Адаптер физического устройства
adapter = await entry.requestAdapter();

▍Устройство

Разработка WebGPU-приложений - 4

Объект типа GPUDevice олицетворяет логическое устройство, даёт доступ к основному функционалу API WebGPU и позволяет создавать необходимые структуры данных.

// Объявляем ссылку на устройство
let device: GPUDevice = null;

// В асинхронной функции...


// Логическое устройство
device = await adapter.requestDevice();

▍Очередь

Разработка WebGPU-приложений - 5

Очередь, объект GPUQueue, позволяет отправлять GPU задания в асинхронном режиме. Сейчас, во время написания этого материала, работать можно только с очередью defaultQueue конкретного устройства GPUDevice.

// Объявляем ссылку на очередь
let queue: GPUQueue = null;

// Очередь
queue = device.defaultQueue;

Вспомогательные механизмы формирования кадра

▍Цепочка буферов

Разработка WebGPU-приложений - 6

Для того чтобы увидеть на экране то, что мы будем рисовать, нам нужен 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);

▍Буферы, прикрепляемые к базовому буферу кадра

Разработка WebGPU-приложений - 7

При выполнении различных проходов системы рендеринга нужны выходные текстуры, в которые можно записывать данные. Речь идёт о текстурах глубины для теста глубины или для расчёта теней. Это могут быть прикрепления, реализующие различные аспекты отложенного рендеринга, такие, как применение нормалей и 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();

Инициализация ресурсов

▍Буферы

Разработка WebGPU-приложений - 8

Буфер — это массив с данными, например, с данными о позициях вершин сетки, с цветовыми данными, с данными об индексах и так далее. При рендеринге треугольников с использованием графического конвейера, формирующего растровое изображение, нужен 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);

▍Компиляция шейдеров

Разработка WebGPU-приложений - 9

В нашем примере вершинный шейдер описан следующим 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 [16], и то, что пути к соответствующим инструментам находятся в переменной PATH, для компиляции шейдеров выполните следующие команды в терминале:

glslangValidator -V triangle.vert -o triangle.vert.spv
glslangValidator -V triangle.frag -o triangle.frag.spv

▍Модули шейдеров

Разработка WebGPU-приложений - 10

Модули шейдеров — это заранее скомпилированные бинарные файлы шейдеров, которые выполняются на GPU при выполнении конкретного конвейера.

Так как шейдеры должны быть предварительно скомпилированы для использования их в WebGPU, вам понадобится средство для компиляции шейдеров в формат SPIR-V. Я работаю сейчас над новой версией CrossShader [17] — средства, которое позволяет выполнить эту операцию. С этим средством пока не очень удобно работать. Например, ему не помешала бы загрузка шейдеров с использованием 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);

▍Группировка

Разработка WebGPU-приложений - 11

Часто нужно передавать данные напрямую в модули шейдеров. Для того чтобы это сделать, нужен 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 [13].

// Объявление ссылок
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]}),

Графический конвейер

▍Растровый графический конвейер

Разработка WebGPU-приложений - 12

Графический конвейер — это описание всех данных, которые будут переданы в обработку. Он включает в себя следующее:

  • Входная сборка. Как выглядит каждая вершина? Где находится каждый атрибут и как атрибуты выравниваются в памяти?
  • Модули шейдеров. Какие модули шейдеров будут использоваться при выполнении данного графического конвейера?
  • Дескриптор глубины. Нужно ли выполнять тест глубины? Если да — то какую функцию следует для этого использовать.
  • Дескриптор смешивания цветов. Как должно производиться смешивание текущих и уже записанных цветов?
  • Растеризация. Как производится растеризация? Осуществляется ли отсечение граней? Если да — то в каком направлении оно должно осуществляться?
  • 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);

▍Кодировщик команд

Разработка WebGPU-приложений - 13

Кодировщики команд выполняют кодирование команд рисования, которые планируется выполнить в ходе рендеринга. После завершения кодирования команд в вашем распоряжении окажется буфер команд, который можно передать в очередь.

В этом смысле буфер команд аналогичен коллбэку, который, будучи отправленным в очередь, выполняет функции рисования на 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-приложений - 14

Рендеринг в WebGPU — это всего лишь обновление любых uniform-данных, которые вы намереваетесь обновить, получение следующего прикрепления из цепочки буферов, отправка закодированных команд на выполнение, и вызов requestAnimationFrame для того, чтобы снова проделать все эти операции.

let render = () => {
    // Получение следующего изображения из цепочки буферов
    colorTexture = swapchain.getCurrentTexture();
    colorTextureView = colorTexture.createView();

    // Запись команд и отправка их в очередь
    encodeCommands();

    // Обновление элемента canvas
    requestAnimationFrame(render);
};

Итоги

Возможно, писать под WebGPU сложнее, чем под другие графические API, но этот API лучше других соответствует возможностям современных видеокарт. Это, как результат, должно привести не только к тому, что с использованием WebGPU можно будет писать более быстрые приложения, но и к тому, что сама технология WebGPU ещё долго не устареет.

Надо отметить, что здесь не были рассмотрены некоторые продвинутые вопросы по работе с WebGPU. В частности, речь идёт о следующем:

  • Использование матриц для работы с камерой.
  • Подробный обзор всех возможных состояний графического конвейера.
  • Вычислительные конвейеры.
  • Загрузка текстур.

Уважаемые читатели! Планируете ли вы использовать WebGPU в своих проектах?

Автор: ru_vds

Источник [18]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/razrabotka/345003

Ссылки в тексте:

[1] API: https://alain.xyz/blog/comparison-of-modern-graphics-apis

[2] спецификацию: https://gpuweb.github.io/gpuweb/

[3] Image: https://habr.com/ru/company/ruvds/blog/485644/

[4] репозиторий: https://github.com/alaingalvan/webgpu-seed

[5] Microsoft Edge: https://www.microsoftedgeinsider.com/en-us/download

[6] Google Chrome: https://www.google.com/chrome/canary/

[7] Git: https://git-scm.com/

[8] Node.js: https://nodejs.org/en/

[9] VS Code: https://code.visualstudio.com/

[10] Вот: https://alain.xyz/blog/designing-a-web-app

[11] движков компьютерных игр: https://alain.xyz/blog/game-engine-architecture

[12] рендеринга: https://alain.xyz/blog/realtime-renderer-architectures

[13] gl-matrix: https://github.com/toji/gl-matrix

[14] TypeScript: https://github.com/microsoft/typescript

[15] Webpack: https://github.com/webpack/webpack

[16] glslang: https://github.com/KhronosGroup/glslang/releases

[17] CrossShader: https://alain.xyz/libraries/crossshader

[18] Источник: https://habr.com/ru/post/485644/?utm_source=habrahabr&utm_medium=rss&utm_campaign=485644