вторник, 17 июля 2012 г.

Частицы. Часть четвертая, полевые испытания.

Часть первая, и снова тесты
Часть вторая, если очень хочется
Часть третья, миллионы частиц
Часть четвертая, полевые испытания

В прошлых частях было сделано множество предположений и "идеальных" тестов, но это дает лишь поверхностное представление о производительности (так как на реальную картину влияет множество других факторов, таких как количество растеризируемых пикселей, сортировка, мипмаппинг, сложность шейдера и прочее), вызывая еще больше вопросов, и в конечном счете может поставить под вопрос все повествование.
Собственно так и случилось еще до опубликования этой статьи, при беседе с автором редактора частиц Magic Particles. Человек себя повел очень не адекватно и продемонстрировал абсолютную некомпетентность в вопросах 3D-рендера и оптимизаций. Словесная перепалка в этом случае ничего бы не дала, потому очевидным решением является на практике показать что человек очень сильно заблуждался, заодно это позволит развеять некоторые мифы, сложившиеся вокруг систем частиц, потому эту главу я посвящаю "полевым испытаниям" описанных подходов. За основу для тестов возьмем предмет спора с автором MP - вывод 10к частиц, наша цель - получить более высокий фпс, сохранив функциональность.

И так, приступим, для начала определим задачу - разработать простейший менеджер частиц, на основе которого можно для 10к частиц получить фпс существенно выше чем в MagicParticles, в котором он составляет всего 32 кадра в секунду:
Рис.1. Окно "MagicParticles" с 9959 частицами и 32фпс, показываемых Fraps.
В эффекте участвовало равноускоренное движение частиц + вращение частиц вокруг своей оси. Так как после общения с автором MP желание разбираться с редактором у меня отпало, то реализовал я очень простой эффект - разлет частиц от центра, добившись параметрами длительности жизни частицы и частотой рождения новых частиц присутствия тех самых 10к частиц на сцене.

Для написания демок мне пришлось разработать простой класс менеджера частиц, позволявший управлять теми же параметрами частиц что и в редакторе MP, в частности - равноускоренное движение, вращение под действием угловой скорости, изменение размера, цвета и прозрачности.

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

В качестве первого теста мы воспользуемся самым простым подходом - рисование каждой частицы через glBegin/glEnd. Данный подход используется при выводе частиц и спрайтов в GLScene. При таком подходе, на каждом кадре рендера, мы вынуждены производить вычисление координат каждой вершины, используя вектора Left и Up видовой матрицы, после чего эти вектора нужно отмасштабировать, чтоб они соответствовали размерам нашей частицы и после этого повернуть их в плоскости экрана на заданный угол, для чего нам необходимо сформировать соответствующую матрицу поворота. Все эти преобразования запишутся как:

  glGetFloatv(GL_MODELVIEW_MATRIX, @ViewMat);
  axis.SetVector(ViewMat[0][2],ViewMat[1][2],ViewMat[2][2]);
  lv.SetVector(ViewMat[0][0],ViewMat[1][0],ViewMat[2][0]);
  tv.SetVector(ViewMat[0][1],ViewMat[1][1],ViewMat[2][1]);
  axis.Norm; lv.Norm; tv.Norm;

  dx:=lv*Particle.Size/2; dy:=tv*Particle.Size/2;
  rm:=CreateRotationMatrix(axis, Particle.Angle);
  dx:=dx*rm; dy:=dy*rm;

Здесь axis - ось вокруг которой осуществляется поворот, lv и  tv - вектора Left и Top, полученные из видовой матрицы, dx, dy - прирост координат в плоскости экрана относительно центра частицы, p - координаты центра частицы.

Тогда рисование квада по этим вершинам будет иметь такой вот вид:
  glBegin(GL_QUADS);
    glVertex3f(dx[0] + dy[0]+p.x, dx[1] + dy[1]+p.y, dx[2] + dy[2]+p.z);
    glVertex3f(-dx[0] + dy[0]+p.x, -dx[1] + dy[1]+p.y, -dx[2] + dy[2]+p.z);
    glVertex3f(-dx[0] - dy[0]+p.x, -dx[1] - dy[1]+p.y, -dx[2] - dy[2]+p.z);
    glVertex3f(dx[0] - dy[0]+p.x, dx[1] - dy[1]+p.y, dx[2] - dy[2]+p.z);
 glEnd;

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

