3D-графика: учебник по WebGL

Опубликовано: 2022-03-11

Мир 3D-графики может быть очень пугающим. Хотите ли вы просто создать интерактивный 3D-логотип или разработать полноценную игру, если вы не знаете принципов 3D-рендеринга, вы застряли с использованием библиотеки, которая абстрагирует многие вещи.

Использование библиотеки может быть правильным инструментом, и у JavaScript есть замечательный инструмент с открытым исходным кодом в виде three.js. Однако использование готовых решений имеет некоторые недостатки:

  • У них может быть много функций, которые вы не планируете использовать. Размер минимизированных функций base three.js составляет около 500 КБ, а любые дополнительные функции (одной из них является загрузка реальных файлов моделей) делают полезную нагрузку еще больше. Передача такого большого количества данных только для того, чтобы показать вращающийся логотип на вашем веб-сайте, была бы пустой тратой времени.
  • Дополнительный уровень абстракции может затруднить выполнение простых модификаций. Ваш творческий способ затенения объекта на экране может быть либо простым в реализации, либо требовать десятков часов работы для включения в абстракции библиотеки.
  • Хотя в большинстве сценариев библиотека очень хорошо оптимизирована, для вашего варианта использования можно убрать множество наворотов. Средство рендеринга может привести к тому, что определенные процедуры будут выполняться на графической карте миллионы раз. Каждая удаленная из такой процедуры инструкция означает, что более слабая видеокарта без проблем справится с вашим контентом.

Даже если вы решите использовать высокоуровневую графическую библиотеку, базовые знания о том, что под капотом, позволит вам использовать ее более эффективно. Библиотеки также могут иметь расширенные функции, такие как ShaderMaterial в three.js . Знание принципов рендеринга графики позволяет использовать такие возможности.

Иллюстрация трехмерного логотипа Toptal на холсте WebGL

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

Окончательный код доступен для разветвления и экспериментирования.

Представление 3D-моделей

Первое, что вам нужно понять, это то, как представлены 3D-модели. Модель состоит из сетки треугольников. Каждый треугольник представлен тремя вершинами для каждого из углов треугольника. Есть три наиболее распространенных свойства, связанных с вершинами.

Положение вершины

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

Нормальная вершина

Сферы с одинаковым каркасом, к которым применяется плоское и гладкое затенение.

Рассмотрим две модели выше. Они состоят из одних и тех же позиций вершин, но при визуализации выглядят совершенно по-разному. Как это возможно?

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

Сравнение нормалей для плоского и гладкого затенения

Левая и правая поверхности соответствуют левому и правому шару на предыдущем изображении соответственно. Красные стрелки представляют нормали, указанные для вершины, а синие стрелки представляют расчеты средства визуализации того, как нормаль должна выглядеть для всех точек между вершинами. На изображении показана демонстрация для 2D-пространства, но тот же принцип применяется и в 3D.

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

Текстурные координаты

Последним важным свойством являются координаты текстуры, обычно называемые UV-отображением. У вас есть модель и текстура, которую вы хотите применить к ней. Текстура имеет различные области, представляющие изображения, которые мы хотим применить к разным частям модели. Должен быть способ отметить, какой треугольник должен быть представлен какой частью текстуры. Вот где на помощь приходит наложение текстур.

Для каждой вершины мы отмечаем две координаты, U и V. Эти координаты представляют положение на текстуре, где U представляет собой горизонтальную ось, а V — вертикальную ось. Значения не в пикселях, а в процентах от позиции на изображении. Левый нижний угол изображения представлен двумя нулями, а правый верхний — двумя единицами.

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

Демонстрация UV-мэппинга с выделенным одним патчем и видимыми на модели швами.

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

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

Загрузка модели OBJ

Хотите верьте, хотите нет, но это все, что вам нужно знать, чтобы создать свой собственный простой загрузчик моделей. Формат файла OBJ достаточно прост, чтобы реализовать синтаксический анализатор в несколько строк кода.

В файле перечислены позиции вершин в формате v <float> <float> <float> с необязательным четвертым значением float, которое мы будем игнорировать для простоты. Нормали вершин представляются аналогично vn <float> <float> <float> . Наконец, координаты текстуры представлены с помощью vt <float> <float> с необязательным третьим значением float, которое мы будем игнорировать. Во всех трех случаях поплавки представляют соответствующие координаты. Эти три свойства собраны в три массива.

Грани представлены группами вершин. Каждая вершина представлена ​​индексом каждого из свойств, причем индексы начинаются с 1. Это можно представить различными способами, но мы будем придерживаться f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3 , требуя предоставления всех трех свойств и ограничивая количество вершин на грани тремя. Все эти ограничения сделаны для того, чтобы сделать загрузчик как можно более простым, поскольку все остальные параметры требуют некоторой дополнительной тривиальной обработки, прежде чем они будут в формате, который нравится WebGL.

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

