Зміст

webgpufundamentals.org

Fix, Fork, Contribute

Схема розміщення даних в пам’яті

Працюючи з WebGPU, ви повинні розмітити майже усі дані, які ви йому надаєте. Це потрібно для того щоб вони збігались з структурами, які ви описали в шейдерах. Це велика відмінність від того як працюють JavaScript та TypeScript, де проблеми з розміщенням даних виникають рідко.

Коли ви пишете свій шейдер на WGSL, то зазвичай описуєте якісь структури. Структури (struct) схожі на JavaScript об’єкти. Ви оголошуєте поле структури схожим чином до того, як оголошується властивість об’єкта в JavaScript. Але, окрім надання усім властивостям імен, ви також повинні вказати їхній тип. Передаючи певні дані в WebGPU, ви відповідаєте за те, щоб вирахувати конкретне місце кожного поля вашої структури в буфері, який ви передасте.

В WGSL версії v1, є чотири базових типи:

  • f32 (32-бітне число з рухомою комою)
  • i32 (32-бітне ціле число)
  • u32 (32-бітне беззнакове ціле число)
  • f16 (16-бітне число з рухомою комою) [1]

Один байт це 8 бітів, тому 32-бітне значення займе 4 байти, а 16-бітне - 2 байти.

Якщо ми оголосимо таку структуру:

struct OurStruct {
  velocity: f32,
  acceleration: f32,
  frameCount: u32,
};

То візуальне представлення цієї структури може виглядати приблизно так:

Кожен квадратний блок тут це один байт. Ви можете побачити, що наші дані займають 12 байт. Поле velocity займає перших 4 байти, поле acceleration - наступних 4 байти, а поле frameCount - останніх 4 байти.

Для того, щоб передати дані в наш шейдер нам потрібно розмітити їх таким чином, щоб вони збігались з розміщенням даних в структурі OurStruct. Для того щоб зробити це, нам потрібно створити ArrayBuffer розміром в 12 байт. Далі налаштувати відображення у вигляді TypedArray правильного типу, щоб можна було заповнити його даними.

const kOurStructSizeBytes =
  4 + // velocity
  4 + // acceleration
  4 ; // frameCount
const ourStructData = new ArrayBuffer(kOurStructSizeBytes);
const ourStructValuesAsF32 = new Float32Array(ourStructData);
const ourStructValuesAsU32 = new Uint32Array(ourStructData);

Вище, значення ourStructData дорівнює об’єкту ArrayBuffer, що є шматком пам’яті. Для того, щоб мати змогу переглядати вміст цієї пам’яті, ми створюємо відображення цього об’єкту. ourStructValuesAsF32 це відображення цієї пам’яті у вигляді масиву 32-бітних чисел з рухомою комою. ourStructValuesAsU32 це відображення тієї самої пам’яті у вигляді 32-бітних беззнакових цілих чисел.

Тепер, коли у нас є буфер і два його відображення, ми можемо передати дані в нашу структуру.

const kVelocityOffset = 0;
const kAccelerationOffset = 1;
const kFrameCountOffset = 2;

ourStructValuesAsF32[kVelocityOffset] = 1.2;
ourStructValuesAsF32[kAccelerationOffset] = 3.4;
ourStructValuesAsU32[kFrameCountOffset] = 56;    // an integer value

TypedArrays

