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:
Каркас налаштування
// 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.
Якщо ваш браузер не підтримує WebGL2 Transform Feedback, JavaScript-резерв натомість відмалює спрощену CPU-симуляцію з 10 000 частинок.