🟣 Three.js · WebGL · Посібник
📅 Березень 2026 ⏱ ≈ 12 хв читання 🟡 Середній 📦 Three.js r165+ · Останнє оновлення: 5 липня 2026 р.

Посібник із системи частинок Three.js

Як відрендерити 100 000 анімованих частинок при 60 fps у браузері — за допомогою BufferGeometry та Float32Array, щоб тримати всі дані частинок на GPU, не звертаючись до збирача сміття JavaScript жодного разу за кадр.

Чому BufferGeometry (а не Geometry)

Three.js має два способи визначення геометрії. Застарілий клас Geometry зберігав дані як об’єкти та масиви JavaScript — можна було написати geometry.vertices.push(new THREE.Vector3(...)). Це було зручно, але повільно: кожен кадр витрачався на зчитування положень з GPU, запуск збирача сміття JS на тисячах об’єктів Vector3 та повторне завантаження на GPU.

BufferGeometry зберігає дані як типізовані масиви — Float32Array, Uint16Array тощо — які є суміжними блоками пам’яті, що можуть передаватися безпосередньо на GPU як буферні об’єкти. Переваги:

Нотатка щодо застарілого: клас Geometry був видалений у Three.js r125 (2021). Увесь сучасний код Three.js використовує BufferGeometry.

Налаштування проєкту

Імпортуйте Three.js із CDN або встановіть через npm:

/* Варіант A: ES-модуль із CDN (бандлер не потрібен) */ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js'; /* Варіант B: npm install three → import * as THREE from 'three'; */

Мінімальний HTML-каркас:

<!DOCTYPE html> <html> <head><meta name="viewport" content="width=device-width,initial-scale=1"></head> <body style="margin:0;overflow:hidden;background:#000"> <canvas id="c"></canvas> <script type="module" src="particles.js"></script> </body> </html>
// particles.js — шаблонне налаштування сцени import * as THREE from 'three'; const canvas = document.getElementById('c'); const renderer = new THREE.WebGLRenderer({ canvas, antialias: false }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(0, 0, 80); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });

Виділення положень за допомогою Float32Array

Виділіть типізований масив один раз перед циклом. Три float на частинку (x, y, z). Ніколи не створюйте новий масив усередині циклу анімації.

const COUNT = 100_000; // 100 000 частинок // ОДНЕ виділення — повторно використовується кожен кадр const positions = new Float32Array(COUNT * 3); // Ініціалізувати випадкові положення у сфері радіусом 50 for (let i = 0; i < COUNT; i++) { const r = 50 * Math.cbrt(Math.random()); // рівномірний розподіл у сфері const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); positions[i * 3] = r * Math.sin(phi) * Math.cos(theta); // x positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta); // y positions[i * 3 + 2] = r * Math.cos(phi); // z } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
Порада щодо рівномірного розподілу у сфері: поширена помилка — генерувати випадкову точку в [−r, +r]³ і відкидати точки поза сферою. Це працює, але використання сферичних координат із r * Math.cbrt(Math.random()) забезпечує рівномірну густину за об’ємом, а не концентрацію на поверхні біля полюсів.

PointsMaterial та об’єкт Points

THREE.Points рендерить кожну вершину як спрайт в екранному просторі — найшвидший примітив для частинок; без трикутників, без індексів. PointsMaterial керує розміром і кольором.

