Общие сведения о шейдерах

Введение

Сначала прочитайте введение в Three.js, так как оно является основой для этого документа.

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

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

1. Два шейдера

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

  1. Вершинные шейдеры
  2. Фрагментные шейдеры

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

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

Возьмите стандартную примитивную форму, например сферу. Он состоит из вершин, верно? Вершинный шейдер получает каждую из этих вершин по очереди и может с ними работать. Что именно он делает с каждой из них, зависит от самого шейдера, но у него есть одна обязанность: в какой-то момент он должен установить значение gl_Position, четырехмерного вектора с плавающей запятой, который является конечным положением вершины на экране. Это довольно интересный процесс, потому что мы говорим о получении трехмерной позиции (вершины с x, y, z) на двумерном экране. К счастью, если мы используем Three.js, у нас есть сокращенный способ установки gl_Position без усложнения кода.

3. Фрагментные шейдеры

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

Но что такое фрагмент? Представьте три вершины, которые образуют треугольник. Каждый пиксель внутри этого треугольника должен быть отрисован. Фрагмент — это данные, предоставляемые этими тремя вершинами для рисования каждого пикселя в этом треугольнике. Поэтому фрагменты получают интерполированные значения от своих составных вершин. Если одна вершина окрашена в красный цвет, а соседняя – в синий, мы увидим, как цвет интерполируется от красного через фиолетовый к синему.

4. Переменные шейдеров

Вы можете объявить три типа переменных: Uniforms, Attributes и Varyings. Это может сбивать с толку, поскольку они не соответствуют другим распространенным языкам. Вот как это работает:

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

  2. Атрибуты – это значения, которые применяются к отдельным вершинам. Атрибуты доступны только вершинному шейдеру. Это может быть что-то вроде того, что каждая вершина имеет свой цвет. Атрибуты имеют отношение "один к одному" с вершинами.

  3. Varying – это переменные, объявленные в вершинном шейдере, которые мы хотим передать во фрагментный шейдер. Для этого мы объявляем переменную varying одного типа и с одним именем как в вершинном, так и во фрагментном шейдере. Классическим примером использования этого может быть нормаль вершины, поскольку ее можно использовать в расчетах освещения.

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

Нет, посмотрите на один из самых простых шейдеров, которые вы можете создать.

5. Bonjourno World

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

/**
* Multiply each vertex by the model-view matrix
* and the projection matrix (both provided by
* Three.js) to get a final vertex position
*/
void main() {
gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}   

а вот то же самое для фрагментного шейдера:

/**
* Set the color to a lovely pink.
* Note that the color is a 4D Float
* Vector, R,G,B and A and each part
* runs from 0.0 to 1.0
*/
void main() {
gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
}

Но не слишком сложно, правда?

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

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

6. Использование MeshShaderMaterial

Итак, шейдер настроен. Как использовать его в Three.js?

/**
* Assume we have jQuery to hand and pull out
* from the DOM the two snippets of text for
* each of our shaders
*/
var shaderMaterial = new THREE.MeshShaderMaterial({
vertexShader:   $('vertexshader').text(),
fragmentShader: $('fragmentshader').text()
});

После этого Three.js скомпилирует и запустит шейдеры, прикрепленные к сетке, которой вы присвоили этот материал. Это действительно очень просто. Возможно, так и есть, но мы говорим о 3D в браузере, так что вы, вероятно, ожидаете определенной сложности.

Мы можем добавить ещё два свойства в MeshShaderMaterial: uniforms и attributes. Они могут принимать векторы, целые числа или числа с плавающей запятой, но, как я упоминал ранее, униформы одинаковы для всего кадра или для всех вершин, поэтому они, как правило, являются одиночными значениями. Атрибуты, однако, являются переменными для каждой вершины, поэтому они должны быть массивом. Между количеством значений в массиве атрибутов и количеством вершин в сетке должно быть отношение "один к одному".

7. Дальнейшие действия

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

8. Искусственный свет

