Урок · WebGL2 · Просунутий рівень
📅 Березень 2026⏱ 50 хв читання🎓 Просунутий рівень⚙️ WebGL2 / GLSL

WebGL Transform Feedback — симуляція частинок на GPU

Transform Feedback захоплює вихідні дані вершинного шейдера назад у буфер GPU, повністю оминаючи CPU. Результат: 1 000 000 частинок, що оновлюються на 60 FPS, порівняно з менш ніж 5 000 у наївному циклі на CPU. Цей урок крок за кроком проходить повну реалізацію на WebGL2 — від налаштування буферів до патерну ping-pong і налагодження NaN.

1. Як працює Transform Feedback

У стандартному конвеєрі WebGL вихід вершинного шейдера подається на растеризатор, а потім на фрагментний шейдер. Transform Feedback перехоплює цей вихід і записує його в об'єкт Transform Feedback (TFO) — звичайний буфер GPU зі спеціальною прив'язкою. Фрагментний шейдер вимкнено під час цього проходу (RASTERIZER_DISCARD).

Кадр N: BufferA (pos, vel) → ВЕРШИННИЙ ШЕЙДЕР → TFO → BufferB (нова pos, нова vel) Кадр N+1: BufferB → ВЕРШИННИЙ ШЕЙДЕР → TFO → BufferA ↑ "ping-pong"

Вершинний шейдер тут не обчислює екранні позиції — він обчислює фізику. Ви передаєте дані частинок як атрибути, виконуєте інтегрування за Ейлером у GLSL і записуєте gl_Position (перевизначений) або власні varying-и до TFO.

2. Налаштування буферів та VAO

Кожна частинка зберігає 4 float для позиції (x, y, z, вік) і 3 float для швидкості (vx, vy, vz). Створіть два переплетені буфери та два VAO для ping-pong:

const N      = 1_000_000;  // кількість частинок
const STRIDE = 7;          // 4 float позиції + 3 float швидкості
const BYTES  = 4;          // байтів на float32

// Ініціалізуємо частинки випадковими позиціями появи
const init = new Float32Array(N * STRIDE);
for (let i = 0; i < N; i++) {
  const b = i * STRIDE;
  init[b    ] = (Math.random() - .5) * 20;  // x
  init[b + 1] = (Math.random() - .5) * 20;  // y
  init[b + 2] = (Math.random() - .5) * 20;  // z
  init[b + 3] = Math.random() * 5;         // вік
  init[b + 4] = (Math.random() - .5);      // vx
  init[b + 5] = Math.random() * 2 + 1;     // vy (зміщення вгору)
  init[b + 6] = (Math.random() - .5);      // vz
}

function makeBuffer(data) {
  const buf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, data, gl.STREAM_COPY);
  return buf;
}

const bufA = makeBuffer(init);
const bufB = makeBuffer(init); // ті самі початкові дані, будуть перезаписані

// Створюємо VAO, що вказують на кожен буфер як на вхідні атрибути
function makeVAO(buf, prog) {
  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  const posLoc = gl.getAttribLocation(prog, 'a_pos');
  const velLoc = gl.getAttribLocation(prog, 'a_vel');
  gl.enableVertexAttribArray(posLoc);
  gl.vertexAttribPointer(posLoc, 4, gl.FLOAT, false, STRIDE * BYTES, 0);
  gl.enableVertexAttribArray(velLoc);
  gl.vertexAttribPointer(velLoc, 3, gl.FLOAT, false, STRIDE * BYTES, 4 * BYTES);
  return vao;
}

const vaoA = makeVAO(bufA, updateProg); // A → читає з bufA
const vaoB = makeVAO(bufB, updateProg); // B → читає з bufB

3. Шейдер оновлення частинок

Це вершинний шейдер, що виконує фізику, а не геометрію. Оголосіть вихідні varying-и та зареєструйте їх через transformFeedbackVaryings перед лінкуванням програми:

// particles.vert — прохід оновлення (фрагментний шейдер не потрібен)
const updateVS = `#version 300 es
precision highp float;

in vec4 a_pos;   // xyz = позиція, w = вік
in vec3 a_vel;   // швидкість

out vec4 v_pos;  // НОВА позиція → TFO
out vec3 v_vel;  // НОВА швидкість → TFO

uniform float u_dt;
uniform vec3  u_gravity;
uniform float u_drag;
uniform float u_maxAge;

void main() {
  float age = a_pos.w + u_dt;
  bool  dead = age > u_maxAge;

  // Якщо частинка вмирає, відроджуємо в початку координат з випадковою швидкістю
  vec3 pos = dead ? vec3(0.0) : a_pos.xyz;
  vec3 vel = dead ? vec3(
      (fract(sin(float(gl_VertexID) * 127.1) * 43758.5) - 0.5) * 2.0,
      fract(sin(float(gl_VertexID) * 311.7) * 43758.5) * 3.0 + 0.5,
      (fract(sin(float(gl_VertexID) * 74.9)  * 43758.5) - 0.5) * 2.0
    ) : a_vel;
  age = dead ? 0.0 : age;

  // Інтегрування за Ейлером
  vel += u_gravity * u_dt;
  vel *= (1.0 - u_drag * u_dt);
  pos += vel * u_dt;

  v_pos = vec4(pos, age);
  v_vel = vel;

  // Обов'язково, але ігнорується (активний RASTERIZER_DISCARD)
  gl_Position = vec4(0.0);
}`;

