Иногда программирование шейдеров представляется загадочной чёрной магией и его часто понимают неправильно. Существует множество примеров кода, демонстрирующих создание невероятных эффектов, но в которых практически нет объяснений. В своём руководстве я хочу восполнить этот пробел. Я сосредоточусь в основном на базовых принципах создания и понимания кода шейдеров, чтобы вы смогли с лёгкостью настраивать, сочетать или писать свои собственные шейдеры с нуля!

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

Что же такое шейдер?

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

Шейдеры пишут на специальном языке шейдеров. Не волнуйтесь, вам не придётся изучать совершенно новый язык: мы будем использовать GLSL (OpenGL Shading Language), который похож на C. (Существует несколько языков написания шейдеров для разных платформ, но поскольку все они адаптированы под выполнение в видеопроцессоре, то похожи друг на друга.)

Примечание: эта статья посвящена исключительно фрагментным шейдерам (fragment shader). Если вам любопытно, какие ещё виды шейдеров бывают, то можно почитать о различных этапах графического конвейера в OpenGL Wiki.

Приступаем!

В этом туториале мы будем использовать ShaderToy . Он позволяет нам начать программировать шейдеры прямо в браузере, без возни с установкой и настройкой! (Для рендеринга он использует WebGL, поэтому требуется браузер с поддержкой этой технологии.) Создавать учётную запись не обязательно, но удобно для сохранения кода.

Примечание: на момент написания статьи ShaderToy находился в состоянии беты [прим. пер.: статья написана в 2015 году] . Некоторые детали интерфейса/синтаксиса могут немного отличаться.


Можете на CodePen.

Шаг 2: применение шейдера

Способы применения шейдера могут отличаться, они зависят от используемой платформы и её интерфейса с видеопроцессором. С этим никогда не возникает трудностей, и быстрым поиском можно выяснить, как создать объект и применить к нему шейдеры с помощью Three.js .

Нам нужно создать специальный материал и передать его коду шейдера. В качестве объекта шейдера мы создадим плоскость (но можем использовать и куб). Вот что нужно сделать:

// Создаём объект, к которому нужно применить шейдер var material = new THREE.ShaderMaterial({fragmentShader:shaderCode}) var geometry = new THREE.PlaneGeometry(10, 10); var sprite = new THREE.Mesh(geometry,material); scene.add(sprite); sprite.position.z = -1;// Перемещаем его назад, чтобы его было видно
На этом этапе вы должны видеть белый экран:


Можете создать форк и отредактировать проект на CodePen.

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

Задача: Сможете ли вы сделать одну часть экрана красной, а другую синей? (Если не получится, то на следующем шаге я дам подсказку!)

Шаг 3: передача данных

На этот момент мы уже можем делать с шейдером всё, что угодно, но пока не знаем, что можно сделать. У нас есть только встроенная возможность определения положения пикселя gl_FragCoord и, если вы помните, это положение нормализовано. Нам как минимум нужно знать размеры экрана.

Для передачи данных шейдеру нам нужно отправить то, что мы назвали uniform -переменной. Для этого мы создаём объект uniforms и добавляем к нему наши переменные. Вот синтаксис передачи разрешения экрана:

Var uniforms = {}; uniforms.resolution = {type:"v2",value:new THREE.Vector2(window.innerWidth,window.innerHeight)};
Каждая uniform-переменная дожна иметь тип и значение. В данном случае это двухмерный вектор, координатами которого являются ширина и высота окна. В таблице ниже (взятой из документации Three.js) представлены все типы данных, которые можно отправлять, вместе с их идентификаторами:

Строка типа Uniform Тип GLSL Тип JavaScript
"i", "1i"
int
Number
"f", "1f" float
Number
"v2"
vec2
THREE.Vector2
"v3"
vec3
THREE.Vector3
"c" vec3
THREE.Color
"v4" vec4
THREE.Vector4
"m3" mat3
THREE.Matrix3
"m4" mat4
THREE.Matrix4
"t" sampler2D
THREE.Texture
"t" samplerCube
THREE.CubeTexture
Чтобы отправить его шейдеру, изменим формирователь экземпляров ShaderMaterial , добавив туда вектор:

Var material = new THREE.ShaderMaterial({uniforms:uniforms,fragmentShader:shaderCode})
Мы ещё не закончили! Теперь, когда шейдер получает эту переменную, нам нужно с ней что-то сделать. Давайте создадим градиент, как мы делали это раньше: нормализовав координату и используя её для создания значения цвета.

Изменим код шейдера следующим образом:

Uniform vec2 resolution;// Здесь сначала должны быть объявлены uniform-переменные void main() { // Теперь можно нормализовать координату vec2 pos = gl_FragCoord.xy / resolution.xy; // И создать градиент! gl_FragColor = vec4(1.0,pos.x,pos.y,1.0); }
В результате у нас получится красивый градиент!


Можете создать форк и отредактировать проект на CodePen.

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

Шаг 4: обновление данных

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

Для обновления переменных обычно просто заново отправляют uniform-переменную. Однако в Three.js достаточно просто обновить объект uniforms в функции render , повторно отправлять данные шейдеру не требуется.

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

Function render() { cube.rotation.y += 0.02; uniforms.resolution.value.x = window.innerWidth; uniforms.resolution.value.y = window.innerHeight; requestAnimationFrame(render); renderer.render(scene, camera); }
Если открыть новый CodePen и изменить размер окна, то вы увидите, как изменяются цвета, несмотря на то, что изначальный размер окна просмотра остался тем же). Проще всего это заметить, посмотрев на цвета в углах и убедившись, что они не меняются.

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

Задача: Попробуйте постепенно изменять цвета.

Шаг 5: работа с текстурами

Вне зависимости от способа загрузки и формата текстур на всех платформах они передаются в шейдер как uniform-переменные.

Небольшое примечание о загрузке файлов в JavaScript: можно без проблем загружать изображения с внешнего URL (именно так мы и будем делать). Однако если вы захотите загрузить изображение локально, то возникнут проблемы с разрешениями, потому что JavaScript обычно не может и не должен иметь доступа к файлам в системе. Простейший способ решения - запустить локальный Python-сервер , что на самом деле проще, чем кажется.

В Three.js есть небольшая удобная функция для загрузки изображения как текстуры:

THREE.ImageUtils.crossOrigin = "";// Позволяет загружать внешнее изображение var tex = THREE.ImageUtils.loadTexture("https://tutsplus.github.io/Beginners-Guide-to-Shaders/Part2/SIPI_Jelly_Beans.jpg");
Первая строка задаётся только один раз. В неё можно вставить любой URL изображения.

Затем нам нужно добавить текстуру к объекту uniforms .

Uniforms.texture = {type:"t",value:tex};
И, наконец, нам нужно объявить uniform-переменную в коде шейдера, а потом отрисовать её тем же способом, как мы делали ранее - с помощью функции texture2D:

Uniform vec2 resolution; uniform sampler2D texture; void main() { vec2 pos = gl_FragCoord.xy / resolution.xy; gl_FragColor = texture2D(texture,pos); }
На экране должно появиться растянутое изображение конфет:

(Это изображение является стандартным тестовым изображением в компьютерной графике, оно взято у (поэтому на нём показана аббревиатура IPI) Университета Южной Калифорнии. Думаю, оно нам подходит, ведь мы как раз изучаем графические шейдеры!)

Задача: Попробуйте постепенно менять цвета текстуры с полного цвета на градации серого.

Дополнительный шаг: применяем шейдеры к другим объектам

В созданной нами плоскости нет ничего особенного. Мы могли применить шейдер и к кубу. На самом деле можно просто заменить строку с геометрией плоскости:

Var geometry = new THREE.PlaneGeometry(10, 10);
на:

Var geometry = new THREE.BoxGeometry(1, 1, 1);
Вуаля, конфеты нарисованы на кубе:


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

Следующие шаги

На этом этапе мы уже можем делать всё, что сделали в ShaderToy, однако теперь мы способны использовать любые текстуры и любые объекты, на любой платформе.

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

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

Начиная с этого момента мы будем рассматривать общие концепции графических шейдеров без привязки к конкретной платформе. (Для удобства во всех примерах кода по-прежнему будет использоваться JavaScript/WebGL.)

Для начала найдите подходящий вам способ выполнения шейдеров. (JavaScript/WebGL - это простейший способ, но я рекомендую вам поэкспериментировать со своей любимой платформой!)

Цели

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

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


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

Отличным примером этого является Chroma . Игрок может бегать по динамическим теням, создаваемым в реальном времени:

Приступаем к работе: наша исходная сцена

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

