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

Paul Lewis

Введение

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

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

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

1. Наши два шейдера

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

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

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

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

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

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

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

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

4. Переменные шейдера

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

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

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

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

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

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

5. Мир Бонджорно

Итак, вот пример работы вершинных шейдеров:

/**
* 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 передает нам пару униформ. Эти две униформы представляют собой 4D-матрицы, называемые матрицей модели-вида и матрицей проекции. Вам не обязательно точно знать, как они работают, хотя всегда лучше понимать, как всё работает. Вкратце, они определяют, как 3D-положение вершины проецируется в конечное 2D-положение на экране.

Я на самом деле не включил их в пример, потому что 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 еще два свойства: uniform-переменные и attributes. Оба могут принимать векторы, целые числа или числа с плавающей запятой, но, как я уже упоминал, uniform-переменные одинаковы для всего кадра или для всех вершин, поэтому они, как правило, представляют собой единичные значения. Атрибуты же являются переменными для каждой вершины, поэтому ожидается, что они будут массивом. Должно существовать однозначное соответствие между количеством значений в массиве attributes и количеством вершин в сетке.

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

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

8. Искусственное освещение

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

// 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 или равное ему (полностью освещенная), тогда как вершина сбоку будет иметь значение, близкое к 0, а сзади — -1. Мы ограничиваем значение 0 для любых отрицательных значений, но когда вы подставляете числа, вы получаете базовое освещение, которое мы видим.

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

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. Параметр `uniform` задаёт величину смещения в каждом кадре. Для этого можно использовать синус или косинус, поскольку их значения варьируются от -1 до 1.
  2. Цикл анимации в JavaScript

Мы собираемся добавить униформу как к MeshShaderMaterial, так и к Vertex Shader. Сначала Vertex Shader:

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-коде нам теперь нужно обернуть вызов функции render в другую функцию, а затем использовать 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);

Заключение

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

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