Статьи

Основы WebGL: Часть II

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

Эта статья в значительной степени опирается на первую статью, поэтому, если вы еще не прочитали ее, вам следует начать с нее в первую очередь .

WebGL манипулирует элементами в трехмерном мире, используя математические формулы, известные как преобразования . Поэтому, прежде чем мы начнем создавать 3D-класс, я покажу вам некоторые виды преобразований и способы их реализации.


Есть три основных преобразования при работе с трехмерными объектами.

  • перемещение
  • пересчет
  • вращающийся

Каждая из этих функций может быть выполнена по оси X, Y или Z, что дает в общей сложности девять базовых преобразований. Все они по-разному влияют на матрицу преобразования 4х4 трехмерного объекта. Чтобы выполнить несколько преобразований для одного и того же объекта без проблем с перекрытием, мы должны умножить преобразование в матрицу объекта и не применять его непосредственно к матрице объекта. Переезд проще всего, так что давайте начнем с него.

Перемещение трехмерного объекта — это одно из самых простых преобразований, которое вы можете сделать, потому что для него есть специальное место в матрице 4×4. Там нет необходимости в какой-либо математике; просто поместите координаты X, Y и Z в матрицу и все готово. Если вы смотрите на матрицу 4х4, то это первые три цифры в нижнем ряду. Кроме того, вы должны знать, что положительный Z находится позади камеры. Следовательно, значение Z, равное -100, помещает объект на 100 единиц внутрь на экране. Мы компенсируем это в нашем коде.

Чтобы выполнить несколько преобразований, вы не можете просто изменить реальную матрицу объекта; Вы должны применить преобразование к новой пустой матрице, известной как единичная матрица, и умножить ее на основную матрицу.

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

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function MH(A, B) {
    var Sum = 0;
    for (var i = 0; i < A.length; i++) {
        Sum += A[i] * B[i];
    }
    return Sum;
}
 
function MultiplyMatrix(A, B) {
    var A1 = [A[0], A[1], A[2], A[3]];
    var A2 = [A[4], A[5], A[6], A[7]];
    var A3 = [A[8], A[9], A[10], A[11]];
    var A4 = [A[12], A[13], A[14], A[15]];
 
    var B1 = [B[0], B[4], B[8], B[12]];
    var B2 = [B[1], B[5], B[9], B[13]];
    var B3 = [B[2], B[6], B[10], B[14]];
    var B4 = [B[3], B[7], B[11], B[15]];
 
    return [
    MH(A1, B1), MH(A1, B2), MH(A1, B3), MH(A1, B4),
    MH(A2, B1), MH(A2, B2), MH(A2, B3), MH(A2, B4),
    MH(A3, B1), MH(A3, B2), MH(A3, B3), MH(A3, B4),
    MH(A4, B1), MH(A4, B2), MH(A4, B3), MH(A4, B4)];
}

Я не думаю, что это требует какого-либо объяснения, поскольку это просто необходимая математика для умножения матриц. Давайте перейдем к масштабированию.

Масштабирование модели также довольно просто — это простое умножение. Вы должны умножить первые три диагональных числа на любой масштаб. Еще раз, порядок — X, Y и Z. Итак, если вы хотите, чтобы ваш объект был увеличен в два раза по всем трем осям, вы умножили бы первый, шестой и одиннадцатый элементы в вашем массиве на 2.

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

Не волнуйтесь, если эта картина не имеет смысла для вас; мы скоро рассмотрим реализацию JavaScript.

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

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

Для создания более сложной анимации может потребоваться более одного преобразования. Например, если вы хотите, чтобы дверь открывалась на петлях, вы должны переместить дверь так, чтобы ее петли были на оси Y (то есть 0 на оси X и Z). Затем вы вращаете ось Y, чтобы дверь вращалась на петлях. Наконец, вы бы снова переместили его в нужное место на вашей сцене.

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

Теперь, когда у вас есть общее представление о математике всего этого и о том, как работают анимации, давайте создадим тип данных JavaScript для хранения наших 3D-объектов.


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