Var uniforms = { tex: {type:"t",value:texture},// Текстура res: {type: "v2",value:new THREE.Vector2(window.innerWidth,window.innerHeight)}// Хранит разрешение }
В коде на GLSL мы объявляем и используем эти uniform-переменные:

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?

Шаг 1: добавление источника света

Прежде чем создать что-то интересное, нам нужен источник света. «Источник света» - это просто точка, передаваемая шейдеру. Для этой точки мы создадим новую uniform:

Var uniforms = { //Добавляем переменную источника света light: {type:"v3", value:new THREE.Vector3()}, tex: {type:"t",value:texture},// Текстура res: {type: "v2",value:new THREE.Vector2(window.innerWidth,window.innerHeight)}// Хранит разрешение }
Мы создали вектор с тремя измерениями, потому что мы хотим использовать x и y в качестве положения источника на экране, а z - в качестве радиуса .

Давайте присвоим в JavaScript значения нашему источнику света:

Uniforms.light.value.z = 0.2;// Радиус
Мы будем использовать радиус как процент от размеров экрана, поэтому 0.2 будет составлять 20% экрана. (В этом выборе нет ничего особенного. Мы могли бы задать размер в пикселях. Это число ничего не значит, пока мы не начнём с ним делать что-нибудь в коде GLSL.)

Чтобы получить положение мыши, нужно просто добавить получатель события (event listener):

Document.onmousemove = function(event){ // Обновляем источник света, чтобы он следовал за мышью uniforms.light.value.x = event.clientX; uniforms.light.value.y = event.clientY; }
Давайте теперь напишем код шейдера, чтобы воспользоваться этой координатой источника света. Начнём с простой задачи: сделаем так, чтобы каждый пиксель в пределах радиуса источника света был видимым, а остальные были чёрными .

На GLSL это может выглядеть примерно так:

Uniform sampler2D tex; uniform vec2 res; uniform vec3 light;// Не забывайте объявлять здесь uniform! void main() { vec2 pixel = gl_FragCoord.xy / res.xy; vec4 color = texture2D(tex,pixel); // Расстояние от текущего пикселя до источника света float dist = distance(gl_FragCoord.xy,light.xy); if(light.z * res.x > dist){// Проверяем, находится ли пиксель внутри радиуса gl_FragColor = color; } else { gl_FragColor = vec4(0.0); } }
Здесь мы сделали следующее:

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

Ой-ёй! Источник света следует за мышью как-то странно.

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

Исправление движения источника света

Возможно, вы помните, что ось Y здесь перевёрнута. Вы можете поторопиться просто ввести:

Light.y = res.y - light.y;
Это математически верно, но если поступить так, то шейдер не скомпилируется! Проблема в том, что uniform-переменные невозмжоно изменять . Чтобы понять, почему, нужно помнить, что этот код выполняется для каждого отдельного пикселя параллельно . Представьте, что все процессорные ядра попытаются изменить единственную переменную одновременно. Плохая ситуация!

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


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: добавляем глубину

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

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

Однако вот что видит наша система освещения сейчас:

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

A находится наверху фигуры, а B и C - по бокам. D - это ещё одна точка на земле. Мы видим, что A и D должны быть самыми яркими, причём D немного темнее, потому что свет достигает её под углом. С другой стороны, B и C должны быть очень тёмными, потому что до них почти не доходит свет, ведь они направлены от источника света.

Не так важна высота, как направление, в котором повёрнута поверхность . Оно называется нормалью поверхности .

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

Именно это делает карта нормалей: она просто является изображением, в котором значения r , g и b каждого пикселя представляют не цвет, а направление.

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

Наклонная сторона справа повёрнута вправо, поэтому её значение x выше. Значение 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}, //.. делаем всё остальное }
Чтобы проверить, что мы загрузили её правильно, давайте попробуем отрендерить её вместо текстуры, изменив код на GLSL (помните, что мы на этом этапе используем просто фоновую текстуру, а не карту нормалей):

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

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

Простейшей для реализации моделью является модель Фонга . Вот как она работает: пусть у нас есть поверхность с данными о нормалях:

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

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

Это значит, что когда пиксель находится непосредственно под источником света, где разность углов равна 0, он будет самым ярким. Самые тёмные пиксели будут указывать в том же направлении, что и источник света (это будет похоже на заднюю часть объекта).

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

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

Поэтому вместо:

Vec4 color = texture2D(...);
Давайте сделаем сплошной белый цвет (или любой другой цвет):

Vec4 color = vec4(1.0); // белый цвет
Это сокращение GLSL для создания vec4 со всеми компонентами, равными 1.0 .

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

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

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

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

Vec3 NormalVector = texture2D(norm,pixel).xyz;
Поскольку альфа-значение ничего не обозначает на карте нормалей, нам требуются только первые три компонента.

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

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


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

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

Теперь нам нужно нормализировать:

NormalVector = normalize(NormalVector); LightVector = normalize(LightVector);
Чтобы оба вектора имели длину 1.0 , мы воспользуемся встроенной функцией normalize . Это необходимо, потому что мы хотим вычислить угол с помощью скалярного произведения . Если вы не очень понимаете, как оно работает, то стоит немного изучить линейную алгебру. Для наших целей нам нужно знать только, что скалярное произведение возвращает косинус угла между векторами одинаковой длины .

4. Вычисляем угол между векторами

Давайте сделаем это с помощью встроенной функции dot :


Я назвал переменную 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);
И у нас получилась работающая модель освещения!

Можно поставить на фон каменную текстуру, а настоящую карту нормалей взять в (а именно ):

Нам нужно только изменить одну строку на 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);// белый цвет
Нам больше не нужен сплошной белый цвет, мы загрузим настоящую текстуру, вот так:

Vec4 color = texture2D(tex,pixel);
И вот окончательный результат:

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

Ветвление

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

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

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

Это очень полезная концепция при работе с освещением. Представьте, что нам нужны два источника освещения, или три, или десяток. Нам придётся вычислять угол между каждой нормалью поверхности и каждой точкой источника света. В результате шейдер будет выполняться с черепашьей скоростью. Отложенный рендеринг - это способ оптимизации такого процесса разбиением работы шейдера на несколько проходов. , в которой подробно объяснено, что это значит. Я процитирую важную для нас часть:
Освещение - это основная причина выбора того или иного пути. В стандартном прямолинейном конвейере рендеринга вычисления освещения должны выполняться для каждой вершины и каждого фрагмента видимой сцены для каждого источника света в сцене .

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

Способность разделить работу на несколько проходов - это очень полезная техника при создании шейдеров. Например, она используется для ускорения шейдера при вычислении эффекта размывки, а также в шейдерах жидкостей/дыма.

