WebGL2 (доступний у всіх основних браузерах із 2017 року, на основі OpenGL ES 3.0) приніс кілька потужних можливостей, яких бракувало в WebGL1: об'єкти масивів вершин (VAO), Transform Feedback, кілька цілей рендерингу (MRT), цілочислові текстури, інстансований рендеринг і фреймбуфери з рухомою комою. Разом вони дають змогу виконувати GPGPU-обчислення продакшн-якості повністю в браузері без WebGPU. Ця стаття охоплює три найважливіші просунуті шаблони.

1. WebGL2 проти WebGL1 — різниця можливостей

Можливість WebGL1 WebGL2
Об'єкти масивів вершин (VAO) Лише розширення (OES_vertex_array_object) Базова
Transform Feedback Недоступний Базова
Текстури з рухомою комою (рендеринг RGBA32F) Розширення (не для рендерингу) EXT_color_buffer_float (для рендерингу)
Цілочислові текстури Ні RGBA8UI, RGBA32I тощо
Кілька цілей рендерингу (MRT) Розширення WEBGL_draw_buffers Базова (drawBuffers)
Інстансований рендеринг Розширення ANGLE_instanced_arrays Базова
Об'єкти uniform-буферів (UBO) Ні Базова
3D-текстури Ні Базова (TEXTURE_3D)
Версія GLSL #version 100 (ES 1.0) #version 300 es (ES 3.0)

2. Об'єкти масивів вершин (VAO)

У WebGL1 кожен виклик відмалювання потребує повторного прив'язування всіх вказівників на атрибути вершин: bindBuffer, vertexAttribPointer, enableVertexAttribArray для кожного атрибута. VAO інкапсулюють увесь цей стан один раз і відтворюють його єдиним gl.bindVertexArray(vao).

// WebGL2 — створюємо й заповнюємо VAO один раз
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);

// позиції
const posBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.DYNAMIC_COPY);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);

// швидкості
const velBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, velBuf);
gl.bufferData(gl.ARRAY_BUFFER, velocities, gl.DYNAMIC_COPY);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0);

gl.bindVertexArray(null);

// Згодом, щокадру:
gl.bindVertexArray(vao);
gl.drawArrays(gl.POINTS, 0, N_PARTICLES);
gl.bindVertexArray(null);

VAO особливо важливі для сцен із багатьма різними сітками — кожна сітка отримує власний VAO. Перемикання зі сітки A на сітку B коштує одного bindVertexArray замість 10–20 змін стану.

3. Transform Feedback: системи частинок на GPU

Transform Feedback дає змогу захопити вихід вершинного шейдера назад у GPU-буфер, оминаючи растеризацію. Це уможливлює цикли обчислень на боці GPU без жодного зчитування даних назад на CPU:

VBO_A (читання) → [вершинний шейдер: оновлення фізики] → TF VBO_B (запис) VBO_B (читання) → [вершинний шейдер: оновлення фізики] → TF VBO_A (запис) → ping-pong щокадру

Каркас налаштування

// 1. Оголошуємо TF-varyings ПЕРЕД лінкуванням програми
gl.transformFeedbackVaryings(
  updateProgram,
  ['v_position', 'v_velocity', 'v_age'],
  gl.SEPARATE_ATTRIBS   // або INTERLEAVED_ATTRIBS
);
gl.linkProgram(updateProgram);

// 2. Створюємо об'єкт transform feedback
const tf = gl.createTransformFeedback();
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, tf);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, bufB_pos);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 1, bufB_vel);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 2, bufB_age);
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);

// 3. Щокадру — прохід оновлення фізики (без растеризації)
gl.useProgram(updateProgram);
gl.bindVertexArray(vaoA);         // читання з буфера A
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, tf);  // запис у B
gl.enable(gl.RASTERIZER_DISCARD); // пропускаємо фрагментний шейдер
gl.beginTransformFeedback(gl.POINTS);
gl.drawArrays(gl.POINTS, 0, N);
gl.endTransformFeedback();
gl.disable(gl.RASTERIZER_DISCARD);
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);

