Урок · Просунутий рівень · ~55 хв
WebGL 2 · Transform Feedback · GLSL · Частинки на GPU

WebGL Transform Feedback — системи частинок на GPU

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

1Отримання контексту WebGL 2

Transform Feedback — це можливість WebGL 2. Отримайте контекст явно та підтвердьте його доступність:

const canvas = document.querySelector('canvas'); const gl = canvas.getContext('webgl2'); if (!gl) throw new Error('WebGL 2 не підтримується — оновіть браузер.'); // Підтверджуємо transform feedback, перевіряючи конструктор об'єкта const tf = gl.createTransformFeedback(); gl.deleteTransformFeedback(tf); console.log('Transform Feedback доступний');
Усі сучасні настільні браузери підтримують WebGL 2. На мобільних Safari додав підтримку WebGL 2 в iOS 15. Перевіряйте caniuse.com/webgl2 для найновішої матриці покриття.

2Створення ping-pong VAO та буферів

Дві пари буферів зберігають стан частинок. Кожного кадру один читається, а інший записується. Потім вони міняються місцями.

const N = 500_000; // Розкладка: [x, y, z, vx, vy, vz, age, lifespan] 8 float на частинку const STRIDE = 8; function makeParticleBuffer() { const data = new Float32Array(N * STRIDE); for (let i = 0; i < N; i++) { const b = i * STRIDE; data[b] = (Math.random() - 0.5) * 4; // x data[b+1] = (Math.random() - 0.5) * 4; // y data[b+2] = (Math.random() - 0.5) * 4; // z data[b+3] = (Math.random() - 0.5) * 0.02; // vx data[b+4] = Math.random() * 0.02; // vy (дрейф угору) data[b+5] = (Math.random() - 0.5) * 0.02; // vz data[b+6] = Math.random() * 500; // вік (зі зміщенням) data[b+7] = 300 + Math.random() * 200; // тривалість життя } const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_COPY); return buf; } const bufA = makeParticleBuffer(); const bufB = makeParticleBuffer(); let [readBuf, writeBuf] = [bufA, bufB]; // Один об'єкт transform feedback на буфер запису const tfA = gl.createTransformFeedback(); const tfB = gl.createTransformFeedback(); function bindTF(tf, buf) { gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, tf); gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, buf); gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null); } bindTF(tfA, bufA); bindTF(tfB, bufB); let [readTF, writeTF] = [tfA, tfB];

3Шейдер оновлення з varying-ами transform feedback

const updateVS = /* glsl */ `#version 300 es in vec3 a_pos; in vec3 a_vel; in float a_age; in float a_lifespan; out vec3 v_pos; out vec3 v_vel; out float v_age; out float v_lifespan; // LCG-псевдовипадкове, ініціалізоване даними частинки float rand(float seed) { return fract(sin(seed * 127.1 + 311.7) * 43758.5453); } void main() { float t = a_age / a_lifespan; if (a_age >= a_lifespan) { // Відродження в початку координат з випадковою швидкістю float s = a_age + a_pos.x * 13.0; v_pos = vec3(0.0); v_vel = vec3(rand(s)*0.04-0.02, rand(s+1.0)*0.04+0.01, rand(s+2.0)*0.04-0.02); v_age = 0.0; v_lifespan = 300.0 + rand(s+3.0) * 200.0; } else { // Гравітація + вік v_vel = a_vel + vec3(0.0, -0.00003, 0.0); v_pos = a_pos + v_vel; v_age = a_age + 1.0; v_lifespan = a_lifespan; } } `; const updateFS = `#version 300 es precision highp float; void main() {}`; // фрагментний шейдер ніколи не виконується (растеризацію вимкнено) // Компілюємо та лінкуємо — критично: оголосіть varying-и transform feedback ПЕРЕД лінкуванням const updateProg = gl.createProgram(); gl.attachShader(updateProg, compileShader(gl, gl.VERTEX_SHADER, updateVS)); gl.attachShader(updateProg, compileShader(gl, gl.FRAGMENT_SHADER, updateFS)); gl.transformFeedbackVaryings( updateProg, ['v_pos', 'v_vel', 'v_age', 'v_lifespan'], gl.INTERLEAVED_ATTRIBS // єдиний переплетений буфер, а не окремі ); gl.linkProgram(updateProg);
Порядок має значення: викликайте gl.transformFeedbackVaryings() перед gl.linkProgram(). Драйверу потрібно знати розкладку захоплення під час лінкування.