Вот моя реализация трехмерного объекта в JavaScript:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function GLObject(VertexArr, TriangleArr, TextureArr, ImageSrc) {
    this.Pos = {
        X: 0,
        Y: 0,
        Z: 0
    };
    this.Scale = {
        X: 1.0,
        Y: 1.0,
        Z: 1.0
    };
    this.Rotation = {
        X: 0,
        Y: 0,
        Z: 0
    };
    this.Vertices = VertexArr;
    this.Triangles = TriangleArr;
    this.TriangleCount = TriangleArr.length;
    this.TextureMap = TextureArr;
    this.Image = new Image();
    this.Image.onload = function () {
        this.ReadyState = true;
    };
    this.Image.src = ImageSrc;
    this.Ready = false;
    //Add Transformation function Here
}

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

Теперь давайте добавим функцию, которая будет вычислять матрицу преобразования объекта. Эта функция возьмет все локальные переменные и умножит их в порядке, который я упоминал ранее (масштаб, вращение, а затем перевод). Вы можете поиграть с этим заказом для различных эффектов. Замените //Add Transformation function Here комментарием следующего кода:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
this.GetTransforms = function () {
    //Create a Blank Identity Matrix
    var TMatrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
 
    //Scaling
    var Temp = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
    Temp[0] *= this.Scale.X;
    Temp[5] *= this.Scale.Y;
    Temp[10] *= this.Scale.Z;
    TMatrix = MultiplyMatrix(TMatrix, Temp);
 
    //Rotating X
    Temp = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
    var X = this.Rotation.X * (Math.PI / 180.0);
    Temp[5] = Math.cos(X);
    Temp[6] = Math.sin(X);
    Temp[9] = -1 * Math.sin(X);
    Temp[10] = Math.cos(X);
    TMatrix = MultiplyMatrix(TMatrix, Temp);
 
 
    //Rotating Y
    Temp = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
    var Y = this.Rotation.Y * (Math.PI / 180.0);
    Temp[0] = Math.cos(Y);
    Temp[2] = -1 * Math.sin(Y);
    Temp[8] = Math.sin(Y);
    Temp[10] = Math.cos(Y);
    TMatrix = MultiplyMatrix(TMatrix, Temp);
 
    //Rotating Z
    Temp = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
    var Z = this.Rotation.Z * (Math.PI / 180.0);
    Temp[0] = Math.cos(Z);
    Temp[1] = Math.sin(Z);
    Temp[4] = -1 * Math.sin(Z);
    Temp[5] = Math.cos(Z);
    TMatrix = MultiplyMatrix(TMatrix, Temp);
 
 
    //Moving
    Temp = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
    Temp[12] = this.Pos.X;
    Temp[13] = this.Pos.Y;
    Temp[14] = this.Pos.Z * -1;
 
    return MultiplyMatrix(TMatrix, Temp);
}

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


Теперь, когда у нас построен 3D-класс, нам нужен способ загрузки данных. Мы сделаем простой импортер моделей, который преобразует файлы .obj в необходимые данные для создания одного из наших недавно созданных объектов GLObject . Я использую формат модели .obj , потому что он хранит все данные в необработанном виде и имеет очень хорошую документацию о том, как он хранит информацию. Если ваша программа 3D-моделирования не поддерживает экспорт в .obj , то вы всегда можете создать импортер для какого-либо другого формата данных. .obj — это стандартный тип файла 3D; так что это не должно быть проблемой. Кроме того, вы также можете скачать Blender, бесплатное кроссплатформенное приложение для 3D-моделирования, которое поддерживает экспорт в .obj

