Это серия из трех частей, посвященная довольно широкой технической 3D-теме. Первая часть представляет собой теоретический обзор техники, вторая часть редактирует существующую демонстрацию для применения создания экземпляров, а последняя часть исследует оптимизацию.

вступление

Представьте, что у нас есть трехмерный мир с множеством деревьев или фонарных столбов. Когда мы рендерим такой мир, мы делаем много вызовов отрисовки. Вызовы отрисовки имеют накладные расходы и дороги. Ради интерактивной частоты кадров мы хотим удалить их, где это возможно.

Если у нас есть что-то вроде:

const myGeom = new THREE.BoxGeometry()
const myMaterial = new THREE.MeshBasicMaterial()
const myGroup = new THREE.Group()
for ( let i = 0 ; i < 25 ; i ++ ) {
  const myMesh = new THREE.Mesh(myGeom, myMaterial)
  myGroup.add(myMesh)
  myMesh.frustumCulled = false 
  myMesh.position.set(random(),random(),random())
}

И добавив myGroup в сцену и отрендерив ее без какой-либо оптимизации, мы вызовем 25 различных вызовов отрисовки (в дополнение ко всему, что может быть в сцене, включая вызов очистки).

.frustumCulled = false оборотов оптимизации, которая направлена ​​на сокращение этих вызовов отрисовки. Это пересекает ограничивающую сферу с конусом камеры. Если он полностью находится вне его, вызов отрисовки для этого меша не выполняется. Если бы это было правдой, мы потенциально увидели бы менее 25 вызовов отрисовки, в зависимости от конфигурации нашего пространства и того, где в нем находится камера.

Это, с некоторыми компромиссами, может быть одним вызовом отрисовки.

Метод «грубой силы»

Один из подходов, который мы можем использовать для оптимизации вызовов отрисовки, - это объединить эти сетки (геометрии) в одну.

Если мы считаем эти сетки статичными (фонарный столб не должен менять свое положение или масштаб в течение срока действия приложения), нас частично волнует, что описывает «группу».

В приведенном выше фрагменте кода это узел Group графа сцены, содержащий экземпляры Mesh узлов. Каждый узел является частью этого кластера, мы можем переводить их по отдельности, но мы также можем синхронно перемещать их все вместе, переводя родительский.

Что, если бы вместо этого было:

const geom = new THREE.BoxGeometry()
const material = new THREE.MeshBasicMaterial()
const mergedGeometry = new THREE.BufferGeometry()
for ( let i = 0 ; i < 25 ; i ++ ) {
   const nodeGeometry = geom.clone()
   nodeGeometry.translate(random(),random(),random())
   mergedGeometry.merge(nodeGeometry)
}
const myCluster = new THREE.Mesh( mergedGeometry, material)

Объединяем все отдельные фонарные столбы в один кластер. У нас все еще есть доступ к узлу графа сцены myCluster, и мы можем перемещать всю группу, но, например, мы потеряли возможность легко регулировать расстояние между ними (больше нет отдельного узла лампы).

Однако мы можем визуализировать все фонарные столбы в мире или плитку с помощью одного вызова отрисовки.

Недостатки

Этот подход требует много памяти.

Поскольку мы как бы «разворачиваем» эту геометрию, теперь GPU должен хранить гораздо больше данных. В первом примере он хранит только один экземпляр геометрии и ссылается на него при каждом вызове отрисовки. Во втором случае это все еще одна геометрия, но она в 25 раз больше, потому что столько раз мы ее дублировали.

Сама операция слияния может быть медленной и вызывать большую активность сборщика мусора.

Обновлять отдельные экземпляры в кластере сложно.

С другой стороны, мы могли бы убрать умножение матриц из шейдера.

Умный способ

Графические процессоры и WebGL предназначены для управления памятью и выдачи команд. У нас есть функция под названием «создание экземпляров», которая позволит нам выполнить оптимизацию, которую мы только что сделали при слиянии, но гораздо более эффективным способом.

Можно использовать гораздо меньший набор данных, чтобы описать то, что мы хотим, и сделать то же самое. Сначала давайте немного обновим граф сцены и то, что он делает в GLSL.

Когда мы делаем геометрию:

const geometry = new THREE.PlaneGeometry()

Мы всегда можем ожидать, что три произведут некоторый GLSL как таковой:

attribute vec3 position;

Без нормалей для освещения и uvs для отображения это переменная, к которой шейдер будет обращаться и получать положение вершины в пространстве модели. Это значение из lamp_post.obj или какого-то угла плоскости.

GLSL, который производит Material, пока не представляет интереса, поэтому перейдем к графу сцены:

const mesh = new THREE.Mesh(geometry)

Обычно мы манипулируем mesh.rotation, mesh.position, mesh.scale, но все они запекаются в единую матрицу 4x4 на пути к GLSL, давая:

uniform mat4 modelMatrix;

Например, всякий раз, когда мы меняем позицию, движок пересчитывает соответствующий THREE.Matrix4, а шейдер будет иметь переменную freshmodelMatrix.

Хотя это не имеет прямого отношения к инстансу, давайте отметим, как камера отображает:

const camera = new THREE.PerspectiveCamera

GLSL:

uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelViewMatrix; //camera + mesh/line/point node

modelViewMatrix здесь фактически принадлежит как camera, так и mesh, подробнее об этом чуть позже.

Простой шейдер

Давайте преобразуем сетку с помощью очень простого вершинного шейдера. THREE.ShaderMaterial фактически вводит нам всю эту форму, поэтому нам не нужно:

void main(){
  gl_Position = 
    projectionMatrix * viewMatrix * modelMatrix * vec4(position,1.);
}

Справа налево:

  • мы приводим attribute от BufferGeometry к vec4, поскольку он входит как vec3.
  • Мы применяем мировую трансформацию, основанную на позиции, масштабе и вращении.
  • Мы проецируем это на камеру

Как уже упоминалось, вам нужно будет использовать THREE.RawShaderMaterial, чтобы самостоятельно объявить всю эту униформу. THREE.ShaderMaterial получает их от оберток и абстракций.

Сравните два

В первом примере, где граф сцены содержит родительский элемент с 25 дочерними элементами, движок вычислит 25 различных modelMatrix значений. Если вы переместите родителя, три сделают что-то вроде:

parentMatrix.multiply(childMatrix)

потому что это нужно шейдеру GLSL:

vec4 worldPosition = 
  modelMatrix * //moves the instance into world space (parent+child)
  vec4(position,1.); //model space

Когда мы объединяемся, мы избавляемся от необходимости выполнять 25 обновлений матрицы, потому что мы удаляем родительско-дочерние отношения.

cluster.position.set(1,1,1)
cluster.rotation.set(Math.PI,0,0)
cluster.scale.set(2,1,2)

По-прежнему влияет на modelMatrix, но три должны вычислить только один.

В первом примере матрица из Group никогда не встречается напрямую в GLSL, поскольку для такого узла не выполняется вызов отрисовки. Тем не менее, он присутствует во всех вызовах отрисовки, так как его нужно вычислять на ЦП, умножая его на матрицы, которые фактически используются в шейдере (mesh modelMatrix).

Во втором примере мы фактически объединяем это с attribute vec3 position;:

const geom = new THREE.BufferGeometry()
for ( let i = 0 ; i < 25 ; i ++ ) {
  const nodeGeometry = geometry.clone()
  nodeGeometry.applyMatrix( myMatrix[i] )
  geom.merge(nodeGeometry)
}

Мы выполняем однократную операцию с процессором, при которой применяем матрицу непосредственно к вершине:

vec4 worldSpace = 
  modelMatrix * //moves the entire cluster (parent)
  vec4( position, 1.); //not really model space any more, since it has the transformation "baked in" from outside (child)

attribute vec3 position; больше не соответствует lamp_post.obj. Мы сожгли часть графа сцены и потеряли уникальность пространства модели.

Создание экземпляра

Давайте сделаем шаг назад и рассмотрим некоторые элементы, которые у нас есть после долгого обзора:

  • какой-то трехмерный мир (THREE.Scene)
  • некоторая пространственная сущность, такая как район, деревня или плитка (THREE.Group)
  • какой-нибудь актив, например дерево или фонарный столб (THREE.BufferGeometry)
  • некоторое намерение относительно того, как актив соответствует миру, т. е. 25 ламп, разбросанных по миру в некотором узоре (THREE.Mesh)

