Pull to refresh
0
Plarium
Разработчик мобильных и браузерных игр

Введение в программирование шейдеров: часть 3

Reading time 12 min
Views 15K
Original author: Омар Шехата
Освоив азы работы с шейдерами, мы попытаемся на практике обуздать всю мощь GPU, создав систему реалистичного динамического освещения.



В первом уроке этой серии мы рассмотрели основы создания графических шейдеров. Во втором – изучили общий алгоритм действий при настройке шейдеров для любой платформы. Теперь пришло время разобраться с основными понятиями из области графических шейдеров без привязки к платформе. Для удобства в примерах мы всё еще будем использовать JavaScript/WebGL.

Прежде чем двигаться вперед, убедитесь, что вы выбрали самый удобный для вас способ работы с шейдерами. Самым простым вариантом будет JavaScript/WebGL, но я рекомендую попробовать силы на вашей любимой платформе.

Цели

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

Вот так будет выглядеть конечный результат (перейдите на CodePen, чтобы переключить освещение):



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

Отличным примером динамического освещения служит игра Chroma:



Приступаем: начальная сцена

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



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

var uniforms = {
  tex : {type:'t',value:texture},//The texture
  res : {type: 'v2',value:new THREE.Vector2(window.innerWidth,window.innerHeight)}//Keeps the resolution
}


Объявим переменные в GLSL-коде:

uniform sampler2D tex;
uniform vec2 res;
void main() {
    vec2 pixel = gl_FragCoord.xy / res.xy;
    vec4 color = texture2D(tex,pixel);
    gl_FragColor = color;
 }


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

Задача: Удастся ли вам отобразить текстуру, сохранив её пропорции? Постарайтесь сделать это самостоятельно, прежде чем переходить к решению ниже.

Причина, по которой текстура растягивается, вполне очевидна. Но вот небольшая подсказка: взгляните на строчку, в которой регулируются координаты:

vec2 pixel = gl_FragCoord.xy / res.xy;


Мы делим vec2 на vec2, что аналогично делению каждого компонента по отдельности. Иными словами, строчка выше эквивалентна следующим:

vec2 pixel = vec2(0.0,0.0);
pixel.x = gl_FragCoord.x / res.x;
pixel.y = gl_FragCoord.y / res.y;


Так как мы делим x и y на разные числа (ширину и высоту экрана), естественно, текстура растягивается.
Но что было бы, если бы мы просто поделили x и y из переменной gl_FragCoord на значение x res? Или, наоборот, на значение y res?
Для простоты эксперимента оставим всё как есть до конца урока. Но, так или иначе, очень важно понимать, что происходит в коде и почему.

Шаг 1. Добавим источник света

Прежде всего добавим источник света. Источник света – это не более чем точка, которую мы отправляем в шейдер. Создадим новую uniform-переменную для этой точки:

var uniforms = {
  //Add our light variable here
  light: {type:'v3', value:new THREE.Vector3()},
  tex : {type:'t',value:texture},//The texture
  res : {type: 'v2',value:new THREE.Vector2(window.innerWidth,window.innerHeight)}//Keeps the resolution
}


Мы создали вектор с тремя параметрами, так как будем использовать значения x и y для указания положения источника света на экране, а z – в качестве его радиуса.

Зададим значения источника света в JavaScript-коде:

uniforms.light.value.z = 0.2;//Our radius


Выставим радиус на 0.2, что соответствует 20 % от размера экрана. Впрочем, единицы измерения не играют особой роли, размер можно задавать и в пикселях. Это ни на что не влияет, пока дело не доходит до GLSL-кода.

Добавим слушатель событий в JavaScript-код для определения положения курсора мыши:

document.onmousemove = function(event){
    //Update the light source to follow our mouse
    uniforms.light.value.x = event.clientX; 
    uniforms.light.value.y = event.clientY; 
}


Теперь напишем код шейдера, чтобы источник света заработал. Начнем с простого: сделаем так, чтобы все пиксели в пределах радиуса света были видны, а все остальные были черного цвета.

В GLSL-коде это будет выглядеть примерно так:

uniform sampler2D tex;
uniform vec2 res;
uniform vec3 light;//Remember to declare the uniform here!
void main() {
    vec2 pixel = gl_FragCoord.xy / res.xy;
    vec4 color = texture2D(tex,pixel);
    //Distance of the current pixel from the light position
    float dist = distance(gl_FragCoord.xy,light.xy);
     
    if(light.z * res.x > dist){//Check if this pixel is without the range
      gl_FragColor = color;
    } else {
      gl_FragColor = vec4(0.0);
    }
}


Итак, вот что мы сделали:

• Объявили uniform-переменную для источника света.
• Использовали встроенную функцию distance для определения расстояния между источником света и данным пикселем.
• Проверили значение функции distance (в пикселях). Если оно больше 20 % ширины экрана, возвращаем цвет данного пикселя, если нет – возвращаем черный.


Посмотреть в действии — на CodePen.

Упс! Кажется, что-то не так с логикой движения света.
Задача: Сможете ли вы это исправить? Повторюсь: попробуйте сделать это самостоятельно, прежде чем смотреть ответ ниже.

Исправляем движение света

Как вы помните из первого урока, ось y здесь инвертирована. Наверное, вы собираетесь сделать следующее:

light.y = res.y - light.y;


Это верно с математической точки зрения, но так шейдер не скомпилируется. Дело в том, что uniform-переменные нельзя изменять. Вспомните: этот код параллельно выполняется для каждого пикселя. Представьте, как все ядра процессора одновременно пытаются изменить одну-единственную переменную. Нехорошо!

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


На CodePen можно создать ответвление исходного кода и отредактировать его.

uniforms.light.value.y = window.innerHeight - event.clientY;


Мы успешно задали параметры видимой части сцены. Теперь не помешало бы немного сгладить края этой области.

Добавляем градиент

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

Вместо того чтобы возвращать всей видимой области цвет текстуры, как здесь:

gl_FragColor = color;


Мы можем просто умножить цвет на коэффициент расстояния:

gl_FragColor = color * (1.0 - dist/(light.z * res.x));




Это работает, потому что dist – это расстояние в пикселях между данным пикселем и источником света. Член выражения (light.z * res.x) – это длина радиуса. Поэтому, когда мы смотрим на пиксель, который приходится как раз на источник света, dist равно 0. В итоге мы умножаем color на 1, что соответствует полному цвету пикселя.



На данном рисунке dist рассчитывается для произвольного пикселя. Значение dist варьируется в зависимости от того, на каком пикселе мы находимся, в то время как значение light.z * res.x – постоянное.

У пикселя, который находится на краю круга, dist равно радиусу круга, поэтому мы умножаем color на 0, что соответствует черному цвету.

Шаг 2. Добавляем глубину

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



В этом случае предполагается, что участок А будет самым освещенным, поскольку находится прямо под источником света, а участки В и С будут темными, потому что на них практически не попадает свет.
Тем не менее, вот как ведет себя система освещения сейчас:



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



Участок А – верхушка блока, а В и С – его боковые стороны. D – участок поверхности рядом с блоком. Как мы видим, участки A и D должны быть самыми светлыми, но D будет немного темнее, так как свет падает на него под углом. В и С, в свою очередь, будут очень темными, поскольку свет практически не попадает на них.

Выходит, нужно знать скорее не высоту, а направление лицевой стороны поверхности. Это называется нормалью к поверхности.
Но как передать эти данные в шейдер? Мы можем отправить гигантский массив с тысячами чисел для каждого отдельного пикселя, не так ли? На самом деле, именно это мы и делаем! Только в роли массива выступает текстура.

Это и есть карта нормалей – изображение, где значения r, g, b для каждого пикселя указывают направление вместо цвета.



Выше представлена простая карта нормалей. Если взять палитру цветов, можно увидеть, что стандартное «плоское» направление соответствует цвету (0.5, 0.5, 1) – то есть голубому цвету, занимающему большую часть изображения. Пиксели голубого цвета смотрят прямо вверх. Все значения r, g, b для каждого пикселя переводятся в значения x, y, z.

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

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

Итак, загрузим простую карту нормалей:

var normalURL = "https://raw.githubusercontent.com/tutsplus/Beginners-Guide-to-Shaders/master/Part3/normal_maps/normal_test.jpg"
var normal = THREE.ImageUtils.loadTexture(normalURL);


И добавим её как одну из uniform-переменных:

var uniforms = {
  norm: {type:'t', value:normal},
  //.. the rest of our stuff here
}


Чтобы проверить, что всё загрузилось правильно, давайте отрендерим карту нормалей вместо текстуры, предварительно немного подправив GLSL-код (учтите, пока мы используем её как текстуру фона, а не как карту нормалей):



Шаг 3. Применение модели освещения

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

Самой простым вариантом будет модель освещения Фонга (Phong model). Допустим, есть такая поверхность с данными нормалей:



Мы можем просто рассчитать угол между источником света и нормалью к поверхности:



Чем меньше угол, тем ярче пиксель.
То есть пиксели, которые находятся непосредственно под источником света, где угол равен нулю, будут самыми яркими. А самыми темными будут те пиксели, которые расположены на обратной стороне объекта.
Теперь, так как мы используем простую карту нормалей для проверки, применим к текстуре сплошной цвет. Тогда мы точно увидим, работает ли система освещения.

Вместо этого:

vec4 color = texture2D(...);


Сделаем сплошной белый цвет (или любой другой на ваше усмотрение):

vec4 color = vec4(1.0); //solid white


Данное GLSL-сокращение нужно для создания vec4 со всеми компонентами, равными 1.0.

Вот как выглядит алгоритм действий:

1. Получаем вектор нормали в данном пикселе.
2. Получаем вектор направления света.
3. Нормализуем векторы.
4. Считаем угол между векторами.
5. Умножаем окончательный цвет на этот коэффициент.

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

vec3 NormalVector = texture2D(norm,pixel).xyz;


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

2. Получаем вектор направления света

Теперь нам нужно узнать, куда направлен источник света. Представим его в виде фонарика, зависшего напротив экрана в том месте, где находится курсор мышки. Мы можем определить вектор направления света, используя расстояние между источником света и данным пикселем:

vec3 LightVector = vec3(light.x - gl_FragCoord.x,light.y - gl_FragCoord.y,60.0);


Нам также понадобится координата z, чтобы определить угол к трехмерному вектору нормали. Попробуйте поменять её значение, и вы увидите: чем оно меньше, тем резче контраст между темными и яркими участками. Воспринимайте её как высоту, на которой вы держите фонарик над сценой: чем он дальше, тем равномернее распределяется свет.

3. Нормализуем векторы

NormalVector = normalize(NormalVector);
LightVector = normalize(LightVector);


Мы используем встроенную функцию normalize, чтобы убедиться, что длина обоих векторов равна 1.0. Это необходимо, потому что нам нужно рассчитывать угол, используя скалярное произведение (dot product). Если вам не совсем понятно, как это работает, самое время подтянуть свои знания линейной алгебры. Но в данном случае нам нужно знать только то, что скалярное произведение вернет косинус угла между двумя векторами одной длины.

4. Считаем угол между векторами

Для этого нам пригодится встроенная функция dot:

float diffuse = dot( NormalVector, LightVector );


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

5. Умножаем окончательный цвет на этот коэффициент

Вот и всё! Теперь просто умножьте цвет на коэффициент. Я пошел дальше и создал переменную distanceFactor, чтобы улучшить читабельность уравнения:

float distanceFactor = (1.0 - dist/(light.z * res.x));
gl_FragColor = color * diffuse * distanceFactor;


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



Хмм, что-то сработало не так. Кажется, сместился центр источника света.

Давайте еще раз проверим вычисления. Есть вектор:

vec3 LightVector = vec3(light.x - gl_FragCoord.x,light.y - gl_FragCoord.y,60.0);


Который, как мы знаем, вернет (0, 0, 60), если свет падает прямо на этот пиксель. После того как мы нормализуем его, получится (0, 0, 1).
Помните: чтобы получить максимальную яркость, нужна нормаль, которая указывает прямо на источник света. Значение нормали к поверхности, указывающей прямо вверх, по умолчанию составляет (0.5, 0.5, 1).

Задача: Видите ли вы решение проблемы? Сможете исправить?

Дело в том, что в значениях цветов текстуры нельзя хранить отрицательные числа. К примеру, вы не можете задать вектору, указывающему влево, значения (-0.5, 0, 0). Поэтому создатели карт нормалей должны прибавлять 0.5 к каждому значению – то есть сдвигать систему координат. Имейте в виду, что перед использованием карты нужно вычесть эти 0.5 из каждого пикселя.
Вот какой результат получится после вычитания 0.5 из значений x и y в векторе нормали:



Осталось исправить только одну вещь. Так как скалярное произведение возвращает косинус угла, полученное значение может быть от -1 до 1. Но нам не нужны отрицательные значения цветов. И хотя WebGL автоматически отклоняет отрицательные значения, где-нибудь в другом месте всё же может возникнуть проблема. Воспользуемся встроенной функцией max и изменим это:

float diffuse = dot( NormalVector, LightVector );


На это:

float diffuse = max(dot( NormalVector, LightVector ),0.0);


Теперь у вас есть рабочая модель освещения!
Можете вернуть обратно текстуру с камнями. Карта нормалей для нее доступна в репозитории на GitHub (или по прямой ссылке здесь).

Нужно только исправить эту ссылку в JavaScript-коде:

var normalURL = "https://raw.githubusercontent.com/tutsplus/Beginners-Guide-to-Shaders/master/Part3/normal_maps/normal_test.jpg"


На такую:

var normalURL = "https://raw.githubusercontent.com/tutsplus/Beginners-Guide-to-Shaders/master/Part3/normal_maps/blocks_normal.JPG"


И вот эту строчку GLSL-кода:

vec4 color = vec4(1.0);//solid white


Заменив в ней сплошной белый цвет на текстуру:

vec4 color = texture2D(tex,pixel);


Наконец, вот результат:

prntscr.com/auy52h
На CodePen можно создать ответвление исходного кода и отредактировать его.

Советы по оптимизации

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

Ветвление
При работе с шейдерами лучше избегать ветвления там, где это возможно. При работе с кодом для CPU вам редко приходится переживать насчет множества операторов if. Но для GPU они могут стать серьезной помехой.

Чтобы понять причину, вспомним еще раз, что GLSL-код выполняется параллельно для каждого отдельного пикселя на экране. Работа видеокарты может быть значительно оптимизирована при условии, что все пиксели требуют выполнения одинаковых операций. Если же в коде есть множество операторов if, оптимизация может снизиться, так как разные пиксели будут требовать выполнения разного кода. Конечно, это зависит от характеристики тех или иных комплектующих, а также от особенностей использования видеокарты. Но об этом полезно помнить для ускорения работы шейдера.

Отложенный рендеринг

Это очень полезный прием для работы с освещением. Представьте, если бы нам захотелось сделать не 1 источник света, а 2, 3 или даже 10. Нам пришлось бы рассчитывать угол между каждой нормалью к поверхности и каждым источником света. Это бы очень быстро уменьшило скорость работы шейдера. Отложенный рендеринг помогает исправить это путем разделения работы шейдера на множество ходов. Эта статья рассматривает данный вопрос во всех подробностях. Здесь я приведу только фрагмент по теме этого урока:

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

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

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

Дальнейшие шаги

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

• Поменяйте высоту (значение z) вектора света, чтобы увидеть, какой это даст эффект.
• Поменяйте интенсивность света. Это можно сделать, умножив diffuse-составляющую на коэффициент.
• Добавьте ambient-составляющую в уравнение по расчету света. Это подразумевает добавление в сцену минимального (начального) освещения для создания большей реалистичности. В реальной жизни не бывает абсолютно темных предметов, потому что минимальное количество света всё равно попадает на любую поверхность.
• Попробуйте создать какой-нибудь из шейдеров, рассмотренных в этом уроке по WebGL. Они создаются на основе Babylon.js, а не Three.js, но можно сразу перейти к настройкам GLSL. В частности, вас могут заинтересовать cel shading и Phong shading.
• Ознакомьтесь с интересными работами на сайтах GLSL Sandbox и ShaderToy

Ссылки
Текстура с камнями и карта нормалей, использованные в этом уроке, были взяты с сайта OpenGameArt. Помимо прочего, на нём доступно множество программ по созданию карт нормалей. Если вы хотите узнать больше о том, как создаются карты нормалей, смотрите также эту статью.
Tags:
Hubs:
+16
Comments 3
Comments Comments 3

Articles

Information

Website
company.plarium.com
Registered
Founded
2009
Employees
1,001–5,000 employees
Location
Израиль