В файлах .obj первые две буквы каждой строки указывают нам, какие данные содержит эта строка. « v » для линии «координаты вершины», « vt » для линии «координаты текстуры» и « f » для линии отображения. С этой информацией я написал следующую функцию:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
function LoadModel(ModelName, CB) {
   var Ajax = new XMLHttpRequest();
   Ajax.onreadystatechange = function () {
       if (Ajax.readyState == 4 && Ajax.status == 200) {
           //Parse Model Data
           var Script = Ajax.responseText.split(«\n»);
 
           var Vertices = [];
           var VerticeMap = [];
 
           var Triangles = [];
 
           var Textures = [];
           var TextureMap = [];
 
           var Normals = [];
           var NormalMap = [];
 
           var Counter = 0;

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

Импортер начинает с создания объекта XMLHttpRequest и определения его onreadystatechange события onreadystatechange . Внутри обработчика мы разбиваем файл на строки и определяем несколько переменных. Файлы .obj сначала определяют все уникальные координаты, а затем определяют их порядок. Вот почему есть две переменные для вершин, текстур и нормалей. Переменная counter используется для заполнения массива треугольников, поскольку файлы .obj определяют треугольники по порядку.

Далее мы должны пройти через каждую строку файла и проверить, что это за строка:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
for (var I in Script) {
               var Line = Script[I];
               //If Vertice Line
               if (Line.substring(0, 2) == «v «) {
                   var Row = Line.substring(2).split(» «);
                   Vertices.push({
                       X: parseFloat(Row[0]),
                       Y: parseFloat(Row[1]),
                       Z: parseFloat(Row[2])
                   });
               }
               //Texture Line
               else if (Line.substring(0, 2) == «vt») {
                   var Row = Line.substring(3).split(» «);
                   Textures.push({
                       X: parseFloat(Row[0]),
                       Y: parseFloat(Row[1])
                   });
               }
               //Normals Line
               else if (Line.substring(0, 2) == «vn») {
                   var Row = Line.substring(3).split(» «);
                   Normals.push({
                       X: parseFloat(Row[0]),
                       Y: parseFloat(Row[1]),
                       Z: parseFloat(Row[2])
                   });
               }

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//Mapping Line
               else if (Line.substring(0, 2) == «f «) {
                   var Row = Line.substring(2).split(» «);
                   for (var T in Row) {
                       //Remove Blank Entries
                       if (Row[T] != «») {
                           //If this is a multi-value entry
                           if (Row[T].indexOf(«/») != -1) {
                               //Split the different values
                               var TC = Row[T].split(«/»);
                               //Increment The Triangles Array
                               Triangles.push(Counter);
                               Counter++;
 
                               //Insert the Vertices
                               var index = parseInt(TC[0]) — 1;
                               VerticeMap.push(Vertices[index].X);
                               VerticeMap.push(Vertices[index].Y);
                               VerticeMap.push(Vertices[index].Z);
 
                               //Insert the Textures
                               index = parseInt(TC[1]) — 1;
                               TextureMap.push(Textures[index].X);
                               TextureMap.push(Textures[index].Y);
 
                               //If This Entry Has Normals Data
                               if (TC.length > 2) {
                                   //Insert Normals
                                   index = parseInt(TC[2]) — 1;
                                   NormalMap.push(Normals[index].X);
                                   NormalMap.push(Normals[index].Y);
                                   NormalMap.push(Normals[index].Z);
                               }
                           }
                           //For rows with just vertices
                           else {
                               Triangles.push(Counter);
                               Counter++;
                               var index = parseInt(Row[T]) — 1;
                               VerticeMap.push(Vertices[index].X);
                               VerticeMap.push(Vertices[index].Y);
                               VerticeMap.push(Vertices[index].Z);
                           }
                       }
                   }
               }

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

Теперь давайте передадим массивы в функцию обратного вызова и LoadModel функцию LoadModel :

1
2
3
4
5
6
7
8
}
            //Return The Arrays
            CB(VerticeMap, Triangles, TextureMap, NormalMap);
        }
    }
    Ajax.open(«GET», ModelName + «.obj», true);
    Ajax.send();
}

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

Теперь давайте Draw функцию Draw из последнего урока, чтобы включить наш новый тип данных 3D-объекта:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
this.Draw = function (Model) {
    if (Model.Image.ReadyState == true && Model.Ready == false) {
        this.PrepareModel(Model);
    }
    if (Model.Ready) {
        this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.Vertices);
        this.GL.vertexAttribPointer(this.VertexPosition, 3, this.GL.FLOAT, false, 0, 0);
 
 
        this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.TextureMap);
        this.GL.vertexAttribPointer(this.VertexTexture, 2, this.GL.FLOAT, false, 0, 0);
 
        this.GL.bindBuffer(this.GL.ELEMENT_ARRAY_BUFFER, Model.Triangles);
 
        //Generate The Perspective Matrix
        var PerspectiveMatrix = MakePerspective(45, this.AspectRatio, 1, 1000.0);
 
        var TransformMatrix = Model.GetTransforms();
        //Set slot 0 as the active Texture
        this.GL.activeTexture(this.GL.TEXTURE0);
 
        //Load in the Texture To Memory
        this.GL.bindTexture(this.GL.TEXTURE_2D, Model.Image);
 
        //Update The Texture Sampler in the fragment shader to use slot 0
        this.GL.uniform1i(this.GL.getUniformLocation(this.ShaderProgram, «uSampler»), 0);
 
        //Set The Perspective and Transformation Matrices
        var pmatrix = this.GL.getUniformLocation(this.ShaderProgram, «PerspectiveMatrix»);
        this.GL.uniformMatrix4fv(pmatrix, false, new Float32Array(PerspectiveMatrix));
 
        var tmatrix = this.GL.getUniformLocation(this.ShaderProgram, «TransformationMatrix»);
        this.GL.uniformMatrix4fv(tmatrix, false, new Float32Array(TransformMatrix));
 
        //Draw The Triangles
        this.GL.drawElements(this.GL.TRIANGLES, Model.TriangleCount, this.GL.UNSIGNED_SHORT, 0);
    }
};

