3D своими руками. Часть 1: пиксели и линии


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

В первой части мы рассмотрим:


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

Давайте перед началом изучения интересного мира 3D, выберем язык программирования для примеров, а также среду разработки. Какой язык стоит выбрать для программирования 3D-графики? Любой, можете работать там, где вам удобнее всего, математика будет везде одинаковая. В этой статье все примеры будут показаны в контексте JS (тут в меня летят помидоры). Почему JS? Все просто — в последнее время работаю преимущественно с ним, и поэтому смогу эффективнее вам донести суть. Буду обходить стороной все особенности JS в примерах, т.к. нам нужны лишь самые основные возможности, которые есть у любого языка, поэтому будем уделять внимание конкретно 3D. Но вы выберите то, что любите, т.к. в статьях все формулы не будут привязаны к особенностям какого-либо языка программирования. Какую выбрать среду? Не имеет никакого значения, в случае с JS — подойдет любой текстовый редактор, вы можете использовать тот, что ближе вам.

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

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


Я не стремлюсь к званию «определение года» и все описания терминов я стараюсь изложить максимально понятно. Главное — понять идею, которую потом можно будет самостоятельно развить. Хочу также обратить внимание на то, что все примеры кода, которые будут показаны в статьях, часто не оптимизированы по скорости, для сохранения простоты понимания. Когда вы поймете главное — как работает 3D графика, вы сможете все оптимизировать самостоятельно.

Для начала создадим проект, у меня это просто текстовый index.html файл, со следующим контентом:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>3D it’s easy. Part 1</title>
</head>

<body>
    <!-- этот элемент и будет окном для отображения графики -->
    <canvas id="surface" width="800" height="600"></canvas>

    <script>
        // тут будет весь код
    </script>
</body>

</html>

Не буду слишком акцентировать внимание на JS и canvas сейчас — это не главные герои этой статьи. Но для общего понимания уточню, что <canvas …> это прямоугольник (в моем случае размером 800 на 600 пикселей) на котором я буду отображать всю графику. Я canvas прописал единожды и больше его менять не буду.

<script> … </script> 

Script — элемент внутри которого мы будем писать всю логику для рендеринга 3D графики своими руками (на JavaScript). 

Когда мы лишь обзорно рассмотрели структуру файла index.html только что созданного проекта, начнем разбираться с 3D графикой.

Когда мы что-то рисуем в окне, это в итоговом счете, превращается в пиксели, ведь именно их отображает монитор. Чем больше пикселей, тем более четкая картинка, но и компьютер нагружается сильнее. Как же хранится то, что мы рисуем в окне? Графику в любом окне можно представить в виде массива пикселей, а сам пиксель — просто цвет. То есть, разрешение экрана 800х600 означает, что наше окно содержит 600 строк по 800 пикселей в каждой, а именно 800 * 600 = 480000 пикселей, много, не правда ли? Пиксели хранятся в массиве. Давайте подумаем, в каком массиве мы бы хранили пиксели. Если у нас должно быть 800 на 600 пикселей, то самый очевидный вариант — в двумерном массиве 800 на 600. И это почти правильный вариант, а точнее — полностью правильный вариант. Но пиксели окна, лучше хранить в одномерном массиве на 480000 элементов (если разрешение 800 на 600), только потому что с одномерным массивом быстрее работать, т.к. он хранится в памяти сплошной последовательностью байт (все лежит рядом и поэтому его легко достать). В двумерном массиве (например, в случае JS), каждая строчка может быть разбросана по разным местам в памяти, поэтому обращение к элементам такого массива будет происходить дольше. Также, для перебора одномерного массива нужен только 1 цикл, а для двумерного целых 2, учитывая необходимость делать десятки тысяч итераций цикла, скорость здесь немаловажна. Что из себя представляет пиксель в таком массиве? Как выше упоминалось — это просто цвет, а точнее 3 его составляющих (красный, зеленый, синий). Любая, даже самая красочная картинка – это просто массив пикселей разного цвета. Пиксель в памяти можно хранить как угодно, либо массивом на 3 элемента, либо в структуре, где будут red, gree, blue; или как-то еще. Изображение, состоящее из массива пикселей, который мы только что разобрали, я дальше буду называть поверхностью (surface). Получается, раз все, что отображается на экране — хранится в массиве пикселей, то меняя элементы (пиксели) в этом массиве — мы будем попиксельно изменять изображение на экране. Именно так мы и поступим в этой статье.

В canvas отсутствует функция рисования пикселя, но есть возможность получить доступ к одномерном массиву пикселей, который мы обсудили выше. Как это сделать показано в примере ниже (этот и все примеры в дальнейшем будут только внутри элемента script):

