оглавление

webgpufundamentals.org

Fix, Fork, Contribute

WebGPU Основы

Эта статья обучит вас основам WebGPU.

Рекомендую читателю ознакомится с JavaScript перед прочтением этой статьи. Концепции вроде mapping arrays, destructuring assignment, spreading values, async/await, es6 modules, и иногие другие будут встречаться довольно часто. Если вы ещё не знакомы с JavaScript, и хотите его изучить, загляните в JavaScript.info, Eloquent JavaScript, и/или CodeCademy.
Если вы уже знакомы с WebGL читайте это.

Если вкратце, то WebGPU это API, позволяющее делать 2 базовые вещи:

  1. Рисовать треугольники/точки/линии на текстурах

  2. Запускать вычисления на графическом процессоре (GPU)

Вот и все!

Как именно вы будете использовать WebGPU в дальнейшем, зависит только от вас. Это как учить какой-нибудь язык программирования. Все начинается с изучения основ, но после того, как они будут освоены, лишь от вас зависит, насколько творчески вы будете использовать полученные знания для решения ваших задач.

WebGPU - это крайне низкоуровневое API. Пока вы делаете что-то простое, это не будет создавать вам каких-то больших сложностей, но как только потребуется реализовать что-то более-менее масштабное, то, скорее всего, потребуется написать большое количество вспомогательного кода и хорошо проработать архитектуру вашего приложения (особенно работу с данными). Например, three.js, поддерживающий WebGPU, содержит около 600 тысяч строк минифицированного кода на JavaScript, и это только базовая библиотека, которая не включает в себя никакие загрузчики, обработчики нажатий, постобработку и т.д. Аналогично, TensorFlow с подддержкой WebGPU backend содержит около полумиллиона минифицированных строчек вспомогательного кода на JavaScript.

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

Если же в вашем случае речь идет о чем-то более сложном. Как, например, какой-нибудь специфичный случай, который очень тяжело или невозможно проработать с использованием функционала готовой библиотеки, или может вы и вовсе хотите изменить/доработать уже существующую библиотеку. Да даже если вам банально интересно, как оно там все внутри работает, то вы по адресу. Бегом читать! :)

Приступая к работе

Сложно точно сказать, с чего нужно начинать. На определённом уровне, WebGPU - это очень простая система. Все, что WebGPU делает - запускает три типа функций вашейна графическом процессоре. Вершинные (vertex), фрагментные (fragment) и вычислительные (compute) шейдеры (shaders).

Вершинный шейдер расчитывает вершины. Этот шейдер возвращает позиции вершин. Для каждой группы из трех вершин, возвращенных шейдером, между этими тремя позициями отрисовывается треугольник. [1]

Фрагментный шейдер расчитывает цвета [2]. Когда треугольник отрисован для каждого пикселя графический процессор вызовет фрагментный шейдер. Шейдер возвращает цвет.

Вычислительный шейдер является более универсальным. Это просто функция, которую вы вызываете и говорите: “вызови эту функцию N раз”. Графический процессор передаёт номер итерации каждый раз, когда вызывает вашу функцию, так что вы можете использовать этот номер, чтобы сделать что-либо уникальное на каждой итерации.

Если вы прищурились, то можете подумать об этом как о функциях, передаваемых в array.forEach или array.map. Когда вы вызываете функцию на графическом процессоре - это те же самые функции как, например, в JavaScript. Отличие в том, что они запускаются на графическом процессоре, поэтому, чтобы запустить их, вам нужно скопировать на графический процессор все нужные им данные в виде буферов и текстур, а ещё эти функции могут писать только в буферы и текстуры. В функциях вам нужно указать привязки (bindings) и слоты (locations), откуда брать данные. А в JavaScript, вам нужно привязать буферы и текстуры к привязкам или слотам. Как только вы это сделаете, вы можете запустить нужную функцию на графическом процессоре.

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

Обратите внимание на:

  • Конвейер (pipeline). Содержит вершинный и фрагментный шейдеры, которые будут запускаться на графическом процессоре. Также можно создать pipeline с вычислительным шейдером.

  • Ссылки на ресурсы для шейдеров (буферы, текстуры, семплеры) - неявно через Bind Groups.

  • Pipeline определяет атрибуты, косвенно ссылающиеся на буферы через внутреннее состояние.

  • Атрибуты вытаскивают данные из буферов и передают их в вершинный шейдер.

  • Вершинный шейдер может передавать данные в фрагментный шейдер.

  • Фрагментный шейдер неявно записывает данные в текстуры через описание прохода рендера (render pass description).