Основная идея:

const asset = OBJLoader.load('lamp_post.obj') //load a small asset once
//scatter asset
const tile = new THREE.Mesh(new THREE.PlaneGeometry)
myPositions.forEach( pos=>{
  const mesh = new THREE.Mesh(asset, myMaterial)
  mesh.position.copy(pos)
  tile.add(mesh)
})

Когда мы вызываем tile.position.set(), мы перемещаем вместе с ним все экземпляры актива. Мы хотим сохранить это удобство.

Когда мы вызываем tile.children[0].position.set(), мы можем перемещать один актив относительно других (мы теряем это при слиянии), но вызываем вызовы отрисовки и дорогостоящее вычисление боковой матрицы ЦП с отношениями дочерний и родительский графа сцены.

Нам нужно удобство, но не побочные эффекты. Мы можем решить все эти проблемы с помощью одной ошибки.

Низкий уровень

В документации упоминаются только экземпляры классов InstancedBufferAttribute и InstancedBufferGeometry.

Есть несколько примеров, но все они низкого уровня, в том числе и этот.

Вы заметите, что удобство исчезло:

myLampPost.clone().position.copy(myPosition)

А здесь это примерно так:

var offsets = new Float32Array( INSTANCES * 3 ); // xyz
var colors = new Float32Array( INSTANCES * 3 ); // rgb
var scales = new Float32Array( INSTANCES * 1 ); // s
for ( var i = 0, l = INSTANCES; i < l; i ++ ) {
    var index = 3 * i;
    // per-instance position offset
    offsets[ index ] = positions[ i ].x;
    offsets[ index + 1 ] = positions[ i ].y;
    offsets[ index + 2 ] = positions[ i ].z;
    // per-instance color tint - optional
    colors[ index ] = 1;
    colors[ index + 1 ] = 1;
    colors[ index + 2 ] = 1;
    // per-instance scale variation
    scales[ i ] = 1 + 0.5 * Math.sin( 32 * Math.PI * i / INSTANCES );
}
geometry.addAttribute( 'instanceOffset', new THREE.InstancedBufferAttribute( offsets, 3 ) );
geometry.addAttribute( 'instanceColor', new THREE.InstancedBufferAttribute( colors, 3 ) );
geometry.addAttribute( 'instanceScale', new THREE.InstancedBufferAttribute( scales, 1 ) );

Выглядит довольно коряво, и это лишь небольшая часть кода, необходимая для запуска (частичного) экземпляра того, что тривиально выполняется через граф сцены.

Тем не менее этот фрагмент полезен, чтобы рассказать, что происходит. Но давайте также включим часть шейдера.

Соответствующая часть из примера:

#ifdef INSTANCED       
attribute vec3 instanceOffset;     
attribute float instanceScale;
#endif

Это немного отличается от формата, который мы использовали в предыдущих двух примерах, поскольку он использует не матрицу, а отдельные компоненты (и в нем отсутствует вращение).

К сожалению, установка mat4 с инстансингом - это немного сложная задача, но давайте на мгновение представим, что мы можем:

attribute mat4 instanceMatrix; //instance attribute 
attribute vec3 position; //regular attribute
void main(){
  gl_Position = 
    projectionMatrix * viewMatrix * //from THREE.Camera
    modelMatrix * //from THREE.Mesh
    instanceMatrix * //we add this to the chain, 
    vec4(position,1.) //from THREE.BufferGeometry
  ;
}

Добавляем в шейдер еще один шаг трансформации. В отличие от projectionMatrix, viewMatrix и modelMatrix instanceMatrix не единообразие, а атрибут. Это особый вид экземпляра атрибута, который является частью магии управления памятью, которую должен выполнять WebGL.

Мы не можем объявить атрибут mat4, поэтому мы должны составить его из нескольких vec4 атрибутов. Хотя это утверждение не может быть на 100% правильным, это простой способ решения проблемы.

По сравнению с двумя предыдущими примерами, этот шейдер по-прежнему будет запускать в 25 раз больше вершин на экземпляр.

