Students Save 30%! Learn & create with unlimited courses & creative assets Students Save 30%! Save Now
Advertisement
  1. Game Development
  2. Shaders
Gamedevelopment

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

by
Difficulty:IntermediateLength:LongLanguages:
This post is part of a series called A Beginner's Guide to Coding Graphics Shaders.
A Beginner's Guide to Coding Graphics Shaders: Part 2

Russian (Pусский) translation by Marat Amerov (you can also view the original English article)

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

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

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

Цели

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

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

Вы можете форкнуть и редактировать это на CodePen.

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

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

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

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

Вы можете форкнуть и редактировать это на CodePen.

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

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

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

Но сначала давайте для разминки выполним следующую задачу:

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

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

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

ак как мы делим x и y на разные числа (ширину и высоту экрана), естественно, текстура растягивается.

Но что было бы, если бы мы просто поделили x и y из переменной gl_FragCoord на значение x res? Или, наоборот, на значение y res?

Для простоты эксперимента оставим всё как есть до конца урока. Но, так или иначе, очень важно понимать, что происходит в коде и почему.

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

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

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

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

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

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

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

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

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

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

Упс! Кажется, что-то не так с логикой движения света.

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

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

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

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

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

Вы можете форкнуть и редактировать это на CodePen.

Мы успешно задали параметры видимой части сцены. Это выглядит очень остро, хотя ....

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

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

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

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

Вы можете форкнуть и редактировать это на CodePen.

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

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

Когда мы смотрим на пиксель на краю круга, dist равен длине радиуса, поэтому мы заканчиваем тем, что умножаем color на 0, что является черным.

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

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

В приведенном выше сценарии вы ожидаете, что A будет наиболее освещенным, так как наш источник света находится прямо над головой, при этом тембры B и C темнеют, так как почти нет лучей света на боках.

Тем не менее, вот как ведет себя система освещения сейчас: 

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

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

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

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

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

Example normal map

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

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

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

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

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

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

Вы можете форкнуть и редактировать это на CodePen.

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

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

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

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

Чем меньше угол, тем ярче пиксель.

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

Теперь давайте реализуем это.

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

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

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

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

Вот как выглядит наш алгоритм:

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

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

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

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

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

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

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

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

Теперь для нормализации:

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

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

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

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

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

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

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

Вы можете форкнуть и редактировать это на CodePen.

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

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

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

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

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

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

Вот какой результат получится после вычитания 0.5 из значений x и y в векторе нормали:

Вы можете форкнуть и редактировать это на CodePen.

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

На это:

Теперь у вас есть рабочая модель освещения!

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

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

На такую:

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

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

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

Вы можете форкнуть и редактировать это на CodePen.

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

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

Ветвление

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

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

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

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

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

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

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

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

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

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

Ссылки

Текстура с камнями и карта нормалей, использованные в этом уроке, были взяты с сайта OpenGameArt:

http://opengameart.org/content/50-free-textures-4-normalmaps

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

Advertisement
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.