При таком подходе, для формирования квада необходимо произвести огромное количество вычислений, особенно это касается формирования матрицы поворота и трансформации координат двух векторов. В результате чего, на вывод 10к частиц тратится примерно 26мс (или 38.4 фпс). Чтоб отделить сложность математических вычислений от времени рендеринга, я провел еще два теста, в первом тесте я отключил рисование частиц, время вычислений одного кадра получилось примерно 19мс(53фпс). Вывод тех же частиц, но без вращения в плоскости экрана, затребовал 13,7мс на кадр (73 фпс).

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


Включение сортировки частиц на каждом кадре еще больше усугубило эту ситуацию, снизив фпс с 73 до 40 кадров в секунду. Попытка снизить нагрузку на CPU за счет сортировки синхронно обновлению координат партиклов (1/30 секунды) дала выигрыш в 5 фпс, повысив частоту кадров до 44-46.

Использование текстур практически не повлияло на фпс:

      
Рис.1. Слева система частиц без текстуры, справа - с текстурой.
Рис.2. Текстурный атлас частицы, разрешение 512х512 пикселей
Размер текстуры не оказывает особого влияния на результат, так как используется мипмаппинг и GPU само выбирает подходящий уровень детализации, исключение - подгонка размера частиц под размер текстуры (обычно совместно с ортографической проекцией), чтоб исключить искажения масштабирования и связанные с этим артефакты альфаканала. В этом случае работает все то же правило - производительность на прямую зависит от количества растеризируемых пикселей, потому чем крупнее будет частица тем ниже будет фпс.

Так как на данном этапе сложно оценить производительность рендера, из-за повышенной нагрузки на CPU и шину PCIEx, то к вопросу детализации текстур я вернусь при рассмотрении других способов рендеринга частиц. 
Так же на Рис.1. можно заметить показания счетчика времени рендеринга одного кадра, на левом скрине - 29мс, на правом - 18мс, при этом фпс примерно одинаковый. Причина такого разброса в том, что каждые 1/30 секунды я произвожу все физические вычисления, но так как это занимает лишь один кадр, то на усредненном фпс это не отражается.

Подведем итог - на этом простом примере мы оценили эффективность рендера, системы частиц, оценили нагрузку на CPU/GPU и на практике подтвердили узкие места, описанные еще в первых частях этого повествования. В результате мы с вами научились выводить систему частиц так, как это делали наши предки, используя команды незамедлительного рисования glBegin/glEnd с расчетами движения на CPU. Результат откровенно говоря не радуют, так как одной системой частиц мы сразу же загрузили CPU на 100% и "добились" простаивания GPU. Тем не менее, кое-какого результата мы все же достигли, в частности мы уже в полтора раза превысили фпс, достигаемый в ParticleMagic. Но в предыдущих статьях я говорил что это не предел, самое время это проверить.

Основная задача оптимизации - разгрузить CPU и шину, для этого переложим часть расчетов, по восстановление координат вершин квадов и их трансформацию, на GPU.

Вся идея данного подгода была описана ранее, здесь я приведу лишь основные моменты ее реализации:
  1. Используем VBO вместо передачи всего пакета геометрии на каждом кадре;
  2. Помещаем все квады в один буфер VBO и рисуем все частицы за один вызов;
  3. Все обновляемые данные собираем в нескольких буферах VBO;
  4. Координаты вершин квада восстанавливаем в вершинном шейдере.
Для краштеста (и краша MP) мы решили использовать максимально полную систему параметров частицы (положение, размер, поворот), создадим для этого соответствующий буферы VBO:


Vertices: array of array of record s,t: single; end;
Positions:  array of array of record x,y,z: single; end;
ScaleRots: array of array of record  sx,sy, sina,cosa: single; end;
//Atlas: array of array of record os,st,ss,st: single; end;

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

В буфер Positions мы поместим координаты центра частицы. Этот массив будет обновляться каждый 1/30 секунды.

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

