Графіка та рендеринг
Квітень 2026 · 16 хв читання · WebGL · WebGPU · Продуктивність GPU · Останнє оновлення: 3 липня 2026 р.

Інстансований рендеринг та LOD: мільйони об'єктів на 60 fps

Автор: Команда MySimulator · Редакційна перевірка: Редакція MySimulator

Сучасна гра чи симуляція має відмальовувати сотні тисяч дерев, травинок, частинок або об'єктів симуляції, не опускаючись нижче за 60 fps. Дві технології GPU роблять це можливим: інстансований рендеринг згортає багато викликів відмалювання в один, а рівні деталізації (LOD) замінюють віддалені об'єкти дешевшими наближеннями. Ця стаття пояснює обидві технології на рівні GPU і показує, як реалізувати їх у WebGL 2 та WebGPU.

1. Вузьке місце викликів відмалювання

Кожен виклик gl.drawElements() (або його аналогів) створює значне навантаження на CPU: драйвер перевіряє стан, компілює буфер команд, скидає його в чергу команд GPU та синхронізує пам'ять. На сучасному настільному GPU «сирий» бюджет викликів відмалювання зазвичай становить 5 000–50 000 викликів на кадр, перш ніж CPU насититься — незалежно від того, наскільки швидким є сам GPU.

Для сцени лісу з 200 000 дерев видавати один виклик відмалювання на дерево фізично неможливо. Розв'язки такі:

Ключова метрика: На потужному настільному ПК (R9 7900X + RTX 4080) неінстансовані виклики відмалювання насичують CPU на рівні ~50 000 викликів/кадр. Інстансований рендеринг досягає того самого виводу пікселів за 1–10 викликів, вивільняючи CPU для фізики, ШІ та роботи симуляції.

2. Інстансований рендеринг: gl_InstanceID

У WebGL 2 (який надає OpenGL ES 3.0) інстансований рендеринг викликається так:

// WebGL 2 — відмалювати 100 000 дерев одним викликом
gl.drawElementsInstanced(
  gl.TRIANGLES,         // тип примітива
  indexCount,           // індекси в базовій сітці
  gl.UNSIGNED_INT,      // тип індексу
  0,                    // зміщення в байтах
  100_000               // instanceCount
);
    

Усередині вершинного шейдера вбудована змінна gl_InstanceID (GLSL ES 3.0) містить індекс поточного інстансу (0 … instanceCount−1). Використовуйте її для отримання даних на інстанс з uniform-масиву або текстури:

// Вершинний шейдер (GLSL ES 3.0)
#version 300 es
in vec3 a_position;   // вершина базової сітки
in vec3 a_normal;

// Дані на інстанс (дільник = 1)
in mat4 a_instanceMatrix;  // 4 послідовні атрибути vec4

uniform mat4 u_viewProj;

out vec3 v_normal;

void main() {
  mat3 normalMat = transpose(inverse(mat3(a_instanceMatrix)));
  v_normal = normalize(normalMat * a_normal);
  gl_Position = u_viewProj * a_instanceMatrix * vec4(a_position, 1.0);
}
    

Матриця на інстанс займає 4 слоти атрибутів. Встановлення дільника атрибута на 1 повідомляє GPU просувати вказівник даних інстансу один раз на інстанс, а не один раз на вершину:

// Прив'язуємо буфер перетворень на інстанс
gl.bindBuffer(gl.ARRAY_BUFFER, instanceMatrixBuffer);
const bytesPerMatrix = 16 * 4;
for (let i = 0; i < 4; i++) {
  const attribLoc = matrixAttribLocation + i;
  gl.enableVertexAttribArray(attribLoc);
  gl.vertexAttribPointer(attribLoc, 4, gl.FLOAT, false,
    bytesPerMatrix, i * 16); // кожен рядок = 4 float × 4 байти
  gl.vertexAttribDivisor(attribLoc, 1); // просувати один раз на ІНСТАНС
}
    

3. Компонування даних на інстанс

Кожному інстансу потрібна щонайменше матриця перетворення 4×4 (64 байти). Зазвичай додаються такі дані на інстанс:

Разом: ~96 байтів на інстанс. Для 1 000 000 інстансів це 96 МБ — що комфортно вміщується в сучасний VRAM GPU. Завантажуйте один раз; оновлюйте лише «брудні» інстанси на кадр.

Упакування в текстуру

Альтернативою буферам атрибутів є текстура даних: упакуйте матриці як пікселі RGBA32F, вибирайте їх у вершинному шейдері за допомогою texelFetch() з ідентифікатором інстансу:

// Упакування: N матриць → текстура завширшки W = 4 рядки/матриця, висота = ceil(N/4)
// Вибірка у вершинному шейдері:
int baseTexel = gl_InstanceID * 4;
mat4 transform = mat4(
  texelFetch(u_instanceTex, ivec2(baseTexel+0, 0), 0),
  texelFetch(u_instanceTex, ivec2(baseTexel+1, 0), 0),
  texelFetch(u_instanceTex, ivec2(baseTexel+2, 0), 0),
  texelFetch(u_instanceTex, ivec2(baseTexel+3, 0), 0)
);
    

Це особливо ефективно, коли дані інстансів уже згенеровані обчислювальним шейдером (Texture Output або Storage Texture у WebGPU), повністю уникаючи передачі CPU→GPU.

4. Відсікання за пірамідою видимості та оклюзією

Відсікання за пірамідою видимості

Навіть з інстансуванням відмалювання 1 000 000 невидимих дерев марнує цикли вершинного шейдера. Відсікання за пірамідою видимості (frustum culling) відкидає інстанси, обмежувальна сфера яких лежить повністю поза 6 півпросторами піраміди видимості:

// Рівняння площини піраміди: p = {normal: n, distance: d} // Сфера (центр c, радіус r) є зовні, якщо: dot(p.n, c) + p.d + r < 0 для будь-якої з 6 площин

Для відсікання на боці CPU перебирайте всі інстанси й записуйте матриці видимих інстансів у компактний буфер (ущільнення через префіксну суму), потім відмальовуйте лише N_visible інстансів. Відсікання на CPU просте, але обмежує масштабованість.

Відсікання, кероване GPU

Сучасний підхід переносить відсікання повністю в обчислювальний шейдер:

  1. Обчислювальний шейдер зчитує всі обмежувальні сфери інстансів зі сховищного буфера.
  2. Потік на інстанс: перевірка проти площин піраміди; якщо видимий, атомарно збільшує лічильник і записує в компактний вихідний буфер (потокове ущільнення).
  3. Записує лічильник у буфер аргументів непрямого відмалювання.
  4. Викликає drawIndirect() — GPU відмальовує точно відсіяну кількість інстансів, тоді як CPU не знає цього числа.

Цей конвеєр тримає дані повністю на GPU й усуває вузьке місце зворотного зчитування на CPU. У WebGPU це робиться прямо; у WebGL 2 це вимагає розширення WEBGL_draw_instanced_base_vertex_base_instance для підтримки непрямого відмалювання.

Відсікання за оклюзією

Відсікання за оклюзією додатково відкидає об'єкти, приховані за ближчою геометрією. Ієрархічний Z-буфер (Hi-Z) для відсікання за оклюзією будує mip-піраміду буфера глибини; обмежувальний паралелепіпед кожного інстансу перевіряється на відповідному рівні mip. Якщо всі спроєктовані пікселі обмежувального паралелепіпеда глибші за збережену максимальну глибину, об'єкт перекритий. Це прийом, який використовують Frostbite, Killzone та інші AAA-рушії для масового зменшення кількості викликів відмалювання на сцені.

5. Дискретний LOD та екранна похибка

Система рівнів деталізації (LOD) зберігає кілька попередньо спрощених версій сітки й вибирає серед них залежно від відстані до об'єкта або його спроєктованого екранного розміру:

// Спроєктований екранний діаметр (приблизно): screenSize = (objectRadius * projectionScaleFactor) / depth // Порогові значення LOD (приклад для дерева): if (screenSize > 0.15) LOD = 0; // повна сітка (~8000 трикутників) if (screenSize > 0.05) LOD = 1; // середня деталізація (~1500 трикутників) if (screenSize > 0.01) LOD = 2; // низька деталізація (~200 трикутників) else LOD = 3; // спрайт-імпостор