const material = new THREE.PointsMaterial({ size: 0.3, // розмір у світовому просторі sizeAttenuation: true, // частинки зменшуються з відстанню (перспектива) color: 0xffffff, transparent: true, opacity: 0.8, depthWrite: false, // запобігає взаємному перекриттю частинок blending: THREE.AdditiveBlending // яскраве накладання — ефект сяйва }); const particles = new THREE.Points(geometry, material); scene.add(particles);

AdditiveBlending означає, що частинки, які перекриваються, додають свої кольори разом, створюючи «сяйво» там, де щільно зібрані частинки стають яскраво-білими — класичний вигляд зорі/туманності. Використовуйте THREE.NormalBlending для непрозорих частинок (наприклад, пісок, дим).

Кольори вершин для кожної частинки

Додайте атрибут color із трьома float на частинку (r, g, b у діапазоні 0–1) та увімкніть vertexColors: true:

const colors = new Float32Array(COUNT * 3); // одне виділення const _color = new THREE.Color(); for (let i = 0; i < COUNT; i++) { // Відтінок за висотою, повна насиченість, половинна світлість const y = positions[i * 3 + 1]; _color.setHSL((y + 50) / 100, 1.0, 0.5); colors[i * 3] = _color.r; colors[i * 3 + 1] = _color.g; colors[i * 3 + 2] = _color.b; } geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); material.vertexColors = true;
Повторно використовуйте об’єкт Color: зверніть увагу, що об’єкт _color виділяється один раз поза циклом. setHSL() змінює його на місці. Створення new THREE.Color() усередині циклу виділило б 100 000 об’єктів — запустивши збирач сміття під час наступного кадру.

Цикл анімації — нуль виділень

Золоте правило: ніколи не виділяйте пам’ять усередині циклу анімації. Оголосіть усі тимчасові змінні до того, як почнеться requestAnimationFrame, а потім змінюйте їх на місці кожного кадру.

const posAttr = geometry.getAttribute('position'); let t = 0; function animate() { requestAnimationFrame(animate); t += 0.001; // Оновлення положень частинок — читаємо й пишемо Float32Array безпосередньо for (let i = 0; i < COUNT; i++) { const ix = i * 3; // проста орбіта: обертаємо кожну частинку навколо осі Y const x = positions[ix]; const z = positions[ix + 2]; const s = Math.sin(t), c = Math.cos(t); positions[ix] = x * c - z * s; // обертаємо x positions[ix + 2] = x * s + z * c; // обертаємо z } // Повідомляємо Three.js, що буфер положень змінився цього кадру posAttr.needsUpdate = true; renderer.render(scene, camera); } animate();

Цикл вище змінює ~1,2 МБ даних Float32Array на кадр повністю в JavaScript. Для інтенсивних для CPU симуляцій (рідина SPH, гравітаційна задача N тіл) ви перенесли б цю логіку у вершинний шейдер GLSL, дозволивши GPU оновлювати всі положення паралельно й повністю усунувши копіювання типізованого масиву.

Власний шейдер: круглі спрайти

За замовчуванням PointsMaterial рендерить квадратні білборди. Власний шейдер, що відкидає фрагменти поза колом, дає круглі частинки:

const material = new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 } }, vertexShader: ` attribute vec3 color; varying vec3 vColor; void main() { vColor = color; vec4 mvPos = modelViewMatrix * vec4(position, 1.0); gl_PointSize = 3.0 * (300.0 / -mvPos.z); // перспективне масштабування gl_Position = projectionMatrix * mvPos; } `, fragmentShader: ` varying vec3 vColor; void main() { // gl_PointCoord: (0,0) = верхній лівий, (1,1) = нижній правий vec2 uv = gl_PointCoord - 0.5; float r = dot(uv, uv); // r² — уникаємо sqrt if (r > 0.25) discard; // поза колом радіусом 0.5 float alpha = 1.0 - smoothstep(0.15, 0.25, r); gl_FragColor = vec4(vColor, alpha); } `, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexColors: true });
Оптимізація: використання dot(uv, uv) та порівняння з 0.25 (тобто 0.5²) уникає виклику sqrt() на кожен фрагмент. Фрагментні шейдери виконуються для кожного растеризованого пікселя, тож невеликі оптимізації на фрагмент множаться на мільйони викликів.

Довідка з продуктивності

Підхід 100 тис. частинок, настільний dGPU 100 тис. частинок, мобільний Навантаження на GC
Float32Array + BufferGeometry (цей посібник) ~0,5 мс/кадр CPU ~2–5 мс/кадр Немає (після ініціалізації)
Застарілий Geometry (об’єкти Vector3) ~5–15 мс/кадр CPU Непридатний Високе — паузи GC кожні кілька секунд
Обчислення на GPU (обчислювальні шейдери / transform feedback) <0,1 мс/кадр CPU <1 мс/кадр Немає

Для кількості частинок понад ~500 тис. або симуляцій, що потребують фізики на кожну частинку (гравітація, зіткнення, SPH), перенесіть усю логіку симуляції в обчислювальний шейдер GLSL (WebGPU) або вершинний шейдер із transform feedback (WebGL 2). Тоді CPU лише видає один виклик малювання й нічого не зчитує назад з GPU.

Наступні кроки

🌊 Симуляція рідини SPH (частинки на GPU) →