Зверніть увагу, що, як і багато речей в програмуванні, ми можемо зробити це різними способами. TypedArray має конструктор, який набуває різних форм. Для прикладу:

  • new Float32Array(12)

    Ця версія створює новий ArrayBuffer розміром 12 * 4 байти. Після цього створює масив Float32Array для відображення цього буфера.

  • new Float32Array([4, 5, 6])

    Ця версія створює новий ArrayBuffer розміром 3 * 4 байти. Після цього створює масив Float32Array для відображення цього буфера і встановлює початкові значення цього масиву (4, 5, 6).

    Зверніть увагу, що ви можете передати сюди інший TypedArray. Для прикладу

    new Float32Array(someUint8ArrayOf6Values) створить новий ArrayBuffer розміром 6 * 4 байти, далі створить Float32Array для його відображення і після цього скопіює значення з переданого відображення в новий масив Float32Array. Значення копіюються як числа, а не у вигляді бінарних значень. Іншими словами це відбувається якось так

    srcArray.forEach((v, i) => dstArray[i] = v);
    

    Що саме означає “скопійовані за значенням”? Розгляньте цей приклад:

    const f32s = new Float32Array([0.8, 0.9, 1.0, 1.1, 1.2]);
    const u32s = new Uint32Array(f32s); 
    console.log(u32s);   // produces 0, 0, 1, 1, 1
    

    Причина полягає в тому, що ми не можемо помістити такі числа як 0.8 чи 1.2 в масив беззнакових цілих чисел Uint32Array.

  • new Float32Array(someArrayBuffer)

    Це той випадок, який ми використали раніше. Нове Float32Array відображення створюється для буфера, який уже існує.

  • new Float32Array(someArrayBuffer, byteOffset)

    Цей приклад створює нове Float32Array відображення для буфера, який уже існує, але початок цього відображення бере з відступом у розмірі byteOffset.

  • new Float32Array(someArrayBuffer, byteOffset, length)

    Цей приклад створює нове Float32Array відображення для буфера, який уже існує. Відображення починається з відступу у розмірі byteOffset, а довжина цього відображення буде розміром length елементів. Тобто, якщо ми передамо сюди значення довжини як 3, то відображення міститиме 3 32-бітних числа (12 байт).

Використовуючи останній приклад, ми можемо наш попередній код на цей:

const kOurStructSizeBytes =
  4 + // velocity
  4 + // acceleration
  4 ; // frameCount
const ourStructData = new ArrayBuffer(kOurStructSizeBytes);
const velocityView = new Float32Array(ourStructData, 0, 1);
const accelerationView = new Float32Array(ourStructData, 4, 1);
const frameCountView = new Uint32Array(ourStructData, 8, 1);

velocityView[0] = 1.2;
accelerationView[0] = 3.4;
frameCountView[0] = 56;

Надалі, кожен масив TypedArray матиме такі властивості:

  • length: кількість елементів
  • byteLength: розмір в байтах
  • byteOffset: зміщення від початку батьківського буфера ArrayBuffer
  • buffer: батьківський буфер ArrayBuffer

Масиви TypedArray мають також багато різних методів, більшість з яких схожі до методів звичайних масивів типу Array. Один із тих, які не схожі це метод subarray. Він створює новий TypedArray такого є типу. Він приймає два параметри subarray(begin, end), де end не включає себе в проміжок. Тож someTypedArray.subarray(5, 10) створює нове відображення TypedArray того ж самого буфера ArrayBuffer включаючи елементи масиву someTypedArray починаючи 5 та закінчуючи 9.

Тому ми можемо змінити наш код на цей:

const kOurStructSizeFloat32Units =
  1 + // velocity
  1 + // acceleration
  1 ; // frameCount
const ourStructDataAsF32 = new Float32Array(kOurStructSizeFloat32Units);
const ourStructDataAsU32 = new Uint32Array(ourStructDataAsF32.buffer);
const velocityView = ourStructDataAsF32.subarray(0, 1);
const accelerationView = ourStructDataAsF32.subarray(1, 2);
const frameCountView = ourStructDataAsU32.subarray(2, 3);

velocityView[0] = 1.2;
accelerationView[0] = 3.4;
frameCountView[0] = 56;

Декілька відображень одного й того ж буфера ArrayBuffer

Маючи відображення одного й того ж буфера означає, що ми маємо доступ до нього з різних точок. Для прикладу:

const v1 = new Float32Array(5);
const v2 = v1.subarray(3, 5);  // view the last 2 floats of v1
v2[0] = 123;
v2[1] = 456;
console.log(v1);  // shows 0, 0, 0, 123, 456

Схожим чином це працює для відображень різного типу:

const f32 = new Float32Array([1, 1000, -1000])
const u32 = new Uint32Array(f32.buffer);

console.log(Array.from(u32).map(v => v.toString(16).padStart(8, '0')));
// shows '3f800000', '447a0000', 'c47a0000' 

Значення вище виведені у вигляді шістнадцяткового представлення для чисел 1, 1000, -1000 з рухомою комою.

Особливості методу map

Будьте обачні з методом map, оскільки для об’єктів типу TypedArray він створює новий типізований масив з тим самим типом!

const f32a = new Float32Array(1, 2, 3);
const f32b = f32a.map(v => v * 2);                    // Ok
const f32c = f32a.map(v => `${v} doubled = ${v *2}`); // BAD!
                    //  ви не можете помістити текст в масив з числами