// 4. Прохід рендерингу — відмальовуємо буфер B
gl.useProgram(renderProgram);
gl.bindVertexArray(vaoB);
gl.drawArrays(gl.POINTS, 0, N);

// 5. Міняємо A ↔ B для наступного кадру

Вершинний шейдер для оновлення фізики отримує поточні позицію, швидкість і вік як атрибути та видає наступний стан як varyings, захоплені TF. Жодного обміну даними CPU-GPU — уся симуляція виконується на пропускній здатності пам'яті GPU (сотні ГБ/с).

Вершинний шейдер фізики на GLSL 300 es (фрагмент)

#version 300 es
precision highp float;

layout(location=0) in vec3 a_position;
layout(location=1) in vec3 a_velocity;
layout(location=2) in float a_age;

out vec3 v_position;
out vec3 v_velocity;
out float v_age;

uniform float u_dt;
uniform vec3  u_gravity;
uniform float u_lifespan;

void main() {
  vec3 vel = a_velocity + u_gravity * u_dt;
  vec3 pos = a_position + vel * u_dt;

  // відроджуємо мертві частинки на початку координат з випадковою швидкістю
  float age = a_age + u_dt;
  if (age > u_lifespan) {
    // використовуємо gl_VertexID як зерно для псевдовипадкового відродження
    float s = sin(float(gl_VertexID) * 127.1 + age * 311.7);
    vel = normalize(vec3(s, fract(s*1.61), fract(s*2.72))) * 2.0;
    pos = vec3(0.0);
    age = 0.0;
  }

  // простий відскок від землі
  if (pos.y < 0.0) { pos.y = 0.0; vel.y = abs(vel.y) * 0.6; }

  v_position = pos;
  v_velocity = vel;
  v_age      = age;
}

4. GPGPU ping-pong фреймбуфери

Для двовимірних симуляцій (рідина, реакція-дифузія, гра «Життя», хвильові рівняння) бажаним шаблоном GPGPU є ping-pong текстура: два фреймбуфери, кожен на основі float-текстури. Щокадру читаємо з «A» і пишемо в «B», потім міняємо місцями.

// Налаштування — текстури RGBA32F (потребує EXT_color_buffer_float)
const ext = gl.getExtension('EXT_color_buffer_float');
if (!ext) throw new Error('EXT_color_buffer_float недоступний');

function makeFloatFBO(w, h) {
  const tex = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, tex);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, w, h, 0,
                gl.RGBA, gl.FLOAT, null);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

  const fbo = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0,
                          gl.TEXTURE_2D, tex, 0);
  return { fbo, tex };
}

let [A, B] = [makeFloatFBO(W, H), makeFloatFBO(W, H)];

// Щокадру:
gl.bindFramebuffer(gl.FRAMEBUFFER, B.fbo);
gl.bindTexture(gl.TEXTURE_2D, A.tex);
// відмальовуємо повноекранний чотирикутник із шейдером симуляції
[A, B] = [B, A]; // міняємо місцями

Ядро симуляції виконується повністю у фрагментному шейдері: кожен тексель обчислює свій новий стан із цього текселя та його сусідів (через texture(uState, uv + offset)). Цей підхід забезпечує роботу симуляцій рідини WebGL, реакції-дифузії, клітинних автоматів і власного виведення нейронних мереж у браузері.

5. Кілька цілей рендерингу (MRT)

Один прохід рендерингу може писати в кілька текстур одночасно за допомогою gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1, …]) з кількома виходами фрагментного шейдера layout(location=N):

// Фрагментний шейдер із MRT
#version 300 es
precision highp float;
layout(location=0) out vec4 out_position;  // позиція G-буфера
layout(location=1) out vec4 out_normal;    // нормаль G-буфера
layout(location=2) out vec4 out_albedo;    // альбедо G-буфера

