Урок · Середній рівень · ~50 хв
Система частинок з 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, який
відмальовується після вогню, щоб забезпечити правильне сортування.