Якщо вам потрібно відобразити типізований масив в інший тип, то вам потрібно або пройтись циклом по цьому масиву, або перетворити його в звичайний масив, що можна зробити з допомогою Array.from. Це буде виглядати таким чином:

const f32d = Array.from(f32a).map(v => `${v} doubled = ${v *2}`); // Ok

Типи vec та mat

WGSL має також типи, які складаються з 4 основних типів. Ось їхній перелік:

typedescriptionshort name
vec2<f32>a type with 2 f32svec2f
vec2<u32>a type with 2 u32svec2u
vec2<i32>a type with 2 i32svec2i
vec2<f16>a type with 2 f16svec2h
vec3<f32>a type with 3 f32svec3f
vec3<u32>a type with 3 u32svec3u
vec3<i32>a type with 3 i32svec3i
vec3<f16>a type with 3 f16svec3h
vec4<f32>a type with 4 f32svec4f
vec4<u32>a type with 4 u32svec4u
vec4<i32>a type with 4 i32svec4i
vec4<f16>a type with 4 f16svec4h
mat2x2<f32>a matrix of 2 vec2<f32>smat2x2f
mat2x2<u32>a matrix of 2 vec2<u32>smat2x2u
mat2x2<i32>a matrix of 2 vec2<i32>smat2x2i
mat2x2<f16>a matrix of 2 vec2<f16>smat2x2h
mat2x3<f32>a matrix of 2 vec3<f32>smat2x3f
mat2x3<u32>a matrix of 2 vec3<u32>smat2x3u
mat2x3<i32>a matrix of 2 vec3<i32>smat2x3i
mat2x3<f16>a matrix of 2 vec3<f16>smat2x3h
mat2x4<f32>a matrix of 2 vec4<f32>smat2x4f
mat2x4<u32>a matrix of 2 vec4<u32>smat2x4u
mat2x4<i32>a matrix of 2 vec4<i32>smat2x4i
mat2x4<f16>a matrix of 2 vec4<f16>smat2x4h
mat3x2<f32>a matrix of 3 vec2<f32>smat3x2f
mat3x2<u32>a matrix of 3 vec2<u32>smat3x2u
mat3x2<i32>a matrix of 3 vec2<i32>smat3x2i
mat3x2<f16>a matrix of 3 vec2<f16>smat3x2h
mat3x3<f32>a matrix of 3 vec3<f32>smat3x3f
mat3x3<u32>a matrix of 3 vec3<u32>smat3x3u
mat3x3<i32>a matrix of 3 vec3<i32>smat3x3i
mat3x3<f16>a matrix of 3 vec3<f16>smat3x3h
mat3x4<f32>a matrix of 3 vec4<f32>smat3x4f
mat3x4<u32>a matrix of 3 vec4<u32>smat3x4u
mat3x4<i32>a matrix of 3 vec4<i32>smat3x4i
mat3x4<f16>a matrix of 3 vec4<f16>smat3x4h
mat4x2<f32>a matrix of 4 vec2<f32>smat4x2f
mat4x2<u32>a matrix of 4 vec2<u32>smat4x2u
mat4x2<i32>a matrix of 4 vec2<i32>smat4x2i
mat4x2<f16>a matrix of 4 vec2<f16>smat4x2h
mat4x3<f32>a matrix of 4 vec3<f32>smat4x3f
mat4x3<u32>a matrix of 4 vec3<u32>smat4x3u
mat4x3<i32>a matrix of 4 vec3<i32>smat4x3i
mat4x3<f16>a matrix of 4 vec3<f16>smat4x3h
mat4x4<f32>a matrix of 4 vec4<f32>smat4x4f
mat4x4<u32>a matrix of 4 vec4<u32>smat4x4u
mat4x4<i32>a matrix of 4 vec4<i32>smat4x4i
mat4x4<f16>a matrix of 4 vec4<f16>smat4x4h

Враховуючи, що vec3f це тип з трьома числами f32, а тип mat4x4f - це матриця чисел типу f32 розміром 4х4, як ви гадаєте, якою буде розмітка пам’яті для цієї структури?

struct Ex2 {
  scale: f32,
  offset: vec3f,
  projection: mat4x4f,
};

Готові?

Що тут взагалі відбувається? Виявляється, що кожен тип має вимоги вирівнювання. Кожен тип має бути вирівняний в пам’яті до певного числа байтів.