Екранний розмір — правильна метрика: маленький вертоліт на 10 м і великий хмарочос на 1 км можуть проєктуватися на той самий екранний діаметр і заслуговувати на той самий LOD. Використання «сирої» відстані як порога дає візуально неправильні перемикання LOD.

Віртуальна геометрія в стилі Nanite

Nanite від Unreal Engine 5 доводить LOD до межі: кожен кластер трикутників на сцені має попередньо обчислену межу екранної похибки, і середовище виконання вибирає найгрубіший рівень, похибка якого залишається нижчою за 1 піксель. Немає рівнів LOD, створених вручну; вся ієрархія сітки будується офлайн за допомогою DAG (орієнтованого ациклічного графа) поступово спрощуваних груп кластерів. GPU відсікає й вибирає з гранулярністю кластера в обчислювальному шейдері, а потім растеризує за допомогою власного програмного растеризатора для малих (субпіксельних) трикутників. Це далеко за межами того, що WebGL може сьогодні, але обчислювальні конвеєри WebGPU — це необхідний перший крок.

LOD з інстансуванням

Поєднання LOD та інстансування вимагає сортування інстансів у кошики за рівнем LOD і видачі одного інстансованого виклику відмалювання на LOD на матеріал. З 4 рівнями LOD та 3 матеріалами це 12 викликів відмалювання — все одно набагато менше, ніж один виклик на об'єкт. Підходи, керовані GPU, можуть виконати це сортування в обчислювальному шейдері за допомогою паралельної префіксної суми.

6. CLOD та геоморфінг

Дискретний LOD спричиняє помітний артефакт «стрибання» (popping), коли об'єкт перемикається між рівнями — раптову зміну кількості та положення вершин. Дві технології усувають це:

Альфа-LOD (розмите перехресне згасання)

Відмальовуйте поточний і наступний рівні LOD одночасно, перехресно змішуючи їх за допомогою екранної дизерної альфа-маски. Це розтягує перехід на діапазон screenSize [d₁, d₂]. Використовується компонентом LOD Group в Unity та помічником LOD у Three.js. Подвоює кількість викликів відмалювання в смузі переходу, але уникає різкого «стрибання».

Геоморфінг (CLOD)

Неперервний LOD (CLOD) геоморфує положення вершин до їхньої цільової конфігурації LOD з часом. Кожна сітка LOD записує для кожної вершини, куди ця вершина відображається в наступному простішому LOD (або що вона просто зникає). Вершинний шейдер лінійно інтерполює до цільового положення:

// Дані на вершину: поточне положення + цільове положення морфінгу + screenSize початку/кінця морфінгу vec3 morphedPos = mix(a_posLOD_current, a_posLOD_next, morphFactor); // morphFactor = smoothstep(screenSize_threshold_lo, screenSize_threshold_hi, screenSize)

Це вимагає зберігання двох наборів положень на вершину (подвоює дані вершин) та акуратного спрощення сітки, що записує карту згортання. Алгоритм Progressive Mesh від Гоппе (1996) — канонічний алгоритм для генерації даних цілей морфінгу на вершину.

CLOD для рельєфу: ROAM та Geoclipmap

LOD рельєфу — це окремий випадок: адаптивна тесселяція на основі піраміди видимості та кривини. Технологія Geoclipmap (Losasso & Hoppe, Siggraph 2004) використовує осесиметричні кільця геометрії з центром на камері, де кожне зовнішнє кільце має вдвічі меншу роздільність, ніж внутрішнє. Переходи між кільцями геоморфуються, щоб уникнути швів. Це основа рендерингу рельєфу в Google Earth, Houdini Terragen та більшості рушіїв ігор з відкритим світом.

7. Імпостори та білборд-спрайти

Коли об'єкти стають дуже малими на екрані (screenSize < 0.005), навіть сітка зі 100 трикутників марнує цикли вершинного шейдера. Найдешевше представлення — це імпостор: один чотирикутник (2 трикутники) з текстурою попередньо відрендереного зображення об'єкта.

Білборди, обернені до камери