Он будет выполнять дополнительное умножение матриц, но в случае 25 уникальных узлов это будет выполняться на графическом процессоре, а не на процессоре. В соответствии с принципом работы графических процессоров они могут простаивать в ожидании вызова отрисовки, поэтому здесь можно получить выгоду, удерживая их более загруженными при тех же накладных расходах (вызов отрисовки).

Что касается памяти, attribute vec3 position; по-прежнему ссылается на вершины BufferGeometry или lamp_post.obj только один раз, так же, как если бы мы повторно использовали BufferGeometry с 25 уникальными узлами. Это экономит много памяти при слиянии.

API WebGL не позволяет нам иметь uniform mat4 instanceMatrix; так же, как мы используем другие матрицы преобразования. Он должен ссылаться на это как на атрибут, следовательно:

geometry.addAttribute( 'instanceOffset', new THREE.InstancedBufferAttribute( offsets, 3 ) );

Three.js под капотом настраивает другие формы перед каждым вызовом отрисовки. Поскольку мы сжимаем много вызовов отрисовки в один, нам необходимо иметь доступ ко всей этой информации. Это то, что делает InstancedBufferAttribute. Это массив чисел, отформатированный таким образом, что они соответствуют данным, которые в противном случае вы бы нарисовали с помощью уникального вызова отрисовки.

В этом фрагменте мы используем не матрицу, а простой трехмерный вектор для индивидуального перемещения экземпляров. Для наших 25 фонарных столбов это будет означать 75 чисел, представляющих 25 различных трехмерных векторов.

В простом шейдере для каждого вызова отрисовки у нас будет:

uniform vec3 offset;

Поскольку мы сжимаем их в один вызов отрисовки:

attribute vec3 offset; //this is actually 25 different values that will be referenced

Затем вызов отрисовки отрисовывает множество вершин одинаково, снова и снова для каждого экземпляра, с каждым экземпляром, он обращается к разному значению:

void main(){
  vec3 myPosition = position + offset; //offset will change value 25 times during the draw call
}

Этот атрибут может быть намного больше, чем геометрия, например, для рисования простой плоскости миллион раз потребуется атрибут экземпляра гораздо большего размера, чем геометрические. И наоборот, отрисовка детальной геометрии несколько раз уменьшит ее размер.

При такой настройке мы сохраняем некоторый контроль над позиционированием каждого экземпляра, будучи ограниченными теми же ограничениями, что и при обновлении геометрии.

Т.е. изменение смещений экземпляров аналогично изменению вершин меша (оно работает с атрибутом, и атрибут должен быть обновлен), это все еще может быть очень эффективным. Но удобство пропало:

for ( var i = 0, l = INSTANCES; i < l; i ++ ) {
    var index = 3 * i;
    offsets[ index ] = positions[ i ].x;
    offsets[ index + 1 ] = positions[ i ].y;
    offsets[ index + 2 ] = positions[ i ].z;

Как и любой другой атрибут, three.js позволяет нам заполнить массив соответствующими значениями.

Проблемы

Как уже упоминалось, все это довольно низкий уровень, и все это предлагает three.js, но на то есть веские причины. Игровой движок угадывает, как используются ресурсы, и пытается оптимизировать это под капотом. Three.js можно использовать для создания игрового движка, но его можно использовать для чего-то еще, где потребность в инстансинге будет совершенно иной.

Итак, чтобы использовать создание экземпляров с three.js, нам нужно знать GLSL и то, как работают структуры данных WebGL. И это только для общего экземпляра, чтобы заставить его работать со всей системой three.js, он становится более сложным.

Пример three.js касается только материала Ламберта, используя один подход для расширения материала (копирование шейдера). Для более сложного материала, такого как MeshStandardMaterial, потребуется больше кода.

Пользователь должен правильно отформатировать атрибут, что предполагает преобразование BufferGeometry в InstancedBufferGeometry.

Ответственность за граф сцены немного приравнивается к Geometry, который не является его частью. Основа графа сцены - это узел, который мы можем установить position и который содержит Matrix4. Поскольку наша форма превратилась в атрибут, мы должны установить position, как если бы это была вершина меша (на Geometry).

InstancedBufferGeometry выполняет работу как Geometry, так и Object3D (Group).

В следующей части мы напишем код и применим инстансинг к демонстрации.