Следующий код анализирует строку, представляющую файл OBJ, и создает модель в виде массива лиц.

 function Geometry (faces) { this.faces = faces || [] } // Parses an OBJ file, passed as a string Geometry.parseOBJ = function (src) { var POSITION = /^v\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var NORMAL = /^vn\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var UV = /^vt\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var FACE = /^f\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)(?:\s+(-?\d+)\/(-?\d+)\/(-?\d+))?/ lines = src.split('\n') var positions = [] var uvs = [] var normals = [] var faces = [] lines.forEach(function (line) { // Match each line of the file against various RegEx-es var result if ((result = POSITION.exec(line)) != null) { // Add new vertex position positions.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = NORMAL.exec(line)) != null) { // Add new vertex normal normals.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = UV.exec(line)) != null) { // Add new texture mapping point uvs.push(new Vector2(parseFloat(result[1]), 1 - parseFloat(result[2]))) } else if ((result = FACE.exec(line)) != null) { // Add new face var vertices = [] // Create three vertices from the passed one-indexed indices for (var i = 1; i < 10; i += 3) { var part = result.slice(i, i + 3) var position = positions[parseInt(part[0]) - 1] var uv = uvs[parseInt(part[1]) - 1] var normal = normals[parseInt(part[2]) - 1] vertices.push(new Vertex(position, normal, uv)) } faces.push(new Face(vertices)) } }) return new Geometry(faces) } // Loads an OBJ file from the given URL, and returns it as a promise Geometry.loadOBJ = function (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(Geometry.parseOBJ(xhr.responseText)) } } xhr.open('GET', url, true) xhr.send(null) }) } function Face (vertices) { this.vertices = vertices || [] } function Vertex (position, normal, uv) { this.position = position || new Vector3() this.normal = normal || new Vector3() this.uv = uv || new Vector2() } function Vector3 (x, y, z) { this.x = Number(x) || 0 this.y = Number(y) || 0 this.z = Number(z) || 0 } function Vector2 (x, y) { this.x = Number(x) || 0 this.y = Number(y) || 0 }

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

Выполнение пространственных преобразований

Все точки загруженной модели относятся к ее системе координат. Если мы хотим перевести, повернуть и масштабировать модель, все, что нам нужно сделать, это выполнить эту операцию в ее системе координат. Система координат A относительно системы координат B определяется положением ее центра в виде вектора p_ab и вектора для каждой из ее осей, x_ab , y_ab и z_ab , представляющих направление этой оси. Итак, если точка перемещается на 10 по оси x системы координат A, то — в системе координат B — она будет двигаться в направлении x_ab , умноженном на 10.

Вся эта информация хранится в следующей матричной форме:

 x_ab.x y_ab.x z_ab.x p_ab.x x_ab.y y_ab.y z_ab.y p_ab.y x_ab.z y_ab.z z_ab.z p_ab.z 0 0 0 1

Если мы хотим преобразовать трехмерный вектор q , нам просто нужно умножить матрицу преобразования на вектор:

 qx qy qz 1

Это заставляет точку перемещаться на qx по новой оси x , на qy по новой оси y и на qz по новой оси z . Наконец, это заставляет точку дополнительно перемещаться по вектору p , поэтому мы используем единицу в качестве последнего элемента умножения.

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

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

Без трансформации

Если никаких преобразований не происходит, то вектор p является нулевым вектором, вектор x равен [1, 0, 0] , y равен [0, 1, 0] и z равен [0, 0, 1] . С этого момента мы будем ссылаться на эти значения как на значения по умолчанию для этих векторов. Применение этих значений дает нам единичную матрицу:

 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1

Это хорошая отправная точка для цепочки преобразований.

Перевод

Преобразование кадра для перевода

Когда мы выполняем перевод, все векторы, кроме вектора p , имеют свои значения по умолчанию. В результате получается следующая матрица:

 1 0 0 px 0 1 0 py 0 0 1 pz 0 0 0 1

Масштабирование

Преобразование кадра для масштабирования

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

 s_x 0 0 0 0 s_y 0 0 0 0 s_z 0 0 0 0 1

Здесь s_x , s_y и s_z представляют масштабирование, применяемое к каждой оси.

Вращение

Трансформация кадра для поворота вокруг оси Z

На изображении выше показано, что происходит, когда мы вращаем систему координат вокруг оси Z.

Вращение приводит к неравномерному смещению, поэтому вектор p сохраняет значение по умолчанию. Теперь все становится немного сложнее. Повороты заставляют движение вдоль определенной оси в исходной системе координат двигаться в другом направлении. Таким образом, если мы повернем систему координат на 45 градусов вокруг оси Z, перемещение вдоль оси x исходной системы координат вызовет движение в диагональном направлении между осями x и y в новой системе координат.

