Оптимізація продуктивності Three.js — рендеринг 100k об'єктів на 60 fps
Різниця між прототипом, що смикається, і максимально плавною симуляцією зазвичай зводиться до кількох вибраних можливостей API. Цей урок крок за кроком розглядає оптимізації з найбільшим впливом: InstancedMesh, відсікання за пірамідою видимості, рівень деталізації, фізику у Web Worker та усунення найпоширеніших витоків пам'яті.
1. Спершу профілювання
Ніколи не оптимізуйте наосліп. Відкрийте Chrome DevTools → вкладку Performance, запишіть 3 секунди й шукайте:
- Довгі кадри (>16,7 мс) — розгорніть, щоб побачити час JavaScript проти GPU
- Renderer.info — виводьте це щокадру під час розробки
// Додайте до циклу анімації під час розробки
const info = renderer.info;
const debugEl = document.getElementById('debug');
if (debugEl) debugEl.textContent =
`Draw calls: ${info.render.calls} | Tris: ${info.render.triangles
} | Textures: ${info.memory.textures}`;
Додайте Stats.js для панелі fps/мс/мб у реальному часі. Ціль: <5 мс CPU на кадр на обладнанні середнього класу.
2. InstancedMesh — один виклик відмалювання для тисяч об'єктів
Вузьке місце продуктивності №1 у сценах Three.js — це надто багато
викликів відмалювання. Кожен Mesh — це окремий виклик
відмалювання. Сучасні GPU можуть обробляти мільйони трикутників за виклик, але застрягають
через накладні витрати CPU при ~100–1000 окремих викликах відмалювання на кадр.
InstancedMesh згортає N однакових об'єктів в один виклик відмалювання, розміщуючи матриці перетворень для кожного екземпляра у буфер GPU:
import * as THREE from 'three';
const COUNT = 100_000;
const geo = new THREE.SphereGeometry(0.1, 6, 4); // низькополігональна сфера
const mat = new THREE.MeshLambertMaterial({ color: 0x88aaff });
const mesh = new THREE.InstancedMesh(geo, mat, COUNT);
scene.add(mesh);
// Задаємо початкові перетворення
const dummy = new THREE.Object3D();
for (let i = 0; i < COUNT; i++) {
dummy.position.set(Math.random()*200-100, Math.random()*200-100, Math.random()*200-100);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
// Оновлення кожного кадру (наприклад, з фізики):
function updateInstances(positions) {
for (let i = 0; i < COUNT; i++) {
dummy.position.copy(positions[i]);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true; // вивантаження на GPU
}
| Підхід | 100k сфер | Виклики відмалювання |
|---|---|---|
| Окремі меші | ~1 fps | 100 000 |
| Об'єднана BufferGeometry | 30–60 fps | 1 (лише статичні) |
| InstancedMesh | 55–60 fps | 1 (динамічні перетворення) |
mesh.setColorAt(i, color) і вивантажується за допомогою
mesh.instanceColor.needsUpdate = true. Це додає другий
буфер GPU, але не збільшує кількість викликів відмалювання.
3. Відсікання за пірамідою видимості та LOD
Відсікання за пірамідою видимості
Three.js автоматично відсікає за пірамідою видимості окремі
об'єкти Mesh — але не окремі
екземпляри всередині InstancedMesh. Для великих
симуляцій, де видима лише частина екземплярів, реалізуйте
ручне відсікання екземплярів:
const frustum = new THREE.Frustum();
const matrix = new THREE.Matrix4();
const sphere = new THREE.Sphere();
function cullInstances(camera, positions, radius) {
matrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
frustum.setFromProjectionMatrix(matrix);
let visibleCount = 0;
for (let i = 0; i < positions.length; i++) {
sphere.center.copy(positions[i]);
sphere.radius = radius;
if (frustum.intersectsSphere(sphere)) {
dummy.position.copy(positions[i]);
dummy.updateMatrix();
mesh.setMatrixAt(visibleCount++, dummy.matrix);
}
}
mesh.count = visibleCount; // рендеримо лише видимі екземпляри
mesh.instanceMatrix.needsUpdate = true;
}
Рівень деталізації (LOD)
const lod = new THREE.LOD();
const high = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 32), mat);
const medium = new THREE.Mesh(new THREE.SphereGeometry(1, 12, 8), mat);
const low = new THREE.Mesh(new THREE.SphereGeometry(1, 4, 3), mat);
lod.addLevel(high, 0); // 0–30 одиниць від камери
lod.addLevel(medium, 30); // 30–100 одиниць
lod.addLevel(low, 100); // 100+ одиниць
scene.add(lod);
// LOD.update(camera) викликається автоматично, якщо додано до сцени
4. BufferGeometry та потокове оновлення атрибутів
Симулюючи деформівні об'єкти (тканину, меш поверхні рідини), оновлюйте атрибути геометрії напряму замість того, щоб перебудовувати геометрію кожного кадру:
// НЕПРАВИЛЬНО: перебудова геометрії щокадру (постійно виділяє пам'ять GPU)
function badUpdate(vertices) {
scene.remove(mesh);
mesh.geometry.dispose();
mesh.geometry = new THREE.BufferGeometry();
mesh.geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
scene.add(mesh);
}
// ПРАВИЛЬНО: повторно використовуємо буфер, ставимо прапорець needsUpdate
const positions = new Float32Array(N * 3);
const attr = new THREE.BufferAttribute(positions, 3);
attr.usage = THREE.DynamicDrawUsage; // підказка драйверу GPU
geometry.setAttribute('position', attr);
function goodUpdate(newPositions) {
positions.set(newPositions); // копіювання типізованого масиву — швидко
attr.needsUpdate = true; // вивантаження на GPU при наступному рендері
geometry.computeBoundingSphere(); // щоб працювало відсікання за пірамідою видимості
}
5. Web Workers для фізики
Симуляція фізики блокує головний потік. Перенесіть її у
Web Worker і спілкуйтеся через
SharedArrayBuffer (SAB) для передачі без копіювання:
// main.js
const N = 10_000;
const sharedBuf = new SharedArrayBuffer(N * 3 * 4); // xyz float32 на частинку
const positions = new Float32Array(sharedBuf);
const worker = new Worker('physics.worker.js');
worker.postMessage({ sharedBuf, N }); // спільний буфер — без копіювання
function animate() {
requestAnimationFrame(animate);
// positions[] вже оновлено воркером у фоні
positionAttr.needsUpdate = true;
renderer.render(scene, camera);
}
// physics.worker.js
let positions, N;
onmessage = ({ data }) => {
positions = new Float32Array(data.sharedBuf);
N = data.N;
physicsLoop();
};
function physicsLoop() {
setInterval(() => {
for (let i = 0; i < N; i++) {
positions[i*3+1] -= 0.01; // крок гравітації
}
}, 1000/60);
}
Cross-Origin-Opener-Policy: same-origin та
Cross-Origin-Embedder-Policy: require-corp. Без цих
заголовків SharedArrayBuffer вимкнено браузерами.
6. Оптимізація текстур і матеріалів
- Використовуйте текстури зі сторонами, кратними степеню 2 (512×512, 1024×1024) — не-PoT текстури вимикають міпмапінг і марнують пам'ять
-
Установіть
texture.generateMipmaps = falseдля цілей рендерингу або текстур, що показуються лише в одному розмірі -
Використовуйте стиснені текстури KTX2 / ETC1S / UASTC через
KTX2Loader— у 4–8× менше на GPU (використовує апаратне стиснення текстур GPU, а не JPEG зі втратами — залишається стисненим у VRAM) -
Використовуйте
MeshLambertMaterialзамістьMeshStandardMaterialдля сцен без якості трасування променів — Lambert пропускає дороге обчислення BRDF для PBR (удвічі швидший прохід освітлення) -
Спільне використання матеріалів:
const mat = new MeshLambertMaterial()один раз; призначайте всім екземплярам — матеріали вивантажуються на GPU лише раз
7. Запобігання витокам пам'яті
Ресурси GPU у Three.js (геометрія, матеріали, текстури, цілі рендерингу)
не очищуються збирачем сміття автоматично. Завжди викликайте
.dispose():
function removeMesh(mesh) {
scene.remove(mesh);
mesh.geometry.dispose(); // звільняємо вершинні буфери GPU
if (mesh.material.map) mesh.material.map.dispose(); // текстура
mesh.material.dispose(); // звільняємо шейдерні програми
}
// Цілі рендерингу
renderTarget.dispose();
// При заміні рендерера (наприклад, при HMR / гарячому перезавантаженні):
renderer.dispose();
renderer.forceContextLoss();
Використовуйте вкладку Memory у Chrome DevTools — робіть знімки купи до і після
видалення об'єктів. Якщо лічильники WebGLBuffer та
WebGLTexture не зменшуються, у вас витік.
8. Чек-лист продуктивності
- ☐ Профілюйте за допомогою Chrome DevTools перед оптимізацією
-
☐ Замініть N окремих
Meshів однимInstancedMesh -
☐ Використовуйте
MeshLambertMaterialзамістьMeshStandardMaterial, де PBR не потрібен -
☐ Повторно використовуйте
BufferGeometryзneedsUpdate = trueзамість повторного створення -
☐ Додайте підказку
DynamicDrawUsageдля атрибутів, що часто оновлюються - ☐ Реалізуйте LOD для об'єктів сцени далі за 50 одиниць
- ☐ Вручну відсікайте екземпляри за пірамідою видимості для великих інстансованих мешів
- ☐ Перенесіть оновлення фізики/частинок у Web Worker + SharedArrayBuffer
- ☐ Викликайте dispose для геометрії, матеріалу, текстури при видаленні об'єкта
- ☐ Використовуйте розміри текстур, кратні степеню 2; розгляньте стиснення KTX2
-
☐ Тримайте кількість викликів відмалювання <100 на кадр (перевіряйте
renderer.info) - ☐ Ціль: <5 мс CPU + <8 мс GPU на кадр на обладнанні середнього класу