// Після compileShader / createProgram, ПЕРЕД linkProgram:
gl.transformFeedbackVaryings(
  updateProg,
  ['v_pos', 'v_vel'],
  gl.INTERLEAVED_ATTRIBS  // вихід переплетено в один буфер
);
gl.linkProgram(updateProg);
Порядок має значення: transformFeedbackVaryings треба викликати перед linkProgram. Якщо викликати після, програма злінкується успішно, але жодні дані не захоплюватимуться — прихована вада.

4. Цикл оновлення ping-pong

let readBuf = bufA, readVAO = vaoA;
let writeBuf = bufB, writeVAO = vaoB;

const tfo = gl.createTransformFeedback();

function update(dt) {
  gl.useProgram(updateProg);
  gl.uniform1f(gl.getUniformLocation(updateProg, 'u_dt'), dt);
  gl.uniform3f(gl.getUniformLocation(updateProg, 'u_gravity'), 0, -9.8, 0);
  gl.uniform1f(gl.getUniformLocation(updateProg, 'u_drag'), 0.02);
  gl.uniform1f(gl.getUniformLocation(updateProg, 'u_maxAge'), 5.0);

  // Прив'язуємо вихідний буфер до слоту 0 TFO
  gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, tfo);
  gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, writeBuf);

  gl.enable(gl.RASTERIZER_DISCARD);  // пропускаємо фрагментний шейдер
  gl.bindVertexArray(readVAO);
  gl.beginTransformFeedback(gl.POINTS);
  gl.drawArrays(gl.POINTS, 0, N);
  gl.endTransformFeedback();
  gl.disable(gl.RASTERIZER_DISCARD);
  gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);

  // Міняємо місцями ping-pong
  [readBuf, writeBuf] = [writeBuf, readBuf];
  [readVAO, writeVAO] = [writeVAO, readVAO];
}

5. Рендеринг частинок

Для проходу рендерингу використайте окрему шейдерну програму, що читає з поточного readBuf (який містить щойно оновлені дані) і малює його як gl.POINTS з матрицею view-projection:

// render.vert — прохід відображення
const renderVS = `#version 300 es
in vec4 a_pos;  // xyz=позиція, w=вік
in vec3 a_vel;
uniform mat4 u_mvp;
uniform float u_maxAge;
out float v_alpha;
void main() {
  gl_Position  = u_mvp * vec4(a_pos.xyz, 1.0);
  gl_PointSize = max(1.0, 4.0 * (1.0 - a_pos.w / u_maxAge));
  v_alpha      = 1.0 - a_pos.w / u_maxAge;
}`;

const renderFS = `#version 300 es
precision mediump float;
in  float v_alpha;
out vec4  fragColor;
void main() {
  // Кругла форма точки
  vec2 c = gl_PointCoord - 0.5;
  if (dot(c, c) > 0.25) discard;
  fragColor = vec4(1.0, 0.6, 0.2, v_alpha);
}`;

function render(mvp) {
  gl.useProgram(renderProg);
  gl.uniformMatrix4fv(gl.getUniformLocation(renderProg, 'u_mvp'), false, mvp);
  // Прив'язуємо VAO зі сторони читання (той самий буфер, але renderVAO використовує розташування атрибутів renderProg)
  gl.bindVertexArray(renderReadVAO);
  gl.drawArrays(gl.POINTS, 0, N);
}
Окремі VAO для окремих програм: розташування атрибутів відрізняються між updateProg та renderProg. Створіть окремий VAO (renderReadVAO) для кожного з bufA/bufB, прив'язаний до розташувань renderProg — а потім міняйте їх синхронно з ping-pong.

6. Результати бенчмарка

Підхід Кількість частинок Час кадру Примітки
CPU (цикл Float32Array) 5 000 ~16 мс Вузьке місце: цикл JS + bufferData щокадру
CPU (SharedArrayBuffer + Worker) 25 000 ~16 мс Краще, але залишається вартість передачі CPU-GPU
GPU Transform Feedback 1 000 000 ~4 мс Нуль CPU на частинку; GPU повністю задіяно
WebGPU Compute Shaders 5 000 000+ ~4 мс Наступне покоління; потрібен Chrome 113+

Протестовано на NVIDIA RTX 3070; Chrome 120; M1 MacBook Pro досягає подібних показників через WebGL2 на Metal.

7. Налагодження: частинки NaN на GPU

Прихована поява NaN — найпоширеніша вада GPGPU на GPU. Щойно позиція однієї частинки стає NaN (наприклад, через ділення 0/0), вона часто поширюється та «вбиває» великі ділянки буфера. Як її знайти:

// Зчитуємо невеликий зріз вихідного буфера на CPU для перевірки
const check = new Float32Array(7 * 100);  // перші 100 частинок
gl.bindBuffer(gl.ARRAY_BUFFER, readBuf);
gl.getBufferSubData(gl.ARRAY_BUFFER, 0, check);

const badIdx = Array.from({length: 100}, (_, i) => i)
  .filter(i => !isFinite(check[i * 7]) || !isFinite(check[i * 7 + 1]));

if (badIdx.length > 0) console.warn('NaN particles at:', badIdx);

Поширені джерела NaN: normalize(vec3(0)) (вектор нульової довжини), 1.0 / 0.0 у GLSL (повертає Infinity, а не NaN, але може поширюватися), sqrt(x), де x < 0. Захищайте їх через max(length, 0.0001) або max(x, 0.0).