Для простоты мы просто покажем вам, как матрицы преобразования выглядят при вращении вокруг главных осей.

 Around X: 1 0 0 0 0 cos(phi) sin(phi) 0 0 -sin(phi) cos(phi) 0 0 0 0 1 Around Y: cos(phi) 0 sin(phi) 0 0 1 0 0 -sin(phi) 0 cos(phi) 0 0 0 0 1 Around Z: cos(phi) -sin(phi) 0 0 sin(phi) cos(phi) 0 0 0 0 1 0 0 0 0 1

Реализация

Все это можно реализовать в виде класса, который хранит 16 чисел, сохраняя матрицы в порядке столбцов.

 function Transformation () { // Create an identity transformation this.fields = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } // Multiply matrices, to chain transformations Transformation.prototype.mult = function (t) { var output = new Transformation() for (var row = 0; row < 4; ++row) { for (var col = 0; col < 4; ++col) { var sum = 0 for (var k = 0; k < 4; ++k) { sum += this.fields[k * 4 + row] * t.fields[col * 4 + k] } output.fields[col * 4 + row] = sum } } return output } // Multiply by translation matrix Transformation.prototype.translate = function (x, y, z) { var mat = new Transformation() mat.fields[12] = Number(x) || 0 mat.fields[13] = Number(y) || 0 mat.fields[14] = Number(z) || 0 return this.mult(mat) } // Multiply by scaling matrix Transformation.prototype.scale = function (x, y, z) { var mat = new Transformation() mat.fields[0] = Number(x) || 0 mat.fields[5] = Number(y) || 0 mat.fields[10] = Number(z) || 0 return this.mult(mat) } // Multiply by rotation matrix around X axis Transformation.prototype.rotateX = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[5] = c mat.fields[10] = c mat.fields[9] = -s mat.fields[6] = s return this.mult(mat) } // Multiply by rotation matrix around Y axis Transformation.prototype.rotateY = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[10] = c mat.fields[2] = -s mat.fields[8] = s return this.mult(mat) } // Multiply by rotation matrix around Z axis Transformation.prototype.rotateZ = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[5] = c mat.fields[4] = -s mat.fields[1] = s return this.mult(mat) }

Глядя через камеру

А вот и ключевая часть представления объектов на экране: камера. Камера состоит из двух ключевых компонентов; а именно, его положение и то, как он проецирует наблюдаемые объекты на экран.

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

Второй ключевой компонент — это то, как наблюдаемые объекты проецируются на объектив. В WebGL все, что видно на экране, находится в рамке. Поле охватывает от -1 до 1 на каждой оси. Все видимое находится внутри этой коробки. Мы можем использовать тот же подход матриц преобразования, чтобы создать матрицу проекции.

Орфографическая проекция

Прямоугольное пространство преобразуется в правильные размеры буфера кадра с использованием ортогональной проекции.

Простейшей проекцией является орфографическая проекция. Вы берете прямоугольник в пространстве, обозначая ширину, высоту и глубину, при условии, что его центр находится в нулевой позиции. Затем проекция изменяет размер блока, чтобы он соответствовал ранее описанному блоку, в котором WebGL наблюдает за объектами. Поскольку мы хотим изменить размер каждого измерения до двух, мы масштабируем каждую ось на 2/size , где size — это размер соответствующей оси. Небольшое предостережение заключается в том, что мы умножаем ось Z на минус. Это сделано потому, что мы хотим изменить направление этого измерения. Окончательная матрица имеет следующий вид:

 2/width 0 0 0 0 2/height 0 0 0 0 -2/depth 0 0 0 0 1

Перспективная проекция

Усеченная пирамида преобразуется в правильные размеры буфера кадра с использованием перспективной проекции.

Мы не будем вдаваться в детали того, как устроена эта проекция, а просто воспользуемся окончательной формулой, которая к настоящему моменту уже является стандартной. Мы можем упростить его, поместив проекцию в нулевое положение по осям x и y, сделав правый/левый и верхний/нижний пределы равными width/2 и height/2 соответственно. Параметры n и f представляют собой near и far плоскости отсечения, которые являются наименьшим и наибольшим расстоянием, на котором точка может быть захвачена камерой. Они представлены параллельными сторонами усеченного конуса на изображении выше.

Перспективная проекция обычно представлена ​​полем зрения (мы будем использовать вертикальное), соотношением сторон и расстояниями в ближней и дальней плоскостях. Эту информацию можно использовать для расчета width и height , а затем можно создать матрицу из следующего шаблона:

 2*n/width 0 0 0 0 2*n/height 0 0 0 0 (f+n)/(nf) 2*f*n/(nf) 0 0 -1 0

Для расчета ширины и высоты можно использовать следующие формулы:

 height = 2 * near * Math.tan(fov * Math.PI / 360) width = aspectRatio * height

FOV (поле зрения) представляет собой вертикальный угол, который камера фиксирует своим объективом. Соотношение сторон представляет собой соотношение между шириной и высотой изображения и зависит от размеров экрана, на котором выполняется рендеринг.

Реализация

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

 function Camera () { this.position = new Transformation() this.projection = new Transformation() } Camera.prototype.setOrthographic = function (width, height, depth) { this.projection = new Transformation() this.projection.fields[0] = 2 / width this.projection.fields[5] = 2 / height this.projection.fields[10] = -2 / depth } Camera.prototype.setPerspective = function (verticalFov, aspectRatio, near, far) { var height_div_2n = Math.tan(verticalFov * Math.PI / 360) var width_div_2n = aspectRatio * height_div_2n this.projection = new Transformation() this.projection.fields[0] = 1 / height_div_2n this.projection.fields[5] = 1 / width_div_2n this.projection.fields[10] = (far + near) / (near - far) this.projection.fields[10] = -1 this.projection.fields[14] = 2 * far * near / (near - far) this.projection.fields[15] = 0 } Camera.prototype.getInversePosition = function () { var orig = this.position.fields var dest = new Transformation() var x = orig[12] var y = orig[13] var z = orig[14] // Transpose the rotation matrix for (var i = 0; i < 3; ++i) { for (var j = 0; j < 3; ++j) { dest.fields[i * 4 + j] = orig[i + j * 4] } } // Translation by -p will apply R^T, which is equal to R^-1 return dest.translate(-x, -y, -z) }

Это последняя часть, которая нам нужна, прежде чем мы сможем начать рисовать что-то на экране.

Рисование объекта с помощью графического конвейера WebGL

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

Общий взгляд на то, что делают шаги графического конвейера

Первое, что вам нужно понять, это то, как экран представлен в WebGL. Это трехмерное пространство, охватывающее значения от -1 до 1 по осям x , y и z . По умолчанию эта ось z не используется, но вас интересует 3D-графика, поэтому вы захотите включить ее сразу.

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

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

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

На последнем шаге GPU растрирует этот треугольник. Растеризация — это процесс получения векторной графики и определения того, какие пиксели экрана необходимо закрасить для отображения этого объекта векторной графики. В нашем случае GPU пытается определить, какие пиксели расположены внутри каждого треугольника. Для каждого пикселя графический процессор спросит вас, в какой цвет вы хотите, чтобы он был окрашен.

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

Фреймбуфер по умолчанию

Наиболее важным элементом приложения WebGL является контекст WebGL. Вы можете получить к нему доступ с помощью gl = canvas.getContext('webgl') или использовать 'experimental-webgl' в качестве запасного варианта, если используемый в настоящее время браузер еще не поддерживает все функции WebGL. canvas , о котором мы говорили, является DOM-элементом холста, на котором мы хотим рисовать. Контекст содержит много вещей, среди которых буфер кадра по умолчанию.

Вы могли бы свободно описать буфер кадра как любой буфер (объект), на котором вы можете рисовать. По умолчанию буфер кадра по умолчанию хранит цвет для каждого пикселя холста, к которому привязан контекст WebGL. Как описано в предыдущем разделе, когда мы рисуем в буфере кадра, каждый пиксель располагается между -1 и 1 по осям x и y . Мы также упомянули тот факт, что по умолчанию WebGL не использует ось z . Эту функциональность можно включить, запустив gl.enable(gl.DEPTH_TEST) . Отлично, но что такое тест на глубину?

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

Любые розыгрыши, которые вы выполняете, остаются на экране до тех пор, пока вы не скажете им очиститься. Для этого вам нужно вызвать gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) . Это очищает буфер цвета и глубины. Чтобы выбрать цвет очищенных пикселей, используйте gl.clearColor(red, green, blue, alpha) .

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

 function Renderer (canvas) { var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') gl.enable(gl.DEPTH_TEST) this.gl = gl } Renderer.prototype.setClearColor = function (red, green, blue) { gl.clearColor(red / 255, green / 255, blue / 255, 1) } Renderer.prototype.getContext = function () { return this.gl } Renderer.prototype.render = function () { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) } var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) loop() function loop () { renderer.render() requestAnimationFrame(loop) }

Присоединение этого скрипта к следующему HTML-коду даст вам ярко-синий прямоугольник на экране.

 <!DOCTYPE html> <html> <head> </head> <body> <canvas width="800" height="500"></canvas> <script src="script.js"></script> </body> </html>

Вызов requestAnimationFrame вызывает повторный вызов цикла, как только предыдущий кадр будет отрендерен и вся обработка событий будет завершена.

Объекты буфера вершин

Первое, что вам нужно сделать, это определить вершины, которые вы хотите нарисовать. Вы можете сделать это, описав их с помощью векторов в трехмерном пространстве. После этого вы хотите переместить эти данные в ОЗУ графического процессора, создав новый объект Vertex Buffer Object (VBO).

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

Вы можете заполнить VBO, взяв все N вершин, которые у нас есть, и создав массив с плавающей запятой с 3N элементами для позиции вершины и нормали вершины VBO, и 2N для текстурных координат VBO. Каждая группа из трех поплавков или двух поплавков для UV-координат представляет отдельные координаты вершины. Затем мы передаем эти массивы в GPU, и наши вершины готовы для остальной части конвейера.

Поскольку данные теперь находятся в ОЗУ графического процессора, вы можете удалить их из ОЗУ общего назначения. То есть, если вы не захотите позже изменить его и загрузить снова. За каждой модификацией должна следовать загрузка, поскольку модификации в наших JS-массивах не применяются к VBO в фактической оперативной памяти графического процессора.

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

Мы также добавили сериализацию в наш класс Geometry и элементы внутри него.

 Geometry.prototype.vertexCount = function () { return this.faces.length * 3 } Geometry.prototype.positions = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.position answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.normals = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.normal answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.uvs = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.uv answer.push(vx, vy) }) }) return answer } //////////////////////////////// function VBO (gl, data, count) { // Creates buffer object in GPU RAM where we can store anything var bufferObject = gl.createBuffer() // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, bufferObject) // Write the data, and set the flag to optimize // for rare changes to the data we're writing gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW) this.gl = gl this.size = data.length / count this.count = count this.data = bufferObject } VBO.prototype.destroy = function () { // Free memory that is occupied by our buffer object this.gl.deleteBuffer(this.data) }

