Шейдеры пишут на специальном языке шейдеров. Не волнуйтесь, вам не придётся изучать совершенно новый язык: мы будем использовать GLSL (OpenGL Shading Language), который похож на C. (Существует несколько языков написания шейдеров для разных платформ, но поскольку все они адаптированы под выполнение в видеопроцессоре, то похожи друг на друга.)
Примечание: эта статья посвящена исключительно фрагментным шейдерам (fragment shader). Если вам любопытно, какие ещё виды шейдеров бывают, то можно почитать о различных этапах графического конвейера в OpenGL Wiki.
Примечание: на момент написания статьи ShaderToy находился в состоянии беты [прим. пер.: статья написана в 2015 году] . Некоторые детали интерфейса/синтаксиса могут немного отличаться.
Нам нужно создать специальный материал и передать его коду шейдера. В качестве объекта шейдера мы создадим плоскость (но можем использовать и куб). Вот что нужно сделать:
// Создаём объект, к которому нужно применить шейдер
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;// Перемещаем его назад, чтобы его было видно
На этом этапе вы должны видеть белый экран:
Если заменить цвет в коде шейдера на любой другой и обновить страницу, то вы увидите новый цвет.
Задача: Сможете ли вы сделать одну часть экрана красной, а другую синей? (Если не получится, то на следующем шаге я дам подсказку!)
Для передачи данных шейдеру нам нужно отправить то, что мы назвали 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 |
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);
}
В результате у нас получится красивый градиент!
Задача: Попробуйте разделить экран на четыре равных части разных цветов. Примерно вот так:
Для обновления переменных обычно просто заново отправляют 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 и изменить размер окна, то вы увидите, как изменяются цвета, несмотря на то, что изначальный размер окна просмотра остался тем же). Проще всего это заметить, посмотрев на цвета в углах и убедившись, что они не меняются.
Примечание: Отправка данных в видеопроцессор обычно является затратной задачей. Отправка нескольких переменных за один кадр вполне нормальна, но при передаче сотен переменных за кадр частота смены кадров значительно снизится. Кажется, что такое маловероятно, но если на экране есть несколько сотен объектов, и ко всем ним применено освещение с разными свойствами, то всё быстро выходит из под контроля. Позже мы узнаем про оптимизацию шейдеров.
Задача: Попробуйте постепенно изменять цвета.
Небольшое примечание о загрузке файлов в 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);
Вуаля, конфеты нарисованы на кубе:
Имея всю эту свободу, мы можем сделать что-нибудь вроде системы освещения, с реалистичными тенями и источниками света. Этим мы ниже и займёмся. Кроме того, я расскажу о техниках оптимизации шейдеров.
Освоившись с основами шейдеров мы на практике применим мощь видеопроцессора для создания реалистичного динамического освещения.
Начиная с этого момента мы будем рассматривать общие концепции графических шейдеров без привязки к конкретной платформе. (Для удобства во всех примерах кода по-прежнему будет использоваться JavaScript/WebGL.)
Для начала найдите подходящий вам способ выполнения шейдеров. (JavaScript/WebGL - это простейший способ, но я рекомендую вам поэкспериментировать со своей любимой платформой!)
Вот как будет выглядеть конечный результат (нажмите мышью для включения света):
Отличным примером этого является Chroma . Игрок может бегать по динамическим теням, создаваемым в реальном времени:
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?
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);
}
}
Здесь мы сделали следующее:
Задача: Сможете это исправить? (Попробуйте снова разобраться самостоятельно, прежде чем мы решим эту задачу ниже.)
Light.y = res.y - light.y;
Это математически верно, но если поступить так, то шейдер не скомпилируется! Проблема в том, что uniform-переменные невозмжоно изменять
. Чтобы понять, почему, нужно помнить, что этот код выполняется для каждого отдельного пикселя параллельно
. Представьте, что все процессорные ядра попытаются изменить единственную переменную одновременно. Плохая ситуация!
Мы можем исправить ошибку, создав новую переменную вместо uniform. Или ещё лучше - мы можем просто сделать этот шаг до передачи данных в шейдер:
Вместо присвоения всем пикселям в пределах радиуса цвета текстуры:
Gl_FragColor = color;
мы можем умножать его на коэффициент расстояния:
Gl_FragColor = color * (1.0 - dist/(light.z * res.x));
На этом рисунке dist вычисляется для произвольного пикселя. dist меняется в зависимости от того, в каком пикселе мы находимся, а значение light.z * res.x постоянно.
Если мы посмотрим на пиксель на границе круга, то dist равно длине радиуса, то есть в результате мы умножаем color на 0 и получаем чёрный цвет.
В представленном выше случае стоит ожидать, что точка 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 (помните, что мы на этом этапе используем просто фоновую текстуру, а не карту нормалей):
Простейшей для реализации моделью является модель Фонга . Вот как она работает: пусть у нас есть поверхность с данными о нормалях:
Мы просто вычисляем угол между источником света и нормалью поверхности:
Чем меньше угол, тем ярче пиксель.
Это значит, что когда пиксель находится непосредственно под источником света, где разность углов равна 0, он будет самым ярким. Самые тёмные пиксели будут указывать в том же направлении, что и источник света (это будет похоже на заднюю часть объекта).
Давайте реализуем эту модель.
Поскольку для проверки мы используем простую карту нормалей, давайте зальём текстуру сплошным цветом, чтобы чётко понимать, всё ли у нас получается.
Поэтому вместо:
Vec4 color = texture2D(...);
Давайте сделаем сплошной белый цвет (или любой другой цвет):
Vec4 color = vec4(1.0); // белый цвет
Это сокращение GLSL для создания vec4 со всеми компонентами, равными 1.0 .
Вот как выглядит алгоритм:
Vec3 NormalVector = texture2D(norm,pixel).xyz;
Поскольку альфа-значение ничего не обозначает на карте нормалей, нам требуются только первые три компонента.
Он должен иметь и координату Z (чтобы можно было вычислить угол относительно трёхмергого вектора нормали поверхности). С этим значением можно поэкспериментировать. Вы заметите, что чем оно меньше, тем резче контраст между яркими и тёмными областями. Можно представить, что это высота фонарика над сценой: чем он дальше, тем равномернее распространяется свет.
NormalVector = normalize(NormalVector);
LightVector = normalize(LightVector);
Чтобы оба вектора имели длину 1.0 , мы воспользуемся встроенной функцией normalize . Это необходимо, потому что мы хотим вычислить угол с помощью скалярного произведения . Если вы не очень понимаете, как оно работает, то стоит немного изучить линейную алгебру. Для наших целей нам нужно знать только, что скалярное произведение возвращает косинус угла между векторами одинаковой длины
.
Я назвал переменную diffuse
потому что этот термин используется в модели освещения по Фонгу, ведь она определяет количество света, достигающее поверхности сцены.
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 вектора нормали:
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);
И вот окончательный результат:
Чтобы понять, почему, стоит снова вспомнить, что код на GLSL выполняется для каждого пикселя на экране параллельно . Графическая карта может выполнить множество оптимизаций, исходя из того, что для всех пикселей нужно выполнять одинаковые операции. Однако если в коде будет куча if , то некоторые оптимизации выполнить не удастся, потому что теперь для разных пикселей выполняется разных код. Будут ли конструкции if замедлять выполнение, или нет, зависит от реализации на конкретном оборудовании и в графической карте, но неплохо помнить об этом, если вы хотите ускорить шейдер.
Освещение - это основная причина выбора того или иного пути. В стандартном прямолинейном конвейере рендеринга вычисления освещения должны выполняться для каждой вершины и каждого фрагмента видимой сцены для каждого источника света в сцене .
Способность разделить работу на несколько проходов - это очень полезная техника при создании шейдеров. Например, она используется для ускорения шейдера при вычислении эффекта размывки, а также в шейдерах жидкостей/дыма.
«Что такое шейдеры?» — очень частый вопрос любопытных игроков и начинающих игровых разработчиков. В этой статье доходчиво и понятно об этих страшных шейдерах расскажу.
Двигателем прогресса в сторону фотореалистичности картинки в компьютерной графике я считаю именно компьютерные игры, поэтому давайте именно в разрезе видео-игр и поговорим о том, что такое «шейдеры».
До того, как появились первые графические ускорители, всю работу по отрисовке кадров видеоигры выполнял бедняга центральный процессор.
Отрисовка кадра, довольно рутинная работа на самом деле: нужно взять «геометрию» — полигональные модели (мир, персонаж, оружие и т.д.) и растеризовать. Что такое растеризовать? Вся 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, ведь задачи решаемые с их помощью, гораздо проще. Система типов в таких языках отражает нужды программистов графики. Поэтому они предоставляют программисту специальные типы данных: матрицы, семплеры, векторы и тп.
Все что мы обсудили выше относится к realtime графике. Но существуют non-realtime графика. В чем разница — realtime — реальное время, тоесть здесь и сейчас — давать 60 кадров в секунду в игре, это процесс реального времени. А вот рендерить комплексный кадр для ультрасовременной анимации по несколько минут это non-realtime. Суть во времени.
Например, графику такого качества как в последних мультипликационных фильмах студии Pixar получить в реальном времени мы сейчас получить не можем. Очень большие рендер-фермы обсчитывают симуляции света по совсем другим алгоритмам, очень затратным, но дающим почти фотореалистичные картинки.
Например, посмотрите, на вот этот милый мультфильм, песчинки, перышки птички, волны, все выглядит невероятно реальным.
*Видео могут забанить на Youtube, если оно не открывается, погуглите pixar sandpiper — короткометражный мультфильм про храброго песочника очень милый и пушистый. Умилит и продемонстрирует насколько крутой может быть компьютерная графика.
Так вот это RenderMan от фирмы Pixar. Он стал первым языком программирования шейдеров. API RenderMan является фактическим стандартом для профессионального рендеринга, используется во всех работах студии Pixar и не только их.
Теперь Вы знаете что такое шейдеры, но помимо шейдеров, есть другие очень интересные темы в разработке игр и компьютерной графике, которые наверняка Вас заинтересуют:
Как обычно, если у Вас остались какие-то вопросы, задавайте их в комментариях, я всегда отвечу. За любое доброе слово или правку ошибок я буду очень признателен.
Иногда программирование шейдеров представляется загадочной чёрной магией и его часто понимают неправильно. Существует множество примеров кода, демонстрирующих создание невероятных эффектов, но в которых практически нет объяснений. В своём руководстве я хочу восполнить этот пробел. Я сосредоточусь в основном на базовых принципах создания и понимания кода шейдеров, чтобы вы смогли с лёгкостью настраивать, сочетать или писать свои собственные шейдеры с нуля!Шейдеры пишут на специальном языке шейдеров. Не волнуйтесь, вам не придётся изучать совершенно новый язык: мы будем использовать GLSL (OpenGL Shading Language), который похож на C. (Существует несколько языков написания шейдеров для разных платформ, но поскольку все они адаптированы под выполнение в видеопроцессоре, то похожи друг на друга.)
Примечание: эта статья посвящена исключительно фрагментным шейдерам (fragment shader). Если вам любопытно, какие ещё виды шейдеров бывают, то можно почитать о различных этапах графического конвейера в OpenGL Wiki.
Примечание: на момент написания статьи ShaderToy находился в состоянии беты [прим. пер.: статья написана в 2015 году] . Некоторые детали интерфейса/синтаксиса могут немного отличаться.
Нам нужно создать специальный материал и передать его коду шейдера. В качестве объекта шейдера мы создадим плоскость (но можем использовать и куб). Вот что нужно сделать:
// Создаём объект, к которому нужно применить шейдер
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;// Перемещаем его назад, чтобы его было видно
На этом этапе вы должны видеть белый экран:
Если заменить цвет в коде шейдера на любой другой и обновить страницу, то вы увидите новый цвет.
Задача: Сможете ли вы сделать одну часть экрана красной, а другую синей? (Если не получится, то на следующем шаге я дам подсказку!)
Для передачи данных шейдеру нам нужно отправить то, что мы назвали 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 |
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);
}
В результате у нас получится красивый градиент!
Задача: Попробуйте разделить экран на четыре равных части разных цветов. Примерно вот так:
Для обновления переменных обычно просто заново отправляют 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 и изменить размер окна, то вы увидите, как изменяются цвета, несмотря на то, что изначальный размер окна просмотра остался тем же). Проще всего это заметить, посмотрев на цвета в углах и убедившись, что они не меняются.
Примечание: Отправка данных в видеопроцессор обычно является затратной задачей. Отправка нескольких переменных за один кадр вполне нормальна, но при передаче сотен переменных за кадр частота смены кадров значительно снизится. Кажется, что такое маловероятно, но если на экране есть несколько сотен объектов, и ко всем ним применено освещение с разными свойствами, то всё быстро выходит из под контроля. Позже мы узнаем про оптимизацию шейдеров.
Задача: Попробуйте постепенно изменять цвета.
Небольшое примечание о загрузке файлов в 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);
Вуаля, конфеты нарисованы на кубе:
Имея всю эту свободу, мы можем сделать что-нибудь вроде системы освещения, с реалистичными тенями и источниками света. Этим мы ниже и займёмся. Кроме того, я расскажу о техниках оптимизации шейдеров.
Освоившись с основами шейдеров мы на практике применим мощь видеопроцессора для создания реалистичного динамического освещения.
Начиная с этого момента мы будем рассматривать общие концепции графических шейдеров без привязки к конкретной платформе. (Для удобства во всех примерах кода по-прежнему будет использоваться JavaScript/WebGL.)
Для начала найдите подходящий вам способ выполнения шейдеров. (JavaScript/WebGL - это простейший способ, но я рекомендую вам поэкспериментировать со своей любимой платформой!)
Вот как будет выглядеть конечный результат (нажмите мышью для включения света):
Отличным примером этого является Chroma . Игрок может бегать по динамическим теням, создаваемым в реальном времени:
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?
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);
}
}
Здесь мы сделали следующее:
Задача: Сможете это исправить? (Попробуйте снова разобраться самостоятельно, прежде чем мы решим эту задачу ниже.)
Light.y = res.y - light.y;
Это математически верно, но если поступить так, то шейдер не скомпилируется! Проблема в том, что uniform-переменные невозмжоно изменять
. Чтобы понять, почему, нужно помнить, что этот код выполняется для каждого отдельного пикселя параллельно
. Представьте, что все процессорные ядра попытаются изменить единственную переменную одновременно. Плохая ситуация!
Мы можем исправить ошибку, создав новую переменную вместо uniform. Или ещё лучше - мы можем просто сделать этот шаг до передачи данных в шейдер:
Вместо присвоения всем пикселям в пределах радиуса цвета текстуры:
Gl_FragColor = color;
мы можем умножать его на коэффициент расстояния:
Gl_FragColor = color * (1.0 - dist/(light.z * res.x));
На этом рисунке dist вычисляется для произвольного пикселя. dist меняется в зависимости от того, в каком пикселе мы находимся, а значение light.z * res.x постоянно.
Если мы посмотрим на пиксель на границе круга, то dist равно длине радиуса, то есть в результате мы умножаем color на 0 и получаем чёрный цвет.
В представленном выше случае стоит ожидать, что точка 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 (помните, что мы на этом этапе используем просто фоновую текстуру, а не карту нормалей):
Простейшей для реализации моделью является модель Фонга . Вот как она работает: пусть у нас есть поверхность с данными о нормалях:
Мы просто вычисляем угол между источником света и нормалью поверхности:
Чем меньше угол, тем ярче пиксель.
Это значит, что когда пиксель находится непосредственно под источником света, где разность углов равна 0, он будет самым ярким. Самые тёмные пиксели будут указывать в том же направлении, что и источник света (это будет похоже на заднюю часть объекта).
Давайте реализуем эту модель.
Поскольку для проверки мы используем простую карту нормалей, давайте зальём текстуру сплошным цветом, чтобы чётко понимать, всё ли у нас получается.
Поэтому вместо:
Vec4 color = texture2D(...);
Давайте сделаем сплошной белый цвет (или любой другой цвет):
Vec4 color = vec4(1.0); // белый цвет
Это сокращение GLSL для создания vec4 со всеми компонентами, равными 1.0 .
Вот как выглядит алгоритм:
Vec3 NormalVector = texture2D(norm,pixel).xyz;
Поскольку альфа-значение ничего не обозначает на карте нормалей, нам требуются только первые три компонента.
Он должен иметь и координату Z (чтобы можно было вычислить угол относительно трёхмергого вектора нормали поверхности). С этим значением можно поэкспериментировать. Вы заметите, что чем оно меньше, тем резче контраст между яркими и тёмными областями. Можно представить, что это высота фонарика над сценой: чем он дальше, тем равномернее распространяется свет.
NormalVector = normalize(NormalVector);
LightVector = normalize(LightVector);
Чтобы оба вектора имели длину 1.0 , мы воспользуемся встроенной функцией normalize . Это необходимо, потому что мы хотим вычислить угол с помощью скалярного произведения . Если вы не очень понимаете, как оно работает, то стоит немного изучить линейную алгебру. Для наших целей нам нужно знать только, что скалярное произведение возвращает косинус угла между векторами одинаковой длины
.
Я назвал переменную diffuse
потому что этот термин используется в модели освещения по Фонгу, ведь она определяет количество света, достигающее поверхности сцены.
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 вектора нормали:
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);
И вот окончательный результат:
Чтобы понять, почему, стоит снова вспомнить, что код на GLSL выполняется для каждого пикселя на экране параллельно . Графическая карта может выполнить множество оптимизаций, исходя из того, что для всех пикселей нужно выполнять одинаковые операции. Однако если в коде будет куча if , то некоторые оптимизации выполнить не удастся, потому что теперь для разных пикселей выполняется разных код. Будут ли конструкции if замедлять выполнение, или нет, зависит от реализации на конкретном оборудовании и в графической карте, но неплохо помнить об этом, если вы хотите ускорить шейдер.
Освещение - это основная причина выбора того или иного пути. В стандартном прямолинейном конвейере рендеринга вычисления освещения должны выполняться для каждой вершины и каждого фрагмента видимой сцены для каждого источника света в сцене .
Способность разделить работу на несколько проходов - это очень полезная техника при создании шейдеров. Например, она используется для ускорения шейдера при вычислении эффекта размывки, а также в шейдерах жидкостей/дыма.