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

Введение

Ранее я знакомил вас с Three.js . Если вы еще не читали это, возможно, вам захочется прочитать это, поскольку это фундамент, на котором я буду строить эту статью.

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

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

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

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

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

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

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

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

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

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

Говоря о переменных, вы можете сделать три объявления: Uniforms , Attributes и Varyings . Когда я впервые услышал об этих троих, я был очень сбит с толку, поскольку они не соответствуют ничему, с чем я когда-либо работал. Но вот как вы можете о них думать:

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

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

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

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

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

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

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

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

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

8. Фальшивый свет

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

// 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 colour
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. Униформа для анимации того, какое смещение следует применить в каждом кадре. Для этого мы можем использовать синус или косинус, поскольку они работают от -1 до 1.
  2. Цикл анимации в JS

Мы собираемся добавить единообразие как в MeshShaderMaterial, так и в 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 нам нужно обернуть вызов рендеринга в функцию, а затем использовать requestAnimationFrame для ее вызова. Там нам также необходимо обновить значение униформы.

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);

12. Заключение

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

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