Тип данных VBO генерирует VBO в переданном контексте WebGL на основе массива, переданного в качестве второго параметра.

Вы можете увидеть три вызова контекста gl . createBuffer() создает буфер. bindBuffer() указывает конечному автомату WebGL использовать эту конкретную память в качестве текущего VBO ( ARRAY_BUFFER ) для всех будущих операций, пока не будет указано иное. После этого мы устанавливаем значение текущего VBO в предоставленные данные с помощью bufferData() .

Мы также предоставляем метод уничтожения, который удаляет наш объект буфера из ОЗУ графического процессора с помощью deleteBuffer() .

Вы можете использовать три VBO и преобразование для описания всех свойств меша вместе с его положением.

 function Mesh (gl, geometry) { var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() }

В качестве примера, вот как мы можем загрузить модель, сохранить ее свойства в сетке, а затем уничтожить ее:

 Geometry.loadOBJ('/assets/model.obj').then(function (geometry) { var mesh = new Mesh(gl, geometry) console.log(mesh) mesh.destroy() })

Шейдеры

Далее следует описанный ранее двухэтапный процесс перемещения точек в желаемые положения и закрашивания всех отдельных пикселей. Для этого напишем программу, которая много раз запускается на видеокарте. Эта программа обычно состоит как минимум из двух частей. Первая часть — это вершинный шейдер , который запускается для каждой вершины и, среди прочего, выводит, где мы должны разместить вершину на экране. Вторая часть — это Fragment Shader , который запускается для каждого пикселя, покрываемого треугольником на экране, и выводит цвет, в который пиксель должен быть окрашен.

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

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

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

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

