Урок · Середній рівень · ~45 хв
WebGL · GLSL · Three.js · Ping-pong-текстури

«Гра життя» на GPU за 50 рядків GLSL

Перенесення автомата «Гра життя» на GPU відкриває можливість працювати із сітками на мільйон клітин при 60 FPS. Кожен піксель текстури представляє одну клітину. Фрагментний шейдер читає текстуру, рахує сусідів і записує наступний стан — усе паралельно на кожному потоці GPU. Дві текстури чергуються (ping-pong), тож шейдер читає зі старого стану й записує в новий.

1Створення ping-pong render targets

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'; const SIZE = 1024; // сітка має SIZE × SIZE клітин function makeRT() { return new THREE.WebGLRenderTarget(SIZE, SIZE, { minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, format: THREE.RGBAFormat, type: THREE.UnsignedByteType, wrapS: THREE.RepeatWrapping, // тороїдальне загортання wrapT: THREE.RepeatWrapping, }); } // Дві цілі — щокадру чергуємо читання/запис let rtA = makeRT(); let rtB = makeRT(); // Допоміжна функція, щоб міняти їх місцями function swap() { [rtA, rtB] = [rtB, rtA]; }
NearestFilter має вирішальне значення — ми не повинні змішувати значення текселів під час семплювання сусідів. Білінійна фільтрація зіпсувала б двійковий стан клітини.

2Засів початкового стану

// Створюємо текстуру з випадковим шумом (канал R кожного пікселя = жива/мертва) const data = new Uint8Array(SIZE * SIZE * 4); for (let i = 0; i < SIZE * SIZE; i++) { const alive = Math.random() < 0.3 ? 255 : 0; // 30 % живих data[i * 4 + 0] = alive; // R = жива data[i * 4 + 3] = 255; // A = непрозора } const seedTex = new THREE.DataTexture(data, SIZE, SIZE, THREE.RGBAFormat); seedTex.needsUpdate = true; // Малюємо засів у rtA через простий прохід копіювання const copyMat = new THREE.MeshBasicMaterial({ map: seedTex }); const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), copyMat); const simCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); const simScene = new THREE.Scene(); simScene.add(quad); renderer.setRenderTarget(rtA); renderer.render(simScene, simCam); renderer.setRenderTarget(null);

3Фрагментний шейдер «Гри життя»

const simFrag = /* glsl */ ` precision highp float; uniform sampler2D u_state; uniform vec2 u_res; // SIZE, SIZE varying vec2 vUv; float cell(vec2 offset) { // Семплюємо із загортанням — використовуючи RepeatWrapping на текстурі return texture2D(u_state, vUv + offset / u_res).r > 0.5 ? 1.0 : 0.0; } void main() { float alive = cell(vec2(0.0)); // Рахуємо 8 сусідів float neighbours = cell(vec2(-1,-1)) + cell(vec2(0,-1)) + cell(vec2(1,-1)) + cell(vec2(-1, 0)) + cell(vec2(1, 0)) + cell(vec2(-1, 1)) + cell(vec2(0, 1)) + cell(vec2(1, 1)); // Правила Конвея float next; if (alive > 0.5) { next = (neighbours == 2.0 || neighbours == 3.0) ? 1.0 : 0.0; } else { next = (neighbours == 3.0) ? 1.0 : 0.0; } gl_FragColor = vec4(next, next, next, 1.0); } `; const simMat = new THREE.ShaderMaterial({ uniforms: { u_state: { value: null }, u_res: { value: new THREE.Vector2(SIZE, SIZE) }, }, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position, 1.0); } `, fragmentShader: simFrag, }); quad.material = simMat;

4Прохід симуляції в циклі рендерингу

function stepSimulation() { // ЧИТАЄМО з rtA, ЗАПИСУЄМО в rtB simMat.uniforms.u_state.value = rtA.texture; renderer.setRenderTarget(rtB); renderer.render(simScene, simCam); renderer.setRenderTarget(null); // Тепер rtB має нове покоління — міняємо місцями для наступного кадру swap(); } function animate() { requestAnimationFrame(animate); // Виконуємо кілька кроків симуляції на один візуальний кадр для швидших візерунків for (let s = 0; s < 2; s++) stepSimulation(); // Відображаємо поточний стан (rtA після обміну = щойно записаний rtB) displayMat.uniforms.u_tex.value = rtA.texture; renderer.render(displayScene, displayCam); }
Завжди читайте з однієї render target і записуйте в іншу. Обмін вказівниками виконується за O(1) і уникає дорогих копіювань текстур.

5Прохід відображення та взаємодія мишею

// Шейдер відображення: жива=біла, мертва=темно-зелена const displayFrag = /* glsl */ ` precision highp float; uniform sampler2D u_tex; varying vec2 vUv; void main() { float v = texture2D(u_tex, vUv).r; vec3 col = mix(vec3(0.05, 0.07, 0.05), vec3(0.28, 0.93, 0.43), v); gl_FragColor = vec4(col, 1.0); } `; // Миша: малюємо живі клітини при кліку/перетягуванні canvas.addEventListener('pointermove', e => { if (e.buttons === 0) return; const uv = { x: e.offsetX / canvas.width, y: 1 - e.offsetY / canvas.height }; // Записуємо блок 5×5 живих клітин навколо позиції миші const px = Math.floor(uv.x * SIZE); const py = Math.floor(uv.y * SIZE); const tex = rtA.texture; // Для постійного редагування використовуйте підхід DataTexture + readRenderTargetPixels // або просто семплюйте стан у цій позиції в шейдері через uniform u_brush console.info(`Paint at cell (${px}, ${py})`); });
Для інтерактивного малювання передайте позицію пензля як uniform vec2 у шейдер симуляції та зробіть клітини в межах радіуса живими — зчитування з GPU на CPU не потрібне.