Вступ до WebGPU: перші кроки
WebGPU — це наступник WebGL: сучасний API для графіки та обчислень, який браузер надає напряму, за зразком нативних API на кшталт Vulkan, Metal і Direct3D 12. Він замінює неявну машину станів WebGL на явні об'єкти: адаптери, пристрої, конвеєри та групи прив'язки. Цей урок проходить через кожне поняття, необхідне для рендерингу першого трикутника та запуску першого обчислювального шейдера.
1. Навіщо WebGPU? Від WebGL до сучасного API
WebGL — це прив'язка JavaScript до OpenGL ES, API, розробленого в епоху неявного глобального стану: прив'язати текстуру, прив'язати буфер, викликати відмальовку — і сподіватися, що ніщо інше в коді не змінило прив'язаний стан у проміжку. WebGPU відкидає цю модель. Він побудований навколо явних, незмінних об'єктів: ви описуєте конвеєр один раз (шейдери, розкладка вершин, змішування, тест глибини) і повторно використовуєте його, замість того щоб щокадру заново викликати десятки функцій встановлення стану.
З такого дизайну випливають три практичні переваги:
- Менші витрати CPU: кодування команд дешевше, оскільки валідація відбувається один раз — під час створення конвеєра, а не при кожному виклику відмальовки.
- Обчислення "з коробки": WebGPU надає обчислювальні шейдери загального призначення як базову можливість, а не розширення — це корисно для систем частинок, фізики та GPGPU-навантажень безпосередньо в браузері.
- Багатопотокове кодування команд: буфери команд можна будувати поза головним потоком і надсилати пізніше — те, чого неявний одиночний контекст WebGL ніколи не міг безпечно підтримувати.
if (!navigator.gpu) і повертайтеся до
WebGL-рендерера (наприклад, у Three.js) для старіших браузерів.
2. Адаптери та пристрої
WebGPU розділяє поняття «GPU» на два об'єкти. GPUAdapter
представляє фізичний або програмний GPU, доступний браузеру — ви
запитуєте його та перевіряєте його limits і
features. GPUDevice — це логічне з'єднання,
через яке ви фактично створюєте ресурси та надсилаєте роботу.
async function initWebGPU() {
if (!navigator.gpu) {
throw new Error("Цей браузер не підтримує WebGPU.");
}
// Запит фізичного адаптера — можна вказати перевагу продуктивності
const adapter = await navigator.gpu.requestAdapter({
powerPreference: "high-performance",
});
if (!adapter) throw new Error("Не знайдено підходящого GPUAdapter.");
// Запит логічного пристрою — об'єкт, який ви фактично використовуєте
const device = await adapter.requestDevice();
// Виводимо неперехоплені помилки замість тихого збою
device.addEventListener("uncapturederror", (event) => {
console.error("Помилка WebGPU:", event.error.message);
});
return { adapter, device };
}
device.lost
(це Promise) і будьте готові переініціалізувати конвеєр після
відновлення в продакшн-коді.
3. Налаштування контексту canvas
Щоб малювати в <canvas>, запросіть контекст
"webgpu" і налаштуйте його пристроєм та форматом
текстури. На відміну від WebGL, тут потрібно явно вказати формат
пікселів — зазвичай той, який браузер вважає кращим для swap-chain,
заради продуктивності.
const canvas = document.querySelector("canvas");
const context = canvas.getContext("webgpu");
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device,
format,
alphaMode: "opaque",
});
Щокадру ви викликатимете
context.getCurrentTexture(), щоб отримати текстуру, в
яку браузер очікує рендеринг, і обгортаєте її в
GPUTextureView для прогону рендерингу.
4. WGSL: мова шейдерів WebGPU
WebGPU не використовує GLSL. Натомість визначено власну мову шейдерів — WGSL (WebGPU Shading Language): статично типізований синтаксис у стилі Rust, розроблений так, щоб чисто відображатися на SPIR-V, MSL та HLSL під капотом. Вершинний і фрагментний етапи можуть жити в одному модулі, розрізняючись атрибутами.
struct VertexOut {
@builtin(position) position : vec4<f32>,
@location(0) color : vec3<f32>,
};
@vertex
fn vs_main(
@location(0) pos : vec2<f32>,
@location(1) color : vec3<f32>
) -> VertexOut {
var out : VertexOut;
out.position = vec4<f32>(pos, 0.0, 1.0);
out.color = color;
return out;
}
@fragment
fn fs_main(in : VertexOut) -> @location(0) vec4<f32> {
return vec4<f32>(in.color, 1.0);
}
Ключові синтаксичні відмінності від GLSL для тих, хто прийшов
звідти: типи явні (vec2<f32>, а не просто
vec2), @location(n) замінює
layout(location = n), а @builtin(position)
позначає вихід у clip-просторі — еквівалент неявного
gl_Position у GLSL.
5. Буфери та розкладка вершин
Буфери GPU створюються з фіксованим розміром і набором дозволених
прапорців usage. Щоб завантажити дані вершин, ви
створюєте буфер із прапорцями VERTEX | COPY_DST, потім
записуєте в нього дані, а потім описуєте байтову розкладку, щоб
конвеєр знав, як інтерпретувати атрибути кожної вершини.
// Чергування: x, y, r, g, b на вершину (5 float = 20 байтів)
const vertices = new Float32Array([
0.0, 0.5, 1, 0, 0,
-0.5, -0.5, 0, 1, 0,
0.5, -0.5, 0, 0, 1,
]);
const vertexBuffer = device.createBuffer({
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(vertexBuffer, 0, vertices);
const vertexLayout = {
arrayStride: 5 * 4, // 5 float × 4 байти
attributes: [
{ shaderLocation: 0, offset: 0, format: "float32x2" }, // позиція
{ shaderLocation: 1, offset: 2 * 4, format: "float32x3" }, // колір
],
};
gl.bufferData у WebGL, буфери WebGPU — це непрозора
пам'ять GPU. Ви записуєте в них через
queue.writeBuffer() або мапите для прямого доступу з
CPU через mapAsync(), коли буфер створено з прапорцями
MAP_WRITE/MAP_READ.
6. Побудова конвеєра рендерингу
GPURenderPipeline об'єднує модуль шейдера, розкладку
вершин, топологію примітивів і формат кольорової цілі в єдиний
провалідований, незмінний об'єкт. Його компіляція відносно
дорога, тому ви створюєте його один раз і повторно використовуєте
щокадру — ніколи не створюйте конвеєр усередині циклу рендерингу.
const shaderModule = device.createShaderModule({ code: wgslSource });
const pipeline = device.createRenderPipeline({
layout: "auto",
vertex: {
module: shaderModule,
entryPoint: "vs_main",
buffers: [vertexLayout],
},
fragment: {
module: shaderModule,
entryPoint: "fs_main",
targets: [{ format }],
},
primitive: {
topology: "triangle-list",
cullMode: "back",
},
});
7. Кодування та надсилання команд
Виклики відмальовки в WebGPU не виконуються напряму — вони
записуються в GPUCommandEncoder, обгорнутий
GPURenderPassEncoder, який описує, які текстури
очищати та в які записувати. Готовий буфер команд потім надсилається
в чергу пристрою.
function frame() {
const encoder = device.createCommandEncoder();
const textureView = context.getCurrentTexture().createView();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: textureView,
clearValue: { r: 0.05, g: 0.05, b: 0.08, a: 1 },
loadOp: "clear",
storeOp: "store",
}],
});
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(3); // 3 вершини, 1 інстанс
pass.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
8. Уніформи та групи прив'язки
У WebGPU немає поняття «розташування уніформи», яке ви встановлюєте
за іменем під час виконання, як у WebGL. Замість цього ресурси
шейдера (уніформ-буфери, текстури, семплери) групуються в
GPUBindGroup, розкладка якого має відповідати парі
@group/@binding, оголошеній у WGSL.
const uniformBuffer = device.createBuffer({
size: 64, // матриця 4x4 з f32 = 16 * 4 байти
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: uniformBuffer } },
],
});
// Усередині прогону рендерингу, перед draw():
pass.setBindGroup(0, bindGroup);
9. Обчислювальні шейдери та обчислювальні проходи
Обчислювальні шейдери виконують довільну паралельну роботу без
растеризації — ідеально для симуляції частинок, інтегрування
фізики або обробки зображень повністю на GPU. Обчислювальний
шейдер оголошує workgroup_size і диспетчеризується по
тривимірній сітці робочих груп.
// WGSL: подвоєння кожного елемента буфера сховища
@group(0) @binding(0) var<storage, read_write> data : array<f32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id : vec3<u32>) {
let i = id.x;
if (i >= arrayLength(&data)) { return; }
data[i] = data[i] * 2.0;
}
const computePipeline = device.createComputePipeline({
layout: "auto",
compute: { module: computeShaderModule, entryPoint: "main" },
});
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(computePipeline);
pass.setBindGroup(0, computeBindGroup);
pass.dispatchWorkgroups(Math.ceil(elementCount / 64)); // сітка робочих груп
pass.end();
device.queue.submit([encoder.finish()]);
MAP_READ | COPY_DST,
через encoder.copyBufferToBuffer(), а потім
await buffer.mapAsync(GPUMapMode.READ).
10. Типові пастки та налагодження
-
Забутий
storeOp: "store": якщо встановити"discard", відрендерений кадр відкидається після прогону — на екрані нічого не з'явиться, хоча жодної помилки не буде викинуто. -
Невідповідність розміру буфера:
writeBuffer()тихо провалює валідацію, якщо вихідні дані більші за буфер призначення. Завжди створюйте буфери точно під розмір даних, які плануєте записати. -
Неправильний
arrayStride: крок, що не відповідає розкладці ваших чергованих даних, дає спотворену або невидиму геометрію, а не крах — уважно перевіряйте байтові зсуви. -
Створення конвеєра щокадру: створення конвеєра —
це крок компіляції. Виконання його всередині
frame()замість одного разу при старті різко знижує продуктивність і може спричинити помітні затримки. -
Ігнорування
uncapturederror: помилки валідації WebGPU за замовчуванням не викидаються синхронно — слухайте подію помилки пристрою або обгортайте виклики вdevice.pushErrorScope()/popErrorScope()під час розробки.
storage,
щоб передати тисячі інстансів в один виклик відмальовки через
@builtin(instance_index).