1. Введение
Что такое WebGPU?
WebGPU — это новый современный API для доступа к возможностям вашего графического процессора в веб-приложениях.
Современный API
До WebGPU существовал WebGL , который предлагал подмножество функций WebGPU. Это позволило создать новый класс богатого веб-контента, и разработчики создали с его помощью удивительные вещи. Однако он был основан на API OpenGL ES 2.0 , выпущенном в 2007 году, который был основан на еще более старом API OpenGL. За это время графические процессоры значительно изменились, и собственные API, используемые для взаимодействия с ними, также изменились с помощью Direct3D 12 , Metal и Vulkan .
WebGPU переносит достижения этих современных API на веб-платформу. Он фокусируется на кросс-платформенном включении функций графического процессора, одновременно представляя API, который выглядит естественно в Интернете и менее многословен, чем некоторые собственные API, на основе которых он создан.
Рендеринг
Графические процессоры часто ассоциируются с быстрой и детализированной графикой, и WebGPU не является исключением. Он обладает функциями, необходимыми для поддержки многих наиболее популярных сегодня методов рендеринга на настольных и мобильных графических процессорах, а также обеспечивает возможность добавления новых функций в будущем по мере развития аппаратных возможностей.
Вычислить
Помимо рендеринга, WebGPU раскрывает потенциал вашего графического процессора для выполнения универсальных, высокопараллельных рабочих нагрузок. Эти вычислительные шейдеры можно использовать автономно, без каких-либо компонентов рендеринга, или как тесно интегрированную часть вашего конвейера рендеринга.
В сегодняшнем уроке кода вы узнаете, как использовать возможности рендеринга и вычислений WebGPU для создания простого вводного проекта!
Что ты построишь
В этой лаборатории кода вы создадите «Игру жизни Конвея» с помощью WebGPU. Ваше приложение будет:
- Используйте возможности рендеринга WebGPU для рисования простой 2D-графики.
- Используйте вычислительные возможности WebGPU для выполнения моделирования.
Игра «Жизнь» — это так называемый клеточный автомат, в котором сетка ячеек со временем меняет состояние на основе некоторого набора правил. В «Игре жизни» клетки становятся активными или неактивными в зависимости от того, сколько активных соседних с ними клеток, что приводит к интересным закономерностям, которые колеблются по мере вашего просмотра.
Что вы узнаете
- Как настроить WebGPU и настроить холст.
- Как нарисовать простую 2D-геометрию.
- Как использовать вершинные и фрагментные шейдеры для изменения рисуемого.
- Как использовать вычислительные шейдеры для выполнения простого моделирования.
Эта лаборатория фокусируется на представлении фундаментальных концепций, лежащих в основе WebGPU. Он не предназначен для всестороннего обзора API и не охватывает (и не требует) часто связанных тем, таких как математика 3D-матриц.
Что вам понадобится
- Последняя версия Chrome (113 или новее) для ChromeOS, macOS или Windows. WebGPU — это кроссбраузерный и кроссплатформенный API, но он еще не доступен повсеместно.
- Знание HTML, JavaScript и Chrome DevTools .
Знакомство с другими графическими API, такими как WebGL, Metal, Vulkan или Direct3D, не требуется , но если у вас есть опыт работы с ними, вы, вероятно, заметите много общего с WebGPU, что может помочь вам начать обучение!
2. Настройте
Получить код
Эта лаборатория кода не имеет каких-либо зависимостей и проведет вас через каждый шаг, необходимый для создания приложения WebGPU, поэтому для начала вам не понадобится какой-либо код. Однако некоторые рабочие примеры, которые могут служить контрольными точками, доступны по адресу https://glitch.com/edit/#!/your-first-webgpu-app . Вы можете проверить их и ссылаться на них по ходу дела, если застрянете.
Используйте консоль разработчика!
WebGPU — довольно сложный API со множеством правил, обеспечивающих правильное использование. Хуже того, из-за особенностей работы API он не может генерировать типичные исключения JavaScript для многих ошибок, что затрудняет точное определение источника проблемы.
Вы столкнетесь с проблемами при разработке с использованием WebGPU, особенно если вы новичок, и это нормально! Разработчики API осознают трудности, связанные с разработкой графических процессоров, и приложили все усилия, чтобы гарантировать, что каждый раз, когда ваш код WebGPU вызывает ошибку, вы получаете очень подробные и полезные сообщения в консоли разработчика, которые помогут вам выявить и исправить ошибку. проблема.
Всегда полезно держать консоль открытой во время работы с любым веб-приложением, но здесь это особенно актуально!
3. Инициализируйте WebGPU
Начните с <canvas>
WebGPU можно использовать, ничего не показывая на экране, если все, что вам нужно, — это использовать его для вычислений. Но если вы хотите что-либо визуализировать, как мы собираемся делать в лаборатории кода, вам понадобится холст. Так что это хорошее место для начала!
Создайте новый HTML-документ с одним элементом <canvas>
, а также тегом <script>
, в котором мы запрашиваем элемент холста. (Или используйте 00-starter-page.html из-за сбоя.)
- Создайте файл
index.html
со следующим кодом:
index.html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>WebGPU Life</title>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script type="module">
const canvas = document.querySelector("canvas");
// Your WebGPU code will begin here!
</script>
</body>
</html>
Запросить адаптер и устройство
Теперь вы можете приступить к работе с WebGPU! Во-первых, следует учитывать, что распространение таких API, как WebGPU, по всей веб-экосистеме может занять некоторое время. В результате хорошим первым шагом предосторожности является проверка, может ли браузер пользователя использовать WebGPU.
- Чтобы проверить, существует ли объект
navigator.gpu
, который служит точкой входа для WebGPU, добавьте следующий код:
index.html
if (!navigator.gpu) {
throw new Error("WebGPU not supported on this browser.");
}
В идеале вы хотите сообщить пользователю, если WebGPU недоступен, переведя страницу в режим, в котором не используется WebGPU. (Может быть, вместо этого можно было бы использовать WebGL?) Однако для целей этой лаборатории вы просто выдаете ошибку, чтобы остановить дальнейшее выполнение кода.
Как только вы узнаете, что браузер поддерживает WebGPU, первым шагом при инициализации WebGPU для вашего приложения является запрос GPUAdapter
. Вы можете думать об адаптере как о представлении WebGPU определенной части аппаратного обеспечения графического процессора вашего устройства.
- Чтобы получить адаптер, используйте метод
navigator.gpu.requestAdapter()
. Он возвращает обещание, поэтому удобнее всего вызывать его с помощьюawait
.
index.html
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("No appropriate GPUAdapter found.");
}
Если подходящие адаптеры не найдены, возвращаемое значение adapter
может быть null
, поэтому вам следует обработать эту возможность. Это может произойти, если браузер пользователя поддерживает WebGPU, но аппаратное обеспечение его графического процессора не имеет всех функций, необходимых для использования WebGPU.
В большинстве случаев можно просто позволить браузеру выбрать адаптер по умолчанию, как вы делаете здесь, но для более сложных нужд есть аргументы, которые можно передать в requestAdapter()
, которые определяют, хотите ли вы использовать маломощный или высокопроизводительный адаптер. аппаратное обеспечение производительности на устройствах с несколькими графическими процессорами (например, на некоторых ноутбуках).
Если у вас есть адаптер, последний шаг перед тем, как вы сможете начать работу с графическим процессором, — это запрос GPUDevice . Устройство является основным интерфейсом, через который происходит большая часть взаимодействия с графическим процессором.
- Получите устройство, вызвав
adapter.requestDevice()
, который также возвращает обещание.
index.html
const device = await adapter.requestDevice();
Как и в случае с requestAdapter()
, здесь можно передать параметры для более сложных целей, таких как включение определенных аппаратных функций или запрос более высоких пределов, но для ваших целей значения по умолчанию подходят.
Настройте холст
Теперь, когда у вас есть устройство, вам нужно сделать еще одну вещь, если вы хотите использовать его для отображения чего-либо на странице: настроить холст для использования с только что созданным устройством.
- Для этого сначала запросите
GPUCanvasContext
из холста, вызвавcanvas.getContext("webgpu")
. (Это тот же вызов, который вы бы использовали для инициализации контекстов Canvas 2D или WebGL, используя типы контекстов2d
иwebgl
соответственно.) Возвращаемыйcontext
затем должен быть связан с устройством с помощью методаconfigure()
, например так:
index.html
const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: canvasFormat,
});
Здесь можно передать несколько параметров, но наиболее важными из них являются device
, с которым вы собираетесь использовать контекст, и format
, то есть формат текстуры , который должен использовать контекст.
Текстуры — это объекты, которые WebGPU использует для хранения данных изображения, и каждая текстура имеет формат, который позволяет графическому процессору знать, как эти данные располагаются в памяти. Детали того, как работает память текстур, выходят за рамки этой лаборатории. Важно знать, что контекст холста предоставляет текстуры для рисования вашего кода, а используемый вами формат может влиять на то, насколько эффективно холст отображает эти изображения. Различные типы устройств работают лучше всего при использовании разных форматов текстур, и если вы не используете предпочтительный формат устройства, это может привести к тому, что дополнительные копии памяти будут происходить «за кулисами», прежде чем изображение сможет отображаться как часть страницы.
К счастью, вам не нужно об этом сильно беспокоиться, потому что WebGPU подскажет вам, какой формат использовать для вашего холста! Почти во всех случаях вы хотите передать значение, возвращаемое путем вызова navigator.gpu.getPreferredCanvasFormat()
, как показано выше.
Очистить холст
Теперь, когда у вас есть устройство и холст настроен с его помощью, вы можете начать использовать устройство для изменения содержимого холста. Для начала очистите его сплошным цветом.
Чтобы сделать это (или многое другое в WebGPU) вам нужно предоставить графическому процессору несколько команд, указывающих ему, что делать.
- Для этого попросите устройство создать
GPUCommandEncoder
, который предоставляет интерфейс для записи команд графического процессора.
index.html
const encoder = device.createCommandEncoder();
Команды, которые вы хотите отправить на графический процессор, связаны с рендерингом (в данном случае с очисткой холста), поэтому следующим шагом будет использование encoder
для запуска прохода рендеринга.
Проходы рендеринга — это когда происходят все операции рисования в WebGPU. Каждый из них начинается с вызова beginRenderPass()
, который определяет текстуры, которые получают выходные данные любых выполняемых команд рисования. Более продвинутые варианты использования могут предоставить несколько текстур, называемых вложениями , с различными целями, такими как сохранение глубины отображаемой геометрии или обеспечение сглаживания. Однако для этого приложения вам понадобится только один.
- Получите текстуру из контекста холста, который вы создали ранее, вызвав
context.getCurrentTexture()
, который возвращает текстуру с шириной и высотой в пикселях, совпадающими с атрибутамиwidth
иheight
холста иformat
, указанным при вызовеcontext.configure()
.
index.html
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
storeOp: "store",
}]
});
Текстура задается как свойство view
colorAttachment
. Для проходов рендеринга требуется, чтобы вы предоставили GPUTextureView
вместо GPUTexture
, который сообщает, какие части текстуры нужно визуализировать. Это действительно важно только для более сложных случаев использования, поэтому здесь вы вызываете createView()
без аргументов для текстуры, указывая, что вы хотите, чтобы проход рендеринга использовал всю текстуру.
Вам также необходимо указать, что вы хотите, чтобы проход рендеринга делал с текстурой, когда он начинается и когда заканчивается:
- Значение
loadOp
"clear"
указывает, что вы хотите, чтобы текстура очищалась при запуске прохода рендеринга. - Значение
storeOp
"store"
указывает, что после завершения прохода рендеринга вы хотите, чтобы результаты любого рисования, выполненного во время прохода рендеринга, сохранялись в текстуру.
Как только этап рендеринга начался, вы не делаете... ничего! По крайней мере, на данный момент. Запуска рендеринга с помощью loadOp: "clear"
достаточно, чтобы очистить представление текстуры и холст.
- Завершите этап рендеринга, добавив следующий вызов сразу после
beginRenderPass()
:
index.html
pass.end();
Важно знать, что простое выполнение этих вызовов не приводит к тому, что графический процессор фактически что-либо делает. Они просто записывают команды, которые графический процессор может выполнить позже.
- Чтобы создать
GPUCommandBuffer
, вызовитеfinish()
в кодировщике команд. Буфер команд представляет собой непрозрачный дескриптор записанных команд.
index.html
const commandBuffer = encoder.finish();
- Отправьте буфер команд в графический процессор, используя
queue
GPUDevice
. Очередь выполняет все команды графического процессора, гарантируя, что их выполнение упорядочено и синхронизировано. Методsubmit()
очереди принимает массив командных буферов, хотя в данном случае у вас есть только один.
index.html
device.queue.submit([commandBuffer]);
После того как вы отправите командный буфер, его нельзя будет использовать снова, поэтому нет необходимости его хранить. Если вы хотите отправить больше команд, вам нужно создать еще один буфер команд. Вот почему довольно часто можно увидеть, как эти два шага объединены в один, как это сделано на примерах страниц для этой кодовой лаборатории:
index.html
// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);
После отправки команд в графический процессор позвольте JavaScript вернуть управление браузеру. В этот момент браузер видит, что вы изменили текущую текстуру контекста, и обновляет холст, чтобы отобразить эту текстуру в виде изображения. Если после этого вы хотите снова обновить содержимое холста, вам необходимо записать и отправить новый командный буфер, снова вызвав context.getCurrentTexture()
чтобы получить новую текстуру для прохода рендеринга.
- Перезагрузите страницу. Обратите внимание, что холст заполнен черным цветом. Поздравляем! Это означает, что вы успешно создали свое первое приложение WebGPU.
Выберите цвет!
Однако, если честно, черные квадраты довольно скучны. Так что найдите минутку, прежде чем перейти к следующему разделу, чтобы немного персонализировать его.
- В вызове
encoder.beginRenderPass()
добавьте новую строкуclearValue
вcolorAttachment
, например:
index.html
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0, g: 0, b: 0.4, a: 1 }, // New line
storeOp: "store",
}],
});
clearValue
указывает проходу рендеринга, какой цвет следует использовать при выполнении операции clear
в начале прохода. Передаваемый в него словарь содержит четыре значения: r
для красного , g
для зеленого , b
для синего и a
для альфа (прозрачности). Каждое значение может находиться в диапазоне от 0
до 1
, и вместе они описывают значение этого цветового канала. Например:
-
{ r: 1, g: 0, b: 0, a: 1 }
ярко-красный. -
{ r: 1, g: 0, b: 1, a: 1 }
ярко-фиолетовый. -
{ r: 0, g: 0.3, b: 0, a: 1 }
темно-зеленый. -
{ r: 0.5, g: 0.5, b: 0.5, a: 1 }
— средне-серый. -
{ r: 0, g: 0, b: 0, a: 0 }
— прозрачный черный цвет по умолчанию.
В примере кода и на скриншотах в этой лаборатории кода используется темно-синий цвет, но вы можете выбрать любой цвет по своему усмотрению!
- После того, как вы выбрали цвет, перезагрузите страницу. Вы должны увидеть выбранный вами цвет на холсте.
4. Рисуем геометрию
К концу этого раздела ваше приложение нарисует на холсте простую геометрию: цветной квадрат. Имейте в виду, что для такого простого вывода потребуется много работы, но это потому, что WebGPU предназначен для очень эффективной визуализации большого количества геометрии. Побочным эффектом такой эффективности является то, что выполнение относительно простых задач может показаться необычно трудным, но именно этого и следует ожидать, если вы обращаетесь к такому API, как WebGPU — вы хотите сделать что-то немного более сложное.
Поймите, как графические процессоры рисуют
Прежде чем вносить какие-либо изменения в код, стоит сделать очень быстрый, упрощенный и общий обзор того, как графические процессоры создают формы, которые вы видите на экране. (Не стесняйтесь перейти к разделу «Определение вершин», если вы уже знакомы с основами работы рендеринга с помощью графического процессора.)
В отличие от API, такого как Canvas 2D, который имеет множество готовых к использованию фигур и опций, ваш графический процессор действительно работает только с несколькими различными типами фигур (или примитивами , как их называет WebGPU): точками, линиями и треугольниками. . Для целей этой лаборатории вы будете использовать только треугольники.
Графические процессоры работают почти исключительно с треугольниками, поскольку треугольники обладают множеством хороших математических свойств, которые позволяют легко и предсказуемо и эффективно обрабатывать их. Почти все, что вы рисуете с помощью графического процессора, необходимо разделить на треугольники, прежде чем графический процессор сможет это нарисовать, и эти треугольники должны определяться их угловыми точками.
Эти точки или вершины задаются в виде значений X, Y и (для 3D-контента) Z, которые определяют точку в декартовой системе координат , определенной WebGPU или аналогичными API. Структуру системы координат проще всего представить с точки зрения того, как она связана с холстом на вашей странице. Независимо от ширины или высоты вашего холста, левый край всегда имеет значение -1 по оси X, а правый край всегда имеет значение +1 по оси X. Аналогично, нижний край всегда равен -1 по оси Y, а верхний край равен +1 по оси Y. Это означает, что (0, 0) всегда является центром холста, (-1, -1) всегда является нижним левым углом, а (1, 1) всегда является верхним правым углом. Это известно как Clip Space .
Вершины изначально редко определяются в этой системе координат, поэтому графические процессоры полагаются на небольшие программы, называемые вершинными шейдерами, для выполнения любых математических вычислений, необходимых для преобразования вершин в пространство отсечения, а также любых других вычислений, необходимых для рисования вершин. Например, шейдер может применить некоторую анимацию или вычислить направление от вершины к источнику света. Эти шейдеры написаны вами, разработчиком WebGPU, и они обеспечивают потрясающий контроль над работой графического процессора.
Отсюда графический процессор берет все треугольники, состоящие из этих преобразованных вершин, и определяет, какие пиксели на экране необходимы для их рисования. Затем он запускает еще одну написанную вами небольшую программу, называемую фрагментным шейдером , которая вычисляет, какого цвета должен быть каждый пиксель. Этот расчет может быть таким же простым, как возвращение зеленого цвета , или таким же сложным, как вычисление угла поверхности относительно солнечного света, отражающегося от других близлежащих поверхностей, фильтруемого сквозь туман и изменяемого в зависимости от того, насколько металлической является поверхность. Это полностью под вашим контролем, что может быть как воодушевляющим, так и подавляющим.
Результаты цветов этих пикселей затем накапливаются в текстуру, которую затем можно отобразить на экране.
Определить вершины
Как упоминалось ранее, симуляция «Игры жизни» отображается в виде сетки ячеек . Вашему приложению нужен способ визуализировать сетку, отличая активные ячейки от неактивных. Подход, используемый в этой кодовой лаборатории, будет заключаться в рисовании цветных квадратов в активных ячейках и оставлении неактивных ячеек пустыми.
Это означает, что вам нужно будет предоставить графическому процессору четыре разные точки, по одной на каждый из четырех углов квадрата. Например, квадрат, нарисованный в центре холста, вытянутый за края в разные стороны, имеет такие угловые координаты:
Чтобы передать эти координаты в графический процессор, вам нужно поместить значения в TypedArray . Если вы еще не знакомы, TypedArrays — это группа объектов JavaScript, которая позволяет вам выделять смежные блоки памяти и интерпретировать каждый элемент в серии как определенный тип данных. Например, в Uint8Array
каждый элемент массива представляет собой один байт без знака. TypedArrays отлично подходят для отправки данных туда и обратно с помощью API, чувствительных к расположению памяти, таких как WebAssembly, WebAudio и (конечно) WebGPU.
Для примера с квадратом, поскольку значения дробные, подходит Float32Array
.
- Создайте массив, содержащий все позиции вершин на диаграмме, поместив в свой код следующее объявление массива. Хорошее место для его размещения — вверху, сразу под вызовом
context.configure()
.
index.html
const vertices = new Float32Array([
// X, Y,
-0.8, -0.8,
0.8, -0.8,
0.8, 0.8,
-0.8, 0.8,
]);
Обратите внимание, что интервал и комментарий не влияют на значения; это просто для вашего удобства и для того, чтобы сделать его более читабельным. Это поможет вам увидеть, что каждая пара значений составляет координаты X и Y для одной вершины.
Но есть проблема! Помните, графические процессоры работают по принципу треугольников? Это означает, что вам нужно предоставить вершины группами по три. У вас есть одна группа из четырех человек. Решение состоит в том, чтобы повторить две вершины, чтобы создать два треугольника, общий край которых проходит через середину квадрата.
Чтобы сформировать квадрат из диаграммы, вам нужно указать вершины (-0,8, -0,8) и (0,8, 0,8) дважды: один раз для синего треугольника и один раз для красного. (Вместо этого вы также можете разделить квадрат на два других угла; это не имеет значения.)
- Обновите предыдущий массив
vertices
, чтобы он выглядел примерно так:
index.html
const vertices = new Float32Array([
// X, Y,
-0.8, -0.8, // Triangle 1 (Blue)
0.8, -0.8,
0.8, 0.8,
-0.8, -0.8, // Triangle 2 (Red)
0.8, 0.8,
-0.8, 0.8,
]);
Хотя на диаграмме для ясности показано разделение между двумя треугольниками, положения вершин абсолютно одинаковы, и графический процессор визуализирует их без пробелов. Он будет отображаться как один сплошной квадрат.
Создать буфер вершин
Графический процессор не может рисовать вершины с данными из массива JavaScript. Графические процессоры часто имеют собственную память, которая оптимизирована для рендеринга, поэтому любые данные, которые графический процессор должен использовать во время отрисовки, должны быть помещены в эту память.
Для многих значений, включая данные вершин, память на стороне графического процессора управляется через объекты GPUBuffer
. Буфер — это блок памяти, который легко доступен для графического процессора и помечен для определенных целей. Вы можете думать об этом как о TypedArray, видимом для графического процессора.
- Чтобы создать буфер для хранения ваших вершин, добавьте следующий вызов
device.createBuffer()
после определения вашего массиваvertices
.
index.html
const vertexBuffer = device.createBuffer({
label: "Cell vertices",
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
Первое, на что следует обратить внимание, это то, что вы присваиваете буферу метку . Каждому объекту WebGPU, который вы создаете, можно присвоить необязательную метку, и вы определенно захотите это сделать! Метка — это любая строка, которая вам нужна, если она помогает вам определить, что представляет собой объект. Если у вас возникнут какие-либо проблемы, эти метки будут использоваться в сообщениях об ошибках, которые выдает WebGPU, чтобы помочь вам понять, что пошло не так.
Далее укажите размер буфера в байтах. Вам нужен буфер размером 48 байт, который вы определяете, умножая размер 32-битного числа с плавающей запятой ( 4 байта ) на количество чисел с плавающей запятой в вашем массиве vertices
(12). К счастью, TypedArrays уже вычисляет для вас свою длину byteLength , и вы можете использовать ее при создании буфера.
Наконец, вам нужно указать использование буфера. Это один или несколько флагов GPUBufferUsage
, при этом несколько флагов объединяются с помощью |
( побитовый ИЛИ ) оператор. В этом случае вы указываете, что хотите, чтобы буфер использовался для данных вершин ( GPUBufferUsage.VERTEX
), а также хотите иметь возможность копировать в него данные ( GPUBufferUsage.COPY_DST
).
Объект буфера, который вам возвращается, непрозрачен — вы не можете (легко) проверить хранящиеся в нем данные. Кроме того, большинство его атрибутов неизменяемы — вы не можете изменить размер GPUBuffer
после его создания, а также не можете изменить флаги использования. Что вы можете изменить, так это содержимое его памяти.
При первоначальном создании буфера содержащаяся в нем память будет инициализирована нулем. Есть несколько способов изменить его содержимое, но самый простой — вызвать метод device.queue.writeBuffer()
с TypedArray, который вы хотите скопировать.
- Чтобы скопировать данные вершин в память буфера, добавьте следующий код:
index.html
device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);
Определите расположение вершин
Теперь у вас есть буфер с данными вершин, но с точки зрения графического процессора это всего лишь кусок байтов. Вам нужно предоставить немного больше информации, если вы собираетесь что-нибудь нарисовать с ее помощью. Вам нужно иметь возможность сообщить WebGPU больше о структуре данных вершин.
- Определите структуру данных вершин с помощью словаря
GPUVertexBufferLayout
:
index.html
const vertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: "float32x2",
offset: 0,
shaderLocation: 0, // Position, see vertex shader
}],
};
На первый взгляд это может показаться немного запутанным, но это относительно легко сломать.
Первое, что вы даете, — это arrayStride
. Это количество байтов, которое графический процессор должен пропустить в буфере при поиске следующей вершины. Каждая вершина вашего квадрата состоит из двух 32-битных чисел с плавающей запятой. Как упоминалось ранее, 32-битное число с плавающей запятой имеет размер 4 байта, поэтому два числа с плавающей запятой составляют 8 байт.
Далее идет свойство attributes
, которое представляет собой массив. Атрибуты — это отдельные фрагменты информации, закодированные в каждой вершине. Ваши вершины содержат только один атрибут (позицию вершины), но в более продвинутых вариантах использования часто есть вершины с несколькими атрибутами, такими как цвет вершины или направление, на которое указывает геометрическая поверхность. Однако это выходит за рамки данной кодовой лаборатории.
В вашем единственном атрибуте вы сначала определяете format
данных. Это происходит из списка типов GPUVertexFormat
, которые описывают каждый тип данных вершин, которые может понять графический процессор. Каждая из ваших вершин имеет по два 32-битных числа с плавающей запятой, поэтому вы используете формат float32x2
. Например, если ваши данные вершин состоят из четырех 16-битных целых чисел без знака каждое, вместо этого вы должны использовать uint16x4
. Видите образец?
Далее, offset
описывает, сколько байтов в вершине начинается с этого конкретного атрибута. На самом деле вам стоит беспокоиться об этом только в том случае, если ваш буфер содержит более одного атрибута, который не появится во время этой лабораторной работы.
Наконец, у вас есть shaderLocation
. Это произвольное число от 0 до 15, которое должно быть уникальным для каждого определяемого вами атрибута. Он связывает этот атрибут с определенным входом в вершинный шейдер, о котором вы узнаете в следующем разделе.
Обратите внимание: хотя вы определяете эти значения сейчас, вы на самом деле пока никуда не передаете их в API WebGPU. Это скоро произойдет, но об этих значениях проще всего подумать в тот момент, когда вы определяете свои вершины, поэтому вы настраиваете их сейчас для использования позже.
Начните с шейдеров
Теперь у вас есть данные, которые вы хотите визуализировать, но вам все равно нужно указать графическому процессору, как именно их обрабатывать. Большая часть этого происходит с шейдерами.
Шейдеры — это небольшие программы, которые вы пишете и которые выполняются на вашем графическом процессоре. Каждый шейдер работает на разных этапах обработки данных: обработка вершин , обработка фрагментов или общие вычисления . Поскольку они выполняются на графическом процессоре, их структура более жесткая, чем у обычного JavaScript. Но эта структура позволяет им работать очень быстро и, что особенно важно, параллельно!
Шейдеры в WebGPU написаны на языке шейдеров, называемом WGSL (язык шейдеров WebGPU). Синтаксически WGSL немного похож на Rust, с функциями, направленными на упрощение и ускорение распространенных типов работы графического процессора (например, векторной и матричной математики). Обучение языку шейдеров в целом выходит за рамки этой лабораторной работы, но, надеюсь, вы усвоите некоторые основы, пройдя через несколько простых примеров.
Сами шейдеры передаются в WebGPU в виде строк.
- Создайте место для ввода кода шейдера, скопировав следующее в свой код под
vertexBufferLayout
:
index.html
const cellShaderModule = device.createShaderModule({
label: "Cell shader",
code: `
// Your shader code will go here
`
});
Чтобы создать шейдеры, вы вызываете метод device.createShaderModule()
, которому вы предоставляете необязательную label
и code
WGSL в виде строки. (Обратите внимание, что здесь вы используете обратные кавычки, чтобы разрешить многострочные строки!) Как только вы добавите действительный код WGSL, функция вернет объект GPUShaderModule
с скомпилированными результатами.
Определить вершинный шейдер
Начните с вершинного шейдера, потому что именно здесь начинается работа графического процессора!
Вершинный шейдер определяется как функция, и графический процессор вызывает эту функцию один раз для каждой вершины в вашем vertexBuffer
. Поскольку ваш vertexBuffer
имеет шесть позиций (вершин), определяемая вами функция вызывается шесть раз. При каждом вызове функции передается другая позиция из vertexBuffer
в качестве аргумента, и задача функции вершинного шейдера — вернуть соответствующую позицию в пространстве отсечения.
Важно понимать, что они не обязательно будут вызываться в последовательном порядке. Вместо этого графические процессоры превосходно справляются с параллельным запуском подобных шейдеров, потенциально обрабатывая сотни (или даже тысячи!) вершин одновременно! Это огромная часть того, что отвечает за невероятную скорость графических процессоров, но она имеет ограничения. Чтобы обеспечить экстремальное распараллеливание, вершинные шейдеры не могут взаимодействовать друг с другом. Каждый вызов шейдера может видеть данные только для одной вершины одновременно и может выводить значения только для одной вершины.
В WGSL функцию вершинного шейдера можно назвать как угодно, но перед ней должен стоять атрибут @vertex
, чтобы указать, какой этап шейдера она представляет. WGSL обозначает функции ключевым словом fn
, использует круглые скобки для объявления любых аргументов и использует фигурные скобки для определения области действия.
- Создайте пустую функцию
@vertex
, например:
index.html (код createShaderModule)
@vertex
fn vertexMain() {
}
Однако это неверно, поскольку вершинный шейдер должен возвращать как минимум конечную позицию обрабатываемой вершины в пространстве отсечения. Это всегда задается как 4-мерный вектор. Векторы настолько распространены в шейдерах, что в языке они рассматриваются как первоклассные примитивы со своими собственными типами, такими как vec4f
для 4-мерного вектора. Существуют аналогичные типы для 2D-векторов ( vec2f
) и 3D-векторов ( vec3f
)!
- Чтобы указать, что возвращаемое значение является требуемой позицией, отметьте его атрибутом
@builtin(position)
. Символ->
используется для обозначения того, что именно возвращает функция.
index.html (код createShaderModule)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
}
Конечно, если функция имеет тип возвращаемого значения, вам необходимо вернуть значение в теле функции. Вы можете создать новый vec4f
для возврата, используя синтаксис vec4f(x, y, z, w)
. Значения x
, y
и z
— это числа с плавающей запятой, которые в возвращаемом значении указывают, где находится вершина в пространстве отсечения.
- Верните статическое значение
(0, 0, 0, 1)
, и у вас технически есть допустимый вершинный шейдер, хотя тот, который никогда не отображает ничего, поскольку графический процессор признает, что треугольники, которые он производит, являются лишь одной точкой, а затем отбрасывают ее.
index.html (CreateShadermodule Code)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}
Вместо этого вы хотите использовать данные из созданного вами буфера, и вы делаете это, объявив аргумент для вашей функции с атрибутом @location()
и введите, что соответствует тому, что вы описали в vertexBufferLayout
. Вы указали shaderLocation
0
, поэтому в вашем коде WGSL отметьте аргумент @location(0)
. Вы также определили формат как float32x2
, который является вектором 2D, поэтому в WGSL ваш аргумент - vec2f
. Вы можете назвать это все, что вам нравится, но, поскольку они представляют ваши позиции в вершине, имя, подобное POS, кажется естественным.
- Измените функцию шейдера на следующий код:
index.html (CreateShadermodule Code)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(0, 0, 0, 1);
}
И теперь вам нужно вернуть эту позицию. Поскольку позиция представляет собой 2D -вектор, а тип возврата - это 4D -вектор, вы должны немного его изменить. Что вы хотите сделать, так это взять два компонента из аргумента позиции и поместить их в первые два компонента вектора возврата, оставляя два последних компонента как 0
и 1
соответственно.
- Верните правильную позицию, явно указав, какие компоненты позиции использовать:
index.html (CreateShadermodule Code)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos.x, pos.y, 0, 1);
}
Однако , поскольку эти виды отображений так часто встречаются в шейдерах, вы также можете передать вектор положения в качестве первого аргумента в удобной сокращении, и это означает то же самое.
- Перепишите оператор
return
со следующим кодом:
index.html (CreateShadermodule Code)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
И это ваш первоначальный вершинный шейдер! Это очень просто, просто разрабатывая позицию эффективно без изменений, но это достаточно хорошо, чтобы начать.
Определите фрагментный шейдер
Следующим является фрагментный шейдер. Фрагментные шейдеры работают очень похожими на вершины -шейдеры, но вместо того, чтобы призвать к каждой вершине, их вызывают для каждого пикселя.
Фрагментные шейдеры всегда называются после вершин -шейдеров. GPU берет вывод вершин -шейдеров и триангулирует его, создавая треугольники из наборов из трех точек. Затем он расстиливает каждый из этих треугольников, выясняя, какие пиксели выходных цветовых наложений включены в этот треугольник, а затем называет фрагментный шейдер один раз для каждого из этих пикселей. Фрагментный шейдер возвращает цвет, обычно рассчитываемый по значениям, отправляемым ему из вершинного шейдера и активов, таких как текстуры, которые GPU пишет для прикрепления цвета.
Так же, как вершины, фрагментные шейдеры выполняются массово параллельно. Они немного более гибкие, чем вершины с точки зрения их входов и выходов, но вы можете рассмотреть их, чтобы просто вернуть один цвет для каждого пикселя каждого треугольника.
Функция фрагмента WGSL -фрагмента обозначена атрибутом @fragment
, а также возвращает vec4f
. В этом случае, однако, вектор представляет собой цвет, а не позицию. Обратном значении необходимо получить атрибут @location
, чтобы указать, какую colorAttachment
из beginRenderPass
вызовов записан возвращенный цвет. Поскольку у вас было только одно вложение, место - 0.
- Создайте пустую функцию
@fragment
, например:
index.html (CreateShadermodule Code)
@fragment
fn fragmentMain() -> @location(0) vec4f {
}
Четыре компонента возвращаемого вектора - это значения красного, зеленого, синего и альфа -цветов, которые интерпретируются точно так же, как и clearValue
, которое вы установили в beginRenderPass
ранее. Таким образом, vec4f(1, 0, 0, 1)
ярко -красный, что кажется приличным цветом для вашего квадрата. Вы можете установить его на любой цвет, который вы хотите, хотя!
- Установите возвращенный цветовой вектор, например,:
index.html (CreateShadermodule Code)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}
И это полный фрагментный шейдер! Это не очень интересный; Это просто устанавливает каждый пиксель каждого треугольника на красный, но сейчас этого достаточно.
Просто чтобы резюме, после добавления кода шейдера, подробно описанным выше, ваш звонок createShaderModule
теперь выглядит так:
index.html
const cellShaderModule = device.createShaderModule({
label: 'Cell shader',
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
`
});
Создать конвейер рендеринга
Модуль шейдера не может быть использован для рендеринга самостоятельно. Вместо этого вы должны использовать его как часть GPURenderPipeline
, созданную Calling Device.createrenderPipeline () . Трубопровод рендеринга контролирует, как нарисована геометрия, в том числе такие вещи, как используются шейдеры, как интерпретировать данные в буферах вершины, которые должны быть отображены (линии, точки, треугольники ...) и многое другое!
Руководитель рендеринга является самым сложным объектом во всем API, но не волнуйтесь! Большинство значений, которые вы можете передать к нему, являются необязательными, и вам нужно только дать несколько для начала.
- Создайте конвейер рендеринга, например:
index.html
const cellPipeline = device.createRenderPipeline({
label: "Cell pipeline",
layout: "auto",
vertex: {
module: cellShaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: cellShaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
Каждому трубопроводу нуждается layout
, которая описывает, какие типы входов (кроме буферов вершин) необходимы трубопровод, но у вас нет их. К счастью, вы можете пройти "auto"
на данный момент, и трубопровод строит свой собственный макет у шейдеров.
Далее вы должны предоставить подробную информацию о стадии vertex
. module
- это Gpushadermodule, который содержит ваш вертексный шейдер, а entryPoint
дает имя функции в коде шейдера, которая требуется для каждого вызова вершины. (Вы можете иметь несколько функций @vertex
и @fragment
в одном модуле шейдера!) Буферы представляют собой массив объектов GPUVertexBufferLayout
, которые описывают, как ваши данные упакованы в буферы вершины, с которыми вы используете этот трубопровод. К счастью, вы уже определили это ранее в своей vertexBufferLayout
! Вот где вы проходите.
Наконец, у вас есть подробности о стадии fragment
. Это также включает в себя шейдерный модуль и точку входа , как этап вершины. Последний бит - определить targets
, с которыми используется этот трубопровод. Это массив словарей, дающих детали, такие как format
текстуры, - цветовые прикрепления, к которым выводит трубопровод. Эти детали должны соответствовать текстурам, приведенным в colorAttachments
любых проходов рендеринга, с которыми используется этот трубопровод. Ваш рендеринговый проход использует текстуры из контекста Canvas и использует значение, которое вы сохранили в canvasFormat
для его формата, поэтому вы передаете здесь тот же формат.
Это даже не близко ко всем вариантам, которые вы можете указать при создании рендерингового конвейера, но этого достаточно для потребностей этого коделаба!
Нарисуйте квадрат
И с этим, теперь у вас есть все, что вам нужно, чтобы нарисовать ваш квадрат!
- Чтобы нарисовать квадрат, спрыгните обратно в
encoder.beginRenderPass()
иpass.end()
пару вызовов, а затем добавьте эти новые команды между ними:
index.html
// After encoder.beginRenderPass()
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices
// before pass.end()
Это снабжает WebGPU всю информацию, необходимую для рисования вашего квадрата. Во -первых, вы используете setPipeline()
чтобы указать, какой трубопровод следует использовать для рисования. Это включает в себя используемые шейдеры, макет данных вершины и другие соответствующие данные состояния.
Затем вы вызовите setVertexBuffer()
с буфером, содержащим вершины для вашего квадрата. Вы называете его с 0
потому что этот буфер соответствует 0th -элементу в текущем определении vertex.buffers
трубопровода.
И, наконец, вы делаете вызов draw()
, который кажется странно простым после всей установки, которая пришла раньше. Единственное, что вам нужно пройти, - это количество вершин, которые он должен отображать, которые он вытаскивает из в данный момент установленным вершинным буфером и интерпретирует с установленным в настоящее время трубопровод. Вы можете просто жестко кодировать его до 6
, но вычислять его из массива вершин (12 поплавок / 2 координат на вершину == 6 вершин) означает, что, если вы когда-нибудь решили заменить квадрат, например, кружком, меньше. обновить вручную.
- Обновите свой экран и (наконец) увидите результаты всей вашей тяжелой работы: один большой квадрат.
5. Нарисуйте сетку
Во -первых, найдите время, чтобы поздравить себя! Получение первых кусочков геометрии на экране часто является одним из самых сложных шагов с большинством API -интерфейсов GPU. Все, что вы делаете отсюда, можно сделать небольшими шагами, облегчая проверку вашего прогресса по ходу дела.
В этом разделе вы узнаете:
- Как передавать переменные (называемые униформой) в шейдер от JavaScript.
- Как использовать униформу, чтобы изменить поведение рендеринга.
- Как использовать Instancing, чтобы нарисовать много разных вариантов одной и той же геометрии.
Определите сетку
Чтобы отобрать сетку, вам нужно знать очень фундаментальную информацию об этом. Сколько ячеек он содержит, как по ширине, так и по высоте? Это зависит от вас как разработчика, но для того, чтобы сохранить ситуацию немного проще, рассматривать сетку как квадрат (та же ширина и высота) и использовать размер, который является силой двух. (Это облегчает математику позже.) В конце концов, вы хотите сделать ее больше, но для остальной части этого раздела установите размер вашей сетки на 4x4, потому что это облегчает демонстрацию некоторых из математики, используемой в этом разделе. Скамеруйте это после!
- Определите размер сетки, добавив константу в верхнюю часть кода JavaScript.
index.html
const GRID_SIZE = 4;
Затем вам нужно обновить, как вы визуализируете свой квадрат, чтобы вы могли установить GRID_SIZE
Times GRID_SIZE
на холсте. Это означает, что квадрат должен быть намного меньше, и их должно быть много.
Теперь, один из способов приблизиться к этому - сделать ваш буфер вершины значительно больше и определять квадраты GRID_SIZE
Times GRID_SIZE
внутри него в нужном размере и положении. На самом деле код для этого не был бы слишком плохим! Просто пара для петель и немного математики. Но это также не использует наилучшее использование графического процессора и использует больше памяти, чем необходимо для достижения эффекта. В этом разделе рассматривается более благоприятный GPU подход.
Создать унифицированный буфер
Во -первых, вам нужно сообщить размер сетки, который вы выбрали в шейдер, поскольку он использует это, чтобы изменить то, как все отображается. Вы можете просто жестко код размеру в шейдер, но это означает, что в любое время, когда вы хотите изменить размер сетки, вы должны воссоздать трубопровод шейдера и рендеринга, что дорого. Лучший способ - обеспечить размер сетки шейдеру как униформу .
Ранее вы узнали, что отличное значение от буфера вершины передается во все вызовы вершин -шейдера. Униформа - это значение из буфера, который одинаково для каждого вызова. Они полезны для передачи значений, которые являются общими для части геометрии (например, ее позиции), полной кадры анимации (например, текущего времени) или даже всей жизни приложения (например, предпочтения пользователя).
- Создайте унифицированный буфер, добавив следующий код:
index.html
// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
label: "Grid Uniforms",
size: uniformArray.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);
Это должно выглядеть очень знакомо, потому что это почти тот же код, который вы использовали для создания буфера вершины раньше! Это связано с тем, что униформы передаются в API WebGPU через те же объекты Gpubuffer, что и вершины, с основным отличием заключается в том, что usage
на этот раз включает в себя GPUBufferUsage.UNIFORM
вместо GPUBufferUsage.VERTEX
.
Доступ к форме в шейдере
- Определите форму, добавив следующий код:
index.html (CreateShadermodule Call)
// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos / grid, 0, 1);
}
// ...fragmentMain is unchanged
Это определяет униформу в вашем шейдере, называемой grid
, которая представляет собой 2D -поплавок, который соответствует массиву, который вы только что скопировали в равномерный буфер. Это также указывает, что униформа связана с @group(0)
и @binding(0)
. Вы узнаете, что эти ценности означают через мгновение.
Затем, в другом месте в коде шейдера, вы можете использовать вектор сетки, как вам нужно. В этом коде вы разделяете позицию вершины на вектор сетки. Поскольку pos
является 2D-вектором, а grid
-это 2D-вектор, WGSL выполняет компонентное подразделение. Другими словами, результат такой же, как и высказывание vec2f(pos.x / grid.x, pos.y / grid.y)
.
Эти типы векторных операций очень распространены в шейдерах графических процессоров, так как многие рендеринг и вычисления полагаются на них!
В вашем случае это означает, что если вы использовали размер сетки 4), квадрат, который вы делаете, составит одну четвертую ее первоначального размера. Это идеально, если вы хотите установить четыре из них в ряд или столбца!
Создать группу привязки
Объявление формы в шейдере не соединяет ее с созданным вами буфером. Чтобы сделать это, вам нужно создать и установить группу Bind .
Группа Bind - это коллекция ресурсов, которые вы хотите сделать доступным для вашего шейдера одновременно. Он может включать несколько типов буферов, таких как ваш унифицированный буфер, и другие ресурсы, такие как текстуры и пробоотборники, которые здесь не покрыты, но являются общими частями методов рендеринга WebGPU.
- Создайте группу Bind с вашим равномерным буфером, добавив следующий код после создания равномерного буфера и рендеринга:
index.html
const bindGroup = device.createBindGroup({
label: "Cell renderer bind group",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}],
});
В дополнение к вашему ныне стандартному label
вам также нужен layout
, который описывает, какие типы ресурсов содержит эта группа связывания. Это то, что вы копаете дальше на будущем шаге, но на данный момент вы можете с радостью попросить ваш трубопровод для макета группы Bind, потому что вы создали трубопровод с layout: "auto"
. Это заставляет трубопровод автоматически создавать макеты групп Bind из привязки, которые вы объявили в самом коде шейдера. В этом случае вы просите его getBindGroupLayout(0)
, где 0
соответствует @group(0)
которую вы напечатали в шейдере.
После указания макета вы предоставляете множество entries
. Каждая запись представляет собой словарь, по крайней мере, со следующими значениями:
-
binding
, которая соответствует значению@binding()
которое вы ввели в шейдере. В этом случае0
. -
resource
, который является фактическим ресурсом, который вы хотите подвергнуть переменной при указанном индексе привязки. В этом случае ваш унифицированный буфер.
Функция возвращает GPUBindGroup
, которая является непрозрачной, неизменной ручкой. Вы не можете изменить ресурсы, на которые указывает группа Bind после ее создания, хотя вы можете изменить содержание этих ресурсов. Например, если вы измените унифицированный буфер, чтобы содержать новый размер сетки, который отражается в будущих вызовах рисования с использованием этой группы привязки.
Связывать группу связей
Теперь, когда группа Bind создана, вам все равно нужно сообщить WebGPU использовать его при рисовании. К счастью, это довольно просто.
- Спрыгните обратно к пропуску рендеринга и добавьте эту новую линию перед методом
draw()
:
index.html
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.setBindGroup(0, bindGroup); // New line!
pass.draw(vertices.length / 2);
0
прошел как первый аргумент соответствует @group(0)
в коде шейдера. Вы говорите, что каждая @binding
, которая является частью @group(0)
использует ресурсы в этой группе Bind.
И теперь унифицированный буфер воздействует на ваш шейдер!
- Обновите свою страницу, и тогда вы должны увидеть что -то вроде этого:
Ура! Ваш квадрат сейчас на четверть, чем размер, это было раньше! Это не так много, но это показывает, что ваша форма фактически применяется и что шейдер теперь может получить доступ к размеру вашей сетки.
Манипулировать геометрией в шейдере
Итак, теперь, когда вы можете ссылаться на размер сетки в шейдере, вы можете начать выполнять некоторую работу, чтобы манипулировать геометрией, которую вы видите, чтобы соответствовать желаемому рисунку сетки. Для этого подумайте, чего именно вы хотите достичь.
Вам нужно концептуально разделить свой холст на отдельные ячейки. Чтобы сохранить соглашение, что ось x увеличивается при движении направо, и ось Y увеличивается при движении вверх, скажем, что первая ячейка находится в левом нижнем углу холста. Это дает вам макет, которая выглядит так, с вашей нынешней квадратной геометрией в середине:
Ваша задача состоит в том, чтобы найти метод в шейдере, который позволяет расположить квадратную геометрию в любой из этих ячеек, учитывая координаты ячейки.
Во -первых, вы можете видеть, что ваш квадрат не очень хорошо выровнен ни с одной из ячеек, потому что он был определен для окружения центра холста. Вы хотели бы, чтобы квадрат сместился на половину ячейки, чтобы он хорошо выстроился в них.
Один из способов исправить это - обновить буфер вершины Square. Сдвинув вершины так, чтобы в левом нижнем углу находился, например, (0,1, 0,1) вместо (-0,8, -0,8), вы будете перемещать этот квадрат, чтобы более красиво перемещать этот квадрат с границами ячейки. Но, поскольку вы имеете полный контроль над тем, как вершины обрабатываются в вашем шейдере, так же легко просто подтолкнуть их на место с помощью кода шейдера!
- Измените модуль вершины шейдера со следующим кодом:
index.html (CreateShadermodule Call)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
// Add 1 to the position before dividing by the grid size.
let gridPos = (pos + 1) / grid;
return vec4f(gridPos, 0, 1);
}
Это перемещает каждую вершину вверх и вправо на одну (которая, помня, является половиной пространства клипа), прежде чем делить ее на размер сетки. Результатом является красиво выровненная сетка квадрат недалеко от происхождения.
Далее, потому что ваш координатный холст помещает (0, 0) в центре и (-1, -1) в левом нижнем углу, и вы хотите (0, 0), чтобы быть в нижней части слева, вам нужно перевести геометрию Положение по (-1, -1) после деления на размер сетки, чтобы переместить ее в этот угол.
- Переведите позицию вашей геометрии, как это:
index.html (CreateShadermodule Call)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
// Subtract 1 after dividing by the grid size.
let gridPos = (pos + 1) / grid - 1;
return vec4f(gridPos, 0, 1);
}
И теперь ваш квадрат хорошо расположен в ячейке (0, 0)!
Что если вы хотите поместить его в другую ячейку? Поместите это, объявив вектор cell
в вашем шейдере и заполнив его статическим значением, таким как let cell = vec2f(1, 1)
.
Если вы добавите это в gridPos
, это отменяет - 1
в алгоритме, так что это не то, что вы хотите. Вместо этого вы хотите переместить квадрат только на одну сетку (одна четверть холста) для каждой ячейки. Похоже, вам нужно сделать еще один разрыв по grid
!
- Измените позиционирование сетки, например,:
index.html (CreateShadermodule Call)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
let cell = vec2f(1, 1); // Cell(1,1) in the image above
let cellOffset = cell / grid; // Compute the offset to cell
let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!
return vec4f(gridPos, 0, 1);
}
Если вы обновите сейчас, вы видите следующее:
Хм. Не совсем то, что вы хотели.
Причина этого в том, что, поскольку координаты холста переходят от -1 до +1, на самом деле это 2 единицы . Это означает, что если вы хотите переместить вершину на четверть холста, вы должны переместить ее на 0,5 единицы. Это легкая ошибка, чтобы совершить при рассуждении с координатами графического процессора! К счастью, исправление так же просто.
- Умножьте смещение на 2, например,:
index.html (CreateShadermodule Call)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
let cell = vec2f(1, 1);
let cellOffset = cell / grid * 2; // Updated
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
И это дает вам именно то, что вы хотите.
Скриншот выглядит так:
Кроме того, теперь вы можете установить cell
на любое значение в пределах границ сетки, а затем обновить, чтобы увидеть квадратный рендеринг в желаемом месте.
Рисовать экземпляры
Теперь, когда вы можете поместить квадрат, где вы хотите, с небольшим количеством математики, следующим шагом является отображение по одному квадрату в каждой ячейке сетки.
Один из способов, которым вы можете приблизиться к нему, - это написать координаты ячейки в равномерный буфер, а затем вызовать рисование один раз для каждого квадрата в сетке, каждый раз обновляя униформу. Однако это было бы очень медленным, так как графический процессор должен ждать, когда новая координата будет писать JavaScript каждый раз. Один из ключей к получению хорошей производительности от графического процессора - минимизировать время, которое он тратит на ожидание на других частях системы!
Вместо этого вы можете использовать технику, называемую Instancing. Инстанция - это способ сказать GPU, чтобы нарисовать несколько копий одной и той же геометрии с одним вызовом для draw
, что намного быстрее, чем вызов draw
один раз для каждой копии. Каждая копия геометрии называется экземпляром .
- Чтобы сказать GPU, что вы хотите достаточно экземпляров вашего квадрата, чтобы заполнить сетку, добавьте один аргумент к существующему вызову рисования:
index.html
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
Это говорит системе, что вы хотите, чтобы она нарисовала шесть ( vertices.length / 2
GRID_SIZE * GRID_SIZE
Но если вы обновите страницу, вы все равно видите следующее:
Почему? Ну, это потому, что вы рисуете все 16 из этих квадратов в одном месте. Вам необходимо иметь некоторую дополнительную логику в шейдере, которая перемещает геометрию на основе каждого.
В шейдере, в дополнение к атрибутам вершины, таким как pos
, которые поступают из вашего буфера вершины, вы также можете получить доступ к тому, что известны как встроенные значения WGSL. Это значения, которые рассчитываются WebGPU, и одним из таких значений является instance_index
. instance_index
- это 32 -разрядное 32 -разрядное число от 0
до number of instances - 1
, который вы можете использовать как часть вашей логики шейдера. Его значение одинаково для каждой обработанной вершины, которая является частью одного и того же экземпляра. Это означает, что ваш вертексный шейдер называется шесть раз с помощью instance_index
0
, один раз для каждой позиции в вашем буфере вершины. Затем еще шесть раз с instance_index
из 1
, затем еще шесть с instance_index
из 2
и так далее.
Чтобы увидеть это в действии, вы должны добавить встроенный instance_index
к входу шейдера. Сделайте это так же, как и позиция, но вместо того, чтобы пометить его атрибутом @location
, используйте @builtin(instance_index)
, а затем назовите аргумент, что вы хотите. (Вы можете вызвать его instance
, чтобы соответствовать примеру кода.) Затем используйте его как часть логики шейдеров!
- Используйте
instance
вместо координат ячейки:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance); // Save the instance_index as a float
let cell = vec2f(i, i); // Updated
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
Если вы обновляете теперь, вы видите, что у вас действительно есть более одного квадрата! Но вы не можете увидеть все 16 из них.
Это связано с тем, что координаты ячейки, которые вы генерируете, являются (0, 0), (1, 1), (2, 2) ... вплоть до (15, 15), но только первые четыре из тех, кто подходит на холсте. Чтобы сделать нужную сетку, вам необходимо преобразовать instance_index
так, чтобы каждый индекс отображал в уникальную ячейку в вашей сетке, например:
Математика для этого достаточно проста. Для значения x каждой ячейки вы хотите, чтобы модул instance_index
и ширина сетки, которые вы можете выполнить в WGSL с оператором %
. И для значения y каждой ячейки вы хотите, чтобы instance_index
делил на ширину сетки, отбрасывая любые дробные остатки. Вы можете сделать это с помощью функции Wgsl's floor()
.
- Измените расчеты, например:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance);
// Compute the cell coordinate from the instance_index
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
После этого обновления в коде у вас наконец-то есть долгожданная сетка квадратов!
- А теперь, когда он работает, вернитесь и запустите размер сетки!
index.html
const GRID_SIZE = 32;
Тада! Вы можете сделать эту сетку действительно, очень большой сейчас, и ваш обычный графический процессор обрабатывает ее просто отлично. Вы перестанете видеть отдельные квадраты задолго до того, как столкнетесь с любыми узкими местами производительности GPU.
6. Дополнительный кредит: Сделайте его более красочным!
На этом этапе вы можете легко перейти к следующему разделу, поскольку вы заложили основу для остальной части CodeLab. Но в то время как сетка квадратов, разделяющая один и тот же цвет, является исправной, она не совсем захватывающая, не так ли? К счастью, вы можете сделать вещи немного ярче с немного большим количеством математического и шейдера!
Используйте структуры в шейдерах
До сих пор вы проходили одну часть данных из вершин -шейдера: преобразованное положение. Но на самом деле вы можете вернуть гораздо больше данных из вершин -шейдера, а затем использовать его в фрагментной шейдере!
Единственный способ пропустить данные из вершин -шейдера - это вернуть их. Вершино -шейдер всегда требуется, чтобы вернуть позицию, поэтому, если вы хотите вернуть любые другие данные вместе с ним, вам нужно поместить ее в структуру. Структы в WGSL называются типами объектов, которые содержат одно или несколько названных свойств. Свойства могут быть отмечены такими атрибутами, как @builtin
и @location
. Вы объявляете их вне каких -либо функций, а затем вы можете передать их экземпляры в функциях и выходить из функций по мере необходимости. Например, рассмотрим свой текущий вершинный шейдер:
index.html (CreateShadermodule Call)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
- Выразите то же самое, используя структуры для функции ввода и вывода:
index.html (CreateShadermodule Call)
struct VertexInput {
@location(0) pos: vec2f,
@builtin(instance_index) instance: u32,
};
struct VertexOutput {
@builtin(position) pos: vec4f,
};
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(input: VertexInput) -> VertexOutput {
let i = f32(input.instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
return output;
}
Обратите внимание, что это требует, чтобы вы обратились к входному положению и индексу экземпляра с input
, и структуру, которую вы возвращаете, сначала необходимо объявить как переменную и иметь свои индивидуальные свойства. В этом случае это не имеет слишком большого значения и на самом деле делает шейдер немного дольше, но по мере того, как ваши шейдеры становятся более сложными, использование структур может быть отличным способом помочь организовать ваши данные.
Передайте данные между функциями вершины и фрагмента
Как напоминание, ваша функция @fragment
максимально проста:
index.html (CreateShadermodule Call)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
Вы не принимаете никаких входов, и вы раздаете твердый цвет (красный) в качестве своего выхода. Если шейдер знал больше о геометрии, что она окраска, вы могли бы использовать эти дополнительные данные, чтобы сделать вещи более интересными. Например, что, если вы хотите изменить цвет каждого квадрата на основе его координаты ячейки? Стадия @vertex
знает, какая ячейка отображается; Вам просто нужно передать его на стадию @fragment
.
Чтобы передавать любые данные между этапами вершины и фрагмента, вам необходимо включить их в выходной структуре с @location
по нашему выбору. Поскольку вы хотите передать координату ячейки, добавьте ее в структуру VertexOutput
из ранее, а затем установите ее в функции @vertex
, прежде чем вернуть.
- Измените возвращаемое значение вашего вершинного шейдера, например,:
index.html (CreateShadermodule Call)
struct VertexInput {
@location(0) pos: vec2f,
@builtin(instance_index) instance: u32,
};
struct VertexOutput {
@builtin(position) pos: vec4f,
@location(0) cell: vec2f, // New line!
};
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(input: VertexInput) -> VertexOutput {
let i = f32(input.instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
output.cell = cell; // New line!
return output;
}
- В функции
@fragment
получите значение, добавив аргумент с тем же@location
. (Имена не должны совпадать, но легче отслеживать вещи, если они это сделают!)
index.html (CreateShadermodule Call)
@fragment
fn fragmentMain(@location(0) cell: vec2f) -> @location(0) vec4f {
// Remember, fragment return values are (Red, Green, Blue, Alpha)
// and since cell is a 2D vector, this is equivalent to:
// (Red = cell.x, Green = cell.y, Blue = 0, Alpha = 1)
return vec4f(cell, 0, 1);
}
- В качестве альтернативы, вместо этого вы можете использовать структуру:
index.html (CreateShadermodule Call)
struct FragInput {
@location(0) cell: vec2f,
};
@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
- Другая альтернатива, поскольку в вашем коде обе эти функции определены в одном и том же модуле шейдера, - это повторное использование выходной структуры
@vertex
Stage! Это облегчает проходные значения, потому что имена и местоположения, естественно, согласованы.
index.html (CreateShadermodule Call)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
Независимо от того, какой шаблон вы выбрали, в результате вы имеете доступ к номеру ячейки в функции @fragment
и можете использовать его, чтобы повлиять на цвет. С любым из вышеперечисленного кода, выход выглядит так:
Сейчас определенно больше цветов, но это не совсем красиво. Вы можете задаться вопросом, почему только левые и нижние ряды разные. Это связано с тем, что значения цвета, которые вы возвращаете из функции @fragment
ожидают, что каждый канал будет в диапазоне от 0 до 1, и любые значения за пределами этого диапазона привязаны к нему. С другой стороны, значения ваших ячеек варьируются от 0 до 32 вдоль каждой оси. Итак, вы видите здесь, что первая строка и столбец сразу же достигли этого полного значения 1 на канале красного или зеленого цвета, и каждая ячейка после этого зажима до того же значения.
Если вам нужен более гладкий переход между цветами, вам нужно вернуть дробное значение для каждого цветового канала, в идеале начинается с нуля и заканчиваясь на одну вдоль каждой оси, что означает еще один разрыв с помощью grid
!
- Измените фрагментный шейдер, как это:
index.html (CreateShadermodule Call)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell/grid, 0, 1);
}
Обновите страницу, и вы можете видеть, что новый код дает вам гораздо более приятный градиент цветов по всей сетке.
Хотя это, безусловно, улучшение, теперь в левом нижнем углу есть неудачный темный угол, где сетка становится черной. Когда вы начнете делать игру в симуляцию жизни, трудный для просмотра участок сетки будет скрывать то, что происходит. Было бы неплохо украсить это.
К счастью, у вас есть целый неиспользованный цветовой канал - Blue - который вы можете использовать. Эффект, который вы в идеале хотите, состоит в том, чтобы синий был самым ярким, где другие цвета самые темные, а затем исчезают по мере того, как другие цвета растут в интенсивности. Самый простой способ сделать это - запустить канал с 1 и вычесть одно из значений ячейки. Это может быть либо cx
либо cy
. Попробуйте оба, а затем выберите тот, который вы предпочитаете!
- Добавьте яркие цвета в фрагментный шейдер, например,:
CreateShaderModule Call
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
let c = input.cell / grid;
return vec4f(c, 1-c.x, 1);
}
Результат выглядит довольно красиво!
Это не критический шаг! Но поскольку это выглядит лучше, он включен в соответствующий исходный файл контрольной точки, а остальные скриншоты в этом коделабе отражают эту более красочную сетку.
7. Управление состоянием ячейки
Затем вам необходимо контролировать, какие ячейки на рендеринге сетки на основе какого -то состояния хранятся на графическом процессоре. Это важно для окончательного симуляции!
Все, что вам нужно, это сигнал отключения для каждой ячейки, поэтому любые параметры, которые позволяют вам хранить большой массив практически любого типа значения. Вы можете подумать, что это еще один вариант использования для унифицированных буферов! While you could make that work, it's more difficult because uniform buffers are limited in size, can't support dynamically sized arrays (you have to specify the array size in the shader), and can't be written to by compute shaders. That last item is the most problematic, since you want to do the Game of Life simulation on the GPU in a compute shader.
Fortunately, there's another buffer option that avoids all of those limitations.
Create a storage buffer
Storage buffers are general-use buffers that can be read and written to in compute shaders, and read in vertex shaders. They can be very large, and they don't need a specific declared size in a shader, which makes them much more like general memory. That's what you use to store the cell state.
- To create a storage buffer for your cell state, use what—by now—is probably starting to be a familiar-looking snippet of buffer creation code:
index.html
// Create an array representing the active state of each cell.
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE);
// Create a storage buffer to hold the cell state.
const cellStateStorage = device.createBuffer({
label: "Cell State",
size: cellStateArray.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
Just like with your vertex and uniform buffers, call device.createBuffer()
with the appropriate size, and then make sure to specify a usage of GPUBufferUsage.STORAGE
this time.
You can populate the buffer the same way as before by filling the TypedArray of the same size with values and then calling device.queue.writeBuffer()
. Because you want to see the effect of your buffer on the grid, start by filling it with something predictable.
- Activate every third cell with the following code:
index.html
// Mark every third cell of the grid as active.
for (let i = 0; i < cellStateArray.length; i += 3) {
cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage, 0, cellStateArray);
Read the storage buffer in the shader
Next, update your shader to look at the contents of the storage buffer before you render the grid. This looks very similar to how uniforms were added previously.
- Update your shader with the following code:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!
First, you add the binding point, which tucks right underneath the grid uniform. You want to keep the same @group
as the grid
uniform, but the @binding
number needs to be different. The var
type is storage
, in order to reflect the different type of buffer, and rather than a single vector, the type that you give for the cellState
is an array of u32
values, in order to match the Uint32Array
in JavaScript.
Next, in the body of your @vertex
function, query the cell's state. Because the state is stored in a flat array in the storage buffer, you can use the instance_index
in order to look up the value for the current cell!
How do you turn off a cell if the state says that it's inactive? Well, since the active and inactive states that you get from the array are 1 or 0, you can scale the geometry by the active state! Scaling it by 1 leaves the geometry alone, and scaling it by 0 makes the geometry collapse into a single point, which the GPU then discards.
- Update your shader code to scale the position by the cell's active state. The state value must be cast to a
f32
in order to satisfy WGSL's type safety requirements:
index.html
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) -> VertexOutput {
let i = f32(instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let state = f32(cellState[instance]); // New line!
let cellOffset = cell / grid * 2;
// New: Scale the position by the cell's active state.
let gridPos = (pos*state+1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
output.cell = cell;
return output;
}
Add the storage buffer to the bind group
Before you can see the cell state take effect, add the storage buffer to a bind group. Because it's part of the same @group
as the uniform buffer, add it to the same bind group in the JavaScript code, as well.
- Add the storage buffer, like this:
index.html
const bindGroup = device.createBindGroup({
label: "Cell renderer bind group",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
},
// New entry!
{
binding: 1,
resource: { buffer: cellStateStorage }
}],
});
Make sure that the binding
of the new entry matches the @binding()
of the corresponding value in the shader!
With that in place, you should be able to refresh and see the pattern appear in the grid.
Use the ping-pong buffer pattern
Most simulations like the one you're building typically use at least two copies of their state. On each step of the simulation, they read from one copy of the state and write to the other. Then, on the next step, flip it and read from the state they wrote to previously. This is commonly referred to as a ping pong pattern because the most up-to-date version of the state bounces back and forth between state copies each step.
Why is that necessary? Look at a simplified example: imagine that you're writing a very simple simulation in which you move any active blocks right by one cell each step. To keep things easy to understand, you define your data and simulation in JavaScript:
// Example simulation. Don't copy into the project!
const state = [1, 0, 0, 0, 0, 0, 0, 0];
function simulate() {
for (let i = 0; i < state.length-1; ++i) {
if (state[i] == 1) {
state[i] = 0;
state[i+1] = 1;
}
}
}
simulate(); // Run the simulation for one step.
But if you run that code, the active cell moves all the way to the end of the array in one step! Почему? Because you keep updating the state in-place, so you move the active cell right, and then you look at the next cell and... hey! It's active! Better move it to the right again. The fact that you change the data at the same time that you observe it corrupts the results.
By using the ping pong pattern, you ensure that you always perform the next step of the simulation using only the results of the last step.
// Example simulation. Don't copy into the project!
const stateA = [1, 0, 0, 0, 0, 0, 0, 0];
const stateB = [0, 0, 0, 0, 0, 0, 0, 0];
function simulate(inArray, outArray) {
outArray[0] = 0;
for (let i = 1; i < inArray.length; ++i) {
outArray[i] = inArray[i-1];
}
}
// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA);
- Use this pattern in your own code by updating your storage buffer allocation in order to create two identical buffers:
index.html
// Create two storage buffers to hold the cell state.
const cellStateStorage = [
device.createBuffer({
label: "Cell State A",
size: cellStateArray.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
}),
device.createBuffer({
label: "Cell State B",
size: cellStateArray.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
})
];
- To help visualize the difference between the two buffers, fill them with different data:
index.html
// Mark every third cell of the first grid as active.
for (let i = 0; i < cellStateArray.length; i+=3) {
cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);
// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
- To show the different storage buffers in your rendering, update your bind groups to have two different variants, as well:
index.html
const bindGroups = [
device.createBindGroup({
label: "Cell renderer bind group A",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[0] }
}],
}),
device.createBindGroup({
label: "Cell renderer bind group B",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[1] }
}],
})
];
Set up a render loop
So far, you've only done one draw per page refresh, but now you want to show data updating over time. To do that you need a simple render loop.
A render loop is an endlessly repeating loop that draws your content to the canvas at a certain interval. Many games and other content that want to animate smoothly use the requestAnimationFrame()
function to schedule callbacks at the same rate that the screen refreshes (60 times every second).
This app can use that, as well, but in this case, you probably want updates to happen in longer steps so that you can more easily follow what the simulation is doing. Manage the loop yourself instead so that you can control the rate at which your simulation updates.
- First, pick a rate for our simulation to update at (200ms is good, but you can go slower or faster if you like), and then keep track of how many steps of simulation have been completed.
index.html
const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
- Then move all of the code you currently use for rendering into a new function. Schedule that function to repeat at your desired interval with
setInterval()
. Make sure that the function also updates the step count, and use that to pick which of the two bind groups to bind.
index.html
// Move all of our rendering code into a function
function updateGrid() {
step++; // Increment the step count
// Start a render pass
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0, g: 0, b: 0.4, a: 1.0 },
storeOp: "store",
}]
});
// Draw the grid.
pass.setPipeline(cellPipeline);
pass.setBindGroup(0, bindGroups[step % 2]); // Updated!
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
// End the render pass and submit the command buffer
pass.end();
device.queue.submit([encoder.finish()]);
}
// Schedule updateGrid() to run repeatedly
setInterval(updateGrid, UPDATE_INTERVAL);
And now when you run the app you see that the canvas flips back and forth between showing the two state buffers you created.
With that, you're pretty much done with the rendering side of things! You're all set to display the output of the Game of Life simulation you build in the next step, where you finally start using compute shaders.
Obviously there is so much more to WebGPU's rendering capabilities than the tiny slice that you explored here, but the rest is beyond the scope of this codelab. Hopefully, it gives you enough of a taste of how WebGPU's rendering works, though, that it helps make exploring more advanced techniques like 3D rendering easier to grasp.
8. Run the simulation
Now, for the last major piece of the puzzle: performing the Game of Life simulation in a compute shader!
Use compute shaders, at last!
You've learned abstractly about compute shaders throughout this codelab, but what exactly are they?
A compute shader is similar to vertex and fragment shaders in that they are designed to run with extreme parallelism on the GPU, but unlike the other two shader stages, they don't have a specific set of inputs and outputs. You are reading and writing data exclusively from sources you choose, like storage buffers. This means that instead of executing once for each vertex, instance, or pixel, you have to tell it how many invocations of the shader function you want. Then, when you run the shader, you are told which invocation is being processed, and you can decide what data you are going to access and which operations you are going to perform from there.
Compute shaders must be created in a shader module, just like vertex and fragment shaders, so add that to your code to get started. As you might guess, given the structure of the other shaders that you've implemented, the main function for your compute shader needs to be marked with the @compute
attribute.
- Create a compute shader with the following code:
index.html
// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
label: "Game of Life simulation shader",
code: `
@compute
fn computeMain() {
}`
});
Because GPUs are used frequently for 3D graphics, compute shaders are structured such that you can request that the shader be invoked a specific number of times along an X, Y, and Z axis. This lets you very easily dispatch work that conforms to a 2D or 3D grid, which is great for your use case! You want to call this shader GRID_SIZE
times GRID_SIZE
times, once for each cell of your simulation.
Due to the nature of GPU hardware architecture, this grid is divided into workgroups . A workgroup has an X, Y, and Z size, and although the sizes can be 1 each, there are often performance benefits to making your workgroups a bit bigger. For your shader, choose a somewhat arbitrary workgroup size of 8 times 8. This is useful to keep track of in your JavaScript code.
- Define a constant for your workgroup size, like this:
index.html
const WORKGROUP_SIZE = 8;
You also need to add the workgroup size to the shader function itself, which you do using JavaScript's template literals so that you can easily use the constant you just defined.
- Add the workgroup size to the shader function, like this:
index.html (Compute createShaderModule call)
@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {
}
This tells the shader that work done with this function is done in (8 x 8 x 1) groups. (Any axis you leave off defaults to 1, although you have to at least specify the X axis.)
As with the other shader stages, there's a variety of @builtin
values that you can accept as input into your compute shader function in order to tell you which invocation you're on and decide what work you need to do.
- Add a
@builtin
value, like this:
index.html (Compute createShaderModule call)
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
You pass in the global_invocation_id
builtin, which is a three-dimensional vector of unsigned integers that tells you where in the grid of shader invocations you are. You run this shader once for each cell in your grid. You get numbers like (0, 0, 0)
, (1, 0, 0)
, (1, 1, 0)
... all the way to (31, 31, 0)
, which means that you can treat it as the cell index you're going to operate on!
Compute shaders can also use uniforms, which you use just like in the vertex and fragment shaders.
- Use a uniform with your compute shader to tell you the grid size, like this:
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f; // New line
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
Just like in the vertex shader, you also expose the cell state as a storage buffer. But in this case, you need two of them! Because compute shaders don't have a required output, like a vertex position or fragment color, writing values to a storage buffer or texture is the only way to get results out of a compute shader. Use the ping-pong method that you learned earlier; you have one storage buffer that feeds in the current state of the grid and one that you write out the new state of the grid to.
- Expose the cell input and output state as storage buffers, like this:
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f;
// New lines
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
Note that the first storage buffer is declared with var<storage>
, which makes it read-only, but the second storage buffer is declared with var<storage, read_write>
. This allows you to both read and write to the buffer, using that buffer as the output for your compute shader. (There is no write-only storage mode in WebGPU).
Next, you need to have a way to map your cell index into the linear storage array. This is basically the opposite of what you did in the vertex shader, where you took the linear instance_index
and mapped it to a 2D grid cell. (As a reminder, your algorithm for that was vec2f(i % grid.x, floor(i / grid.x))
.)
- Write a function to go in the other direction. It takes the cell's Y value, multiplies it by the grid width, and then adds the cell's X value.
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
// New function
fn cellIndex(cell: vec2u) -> u32 {
return cell.y * u32(grid.x) + cell.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
And, finally, to see that it's working, implement a really simple algorithm: if a cell is currently on, it turns off, and vice versa. It's not the Game of Life yet, but it's enough to show that the compute shader is working.
- Add the simple algorithm, like this:
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
fn cellIndex(cell: vec2u) -> u32 {
return cell.y * u32(grid.x) + cell.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// New lines. Flip the cell state every step.
if (cellStateIn[cellIndex(cell.xy)] == 1) {
cellStateOut[cellIndex(cell.xy)] = 0;
} else {
cellStateOut[cellIndex(cell.xy)] = 1;
}
}
And that's it for your compute shader—for now! But before you can see the results, there are a few more changes that you need to make.
Use Bind Group and Pipeline Layouts
One thing that you might notice from the above shader is that it largely uses the same inputs (uniforms and storage buffers) as your render pipeline. So you might think that you can simply use the same bind groups and be done with it, right? The good news is that you can! It just takes a bit more manual setup to be able to do that.
Any time that you create a bind group, you need to provide a GPUBindGroupLayout
. Previously, you got that layout by calling getBindGroupLayout()
on the render pipeline, which in turn created it automatically because you supplied layout: "auto"
when you created it. That approach works well when you only use a single pipeline, but if you have multiple pipelines that want to share resources, you need to create the layout explicitly, and then provide it to both the bind group and pipelines.
To help understand why, consider this: in your render pipelines you use a single uniform buffer and a single storage buffer, but in the compute shader you just wrote, you need a second storage buffer. Because the two shaders use the same @binding
values for the uniform and first storage buffer, you can share those between pipelines, and the render pipeline ignores the second storage buffer, which it doesn't use. You want to create a layout that describes all of the resources that are present in the bind group, not just the ones used by a specific pipeline.
- To create that layout, call
device.createBindGroupLayout()
:
index.html
// Create the bind group layout and pipeline layout.
const bindGroupLayout = device.createBindGroupLayout({
label: "Cell Bind Group Layout",
entries: [{
binding: 0,
// Add GPUShaderStage.FRAGMENT here if you are using the `grid` uniform in the fragment shader.
visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
buffer: {} // Grid uniform buffer
}, {
binding: 1,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
buffer: { type: "read-only-storage"} // Cell state input buffer
}, {
binding: 2,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: "storage"} // Cell state output buffer
}]
});
This is similar in structure to creating the bind group itself, in that you describe a list of entries
. The difference is that you describe what type of resource the entry must be and how it's used rather than providing the resource itself.
In each entry, you give the binding
number for the resource, which (as you learned when you created the bind group) matches the @binding
value in the shaders. You also provide the visibility
, which are GPUShaderStage
flags that indicate which shader stages can use the resource. You want both the uniform and first storage buffer to be accessible in the vertex and compute shaders, but the second storage buffer only needs to be accessible in compute shaders.
Finally, you indicate what type of resource is being used. This is a different dictionary key, depending on what you need to expose. Here, all three resources are buffers, so you use the buffer
key to define the options for each. Other options include things like texture
or sampler
, but you don't need those here.
In the buffer dictionary, you set options like what type
of buffer is used. The default is "uniform"
, so you can leave the dictionary empty for binding 0. (You do have to at least set buffer: {}
, though, so that the entry is identified as a buffer.) Binding 1 is given a type of "read-only-storage"
because you don't use it with read_write
access in the shader, and binding 2 has a type of "storage"
because you do use it with read_write
access!
Once the bindGroupLayout
is created, you can pass it in when creating your bind groups rather than querying the bind group from the pipeline. Doing so means that you need to add a new storage buffer entry to each bind group in order to match the layout you just defined.
- Update the bind group creation, like this:
index.html
// Create a bind group to pass the grid uniforms into the pipeline
const bindGroups = [
device.createBindGroup({
label: "Cell renderer bind group A",
layout: bindGroupLayout, // Updated Line
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[0] }
}, {
binding: 2, // New Entry
resource: { buffer: cellStateStorage[1] }
}],
}),
device.createBindGroup({
label: "Cell renderer bind group B",
layout: bindGroupLayout, // Updated Line
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[1] }
}, {
binding: 2, // New Entry
resource: { buffer: cellStateStorage[0] }
}],
}),
];
And now that the bind group has been updated to use this explicit bind group layout, you need to update the render pipeline to use the same thing.
- Create a
GPUPipelineLayout
.
index.html
const pipelineLayout = device.createPipelineLayout({
label: "Cell Pipeline Layout",
bindGroupLayouts: [ bindGroupLayout ],
});
A pipeline layout is a list of bind group layouts (in this case, you have one) that one or more pipelines use. The order of the bind group layouts in the array needs to correspond with the @group
attributes in the shaders. (This means that bindGroupLayout
is associated with @group(0)
.)
- Once you have the pipeline layout, update the render pipeline to use it instead of
"auto"
.
index.html
const cellPipeline = device.createRenderPipeline({
label: "Cell pipeline",
layout: pipelineLayout, // Updated!
vertex: {
module: cellShaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: cellShaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
Create the compute pipeline
Just like you need a render pipeline to use your vertex and fragment shaders, you need a compute pipeline to use your compute shader. Fortunately, compute pipelines are far less complicated than render pipelines, as they don't have any state to set, only the shader and layout.
- Create a compute pipeline with the following code:
index.html
// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
label: "Simulation pipeline",
layout: pipelineLayout,
compute: {
module: simulationShaderModule,
entryPoint: "computeMain",
}
});
Notice that you pass in the new pipelineLayout
instead of "auto"
, just like in the updated render pipeline, which ensures that both your render pipeline and your compute pipeline can use the same bind groups.
Compute passes
This brings you to the point of actually making use of the compute pipeline! Given that you do your rendering in a render pass, you can probably guess that you need to do compute work in a compute pass. Compute and render work can both happen in the same command encoder, so you want to shuffle your updateGrid
function a bit.
- Move the encoder creation to the top of the function, and then begin a compute pass with it (before the
step++
).
index.html
// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();
const computePass = encoder.beginComputePass();
// Compute work will go here...
computePass.end();
// Existing lines
step++; // Increment the step count
// Start a render pass...
Just like compute pipelines, compute passes are much simpler to kick off than their rendering counterparts because you don't need to worry about any attachments.
You want to do the compute pass before the render pass because it allows the render pass to immediately use the latest results from the compute pass. That's also the reason that you increment the step
count between the passes, so that the output buffer of the compute pipeline becomes the input buffer for the render pipeline.
- Next, set the pipeline and bind group inside the compute pass, using the same pattern for switching between bind groups as you do for the rendering pass.
index.html
const computePass = encoder.beginComputePass();
// New lines
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);
computePass.end();
- Finally, instead of drawing like in a render pass, you dispatch the work to the compute shader, telling it how many workgroups you want to execute on each axis.
index.html
const computePass = encoder.beginComputePass();
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);
// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);
computePass.end();
Something very important to note here is that the number you pass into dispatchWorkgroups()
is not the number of invocations! Instead, it's the number of workgroups to execute, as defined by the @workgroup_size
in your shader.
If you want the shader to execute 32x32 times in order to cover your entire grid, and your workgroup size is 8x8, you need to dispatch 4x4 workgroups (4 * 8 = 32). That's why you divide the grid size by the workgroup size and pass that value into dispatchWorkgroups()
.
Now you can refresh the page again, and you should see that the grid inverts itself with each update.
Implement the algorithm for the Game of Life
Before you update the compute shader to implement the final algorithm, you want to go back to the code that's initializing the storage buffer content and update it to produce a random buffer on each page load. (Regular patterns don't make for very interesting Game of Life starting points.) You can randomize the values however you want, but there's an easy way to start that gives reasonable results.
- To start each cell in a random state, update the
cellStateArray
initialization to the following code:
index.html
// Set each cell to a random state, then copy the JavaScript array
// into the storage buffer.
for (let i = 0; i < cellStateArray.length; ++i) {
cellStateArray[i] = Math.random() > 0.6 ? 1 : 0;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);
Now you can finally implement the logic for the Game of Life simulation. After everything it took to get here, the shader code may be disappointingly simple!
First, you need to know for any given cell how many of its neighbors are active. You don't care about which ones are active, only the count.
- To make getting neighboring cell data easier, add a
cellActive
function that returns thecellStateIn
value of the given coordinate.
index.html (Compute createShaderModule call)
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
The cellActive
function returns one if the cell is active, so adding the return value of calling cellActive
for all eight surrounding cells gives you how many neighboring cells are active.
- Find the number of active neighbors, like this:
index.html (Compute createShaderModule call)
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// New lines:
// Determine how many active neighbors this cell has.
let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
cellActive(cell.x+1, cell.y) +
cellActive(cell.x+1, cell.y-1) +
cellActive(cell.x, cell.y-1) +
cellActive(cell.x-1, cell.y-1) +
cellActive(cell.x-1, cell.y) +
cellActive(cell.x-1, cell.y+1) +
cellActive(cell.x, cell.y+1);
This leads to a minor problem, though: what happens when the cell you're checking is off the edge of the board? According to your cellIndex()
logic right now, it either overflows to the next or previous row, or runs off the edge of the buffer!
For the Game of Life, a common and easy way to resolve this is to have cells on the edge of the grid treat cells on the opposite edge of the grid as their neighbors, creating a kind of wrap-around effect.
- Support grid wrap-around with a minor change to the
cellIndex()
function.
index.html (Compute createShaderModule call)
fn cellIndex(cell: vec2u) -> u32 {
return (cell.y % u32(grid.y)) * u32(grid.x) +
(cell.x % u32(grid.x));
}
By using the %
operator to wrap the cell X and Y when it extends past the grid size, you ensure that you never access outside the storage buffer bounds. With that, you can rest assured that the activeNeighbors
count is predictable.
Then you apply one of four rules:
- Any cell with fewer than two neighbors becomes inactive.
- Any active cell with two or three neighbors stays active.
- Any inactive cell with exactly three neighbors becomes active.
- Any cell with more than three neighbors becomes inactive.
You can do this with a series of if statements, but WGSL also supports switch statements, which are a good fit for this logic.
- Implement the Game of Life logic, like this:
index.html (Compute createShaderModule call)
let i = cellIndex(cell.xy);
// Conway's game of life rules:
switch activeNeighbors {
case 2: { // Active cells with 2 neighbors stay active.
cellStateOut[i] = cellStateIn[i];
}
case 3: { // Cells with 3 neighbors become or stay active.
cellStateOut[i] = 1;
}
default: { // Cells with < 2 or > 3 neighbors become inactive.
cellStateOut[i] = 0;
}
}
For reference, the final compute shader module call now looks like this:
index.html
const simulationShaderModule = device.createShaderModule({
label: "Life simulation shader",
code: `
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
fn cellIndex(cell: vec2u) -> u32 {
return (cell.y % u32(grid.y)) * u32(grid.x) +
(cell.x % u32(grid.x));
}
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// Determine how many active neighbors this cell has.
let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
cellActive(cell.x+1, cell.y) +
cellActive(cell.x+1, cell.y-1) +
cellActive(cell.x, cell.y-1) +
cellActive(cell.x-1, cell.y-1) +
cellActive(cell.x-1, cell.y) +
cellActive(cell.x-1, cell.y+1) +
cellActive(cell.x, cell.y+1);
let i = cellIndex(cell.xy);
// Conway's game of life rules:
switch activeNeighbors {
case 2: {
cellStateOut[i] = cellStateIn[i];
}
case 3: {
cellStateOut[i] = 1;
}
default: {
cellStateOut[i] = 0;
}
}
}
`
});
And... that's it! Все готово! Refresh your page and watch your newly built cellular automaton grow!
9. Congratulations!
You created a version of the classic Conway's Game of Life simulation that runs entirely on your GPU using the WebGPU API!
Что дальше?
- Review the WebGPU Samples