Существует три типа переменных, которые входят в вершинный шейдер и выходят из него, и все они служат определенному назначению:

  • attribute — это входные данные, которые содержат определенные свойства вершины. Ранее мы описывали положение вершины как атрибут в виде трехэлементного вектора. Вы можете рассматривать атрибуты как значения, описывающие одну вершину.
  • uniform — это входные данные, одинаковые для каждой вершины в одном и том же вызове рендеринга. Допустим, мы хотим иметь возможность перемещать нашу модель, определяя матрицу преобразования. Вы можете использовать uniform -переменную, чтобы описать это. Вы также можете указать ресурсы на графическом процессоре, например текстуры. Вы можете рассматривать униформы как значения, описывающие модель или часть модели.
  • varying — это выходные данные, которые мы передаем фрагментному шейдеру. Поскольку для треугольника вершин потенциально существуют тысячи пикселей, каждый пиксель получит интерполированное значение этой переменной в зависимости от положения. Таким образом, если одна вершина отправляет на выходе 500, а другая — 100, пиксель, находящийся посередине между ними, получит 300 в качестве ввода для этой переменной. Вы можете рассматривать вариации как значения, которые описывают поверхности между вершинами.

So, let's say you want to create a vertex shader that receives a position, normal, and uv coordinates for each vertex, and a position, view (inverse camera position), and projection matrix for each rendered object. Let's say you also want to paint individual pixels based on their uv coordinates and their normals. “How would that code look?” Вы можете спросить.

 attribute vec3 position; attribute vec3 normal; attribute vec2 uv; uniform mat4 model; uniform mat4 view; uniform mat4 projection; varying vec3 vNormal; varying vec2 vUv; void main() { vUv = uv; vNormal = (model * vec4(normal, 0.)).xyz; gl_Position = projection * view * model * vec4(position, 1.); }