Следующие шаги

Теперь, когда вы получили работающий шейдер освещения, вот с чем ещё можно поэкспериментировать:
  • Попробуйте изменять высоту (значение z) вектора освещения, чтобы понаблюдать за его воздействием
  • Поэкспериментируйте с интенсивностью освещения. (Можно сделать это, умножая значение diffuse на коэффициент.)
  • Добавьте в уравнение вычисления освещения значение ambient (окружающего освещения). (Это значит, что мы присваиваем минимальное значение, то есть даже в тёмных областях цвет не будет полностью чёрным. Такое освещение позволяет сделать сцену реалистичной, потому что объекты в реальной жизни всегда освещены, даже если на них не падает прямой свет)
  • opengl
  • glsl
  • webgl
  • освещение
Добавить метки

С глобальной компьютеризацией в наш мир пришло огромное количество непонятных терминов. Разбираться с ними всеми не так-то просто, как кажется на первый взгляд. Многие из них похожи названиями, многие имеют широкий функционал. Пришло время узнать, что такое шейдер, откуда он взялся, для чего нужен и какой бывает.

Оптимизатор

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

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

Толкование

Что такое шейдер? которую исполняют процессоры видеокарты. Эти инструменты разрабатывают на специальном языке. В зависимости от назначений он может быть разным. После чего шейдеры условно транслируются в инструкции к процессорам графических ускорителей.

Применение

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

Предпосылка

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

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

Пример

Чтобы осознать разницу, стоит рассмотреть пару примеров. Очевидно, что в игре рендеринг мог быть аппаратным и программным. К примеру, все мы помним знаменитую Quake 2. Так вот, вода в игре могла быть просто синим фильтром, если речь идет об аппаратном рендеринге. А вот при программном вмешательстве появился плеск воды. Та же история и со в CS 1.6. Аппаратный рендеринг давал только белую вспышку, а программный добавлял пикселизированный экран.

Доступ

Так стало понятно, что необходимо решать подобные проблемы. Графические ускорители стали расширять количество алгоритмов, которые были популярными среди разработчиков. Стало понятно, что все «запихнуть» невозможно. Нужно было открыть доступ специалистам к видеокарте.

Прежде чем появились игры типа «Майнкрафт» с модами и шейдерами, разработчикам дали возможность работать с блоками GPU в конвейеры, которые могли бы отвечать за разные инструкции. Так стали известны программы с названием «шейдер». Для их создания специально разработали языки программирования. Так, видеокарты стали нагружать не только стандартной «геометрией», но и инструкцией для процессора.

Когда такой доступ стал возможным, стали открываться новые возможности программирования. Специалисты могли решать математические задачки на GPU. Такие расчеты стали называться GPGPU. Для этого процесса нужны были специальные инструменты. От компании nVidia CUDA, от Microsoft DirectCompute, а также фреймворк OpenCL.

Типы

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

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

Геометрический тип работал не просто с одной вершиной, но и с целым примитивом. Пиксельный был рассчитан на обработку фрагментов растровых иллюстраций и в целом с текстурами.

В играх

Если вы ищете шейдеры для «Майнкрафт 1.5.2», то вы, скорее всего, просто хотите улучшить картинку в игре. Чтобы такое стало возможным, программы прошли «огонь, воду и медные трубы». Шейдеры тестировали и дорабатывали. В итоге стало понятным, что у этого инструмента есть достоинства и недостатки.

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

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

Установка

Если вы нашли шейдер пак для «Майнкрафта», нужно понимать, что существует много подводных камней в его установке. Несмотря на уже угасающую популярность этой игры, все же остаются её преданные поклонники. Не всем нравится графика, тем более в 2017 год. Некоторые считают, что благодаря шейдерам они смогут её улучшить. Теоретически это утверждение правильное. Но на практике вы измените немногое.

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

В интернете много мест, где можно установить и скачать такой инструмент. Дальше нужно распаковать архив в любую папку. Там вы найдете файлик «GLSL-Shaders-Mod-1.7-Installer.jar». После запуска вам будет указан путь к игре, если он верный, то соглашайтесь со всеми последующими указаниями.

После нужно переместить папку «shaderpacks» в «.minecraft». Теперь при запуске лаунчера нужно будет зайти в настройки. Тут, если установка прошла корректно, появится строка «Shaders». Из всего списка можете выбрать нужный пакет.

Если вам нужны шейдеры для Minecraft 1.7.10, то просто находите шейдерпак нужной версии и проделываете то же самое. В интернете могут встречаться нестабильные версии. Иногда приходится их менять, переустанавливать и искать подходящий. Лучше смотреть на отзывы и выбирать наиболее популярные.

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

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

Сцена из Minecraft: до (сверху) и после (внизу) применения шейдеров.

Цели данного урока

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

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

Итак, что же такое вообще шейдер?

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

Шейдеры пишутся на особом языке программирования. Однако не стоит волноваться. Вам не придется учить с нуля совершенно новый язык, поскольку для написания шейдеров мы будем использовать GLSL, синтаксис которого очень похож на другие C-подобные языки. (На самом деле, существует достаточно большое количество языков для написания шейдеров , однако, ввиду того, что все они предназначены для выполнения на GPU, они все практически одинаковые)

