Урок · Середній рівень · ~50 хв
Three.js · BufferGeometry · Шейдери · Продуктивність

Система частинок з LOD та білбордами

Наївна система частинок зі 100 000 частинок поставить навколішки більшість GPU. Цей урок будує високопродуктивну систему за допомогою BufferGeometry з типізованими масивами, спрайтів-білбордів, що рендеряться через власний вершинний шейдер, та рівнів деталізації на основі відстані для пропуску невидимої роботи.

1Плоский пул частинок з типізованими масивами

Зберігайте всі дані у плоских Float32Array замість об'єктів JS. Кожен індекс відповідає одній частинці в кількох паралельних масивах:

const MAX = 100_000; // Паралельні типізовані масиви — один слот на частинку const pos = new Float32Array(MAX * 3); // xyz const vel = new Float32Array(MAX * 3); // vxvyvz const life = new Float32Array(MAX); // залишковий час життя (с) const maxLife = new Float32Array(MAX); // початковий час життя для t=life/maxLife const size = new Float32Array(MAX); // радіус частинки const dead = new Uint8Array(MAX); // 0=жива, 1=мертва (для пулу) let alive = 0; // кількість живих частинок let head = 0; // курсор випуску кільцевого буфера // Ініціалізуємо всі як мертві dead.fill(1);
Типізовані масиви розміщуються в неперервній пам'яті, тож їх ітерація дружня до кешу — значно швидша за масиви об'єктів на кшталт {x,y,z,vx,vy,...}.

2Завантаження на GPU як BufferGeometry

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'; const geo = new THREE.BufferGeometry(); const posAttr = new THREE.BufferAttribute(pos, 3); const sizeAttr = new THREE.BufferAttribute(size, 1); const lifeAttr = new THREE.BufferAttribute(life, 1); posAttr.setUsage(THREE.DynamicDrawUsage); // підказка: оновлюється щокадру sizeAttr.setUsage(THREE.DynamicDrawUsage); lifeAttr.setUsage(THREE.DynamicDrawUsage); geo.setAttribute('position', posAttr); geo.setAttribute('a_size', sizeAttr); geo.setAttribute('a_life', lifeAttr); geo.setDrawRange(0, MAX); // відмальовуємо до MAX точок const particles = new THREE.Points(geo, /* власний матеріал нижче */); scene.add(particles);

3Вершинний шейдер білбордів

const mat = new THREE.ShaderMaterial({ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, uniforms: { u_tex: { value: sparkTexture } }, vertexShader: /* glsl */ ` attribute float a_size; attribute float a_life; // 0=мертва, 1=щойно народжена varying float v_life; void main() { v_life = a_life; if (a_life <= 0.0) { // Виштовхуємо мертві частинки за екран (у вершинному шейдері немає discard) gl_Position = vec4(99999.0, 0.0, 0.0, 1.0); gl_PointSize = 0.0; return; } vec4 mvp = modelViewMatrix * vec4(position, 1.0); gl_PointSize = a_size * (300.0 / -mvp.z); // розмір з урахуванням перспективи gl_Position = projectionMatrix * mvp; } `, fragmentShader: /* glsl */ ` uniform sampler2D u_tex; varying float v_life; void main() { vec4 tex = texture2D(u_tex, gl_PointCoord); float alpha = tex.a * v_life; // згасання з часом життя gl_FragColor = vec4(tex.rgb, alpha); } `, }); particles.material = mat;

4Випуск та переробка частинок

function emit(x, y, z, count = 100) { for (let i = 0; i < count; i++) { // Кільцевий буфер: перезаписуємо найстарішу частинку const idx = (head++) % MAX; const i3 = idx * 3; pos[i3] = x; pos[i3+1] = y; pos[i3+2] = z; const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); const speed = 0.5 + Math.random() * 2.5; vel[i3] = Math.sin(phi) * Math.cos(theta) * speed; vel[i3+1] = Math.abs(Math.cos(phi)) * speed + 1; // зміщення вгору vel[i3+2] = Math.sin(phi) * Math.sin(theta) * speed; maxLife[idx] = life[idx] = 0.5 + Math.random() * 1.5; size[idx] = 2 + Math.random() * 6; dead[idx] = 0; } } function update(dt) { for (let i = 0; i < MAX; i++) { if (dead[i]) continue; life[i] -= dt; if (life[i] <= 0) { dead[i] = 1; life[i] = 0; continue; } const i3 = i * 3; vel[i3+1] -= 2.5 * dt; // гравітація pos[i3] += vel[i3] * dt; pos[i3+1] += vel[i3+1] * dt; pos[i3+2] += vel[i3+2] * dt; } posAttr.needsUpdate = true; lifeAttr.needsUpdate = true; }

5LOD на основі відстані

// Пропускаємо оновлення частинок далі за порогову відстань від камери const camPos = new THREE.Vector3(); function updateLOD(camera) { camera.getWorldPosition(camPos); for (let i = 0; i < MAX; i++) { if (dead[i]) continue; const i3 = i * 3; const dx = pos[i3] - camPos.x; const dy = pos[i3+1] - camPos.y; const dz = pos[i3+2] - camPos.z; const d2 = dx*dx + dy*dy + dz*dz; // Пропускаємо оновлення, але все одно рендеримо частинки далі за 200 одиниць if (d2 > 200 * 200) continue; // Повне оновлення лише для близьких частинок // (інтегрування швидкості тощо вже пропущене перевіркою LOD вище) } }

6Адитивне змішування для вогню та диму

// Вогонь: адитивне змішування — яскраво там, де частинки накладаються // помаранчево-жовтий градієнт за часом життя const fireFrag = /* glsl */ ` varying float v_life; void main() { float r = length(gl_PointCoord - 0.5); if (r > 0.5) discard; // Колір: розпечений білий → помаранчевий → червоний vec3 hot = vec3(1.0, 1.0, 0.7); vec3 mid = vec3(1.0, 0.4, 0.0); vec3 cool = vec3(0.4, 0.0, 0.0); float t = clamp(v_life, 0.0, 1.0); vec3 col = t > 0.5 ? mix(mid, hot, (t - 0.5) * 2.0) : mix(cool, mid, t * 2.0); float alpha = (1.0 - r * 2.0) * v_life; gl_FragColor = vec4(col * alpha, alpha); } `; // Дим: звичайне змішування (src-alpha) із затемненням у відтінках сірого з часом const smokeFrag = /* glsl */ ` varying float v_life; void main() { float r = length(gl_PointCoord - 0.5); if (r > 0.5) discard; float grey = 0.1 + v_life * 0.4; float alpha = (1.0 - r * 2.0) * v_life * 0.6; gl_FragColor = vec4(grey, grey, grey, alpha); } `;
Поєднайте емітер вогню (адитивний) з емітером диму (альфа-змішування) — додайте меш диму як окремий об'єкт Points, який відмальовується після вогню, щоб забезпечити правильне сортування.