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).
Вершинний шейдер тут не обчислює екранні
позиції — він обчислює фізику. Ви передаєте дані частинок як
атрибути, виконуєте інтегрування за Ейлером у 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);
}
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).