Итак, приступим!

Для данного урока мы будем использовать ShaderToy . Этот ресурс позволяет писать шейдеры прямо в вашем браузере, не утруждая себя никакими дополнительными настройками. (Однако для использования ShaderToy вам понадобится браузер с поддержкой WebGL, поскольку именно он используется для рендеринга.) Регистрация не обязательна, однако она позволит сохранить свой код на будущее в профиле.

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

Иногда программирование шейдеров представляется загадочной чёрной магией и его часто понимают неправильно. Существует множество примеров кода, демонстрирующих создание невероятных эффектов, но в которых практически нет объяснений. В своём руководстве я хочу восполнить этот пробел. Я сосредоточусь в основном на базовых принципах создания и понимания кода шейдеров, чтобы вы смогли с лёгкостью настраивать, сочетать или писать свои собственные шейдеры с нуля!

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

Что же такое шейдер?

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

Шейдеры пишут на специальном языке шейдеров. Не волнуйтесь, вам не придётся изучать совершенно новый язык: мы будем использовать GLSL (OpenGL Shading Language), который похож на C. (Существует несколько языков написания шейдеров для разных платформ, но поскольку все они адаптированы под выполнение в видеопроцессоре, то похожи друг на друга.)

Примечание: эта статья посвящена исключительно фрагментным шейдерам (fragment shader). Если вам любопытно, какие ещё виды шейдеров бывают, то можно почитать о различных этапах графического конвейера в OpenGL Wiki.

Приступаем!

В этом туториале мы будем использовать ShaderToy . Он позволяет нам начать программировать шейдеры прямо в браузере, без возни с установкой и настройкой! (Для рендеринга он использует WebGL, поэтому требуется браузер с поддержкой этой технологии.) Создавать учётную запись не обязательно, но удобно для сохранения кода.

Примечание: на момент написания статьи ShaderToy находился в состоянии беты [прим. пер.: статья написана в 2015 году] . Некоторые детали интерфейса/синтаксиса могут немного отличаться.


Можете на CodePen.

Шаг 2: применение шейдера

Способы применения шейдера могут отличаться, они зависят от используемой платформы и её интерфейса с видеопроцессором. С этим никогда не возникает трудностей, и быстрым поиском можно выяснить, как создать объект и применить к нему шейдеры с помощью Three.js .

Нам нужно создать специальный материал и передать его коду шейдера. В качестве объекта шейдера мы создадим плоскость (но можем использовать и куб). Вот что нужно сделать:

// Создаём объект, к которому нужно применить шейдер var material = new THREE.ShaderMaterial({fragmentShader:shaderCode}) var geometry = new THREE.PlaneGeometry(10, 10); var sprite = new THREE.Mesh(geometry,material); scene.add(sprite); sprite.position.z = -1;// Перемещаем его назад, чтобы его было видно
На этом этапе вы должны видеть белый экран:


Можете создать форк и отредактировать проект на CodePen.

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

Задача: Сможете ли вы сделать одну часть экрана красной, а другую синей? (Если не получится, то на следующем шаге я дам подсказку!)

Шаг 3: передача данных

На этот момент мы уже можем делать с шейдером всё, что угодно, но пока не знаем, что можно сделать. У нас есть только встроенная возможность определения положения пикселя gl_FragCoord и, если вы помните, это положение нормализовано. Нам как минимум нужно знать размеры экрана.

Для передачи данных шейдеру нам нужно отправить то, что мы назвали uniform -переменной. Для этого мы создаём объект uniforms и добавляем к нему наши переменные. Вот синтаксис передачи разрешения экрана:

Var uniforms = {}; uniforms.resolution = {type:"v2",value:new THREE.Vector2(window.innerWidth,window.innerHeight)};
Каждая uniform-переменная дожна иметь тип и значение. В данном случае это двухмерный вектор, координатами которого являются ширина и высота окна. В таблице ниже (взятой из документации Three.js) представлены все типы данных, которые можно отправлять, вместе с их идентификаторами:

Строка типа Uniform Тип GLSL Тип JavaScript
"i", "1i"
int
Number
"f", "1f" float
Number
"v2"
vec2
THREE.Vector2
"v3"
vec3
THREE.Vector3
"c" vec3
THREE.Color
"v4" vec4
THREE.Vector4
"m3" mat3
THREE.Matrix3
"m4" mat4
THREE.Matrix4
"t" sampler2D
THREE.Texture
"t" samplerCube
THREE.CubeTexture
Чтобы отправить его шейдеру, изменим формирователь экземпляров ShaderMaterial , добавив туда вектор:

Var material = new THREE.ShaderMaterial({uniforms:uniforms,fragmentShader:shaderCode})
Мы ещё не закончили! Теперь, когда шейдер получает эту переменную, нам нужно с ней что-то сделать. Давайте создадим градиент, как мы делали это раньше: нормализовав координату и используя её для создания значения цвета.

Изменим код шейдера следующим образом:

Uniform vec2 resolution;// Здесь сначала должны быть объявлены uniform-переменные void main() { // Теперь можно нормализовать координату vec2 pos = gl_FragCoord.xy / resolution.xy; // И создать градиент! gl_FragColor = vec4(1.0,pos.x,pos.y,1.0); }
В результате у нас получится красивый градиент!


Можете создать форк и отредактировать проект на CodePen.

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

Шаг 4: обновление данных

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

Для обновления переменных обычно просто заново отправляют uniform-переменную. Однако в Three.js достаточно просто обновить объект uniforms в функции render , повторно отправлять данные шейдеру не требуется.

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

Function render() { cube.rotation.y += 0.02; uniforms.resolution.value.x = window.innerWidth; uniforms.resolution.value.y = window.innerHeight; requestAnimationFrame(render); renderer.render(scene, camera); }
Если открыть новый CodePen и изменить размер окна, то вы увидите, как изменяются цвета, несмотря на то, что изначальный размер окна просмотра остался тем же). Проще всего это заметить, посмотрев на цвета в углах и убедившись, что они не меняются.

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

Задача: Попробуйте постепенно изменять цвета.

Шаг 5: работа с текстурами

Вне зависимости от способа загрузки и формата текстур на всех платформах они передаются в шейдер как uniform-переменные.

Небольшое примечание о загрузке файлов в JavaScript: можно без проблем загружать изображения с внешнего URL (именно так мы и будем делать). Однако если вы захотите загрузить изображение локально, то возникнут проблемы с разрешениями, потому что JavaScript обычно не может и не должен иметь доступа к файлам в системе. Простейший способ решения - запустить локальный Python-сервер , что на самом деле проще, чем кажется.

В Three.js есть небольшая удобная функция для загрузки изображения как текстуры:

THREE.ImageUtils.crossOrigin = "";// Позволяет загружать внешнее изображение var tex = THREE.ImageUtils.loadTexture("https://tutsplus.github.io/Beginners-Guide-to-Shaders/Part2/SIPI_Jelly_Beans.jpg");
Первая строка задаётся только один раз. В неё можно вставить любой URL изображения.

Затем нам нужно добавить текстуру к объекту uniforms .

Uniforms.texture = {type:"t",value:tex};
И, наконец, нам нужно объявить uniform-переменную в коде шейдера, а потом отрисовать её тем же способом, как мы делали ранее - с помощью функции texture2D:

Uniform vec2 resolution; uniform sampler2D texture; void main() { vec2 pos = gl_FragCoord.xy / resolution.xy; gl_FragColor = texture2D(texture,pos); }
На экране должно появиться растянутое изображение конфет:

(Это изображение является стандартным тестовым изображением в компьютерной графике, оно взято у (поэтому на нём показана аббревиатура IPI) Университета Южной Калифорнии. Думаю, оно нам подходит, ведь мы как раз изучаем графические шейдеры!)

Задача: Попробуйте постепенно менять цвета текстуры с полного цвета на градации серого.

Дополнительный шаг: применяем шейдеры к другим объектам

В созданной нами плоскости нет ничего особенного. Мы могли применить шейдер и к кубу. На самом деле можно просто заменить строку с геометрией плоскости:

Var geometry = new THREE.PlaneGeometry(10, 10);
на:

Var geometry = new THREE.BoxGeometry(1, 1, 1);
Вуаля, конфеты нарисованы на кубе:


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

Следующие шаги

На этом этапе мы уже можем делать всё, что сделали в ShaderToy, однако теперь мы способны использовать любые текстуры и любые объекты, на любой платформе.

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

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

Начиная с этого момента мы будем рассматривать общие концепции графических шейдеров без привязки к конкретной платформе. (Для удобства во всех примерах кода по-прежнему будет использоваться JavaScript/WebGL.)

Для начала найдите подходящий вам способ выполнения шейдеров. (JavaScript/WebGL - это простейший способ, но я рекомендую вам поэкспериментировать со своей любимой платформой!)

Цели

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

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


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

Отличным примером этого является Chroma . Игрок может бегать по динамическим теням, создаваемым в реальном времени:

Приступаем к работе: наша исходная сцена

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