Most of the elements here should be self-explanatory. The key thing to notice is the fact that there are no return values in the main function. All values that we would want to return are assigned, either to varying variables, or to special variables. Here we assign to gl_Position , which is a four-dimensional vector, whereby the last dimension should always be set to one. Another strange thing you might notice is the way we construct a vec4 out of the position vector. You can construct a vec4 by using four float s, two vec2 s, or any other combination that results in four elements. There are a lot of seemingly strange type castings which make perfect sense once you're familiar with transformation matrices.

You can also see that here we can perform matrix transformations extremely easily. GLSL is specifically made for this kind of work. The output position is calculated by multiplying the projection, view, and model matrix and applying it onto the position. The output normal is just transformed to the world space. We'll explain later why we've stopped there with the normal transformations.

For now, we will keep it simple, and move on to painting individual pixels.

Fragment Shaders

A fragment shader is the step after rasterization in the graphics pipeline. It generates color, depth, and other data for every pixel of the object that is being painted.

The principles behind implementing fragment shaders are very similar to vertex shaders. There are three major differences, though:

  • There are no more varying outputs, and attribute inputs have been replaced with varying inputs. We have just moved on in our pipeline, and things that are the output in the vertex shader are now inputs in the fragment shader.
  • Our only output now is gl_FragColor , which is a vec4 . The elements represent red, green, blue, and alpha (RGBA), respectively, with variables in the 0 to 1 range. You should keep alpha at 1, unless you're doing transparency. Transparency is a fairly advanced concept though, so we'll stick to opaque objects.
  • At the beginning of the fragment shader, you need to set the float precision, which is important for interpolations. In almost all cases, just stick to the lines from the following shader.

With that in mind, you can easily write a shader that paints the red channel based on the U position, green channel based on the V position, and sets the blue channel to maximum.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec2 clampedUv = clamp(vUv, 0., 1.); gl_FragColor = vec4(clampedUv, 1., 1.); }

The function clamp just limits all floats in an object to be within the given limits. The rest of the code should be pretty straightforward.

With all of this in mind, all that is left is to implement this in WebGL.

Combining Shaders into a Program

The next step is to combine the shaders into a program:

 function ShaderProgram (gl, vertSrc, fragSrc) { var vert = gl.createShader(gl.VERTEX_SHADER) gl.shaderSource(vert, vertSrc) gl.compileShader(vert) if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(vert)) throw new Error('Failed to compile shader') } var frag = gl.createShader(gl.FRAGMENT_SHADER) gl.shaderSource(frag, fragSrc) gl.compileShader(frag) if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(frag)) throw new Error('Failed to compile shader') } var program = gl.createProgram() gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) throw new Error('Failed to link program') } this.gl = gl this.position = gl.getAttribLocation(program, 'position') this.normal = gl.getAttribLocation(program, 'normal') this.uv = gl.getAttribLocation(program, 'uv') this.model = gl.getUniformLocation(program, 'model') this.view = gl.getUniformLocation(program, 'view') this.projection = gl.getUniformLocation(program, 'projection') this.vert = vert this.frag = frag this.program = program } // Loads shader files from the given URLs, and returns a program as a promise ShaderProgram.load = function (gl, vertUrl, fragUrl) { return Promise.all([loadFile(vertUrl), loadFile(fragUrl)]).then(function (files) { return new ShaderProgram(gl, files[0], files[1]) }) function loadFile (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(xhr.responseText) } } xhr.open('GET', url, true) xhr.send(null) }) } }

There isn't much to say about what's happening here. Each shader gets assigned a string as a source and compiled, after which we check to see if there were compilation errors. Then, we create a program by linking these two shaders. Finally, we store pointers to all relevant attributes and uniforms for posterity.

Actually Drawing the Model

Last, but not least, you draw the model.

First you pick the shader program you want to use.

 ShaderProgram.prototype.use = function () { this.gl.useProgram(this.program) }

Then you send all the camera related uniforms to the GPU. These uniforms change only once per camera change or movement.

 Transformation.prototype.sendToGpu = function (gl, uniform, transpose) { gl.uniformMatrix4fv(uniform, transpose || false, new Float32Array(this.fields)) } Camera.prototype.use = function (shaderProgram) { this.projection.sendToGpu(shaderProgram.gl, shaderProgram.projection) this.getInversePosition().sendToGpu(shaderProgram.gl, shaderProgram.view) }