Чтобы запустить шейдеры на графическом процессоре, вам нужно создать все ресурсы и выставить состояние (state). Создание ресурсов - относительно простой процесс. Одна интересная фишка WebGPU заключается в том, что большинство ресурсов не могут быть изменены после создания. Вы можете изменить их наполнение, но не их размер (size), использование (usage), формат (format) и т.д. Если вы хотите изменить что-то из этого, нужно создать новый ресурс и удалить старый.

Некоторые из этих состояний устанавливаются их созданием и вызовом буферов команд (command buffers; не путать с буферами данных). Буферы команд - это буквально то, как они названы. Это буферы с командами. Вы создаете кодировщики команд (command encoders). Кодировщики кодируют команды в командный буфер. После вы завершаете (finish) кодировщик, и он отдаёт вам созданный им командный буфер. После чего вы можете отправить (submit) этот командный буфер, чтобы WebGPU исполнил эти команды.

Вот немного псевдо-кода кодирования буфера команд с последующим получением созданного командного буфера.

encoder = device.createCommandEncoder()
// отрисовать что-нибудь
{
  pass = encoder.beginRenderPass(...)
  pass.setPipeline(...)
  pass.setVertexBuffer(0, …)
  pass.setVertexBuffer(1, …)
  pass.setIndexBuffer(...)
  pass.setBindGroup(0, …)
  pass.setBindGroup(1, …)
  pass.draw(...)
  pass.end()
}
// отрисовать что-нибудь еще
{
  pass = encoder.beginRenderPass(...)
  pass.setPipeline(...)
  pass.setVertexBuffer(0, …)
  pass.setBindGroup(0, …)
  pass.draw(...)
  pass.end()
}
// расчитать что-нибудь
{
  pass = encoder.beginComputePass(...)
  pass.beginComputePass(...)
  pass.setBindGroup(0, …)
  pass.setPipeline(...)
  pass.dispatchWorkgroups(...)
  pass.end();
}
commandBuffer = encoder.finish();

После создания командного буфера вы можете отправить его на исполнение.

device.queue.submit([commandBuffer]);

Диаграмма показывает состояние в момент исполнения команды draw из командного буфера. Вызов команд будет устанавливать внутреннее состояние, а затем команда отрисовки (draw) скажет графическому процессору исполнить вершинный (и неявно fragment shader). Команда dispatchWorkgroup скажет графическому процессору исполнить вычислительный шейдер.

Надеюсь, это дало вам представление о том, как работают состояния. Как упоминалось выше, у WebGPU есть 2 основные вещи, которые он может делать.

  1. Рисовать треугольники/точки/линии на текстурах

  2. Запускать расчёты на графическом процессоре

Мы рассмотрим небольшие примеры, как делается каждая из этих вещей. Другие статьи покажут разные пути передачи данных в эти штуки. Обратите внимание, это всего лишь основы. Нам нужно построить из них фундамент. Позже мы покажем, как использовать их для вещей, которые люди обычно делают на графических процессорах, таких как 2D-графика, 3D-графика и т.д…

Отрисовка треугольников с текстурами

WebGPU может отрисовывать треугольники на текстурах. Для целей данной статьи текстура - двумерный прямоугольник, состоящий из пикселей. [3] Элемент <canvas> (холст) представляет собой текстуру на веб-странице. В WebGPU мы можем попросить у canvas текстуру и затем рендерить на неё.

Для рисования треугольников с помощью WebGPU нам нужно два “шейдера”. Повторюсь, шейдеры - это функции, которые запускаются на графическом процессоре. Два вида шейдеров:

  1. Вершинные шейдеры

    Вершинные шейдеры - это функции для расчета позиций вершин для отрисовки треугольников/линий/точек

  2. Фрагментные шейдеры

    Фрагментные шейдеры - это функции для расчета цветов (или других данных) для каждого пикселя, который надо отрисовать/растеризовать при отрисовке треугольников/линий/точек

Давайте начнем с самого простой программы WebGPU для отрисовки треугольника.

Нам нужен холст (canvas), чтобы отобразить наш треугольник.

<canvas></canvas>

Теперь нам нужен тег <script>, чтобы добавить JavaScript.

<canvas></canvas>
+<script type="module">

... здесь находится javascript ...

+</script>

Весь JavaScript ниже будет находиться внутри этого тега script.

WebGPU - это асинхронный API, поэтому проще всего использовать асинхронные функции. Мы начнем с получения адаптера (adapter) и получения устройства (device) из адаптера.

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  const device = await adapter?.requestDevice();
  if (!device) {
    fail('need a browser that supports WebGPU');
    return;
  }
}
main();

Приведенный выше код достаточно простой. Сначала мы получаем адаптер используя оператор опциональной цепочки ?.. Если navigator.gpu отсутствует, тогда adapter будет не определён (undefined). Если он существует, тогда мы вызовем requestAdapter. Этот метод возвращает результат асинхронно, поэтому нам нужно добавить оператор await. Адаптер представляет собой видеокарту. Некоторые устройства имеют несколько видеокарт.

