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

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

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

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

let tX = {x: n.x, y: n.y*cosX + n.z*sinX, z: n.z*cosX - n.y*sinX };      // Поворот по X
let tY = {x: tX.x*cosY + tX.z*sinY, y: tX.y, z: tX.z*cosY - tX.x*sinY }; // Поворот по Y
let 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) была по центру экрана.
tZ.x += this.camera.x;
tZ.y += this.camera.y;
tZ.z += this.camera.z;

return {
    x: 160 + Math.trunc(ppd*tZ.x / tZ.z),
    y: 100 - Math.trunc(ppd*tZ.y / tZ.z),
};

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

Как только проекция всех вершин готова, теперь можно запускать отрисовку треугольника, соединяя вершины друг с другом.
for (let i = 0; i < faces.length; i++) {

    let id = faces[i][0];
    let f  = {
        a: faces[i][1],
        b: faces[i][2],
        c: faces[i][3]
    };
    this.raster([ vtx[f.a], vtx[f.b], vtx[f.c] ], {color: colors[id]});
}
Перебирая все треугольники, получаются номера вершин a,b,c, которые уже далее передаются к растеризатору raster. Помимо этого параметра, в растеризацию передается цвет грани из colors, где id - номер цвета для этой грани. Цвет грани хранится в каждой грани.
Это простой растеризатор, который не учитывает ни буфер глубины, ни текстурирование, поэтому тут все очень просто и быстро работает в целом.
Код растеризации запускается довольно бодро:
raster(vtx, param) {

    // Проверка видимости грани
    let AB = {x: vtx[1].x - vtx[0].x, y: vtx[1].y - vtx[0].y};
    let AC = {x: vtx[2].x - vtx[0].x, y: vtx[2].y - vtx[0].y};

    // Отбросить грань, если точки в ней визуально обходятся против часовой стрелки
    if (AB.y*AC.x - AB.x*AC.y > 0) {
        return;
    }

    ...
В данном коде высчитываются разность между точками AB и AC по x и y, и потом эта разность используется в формуле (по сути, вычисление определителя матрицы), по которой определяется с какой стороны обходится грань - по часовой или против часовой. Если обход происходит по часовой, то значение этой формулы будет отрицательным (на самом деле тут должно быть положительным, но поскольку система координат по Y развернута снизу вверх, то берется противоположный знак).
Как только грань не видна, то есть, она обходится против часовой стрелки, то такую грань показывать не надо. Она повернута к наблюдателю задней стороной. Этот прием работает только для выпуклых фигур, таких как куб или шар, тетраэд, октаэдр и так далее. Для других случаев необходимо использовать буфер глубины, чтобы определить, какая точка находится дальше, какая ближе.
Поскольку далее идет то, что я уже рассказывал в этой статье, то я приведу просто код:
// Сортировка по возрастанию
let p = vtx.sort(function (x, y) { return x.y < y.y ? -1 : 1; });

// Главная сторона
let [Ax,  Adx] = [p[0].x, 0];
let [ACx, ACy] = [p[2].x - p[0].x, p[2].y - p[0].y];

// Два треугольника
for (let i = 0; i < 2; i++) {

    // Боковая сторона
    let [Bx,  Bdx] = [p[i].x, 0];
    let [AMx, AMy] = [p[i+1].x - p[i].x, p[i+1].y - p[i].y];

    // Отрисовка полутреугольника
    for (let y = p[i].y; y < p[i+1].y + i; y++) {

        // Находится за пределами
        if (y >= this.height) break;

        // Рисование линии, если она находится в области рисования
        if (y >= 0) {
            for (let x = Math.min(Ax, Bx); x <= Math.max(Ax, Bx); x++) {
                this.pset(x, y, param.color);
            }
        }

        // Сдвиг точек x1,x2. Важно использовать trunc!
        Adx += ACx; Ax += Math.trunc(Adx / ACy); Adx %= ACy;
        Bdx += AMx; Bx += Math.trunc(Bdx / AMy); Bdx %= AMy;
    }
}

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

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

20 авг, 2022
© 2007-2022 Ужас в том, что мышь взлетает котом