Новая функция рисования сначала проверяет, была ли модель подготовлена ​​для WebGL. Если текстура загружена, она подготовит модель для рисования. Мы PrepareModel функции PrepareModel через минуту. Если модель готова, она подключит свои буферы к шейдерам и загрузит матрицы перспективы и преобразования, как это было раньше. Единственное реальное отличие состоит в том, что теперь он берет все данные из модельного объекта.

Функция PrepareModel просто преобразует текстуру и массивы данных в WebGL-совместимые переменные. Вот функция; добавьте его прямо перед функцией рисования:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
this.PrepareModel = function (Model) {
    Model.Image = this.LoadTexture(Model.Image);
 
    //Convert Arrays to buffers
    var Buffer = this.GL.createBuffer();
 
    this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Buffer);
    this.GL.bufferData(this.GL.ARRAY_BUFFER, new Float32Array(Model.Vertices), this.GL.STATIC_DRAW);
    Model.Vertices = Buffer;
 
    Buffer = this.GL.createBuffer();
 
    this.GL.bindBuffer(this.GL.ELEMENT_ARRAY_BUFFER, Buffer);
    this.GL.bufferData(this.GL.ELEMENT_ARRAY_BUFFER, new Uint16Array(Model.Triangles), this.GL.STATIC_DRAW);
    Model.Triangles = Buffer;
 
    Buffer = this.GL.createBuffer();
 
    this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Buffer);
    this.GL.bufferData(this.GL.ARRAY_BUFFER, new Float32Array(Model.TextureMap), this.GL.STATIC_DRAW);
    Model.TextureMap = Buffer;
 
    Model.Ready = true;
};

Теперь наш фреймворк готов, и мы можем перейти к HTML-странице.


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

Это полный JavaScript:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var GL;
var Building;
 
function Ready() {
    GL = new WebGL(«GLCanvas», «FragmentShader», «VertexShader»);
    LoadModel(«House», function (VerticeMap, Triangles, TextureMap) {
        Building = new GLObject(VerticeMap, Triangles, TextureMap, «House.png»);
 
        Building.Pos.Z = 650;
 
        //My Model Was a bit too big
        Building.Scale.X = 0.5;
        Building.Scale.Y = 0.5;
        Building.Scale.Z = 0.5;
 
        //And Backwards
        Building.Rotation.Y = 180;
 
        setInterval(Update, 33);
    });
}
 
function Update() {
    Building.Rotation.Y += 0.2
    GL.Draw(Building);
}

Мы загружаем модель и просим страницу обновлять ее примерно тридцать раз в секунду. Функция Update вращает модель по оси Y, что достигается обновлением свойства Y Rotation объекта. Моя модель была слишком большой для сцены WebGL, и это было задом наперед, поэтому мне нужно было внести некоторые коррективы в код.

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


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

1
document.onkeydown = handleKeyDown;

Это установит функцию handleKeyDown для обработки события handleKeyDown . Вот код для функции handleKeyDown :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
function handleKeyDown(event) {
    //You can uncomment the next line to find out each key’s code
    //alert(event.keyCode);
 
    if (event.keyCode == 37) {
        //Left Arrow Key
        Building.Pos.X -= 4;
    } else if (event.keyCode == 38) {
        //Up Arrow Key
        Building.Pos.Y += 4;
    } else if (event.keyCode == 39) {
        //Right Arrow Key
        Building.Pos.X += 4;
    } else if (event.keyCode == 40) {
        //Down Arrow Key
        Building.Pos.Y -= 4;
    }
}

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


Мы еще не закончили! В третьей и последней части этого мини-сериала мы рассмотрим различные виды освещения и как связать все это с некоторыми 2D-вещами!

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