Var uniforms = { tex: {type:"t",value:texture},// Текстура res: {type: "v2",value:new THREE.Vector2(window.innerWidth,window.innerHeight)}// Хранит разрешение }
В коде на GLSL мы объявляем и используем эти uniform-переменные:

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?

Шаг 1: добавление источника света

Прежде чем создать что-то интересное, нам нужен источник света. «Источник света» - это просто точка, передаваемая шейдеру. Для этой точки мы создадим новую uniform:

Var uniforms = { //Добавляем переменную источника света light: {type:"v3", value:new THREE.Vector3()}, tex: {type:"t",value:texture},// Текстура res: {type: "v2",value:new THREE.Vector2(window.innerWidth,window.innerHeight)}// Хранит разрешение }
Мы создали вектор с тремя измерениями, потому что мы хотим использовать x и y в качестве положения источника на экране, а z - в качестве радиуса .

Давайте присвоим в JavaScript значения нашему источнику света:

Uniforms.light.value.z = 0.2;// Радиус
Мы будем использовать радиус как процент от размеров экрана, поэтому 0.2 будет составлять 20% экрана. (В этом выборе нет ничего особенного. Мы могли бы задать размер в пикселях. Это число ничего не значит, пока мы не начнём с ним делать что-нибудь в коде GLSL.)

Чтобы получить положение мыши, нужно просто добавить получатель события (event listener):

Document.onmousemove = function(event){ // Обновляем источник света, чтобы он следовал за мышью uniforms.light.value.x = event.clientX; uniforms.light.value.y = event.clientY; }
Давайте теперь напишем код шейдера, чтобы воспользоваться этой координатой источника света. Начнём с простой задачи: сделаем так, чтобы каждый пиксель в пределах радиуса источника света был видимым, а остальные были чёрными .

На GLSL это может выглядеть примерно так:

Uniform sampler2D tex; uniform vec2 res; uniform vec3 light;// Не забывайте объявлять здесь uniform! void main() { vec2 pixel = gl_FragCoord.xy / res.xy; vec4 color = texture2D(tex,pixel); // Расстояние от текущего пикселя до источника света float dist = distance(gl_FragCoord.xy,light.xy); if(light.z * res.x > dist){// Проверяем, находится ли пиксель внутри радиуса gl_FragColor = color; } else { gl_FragColor = vec4(0.0); } }
Здесь мы сделали следующее:

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

Ой-ёй! Источник света следует за мышью как-то странно.

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

Исправление движения источника света

Возможно, вы помните, что ось Y здесь перевёрнута. Вы можете поторопиться просто ввести:

Light.y = res.y - light.y;
Это математически верно, но если поступить так, то шейдер не скомпилируется! Проблема в том, что uniform-переменные невозмжоно изменять . Чтобы понять, почему, нужно помнить, что этот код выполняется для каждого отдельного пикселя параллельно . Представьте, что все процессорные ядра попытаются изменить единственную переменную одновременно. Плохая ситуация!

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


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: добавляем глубину

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

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

Однако вот что видит наша система освещения сейчас:

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

A находится наверху фигуры, а B и C - по бокам. D - это ещё одна точка на земле. Мы видим, что A и D должны быть самыми яркими, причём D немного темнее, потому что свет достигает её под углом. С другой стороны, B и C должны быть очень тёмными, потому что до них почти не доходит свет, ведь они направлены от источника света.

Не так важна высота, как направление, в котором повёрнута поверхность . Оно называется нормалью поверхности .

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

Именно это делает карта нормалей: она просто является изображением, в котором значения r , g и b каждого пикселя представляют не цвет, а направление.

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

Наклонная сторона справа повёрнута вправо, поэтому её значение x выше. Значение 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}, //.. делаем всё остальное }
Чтобы проверить, что мы загрузили её правильно, давайте попробуем отрендерить её вместо текстуры, изменив код на GLSL (помните, что мы на этом этапе используем просто фоновую текстуру, а не карту нормалей):

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

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

Простейшей для реализации моделью является модель Фонга . Вот как она работает: пусть у нас есть поверхность с данными о нормалях:

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

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

Это значит, что когда пиксель находится непосредственно под источником света, где разность углов равна 0, он будет самым ярким. Самые тёмные пиксели будут указывать в том же направлении, что и источник света (это будет похоже на заднюю часть объекта).

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

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

Поэтому вместо:

Vec4 color = texture2D(...);
Давайте сделаем сплошной белый цвет (или любой другой цвет):

Vec4 color = vec4(1.0); // белый цвет
Это сокращение GLSL для создания vec4 со всеми компонентами, равными 1.0 .

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

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

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

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

Vec3 NormalVector = texture2D(norm,pixel).xyz;
Поскольку альфа-значение ничего не обозначает на карте нормалей, нам требуются только первые три компонента.

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

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


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

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

Теперь нам нужно нормализировать:

NormalVector = normalize(NormalVector); LightVector = normalize(LightVector);
Чтобы оба вектора имели длину 1.0 , мы воспользуемся встроенной функцией normalize . Это необходимо, потому что мы хотим вычислить угол с помощью скалярного произведения . Если вы не очень понимаете, как оно работает, то стоит немного изучить линейную алгебру. Для наших целей нам нужно знать только, что скалярное произведение возвращает косинус угла между векторами одинаковой длины .

4. Вычисляем угол между векторами

Давайте сделаем это с помощью встроенной функции dot :


Я назвал переменную 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);
И у нас получилась работающая модель освещения!

Можно поставить на фон каменную текстуру, а настоящую карту нормалей взять в (а именно ):

Нам нужно только изменить одну строку на 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);// белый цвет
Нам больше не нужен сплошной белый цвет, мы загрузим настоящую текстуру, вот так:

Vec4 color = texture2D(tex,pixel);
И вот окончательный результат:

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

Ветвление

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

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

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