// Получаем доступ к элементу (окну) в котором будем рисовать
const ctx = document
.getElementById('surface')
.getContext('2d')

// Получаем доступ к массиву пикселей, который будем менять 
// + указываем размеры окна в котором будем рисовать
const imageData = ctx.createImageData(800, 600)

В примере, imageData — это объект в котором есть 3 свойства:


Массив data имеет несложную, но требующую объяснения структуру. В этом одномерном массиве хранятся данные каждого пикселя, который мы будем выводить на экран в следующем формате:
Первые 4 элемента массива (индексы 0,1,2,3) — это данные первого пикселя в первой строке. Вторые 4 элемента (индексы 4, 5, 6, 7) — это данные второго пикселя первой строки. Когда мы дойдем до 800-го пикселя первой строки, при условии ширины окна в 800 пикселей — 801-й пиксель уже будет относиться ко второй строке. Если мы его поменяем, на экране увидим что поменялся 1-й пиксель 2-й строки (хотя по счету в массиве это будет 801-й пиксель). Почему на каждый пиксель в массиве по 4 элемента? Это потому что в canvas, помимо того, что выделяется по 1-му элементу на каждый цвет — красный, зеленый, синий (это уже 3 элемента), еще 1 элемент на прозрачность (еще говорят alpha-канал или opacity). Alpha-канал, как и цвет, задается в диапазоне от 0 (прозрачный) до 255 (непрозрачный). При такой структуре у нас получится 32х битное изображение, потому что каждый пиксель состоит из 4х элементов по 8 бит. Подытожим: каждый пиксель содержит: красный, зеленый, синий цвета и альфа-канал (прозрачность). Такую цветовую схему называют ARGB (Alpha Red Green Blue). А то, что каждый пиксель занимает 32 бита, говорит, что у нас 32 битное изображение (еще говорят, изображение с глубиной цвета 32 бита).

По умолчанию, весь массив пикселей imageData.data (data это свойство, в котором массив пикселей, а imageData это просто объект) заполнен значениями 0, и если бы мы попытались вывести такой массив, то ничего интересного на экране не увидели бы, потому что 0, 0, 0 — это черный цвет, но поскольку прозрачность тут будет тоже 0, а это полностью прозрачный цвет, то даже черного на экране мы не увидим!

С таким одномерным массивом напрямую работать неудобно, поэтому напишем для него класс, в котором создадим методы для рисования. Я назову класс — Drawer. Этот класс будет хранить только необходимые данные и проводить необходимые вычисления, по максимуму абстрагируясь от используемого для рендеринга инструмента. Именно поэтому в нем мы разместим все расчеты и работу с массивом. А уже сам вызов метода отображения на canvas, мы разместим за пределами класса, т.к. вместо canvas может быть что-то еще. В таком случае, наш класс менять не придется. Для работы с массивом пикселей (surface) нам удобнее сохранить его в классе Drawer, а также ширину и высоту картинки, чтобы правильно уметь обращаться к нужному пикселю. Итак, класс Drawer с сохранением минимума необходимых для рисования данных у меня выглядит так:

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

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

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

const drawer = new Drawer(
    imageData.data,
    imageData.width,
    imageData.height
)

В классе Drawer мы напишем несколько функций рисования, для простоты работы в дальнейшем. У нас будет функция рисования пикселя, функция рисования линии, в дальнейших статьях еще появятся функции рисования треугольника и других фигур. Но начнем с метода рисования пикселя. Я его назову drawPixel. Если мы рисуем пиксель, то у него должны быть координаты, а также цвет:

drawPixel(x, y, r, g, b)  { }

Обратите внимание, что функция drawPixel не принимает параметр alpha (прозрачность), а выше мы разобрались, что массив пикселей состоит из 3-х параметров цвета и 1-го параметра прозрачности. Я специально не указал прозрачность, так как она нам для примеров совершенно не нужна. По умолчанию будем устанавливать 255 (т.е. все будет непрозрачным). Теперь подумаем, как в массив пикселей в координаты x, y записать нужный цвет. Поскольку у нас вся информация об изображении хранится в одномерном массиве, в котором на каждый пиксель отводится по 1-му числу (8 бит). Для обращения к нужному пикселю в массиве, нам нужно сначала определить индекс расположения красного цвета, потому что любой пиксель начинается именно с него (напр. [r, g, b, a]). Немного пояснения структуры массива:



В таблице зеленым цветом указано, как хранятся компоненты цвета в одномерном массиве surface. Синим цветом указаны их индексы в этом же массиве, а красным — координаты пикселя, который принимает функции drawPixel, которые нам нужно преобразовать в индексы в одномерном массиве, для задания r, g, b, a для пикселя. Итак, из таблицы видно, что для каждого пикселя красная составляющая цвета идет первой, начнем с нее. Предположим, что мы хотим поменять красную составляющую цвета пикселя в координатах X1Y1 при размере изображения 2 на 2 пикселя. В таблице мы видим, что это индекс 12, но как его вычислить? Для начала находим индекс нужной нам строки, для этого умножим ширину изображения на Y и на 4 (кол-во значений на каждый пиксель) — это будет:

width * y * 4 
// подставим числа:
2 * 1 * 4 = 8

Видим, что 2-я строка начинается с индекса 8. Если сравним с табличкой — результат сходится.

Теперь к найденному индексу строки нужно прибавить смещение по столбцам, чтобы получить искомый индекс красного цвета. Для этого к индексу строки добавим Х умноженный на 4. Полная формула будет такой:

width * y * 4 + x * 4 
// которую можно записать и так:
(width * y + x) * 4
// подставляем значения:
(2 * 1 + 1) * 4 = 12

Теперь мы сравниваем 12 с таблицей и видим что пиксель X1Y1 действительно начинается с индекса 12.

Чтобы найти индексы других компонентов цвета, нужно добавить смещение цвета к индексу красного цвет: +1 (зеленый), +2 (синий), +3 (альфа). Теперь можем реализовать метод drawPixel внутри класса Drawer используя формулу выше:

drawPixel(x, y, r, g, b) {
    const offset = (this.width * y + x) * 4

    this.surface[offset] = r
    this.surface[offset + 1] = g
    this.surface[offset + 2] = b
    this.surface[offset + 3] = 255
}

В этом методе drawPixel я повторяющуюся часть формулы вынес в константу offset. Также видно, что в alpha я просто пишу 255, т.к. она есть в структуре, но нам сейчас для вывода пикселей не нужна.

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

// код после определения класса Drawer
drawer.drawPixel(10, 10, 255, 0, 0)
drawer.drawPixel(10, 20, 0, 0, 255)

// применить все изменения пикселей в массиве к элементу canvas
ctx.putImageData(imageData, 0, 0)

В примере выше я рисую 2 пикселя, один красный 255, 0, 0, другой — синий 0, 0, 255. Но изменения в массиве imageData.data (он же surface внутри класса Drawer) сами на экране не отобразятся. Для отрисовки нужно вызвать ctx.putImageData(imageData, 0, 0), где imageData — объект в котором массив пикселей и ширина/высота области отрисовки, а 0, 0 — это точка, относительно которой будет выводиться массив пикселей (всегда оставляем 0, 0). Если вы все сделали правильно, тогда у вас вверху слева элемента canvas в окне браузера будет такая картина:



Увидели пиксели? Они такие маленькие, а сколько работы проделано.

Теперь попробуем добавить в пример немного динамики, например, чтобы каждые 10 миллисекунд наш пиксель смещался вправо (будем изменять X пикселя на +1 каждые 10 миллисекунд), поправим код рисования пикселя на такой с интервалом:

let x = 10
setInterval(() => {

    drawer.drawPixel(x++, 20, 0, 0, 255)
    ctx.putImageData(imageData, 0, 0)

}, 10)

В этом примере я оставил только вывод синего пикселя и обернул код в JavaScript функцию setInterval с параметром 10. Это означает, что код будет вызываться примерно каждые 10 миллисекунд. Если вы запустите такой пример — увидите, что вместо смещающегося вправо пикселя у вас будет что-то такое:



Такая длинная полоска (или след) остается потому что мы в массиве surface не чистим цвет предыдущего пикселя, поэтому при каждом вызове интервала у нас добавляется еще один пиксель. Давайте напишем метод, который будет чистить surface до изначального состояния. Иными словами — заполним массив нулями. В класс Drawer добавим метод clearSurface:

clearSurface() {
    const surfaceSize = this.width * this.height * 4
    for (let i = 0; i < surfaceSize; i++) {
        this.surface[i] = 0
    }
}

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

let x = 10
setInterval(() => {
    drawer.clearSurface()
    drawer.drawPixel(x++, 20, 0, 0, 255)
    ctx.putImageData(imageData, 0, 0)
}, 10)

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

Последнее, что мы реализуем в первой статье — это метод рисования линии. Добавим его, конечно же, в класс Drawer. Метод я назову drawLine. Что он будет принимать? В отличие от точки, линия еще имеет координаты, в которых она заканчивается. Иными словами, у линии есть начало, конец и цвет, что и будем передавать в метод:

drawLine(x1, y1, x2, y2, r, g, b) { }

