Исправление движения источника света. Приступаем к работе: наша исходная сцена

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

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

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

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

Шейдеры пишут на специальном языке шейдеров. Не волнуйтесь, вам не придётся изучать совершенно новый язык: мы будем использовать 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
  • освещение
Добавить метки

Emiel Boven рассказал о своем подходе к созданию шейдеров неба и льда в Unity 5 .

Вступление

Меня зовут Emiel Boven, я студент третьего курса игрового искусства из Нидерландов. В своей работе я сосредоточен на технической стороне игрового искусства: шейдерах, освещении и VFX. Я работал над кооперативной игрой под названием Rite of Ilk во время моей стажировки в Turtleneck Studios здесь, в Нидерландах, и недавно я объединился с небольшой группой художников, разработчиков и дизайнеров из моей школы, чтобы создать свою собственную компанию под названием Rift Shard Studios. Мы уже начали работать над приключенческой игрой, о которой мы расскажем чуть позже.

Шейдеры в Unity

Я учился на концепт-художника, но мне очень понравились вещи, которые раньше я считал "скучными и техническими" в разработке игр, такие как: 3D-моделирование и программирование. С тех пор я больше никогда не создавал 2D арты, теперь я экспериментирую с шейдерами и создаю свои собственные небольшие проекты на C#. Мне нравится в шейдерах то, что они могут принимать набор переменных и данных из меша, например, направление нормалей или положение вершин, а также манипулировать ими для достижения интересных и визуально приятных эффектов, которых нельзя достичь простым моделированием и текстурированием.

Основы

Основы шейдеров сводятся к простой идее: у вас есть набор 3D-координат и атрибутов, которые проходят через набор небольших программ, называемых шейдерами, прежде чем они будут выведены на экран в виде 2D фрейма. Все эти шейдеры имеют очень специфичный функционал, и некоторые из них можно поменять для пользовательских шейдеров, чтобы понять, как изменится финальный кадр, например, vertex и fragment/pixel shaders. Вертекс шейдер может быть использован, например, для смещения позиций вершин модели, используя Displacement map, без изменения первоначально импортированной модели.

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

Amplify Shader Editor

Amplify Shader Editor - это нодовый редактор Unity. Это означает, что вы можете создавать шейдерный код из системы взаимосвязанных узлов, которые вы можете построить, не зная ни единой строчки HLSL. Это отличный способ узнать о шейдерах без необходимости написания кода. На втором году моего обучения я впервые узнал о шейдерах в серии уроков о шейдерах и рендеринге. В этих уроках я узнал о том, как создать шейдеры, используя другой нодовый редактор Shader Forge. Таким образом, я узнал обо всех крутых вещах, которые я мог сделать с шейдерами. После этих "творческих" уроков я был настолько заинтересован ими, что решил потратить некоторое время на изучение создания шейдеров без нодовых редакторов. Это действительно помогло мне в создании более сложных шейдеров, используя ноды, так как теперь я больше знаю о том, что происходит под капотом.

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

Второй материал создает маску по оси Y, чтобы создать снег и лед только на поверхностях, которые направлены вверх и вниз. Это позволяет вам вращать меш, но сосульки всегда будут направлены вниз в игровом мире. Снег и сосульки используют displacement для смещения вершин вверх или вниз, чтобы создать иллюзию снега, который накапливатся поверх материала. Сосульки немного отличаются от того, как я делал снег. Они создаются, используя Noise map, которая маскирует некоторые части дна, поэтому создаётся такой эффект зубчатых сосулек.

Затем я использовал Vertex Color, который вы можете добавить в своём 3D пакете или непосредственно в самом игровом движке, как сделал я, в качестве маски для смешивания двух материалов на модели.

Шейдеры для стилизованных сцен

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

Skybox Shader

Шейдер отображает skybox с текстурой облаков, которую я сделал. Файл, содержащий текстуру облаков, состоит из трех черно-белых карт, хранящихся в отдельных RGB каналах изображения. Эти текстуры загружаются в шейдер и используются для трех основных составляющих облака: формы облаков, создании света по краю облака, внутренних теней. В шейдере я соединяю эти карты и цвета, чтобы эти части могли окрашиваться отдельно, когда это необходимо. Текстура, отвечающая за свет по краям, немного отличается от двух других, потому что она объединяется с градиентом вокруг солнца так, что свет по краям появляется только тогда, когда солнце находится вблизи облаков.

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

Поскольку облака движутся, а я использую неподвижное изображение, то придется работать с UV, использовать Noise текстуру, чтобы облака медленно двигались по небу и изменяли свою форму.

Реализация шейдеров

Шейдеры в Unity довольно просто в реализации. Шейдер, написанный вручную или созданный с использованием редактора нодов, может быть назначен материалу в Unity, который уже потом может быть назначен объекту в игре. Шейдеры могут быть использованы для создания интересных эффектов на основе меша, но могут использоваться также для эффектов частиц и эффектов пост обработки. Недавно я узнал о том, как технический художник Simon Trumpler создал все стилизованные эффекты в игре RIME. Его видео может послужить неплохим вдохновением для создания подобных классных эффектов.

Урок для начинающих

Я бы хотел посоветовать вам использовать такие программы, как Amplify Shader Editor и Shader Forge. Особенно, если у вас нет опыта работы с шейдерами. Эти программы дают вам возможность начать экспериментировать без необходимости знания HLSL, но также стоит заметить, что вы не сможете создавать узкоспециализированные шейдеры. Даже если вы уже являетесь звездой в кодинге шейдеров, я по-прежнему рекомендую использовать систему нодов, поскольку она устраняет проблемы с синтаксическими ошибками и знает точные названия используемых функций. Таким образом, это может ускорить ваше время разработки и оставит больше времени на разработку чего-нибудь другого.

Learning to write graphics shaders is learning to leverage the power of the GPU, with its thousands of cores all running in parallel. It"s a kind of programming that requires a different mindset, but unlocking its potential is worth the initial trouble.

Virtually every modern graphics simulation you see is powered in some way by code written for the GPU, from the realistic lighting effects in cutting edge AAA games to 2D post-processing effects and fluid simulations.

A scene in Minecraft, before and after applying a few shaders .

The Aim of This Guide

Shader programming sometimes comes off as an enigmatic black magic and is often misunderstood. There are lots of code samples out there that show you how to create incredible effects, but offer little or no explanation. This guide aims to bridge that gap. I"ll focus more on the basics of writing and understanding shader code, so you can easily tweak, combine, or write your own from scratch!

This is a general guide, so what you learn here will apply to anything that can run shaders.

So What is a Shader?

A shader is simply a program that runs in the graphics pipeline and tells the computer how to render each pixel. These programs are called shaders because they"re often used to control lighting and shading effects, but there"s no reason they can"t handle other special effects.

Shaders are written in a special shading language. Don"t worry, you don"t have to go out and learn a completely new language; we will be using GLSL (OpenGL Shading Language) which is a C-like language. (There are a bunch of shading languages out there for different platforms, but since they"re all adapted to run on the GPU, they"re all very similar.)

Note: This article is exclusively about fragment shaders. If you"re curious about what else is out there, you can read about the



Есть вопросы?

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: