3D своими руками. Часть 3: чем дальше в лес, тем меньше дом

В этой статье мы узнаем что такое камера в 3D пространстве (матрица вида) и как она работает, узнаем как сделать те объекты что дальше — меньшего размера (матрица перспективной проекции). 

Мы продолжим работу над кодом, который у нас получился в финале 2-й части.  И начнем с обсуждения камеры в пространстве (м.б. в 3D игре).

В прошлой статье мы описали при помощи 8-ми вершин куб, и все его вершины были в диапазоне [-1, 1] и мы их расположили так, что центр системы координат [0, 0, 0], оказался также центром нашего куба. Это сделано было не просто так. Вспомним преобразования с прошлой части где мы вращали куб сначала вокруг оси X, а потом вокруг оси Y, потом увеличивали куб и перемещали, там мы упоминали про особенности  работы с матрицами вращения, а именно, о том, что порядок вращения вокруг осей имеет  значение, и при перестановки порядка вращения местами мы получаем разный результат. Все это происходило потому что когда мы вращаем куб, например, вокруг оси X, то его ось Y, должна была бы сместиться. Например, у нас есть кубик Рубика в собраном виде, и его синяя сторона смотрит вверх и ось Y проходит через ее центр. В таком случае легко  понимать где ось Y (там где синяя сторона). А теперь попробуйте повернуть на 90 градусов кубик Рубика вокруг оси X, и синяя сторона кубика должна  стать лицом к нам или от нас (зависит в какую сторону вращали), но в любом случае синяя сторона больше не указывает вверх, т.е. в нашем случаем, ось Y теперь указывает в другом направлении, и если бы мы вращали кубик Рубика  вокруг оси Y, то должны были бы получить вращение вокруг все той же синей стороны, но нет, вращение, в нашем случае, будет вокруг новой стороны кубика, которая теперь смотрит вверх, вместо синей стороны. Т.е. в наших матрицах вращения оси не привязаны к 3D моделям и модели всегда вращаются вокруг осей, смотрящим вверх\вправо\к нам, в нашей системе координат  (еще говорят абсолютные оси), иными словами, вокруг точки 0, 0, 0. Из этого также следует еще пара особенностей, если нашу фигуру куба, сначала сдвинуть вправо, например, на 10 пикселей, а потом попробовать повернуть вокруг любой оси, то мы увидим что куб не вращается вокруг своего центра, а теперь вращается с большим радиусом вокруг старого центра 0, 0, 0, который был до того как мы подвинули куб на 10 пикселей вправо. Это еще раз подтверждает то, что все фигуры могут вращаться правильно, только пока они находятся в начале координат, вокруг точки 0, 0, 0. Это не является багой, т.к. у нас вполне  может быть задача вращать куб, не вокруг своего центра, а вокруг  какой-то точки в игре:

Сначала вращаем, потом перемещаемСначала вращаем, потом перемещаемСначала перемещаем, потом вращаемСначала перемещаем, потом вращаем

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

Изначально в API канвас так заложено, что точка 0, 0 это верхний левый угол области рисования и если бы мы нарисовали наш куб в локальных координата (от -1, до 1 по всем осям) то мы бы получили четверть куба в верхнем левом углу, т.к. вся его остальная часть была бы за пределами экрана, но чтобы куб оказался по центру экрана и мы его видели целиком, его умножили на матрицу перемещения Matrix.getTranslation(400, -300, 0) и таким образом переместили куб так, чтобы он оказался точно в середине окна. Это и есть ключевая особенность работы камеры в 3D играх и приложениях, мы не можем переместить программно заложенное начало координат в API Canvas (это всегда будет 0, 0 в верхнем левом углу), но мы можем двигать модель так, чтобы ее координаты X, Y совпали в видимой областью окна (от 0 до 800 по X и от 0 до 600 по Y в нашем случае), ведь если мы нарисуем модель в координатах 1000, 1000, то это будет сильно ниже и правее области рисования canvas и такую модель в окне мы  не увидим. Если хотим увидеть модель, которая находится в координатах за пределами области рисования, то нам нужно ее подвинуть так, чтобы она была в ее пределах, иными словами, мы двигаем не камеру, а сам объект. В играх действует такое же правило — мы двигаем все объекты на карте (весь игровой мир) так, чтобы они оказались под нужным ракурсом в области рисования окна.

До этого мы уже имитировали камеру (хоть и не упоминали это) при работе с кубом, при помощи матрицы перемещения, когда  двигали куб ниже\правее, чтобы он поместился в окне. Но это не самый удобный вариант, т.к. у камеры больше параметров чем просто  местоположение. Например, у камеры может быть положение (место  откуда мы смотрим или место, где сейчас стоит игрок от первого лица), а также точка в которую мы смотрим, ведь, стоя на месте мы можем крутить головой куда захотим. А также есть еще третий параметр, вектор направления вверх, он нужен чтобы указать где в 3D-мире находится верх, а где низ, в случае, если мы хотим, например, перевернуть 3D-сцену и отобразить момент в игре, где персонаж ходит по потолку, или что-то подобное, мы это также применим на практике.

Прежде чем мы попробуем реализовать камеру в сцене, давайте наш куб, превратим в усеченную пирамиду (пирамиду, у которой ровно срезали верхушку, в нашем случае правильная четырехугольная усеченная пирамида), это нужно чтобы видеть где у нашей фигуры верх, ведь если куб перевернуть вверх-ногами то для нас, как для наблюдателя ничего не поменяется. Для создания усеченной пирамиды нам достаточно у куба вершины верхней стороны (те у которых Y=1) сделать ближе к оси Y. Y оставим прежним, чтобы пирамида была такой же высоты как и куб, а X и Z сделаем равными 0.5 в массиве вершин:

const vertices = [
  new Vector(-0.5, 1, 0.5), // 0 вершина
  new Vector(-0.5, 1, -0.5), // 1 вершина
  new Vector(0.5, 1, -0.5), // 2 вершина
  new Vector(0.5, 1, 0.5), // 3 вершина
  new Vector(-1, -1, 1), // 4 вершина
  new Vector(-1, -1, -1), // 5 вершина
  new Vector(1, -1, -1), // 6 вершина
  new Vector(1, -1, 1), // 7 вершина
];

В примере мы заменили X и Z на 0.5 для всех вершин у которых Y = 1, т.е. для верхней стороны куба, и также давайте в месте, где мы комбинируем преобразования (перемножаем матрицы преобразований) отключим временно анимацию с предыдущей статьи (сам setInterval оставим, он понадобится немного позже), для этого в строке где мы задаем вращение по Y, установите константное значение 20:

matrix = Matrix.multiply(
  // Matrix.getRotationY(angle += 1), - предыдущее значение
  Matrix.getRotationY(20), // - новое значение 20 (без анимации)
  matrix
);

Также попробуйте самостоятельно закомментировать код отрисовки осей X/Y/Z с предыдущей статьи (хотя это не обязательно, он нам мешать не будет). После проделанных операций, у вас должна получиться такая 3D-модель:

image-loader.svg

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

static substruct(v1, v2) {
  return new Vector(
    v1.x - v2.x,
    v1.y - v2.y,
    v1.z - v2.z,
  )
}

Результатом вычитания двух векторов v1 и v2 будет третий вектор, который можно сложить со вторым вектором v2 и получить обратно v1. Сейчас можно не вникать в эту формулировку, достаточно понять как это вычитание выполняется.

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

static crossProduct(v1, v2) {
  return new Vector(
    v1.y * v2.z - v1.z * v2.y,
    v1.z * v2.x - v1.x * v2.z,
    v1.x * v2.y - v1.y * v2.x,
  )
}

Матрицу, которой мы воспользуемся для имитации камеры также как все предыдущие матрицы преобразований добавим как метод в класс Matrix:

static getLookAt(eye, target, up) {
  const vz = Vector.substruct(eye, target).normalize()
  const vx = Vector.crossProduct(up, z).normalize()
  const vy = Vector.crossProduct(z, x)
  return [
    [vx.x, vy.x, vz.x, -eye.x],
    [vx.y, vy.y, vz.y, -eye.y],
    [vx.z, vy.z, vz.z, -eye.z],
    [0, 0, 0, 1],
  ]
}

Как видим метод getLookAt принимает 3 параметра, eye — это наше текущее местоположение с которого смотрим (иными словами местоположение глаза или игрового персонажа с видом от первого лица), target — это точка в направление которой мы смотрим, up — вектор, который показывает где сейчас верх, его можно использовать если мы например захотим перевернуть или наклонить всю сцену (попробуем в примерах). И в результате перемножения всех вершин 3D-модели с такой матрицей мы получим новые положения вершин, в которых они будут повернуты так, как если бы на них смотрели под заданными (eye, target, up) параметрами. Давайте сейчас попробуем применить эту матрицу в конвейере визуализации. Но перед этим — исправим одну ошибку отображения сцены. Закомментируйте код, который перемещает куб на 400 пикселей вправо и на 300 пикселей вниз:

// matrix = Matrix.multiply(
//   Matrix.getTranslation(400, -300, 0),
//   matrix,
// );

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

image-loader.svg

Теперь когда мы не двигали модель, видим начальное положение нашей камеры. Модель находится вокруг точки 0, 0, 0 и в 3D-графике, она должна быть по середине экрана, а видим мы лишь кусочек пирамиды, потому что в канвасе точка 0, 0, это не центр, а верхний левый угол. Давайте для правильности дальнейших расчетов поместим начало координат так, чтобы если вершина оказывалась в точке 0, 0, 0, (я пишу 0, 0, 0 для удобства понимания, что речь идет о 3d-положении хотя, мы координату Z пока что не используем при отрисовке пикселей модели) то она была бы нарисована в середине окна, а не в верхнем левом углу, и наше начальное положение камеры было правильно отображено. Ведь сейчас, камера, если мы ничего с не делали, находится в точке 0, 0, 0 и фигура находится вокруг это точки, и поэтому, фигура должна быть прямо перед нами посередине (хочу сделать акцент на том, что камера это не какой-то объект в игре, через который мы видим сцену, это способ повернуть все 3D модели так, чтобы при отрисовки их пикселей, координаты моделей были в диапазоне 0 — 800 по ширине и 0 — 600 по высоте, в нашем случае, т.к. размеры канваса мы указали 800 на 600, и если модель будет в другой точке, то на канвасе этот пиксель попросту не нарисуется). Для этого нам нужно немного изменить метод отрисовки пикселя. Перед тем как выводить пиксель на канвас, мы добавим к X половину ширины канваса и отнимем от Y половину высоты канваса (отнимаем т.к. ось Y на канвасе перевернута). Это очень похоже на смещение которое мы закомментировали выше, но смещение меняло координаты вершин, что повлияло бы на дальнейшие расчеты, а так мы смещение добавляем безусловно к финальным X,Y выводимого пикселя, что ни на какие расчеты не влияет, т.к. дальше уже расчетов вершин нет, есть только их вывод:

drawPixel(x, y, r, g, b) {
  x += this.width / 2; // перемещаем Х в центр канваса
  y = -(y - this.height / 2) // перемещаем Y в центр канваса
  const offset = (this.width * y + x) * 4;
  if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
    this.surface[offset] = r;
    this.surface[offset + 1] = g;
    this.surface[offset + 2] = b;
    this.surface[offset + 3] = 255;
  }
}

Теперь, если вы запустите код, у вас фигура никуда не смещалась, но при этом находится в середине канваса, как и должно быть:

image-loader.svg

Давайте попробуем поработать с камерой. Предположим, что наш персонаж все также смотрит в сторону пирамиды (уменьшения оси Z), но отошел на 200 пикселей вправо, попробуем описать это параметрами eye, target, up для этого добавим матрицу вида в конвейер визуализации прямо перед циклом перебора вершин (последнее умножение после матриц поворотов вершин пирамиды):

matrix = Matrix.multiply(
  Matrix.getLookAt(
    new Vector(200, 0, 0),
    new Vector(200, 0, -1),
    new Vector(0, 1, 0),
  ),
  matrix,
);

В этом примере в качестве точки в которой мы находимся я указал X=200, это значит что весь 3D-мир сдвинется левее, на 200 пикселей, почему левее? Ведь если в реально жизни мы боком пойдем вправо (как краб) то все объекты который были перед нами уходят влево, таким образом и спроектирована матрица вида, чтобы все двигать противоположно направлению камеры, которая всегда на месте. Второй параметр (target) я тоже указал X=200, Y=0, но Z=-1, таким образом направление взгляда я не менял по X, Y,  , а вот при помощи Z, я указал что смотрю в координату -1, т.е. просто в даль. Если бы я указал не -1, а -1000, то ничего бы не поменялось, т.к. точка в которую я смотрю хоть и находилась бы дальше, но направление между моим текущим местоположением и точкой в которую смотрю было бы таким же, просто вектор между ними был бы длиннее. Если мы хотим получить вектор направление в которое мы сейчас смотрим, то можно его вычислить при помощи  метода Vector.substruct(target, eye). Например, если мы в точке 200, 0, 0, смотрим в точку 200, 0, -1000, то после вычитания eye из target мы получим [200 — 200, 0 - 0, -1000 - 0] = [0, 0, -1000] — это вектор направления взгляда, но его длина 1000, и если мы его нормализуем, то получим 0, 0, -1 и нету никакой разницы, смотреть в 0, 0, -1 или в 0, 0, -1000. Но есть и случаи когда большой X, Y, Z влияет на камеру, например, когда мы смотрим не только в направлении одной оси. Попробуем подставить разные значения в камеру вида, и посмотрим что получится:

Matrix.getLookAt(
  new Vector(200, 0, 0),
  new Vector(100, 0, -1),
  new Vector(0, 1, 0),
),

В варианте выше, мы указали X=100 для цели (куда смотрим), и в разнице векторов, мы получим -100, 0, -1 без нормализации видно что вектор направления взгляда смотрит влево и маленький Z, очень слабо влияет на то, куда мы смотрим. Давайте для большей наглядности примера, закомментируем матрицу вращения по оси Y (вращение по оси X на 20 градусов оставим). И теперь на экране у вас должно быть следующее:

image-loader.svg

Мы пирамиду наклонили только по оси X, сама пирамида в точке 0, 0, 0 и мы смотрим из точки 200, 0, 0 в точку 100, 0, -1, т.е. стоя справа от пирамиды мы смотрим налево, и видим ее правый бок, который из нашего ракурса наклонен немного вбок по часовой стрелке, хотя это те самые 20 градусов по X, просто сейчас у нас другой ракурс. Теперь давайте увеличим Z цели до -100:

Matrix.getLookAt(
  new Vector(200, 0, 0),
  new Vector(100, 0, -100),
  new Vector(0, 1, 0),
),

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

image-loader.svg

Один из моментов который может нас смутить, это то, что вершины пирамиды строятся вокруг точки 0, 0, 0 и мы их увеличили в 100 раз матрицей масштабирования, это значит что несмотря на то, что мы можем смотреть на пирамиду изнутри или из очень близкого\дальнего расстояния, она все равно будет одного и того же размера, это потому что мы пока что не учитываем координату Z, для того чтобы увеличить\уменьшить объект с его приближением\удалением от нас соответственно. На это мы посмотрим в следующем разделе этой статьи. Давайте рассмотрим еще один пример, попробуем расположить камеру так, чтобы посмотреть на пирамиду сверху:

Matrix.getLookAt(
  new Vector(0, 0, 0),
  new Vector(0, -1, 0),
  new Vector(0, 0, 1),
),

Обратите внимание, что мое текущее положение это 0, 0, 0, т.е. я внутри пирамиды и смотрю я строго вниз в направление 0, -1, 0, но все равно я ее вижу, т.к. мы еще не учитываем расстояние до объекта, также немаловажно что когда я начал смотреть строго вниз (по направлению оси Y), у меня поменялся вектор направления (параметр up) вверх, теперь это Z и если я его укажу Z=1 то увижу такую картинку:

image-loader.svg

Это вид пирамиды сверху, и также видно что она немного наклонена по оси X

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

Сейчас вернем конвейер визуализации должен выглядеть так:

let matrix = Matrix.getRotationX(20);

// matrix = Matrix.multiply(
//   // Matrix.getRotationY(angle += 1),
//   matrix
// );

matrix = Matrix.multiply(
  Matrix.getScale(100, 100, 100),
  matrix,
);

// matrix = Matrix.multiply(
//   Matrix.getTranslation(400, -300, 0),
//   matrix,
// );

// Передвигаем пирамиду так, чтобы выглядело как будто мы 
// смотрим на неё сверху ( иными словами используем матрицу 
// вида для эффекта камеры )
matrix = Matrix.multiply(
  Matrix.getLookAt(
    new Vector(0, 0, 0),
    new Vector(0, -1, 0),
    new Vector(0, 0, 1),
  ),
  matrix,
);

Теперь давайте разберемся с удалением объектов. В примере выше экспериментируя с камерой (или матрицей перемещения) мы заметили что Z-никак не влияет на модель. Т.е. объект с меньшим Z должен удаляться от нас и становиться меньше, но этого не происходит. Это связано с тем что мы такое поведение в используемые матрицы до этого не заложено и его нужно реализовывать отдельно. Сейчас нам нужно повернуть пирамиду одной из сторон к нам и камеру направить на нее по оси Z. Для этого давайте отключим вращение по оси Х (можно задать угол вращения 0):

let matrix = Matrix.getRotationX(0);

И камеру поместим в точку 0, 0, 0 и будем смотреть в сторону уменьшения Z, т.е. в 0, 0, -1:

matrix = Matrix.multiply(
  Matrix.getLookAt(
    new Vector(0, 0, 0), // где наблюдатель стоит
    new Vector(0, 0, -1), // в какую точку смотрет
    new Vector(0, 1, 0),
  ),
  matrix,
);

В результате вы должны увидеть следующее:

image-loader.svg

Для того чтобы реализовать эффект перспективы (чем дальше объект — тем он меньше) можно воспользоваться самым простым способом — поделить X и Y каждой точки перед отрисовкой на -Z (со знаком минус т.к. у нас чем меньше Z — тем объект дальше, соответственно более дальний Z нам нужно инвертировать, чтобы он становился больше с отдалением. Например: если у нас точка X была равна 10, а Z = -10, то разделив 10 на -(-10) мы получим 1, X стал меньше в 10 раз. А если объект ближе к камере, например у него Z = -2, то 10 / -(-2) будет равно 5, и такой объект имеет больший X, т.к. ближе к камере. Давайте применим это к пирамиде. Добавьте код деления на Z, перед добавлением вершины в растеризатор:

vertex.x = vertex.x / -vertex.z;
vertex.y = vertex.y / -vertex.z;

// строчки выше перед этой строкой
sceneVertices.push(vertex);

Сделав такое деление вы на экране увидите небольшую точку в центре:

image-loader.svg

Это потому что начав делить X и Y на -Z, мы их слишком сильно уменьшили и теперь нужно умножить результат на подобранный скаляр, чтобы промасштабировать пирамиду, я взял в качестве скаляра число 100 (можно выбрать любое другое):

vertex.x = vertex.x / -vertex.z * 100;
vertex.y = vertex.y / -vertex.z * 100;

Результат:

image-loader.svg

Теперь то что мы видим не сильно похоже на пирамиду, а ведь до деления на -Z все было хорошо. Дело в том что эта пирамида находится вокруг точки 0, 0, 0 и когда мы её описывали — часть Z сделали отрицательные, а часть положительные. Во время деления на -Z — подразумевали что Z у вершин пирамиды отрицательные и тогда все хорошо, ведь если Z отрицательная, то -Z будет положительная и при делении на такую -Z знак числителя не поменяется, например: X = 10, Z=-5, тогда 10 / -(-5) = 2 — новый икс просто чуть меньше из-за отдаления. А если Z=5, тогда 10 / -5 = -2 — как видим X поменял знак и переместился не туда куда нужно и теперь когда мы нарисуем пирамиду используя индексы вершин (массив edges) то они будут соединены неправильно, и часть вершин соединяются накрест, что и видно на скриншоте выше. Для того чтобы такого бага не было, нам не нужно рисовать те фигуры, которые находятся сзади нас и имеют положительный Z (другими словами «за камерой»). Такую проверку сейчас мы делать не будем, т.к. еще не со всеми инструментами познакомились. Для того чтобы исключить эту багу на данном этапе достаточно либо подвинуть eye камеры немного назад (в положительную сторону Z), чтобы камера не была внутри пирамиды или подвинуть пирамиду в отрицательную сторону Z, тогда опять же камера не будет внутри пирамиды. Давайте сделаем второе, раскомментируйте матрицу перемещения и сместите пирамиду только по оси Z, например, на -200:

matrix = Matrix.multiply(
  Matrix.getTranslation(0, 0, -200),
  matrix,
);

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

image-loader.svg

Выглядит, конечно, так себе. Задняя сторона пирамиды сильно маленькая, потому что разница между ближним Z и дальним — большая. И вершины X / Y, которые сзади мы делили на больший Z. Такой подход лишь показал самый простой вариант реализации перспективы, но он не гибкий, т.к. нельзя указывать насколько сильно должна уменьшаться фигура при отдалении (угол обзора), есть баги с отсечением ближних стенок модели, когда часть модели «за камерой», и некоторые другие параметры.

Для исправления этих недочетов уже существует специальная матрица, для реализации перспективной проекции, давайте её добавим в класс Matrix:

static getPerspectiveProjection(fovy, aspect, n, f) {
  const radians = Math.PI / 180 * fovy
  const sx = (1 / Math.tan(radians / 2)) / aspect;
  const sy = (1 / Math.tan(radians / 2));
  const sz = (f + n) / (f - n);
  const dz = (-2 * f * n) / (f - n);
  return [
    [sx, 0, 0, 0],
    [0, sy, 0, 0],
    [0, 0, sz, dz],
    [0, 0, -1, 0],
  ]
}

Эта функция принимает угол обзора в градусах (fovy). Угол обзора нужен чтобы указать как много объектов попадает на канвас от точки с которой мы смотрим. Не понятно? Тогда проще — чем больше угол мы передадим, тем меньше объекты становятся при удалении. Диапазон углов лучше использовать от 1 до 179. Т.к. 1 градус это мы как будто мы смотрим на мир через соломинку и очень мало чего видим, а 180 градусов это мы видим даже все что сбоку от нас, что захватит очень много объектов на сцене, сплющит в точки и мы это нарисуем на канвасе. Как только мы применим эту матрицу в нашем конвейере визуализации самостоятельно, подставляйте разные значения в параметр fovy и смотрите как он меняется отображения нашей модели.

Параметр aspect — соотношение сторон канваса (ширина делить на высоту). Он нужен чтобы после проекции у нас получились правильные соотношения ширины и высоты — дальше сами все увидите.

А также параметры n и f (near и far) — этими параметрами мы подгоняем координаты Z у моделей так, чтобы можно было определить какие модели слишком близко к нам, а какие слишком далеко (Z будет в диапазоне от -1 до 1 после преобразований перспективы), настолько что нам их не нужно рисовать на экране. Результат работы параметров n и f, мы используем немного позже, в следующих частях.

Как устроеные перспективные матрицы очень доступно написано здесь https://habr.com/ru/post/252771/

Давайте уберем деление на Z, которое добавляли ранее:

// vertex.x = vertex.x / -vertex.z * 100;
// vertex.y = vertex.y / -vertex.z * 100;

И в конец конвейера визуализации (после умножения на матрицу lookAt) добавим умножения на матрицу перспективы:

matrix = Matrix.multiply(
  Matrix.getPerspectiveProjection(
    90, 800 / 600,
    -1, -1000),
  matrix,
);

Я указал угол 90 градусов, но вы можете потом подставить и другие углы, чтобы посмотреть на разницу. Также указал соотношение сторон канваса и параметры n, f, -1 и -1000, но пока что их использовать не буду, это на будущее, сейчас n, f вообще ни на что не влияют. Также давайте подвинем пирамиду немного дальше с -200 на -500:

matrix = Matrix.multiply(
  Matrix.getTranslation(0, 0, -500),
  matrix,
);

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

image-loader.svg

Пирамида стала плоской, как до деления на -Z, да еще и вытянутая по вертикали. Это произошло потому что текущая матрица перспективы сделала некоторые подготовительные действия, но не все. Умножив вершины на эту матрицу, мы немного промасштабировали фигуру в зависимости от параметра fovy (чем меньше угол, тем больше масштаб), также подогнали пропорции ширины и высоты для дальнейших расчетов (эти расчеты будут ниже), подогнали Z вершины при помощи n и f, чтобы можно было в дальнейшем понять какие вершины слишком близко или далеко и не рисовать их, а также если вы изучите код getPerspectiveProjection то увидите что в последней строке матрицы мы написали [0, 0, -1, 0] и если проследить как вектор умножается на матрицу, то мы обнаружим, что Z компонента вектора будет умножаться на -1 в матрице, и результат положится в W, т.е. таким способом, мы сохранили -Z, в компоненту W. А сделали мы это затем что нам все таки нужно будет каждую вершину поделить на -Z (теперь уже будем говорить на W), чтобы добиться эффекта перспективы для каждой вершины нашей пирамиды. Вот где нам пригодится то самое W, которое мы до этого всегда указывали 1 для точек. Такие координаты в трехмерном пространстве у которых помимо X, Y, Z есть еще W, мы можем называть однородными. Я пропускал этот момент раньше, т.к. нам W еще не нужна была для практического применения. Важное правило, для того чтобы нарисовать точку на канвасе из однородных координат, и все выглядело правильно, нам нужно чтобы W была равна единице, как и было все время до работы с матрицей перспективы. А теперь когда W отличается от 1, нам нужно всегда X, Y, Z, W делить на W. Например, если у нас после умножения вершины на матрицу перспективы получилась такая вершина [10, 10, 10, 2] то тут мы видим что W=2 и прежде чем нарисовать эту точку на экране, нам нужно каждую её компоненту разделить на W, т.е. [10 / 2, 10 / 2, 10 / 2 , 2 / 2]  и мы получим [5, 5, 5, 1], понятно что W делить на само себя всегда даст 1, поэтому можно эту операцию пропустить. Если например у нас вершина равна [10, 10, 10, 1] тот тут делит ничего не нужно, т.к. W=1 и деление нам ничего не даст, тут уже все можно рисовать на экране. Но мы всегда будем делить на W перед выводом, чтобы не писать условия с проверками W на 1 в конвейере визуализации. Также запомните что W хранит в себе -Z в нашем случае, на которое мы делили еще до матрицы перспективы, просто сейчас мы чуть прокачали этот метод, добавив в конвейер больше параметров при помощи матрицы перспективы (fovy, aspect, n, f). Итого, давайте добавим следующий код перед добавлением вершины в sceneVertices:

vertex.x = (vertex.x / vertex.w * 800)
vertex.y = (vertex.y / vertex.w * 600)
// Вот перед этим кодом 2 строчки выше
sceneVertices.push(vertex);

В коде мы видим что когда мы поделили на W, мы еще умножили X на 800 и Y на 600. Это те же самые скаляры, которые были когда мы делили на -Z, только там было 100, а тут 800 и 600, и теперь они разные, т.к. параметр aspect матрицы перспективы исказил масштаб X и чтобы его вернуть, нужно умножать на число большее чем Y именно в том соотношении что мы передали в aspect матрицы перспективы. Вообще чтобы слишком не нагружаться , я предлагаю просто умножать на ширину и высоту окна канваса X и Y соответственно. Если все сделали правильно у вас должен получиться такой результат:

image-loader.svg

Как видите, очень похоже на то, что было при делении на -Z (которое никуда не делось, просто теперь -Z лежит в W), только теперь у нас больше параметров для настройки проекции, хоть и не всем мы еще воспользовались. Можете раскомментировать строчку с умножением матрицы вращения вокруг оси Y чтобы вернуть анимацию, ведь теперь у нас есть перспектива и все выглядит более красиво:

matrix = Matrix.multiply(
  Matrix.getRotationY(angle += 1),
  matrix
);

На этом заканчивается 3я часть. Есть 2 вещи которые были в планах на 3ю часть в конце второй, это работа с треугольниками и нормалями, но текущая часть вышла объемная, как мне показалось и я принял решение перенести темы про треугольники и нормали в 4ю часть.

Итого в этой части мы узнали что камера это не конкретный объект на сцене (как пирамида), а матрица поворота сцены таким образом, чтобы координаты всех объектов что должны быть видны с желаемого нами ракурса оказались в зоне канваса (в нашем случае от 0 до 800 по X и от 0 до 600 по Y) . Можно даже сказать что канвас (или холст) для рисования это и есть наша камера, а все остальное либо попадает на него, либо нет.

Также мы узнали что чтобы сделать простой эффект перспективы (чем дальше тем меньше) нам достаточно поделить X и Y вершины моделей на -Z (т.к у нас Z с отдалением уменьшается, а для деления нужно чтобы увеличивался) и этот метод немного прокачали, добавив матрицу перспективы, в которой больше параметров, а -Z переместился в W. И чтобы вывести вершины в однородных координатах на экран правильно, нужно все их перед выводом делить на W, что мы и сделали. Весь код доступен под спойлером:

Код всего приложения

const ctx = document.getElementById('surface').getContext('2d');
const imageData = ctx.createImageData(800, 600);

class Vector {
  x = 0;
  y = 0;
  z = 0;
  w = 1;

  constructor(x, y, z, w = 1) {
    this.x = x;
    this.y = y;
    this.z = z;
    this.w = w;
  }

  static substruct(v1, v2) {
    return new Vector(
      v1.x - v2.x,
      v1.y - v2.y,
      v1.z - v2.z,
      v1.w - v2.w
    );
  }

  static crossProduct(a, b) {
    return new Vector(
      a.y * b.z - a.z * b.y,
      a.z * b.x - a.x * b.z,
      a.x * b.y - a.y * b.x
    );
  }

  static add(v1, v2) {
    return new Vector(
      v1.x + v2.x,
      v1.y + v2.y,
      v1.z + v2.z
    );
  }

  multiplyByScalar(s) {
    this.x *= s;
    this.y *= s;
    this.z *= s;

    return this;
  }

  getLength() {
    return Math.sqrt(
      this.x * this.x + this.y * this.y + this.z * this.z
    );
  }

  normalize() {
    const length = this.getLength();

    this.x /= length;
    this.y /= length;
    this.z /= length;

    return this;
  }
}

class Matrix {
  static getLookAt(eye, target, up) {
    const vz = Vector.substruct(eye, target).normalize();
    const vx = Vector.crossProduct(up, vz).normalize();
    const vy = Vector.crossProduct(vz, vx);

    return [
      [vx.x, vy.x, vz.x, -eye.x],
      [vx.y, vy.y, vz.y, -eye.y],
      [vx.z, vy.z, vz.z, -eye.z],
      [0, 0, 0, 1]
    ];
  }

  static getPerspectiveProjection(fovy, aspect, n, f) {
    const radians = Math.PI / 180 * fovy;
    const sx = (1 / Math.tan(radians / 2)) / aspect;
    const sy = (1 / Math.tan(radians / 2));
    const sz = (f + n) / (f - n);
    const dz = (-2 * f * n) / (f - n);

    return [
      [sx, 0, 0, 0],
      [0, sy, 0, 0],
      [0, 0, sz, dz],
      [0, 0, -1, 0]
    ];
  }

  static multiply(a, b) {
    const m = [
      [0, 0, 0, 0],
      [0, 0, 0, 0],
      [0, 0, 0, 0],
      [0, 0, 0, 0]
    ];

    for (let i = 0; i < 4; i++) {
      for (let j = 0; j < 4; j++) {
        m[i][j] = a[i][0] * b[0][j] +
          a[i][1] * b[1][j] +
          a[i][2] * b[2][j] +
          a[i][3] * b[3][j];
      }
    }

    return m;
  }

  static getRotationX(angle) {
    const rad = Math.PI / 180 * angle;

    return [
      [1, 0, 0, 0],
      [0, Math.cos(rad), -Math.sin(rad), 0],
      [0, Math.sin(rad), Math.cos(rad), 0],
      [0, 0, 0, 1]
    ];
  }

  static getRotationY(angle) {
    const rad = Math.PI / 180 * angle;

    return [
      [Math.cos(rad), 0, Math.sin(rad), 0],
      [0, 1, 0, 0],
      [-Math.sin(rad), 0, Math.cos(rad), 0],
      [0, 0, 0, 1]
    ];
  }

  static getRotationZ(angle) {
    const rad = Math.PI / 180 * angle;

    return [
      [Math.cos(rad), -Math.sin(rad), 0, 0],
      [Math.sin(rad), Math.cos(rad), 0, 0],
      [0, 0, 1, 0],
      [0, 0, 0, 1]
    ];
  }

  static getTranslation(dx, dy, dz) {
    return [
      [1, 0, 0, dx],
      [0, 1, 0, dy],
      [0, 0, 1, dz],
      [0, 0, 0, 1]
    ];
  }

  static getScale(sx, sy, sz) {
    return [
      [sx, 0, 0, 0],
      [0, sy, 0, 0],
      [0, 0, sz, 0],
      [0, 0, 0, 1]
    ];
  }

  static multiplyVector(m, v) {
    return new Vector(
      m[0][0] * v.x + m[0][1] * v.y + m[0][2] * v.z + m[0][3] * v.w,
      m[1][0] * v.x + m[1][1] * v.y + m[1][2] * v.z + m[1][3] * v.w,
      m[2][0] * v.x + m[2][1] * v.y + m[2][2] * v.z + m[2][3] * v.w,
      m[3][0] * v.x + m[3][1] * v.y + m[3][2] * v.z + m[3][3] * v.w
    );
  }
}

class Drawer {
  surface = null;
  width = 0;
  height = 0;

  constructor(surface, width, height) {
    this.surface = surface;
    this.width = width;
    this.height = height;
  }

  drawPixel(x, y, r, g, b) {
    x += this.width / 2;
    y = -(y - this.height / 2);
    const offset = (this.width * y + x) * 4;

    if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
      this.surface[offset] = r;
      this.surface[offset + 1] = g;
      this.surface[offset + 2] = b;
      this.surface[offset + 3] = 255;
    }
  }

  drawLine(x1, y1, x2, y2, r = 0, g = 0, b = 0) {
    const round = Math.trunc;
    x1 = round(x1);
    y1 = round(y1);
    x2 = round(x2);
    y2 = round(y2);

    const c1 = y2 - y1;
    const c2 = x2 - x
    
            

© Habrahabr.ru