Любая линия состоит из пикселей, осталось только правильно заполнить ее пикселями от x1, y1 до x2, y2. Для начала, раз линия состоит из пикселей, значит, мы будем ее выводить в цикле попиксельно, но как посчитать, сколько пикселей выводить? Например, для рисования линии из [0, 0] в [3, 0] интуитивно видно, что понадобится 4 пикселя ([0, 0], [1, 0], [2, 0], [3, 0],). А вот из [12, 6] в [43, 14], уже непонятно какая длина будет у линии (сколько пикселей выводить) и какие у них будут координаты. Для этого вспомним немного геометрии. Итак, у нас есть линия, которая начинается в точке x1, y1 и заканчивается в точке x2, у2.

Давайте проведем пунктиром линию от начала и конца так, чтобы получился треугольник (рисунок выше). Мы увидим, что в месте соединения проведенных линий образовался угол 90 градусов. Если в треугольнике есть такой угол, значит, треугольник называется прямоугольным, а его стороны, между которыми угол равен 90 градусам, называют катетами. Третья сплошная линия (которую мы и пытаемся нарисовать) называют в треугольнике гипотенузой. При помощи этих двух введенных катетов (на рисунке это c1 и с2), мы и сможем вычислить длину гипотенузы по теореме Пифагора. Давайте посмотрим, как это сделать. Формула длины гипотенузы (или длины линии), будет следующей: 

$$display$$гипотенуза = \sqrt{катет1^2 + катет2^2}$$display$$


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

 drawLine(x1, y1, x2, y2, r, g, b) {
         const c1 = y2 - y1
         const c2 = x2 - x1

         const length = Math.sqrt(c1 * c1 + c2 * c2)

Мы уже знаем, сколько пикселей выводить для отрисовки линии. Но мы еще не знаем, как смещаются пиксели. То есть, нам нужно нарисовать линию от x1, у1 до х2, у2, мы знаем, что длина линии составит, например, 20 пикселей. Мы можем нарисовать 1-й пиксель в х1, у1 и последний в х2, у2, но как найти координаты промежуточных пикселей? Для этого нам и нужно получить, как смещать каждый следующий пиксель по отношению к x1, y1, чтобы получилась нужная линия. Приведу еще один пример, чтобы лучше понять, о каком смещении идет речь. У нас есть точки [0, 0] и [0, 3], по ним нужно нарисовать линию. Из примера хорошо видно, что следующая точка после [0, 0] будет [0, 1], а потом [0, 2] и наконец [0, 3]. То есть, Х каждой точки не смещался, ну или можно говорить, что смещался на 0 пикселей, а Y смещался на 1 пиксель, вот это и есть смещение, его можно записать в виде [0, 1]. Другой пример: у нас есть точка [0, 0] и точка [3, 6], попробуем посчитать в уме, как они смещаются, первой будет [0, 0], потом [0.5, 1], потом [1, 2] потом [1.5, 3] и так далее до [3, 6], в этом примере смещение будет [0.5, 1]. Как же его вычислить? 

Можно использовать такую формулу:

Смещение по Х = Катет2 / Длину гипотенузы
Смещение по Y = Катет1 / Длину гипотенузы 

В коде программы у нас будет так:

const xStep = c2 / length
const yStep = c1 / length

Все данные уже есть: длина линии, смещение пикселей по Х и по Y. Начинаем в цикле рисовать:

for (let i = 0; i < length; i++) {
    this.drawPixel(
        Math.trunc(x1 + xStep * i),
        Math.trunc(y1 + yStep * i),
        r, g, b,
    )
}

В качестве координаты Х функции Pixel передаем начало Х линии + смещение X * i, таким образом, получая координату i-го пикселя, точно также вычисляем и координату Y. Math.trunc это метод в JS который позволяет отбросить дробную часть числа. Весь код метода выглядит так:

drawLine(x1, y1, x2, y2, r, g, b) {
    const c1 = y2 - y1
    const c2 = x2 - x1

    const length = Math.sqrt(c1 * c1 + c2 * c2)

    const xStep = c2 / length
    const yStep = c1 / length

    for (let i = 0; i < length; i++) {
        this.drawPixel(
            Math.trunc(x1 + xStep * i),
            Math.trunc(y1 + yStep * i),
            r, g, b,
        )
    }
}

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

Код класса Drawer
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)  {
    const offset = (this.width * y + x) * 4

    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, g, b) {
    const c1 = y2 - y1
    const c2 = x2 - x1

    const length = Math.sqrt(c1 * c1 + c2 * c2)

    const xStep = c2 / length
    const yStep = c1 / length

    for (let i = 0 ; i < length ; i++) {
        this.drawPixel(
          Math.trunc(x1 + xStep * i),
          Math.trunc(y1 + yStep * i),
          r, g, b,
        )
    }
  }

  clearSurface() {
    const surfaceSize = this.width * this.height * 4
    for (let i = 0; i < surfaceSize; i++) {
      this.surface[i] = 0
    }
  }
}

Что дальше?


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