Ось значення цих величин для різних типів даних:

Але це ще не все!

Як на вашу думку має виглядати розмітка даних для цієї структури?

struct Ex3 {
  transform: mat3x3f,
  directions: array<vec3f, 4>,
};

Конструкція array<type, count> описує масив даних з типом type і кількістю цих елементів count.

Приблизно ось так…

Якщо ви заглянете в таблицю вирівнювань, ви побачите, що vec3<f32> має значення вирівнювання 16 байт. Це означає, що кожен vec3<f32>, незалежно від того чи він в матриці чи в масиві, буде закінчуватись пустим місцем.

Ось ще один приклад.

struct Ex4a {
  velocity: vec3f,
};

struct Ex4 {
  orientation: vec3f,
  size: f32,
  direction: array<vec3f, 1>,
  scale: f32,
  info: Ex4a,
  friction: f32,
};

Чому size вмістився у відступ на позиції 12 байта, одразу після orientation, а поля scale та friction перемістились вниз на позиції 32 та 64 байтів?

Все через те, що масиви та структури мають свої особливі правила вирівнювання. Тому, навіть коли масив чи структура це лише одиничний vec3f, вони вирівнюються за іншими правилами.

typealignsize
struct S with members M1...MNmax(AlignOfMember(S,1), ... , AlignOfMember(S,N))roundUp(AlignOf(S), justPastLastMember)

where justPastLastMember = OffsetOfMember(S,N) + SizeOfMember(S,N)

array<E, N>AlignOf(E)N × roundUp(AlignOf(E), SizeOf(E))

Ви можете прочитати про ці правила більш детально на цій сторінці WGSL специфікації.

Обчислення відступів та розмірів це суцільний клопіт.

Обчислення розмірів та відступів даних в WGSL - це мабуть найболючіша точка в WebGPU. Ви повинні вирахувати ці відступи самостійно і постійно оновлювати їх. Якщо ви додасте нове поле десь в середині вашої структури, то вам прийдеться повернутись в JavaScript і оновити усі відступи. Помилитесь в якомусь одному байті і всі ваші дані, які ви передасть в шейдер, будуть хибними. Ви не отримаєте жодних помилок, але ваш шейдер імовірно буде робити не те, що б вам хотілось, бо він отримав хибні дані. Ваша модель не буде відмальована, або ваші обчислення будуть продукувати хибні результати.

На наше щастя, існують бібліотеки, які допомагають справитись з цим.

Ось одна з них: webgpu-utils

Ви передаєте їй ваш WGSL код, а вона надає вам API для усіх цих речей. Таким чином, ви зможете змінювати ваші структури і, в більшості випадків, усе працюватиме як слід.

Для прикладу, ми можемо передати наш попередній код до webgpu-utils таким чином:

import {
  makeShaderDataDefinitions,
  makeStructuredView,
} from 'https://greggman.github.io/webgpu-utils/dist/0.x/webgpu-utils-1.x.module.js';

const code = `
struct Ex4a {
  velocity: vec3f,
};

struct Ex4 {
  orientation: vec3f,
  size: f32,
  direction: array<vec3f, 1>,
  scale: f32,
  info: Ex4a,
  friction: f32,
};
@group(0) @binding(0) var<uniform> myUniforms: Ex4;

...
`;

const defs = makeShaderDataDefinitions(code);
const myUniformValues = makeStructuredView(defs.uniforms.myUniforms);

// встановлюємо дані методом set
myUniformValues.set({
  orientation: [1, 0, -1],
  size: 2,
  direction: [0, 1, 0],
  scale: 1.5,
  info: {
    velocity: [2, 3, 4],
  },
  friction: 0.1,
});

// тепер ми можемо передати myUniformValues.arrayBuffer до WebGPU

Чи використовувати цю або іншу бібліотеку це тільки ваш вибір. Як на мене, то я краще використаю одну з таких бібліотек ніж витрачатиму від 20 до 60 хвилин на пошук проблеми в моїх ручних розрахунках відступів.

Якщо ви все таки хочете робити це вручну, то ось сторінка, яка допоможе вам вирахувати усі ці відступи


  1. підтримка типу f16 це опціональна можливість ↩︎

Запитання? Запитати на stackoverflow.
Пропозиція? Запит? Проблема? Помилка?
Use <pre><code>code goes here</code></pre> for code blocks
comments powered by Disqus