Это очень полезная концепция при работе с освещением. Представьте, что нам нужны два источника освещения, или три, или десяток. Нам придётся вычислять угол между каждой нормалью поверхности и каждой точкой источника света. В результате шейдер будет выполняться с черепашьей скоростью. Отложенный рендеринг - это способ оптимизации такого процесса разбиением работы шейдера на несколько проходов. , в которой подробно объяснено, что это значит. Я процитирую важную для нас часть:
Освещение - это основная причина выбора того или иного пути. В стандартном прямолинейном конвейере рендеринга вычисления освещения должны выполняться для каждой вершины и каждого фрагмента видимой сцены для каждого источника света в сцене .

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

Способность разделить работу на несколько проходов - это очень полезная техника при создании шейдеров. Например, она используется для ускорения шейдера при вычислении эффекта размывки, а также в шейдерах жидкостей/дыма.

Следующие шаги

Теперь, когда вы получили работающий шейдер освещения, вот с чем ещё можно поэкспериментировать:
  • Попробуйте изменять высоту (значение z) вектора освещения, чтобы понаблюдать за его воздействием
  • Поэкспериментируйте с интенсивностью освещения. (Можно сделать это, умножая значение diffuse на коэффициент.)
  • Добавьте в уравнение вычисления освещения значение ambient (окружающего освещения). (Это значит, что мы присваиваем минимальное значение, то есть даже в тёмных областях цвет не будет полностью чёрным. Такое освещение позволяет сделать сцену реалистичной, потому что объекты в реальной жизни всегда освещены, даже если на них не падает прямой свет)
Добавить метки

«Что такое шейдеры?» — очень частый вопрос любопытных игроков и начинающих игровых разработчиков. В этой статье доходчиво и понятно об этих страшных шейдерах расскажу.

Двигателем прогресса в сторону фотореалистичности картинки в компьютерной графике я считаю именно компьютерные игры, поэтому давайте именно в разрезе видео-игр и поговорим о том, что такое «шейдеры».

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

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

Так вот, центральный процессор (CPU — Central Processing Unit) слишком умный парень, чтобы заставлять его заниматься такой рутиной. Вместо этого логично выделить какой-то аппаратный модуль, который разгрузит CPU, чтобы тот смог заниматься более важным интеллектуальным трудом.

Таким аппаратным модулем стал — графический ускоритель или видеокарта (GPU — Graphics Processing Unit). Теперь CPU подготавливает данные и загружает рутинной работой коллегу. Учитывая, что GPU сейчас это не просто один коллега, это толпа миньонов-ядер, то он с такой работой справляется на раз.

Но мы пока не получили ответа на главный вопрос: Что такое шейдеры? Подождите, я подвожу к этому.

Хорошая, интересная и близкая к фото-реализму графика, требовала от разработчиков видеокарт реализовывать многие алгоритмы на аппаратном уровне. Тени, свет, блики и так далее. Такой подход — с реализацией алгоритмов аппаратно называется «Фиксированный пайплайн или конвейер» и там где требуется качественная графика он теперь не встречается. Его место занял «Программируемый пайплайн».

Запросы игроков «давайте, завозите хороший графоний! удивляйте!», толкали разработчиков игр (и производителей видеокарт соответственно) все к более и более сложным алгоритмам. Пока в какой-то момент зашитых аппаратных алгоритмов им стало слишком мало.

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

И вот, наконец, мы дошли до ответа на наш главный вопрос.

«Что такое шейдеры?»

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

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

Графический пайплайн

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

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

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

Впервые полноценная поддержка шейдеров появилась в видеокартах серии GeForce 3, но зачатки были реализованы ещё в GeForce256 (в виде Register Combiners).

Виды шейдеров

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

Вершинный шейдер

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

Геометрический шейдер

Геометрические шейдеры способны создавать новую геометрию, и могут использоваться для создания частиц, изменения детализации модели «на лету», создание силуэтов и т.п. В отличие от предыдущего вершинного, способны обработать не только одну вершину, но и целый примитив. Примитивом может быть отрезок (две вершины) и треугольник (три вершины), а при наличии информации о смежных вершинах (англ. adjacency) для треугольного примитива может быть обработано до шести вершин.

Пиксельный шейдер

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

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

На чем пишут шейдеры?

Изначально шейдеры можно было писать на assembler-like языке, но позже появились шейдерные языки высокого уровня, похожие на язык С, такие как: Cg, GLSL и HLSL.

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

RenderMan

Все что мы обсудили выше относится к realtime графике. Но существуют non-realtime графика. В чем разница — realtime — реальное время, тоесть здесь и сейчас — давать 60 кадров в секунду в игре, это процесс реального времени. А вот рендерить комплексный кадр для ультрасовременной анимации по несколько минут это non-realtime. Суть во времени.

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

Супер-реалистичная графика в Sand piper

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

*Видео могут забанить на Youtube, если оно не открывается, погуглите pixar sandpiper — короткометражный мультфильм про храброго песочника очень милый и пушистый. Умилит и продемонстрирует насколько крутой может быть компьютерная графика.

Так вот это RenderMan от фирмы Pixar. Он стал первым языком программирования шейдеров. API RenderMan является фактическим стандартом для профессионального рендеринга, используется во всех работах студии Pixar и не только их.

Полезная информация

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

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

Если остались вопросы

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