Из адаптера мы получаем устройство (device), но снова используем оператор ?., так как если адаптер окажется undefined, то и устройство тоже будет undefined.

Если device не назначен, то, скорее всего, у пользователя старый браузер.

Далее, мы получаем холст (canvas) и создаем контекст webgpu для него. Это также позволит нам получить текстуру для отрисовки. Эта текстура будет использоватся для отображения на холсте на веб-странице.

  // Получаем контекст WebGPU из холста и настраиваем его
  const canvas = document.querySelector('canvas');
  const context = canvas.getContext('webgpu');
  const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
  context.configure({
    device,
    format: presentationFormat,
  });

Опять же, этот код максимально простой. Мы получаем контекст "webgpu" из холста. Мы спрашиваем систему, каков предпочитаемый формат. Это будет либо "rgba8unorm", либо "bgra8unorm". Не сказать что это важно, но от этого зависит производительность на системе пользователя.

Мы устанавливаем format в контексте webgpu холст с помощью вызова configure. Мы также устанавливаем device который связывает холст с созданым устройством.

Далее, мы создаем модуль шейдера. Модуль шейдера содержит одну или больше функций шейдеров. В нашем случае, мы создадим одну функцию вершинного шейдера и одну функцию в фрагментного шейдера.

  const module = device.createShaderModule({
    label: 'our hardcoded red triangle shaders',
    code: `
      @vertex fn vs(
        @builtin(vertex_index) vertexIndex : u32
      ) -> @builtin(position) vec4f {
        let pos = array(
          vec2f( 0.0,  0.5),  // top center
          vec2f(-0.5, -0.5),  // bottom left
          vec2f( 0.5, -0.5)   // bottom right
        );

        return vec4f(pos[vertexIndex], 0.0, 1.0);
      }

      @fragment fn fs() -> @location(0) vec4f {
        return vec4f(1.0, 0.0, 0.0, 1.0);
      }
    `,
  });

Шейдер написан на языке, который называется WebGPU Shading Language (WGSL), который часто произносится как виг-сил. WGSL это строго-типированный язык, который мы будем изучать более глубоко в другой статье. А сейчас, я надеюсь небольшое погружение поможет вам понять основы.

