SSAO на OpenGL ES 3.0


    Однажды, разглядывая очередную демку с эффектом, возник вопрос: а можно ли сделать SSAO на мобильном девайсе так, чтобы и выглядело хорошо и не тормозило?
    В качестве устройства был взят Galaxy Note 3 n9000 (mali T62), цель — фпс не ниже 30, а качество должно быть как на картинке выше.

    Не буду вдаваться в подробности об этом эффекте, предполагается, что про сам эффект читатель может узнать из других источников (wiki или steps3d). Упор в статье сделан на адаптацию под мобильную платформу, однако краткий обзор будет сделан в разделе «Разновидности SSAO».

    SSAO довольно прожорливый эффект в плане производительности и вместе с тем достаточно незаметный для обычного пользователя, поэтому я до сих пор не встречал его на мобильном устройстве. Тем более для его ускорения необходима поддержка MRT, которая появилась только в OpenGL ES версии 3.0.

    Разновидности SSAO


    Основная идея алгоритма заключается в определении затенения точки поверхности в пространстве. Для того, чтобы определить степень затенения этой точки в некотором радиусе от нее проверяется наличие объектов, чем больше в окрестности объектов — тем сильнее затенена точка.

    Разумеется, невозможно посчитать затенение для каждой точки на поверхностях в трехмерном пространстве, поэтому вся работа алгоритма ведется в пространстве изображения (отсюда и название screen space ambient occlusion). Другими словами в роли точки на поверхности выступает пиксель на экране (на самом деле не обязательно на экране, размер буфера SSAO часто меньше, чем размер экрана, поэтому правильнее будет сказать пиксель во фрейме или кадре). Так же невозможно обрабатывать значения всех точек пространства в окрестности, поэтому обычно ограничиваются небольшой выборкой случайных точек в определенном радиусе от рассматриваемой точки.

    В простейшей реализации для рассчета затенения достаточно буфера глубины — так z-координата рассматриваемого пикселя сравнивается с z-координатами пикселей из выборки, на основе этой разницы и считается затенение.

    Однако посмотрите на картинку выше — на ней видно, что свой («бесполезный») вклад в затенение вносит и сама поверхность под точкой. В итоге картинка получается в серых тонах, то есть там где затенения быть не должно — оно есть. Выглядит это примерно так:


    Для того, чтобы избежать этого, кроме информации о глубине, нужна еще информация о нормали в этой точке. Для этого во время рендера сцены помимо цвета, записывают еще в отдельный буфер нормали. Учитывая их можно изменить позицию точки из выборки таким образом, чтобы она вносила «полезный» вклад. Делается это на основе угла между нормалью в рассматриваемой точке и нормалью точки из выборки, если он (угол) больше 90° — нормаль из выборки инвертируется и на ее основе пересчитывается принадлежащая ей точка. Такая модификация алгоритма дает затенение только там, где оно нужно, но имеет серьезный недостаток — для каждой точки из выборки помимо чтения из буфера глубины появляется дополнительное чтение из текстуры нормалей. А самое узкое место в SSAO — это большое количество чтений из разных буферов, причем эти чтения производятся в случайном порядке, что является убийством для кэша. Особенно на мобильном устройстве.

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

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

    На мой взгляд это лучшая модификация, ее и применим.

    Реализация


    Для начала нам понадобятся буферы для хранения нормалей, глубины и цвета. Здесь как раз используется технология из OpenGL ES 3.0 — Multiple Render Targets, мы создаем несколько буферов, а потом одновременно пишем в них. В коде это выглядит так (создание текстуры я вынес в отдельную функцию):
    // говорим, что хотим присоединить к фреймбуферу две текстуры (цвет и нормали), буфер глубины не указывается
    GLenum buffers[] = {GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1};
    GLuint fbo;
    GLuint colorBuff;
    GLuint normBuff;
    GLuint depthBuff;
    
    // функция для создания текстуры
    void createTexture(GLuint &id, int inFormat, int w, int h, int format, int type, int filter, int wrap, void* pix=NULL) {
    	glGenTextures(1, &id);
    	glBindTexture(GL_TEXTURE_2D, id);
    	glTexImage2D(GL_TEXTURE_2D, 0, inFormat, w, h, 0, format, type, pix);
    	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, filter);
    	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, filter);
    	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap);
    	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap);
    }
    ...
    	glGenFramebuffers(1, &fbo); // создаем фреймбуфер
    	glBindFramebuffer(GL_FRAMEBUFFER, fbo);
    	// width и height - размеры экрана
    	createTexture(colorBuff, GL_RGB8, width, height, GL_RGB, GL_UNSIGNED_BYTE, GL_NEAREST, GL_MIRRORED_REPEAT);
    	createTexture(normBuff, GL_RGB8, width, height, GL_RGB, GL_UNSIGNED_BYTE, GL_NEAREST, GL_MIRRORED_REPEAT);
    	createTexture(depthBuff, GL_DEPTH_COMPONENT24, width, height, GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, GL_NEAREST, GL_MIRRORED_REPEAT);
    	// присоединяем текстуры к фреймбуферу, вторым параметром указываем что это за текстура
    	glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorBuff, 0);
    	glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, normBuff, 0);
    	glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthBuff, 0);
    	int err = glCheckFramebufferStatus(GL_FRAMEBUFFER);
    	if (err != GL_FRAMEBUFFER_COMPLETE)
    		LOGE("Main framebuffer error: %i", err);
    	glDrawBuffers(2, buffers);
    

    Дальше подобным образом, но уже с одной текстурой, нужно создать буфер под SSAO.
    буфер под SSAO
    glGenFramebuffers(1, &ssaoFbo1);
    glBindFramebuffer(GL_FRAMEBUFFER, ssaoFbo1);
    // так как это буфер затенения, нам хватит одного цветового канала, поэтому тип текстуры GL_R8
    createTexture(ssaoBuff1, GL_R8, width/ssaoScaleW, height/ssaoScaleH, GL_RED, GL_UNSIGNED_BYTE, GL_LINEAR, GL_MIRRORED_REPEAT);
    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, ssaoBuff1, 0);
    // glDrawBuffers вызывать не нужно, если к фреймбуферу присоединяется всего одна текстура
    


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

    Отдельно стоит сказать про z-буфер. Для использования в SSAO его нужно сначала сделать линейным, в противном случае на среднем и дальнем плане сильно теряется точность. Обычно для этого значения глубины пишут в отдельный буфер, часто вместе с нормалями. Однако дополнительный буфер не сулит ничего хорошего в плане производительности, поэтому линеаризовывать значения и записывать их мы будем не в отдельный буфер, а прямо в действующий, стандартный буфер глубины (gl_FragDepth). Это может вызвать артефакты на переднем плане (очень близком, практически вблизи передней плоскости отсечения), однако в основном такой буфер ведет себя вполне нормально.

    Рендер сцены осуществляется как обычно, с той лишь разницей, что помимо цвета мы еще записываем нормали и чуть изменяем буфер глубины. Вершинный шейдер для рендера сцены:
    #version 300 es
    
    uniform mat4 matrixProj;
    uniform mat4 matrixView;
    uniform vec3 lightPos;
    
    layout(location = 0) in vec3 vPos; // позиция вершины
    layout(location = 1) in vec3 nPos; // нормаль
    layout(location = 2) in vec3 tPos; // тангент для карты нормалей
    layout(location = 3) in vec2 tCoord; // текстурные координаты
    
    out vec3 light;
    out vec3 gNorm;
    out float zPos;
    out vec2 texCoord;
    
    void main() {
    	vec4 p = matrixProj*matrixView*vec4(vPos, 1.0);
    	gl_Position = p;
    	texCoord = tCoord;
    
    	// поворачиваем источник света для карты нормалей, чтобы не делать этого для каждой нормали в пиксельном шейдере
    	vec3 bitangent = cross(tPos, nPos);
    	mat3 tbn = mat3(tPos, bitangent, nPos);
    	light = normalize(lightPos-vPos)*tbn;
    
    	zPos = p.z; // записываем z-координату точки - пригодится для заполнения буфера глубины
    	vec4 n = (matrixView*vec4(nPos, 0.0)); // переносим нормаль в пространство вида
    	gNorm = normalize(n.xyz);
    }
    

    Фрагментный шейдер:
    #version 300 es
    precision highp float; // так как мы пишем в 24-битный буфер, разрядность переменных с плавающей точкой, тоже должна быть равна 24 битам, в противном случае будет потеря точности
    
    uniform sampler2D texDiff; // диффузная текстура
    uniform sampler2D texNorm; // карта нормалей для объекта из сцены
    
    layout(location = 0) out vec3 colorBuff; // сюда записывается цвет
    layout(location = 1) out vec3 normBuff; // сюда нормали
    
    in vec3 light;
    in vec3 gNorm;
    in float zPos;
    in vec2 texCoord;
    
    const vec3 ambientColor = vec3(0.3);
    const float zFar = 40.0; // дальняя плоскость отсечения
    
    void main() {
    	vec3 n = normalize(texture(texNorm, texCoord).xyz*2.0-1.0);
    	vec3 l = normalize(light);
    	vec3 c = texture(texDiff, texCoord).rgb;
    	float a = clamp(dot(n, l), 0.0, 1.0);
    	colorBuff = c*(a+ambientColor); // записываем цвет
    
    	normBuff = normalize(gNorm)*0.5+0.5; // записываем нормали
    	gl_FragDepth = zPos/zFar; // в буфер глубины пишем свои значения
    }
    
    


    Еще нам понадобится массив случайных точек в полусфере (собственно выборка). Для того, чтобы затенение получилось красивым (если так можно выразиться, более физичным) — плотность расположения точек в полусфере должна быть выше к центру и ниже к границе. То есть иметь нормальный закон распределения.
    for (int i=0; i<samples; i++) {
    	rndTable[i] = vec3(random(-1, 1), random(-1, 1), random(-1, -0)); //равномерное распределение в полукубе (рисунок слева)
    	rndTable[i].normalize(); // делаем полусферу
    	rndTable[i] *= (i+1.0f)/samples; //нормальное распределение (рисунок справа)
    }
    






    Разумеется количество точек в выборке будет не таким большим. Сделаем пока равным 10.

    Однако такой выборки с псевдослучайными значениями мало — эти значения случайны только в пределах одного фрагмента, следующий фрагмент будет брать те же самые точки, хоть и с небольшим смещением. Для устранения этого недостатка обычно используют маленькую текстурку (примерно 4х4 пикселя) с псевдослучайными значениями. Теперь в каждом фрагменте можно брать значения из этой текстуры и составлять на их основе матрицу поворота для наших точек из полусферы. А заодно поворачивать их относительно нормали, которую мы будем читать уже из текстуры нормалей. Таким образом умножая точки на полученную матрицу мы будем одновременно ориентировать их относительно нормали и поворачивать на псевдослучайный вектор. Построение такой матрицы называется процессом Грамма-Шмидта.

    В шейдере это будет выглядеть так:
    vec3 normal = texture(normBuff, texCoord).xyz*2.0-1.0;
    vec3 rvec = texture(randMap, texCoord*scr).xyz*2.0-1.0;
    vec3 tangent = normalize(rvec-normal*dot(rvec, normal));
    vec3 bitangent = cross(tangent, normal);
    mat3 rotate = mat3(tangent, bitangent, normal);
    


    Еще нам необходимо восстановить координаты точки в пространстве. Для этого можно умножить координаты точки в пространстве изображения на обратную матрицу перспективного преобразования, а можно поступить проще: умножить вектор (луч) направленный из камеры через текущий пиксель на значение z-координаты этого пикселя. Z-координаты у нас хранятся в буфере глубины, а луч из камеры строится на основе угла обзора, который используется при построении перспективной матрицы (матрицы проекции) — это fov. Я решил сэкономить на uniform-ах, и просто забил это значение в вершинный шейдер как константу.
    #version 300 es
    
    const fov = 0.57735;
    
    layout(location = 0) in vec2 vPos;
    layout(location = 1) in vec2 tCoord;
    
    uniform float aspect;
    
    out vec2 texCoord;
    out vec3 viewRay;
    
    void main() {
    	gl_Position = vec4(vPos, 0.0, 1.0);
    	texCoord = tCoord;
    	viewRay = vec3(-vPos.x*aspect*fov, -vPos.y*fov, 1.0); // луч из камеры, он интерполируется во фрагментный шейдер
    }
    

    В пиксельном шейдере позиция точки на основе луча восстанавливается так:
    float depth = texture(depthBuff, texCoord).x; // значение из буфера глубины, оно нормализировано
    depth *= zFar; // восстанавливаем z-координату
    vec3 pos = viewRay*depth; // позиция пикселя в пространстве вида
    

    После получения матрицы поворота и позиции точки, можно приступать к получению значения затенения от выборок:
    float acc = 0.0;
    for (int i=0; i<samples; i++) {
    	vec3 samplePos = rotate*rndTable[i]; //поворачиваем выборку
    	samplePos = samplePos*radius+pos; //ограничиваем заданным радиусом и переносим в рассматриваемую точку
    
    	// так как до сих пор мы работали в пространстве вида, то для нахождения проекции выборки необходимо умножить ее на матрицу проекции
    	vec4 shift = proj*vec4(samplePos, 1.0);
    	shift.xy /= shift.w;
    	shift.xy = shift.xy*0.5+0.5;
    	// осталось найти z-координату
    	float sampleDepth = texture(depthBuff, shift.xy).x*zFar;
    	// если точка оказалась за пределами радиуса - она свой вклад почти не вносит. Чтобы переходы не были резкими, проверку на выхожд за границы выполняем плавно, интерполируем через smoothstep, чем дальше - тем меньше
    	float distanceCheck = smoothstep(0.0, 1.0, radius/abs(pos.z-sampleDepth));
    	// если сэмпл оказался ниже рассматриваемой точки - значит он ее не затеняет. Иначе - затеняет, это выполняет функция step
    	acc += step(sampleDepth, samplePos.z)*distanceCheck;
    }
    

    Полученный результат можно размыть, поэтому создадим еще один буфер для размытия по гауссу. Количество выборок сделаем небольшим, в конце концов это всего лишь тень, которая к тому же ложится поверх текстуры, поэтому артефакты должны быть не очень заметны. Здесь кэш уже на нашей стороне, если в случае с рендомным чтением в SSAO постоянно случаются кэш-промахи, то тут выборки из текстуры должны хорошо кэшироваться.
    шейдер для вертикального размытия
    #version 300 es
    precision mediump float;
    
    uniform sampler2D ssaoBuff;
    
    layout(location = 0) out float outColor;
    
    in vec2 texCoord;
    
    const float blurSize = 2.5/1920.0;
    
    void main() {
    	float sum = 0.0;
    	sum += texture(ssaoBuff, vec2(texCoord.x, texCoord.y - 2.0*blurSize)).r * 0.0625;
    	sum += texture(ssaoBuff, vec2(texCoord.x, texCoord.y -     blurSize)).r * 0.25;
    	sum += texture(ssaoBuff, vec2(texCoord.x, texCoord.y               )).r * 0.375;
    	sum += texture(ssaoBuff, vec2(texCoord.x, texCoord.y +     blurSize)).r * 0.25;
    	sum += texture(ssaoBuff, vec2(texCoord.x, texCoord.y + 2.0*blurSize)).r * 0.0625;
    
    	outColor = sum;
    }
    

    шейдер для горизонтального размытия
    #version 300 es
    precision mediump float;
    
    uniform sampler2D ssaoBuff;
    
    layout(location = 0) out float outColor;
    
    in vec2 texCoord;
    
    const float blurSize = 2.5/1080.0;
    
    void main() {
    	float sum = 0.0;
    	sum += texture(ssaoBuff, vec2(texCoord.x - 2.0*blurSize, texCoord.y)).r * 0.0625;
    	sum += texture(ssaoBuff, vec2(texCoord.x -     blurSize, texCoord.y)).r * 0.25;
    	sum += texture(ssaoBuff, vec2(texCoord.x,                texCoord.y)).r * 0.375;
    	sum += texture(ssaoBuff, vec2(texCoord.x +     blurSize, texCoord.y)).r * 0.25;
    	sum += texture(ssaoBuff, vec2(texCoord.x + 2.0*blurSize, texCoord.y)).r * 0.0625;
    
    	outColor = sum;
    }
    


    Можно посмотреть на результат:

    Цифра в левом верхнем углу показывает количество кадров в секунду. Не густо. К тому же обратите внимание на полосы на поверхности. Дело в том, в представлении буфера глубины поверхность не идеально гладкая. Там все вполне себе дискретно и любая поверхность выглядит лесенкой, поэтому часть точек из выборок оказываются как бы внутри этих «ступенек»:

    Поэтому при формировании выборки лучше начинать не с нулевой z-координаты, а с некоторой небольшой величины, например с 0,1. В этом случае полусфера как бы урежется снизу:

    и тогда точки из выборки не будут попадать в «ступеньки». Картинка станет получше:

    Но фпс по прежнему не высок.

    Оптимизация


    Очевидные и самые распространенные решения — это снизить количество точек в выборке и уменьшить размер буфера SSAO. Уменьшив размер буфера вдвое получаем прибавку к фпс примерно 150%. Однако изменяя размер буфера мы получаем артефакты на границах объектов, поэтому сильно уменьшать его не будем.

    Посмотрите на результат работы алгоритма — видно, что большая часть изображения белая и совсем не имеет затенения. А ведь алгоритм отрабатывает для каждого пикселя. Хорошо бы создать некую маску, по которой можно было бы отсекать ненужные фрагменты.
    Эту маску можно получить грубо вычислив SSAO для меньшего буфера. То есть создадим еще один буфер, скажем в 16 раз меньше по ширине и высоте, чем буфер для SSAO. Уменьшим количество выборок до пяти и сделаем их не случайными, а расположенными на одинаковом расстоянии от центра полусферы:
    for (int i=0; i<samplesLow; i++) {
    	float angle = DEG2RAD*360.0f*i/samplesLow;
    	rndTableLow[i] = vec3(sinf(angle), cosf(angle), -0.1);
    }
    

    Так как нам нужна маска, без плавных переходов теней, результат будем делать очень контрастным — пиксель либо черный, либо белый:
    outColor = step(254.0/255.0, 1.0-(acc/float(samples)));
    

    Шейдер размытия тоже упростим, теперь это будет не два фрагментных шейдера, а один с четырьмя выборками, расположенными по углам квадрата:
    #version 300 es
    precision mediump float;
    
    uniform sampler2D ssaoLowBuff;
    uniform float aspect;
    
    layout(location = 0) out float outColor;
    
    in vec2 texCoord;
    
    const float blurSize = 0.01;
    
    void main() {
    	float sum = 0.0;
    	sum += texture(ssaoLowBuff, vec2(texCoord.x - blurSize, texCoord.y - blurSize*aspect)).r;
    	sum += texture(ssaoLowBuff, vec2(texCoord.x - blurSize, texCoord.y + blurSize*aspect)).r;
    	sum += texture(ssaoLowBuff, vec2(texCoord.x,            texCoord.y                  )).r;
    	sum += texture(ssaoLowBuff, vec2(texCoord.x + blurSize, texCoord.y - blurSize*aspect)).r;
    	sum += texture(ssaoLowBuff, vec2(texCoord.x + blurSize, texCoord.y + blurSize*aspect)).r;
    
    	outColor = step(254.0/255.0, sum/5.0);
    }
    

    Получаем вот такую маску:

    Используя ее вычисляем SSAO (размер буфера я уменьшил в 1.5 раза, а количество сэмплов сократил до 8):

    В итоге фпс возрос в три раза, без визуальной потери качества. У такого метода есть недостаток — если в сцене будет много углов или иных мест затенения, то маска может стать практически полностью черной, а значит эффективность такой оптимизации сильно снизится и даже может вносить дополнительный оверхед на вычисление уменьшенного SSAO.
    Полный код фрагментного шейдера SSAO:
    #version 300 es
    precision highp float; //работаем с 24-битным буфером глубины
    
    const int samples = 8; // количество выборок
    const float radius = 0.5; // радиус тени
    const float power = 2.0; // усиление эффекта
    const float zFar = 40.0; // дальняя плоскость отсечения
    
    uniform sampler2D normBuff; // буфер нормалей
    uniform sampler2D depthBuff; // буфер глубины
    uniform sampler2D randMap; // маленькая текстура с псевдослучайными значениями
    uniform sampler2D ssaoMask; // уменьшенный SSAO - маска
    uniform vec2 scr; //коррекция текстурных координат для randMap, зависит от размера буфера SSAO
    uniform vec3 rndTable[samples]; // выборки
    uniform mat4 proj; // матрица проекции
    
    layout(location = 0) out float outColor; // выходной буфер
    
    in vec2 texCoord; // текстурные координаты
    in vec3 viewRay; // луч из камеры
    
    void main() {
    	// если пиксель в маске белого цвета, значит затенения нет - отбрасываем фрагмент
    	float k = texture(ssaoMask, texCoord).x;
    	if (k==1.0)
    		discard;
    
    	// если ни один объект не попал в этот фрагмент - отбрасываем
    	float depth = texture(depthBuff, texCoord).x;
    	if (depth==1.0)
    		discard;
    
    	depth *= zFar;
    	vec3 pos = viewRay*depth;
    	vec3 normal = texture(normBuff, texCoord).xyz*2.0-1.0;
    	vec3 rvec = texture(randMap, texCoord*scr).xyz*2.0-1.0;
    	vec3 tangent = normalize(rvec-normal*dot(rvec, normal));
    	vec3 bitangent = cross(tangent, normal);
    	mat3 rotate = mat3(tangent, bitangent, normal);
    
    	float acc = 0.0;
    	for (int i=0; i<samples; i++) {
    		vec3 samplePos = rotate*rndTable[i]; //поворачиваем выборку
    		samplePos = samplePos*radius+pos; //ограничиваем заданным радиусом и переносим в рассматриваемую точку
    
    		// так как до сих пор мы работали в пространстве вида, то для нахождения проекции выборки необходимо умножить ее на матрицу проекции
    		vec4 shift = proj*vec4(samplePos, 1.0);
    		shift.xy /= shift.w;
    		shift.xy = shift.xy*0.5+0.5;
    		// осталось найти z-координату
    		float sampleDepth = texture(depthBuff, shift.xy).x*zFar;
    		// если точка оказалась за пределами радиуса - она свой вклад почти не вносит. Чтобы переходы не были резкими, проверку на выход за границы выполняем плавно, интерполируем через smoothstep, чем дальше - тем меньше
    		float distanceCheck = smoothstep(0.0, 1.0, radius/abs(pos.z-sampleDepth));
    		// если сэмпл оказался ниже рассматриваемой точки - значит он ее не затеняет. Иначе - затеняет, это выполняет функция step
    		acc += step(sampleDepth, samplePos.z)*distanceCheck;
    	}
    
    	outColor = pow(1.0-(acc/float(samples)), power); // итоговое затенение
    }
    

    Сравнительные скриншоты с текстурированными объектами




    Видео с демонстрацией (размер SSAO в 2 раза меньше экрана):

    (размер SSAO в 1.5 раза меньше экрана):


    Тонкости


    В процессе я столкнулся с некоторыми интересностями, но так как это немного оффтопик — спрятал под спойлеры.
    Порядок байтов
    Когда я писал конвертер для текстур и моделей, то столкнулся с тем, что на ARM процессорах порядок байтов отличается от x86. Соответственно при записи в бинарный файл, во всех типах данных имеющих длину больше одного байта желательно инвертировать порядок байтов, чтобы потом не заниматься этим на девайсе.
    Для этого я использовал функции:
    • uint32_t htonl(uint32_t hostlong);
    • uint16_t htons(uint16_t hostshort);

    Например, вывод в файл некоторых значений с измененным порядком байт (с использованием Qt):
    #include <netinet/in.h>
    ...
    QDataStream out(&file);
    out << htons(s); //меняем порядок байтов элемента типа unsigned short
    out << htonl(*((unsigned int*)&f));  //меняем порядок байтов элемента типа float
    
    Разделитель в дробных числах
    В зависимости от региональных настроек и операционной системы, функция sscanf может по разному интерпретировать числа с плавающей точкой. Где-то для разделения дробной и целой части может использоваться точка, где-то — запятая.
    Например:
    readed1 = sscanf("float: 1,5", "float: %f", &f);
    readed2 = sscanf("float: 1.5", "float: %f", &f);
    

    Значения readed1 и readed2 могут различаться на разных системах. Обычно эти настройки устанавливаются в региональных параметрах операционной системы. Это стоит учитывать, например при написании парсера для *.obj файлов.
    Тормоза из-за переполнения лога
    Если вы используете logcat, не забывайте очищать лог на андроиде. По крайней мере на Note 3 n9000 при выводе большого количества информации в лог (я писал туда каждую секунду текущий фреймрейт), начинает все жутко тормозить. Долгое время не мог понять в чем дело, пока не очистил лог (команда adb logcat -c).
    Разные GPU
    Написав шейдер, неплохо бы проверить его на нескольких девайсах с разными gpu. Вышеприведенный код шейдера SSAO прекрасно работает на mali, но глючит на adreno (в частности 320 и 330). Оказалось на adreno (по крайней мере в версии шейдеров es 300) не корректно работают циклы, точнее сказать не могу, но выглядит это так, будто в цикле отрабатывает одна и та же итерация, хотя счетчик увеличивается. Пришлось слегка изменить код шейдера, избавиться от цикла:
    ...
    float getSample(in int i, in mat3 rotate, in vec3 pos, in vec3 rnd) {
    	vec3 samplePos = rotate*rnd;
    	samplePos = samplePos*radius+pos;
    
    	vec4 shift = proj*vec4(samplePos, 1.0);
    	shift.xy /= shift.w;
    	shift.xy = shift.xy*0.5+0.5;
    
    	float sampleDepth = texture(depthBuff, shift.xy).x*zFar;
    	float distanceCheck = smoothstep(0.0, 1.0, radius/abs(pos.z-sampleDepth));
    	return step(sampleDepth, samplePos.z)*distanceCheck;
    }
    
    void main() {
    ...
    	float acc = 0.0;
    	acc += getSample(0, rotate, pos, rndTable[0]);
    	acc += getSample(1, rotate, pos, rndTable[1]);
    	acc += getSample(2, rotate, pos, rndTable[2]);
    	acc += getSample(3, rotate, pos, rndTable[3]);
    	acc += getSample(4, rotate, pos, rndTable[4]);
    	acc += getSample(5, rotate, pos, rndTable[5]);
    	acc += getSample(6, rotate, pos, rndTable[6]);
    	acc += getSample(7, rotate, pos, rndTable[7]);
    	outColor = pow(1.0-(acc/float(samples)), power);
    }
    

    Выглядит ужасно, но если кто-нибудь знает в чем причина такого странного поведения — напишите пожалуйста в комментариях или в лс.
    QtCreator в качестве IDE для NDK-проекта
    Мне лично больше нравится QtCreator, чем Eclipse или Android Studio, тем более что пишу я в основном на NDK. Поэтому проект обычно создаю в Eclipse и переношу его в QtCreator. Если кому интересно, вот процесс переноса проекта:
    Открываем Qt Creator и идем File -> New File or Project… -> Import Project -> Import Existing Project
    скриншот


    Далее вводим имя проекта и указываем путь к уже созданному проекту. О том, как создать проект для Android можно почитать в оффициальной документации: создание проекта в Eclipse, из командной строки и добавление поддержки NDK.
    скриншот


    После этого выбираем какие файлы будут отображаться в древе проекта и, собственно, создаем проект. Qt Creator автоматически создаст такие файлы:
    MyProject.config — сюда можно прописать дифайны для компиляции, например, для поддержки NEON я добавил туда строчку #define __ARM_NEON__
    MyProject.files — все файлы, относящиеся к древу проекта
    MyProject.includes — здесь нужно прописать пути к инклудам библиотек, которые используются в проекте, например:
    /home/torvald/android-ndk-r9/sources/android/cpufeatures
    /home/torvald/android-ndk-r9/sources/cxx-stl/stlport/stlport
    /home/torvald/android-ndk-r9/sources/cxx-stl/gabi++/include
    /home/torvald/android-ndk-r9/toolchains/arm-linux-androideabi-4.6/prebuilt/darwin-x86_64/lib/gcc/arm-linux-androideabi/4.6/include
    /home/torvald/android-ndk-r9/toolchains/arm-linux-androideabi-4.6/prebuilt/darwin-x86_64/lib/gcc/arm-linux-androideabi/4.6/include-fixed
    /home/torvald/android-ndk-r9/platforms/android-18/arch-arm/usr/include
    /home/torvald/android-ndk-r9/toolchains/arm-linux-androideabi-4.6/prebuilt/linux-x86_64/lib/gcc/arm-linux-androideabi/4.6/include/
    /home/torvald/android-ndk-r9/toolchains/arm-linux-androideabi-4.6/prebuilt/linux-x86_64/lib/gcc/arm-linux-androideabi/4.6/include/-fixed

    Так же можно написать небольшой скрипт для управления компиляцией и деплоем проекта:
    #!/bin/sh
    case "$1" in
    	clean) ndk-build clean && ant clean
    		;;
    	deploy) adb install -r bin/MainActivity-debug.apk > /dev/null 2>&1 & # && adb logcat -c && adb logcat -s "SSAOTest" #если хотите вывод лога в консоль Qt Creator - раскомментируйте эту строку
    		;;
    	run) #adb shell am start -n com.torvald.ssaotest/com.torvald.ssaotest.MainActivity > /dev/null 2>&1 &
    		;;
    	*) #kill $(ps aux | grep "adb logcat" | grep -v "grep" | awk '{print $2}') > /dev/null 2>&1 &
    		ndk-build NDK_DEBUG=0 -j9 && ant debug
    		;;
    esac
    

    Во вкладке «Projects» этот скрипт назначается на соответствующие действия:
    скриншоты




    Вот и все, теперь можно стандартными средствами Qt Creator очистить проект, сбилдить и залить его на устройство. Работает подсветка синтаксиса, автодополнение и прочие плюшки для GLSL и C++.


    Демка


    Если есть девайс на андроиде с OpenGL ES 3.0, можете попробовать запустить приложение. На GUI решил не тратить время, поэтому особых настроек нет, а управление осуществляется условными областями на экране:

    1. слайд вверх/вниз — приблизить/отдалить
    2. изменение буфера вывода (ssao, low ssao, ssao+color, color only)
    3. вкл/выкл размытие
    4. смена сценки

    свободная область экрана — вращение камеры


    Параметры я установил такие:
    • количество выборок = 8.
    • размер ssao и blur буферов в полтора раза меньше разрешения экрана.
    • количество выборок для уменьшенного ssao = 5.
    • размер уменьшенного ssao буфера в 16 раз меньше разрешения экрана.

    Исходный код — не забудьте поменять пути на свои
    apk — тестировалось на Note 3 n9000 (mali T62), Note 3 n9005 (adreno 330), Nexus 5 (adreno 330), HTC One (adreno 320).

    Ссылки


    Различные референсы, которые пригодились в этом проекте:
    Screen-Space Ambient Occlusion на steps3d. Несколько способов создания SSAO
    OpenGL ES 3.0 API Reference Card — краткий справочник по OpenGL ES 3.0 спекам
    Hemispherical Screen-Space Ambient Occlusion — один из способов реализации Hemispherical SSAO
    Stone Bridge 3d model — модель моста которую я использовал в демке
    john-chapman-graphics: SSAO Tutorial — на мой взгляд лучшая реализация Hemispherical SSAO
    SSAO | Game Rendering
    Attack of the depth buffer — различные представления z-буфера
    Линейная алгебра для разработчиков игр
    Know your SSAO artifacts — артефакты/косяки/неточности SSAO и способы их устранения
    Поделиться публикацией
    Похожие публикации
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 16
    • +4
      Поэтому если у нас буфер цвета и нормалей имеют формат RGB8, то и буфер глубины придется делать того же формата, а значения упаковывать (разделять) по трем компонентам.


      Не совсем верное утверждение. Необходимо условие: «одинаковая разрядность» всех RT в MRT. К примеру: RGBA8 — 32 бита на пиксель и Single — float-формат, один канал: 32 бита.
      • 0
        Действительно. Исправил, спасибо
      • 0
        Можно попробовать оптимизировать размытие используя субпиксельную выборку.
        • 0
          Вообще, при размытии SSAO-буффера необходимо учитывать глубину неоднородностей, просто так размывать нельзя.
          • 0
            Верно, но дополнительное чтение из буфера глубины во время размытия — это уже слишком для мобильного gpu, фпс и так не высок. Немного спасает то, что плотность пикселей на смартфонах и планшетах обычно довольно высокая, и косяки на границах объектов не так заметны.
        • 0
          Может стоит в самом начале указать, что у Вас версия Note 3 с mali
          • 0
            Указал, но у меня обе версии, хотя на видео n9000 (с mali)
          • 0
            Круто! Хорошая картинка!
            Математика (особенно в шейдерах) всегда пригодится
            • –1
              Кто-то минусит? У кого-то проблемы с 2+2?
            • +1
              Здорово.
              А под какой лицензией Вы опубликовали это? Как Вы относитесь к Creative Commons?
              • +1
                Да как то не особо заморачивался на этот счет, весь мой код используйте как хотите.
                • 0
                  Вот это и называется Creative Commons 2.0 (смайл)
                  Спасибо.
              • 0
                На Nexus 4 (Adreno 320, 1280x720) выглядит жутковато:
                dl.dropbox.com/u/12721305/Screenshots/Screenshot_2014-04-09-20-37-29.png
                Размытие ситуацию мало изменило
                • +1
                  Отличная статья! Позвольте немного критики:
                  Отдельно стоит сказать про z-буфер. Для использования в SSAO его нужно сначала сделать линейным, в противном случае на среднем и дальнем плане сильно теряется точность. Обычно для этого значения глубины пишут в отдельный буфер, часто вместе с нормалями. Однако дополнительный буфер не сулит ничего хорошего в плане производительности, поэтому линеаризовывать значения и записывать их мы будем не в отдельный буфер, а прямо в действующий, стандартный буфер глубины (gl_FragDepth). Это может вызвать артефакты на переднем плане (очень близком, практически вблизи передней плоскости отсечения), однако в основном такой буфер ведет себя вполне нормально.

                  Вот уж сэкономили…
                  1) Заслуженно получили артефакты (не только в SSAO, а во всей геометрии, особенно ближней к камере).
                  2) Лишились Early-Z при рисовании всей геометрии(!). В Вашей сцене перерисовки пикселов почти нет, так что это не сказывается, но в общем случае — это катастрофа.

                  Да и сам способ вычисления линейной глубины вызывает сомнения:
                  vec4 p = matrixProj*matrixView*vec4(vPos, 1.0);
                  gl_Position = p;
                  zPos = p.z;

                  Что по-Вашему есть «p.z»? Несомненно, это z-координата, но в каком пространстве? В проективном… а Вам нужно линейное. Правильно было бы так:
                  vec4 p = matrixView*vec4(vPos, 1.0);
                  zPos = p.z;
                  gl_Position = matrixProj*p;
                  • 0
                    Спасибо, учту в следующем проекте. Хочу попробовать сделать MSSAO, несколько эффектов и небольшой велосипедик.
                  • +1
                    Никогда не понимал смысла SSAO, выглядит ведь совсем не натурально: вместо теней получаются какие-то страшные черные контуры вокруг предметов. Рекомендую к прочтению: nothings.org/gamedev/ssao/

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.