void main() {
  out_position = vec4(v_worldPos, gl_FragDepth);
  out_normal   = vec4(normalize(v_normal) * 0.5 + 0.5, 1.0);
  out_albedo   = texture(u_albedoTex, v_uv);
}

MRT — це основа відкладеного шейдингу (deferred shading): заповнюємо G-буфер за один прохід (дороге обчислення вершин і матеріалів), потім накладаємо всі джерела світла в окремому проході, вибираючи дані з G-буфера. Це відокремлює складність сцени від кількості джерел світла: O(геометрія) + O(джерела світла) замість O(геометрія × джерела світла).

6. Об'єкти uniform-буферів (UBO)

WebGL1 потребує одного виклику gl.uniform*() на кожен uniform у кожній програмі. При перемиканні між багатьма матеріалами чи викликами відмалювання це створює навантаження на драйвер CPU. UBO упаковують блок uniform-змінних у GPU-буфер, спільний для кількох шейдерних програм через точку прив'язки:

// GLSL 300 es — оголошуємо UBO
layout(std140) uniform CameraUniforms {
  mat4 u_view;
  mat4 u_projection;
  vec3 u_cameraPos;
  float u_near;
  float u_far;
};

// JS — створюємо й прив'язуємо UBO
const ubo = gl.createBuffer();
gl.bindBuffer(gl.UNIFORM_BUFFER, ubo);
gl.bufferData(gl.UNIFORM_BUFFER, cameraData, gl.DYNAMIC_DRAW);
const blockIdx = gl.getUniformBlockIndex(program, 'CameraUniforms');
gl.uniformBlockBinding(program, blockIdx, 0);  // точка прив'язки 0
gl.bindBufferBase(gl.UNIFORM_BUFFER, 0, ubo);

// Оновлюємо раз на кадр, усі програми зі спільною прив'язкою 0 бачать нові дані
gl.bufferSubData(gl.UNIFORM_BUFFER, 0, newCameraData);

7. Практичні поради щодо продуктивності

Вузьке місце Діагноз Виправлення
Забагато викликів відмалювання Обмеження з боку CPU; GPU простоює >50% Об'єднайте геометрію; використовуйте інстансинг; групуйте через VAO
Зчитування буфера (readPixels) Зупиняє конвеєр GPU; величезний стрибок часу кадру Використовуйте Transform Feedback або Pixel Pack Buffer (PBO) із затримкою на 2 кадри
Навантаження від uniform-змінних Багато викликів gl.uniform*() Перейдіть на UBO; упаковуйте пов'язані дані разом
Формат текстури RGB не степінь двійки, без mip-рівнів Використовуйте RGBA (вирівняне), генеруйте mip-рівні, віддавайте перевагу gl.NEAREST для float-даних
Обмеження ALU фрагментного шейдера Довгий фрагментний шейдер; перемальовування Попередньо обчислюйте в текстури; зменшуйте перемальовування; early-z
Навантаження від TF Не задано rasterizer discard Завжди задавайте RASTERIZER_DISCARD під час проходів оновлення TF

8. Інтерактив: система частинок на Transform Feedback

Ця демонстрація запускає гравітаційний фонтан зі 100 000 частинок повністю на GPU, використовуючи ping-pong буфери Transform Feedback у WebGL2. Кожна частинка має позицію (xyz), швидкість (xyz) та вік. Шейдер оновлює фізику (гравітація, відскок, відродження), а шейдер рендерингу малює кожну частинку як точковий спрайт, забарвлений за віком. Жодних масивів частинок на боці CPU.

FPS: Частинок: 100 000

Якщо ваш браузер не підтримує WebGL2 Transform Feedback, JavaScript-резерв натомість відмалює спрощену CPU-симуляцію з 10 000 частинок.