Выше мы видим функцию vs она обьявлена с атрибутом @vertex. Он указывает, что это функция вершинного шейдера.

      @vertex fn vs(
        @builtin(vertex_index) vertexIndex : u32
      ) -> @builtin(position) vec4f {
         ...

Функция принимает один параметр с именем vertexIndex. vertexIndex - это u32, означающее 32-битное целое число со знаком. Он получает своё значение из встроенного (builtin) vertex_index. vertex_index это что-то вроде номера итерации, схожее с index в JavaScript-овом Array.map(function(value, index) { ... }). Если мы говорим графическому процессору вызвать функцию 10 раз с помощью функции draw, в первый раз vertex_index будет 0, во второй раз - 1, в третий раз - 2 и так далее…[4]

Наш vs функция будет возвращать vec4f, являющийся вектором из 4 32-битных чисел с плавающей запятой. Думайте о нём, как о массиве с четырьмя значениями или об как обьекте с четырьмя свойствами типа {x: 0, y: 0, z: 0, w: 0}. Возвращённое значение будет назначено встроенному значению (builtin) position. В режиме “triangle-list”, каждые 3 запуска вершинного шейдера будет отрисован треугольник, соединяющий 3 позиции position, возвращённые из шейдера.

Позиции в WebGPU возвращаются в clip space, где X - от -1.0 влево и до +1.0 в право, а Y - от -1.0 вниз и до +1.0 вверх. Это верно, независимо от размера текстуры, в которую мы рисуем.

vs функция объявляет массив их трех vec2f. Каждый vec2f содержит два 32-битных числа с плавающей запятой.

        let pos = array(
          vec2f( 0.0,  0.5),  // top center
          vec2f(-0.5, -0.5),  // bottom left
          vec2f( 0.5, -0.5)   // bottom right
        );

Это будет использовать vertexIndex, чтобы вернуть одно из трех значений в массиве. Функция требует четырех чисел с плавающей запятой для работы и pos это массив vec2f, этот код вернет 0.0 до 1.0 для этих значений.

        return vec4f(pos[vertexIndex], 0.0, 1.0);

Модуль шейдера также содержит функцию, которая называется fs которая обозначена атрибутом @fragment который делает из этой функции функцию fragment shader’a

      @fragment fn fs() -> @location(0) vec4f {

Эта функция не принимает параметров и возвращает vec4f в location(0). Это значит, что значение будет записано как первая цель для отрисовки. Мы сделаем первую цель для отрисовки для нашего canvas текстуры позже.

        return vec4f(1, 0, 0, 1);

Код возвращает 1, 0, 0, 1, то есть красный. Цвета в WebGPU часто представленны как числа с плавающей запятой ( напоминаю, не целые ) от 0.0 до 1.0 где четыре числа выше запишутся как красный, зеленый, синий и альфа канал.

Когда графический процессор будет растрирует треугольник ( нарисует из него пиксели ), вызовется fragment shader, чтобы найти цвет для каждого пикселя. В нашем случае, мы просто вернем красный.

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

В большом приложении может быть сто или тысячи буферов, текстур, модулей шейдеров, pipelines и другого… Если вы получите ошибку "WGSL syntax error in shaderModule at line 10" и у вас сто модулей шейдеров, то возникнет вопрос: “А в каком из них конкретно ошибка?”. Если вы дадите название модулю, то при получении ошибки это будет выглядеть как "WGSL syntax error in shaderModule('our hardcoded red triangle shaders') at line 10 где будет указано название модуля и поможет вам сохранить тонны времени, которые уйдут на отладку.

Теперь, мы создали модуль шейдера и дальше нужно создать render pipeline.

  const pipeline = device.createRenderPipeline({
    label: 'our hardcoded red triangle pipeline',
    layout: 'auto',
    vertex: {
      module,
      entryPoint: 'vs',
    },
    fragment: {
      module,
      entryPoint: 'fs',
      targets: [{ format: presentationFormat }],
    },
  });

Тут особо не на что смотреть. Мы устанавливаем layout в режим 'auto' и вернем макет данных ( layout of data ) из шейдера. Сейчас мы ни где не используем эти данные.

Далее мы говорим render pipeline’у использовать vs функцию из нашего модуля шейдера для vertex shader и fs функцию для нашего fragment shader. В противном случае, мы скажем ему формат для первой цели отрисовки. “цель отрисовки” или как в оригинале “render target” значит, что мы будем рендерить эту текстуру. Далее мы создаем pipeline и необходимо указать формат текстуры/текстур. Мы будем использовать этот pipeline для рендеринга.

Элемент ноль для целей массива соотвутствует локации ноль, которую мы возвращаем в fragment shader’е. Далее мы будем устанавливать эту цель как текстуру для канваса.

Далее нужно подготовить GPURenderPassDescriptor который описывает какие текстуры мы хотим отрендерить и как будем использовать их.

  const renderPassDescriptor = {
    label: 'our basic canvas renderPass',
    colorAttachments: [
      {
        // view: <- Чтобы текстура была заполнена внутри
        clearValue: [0.3, 0.3, 0.3, 1],
        loadOp: 'clear',
        storeOp: 'store',
      },
    ],
  };  

GPURenderPassDescriptor хранит массив для colorAttachments, то есть листы текстур для отрисовки и как хранить их. Мы хотим дождаться обработки той текстуры, которую хотим отрендерить. Сейчас мы устанавливаем значение полу-темного серого в loadOp и storeOp. loadOp: 'clear' устанавливается, чтобы очистить текстуру для следующей отрисовки. Другой вариант - 'load' который значит загрузку существующего контента текстуры в видокарты, короче, мы можем рендерить заного уже то что отрендерено. storeOp: 'store' чначит что мы будет хранить результат того, что хотим отрисовывать. Мы также могли установить 'discard' который не будет хранить то что мы хотим отрисовать. Мы обсудим это в другой статье.

Теперь время рендеринга.

  function render() {
    // Получаем текущую текстуру из canvas context и устанавливаем ее как текстуру для рендеринга
    renderPassDescriptor.colorAttachments[0].view =
        context.getCurrentTexture().createView();

    // создаем шаблон команды, чтобы запускать их
    const encoder = device.createCommandEncoder({ label: 'our encoder' });

    // создаем render pass encoder для установке нашего шаблона
    const pass = encoder.beginRenderPass(renderPassDescriptor);
    pass.setPipeline(pipeline);
    pass.draw(3);  // вызываем наш vertex shader три раза
    pass.end();

    const commandBuffer = encoder.finish();
    device.queue.submit([commandBuffer]);
  }

  render();

Сначала мы вызываем context.getCurrentTexture() для получение текстуры, которая будет отрисовываться на canvas’е. Вызываем createView и получаем представление об определенной части текстур, но без параметров, он будет возвращать стандартную часть которая нам нужна. Сейчас есть только colorAttachment - это представление текстуры из нашего canvas’а, которую мы получаем в контексте, который был создан вначале. Повторюсь, элемент ноль из colorAttachments массива, который соответствует @location(0), который соответствует возвращаемому значение из fragment shader’а.

Далее, мы создаем шаблон команды. Шаблон команды используется, чтобы создать команду буфера. Мы используем это, чтобы создать команду и после “подтвердить” команду буфера и вызвать ее.

Далее мы используем шаблон команды, чтобы создать render pass encoder с помощью вызова beginRenderPass. Render pass encoder служит для создания команд рендеринга. Мы передаем его в renderPassDescriptor, чтобы сказать какую текстуру мы хотим отрендерить.

Мы создаем команду и используем setPipeline, чтобы назначить наш pipeline и далее вызвать vertex shader три раза с помощью функции draw со значением три. По умолчанию каждые три раза наш vertex shader расчитает треугольник, который будет отрисован по трем вершинам.

Мы завершаем render pass и тогда завершаем команду. Это дает нам команду буфера что предтавляет собой шаги для рендеринга треугольника. Теперь мы подтверждаем команду буфера и вызываем ее.

Когда мы вызываем команду draw - это будет нашим стейтом ( state ).

У нас нет ни текстур ни буфером ни bindGroups, но у нас есть pipeline, vertex shader, fragment shader и render pass descriptor, который говорит нашим шейдерам рендерить на канвасе текстуру.

Результат:

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

WebGPU принимает каждые три вершины и возвращает из нашего vertex shader и используется им для растеризации треугольника. Это делается с помощью определения какие пиксели находятся внутри треугольника. Далее это вызывает наш fragment shader для каждого пикселя, чтобы сказать какого цвета они будут.

Представьте текстуру, которую мы рендерим размером 15 на 11 пикселей. Это пиксели, которые мы хотим отрисовать.

drag the vertices

Штош, теперь мы можем видеть простой пример работы WebGPU. Это показывает всю сложность написание и это все выглядит не очень гибко. Нам нужны пути, чтобы добавить данные и мы изучим это в следующих статьях. На что стоит обратить внимание в коде выше:

  • WebGPU просто запускает шейдеры. Вы должны обрабатывать результат, чтобы делать функциональные вещи
  • Шейдеры указаны в модуле шейдера и далее указываются в pipeline’е
  • WebGPU может рендерить треугольники
  • WebGPU отрисовывает треугольники НА текстуре ( мы получаем текстуру из canvas’а )
  • WebGPU работает с помощью шаблонов команд, их вызова и подтверждения.

Запуск на графическом процессоре

Давайте напишем простой пример отрисовки на графическом процессоре.

Мы начинаем с того же самого кода для получения девайса

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  const device = await adapter?.requestDevice();
  if (!device) {
    fail('need a browser that supports WebGPU');
    return;
  }

Далее создаем модуль шейдера

  const module = device.createShaderModule({
    label: 'doubling compute module',
    code: `
      @group(0) @binding(0) var<storage, read_write> data: array<f32>;

      @compute @workgroup_size(1) fn computeSomething(
        @builtin(global_invocation_id) id: vec3u
      ) {
        let i = id.x;
        data[i] = data[i] * 2.0;
      }
    `,
  });

Сначала, мы создаем переменную с названием data типа storage из которой мы можем читать и записывать информацию.

      @group(0) @binding(0) var<storage, read_write> data: array<f32>;

Мы создаем его как тип array<f32> который означает массив 32-битных чисел с плавающей запятой. Мы указываем, что массив должен находиться в binding location 0 (the binding(0)) в bindGroup 0 (the @group(0)).

Далее мы создаем функцию с названием computeSomething с атрибутом @compute который делает из этой функции compute shader.

      @compute @workgroup_size(1) fn computeSomething(
        @builtin(global_invocation_id) id: vec3u
      ) {
        ...

Compute shader нужен для назначения размера “рабочий группы” ( в оригинале workgroup ) о которой мы поговорим позже. А сейчас, мы просто устанавливаем один атрибут @workgroup_size(1). Мы добавляем его, чтобы иметь параметр id, который использует vec3u. vec3u - это три 32 битные целые числа без знака. Как наш vertex shader выше - это число итераций. Это работает по другому в compute shader. Число итераций тут - это три числа. Мы создаем id, чтобы получить значение из built-in global_invocation_id.

Вы можете сделать что-то вроде этого для расчета шейдеров. Это супер упрощенная версия, но оно работает.

// псевдо-код
function dispatchWorkgroups(width, height, depth) {
  for (z = 0; z < depth; ++z) {
    for (y = 0; y < height; ++y) {
      for (x = 0; x < width; ++x) {
        const workgroup_id = {x, y, z};
        dispatchWorkgroup(workgroup_id)
      }
    }
  }
}

function dispatchWorkgroup(workgroup_id) {
  // из @workgroup_size в WGSL
  const workgroup_size = shaderCode.workgroup_size;
  const {x: width, y: height, z: depth} = workgroup_size;
  for (z = 0; z < depth; ++z) {
    for (y = 0; y < height; ++y) {
      for (x = 0; x < width; ++x) {
        const local_invocation_id = {x, y, z};
        const global_invocation_id =
            workgroup_id * workgroup_size + local_invocation_id;
        computeShader(global_invocation_id)
      }
    }
  }
}

С того момента как мы установили @workgroup_size(1), псевдо-код превратился в

// псевдо-код
function dispatchWorkgroups(width, height, depth) {
  for (z = 0; z < depth; ++z) {
    for (y = 0; y < height; ++y) {
      for (x = 0; x < width; ++x) {
        const workgroup_id = {x, y, z};
        dispatchWorkgroup(workgroup_id)
      }
    }
  }
}

function dispatchWorkgroup(workgroup_id) {
  const global_invocation_id = workgroup_id;
  computeShader(global_invocation_id)
}

Наконец-то мы используем x как свойство id для индекса data и умножаем все значения на два.

        let i = id.x;
        data[i] = data[i] * 2.0;

Выше, i это просто первое из трех чисел.

Теперь мы создали шейдер и нам нужно создать pipeline.

  const pipeline = device.createComputePipeline({
    label: 'doubling compute pipeline',
    layout: 'auto',
    compute: {
      module,
      entryPoint: 'computeSomething',
    },
  });

Здесь мы просто указываем что используем compute часть из шейдера. Мы создали module и там есть только один @compute как точка входа и поэтому WebGPU понимает, что мы хотим вызвать его. layout поставлен на режим 'auto' снова, чтобы WebGPU возвращал обработанный макет из шейдера. [5]

Далее, нам нужно немного данных.

  const input = new Float32Array([1, 3, 5]);

Эти данные существуют только в JavaScript. Для WebGPU нам нужен создать буфер, который будет на графическом процессоре и будет копировать данные в этот буфер.

  // Создает буфер на графическом процессоре, чтобы перенести наши вычисления
  // входные и выходные
  const workBuffer = device.createBuffer({
    label: 'work buffer',
    size: input.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
  });
  // Копирует наши данные в буфер
  device.queue.writeBuffer(workBuffer, 0, input);

Выше мы вызываем метод device.createBuffer чтобы создать буфер. size - это размер в байтах. В нашем случае указано 12, так как мы храним в байтах тип Float32Array трех float значений а их размер будет равен двенадцати байтам. Если вы не знакомы с Float32Array и строго-типизированными массивами, то читайте эту статью.

Каждый WebGPU буфер, который мы создаем имеет usage или для чего используется. Есть довольно большой список типов, которые мы можем указать для использования, но не все из них совместимы для совместного использования. Здесь мы указываем, что этот буфер будет использовать storage или хранилище с помощью флага GPUBufferUsage.STORAGE. Это добавляет возможность использовать var<storage,...> из шейдера. Дальше, мы хотим копировать данные из буфера, поэтому добавляем флаг GPUBufferUsage.COPY_DST. И последнее, мы хотим добавить возможность копировать данные из буфера, поэтому добавляем флаг GPUBufferUsage.COPY_SRC.

Также хочу добавить, что вы не можете напрямую читать информацию из WebGPU буфера в JavaScript. Вместо этого, у вас есть “карта” ( в оригинале “map” ) с помощью которой вы можете отправлять запросы к доступу к буферу из WebGPU, потому что буфер существует только на графическом процессоре.

Такой доступ к WebGPU буфере есть только в JavaScript. Другими словами, мы не можем сопоставить буфер, который мы только что создали выше, и если мы попытаемся добавить флаг, чтобы сделать его отображаемым, мы получим ошибку, с которой он несовместим использование STORAGE.

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

  // Создает буфер на графическом процессоре и копирует результат
  const resultBuffer = device.createBuffer({
    label: 'result buffer',
    size: input.byteLength,
    usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
  });

MAP_READ значит, что мы хотим иметь доступ к чтению данных.

Чтобы сказать шейдеру о нашем буфере нам нужно создать bindGroup.

  // Устанавливаем bindGroup, чтобы указать шейдеру
  // буфер, который будем использовать
  const bindGroup = device.createBindGroup({
    label: 'bindGroup for work buffer',
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      { binding: 0, resource: { buffer: workBuffer } },
    ],
  });

Мы получаем макет для bindGroup из pipeline’a. Далее, мы устанавливаем bindGroup. Ноль в pipeline.getBindGroupLayout(0) соотвествует @group(0) в шейдере. {binding: 0 ... с entries соответствует @group(0) @binding(0) в шейдере.

Далее мы можем создавать команды

  // Создает команды, чтобы делать расчеты
  const encoder = device.createCommandEncoder({
    label: 'doubling encoder',
  });
  const pass = encoder.beginComputePass({
    label: 'doubling compute pass',
  });
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.dispatchWorkgroups(input.length);
  pass.end();

Мы создаем команду. Начинаем вычисления. Мы создаем pipeline и устанавливаем bindGroup. Здесь, 0 в pass.setBindGroup(0, bindGroup) соответствует @group(0) в шейдере. Далее мы вызываем dispatchWorkgroups И в нашем случае мы указываем в input.length число 3 говоря WebGPU запустить compute shader три раза и завершить расчет.

Такое произойдет, когда dispatchWorkgroups будет вызван.

После расчетов мы говорим копировать данные из workBuffer ( который занимается расчетом ) в resultBuffer ( который копирует результат ).

  // Пишем команду, чтобы скопировать результат в буфер
  encoder.copyBufferToBuffer(workBuffer, 0, resultBuffer, 0, resultBuffer.size);

Теперь мы можем finish ( завершить ) команду, чтобы получить команду буфера и подтвердить ее.

  // Завершает команду и подтверждает ее
  const commandBuffer = encoder.finish();
  device.queue.submit([commandBuffer]);

Далее мы получаем результат и копируем данные.

  // Читаем результат
  await resultBuffer.mapAsync(GPUMapMode.READ);
  const result = new Float32Array(resultBuffer.getMappedRange());

  console.log('input', input);
  console.log('result', result);

  resultBuffer.unmap();

Чтобы получить результат буфера мы вызываем mapAsync и указываем await. После этого мы вызываем resultBuffer.getMappedRange(), который без параметров вернет ArrayBuffer данных всего буфера. Мы сохраняем результат в Float32Array. Строго-типизированный массив и после мы можем прочитать значения. Важно подметить, что ArrayBuffer возвращаемый с помощью getMappedRange действительный, только до момента вызова unmap. После unmap данные исчезнут.

Запускаем и получаем результат. Видим что все числа были умножены на два.

Мы будем изучать как использовать compute shader в других статьях. Сейчас вам нужно только понять как работает WebGPU. ВСЕ ОСТАЛЬНОЕ ВЫ УЗНАЕТЕ ДАЛЬШЕ!. WebGPU похож на другие языки программирования. Он дает возможность делать базовые вещи и дает возможность воссоздать все ваши идеи.

Особенным WebGPU делают эти функции, vertex shader, fragment shader и compute shader запускаемыми на вашей видеокарте. У видеокарты есть более 10 000 процессоров, которые потенциально могут делать более 10 000 параллельных вычислений, которые невозможно делать на центральном процессоре (CPU).

Изменение размера Canvas’a

Чтобы продвинуться дальше, давайте вернемся к отрисовке треугольника и добавим возможность изменения размера окна ( в данном случае canvas’a ). Изменения размера имеет много мелочей, поэтому более подробно это описано здесь. Сейчас давайте добавим самую простую поддержку.

Сначала нам нужно добавить CSS, чтобы заполнить страницу.

<style>
html, body {
  margin: 0;       /* убирает смещение ( margin ) по умолчанию */
  height: 100%;    /* позволяет html полностью заполнить страницу */
}
canvas {
  display: block;  /* указывает тип канваса как block */
  width: 100%;     /* позволяет canvas'y заполнять страницу */
  height: 100%;
}
</style>

Этот CSS код позволит canvas’y полностью заполнить страницу, но при этом не менять разрешение. Если вы сделаете окно примера выеш больше, например нажав на кнопку полноэкранного режима, то вы увидете что углы треугольника “блочные” ( я сама не знаю как точно перевести слово blocky. Если вы знаете, то сделайте свой pull request в гите ).

<canvas> тег по умолчанию имеет разрешение 300 на 150 пикселей. Мы хотим регулировать разрешение canvas’a, чтобы изменять размер отображаемого изображения. Одно из решений будет узазать ResizeObserver. Вы создаете ResizeObserver и даете его функции, чтобы вызвать его для любого элемента и изменить их размер. Вы говорите за какими элементами нужно наблюдать.

    ...
-    render();

+    const observer = new ResizeObserver(entries => {
+      for (const entry of entries) {
+        const canvas = entry.target;
+        const width = entry.contentBoxSize[0].inlineSize;
+        const height = entry.contentBoxSize[0].blockSize;
+        canvas.width = Math.max(1, Math.min(width, device.limits.maxTextureDimension2D));
+        canvas.height = Math.max(1, Math.min(height, device.limits.maxTextureDimension2D));
+        // заного отрисовать
+        render();
+      }
+    });
+    observer.observe(canvas);

В коде выше мы проходимся по всем элементам, но пройдемся только по одному так как у нас есть только canvas. Нам нужно установить размер нашего canvas’a, чтобы получить максимальный размер поддерживаем устройством, чтобы WebGPU не начал генерировать ошибки, потому что мы пытаем создать текстуру, которая слишком большая. Нам также нужно удостовериться, что это значение не будет равно нуль или мы снова будем получать ошибки. Вот огроменная статья со всеми фишками.

Мы вызываем render, чтобы заного отрисовать треугольник в новом разрешении. Мы удаляем старый вызов render, потому что он нам больше не нужен. ResizeObserver будет вызывается каждый раз, когда размер элементов будет меняться.

Новый размер текстуры будет создаваться, когда мы вызываем context.getCurrentTexture() внутри render, поэтому нам не нужно ничего делать более.

В этих статьях мы рассмотрим разные варианты как указать данные в шейдер.

Далее мы изучим основы WGSL.

Здесь будут самые основы до самых сложных вещей. Inter-stage varibles не требует дополнительных знаний для понимания. Мы можем увидеть как мы используем их ничего не используя, но меня WGSL выше. Uniforms - это эффективные глобальные переменные, которые используют все три вида шейдеров (vertex, fragment и compute). Использовать uniform buffers, чтобы хранить буферы тривиально, как показано выше в статье о хранении буферов. Vertex buffers используются только для vertex shader’ов. Они более гибкие, так как требуют только указания данных в макет в WebGPU. Текстуры являются самыми гибкими и у них есть тонны типов и вариантов.

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

Еще хотелось бы сказать, что все показанные примеры могут быть изменены прямо на странице в браузере. Далее мы можете экспортировать все примеры в jsfiddle и codepen и еще stackoverflow. Просто нажмите кнопку “Export”.

Код для получения устройства WebGPU здесь максимально простой. Для более сложного варианта вам нужно делать как-то так

async function start() {
  if (!navigator.gpu) {
    fail('this browser does not support WebGPU');
    return;
  }

  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) {
    fail('this browser supports webgpu but it appears disabled');
    return;
  }

  const device = await adapter?.requestDevice();
  device.lost.then((info) => {
    console.error(`WebGPU device was lost: ${info.message}`);

    // Если 'reason' ( причина ) будет 'destroyed' ( уничтожен, отключен ), если мы намеренно отключим устройство.
    if (info.reason !== 'destroyed') {
      // Попробовать снова
      start();
    }
  });
  
  main(device);
}
start();

function main(device) {
  ... do webgpu ...
}

device.lost - это promise ( обещание ), которые начинается как невыполненное. Оно выполнится, когда устройство будет потеряно. Устройство может быть потеряно по множеству причин. Может быть пользователь запустил очень требовательное приложение и это убило его видеокарту. Может быть, пользователь обновил драйвера. Может пользователь использует удаленную видеокарту и отключил ее. Может быть другая страница в браузере использует слишком много ресурсов видеокарты и ваша страница находится на зданем плане и теряет устройство из-за нехватки памяти. Это стоит делать, чтобы перехватывать потерю устройства.

Хочу подметить, что requestDevice всегда возвращает устройство. Может только случится потеря в процессе выполнения. WebGPU спроектирован так, что в большинстве случаев устройство будет работать в зависимости от версии API. Вызов для создания вещей и использования их может быть успешно выполненно, но никто не обещает. Это поможет вам перехватить потерю девайса с помощью promise.


  1. Есть пять режимом отрисовки.

    • 'point-list': для каждой позиции рисует точку
    • 'line-list': для каждых двух позиций рисует линию
    • 'line-strip': рисует линии, соединяя следующую точку с предыдущей
    • 'triangle-list': для каждых трех позиций рисует треугольник (по умолчанию)
    • 'triangle-strip': для каждой новой позиции рисует треугольник, соединяя последние две позиции
    ↩︎
  2. Фрагментные шейдеры неявно записывают данные в текстуры. Эти данные не обязаны быть цветами. Например, обычное дело - записывать в текстуру нормаль поверхности, представленной пикселем. ↩︎

  3. Текстуры также могут быть трехмерными кубическими - cube maps (6 квадратов пикселей, формирущих куб) и другими, но в большинстве случаев текстуры - это двумерные пиксельные прямоугольники. ↩︎

  4. Мы также можем использовать индексный буфер, чтобы указать vertex_index. Это написано в статье об индексных буферах. ↩︎

  5. layout: 'auto' - это удобно, но не дает возможности поделиться bind groups с другими pipeline’ами. В большинстве примеров тут мы никогда не будем использовать bind group для множества pipeline’ов. Мы будет использовать явные макеты в другой статье. ↩︎

Нашли ошибку? Создайте задачу на github.
comments powered by Disqus