Finally, you take the transformations and VBOs and assign them to uniforms and attributes, respectively. Since this has to be done to each VBO, you can create its data binding as a method.

 VBO.prototype.bindToAttribute = function (attribute) { var gl = this.gl // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, this.data) // Enable this attribute in the shader gl.enableVertexAttribArray(attribute) // Define format of the attribute array. Must match parameters in shader gl.vertexAttribPointer(attribute, this.size, gl.FLOAT, false, 0, 0) }

Then you assign an array of three floats to the uniform. Each uniform type has a different signature, so documentation and more documentation are your friends here. Finally, you draw the triangle array on the screen. You tell the drawing call drawArrays() from which vertex to start, and how many vertices to draw. The first parameter passed tells WebGL how it shall interpret the array of vertices. Using TRIANGLES takes three by three vertices and draws a triangle for each triplet. Using POINTS would just draw a point for each passed vertex. There are many more options, but there is no need to discover everything at once. Below is the code for drawing an object:

 Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) }

The renderer needs to be extended a bit to accommodate all the extra elements that need to be handled. It should be possible to attach a shader program, and to render an array of objects based on the current camera position.

 Renderer.prototype.setShader = function (shader) { this.shader = shader } Renderer.prototype.render = function (camera, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }

We can combine all the elements that we have to finally draw something on the screen:

 var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Geometry.loadOBJ('/assets/sphere.obj').then(function (data) { objects.push(new Mesh(gl, data)) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) loop() function loop () { renderer.render(camera, objects) requestAnimationFrame(loop) } 

Object drawn on the canvas, with colors depending on UV coordinates

This looks a bit random, but you can see the different patches of the sphere, based on where they are on the UV map. You can change the shader to paint the object brown. Just set the color for each pixel to be the RGBA for brown:

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); gl_FragColor = vec4(brown, 1.); } 

Brown object drawn on the canvas

It doesn't look very convincing. It looks like the scene needs some shading effects.

Adding Light

Lights and shadows are the tools that allow us to perceive the shape of objects. Lights come in many shapes and sizes: spotlights that shine in one cone, light bulbs that spread light in all directions, and most interestingly, the sun, which is so far away that all the light it shines on us radiates, for all intents and purposes, in the same direction.

Sunlight sounds like it's the simplest to implement, since all you need to provide is the direction in which all rays spread. For each pixel that you draw on the screen, you check the angle under which the light hits the object. This is where the surface normals come in.

Demonstration of angles between light rays and surface normals, for both flat and smooth shading

You can see all the light rays flowing in the same direction, and hitting the surface under different angles, which are based on the angle between the light ray and the surface normal. The more they coincide, the stronger the light is.

If you perform a dot product between the normalized vectors for the light ray and the surface normal, you will get -1 if the ray hits the surface perfectly perpendicularly, 0 if the ray is parallel to the surface, and 1 if it illuminates it from the opposite side. So anything between 0 and 1 should add no light, while numbers between 0 and -1 should gradually increase the amount of light hitting the object. You can test this by adding a fixed light in the shader code.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); gl_FragColor = vec4(brown * lightness, 1.); } 

Brown object with sunlight

Мы заставляем солнце светить в направлении вперед-влево-вниз. Вы можете видеть, насколько гладкое затенение, даже несмотря на то, что модель очень неровная. Вы также можете заметить, насколько темной стала нижняя левая сторона. Мы можем добавить уровень рассеянного света, который сделает область в тени ярче.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); float ambientLight = 0.3; lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); } 

Коричневый объект с солнечным светом и рассеянным светом

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

Теперь шейдер становится:

 #ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }

Затем вы можете определить свет:

 function Light () { this.lightDirection = new Vector3(-1, -1, -1) this.ambientLight = 0.3 } Light.prototype.use = function (shaderProgram) { var dir = this.lightDirection var gl = shaderProgram.gl gl.uniform3f(shaderProgram.lightDirection, dir.x, dir.y, dir.z) gl.uniform1f(shaderProgram.ambientLight, this.ambientLight) }

В классе шейдерной программы добавьте необходимые юниформы:

 this.ambientLight = gl.getUniformLocation(program, 'ambientLight') this.lightDirection = gl.getUniformLocation(program, 'lightDirection')

В программе добавляем вызов нового света в рендерере:

 Renderer.prototype.render = function (camera, light, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() light.use(shader) camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }

Затем цикл немного изменится:

 var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }

Если вы все сделали правильно, то отрендеренное изображение должно быть таким, каким оно было на последнем изображении.

Последним шагом, который следует рассмотреть, будет добавление фактической текстуры к нашей модели. Давайте сделаем это сейчас.

