§ Теоретическая часть
В этой статье я снова попытаюсь рассказать о создании трехмерного куба при помощи растеризатора однотонного цвета без текстуры и дополнительных вычислений. Результат работы алгоритма можно посмотреть в конце статьи.Теперь же распишу шаги.
- Чтобы нарисовать куб, необходимо знать координаты 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-куба