4Запуск проходу оновлення (растеризацію вимкнено)

function updatePass() { gl.useProgram(updateProg); bindAttribs(readBuf, updateProg); // a_pos, a_vel, a_age, a_lifespan // Вимикаємо растеризацію — нам потрібні лише перетворені дані gl.enable(gl.RASTERIZER_DISCARD); gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, writeTF); gl.beginTransformFeedback(gl.POINTS); gl.drawArrays(gl.POINTS, 0, N); gl.endTransformFeedback(); gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null); gl.disable(gl.RASTERIZER_DISCARD); // Міняємо місцями читання ↔ запис [readBuf, writeBuf] = [writeBuf, readBuf]; [readTF, writeTF] = [writeTF, readTF]; } function bindAttribs(buf, prog) { gl.bindBuffer(gl.ARRAY_BUFFER, buf); const bytes = Float32Array.BYTES_PER_ELEMENT; const stride = 8 * bytes; function attr(name, size, offset) { const loc = gl.getAttribLocation(prog, name); if (loc === -1) return; gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride, offset * bytes); } attr('a_pos', 3, 0); attr('a_vel', 3, 3); attr('a_age', 1, 6); attr('a_lifespan', 1, 7); }

5Прохід рендерингу: відмалювання оновлених частинок

const renderVS = /* glsl */ `#version 300 es in vec3 a_pos; in float a_age; in float a_lifespan; uniform mat4 u_vp; uniform float u_pixelRatio; out float v_t; void main() { v_t = a_age / a_lifespan; gl_Position = u_vp * vec4(a_pos, 1.0); gl_PointSize = (1.0 - v_t) * 4.0 * u_pixelRatio; } `; const renderFS = /* glsl */ `#version 300 es precision highp float; in float v_t; out vec4 fragColor; void main() { // Круглий точковий спрайт vec2 uv = gl_PointCoord * 2.0 - 1.0; if (dot(uv, uv) > 1.0) discard; // Колір вогню: білий → помаранчевий → темно-червоний vec3 col = mix(vec3(1.0, 0.9, 0.5), vec3(0.8, 0.1, 0.0), v_t); float alpha = (1.0 - v_t) * 0.6; fragColor = vec4(col * alpha, alpha); } `; // У циклі рендерингу: function renderPass(vp) { gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE); // адитивне змішування для вогню gl.useProgram(renderProg); gl.uniformMatrix4fv(vpLoc, false, vp); bindAttribs(readBuf, renderProg); gl.drawArrays(gl.POINTS, 0, N); gl.disable(gl.BLEND); }

6Створення, відмирання та відродження у шейдері

Логіка відродження у вершинному шейдері (Крок 3) обробляє весь життєвий цикл частинки на GPU. Зчитування на CPU не потрібні. Щоб підтримати позицію емітера або сплескове створення, передайте уніформу:

// JS — оновлюємо уніформу позиції емітера кожного кадру const emitterLoc = gl.getUniformLocation(updateProg, 'u_emitter'); function updatePass() { gl.useProgram(updateProg); gl.uniform3f(emitterLoc, emitter.x, emitter.y, emitter.z); // ... решта проходу } // GLSL — використовуємо її у гілці відродження /* glsl */` uniform vec3 u_emitter; // у гілці відродження: v_pos = u_emitter + vec3(rand(s)*0.1-0.05, 0.0, rand(s+1.0)*0.1-0.05); ` // Головний цикл function loop() { updatePass(); // фізика на GPU, нуль CPU renderPass(vpMatrix); // відмалювання на GPU requestAnimationFrame(loop); } requestAnimationFrame(loop);
Transform Feedback — найефективніший шлях для оновлення частинок на боці GPU у WebGL 2. Для WebGPU натомість використовуйте обчислювальні шейдери — вони дають більше гнучкості (довільний доступ, атомарні операції, бар'єрна синхронізація).