Добавление текстур

HTML5 отлично поддерживает загрузку изображений, поэтому нет необходимости заниматься их сумасшедшим анализом. Изображения передаются в GLSL как sampler2D , сообщая шейдеру, какую из связанных текстур нужно сэмплировать. Существует ограниченное количество текстур, которые можно связать, и это ограничение зависит от используемого оборудования. У sampler2D можно запросить цвета в определенных позициях. Здесь вступают в действие UV-координаты. Вот пример, где мы заменили коричневый цвет образцами.

 #ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; uniform sampler2D diffuse; varying vec3 vNormal; varying vec2 vUv; void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }

Новая форма должна быть добавлена ​​в список в шейдерной программе:

 this.diffuse = gl.getUniformLocation(program, 'diffuse')

Наконец, мы реализуем загрузку текстур. Как было сказано ранее, HTML5 предоставляет средства для загрузки изображений. Все, что нам нужно сделать, это отправить изображение на GPU:

 function Texture (gl, image) { var texture = gl.createTexture() // Set the newly created texture context as active texture gl.bindTexture(gl.TEXTURE_2D, texture) // Set texture parameters, and pass the image that the texture is based on gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image) // Set filtering methods // Very often shaders will query the texture value between pixels, // and this is instructing how that value shall be calculated gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) this.data = texture this.gl = gl } Texture.prototype.use = function (uniform, binding) { binding = Number(binding) || 0 var gl = this.gl // We can bind multiple textures, and here we pick which of the bindings // we're setting right now gl.activeTexture(gl['TEXTURE' + binding]) // After picking the binding, we set the texture gl.bindTexture(gl.TEXTURE_2D, this.data) // Finally, we pass to the uniform the binding ID we've used gl.uniform1i(uniform, binding) // The previous 3 lines are equivalent to: // texture[i] = this.data // uniform = i } Texture.load = function (gl, url) { return new Promise(function (resolve) { var image = new Image() image.onload = function () { resolve(new Texture(gl, image)) } image.src = url }) }

Этот процесс мало чем отличается от процесса, используемого для загрузки и привязки VBO. Основное отличие состоит в том, что мы больше не привязываемся к атрибуту, а привязываем индекс текстуры к целочисленному юниформу. Тип sampler2D — это не что иное, как смещение указателя на текстуру.

Теперь все, что нужно сделать, это расширить класс Mesh для обработки текстур:

 function Mesh (gl, geometry, texture) { // added texture var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.texture = texture // new this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() } Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.texture.use(shaderProgram.diffuse, 0) // new this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) } Mesh.load = function (gl, modelUrl, textureUrl) { // new var geometry = Geometry.loadOBJ(modelUrl) var texture = Texture.load(gl, textureUrl) return Promise.all([geometry, texture]).then(function (params) { return new Mesh(gl, params[0], params[1]) }) }

И окончательный основной скрипт будет выглядеть следующим образом:

 var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Mesh.load(gl, '/assets/sphere.obj', '/assets/diffuse.png') .then(function (mesh) { objects.push(mesh) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) } 

Текстурированный объект со световыми эффектами

На этом этапе даже анимация становится легкой. Если вы хотите, чтобы камера вращалась вокруг нашего объекта, вы можете сделать это, просто добавив одну строку кода:

 function loop () { renderer.render(camera, light, objects) camera.position = camera.position.rotateY(Math.PI / 120) requestAnimationFrame(loop) } 

Повернутая голова во время анимации камеры

Не стесняйтесь играть с шейдерами. Добавление одной строки кода превратит это реалистичное освещение в нечто мультяшное.

 void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = lightness > 0.1 ? 1. : 0.; // new lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }

Это так же просто, как сказать освещению перейти в крайности в зависимости от того, пересекло ли оно установленный порог.

Голова с мультяшным освещением

Куда идти дальше

Существует множество источников информации для изучения всех хитростей и тонкостей WebGL. И самое приятное то, что если вы не можете найти ответ, относящийся к WebGL, вы можете поискать его в OpenGL, поскольку WebGL в значительной степени основан на подмножестве OpenGL с некоторыми измененными именами.

В произвольном порядке, вот несколько отличных источников для получения более подробной информации, как для WebGL, так и для OpenGL.

  • Основы WebGL
  • Изучение WebGL
  • Очень подробное руководство по OpenGL, которое проведет вас по всем фундаментальным принципам, описанным здесь, очень медленно и подробно.
  • И есть много, много других сайтов, посвященных обучению вас принципам компьютерной графики.
  • Документация MDN для WebGL
  • Спецификация Khronos WebGL 1.0 для тех, кто хочет узнать больше технических подробностей о том, как WebGL API должен работать во всех пограничных случаях.