§ Теоретическая часть

В этой статье я снова попытаюсь рассказать о создании трехмерного куба при помощи растеризатора однотонного цвета без текстуры и дополнительных вычислений. Результат работы алгоритма можно посмотреть в конце статьи.
Теперь же распишу шаги.
  • Чтобы нарисовать куб, необходимо знать координаты 8 точек (vertex)
  • Помимо вершин, у куба есть 6 граней (face), каждая из которых делится на 2 треугольника
Координаты вершин:
1let vertex = [
2//    x   y   z
3    [-1,  1,  1], [ 1,  1,  1], [ 1, -1,  1], [-1, -1,  1],
4    [-1,  1, -1], [ 1,  1, -1], [ 1, -1, -1], [-1, -1, -1]
5];
И все 12 треугольников куба:
1let faces = [
2//   CLR  A  B  C
3    [0x0, 1, 0, 3], [0x0, 1, 3, 2], // Перед
4    [0x1, 5, 1, 2], [0x1, 5, 2, 6], // Справа
5    [0x2, 2, 3, 7], [0x2, 2, 7, 6], // Низ
6    [0x3, 0, 1, 5], [0x3, 0, 5, 4], // Верх
7    [0x4, 0, 4, 7], [0x4, 0, 7, 3], // Слева
8    [0x5, 4, 5, 6], [0x5, 4, 6, 7], // Зад
9];
Каждый треугольник указывает на используемый цвет (здесь от 0 до 5) и 3 точки номеров вершин от 0 до 7 из массива vertex, который объявлялся ранее.
Цвета могут быть какими угодно, но пусть они будут такие:
1let colors = [0x0000ff, 0x008000, 0x800000, 0x800080, 0x808000, 0x808080];

§ Проекция вершин

Перед рендерингом куба, нужно выполнить преобразования и проекцию всех 8 его вершин, причем обязательно сделать их целочисленными. Я пока что опускаю случаи, когда треугольники уходят за пределы экрана (за камеру), то есть, я имею ввиду случаи с clipping, разрезающие треугольник на несколько частей, когда он пересекает некоторую плоскость перед камерой.
1let vtx = [];
2for (let i = 0; i < vertex.length; i++) {
3    vtx.push(this.projection(vertex[i]));
4}
По итогу, каждая вершина проходит через метод projection, получая объект из {x,y}.
Метод начинается с того, что предварительно вычисляются синусы и косинуса для поворота куба в пространстве по трем осям:
1let cosY = Math.cos(this.camera.ry), sinY = Math.sin(this.camera.ry);
2let cosX = Math.cos(this.camera.rx), sinX = Math.sin(this.camera.rx);
3let cosZ = Math.cos(this.camera.rz), sinZ = Math.sin(this.camera.rz);
Не забываем про импровизированный fov:
1const ppd = 100;
Перед тем, как выполнить проекцию, нужно развернуть куб в пространстве по осям X,Y,Z и транслировать на определенную координату. Можно это сделать двумя способами - матрицей и серией поворотов. В перспективе, матричное преобразование будет намного лучше, потому что матрица вычисляется один раз на все вершины, а серию поворотов придется вычислять каждый раз. Но поскольку я не хочу загромождать лишними методами по вычислению матриц, так что пока так:
1n = {x: n[0], y: n[1], z: n[2]}; // Преобразование из Array в Object
2
3let tX = {x: n.x, y: n.y*cosX + n.z*sinX, z: n.z*cosX - n.y*sinX };      // Поворот по X
4let tY = {x: tX.x*cosY + tX.z*sinY, y: tX.y, z: tX.z*cosY - tX.x*sinY }; // Поворот по Y
5let tZ = {x: tY.x*cosZ + tY.y*sinZ, y: tY.y*cosZ - tY.x*sinZ, z: tY.z }; // Поворот по Z
Здесь n - это входящие данные вершины, которую надо повернуть. Завершает симфонию поворотов трансляция камеры и, непосредственно, проекция трехмерной точки на двухмерную плоскость в перспективной проекции. Перспективная проекция по сути, просто делит на Z точки X и Y и получает новые X' и Y', которые и будут показываться на экране. Здесь есть добавления +160 и вычитание из 100, чтобы точка (0,0) была по центру экрана.
1tZ.x += this.camera.x;
2tZ.y += this.camera.y;
3tZ.z += this.camera.z;
4
5return {
6    x: 160 + Math.trunc(ppd*tZ.x / tZ.z),
7    y: 100 - Math.trunc(ppd*tZ.y / tZ.z),
8};