Білборд-чотирикутник завжди обернений до камери. Існує три варіанти:

// Вершинний шейдер: циліндричний білборд (розгортаємо чотирикутник навколо положення інстансу) vec3 up = vec3(0.0, 1.0, 0.0); vec3 toCamera = normalize(u_cameraPos - instancePos); vec3 right = normalize(cross(up, toCamera)); vec3 worldPos = instancePos + right * a_offsetXY.x // горизонтальне розгортання + up * a_offsetXY.y; // вертикальне розгортання (без z) gl_Position = u_viewProj * vec4(worldPos, 1.0);

Багатовидові імпостори (октаедральні імпостори)

Попередньо відрендеріть об'єкт з півсфери точок огляду (зазвичай 8×8 = 64 напрямки) і упакуйте в текстурний атлас. Під час виконання виберіть два найближчі попередньо відрендерені види за поточним напрямком камери та змішайте їх. Результат переконливий з усіх ракурсів лише з однією вибіркою текстури на піксель. Ця технологія (популяризована в Unity Amplify Impostors та рослинності UE4) досягає візуально достовірного рендерингу складних крон дерев з 2 трикутниками у вершинному бюджеті.

Імпостори на полях відстані зі знаком (SDF)

Замість збереження кольору зберігайте поле відстані зі знаком у текстурі імпостора. У фрагментному шейдері використовуйте SDF для піксельно точного альфа-змішування та реконструкції нормалі — це дає гладкі силуети, які не пікселізуються при наближенні. Використовується для шрифтів, частинок та дрібної рослинності на проміжних відстанях.

8. WebGPU: непрямі та мульти-непрямі виклики відмалювання

WebGPU (доступний у Chrome 113+) надає повний API непрямого відмалювання GPU, уможливлюючи повністю керовані GPU конвеєри рендерингу, які були б неможливими в WebGL.

Непрямий виклик відмалювання у WebGPU

// CPU: створюємо буфер аргументів непрямого відмалювання
// Компонування: [ vertexCount, instanceCount, firstVertex, firstInstance ]
const indirectBuffer = device.createBuffer({
  size: 4 * 4,       // 4 значення uint32
  usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE,
});

// Обчислювальний шейдер заповнює instanceCount після відсікання:
// @group(0) @binding(2) var<storage, read_write> indirect: DrawIndirectArgs;
// atomic_store(&indirect.instanceCount, culledCount);

// Прохід рендерингу — CPU не потрібно знати лічильник:
passEncoder.drawIndirect(indirectBuffer, 0);
    

Мульти-непрямий виклик відмалювання

drawIndirect() у WebGPU видає один виклик відмалювання. Для кількох кошиків LOD або кластерів сіток мульти-непрямий виклик відмалювання (надається через можливість multi-draw-indirect, доступну в Chrome Canary з прапорцями) видає масив команд відмалювання з буфера GPU за один виклик API. Саме так на сучасному обладнанні реалізується планування віртуальної геометрії в стилі Nanite.

Повний конвеєр, керований GPU

  1. Завантаження: усі перетворення інстансів, обмежувальні сфери, порогові значення LOD у сховищні буфери (один раз або лише при оновленні «брудних»).
  2. Обчислювальний прохід відсікання: відсікання за пірамідою + оклюзією Hi-Z; записати видимі інстанси на LOD у компактні буфери; записати лічильники на LOD у буфер непрямих аргументів.
  3. Прохід сортування: необов'язкове порозрядне сортування на GPU за ID матеріалу для покращення когерентності кешу.
  4. Прохід рендерингу: drawIndirect() на кожен фрагмент LOD — разом ~10–20 викликів відмалювання незалежно від складності сцени.
Реальні цифри: Nanite від Epic рендерить сцени з мільярдами полігонів на 60+ fps за допомогою цього підходу. Для сцен симуляції WebGPU кероване GPU інстансоване рендерування з відсіканням за пірамідою видимості зазвичай досягає 50–200-кратного зменшення часу CPU порівняно з окремими викликами відмалювання, уможливлюючи симуляцію мільйонів частинок або агентів у реальному часі з інтерактивною частотою кадрів.