Чаще всего масштаб по осям x и y одинаковый, потому в большинстве случаев можно обойтись и одной компонентой, что позволит вдвое снизить объем передаваемых данных. При моделировании большого числа частиц вариациями их размеров могут пренебречь, явно задав один размер для всех частиц, тем самым вместо буфера VBO мы просто можем передавать одну юниформу. Так же возможен вариант когда нам известен закон изменения размера частицы, в этом случае так же вместо передачи целого буфера VBO можно единожны (при рождении частицы) задать ее исходный размер и время рождения, передать в шейдер коэффициенты полинома, по которым меняется размер частицы и обновлять это значение исходя из времени жизни частицы. Такой подход мы рассмотрим чуть позже, а пока, для теста,  рассмотрим самый сложный случай.

Буфер Atlas нужен для хранения координат текстуры в текстурном атласе (смещение и размер). В большиснтве случаев в данный буфер значения заносятся только при рождении частицы и на результирующий фпс этот буфер практически не влияет. Иногда, если предполагается использование анимированной текстуры, может потребовать изменение этих координат до 30 раз в секунду. Тут так же как и в случае буфера ScaleRots возможны оптимизации, во-первых, чаще всего размер текстур в атласе одинаковый, потому, зная номер текстуры в атласе и количество строк/столбцов в атласе, можно легко вычислить текстурные координаты. Особенно это актуально для анимированных текстур. Когда производительность критична а использование текстур одного размера недопустимо (к примеру при приближении к камере использовать текстуры большего разрешения) есть смысл создать отдельный массив (константы или юниформу) в который занести координаты и размер всех текстурных блоков в атласе, таким образом, нужные нам данные мы так же сможем получить по идентификатору текстуры, который заменит передачу 4-х координат.
В этой демке я воспользуюсь единственной текстурой, потому буфер Atlas я использовать не буду.

Шейдер соответствует рассмотренному в предыдущих главах. Для тестирования я добавил в него юниформы "atlas" и "blend", первая нужна чтоб в очередной раз показать что размер текстуры (или фрагмента атласа) никак не влияет на производительность, даже при выводе 10 тысяч частиц. Юниформа "blend" нужна чтоб вообще отключить использование текстуры, какого бы размера она не была:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//Вершинный шейдер:
uniform  vec4  campos;
uniform  float atlas;
varying vec2 TexCoord;
attribute vec3 Position;
attribute vec4 ScaleRots;
void main()
{
  TexCoord.xy = (gl_Vertex.xy*0.5+0.5)*atlas;
  vec2 tc = gl_Vertex.xy*ScaleRots.xy;
  vec4 Vert;
  Vert.x = tc.x*ScaleRots.w+tc.y*ScaleRots.z;
  Vert.y = -tc.x*ScaleRots.z+tc.y*ScaleRots.w;
  vec3 vNormal = normalize( campos.xyz - Position);
  vec3 vRight = normalize(cross( vec3( 0.0,1.0,0.0 ), vNormal ));
  vec3 vUp = normalize(cross( vNormal, vRight));
  vec3 resultCoord = Position  + (( vUp*Vert.x + vRight*Vert.y)) ;
  gl_Position    = ( gl_ModelViewProjectionMatrix * vec4(resultCoord,1.0) );
  gl_FrontColor = vec4(1.0,0.0,0.0,1.0);
}

//Фрагментный шейдер:
uniform sampler2D MainTex;
uniform float blend;
varying vec2 TexCoord;
void main(void)
{
  vec4 color = texture2D(MainTex,TexCoord.xy);
  gl_FragColor = mix(gl_Color,color,blend);
  gl_FragColor.a = 0.5;
}

Рис.3. Влияние размера блока текстуры в атласе. Слева - полный атлас (512х512пикселей), справа - четвертушка (256х256 пикселей).

Рис.4. Слева - текстурный атлас не используется, справа - используется полный текстурный атлас, но частота обновления координат снижена до 15фпс.
Как видно со скриншотов (Рис.3.), размер текстурного атласа или размер блока текстуры в этом атласе никак не влияет на производительность. При отключении использования текстуры, за счет небольшой оптимизации кода. мы получили прирост в пару кадров, но это так же не является определяющим. Как видно с последнего скриншота (Рис.4., справа) - узкое место у нас все так же на стороне CPU, так, снижение частоты расчета физики (+сортировка частиц по глубине+обновление буферов VBO) позволило повысить фпс с 242 до 486 кадров, тобишь ровно вдвое. Тем не менее, даже при частоте обновления 30 кадров в секунду, мы получили восьмикратный прирост производительности, относительно предыдущего примера и десятикратный, относительно MagicParticles и это далеко не предел. 

Такой прирост производительности мы получили за счет переноса ориентации и вращения спрайта на GPU (разгрузили CPU) и за счет использования VBO (разгрузили шину).
Оптимизируя структуру буферов VBO, выкидывая все лишнее и оптимизируя код шейдера мы можем добиться еще 50% повышения производительности, фактически дойдя до варианта, реализуемого точечными спрайтами (Рис.5.):



 Рис.5. Вывод системы частиц через точечные спрайты. Слева частота обновления физики 30 раз в секунду, справа - 15 раз.

Как заставить эти точечные спрайты вращаться и менять размер, а так же как это влияет на производительность я рассмотрел в предыдущих главах, потому не стану на этом задерживаться. Тем не менее, как показывает правый скриншот, CPU у нас все так же перегружено, и если на левом скриншоте еще заметно влияние на производительность  снижения объема передаваемых атрибутов, то при 15к/с влияния количества атрибутов уже нет (485 против 495 фпс), потому дальнейшие оптимизации технологии отрисовки спрайтов не имеют смысла и нужно сосредоточиться на разгрузке CPU.

Безусловно, мы можем оптимизировать все расчеты, упрощать алгоритмы, распределить расчеты между несколькими процессорами, использовать интерполяцию для снижения нагрузки и многое другое. Но нужно помнить что система частиц сама по себе редко является целью, обычно это часть чего-то большего, к примеру игры, где ресурсы CPU расходуются на расчет динамики тел, для расчетов рэгдоллов, для проверок столкновений/рейкастов, на игровую логику, ИИ и многое другое, потому лишняя нагрузка на CPU может оказаться критичной, причем для всей игры а не только для системы частиц. Одним из выходов является перенос всех расчетов на GPU. В общем обзоре я уже рассказывал о всех преимуществах переноса расчетов на GPU, в частности это распараллеливание вычислений и исключение необходимости перегонять данные по шине между системной памятью и видеопамятью. К сожалению у такого подхода имеются и недостатки, в частности это сортировка частиц и двухпроходный рендеринг, из-за чего в этот раз уже GPU оказывается перегруженным десятком биндов буферов, копированиями PBO->VBO и прочим. В результате вывод малого числа частиц (10к частиц - это уже мало) оказывается нерациональным (получаем все те же 250-350 фпс), единственное преимущество - разгружается CPU для других расчетов.

На этом пожалуй я закончу этот цикл статей, так как поставленные цели достигнуты - способы вывода системы частиц  и их оптимизацию мы рассмотрели, 10-ти кратный прирост производительности, относительно MP мы получили, что, как говорится, и требовалось доказать :)

2 комментария:

  1. Не удевлюсь, если автор MP ответит - "Да я даже не буду читать эту статью!" :)
    А вообще возникает вопрос, действительно ли он кодил этот редактор? :D Из твоих, Фантом, переписок с ним промелькнула идея, что редактор вообще не его, вот он ничего толкового и сказать не может, а только как баран одно и тоже твердит :D

    ОтветитьУдалить
  2. Ну статью я не для него писал, наоборот, это было логическое продолжение статей по оптимизации рендера и МР я планировал использовать в качестве редактора системы частиц для демок, и, возможно, в качестве базового редактора эффектов для VBOMesh (сам редактор все-таки неплох). Но так как единственный тип импортируемых из МР данных был список уже ориентированных на камеру вершин квадов, то тут напрашивалась оптимизация (смотри первую демку), потому я и связался с автором с простым вопросом - нельзя ли импортировать отдельно сырые данные, позицию, масштаб, поворот и текстурные координаты в атласе. Ответ бы на столько неадекватен что это отбило всякое желание иметь что-либо общее с автором МР.

    ОтветитьУдалить