§ Растеризация граней

Как только проекция всех вершин готова, теперь можно запускать отрисовку треугольника, соединяя вершины друг с другом.
1for (let i = 0; i < faces.length; i++) {
2
3    let id = faces[i][0];
4    let f  = {
5        a: faces[i][1],
6        b: faces[i][2],
7        c: faces[i][3]
8    };
9    this.raster([ vtx[f.a], vtx[f.b], vtx[f.c] ], {color: colors[id]});
10}
Перебирая все треугольники, получаются номера вершин a,b,c, которые уже далее передаются к растеризатору raster. Помимо этого параметра, в растеризацию передается цвет грани из colors, где id - номер цвета для этой грани. Цвет грани хранится в каждой грани.
Это простой растеризатор, который не учитывает ни буфер глубины, ни текстурирование, поэтому тут все очень просто и быстро работает в целом.
Код растеризации запускается довольно бодро:
1raster(vtx, param) {
2
3    // Проверка видимости грани
4    let AB = {x: vtx[1].x - vtx[0].x, y: vtx[1].y - vtx[0].y};
5    let AC = {x: vtx[2].x - vtx[0].x, y: vtx[2].y - vtx[0].y};
6
7    // Отбросить грань, если точки в ней визуально обходятся против часовой стрелки
8    if (AB.y*AC.x - AB.x*AC.y > 0) {
9        return;
10    }
11
12    ...
В данном коде высчитываются разность между точками AB и AC по x и y, и потом эта разность используется в формуле (по сути, вычисление определителя матрицы), по которой определяется с какой стороны обходится грань - по часовой или против часовой. Если обход происходит по часовой, то значение этой формулы будет отрицательным (на самом деле тут должно быть положительным, но поскольку система координат по Y развернута снизу вверх, то берется противоположный знак).
Как только грань не видна, то есть, она обходится против часовой стрелки, то такую грань показывать не надо. Она повернута к наблюдателю задней стороной. Этот прием работает только для выпуклых фигур, таких как куб или шар, тетраэд, октаэдр и так далее. Для других случаев необходимо использовать буфер глубины, чтобы определить, какая точка находится дальше, какая ближе.
Поскольку далее идет то, что я уже рассказывал в этой статье, то я приведу просто код:
1// Сортировка по возрастанию
2let p = vtx.sort(function (x, y) { return x.y < y.y ? -1 : 1; });
3
4// Главная сторона
5let [Ax,  Adx] = [p[0].x, 0];
6let [ACx, ACy] = [p[2].x - p[0].x, p[2].y - p[0].y];
7
8// Два треугольника
9for (let i = 0; i < 2; i++) {
10
11    // Боковая сторона
12    let [Bx,  Bdx] = [p[i].x, 0];
13    let [AMx, AMy] = [p[i+1].x - p[i].x, p[i+1].y - p[i].y];
14
15    // Отрисовка полутреугольника
16    for (let y = p[i].y; y < p[i+1].y + i; y++) {
17
18        // Находится за пределами
19        if (y >= this.height) break;
20
21        // Рисование линии, если она находится в области рисования
22        if (y >= 0) {
23            for (let x = Math.min(Ax, Bx); x <= Math.max(Ax, Bx); x++) {
24                this.pset(x, y, param.color);
25            }
26        }
27
28        // Сдвиг точек x1,x2. Важно использовать trunc!
29        Adx += ACx; Ax += Math.trunc(Adx / ACy); Adx %= ACy;
30        Bdx += AMx; Bx += Math.trunc(Bdx / AMy); Bdx %= AMy;
31    }
32}

§ Демонстрация

Здесь показан рендеринг вращающегося по трем осями кубика.
Рисование DX-куба