§ Теоретическая часть
В этой статье я снова попытаюсь рассказать о создании трехмерного куба при помощи растеризатора однотонного цвета без текстуры и дополнительных вычислений. Результат работы алгоритма можно посмотреть в конце статьи.Теперь же распишу шаги.
- Чтобы нарисовать куб, необходимо знать координаты 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 и транслировать на определенную координату. Можно это сделать двумя способами - матрицей и серией поворотов. В перспективе, матричное преобразование будет намного лучше, потому что матрица вычисляется один раз на все вершины, а серию поворотов придется вычислять каждый раз. Но поскольку я не хочу загромождать лишними методами по вычислению матриц, так что пока так:
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 }; // Поворот по Zn = {x: n[Здесь n - это входящие данные вершины, которую надо повернуть. Завершает симфонию поворотов трансляция камеры и, непосредственно, проекция трехмерной точки на двухмерную плоскость в перспективной проекции. Перспективная проекция по сути, просто делит на Z точки X и Y и получает новые X' и Y', которые и будут показываться на экране. Здесь есть добавления +160 и вычитание из 100, чтобы точка (0,0) была по центру экрана.
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), };tZ.x +=
§ Растеризация граней
Как только проекция всех вершин готова, теперь можно запускать отрисовку треугольника, соединяя вершины друг с другом.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 - номер цвета для этой грани. Цвет грани хранится в каждой грани.Это простой растеризатор, который не учитывает ни буфер глубины, ни текстурирование, поэтому тут все очень просто и быстро работает в целом.
Код растеризации запускается довольно бодро:
// Проверка видимости грани 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; } ...raster(vtx, param) { В данном коде высчитываются разность между точками 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-куба