В попередній статті ми помістили дані
вершин в буфер зберігання і проіндексували їх з допомогою вбудованої
змінної vertex_index
. Не зважаючи на те, що ця техніка набуває
популярності, більш традиційним підходом вважається передача даних
вершин у шейдер з допомогою буферів вершин та атрибутів.
Буфери вершин є схожими на будь-які інші буфери в WebGPU - вони зберігають дані. Основна різниця полягає в тому, що ми не маємо до них прямого доступу з вершинного шейдера. Натомість, ми вказуємо WebGPU на тип даних, який ми помістили в цей буфер і на те, як вони розміщенні. Після цього WebGPU автоматично дістає ці дані з буфера і надає їх нам.
Візьмемо для прикладу наш код з попередньої статті і змінимо в ньому використання буфера зберігання на використання буфера вершин.
Спершу нам потрібно змінити шейдер таким чином, щоб він отримував інформацію про вершини з буфера вершин.
struct OurStruct { color: vec4f, offset: vec2f, }; struct OtherStruct { scale: vec2f, }; struct Vertex { - position: vec2f, + @location(0) position: vec2f, }; struct VSOutput { @builtin(position) position: vec4f, @location(0) color: vec4f, }; @group(0) @binding(0) var<storage, read> ourStructs: array<OurStruct>; @group(0) @binding(1) var<storage, read> otherStructs: array<OtherStruct>; -@group(0) @binding(2) var<storage, read> pos: array<Vertex>; @vertex fn vs( - @builtin(vertex_index) vertexIndex : u32, + vert: Vertex, @builtin(instance_index) instanceIndex: u32 ) -> VSOutput { let otherStruct = otherStructs[instanceIndex]; let ourStruct = ourStructs[instanceIndex]; var vsOut: VSOutput; vsOut.position = vec4f( - pos[vertexIndex].position * otherStruct.scale + ourStruct.offset, 0.0, 1.0); + vert.position * otherStruct.scale + ourStruct.offset, 0.0, 1.0); vsOut.color = ourStruct.color; return vsOut; } ...
Як ви можете бачити, це невеликі зміни. Важливим тут є декларування
поля 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
і видалити його з групи прив’язки.
- const vertexStorageBuffer = device.createBuffer({ - label: 'storage buffer vertices', - size: vertexData.byteLength, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, - }); + const vertexBuffer = device.createBuffer({ + label: 'vertex buffer vertices', + size: vertexData.byteLength, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + device.queue.writeBuffer(vertexBuffer, 0, vertexData); const bindGroup = device.createBindGroup({ label: 'bind group for objects', layout: pipeline.getBindGroupLayout(0), entries: [ { binding: 0, resource: { buffer: staticStorageBuffer }}, { binding: 1, resource: { buffer: changingStorageBuffer }}, - { binding: 2, resource: { buffer: vertexStorageBuffer }}, ], });
І після цього, в момент перед малюванням, ми повинні вказати WebGPU на той буфер вершин, який він має використати.
pass.setPipeline(pipeline); + pass.setVertexBuffer(0, vertexBuffer);
Значення 0
тут відповідає першому елементу з пайплайну рендерингу buffers
, який ми визначили вище.
З усім цим, ми перейшли з використання буферу зберігання на буфер вершин.
Стан системи після того, коли команда draw
виконана, буде виглядати так:
Поле атрибуту format
може мати один із цих типів:
Vertex format | Data type | Components | Byte size | Example WGSL type |
---|---|---|---|---|
"uint8x2" | unsigned int | 2 | 2 | vec2<u32> , vec2u |
"uint8x4" | unsigned int | 4 | 4 | vec4<u32> , vec4u |
"sint8x2" | signed int | 2 | 2 | vec2<i32> , vec2i |
"sint8x4" | signed int | 4 | 4 | vec4<i32> , vec4i |
"unorm8x2" | unsigned normalized | 2 | 2 | vec2<f32> , vec2f |
"unorm8x4" | unsigned normalized | 4 | 4 | vec4<f32> , vec4f |
"snorm8x2" | signed normalized | 2 | 2 | vec2<f32> , vec2f |
"snorm8x4" | signed normalized | 4 | 4 | vec4<f32> , vec4f |
"uint16x2" | unsigned int | 2 | 4 | vec2<u32> , vec2u |
"uint16x4" | unsigned int | 4 | 8 | vec4<u32> , vec4u |
"sint16x2" | signed int | 2 | 4 | vec2<i32> , vec2i |
"sint16x4" | signed int | 4 | 8 | vec4<i32> , vec4i |
"unorm16x2" | unsigned normalized | 2 | 4 | vec2<f32> , vec2f |
"unorm16x4" | unsigned normalized | 4 | 8 | vec4<f32> , vec4f |
"snorm16x2" | signed normalized | 2 | 4 | vec2<f32> , vec2f |
"snorm16x4" | signed normalized | 4 | 8 | vec4<f32> , vec4f |
"float16x2" | float | 2 | 4 | vec2<f16> , vec2h |
"float16x4" | float | 4 | 8 | vec4<f16> , vec4h |
"float32" | float | 1 | 4 | f32 |
"float32x2" | float | 2 | 8 | vec2<f32> , vec2f |
"float32x3" | float | 3 | 12 | vec3<f32> , vec3f |
"float32x4" | float | 4 | 16 | vec4<f32> , vec4f |
"uint32" | unsigned int | 1 | 4 | u32 |
"uint32x2" | unsigned int | 2 | 8 | vec2<u32> , vec2u |
"uint32x3" | unsigned int | 3 | 12 | vec3<u32> , vec3u |
"uint32x4" | unsigned int | 4 | 16 | vec4<u32> , vec4u |
"sint32" | signed int | 1 | 4 | i32 |
"sint32x2" | signed int | 2 | 8 | vec2<i32> , vec2i |
"sint32x3" | signed int | 3 | 12 | vec3<i32> , vec3i |
"sint32x4" | signed int | 4 | 16 | vec4<i32> , vec4i |
Атрибути можуть передаватись як для кожної вершини так і для кожного екземпляру.
Передавання для кожного екземпляру це практично те ж, що ми і робимо, коли
індексуємо otherStructs[instanceIndex]
та ourStructs[instanceIndex]
, де
instanceIndex
отримує своє значення з @builtin(instance_index)
.
Давайте позбавимось від буферів зберігання і використаємо буфери вершин для отримання того ж результату. Спершу давайте змінимо шейдер так, щоб використовувати атрибути вершин замість буферів зберігання.
-struct OurStruct { - color: vec4f, - offset: vec2f, -}; - -struct OtherStruct { - scale: vec2f, -}; struct Vertex { @location(0) position: vec2f, + @location(1) color: vec4f, + @location(2) offset: vec2f, + @location(3) scale: vec2f, }; struct VSOutput { @builtin(position) position: vec4f, @location(0) color: vec4f, }; -@group(0) @binding(0) var<storage, read> ourStructs: array<OurStruct>; -@group(0) @binding(1) var<storage, read> otherStructs: array<OtherStruct>; @vertex fn vs( vert: Vertex, - @builtin(instance_index) instanceIndex: u32 ) -> VSOutput { - let otherStruct = otherStructs[instanceIndex]; - let ourStruct = ourStructs[instanceIndex]; var vsOut: VSOutput; - vsOut.position = vec4f( - vert.position * otherStruct.scale + ourStruct.offset, 0.0, 1.0); - vsOut.color = ourStruct.color; + vsOut.position = vec4f( + vert.position * vert.scale + vert.offset, 0.0, 1.0); + vsOut.color = vert.color; return vsOut; } @fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f { return vsOut.color; }
Тепер ми повинні оновити наш пайплайн рендерингу для того, щоб
вказати на те, як ми хочемо надавати дані для цих атрибутів.
Для того, щоб зберегти кількість змін в коді мінімальною,
ми використаємо дані, які ми створили для буферів зберігання.
Ми використаємо два буфери: один буде зберігати значення 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 + {shaderLocation: 2, offset: 16, format: 'float32x2'}, // offset + ], + }, + { + arrayStride: 2 * 4, // 2 floats, 4 bytes each + stepMode: 'instance', + attributes: [ + {shaderLocation: 3, offset: 0, format: 'float32x2'}, // scale + ], + }, ], }, fragment: { module, targets: [{ format: presentationFormat }], }, });
Вище ми додали 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 байту.
Далі, ми можемо змінити код та створити ці буфери.
// create 2 storage buffers const staticUnitSize = 4 * 4 + // color is 4 32bit floats (4bytes each) - 2 * 4 + // offset is 2 32bit floats (4bytes each) - 2 * 4; // padding + 2 * 4; // offset is 2 32bit floats (4bytes each) const changingUnitSize = 2 * 4; // scale is 2 32bit floats (4bytes each) * const staticVertexBufferSize = staticUnitSize * kNumObjects; * const changingVertexBufferSize = changingUnitSize * kNumObjects; * const staticVertexBuffer = device.createBuffer({ * label: 'static vertex for objects', * size: staticVertexBufferSize, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, }); * const changingVertexBuffer = device.createBuffer({ * label: 'changing vertex for objects', * size: changingVertexBufferSize, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, });
Атрибути вершин не мають такі самі обмеження по вирівнюванню, як структури
в буферах зберігання, тому нам більше не потрібні ці вирівнювання. Все, що
ми тут зробили це зміна значення usage
з STORAGE
на VERTEX
(ну і ми
також змінили назви змінних з “storage” на “vertex”).
Оскільки ми більше не використовуємо буфери зберігання, нам більше не потрібна група прив’язки:
- const bindGroup = device.createBindGroup({ - label: 'bind group for objects', - layout: pipeline.getBindGroupLayout(0), - entries: [ - { binding: 0, resource: { buffer: staticStorageBuffer }}, - { binding: 1, resource: { buffer: changingStorageBuffer }}, - ], - });
На сам кінець, ми не потребуємо встановлення групи прив’язки, але потрібно встановити буфери вершин:
const encoder = device.createCommandEncoder(); const pass = encoder.beginRenderPass(renderPassDescriptor); pass.setPipeline(pipeline); pass.setVertexBuffer(0, vertexBuffer); + pass.setVertexBuffer(1, staticVertexBuffer); + pass.setVertexBuffer(2, changingVertexBuffer); ... - pass.setBindGroup(0, bindGroup); pass.draw(numVertices, kNumObjects); pass.end();
Перший параметр в setVertexBuffer
відповідає запису в масиві buffers
в
пайплайні, який ми створили вище.
З усім цим ми отримали той самий результат, який ми і мали раніше, але з використанням буферів вершин замість буферів зберігання.
Давайте, суто для розваги, додамо ще один атрибут для кольору кожної вершини. Спершу давайте змінимо шейдер:
struct Vertex { @location(0) position: vec2f, @location(1) color: vec4f, @location(2) offset: vec2f, @location(3) scale: vec2f, + @location(4) perVertexColor: vec3f, }; struct VSOutput { @builtin(position) position: vec4f, @location(0) color: vec4f, }; @vertex fn vs( vert: Vertex, ) -> VSOutput { var vsOut: VSOutput; vsOut.position = vec4f( vert.position * vert.scale + vert.offset, 0.0, 1.0); - vsOut.color = vert.color; + vsOut.color = vert.color * vec4f(vert.perVertexColor, 1); return vsOut; } @fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f { return vsOut.color; }
Далі нам потрібно оновити пайплайн для того, щоб описати, як ми
будемо надавати ці дані. Ми збираємось переплести дані 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 + {shaderLocation: 4, offset: 8, format: 'float32x3'}, // perVertexColor ], }, { arrayStride: 6 * 4, // 6 floats, 4 bytes each stepMode: 'instance', attributes: [ {shaderLocation: 1, offset: 0, format: 'float32x4'}, // color {shaderLocation: 2, offset: 16, format: 'float32x2'}, // offset ], }, { arrayStride: 2 * 4, // 2 floats, 4 bytes each stepMode: 'instance', attributes: [ {shaderLocation: 3, offset: 0, format: 'float32x2'}, // scale ], }, ], }, fragment: { module, targets: [{ format: presentationFormat }], }, });
Ми оновимо код генерації вершин кола для надання темних кольорів для вершин на зовнішньому боці кола і світлих кольорів на внутрішньому боці.
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. const numVertices = numSubdivisions * 3 * 2; - const vertexData = new Float32Array(numVertices * 2); + const vertexData = new Float32Array(numVertices * (2 + 3)); let offset = 0; - const addVertex = (x, y, r, g, b) => { + const addVertex = (x, y, r, g, b) => { vertexData[offset++] = x; vertexData[offset++] = y; + vertexData[offset++] = r; + vertexData[offset++] = g; + vertexData[offset++] = b; }; + const innerColor = [1, 1, 1]; + const outerColor = [0.1, 0.1, 0.1]; // 2 triangles per subdivision // // 0--1 4 // | / /| // |/ / | // 2 3--5 for (let i = 0; i < numSubdivisions; ++i) { const angle1 = startAngle + (i + 0) * (endAngle - startAngle) / numSubdivisions; const angle2 = startAngle + (i + 1) * (endAngle - startAngle) / numSubdivisions; const c1 = Math.cos(angle1); const s1 = Math.sin(angle1); const c2 = Math.cos(angle2); const s2 = Math.sin(angle2); // first triangle - addVertex(c1 * radius, s1 * radius); - addVertex(c2 * radius, s2 * radius); - addVertex(c1 * innerRadius, s1 * innerRadius); + addVertex(c1 * radius, s1 * radius, ...outerColor); + addVertex(c2 * radius, s2 * radius, ...outerColor); + addVertex(c1 * innerRadius, s1 * innerRadius, ...innerColor); // second triangle - addVertex(c1 * innerRadius, s1 * innerRadius); - addVertex(c2 * radius, s2 * radius); - addVertex(c2 * innerRadius, s2 * innerRadius); + addVertex(c1 * innerRadius, s1 * innerRadius, ...innerColor); + addVertex(c2 * radius, s2 * radius, ...outerColor); + addVertex(c2 * innerRadius, s2 * innerRadius, ...innerColor); } return { vertexData, numVertices, }; }
І з усім цим ми отримуємо затемненні кола:
Вище ми задекларували значення атрибуту perVertexColor
в WGSL як vec3f
:
struct Vertex { @location(0) position: vec2f, @location(1) color: vec4f, @location(2) offset: vec2f, @location(3) scale: vec2f, * @location(4) perVertexColor: vec3f, };
І використали його таким чином:
@vertex fn vs( vert: Vertex, ) -> VSOutput { var vsOut: VSOutput; vsOut.position = vec4f( vert.position * vert.scale + vert.offset, 0.0, 1.0); * vsOut.color = vert.color * vec4f(vert.perVertexColor, 1); return vsOut; }
Ми також могли б задекларувати його як vec4f
і використати таким чином:
struct Vertex { @location(0) position: vec2f, @location(1) color: vec4f, @location(2) offset: vec2f, @location(3) scale: vec2f, - @location(4) perVertexColor: vec3f, + @location(4) perVertexColor: vec4f, }; ... @vertex fn vs( vert: Vertex, ) -> VSOutput { var vsOut: VSOutput; vsOut.position = vec4f( vert.position * vert.scale + vert.offset, 0.0, 1.0); - vsOut.color = vert.color * vec4f(vert.perVertexColor, 1); + vsOut.color = vert.color * vert.perVertexColor; return vsOut; }
І більше нічого не змінювати. В JavaScript ми б могли досі передавати дані як три 32-бітних числа з рухомою комою на кожну вершину.
{ arrayStride: 5 * 4, // 5 floats, 4 bytes each attributes: [ {shaderLocation: 0, offset: 0, format: 'float32x2'}, // position * {shaderLocation: 4, offset: 8, format: 'float32x3'}, // perVertexColor ], },
Це працює тому, що атрибути завжди мають 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. + // 2 triangles per subdivision, 3 verts per tri const numVertices = numSubdivisions * 3 * 2; - const vertexData = new Float32Array(numVertices * (2 + 3)); + // 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 + const vertexData = new Float32Array(numVertices * (2 + 1)); + const colorData = new Uint8Array(vertexData.buffer); let offset = 0; + let colorOffset = 8; const addVertex = (x, y, r, g, b) => { vertexData[offset++] = x; vertexData[offset++] = y; - vertexData[offset++] = r; - vertexData[offset++] = g; - vertexData[offset++] = b; + offset += 1; // skip the color + colorData[colorOffset++] = r * 255; + colorData[colorOffset++] = g * 255; + colorData[colorOffset++] = b * 255; + colorOffset += 9; // skip extra byte and the position };
Вище ми робимо colorData
, яке є Uint8Array
відображенням тих самих
даних як і vertexData
. Перегляньте статтю про схему розміщення даних якшо це не до кінця зрозуміло.
Далі ми використовуємо colorData
для отримання кольорів, перетворюючи
їх з 0 ↔ 1 до 0 ↔ 255.
Розміщення даних в пам’яті (для кожної вершини) виглядає так:
Нам потрібно також оновити дані для кожного екземпляру.
const kNumObjects = 100; const objectInfos = []; // create 2 vertex buffers const staticUnitSize = - 4 * 4 + // color is 4 32bit floats (4bytes each) + 4 + // color is 4 bytes 2 * 4; // offset is 2 32bit floats (4bytes each) const changingUnitSize = 2 * 4; // scale is 2 32bit floats (4bytes each) const staticVertexBufferSize = staticUnitSize * kNumObjects; const changingVertexBufferSize = changingUnitSize * kNumObjects; const staticVertexBuffer = device.createBuffer({ label: 'static vertex for objects', size: staticVertexBufferSize, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, }); const changingVertexBuffer = device.createBuffer({ label: 'changing storage for objects', size: changingVertexBufferSize, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, }); // offsets to the various uniform values in float32 indices const kColorOffset = 0; const kOffsetOffset = 1; const kScaleOffset = 0; { - const staticVertexValues = new Float32Array(staticVertexBufferSize / 4); + const staticVertexValuesU8 = new Uint8Array(staticVertexBufferSize); + const staticVertexValuesF32 = new Float32Array(staticVertexValuesU8.buffer); for (let i = 0; i < kNumObjects; ++i) { - const staticOffset = i * (staticUnitSize / 4); + const staticOffsetU8 = i * staticUnitSize; + const staticOffsetF32 = staticOffsetU8 / 4; // These are only set once so set them now - staticVertexValues.set([rand(), rand(), rand(), 1], staticOffset + kColorOffset); // set the color - staticVertexValues.set([rand(-0.9, 0.9), rand(-0.9, 0.9)], staticOffset + kOffsetOffset); // set the offset + staticVertexValuesU8.set( // set the color + [rand() * 255, rand() * 255, rand() * 255, 255], + staticOffsetU8 + kColorOffset); + + staticVertexValuesF32.set( // set the offset + [rand(-0.9, 0.9), rand(-0.9, 0.9)], + staticOffsetF32 + kOffsetOffset); objectInfos.push({ scale: rand(0.2, 0.5), }); } device.queue.writeBuffer(staticVertexBuffer, 0, staticVertexValuesF32); }
Розміщення даних в пам’яті для кожного екземпляру виглядає так:
Далі нам потрібно змінити пайплайн для отримання даних у вигляді 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 - {shaderLocation: 4, offset: 8, format: 'float32x3'}, // perVertexColor + {shaderLocation: 4, offset: 8, format: 'unorm8x4'}, // perVertexColor ], }, { - arrayStride: 6 * 4, // 6 floats, 4 bytes each + arrayStride: 4 + 2 * 4, // 4 bytes + 2 floats, 4 bytes each stepMode: 'instance', attributes: [ - {shaderLocation: 1, offset: 0, format: 'float32x4'}, // color - {shaderLocation: 2, offset: 16, format: 'float32x2'}, // offset + {shaderLocation: 1, offset: 0, format: 'unorm8x4'}, // color + {shaderLocation: 2, offset: 4, format: 'float32x2'}, // offset ], }, { arrayStride: 2 * 4, // 2 floats, 4 bytes each stepMode: 'instance', attributes: [ {shaderLocation: 3, offset: 0, format: 'float32x2'}, // scale ], }, ], }, fragment: { module, targets: [{ format: presentationFormat }], }, });
З усім цим ми зберегли трохи пам’яті. Ми використовували 20 байт на кожну вершину раніше, а тепер лише 12 байт, на 40% менше. Також ми використовували 24 байти на кожен екземпляр, а тепер це 12 байт, шо дає 50% економії.
Зауважте, що ми не мусимо використовувати структури. Це працюватиме також:
@vertex fn vs( - vert: Vertex, + @location(0) position: vec2f, + @location(1) color: vec4f, + @location(2) offset: vec2f, + @location(3) scale: vec2f, + @location(4) perVertexColor: vec3f, ) -> VSOutput { var vsOut: VSOutput; - vsOut.position = vec4f( - vert.position * vert.scale + vert.offset, 0.0, 1.0); - vsOut.color = vert.color * vec4f(vert.perVertexColor, 1); + vsOut.position = vec4f( + position * scale + offset, 0.0, 1.0); + vsOut.color = color * vec4f(perVertexColor, 1); return vsOut; }
І знову ж таки, усе про що переймається WebGPU, це те чи ми
оголосили locations
в шейдері і чи передали дані для цих локацій
через API.
Ще одна річ, про яку варто тут поговорити це буфери індексів. Буфери індексів описують порядок обробки і використання вершин.
Можна вважать, що команда 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 const vertexData = new Float32Array(numVertices * (2 + 1)); const colorData = new Uint8Array(vertexData.buffer); let offset = 0; let colorOffset = 8; const addVertex = (x, y, r, g, b) => { vertexData[offset++] = x; vertexData[offset++] = y; offset += 1; // skip the color colorData[colorOffset++] = r * 255; colorData[colorOffset++] = g * 255; colorData[colorOffset++] = b * 255; colorOffset += 9; // skip extra byte and the position }; const innerColor = [1, 1, 1]; const outerColor = [0.1, 0.1, 0.1]; - // 2 triangles per subdivision - // - // 0--1 4 - // | / /| - // |/ / | - // 2 3--5 - for (let i = 0; i < numSubdivisions; ++i) { - const angle1 = startAngle + (i + 0) * (endAngle - startAngle) / numSubdivisions; - const angle2 = startAngle + (i + 1) * (endAngle - startAngle) / numSubdivisions; - - const c1 = Math.cos(angle1); - const s1 = Math.sin(angle1); - const c2 = Math.cos(angle2); - const s2 = Math.sin(angle2); - - // first triangle - addVertex(c1 * radius, s1 * radius, ...outerColor); - addVertex(c2 * radius, s2 * radius, ...outerColor); - addVertex(c1 * innerRadius, s1 * innerRadius, ...innerColor); - - // second triangle - addVertex(c1 * innerRadius, s1 * innerRadius, ...innerColor); - addVertex(c2 * radius, s2 * radius, ...outerColor); - addVertex(c2 * innerRadius, s2 * innerRadius, ...innerColor); - } + // 2 triangles per subdivision + // + // 0 2 4 6 8 ... + // + // 1 3 5 7 9 ... + for (let i = 0; i <= numSubdivisions; ++i) { + const angle = startAngle + (i + 0) * (endAngle - startAngle) / numSubdivisions; + + const c1 = Math.cos(angle); + const s1 = Math.sin(angle); + + addVertex(c1 * radius, s1 * radius, ...outerColor); + addVertex(c1 * innerRadius, s1 * innerRadius, ...innerColor); + } + const indexData = new Uint32Array(numSubdivisions * 6); + let ndx = 0; + + // 1st tri 2nd tri 3rd tri 4th tri + // 0 1 2 2 1 3 2 3 4 4 3 5 + // + // 0--2 2 2--4 4 ..... + // | / /| | / /| + // |/ / | |/ / | + // 1 1--3 3 3--5 ..... + for (let i = 0; i < numSubdivisions; ++i) { + const ndxOffset = i * 2; + + // first triangle + indexData[ndx++] = ndxOffset; + indexData[ndx++] = ndxOffset + 1; + indexData[ndx++] = ndxOffset + 2; + + // second triangle + indexData[ndx++] = ndxOffset + 2; + indexData[ndx++] = ndxOffset + 1; + indexData[ndx++] = ndxOffset + 3; + } return { vertexData, + indexData, - numVertices, + numVertices: indexData.length, }; }
Тепер нам потрібно створити буфер індексів:
- const { vertexData, numVertices } = createCircleVertices({ + const { vertexData, indexData, numVertices } = createCircleVertices({ radius: 0.5, innerRadius: 0.25, }); const vertexBuffer = device.createBuffer({ label: 'vertex buffer', size: vertexData.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, }); device.queue.writeBuffer(vertexBuffer, 0, vertexData); + const indexBuffer = device.createBuffer({ + label: 'index buffer', + size: indexData.byteLength, + usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST, + }); + device.queue.writeBuffer(indexBuffer, 0, indexData);
Зверніть увагу, що поле usage
має значення INDEX
.
І нарешті нам потрібно задати цей буфер для команди 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)
відповідає індексу з буфера індексів.
Далі ми розглянемо текстури.