Теперь измените цвет, чтобы объект не был однотонным. Мы могли бы посмотреть, как Three.js обрабатывает освещение, но, как вы понимаете, это сложнее, чем нам нужно, поэтому мы собираемся подделать его. Рекомендуем ознакомиться с шейдерами, которые входят в состав Three.js, а также с шейдерами из проекта Rome, созданного Крисом Милком и Google на основе WebGL. Вернемся к шейдерам. Мы обновим вершинный шейдер, чтобы он передавал нормаль каждой вершины во фрагментный шейдер. Для этого мы используем:

// create a shared variable for the
// VS and FS containing the normal
varying vec3 vNormal;

void main() {

// set the vNormal value with
// the attribute value passed
// in by Three.js
vNormal = normal;

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}

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

// same name and type as VS
varying vec3 vNormal;

void main() {

// calc the dot product and clamp
// 0 -> 1 rather than -1 -> 1
vec3 light = vec3(0.5,0.2,1.0);
    
// ensure it's normalized
light = normalize(light);

// calculate the dot product of
// the light to the vertex normal
float dProd = max(0.0, dot(vNormal, light));

// feed into our frag color
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);

}

Скалярное произведение работает, потому что для двух векторов оно дает число, которое показывает, насколько они "похожи". Если нормализованные векторы указывают в одном направлении, вы получите значение 1. Если они указывают в противоположных направлениях, вы получаете -1. Мы берем это число и применяем его к нашему освещению. Таким образом, вершина в правом верхнем углу будет иметь значение, близкое к 1 или равное 1 (полностью освещена), тогда как вершина сбоку будет иметь значение, близкое к 0, а сзади – -1. Мы заменяем отрицательные значения нулями, но когда вы подставляете числа, вы получаете базовое освещение, которое мы видим.

Что дальше? Было бы неплохо попробовать поиграть с позициями вершин.

9. Атрибуты

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

Для начала добавьте атрибут в вершинный шейдер:

attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// push the displacement into the three
// slots of a 3D vector so it can be
// used in operations with other 3D
// vectors like positions and normals
vec3 newPosition = position +
                    normal *
                    vec3(displacement);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

Как это выглядит?

Не так уж и много! Это связано с тем, что атрибут не был настроен в MeshShaderMaterial, поэтому шейдер фактически использует нулевое значение. Сейчас это что-то вроде плейсхолдера. Через секунду мы добавим атрибут в MeshShaderMaterial в JavaScript, и Three.js автоматически свяжет их.

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

10. Обновление MeshShaderMaterial

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

var attributes = {
displacement: {
    type: 'f', // a float
    value: [] // an empty array
}
};

// create the material and now
// include the attributes property
var shaderMaterial = new THREE.MeshShaderMaterial({
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

// now populate the array of attributes
var vertices = sphere.geometry.vertices;
var values = attributes.displacement.value
for(var v = 0; v < vertices.length; v++) {
values.push(Math.random() * 30);
}

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

11. Анимировать

Мы должны сделать эту анимацию. Как мы это делаем? Для этого нужно сделать две вещи:

  1. Унифицированная переменная для анимации, определяющая, какое смещение должно применяться в каждом кадре. Для этого можно использовать синус или косинус, так как они принимают значения от -1 до 1.
  2. Цикл анимации в JS

Мы добавим uniform-переменную и в MeshShaderMaterial, и в вершинный шейдер. Сначала вершинный шейдер:

uniform float amplitude;
attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// multiply our displacement by the
// amplitude. The amp will get animated
// so we'll have animated displacement
vec3 newPosition = position +
                    normal *
                    vec3(displacement *
                        amplitude);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

Теперь обновим MeshShaderMaterial:

// add a uniform for the amplitude
var uniforms = {
amplitude: {
    type: 'f', // a float
    value: 0
}
};

// create the final material
var shaderMaterial = new THREE.MeshShaderMaterial({
uniforms:       uniforms,
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

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

В коде JavaScript нужно обернуть вызов рендеринга в функцию, а затем вызвать ее с помощью requestAnimationFrame. В нем нам также нужно обновить значение uniform.

var frame = 0;
function update() {

// update the amplitude based on
// the frame value
uniforms.amplitude.value = Math.sin(frame);
frame += 0.1;

renderer.render(scene, camera);

// set up the next call
requestAnimFrame(update);
}
requestAnimFrame(update);

Заключение

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

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