В попередній статті ми помістили дані
вершин в буфер зберігання і проіндексували їх з допомогою вбудованої
змінної vertex_index. Не зважаючи на те, що ця техніка набуває
популярності, більш традиційним підходом вважається передача даних
вершин у шейдер з допомогою буферів вершин та атрибутів.
Буфери вершин є схожими на будь-які інші буфери в WebGPU - вони
зберігають дані. Основна різниця полягає в тому, що ми не маємо
до них прямого доступу з вершинного шейдера. Натомість, ми вказуємо
WebGPU на тип даних, який ми помістили в цей буфер і на те, як
вони розміщенні. Після цього WebGPU автоматично дістає ці дані
з буфера і надає їх нам.
Візьмемо для прикладу наш код з
попередньої статті
і змінимо в ньому використання буфера зберігання на використання
буфера вершин.
Спершу нам потрібно змінити шейдер таким чином, щоб він отримував
інформацію про вершини з буфера вершин.
Як ви можете бачити, це невеликі зміни. Важливим тут є декларування
поля position з атрибутом @location(0).
Далі ми миємо вказати WebGPU, як отримати дані для @location(0) -
для цього ми можемо використати пайплайн рендерингу:
const pipeline = device.createRenderPipeline({
label:'vertex buffer pipeline',
layout:'auto',
vertex:{
module,
buffers:[
{
arrayStride:2*4,// 2 floats, 4 bytes each
attributes:[
{shaderLocation:0, offset:0, format:'float32x2'},// position
],
},
],
},
fragment:{
module,
targets:[{ format: presentationFormat }],
},
});
До поля vertexдескриптора пайплайну ми додали
масив buffers, який використовується для опису отримання даних
з одного чи більше буферів вершин. Для нашого першого і єдиного
буфера ми встановлюємо значення arrayStride як кількість байтів.
we added a buffers array which is used to describe how to pull data out of 1 or more vertex buffers. stride або ж крок в цьому випадку
визначає як багато байтів потрібно взяти з набору даних для однієї
вершини з нашого буфера.
Оскільки наші дані мають тип vec2f, що по суті є двома float32
числами, ми встановлюємо в значення arrayStride число 8.
Далі ми оголошуємо масив атрибутів. Ми маємо тільки один атрибут.
shaderLocation: 0 відповідає атрибуту location(0) в нашій
структурі Vertex. offset: 0 позначає, що дані для цього
атрибуту починаються з нульового байту в буфері вершин. І нарешті
format: 'float32x2' позначає те, що ми просимо WebGPU витягнути
дані з буфера у форматі двох 32-бітних чисел з рухомою комою. (Нотатка: властивість attributes показана на
спрощеній діаграмі малювання з першої статті).
Тепер ми маємо змінити тип використання буфера, який міститиме
вершини з STORAGE на VERTEX і видалити його з групи прив’язки.
Атрибути можуть передаватись як для кожної вершини так і для кожного екземпляру.
Передавання для кожного екземпляру це практично те ж, що ми і робимо, коли
індексуємо otherStructs[instanceIndex] та ourStructs[instanceIndex], де
instanceIndex отримує своє значення з @builtin(instance_index).
Давайте позбавимось від буферів зберігання і використаємо буфери вершин для
отримання того ж результату. Спершу давайте змінимо шейдер так, щоб використовувати
атрибути вершин замість буферів зберігання.
Тепер ми повинні оновити наш пайплайн рендерингу для того, щоб
вказати на те, як ми хочемо надавати дані для цих атрибутів.
Для того, щоб зберегти кількість змін в коді мінімальною,
ми використаємо дані, які ми створили для буферів зберігання.
Ми використаємо два буфери: один буде зберігати значення color та
offset для кожного екземпляра, а інший буде зберігати значення
scale.
const pipeline = device.createRenderPipeline({
label:'flat colors',
layout:'auto',
vertex:{
module,
buffers:[
{
arrayStride:2*4,// 2 floats, 4 bytes each
attributes:[
{shaderLocation:0, offset:0, format:'float32x2'},// position
],
},
{
arrayStride:6*4,// 6 floats, 4 bytes each
stepMode:'instance',
attributes:[
{shaderLocation:1, offset:0, format:'float32x4'},// color
Вище ми додали 2 нових записи в масив buffers в нашому описі пайплайну тож
ми отримали 3 записи в буфері. Це означає, що ми вказуємо WebGPU на те, що
ми передаватимемо дані в 3 буферах.
Для двох нових записів ви встановили значення stepMode як instance. Це
означає, що атрибут буде оновлюватись до наступного значення раз на кожен
екземпляр. За замовчуванням stepMode має значення vertex, що вказує на
те, що оновлювати атрибут потрібно для кожної вершини (і починати заново
для кожного екземпляра).
Ми маємо 2 буфера. Один містить в собі лише значення scale. Як і наш
перший буфер, який містить значення position, він являє собою
два 32-бітних числа з рухомою комою на кожну вершину.
Два наступних буфери містять в собі значення color і offset та будуть
переплетені в таку на вигляд структуру даних:
Вище ми вказуємо на те, що значення arrayStride, яке позначає відстань
між наборами даних, дорівнює 6 * 4, або ж 6 32-бітових чисел з рухомою
комою по 4 байти кожен (24 байти разом). Значення color має зміщення
0, коли значення offset починається після 16 байту.
Далі, ми можемо змінити код та створити ці буфери.
Атрибути вершин не мають такі самі обмеження по вирівнюванню, як структури
в буферах зберігання, тому нам більше не потрібні ці вирівнювання. Все, що
ми тут зробили це зміна значення usage з STORAGE на VERTEX (ну і ми
також змінили назви змінних з “storage” на “vertex”).
Оскільки ми більше не використовуємо буфери зберігання, нам більше не
потрібна група прив’язки:
Далі нам потрібно оновити пайплайн для того, щоб описати, як ми
будемо надавати ці дані. Ми збираємось переплести дані perVertexColor з
даними position так, як на малюнку:
Тому, значення кроку arrayStride повинне бути змінене для відображення нашої
нової структури даних. Нові дані починаються після двох 32-бітних чисел
з рухомою комою, тому їхнє зміщення offset буде дорівнювати 8 байтам.
const pipeline = device.createRenderPipeline({
label:'per vertex color',
layout:'auto',
vertex:{
module,
buffers:[
{
arrayStride:2*4,// 2 floats, 4 bytes each
arrayStride:5*4,// 5 floats, 4 bytes each
attributes:[
{shaderLocation:0, offset:0, format:'float32x2'},// position
Це працює тому, що атрибути завжди мають 4 значення доступних в шейдері.
Їхні типові значення 0, 0, 0, 1, тому кожне значення, яке ми не передали
отримує одне з типових значень.
Використання нормалізованих значень для зберігання даних
Ми використовуємо 32-бітні числа з рухомою комою для кольорів. Кожен
perVertexColor містить 3 значення загальним розміром 12 байт для кожного
кольору кожної вершини. Кожен color має 4 значення, які в сумі дають
16 байт для кожного кольору кожного екземпляру.
Ми б могли оптимізувати це використовуючи 8-бітні значення і вказуючи
WebGPU, щоб він нормалізував ці значення з 0 ↔ 255 до 0.0 ↔ 1.0.
Переглянувши список валідних форматів атрибутів, ми не знайдемо 3-значного
8-бітного формату, але ми маємо 'unorm8x4'. Тому давайте використаємо його.
Спершу давайте змінимо код, який генерує вершини для зберігання кольорів, як
8-бітні значення, які будуть нормалізовані:
function createCircleVertices({
radius =1,
numSubdivisions =24,
innerRadius =0,
startAngle =0,
endAngle =Math.PI *2,
}={}){
// 2 triangles per subdivision, 3 verts per tri, 5 values (xyrgb) each.
Розміщення даних в пам’яті для кожного екземпляру виглядає так:
Далі нам потрібно змінити пайплайн для отримання даних у вигляді 8-бітних
беззнакових значень для нормалізації їх назад у вигляд 0 ↔ 1, оновити зміщення
та оновити крок для нового значення розміру.
const pipeline = device.createRenderPipeline({
label:'per vertex color',
layout:'auto',
vertex:{
module,
buffers:[
{
arrayStride:5*4,// 5 floats, 4 bytes each
arrayStride:2*4+4,// 2 floats, 4 bytes each + 4 bytes
attributes:[
{shaderLocation:0, offset:0, format:'float32x2'},// position
З усім цим ми зберегли трохи пам’яті. Ми використовували 20 байт на
кожну вершину раніше, а тепер лише 12 байт, на 40% менше. Також ми використовували 24 байти на кожен екземпляр, а тепер це 12 байт, шо дає
50% економії.
Ще одна річ, про яку варто тут поговорити це буфери індексів.
Буфери індексів описують порядок обробки і використання вершин.
Можна вважать, що команда draw проходиться по вершинах в
такому порядку:
0,1,2,3,4,5,.....
З допомогою буфера індексів ми можемо змінити цей порядок.
Ми створюємо 6 вершин для частини кола попри те, що дві з цих вершин
ідентичні.
Натомість тепер ми будемо створювати тільки 4 вершини і
використаємо індекси для того, щоб використати їх 6 разів
вказуючи WebGPU на такий порядок використання цих вершин:
0,1,2,2,1,3,...
function createCircleVertices({
radius =1,
numSubdivisions =24,
innerRadius =0,
startAngle =0,
endAngle =Math.PI *2,
}={}){
// 2 triangles per subdivision, 3 verts per tri
const numVertices = numSubdivisions *3*2;
// 2 vertices at each subdivision, + 1 to wrap around the circle.
const numVertices =(numSubdivisions +1)*2;
// 2 32-bit values for position (xy) and 1 32-bit value for color (rgb)
// The 32-bit color value will be written/read as 4 8-bit values
І нарешті нам потрібно задати цей буфер для команди draw:
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.setVertexBuffer(1, staticVertexBuffer);
pass.setVertexBuffer(2, changingVertexBuffer);
pass.setIndexBuffer(indexBuffer,'uint32');
Через те, що наш буфер містить 32-бітні беззнакові цілі числа
для позначення індексів, ми маємо передати в функцію 'uint32'.
Ми б могли також використати 16-бітні числа і тоді ми б мали
передати тут значення 'uint16'.
Також нам потрібно викликати метод drawIndexed замість draw:
pass.draw(numVertices, kNumObjects);
pass.drawIndexed(numVertices, kNumObjects);
З усім цим ми зекономили трішки пам’яті і потенційно
таку ж кількість процесорного часу під час обробки вершин
у шейдері, оскільки можливо, що GPU може повторно використовувати
вершини, які він уже обчислив.
Зверніть увагу, що ми могли б так само використати буфери
індексів у прикладі з буферами зберігання в попередній статті. В цьому випадку значення
@builtin(vertex_index) відповідає індексу з буфера індексів.