Рассмотрим разницу между параллельной и перспективной проекцией. Обе широко используются на практике для различных целей. В предыдущем примере мы вращали квадрат на плоскости — переходим в трёхмерное пространство. Теперь, чтобы отобразить на плоскости экрана поворот трёхмерного объекта, нужно сначала создать математическую модель трёхмерного объекта, повернуть её на угол, срисовать с неё проекцию и отобразить на экране уже проекцию. Для наглядности будем использовать декартову систему координат.
Усложнённая модель, много кубиков: Вращаем пространственный крест.
Параллельная проекция — центр проекции бесконечно удалён от плоскости экрана наблюдателя, размеры предметов выглядят одинаковыми.
Перспективная проекция — параллельные линии сходятся в центре перспективы, предметы выглядят уменьшающимися вдалеке.
Размер куба 200, размер холста 300, начало координат находится в верхнем левом углу. Центр фигуры
в середине холста. Ось X
направлена вправо, ось Y
направлена вниз, ось Z
направлена вдаль.
Выполняется поворот последовательно по всем трём осям: сначала по оси X
, затем по оси Y
и затем
по оси Z
. Настройками модели можно управлять, например можно отключать лишнее вращение по осям и
двигать центральную точку проекции на экране наблюдателя.
Рассчитываем новые координаты точки по формулам матрицы поворота для трёхмерного пространства.
Поворачиваем точку t
относительно точки t0
— получаем точку t'
.
Поворот по оси X
.
Поворот по оси Y
.
Поворот по оси Z
.
Экспериментальные формулы с возможностью смещения центра проекции d0
на экране наблюдателя tv
.
Отображаем точку пространства t
на плоскость экрана — получаем точку t'
.
Параллельная проекция.
Перспективная проекция.
Расстояние от точки до центра проекции.
При создании кубика, вершины каждой грани задаём по часовой стрелке. При получении проекции, подставляем в уравнение прямой три подряд идущие вершины, чтобы определить наклон грани и удалённость её от плоскости проекции.
Уравнение прямой, проходящей через две точки.
Сначала обходим вершины куба и поворачиваем их на угол относительно центральной точки. Затем обходим грани куба и получаем проекции входящих в них вершин. После этого сортируем проекции граней по удалённости. Затем рисуем проекции на плоскости — соединяем точки линиями. Рисуем почти прозрачным цветом сперва дальние грани и поверх них ближние, чтобы сквозь ближние грани было видно дальние.
На каждом шаге отображения фигуры повторяем сортировку граней по удалённости, так как с изменением угла поворота, координаты смещаются, и ближние грани становятся дальними.
Класс Точка трёхмерного пространства содержит методы для поворотов на угол и для получения проекций на плоскость. При получении проекций, вычисляется расстояние от точки до центра проекции. Точка также содержит статический метод для сравнения двух проекций точек.
class Point {
// координаты точки
constructor(x,y,z) {
this.x=x;
this.y=y;
this.z=z;
}
// поворачиваем эту точку на угол (deg)
// по осям (x,y,z) относительно точки (t0)
rotate(deg, t0) {
// функции для получения синуса и косинуса угла в радианах
const sin = (deg) => Math.sin((Math.PI/180)*deg);
const cos = (deg) => Math.cos((Math.PI/180)*deg);
// получаем новые координаты точки по формулам
// матрицы поворота для трёхмерного пространства
let x,y,z;
// поворот по оси 'x'
y = t0.y+(this.y-t0.y)*cos(deg.x)-(this.z-t0.z)*sin(deg.x);
z = t0.z+(this.y-t0.y)*sin(deg.x)+(this.z-t0.z)*cos(deg.x);
this.y=y; this.z=z;
// поворот по оси 'y'
x = t0.x+(this.x-t0.x)*cos(deg.y)-(this.z-t0.z)*sin(deg.y);
z = t0.z+(this.x-t0.x)*sin(deg.y)+(this.z-t0.z)*cos(deg.y);
this.x=x; this.z=z;
// поворот по оси 'z'
x = t0.x+(this.x-t0.x)*cos(deg.z)-(this.y-t0.y)*sin(deg.z);
y = t0.y+(this.x-t0.x)*sin(deg.z)+(this.y-t0.y)*cos(deg.z);
this.x=x; this.y=y;
}
// получаем проекцию типа (type) с расстояния (d)
// на плоскость экрана наблюдателя (tv)
projection(type, tv, d) {
let proj = {};
// получаем проекцию по экспериментальным формулам
switch (type) {
case 'parallel': {
proj.x = this.x;
proj.y = this.y+(tv.y-this.z)/4;
break;
}
case 'perspective': {
proj.x = tv.x+d*(this.x-tv.x)/(this.z-tv.z+d);
proj.y = tv.y+d*(this.y-tv.y)/(this.z-tv.z+d);
break;
}
}
// вычисляем расстояние до центра проекции
proj.dist = Math.sqrt((this.x-tv.x)*(this.x-tv.x)
+(this.y-tv.y)*(this.y-tv.y)
+(this.z-tv.z+d)*(this.z-tv.z+d));
return proj;
}
// сравниваем две проекции точек (p1,p2),
// координаты (x,y) должны совпадать
static pEquals(p1, p2) {
return Math.abs(p1.x-p2.x)<0.0001
&& Math.abs(p1.y-p2.y)<0.0001;
}
};
Класс Куб содержит коллекцию вершин класса Точка и массив граней. Каждая грань — это массив из 4 вершин, выходящих из одной точки и идущих по часовой стрелке. Куб содержит методы для поворота всех вершин на угол и для получения проекций всех граней на плоскость. При получении проекций, вычисляется наклон грани — это удалённость от плоскости проекции. Куб также содержит два статических метода для сравнения двух проекций граней: для определения равноудалённых граней от центра проекции и смежных стенок между соседними кубиками.
class Cube {
// левая верхняя ближняя координата и размер
constructor(x,y,z,size) {
// правая нижняя дальняя координата
let xs=x+size,ys=y+size,zs=z+size;
let v={ // вершины
t000: new Point(x,y,z), // верх
t001: new Point(x,y,zs), // верх
t010: new Point(x,ys,z), // низ
t011: new Point(x,ys,zs), // низ
t100: new Point(xs,y,z), // верх
t101: new Point(xs,y,zs), // верх
t110: new Point(xs,ys,z), // низ
t111: new Point(xs,ys,zs)};// низ
this.vertices=v;
this.faces=[ // грани
[v.t000,v.t100,v.t110,v.t010], // передняя
[v.t000,v.t010,v.t011,v.t001], // левая
[v.t000,v.t001,v.t101,v.t100], // верхняя
[v.t001,v.t011,v.t111,v.t101], // задняя
[v.t100,v.t101,v.t111,v.t110], // правая
[v.t010,v.t110,v.t111,v.t011]];// нижняя
}
// поворачиваем вершины куба на угол (deg)
// по осям (x,y,z) относительно точки (t0)
rotate(deg, t0) {
for (let vertex in this.vertices)
this.vertices[vertex].rotate(deg, t0);
}
// получаем проекции типа (type) с расстояния (d)
// на плоскость экрана наблюдателя (tv)
projection(type, tv, d) {
let proj = [];
for (let face of this.faces) {
// проекция грани, массив вершин
let p = [];
// кумулятивная удалённость вершин
p.dist = 0;
// обходим вершины грани
for (let vertex of face) {
// получаем проекции вершин
let proj = vertex.projection(type, tv, d);
// накапливаем удалённость вершин
p.dist+=proj.dist;
// добавляем в массив вершин
p.push(proj);
}
// вычисляем наклон грани, удалённость от плоскости проекции
p.clock = ((p[1].x-p[0].x)*(p[2].y-p[0].y)
-(p[1].y-p[0].y)*(p[2].x-p[0].x))<0;
proj.push(p);
}
return proj;
}
// сравниваем две проекции граней (f1,f2), вершины
// должны быть равноудалены от центра проекции
static pEquidistant(f1, f2) {
return Math.abs(f1.dist-f2.dist)<0.0001;
}
// сравниваем две проекции граней (f1,f2), координаты
// точек по главной диагонали (p0,p2) должны совпадать
static pAdjacent(f1, f2) {
return Point.pEquals(f1[0],f2[0])
&& Point.pEquals(f1[2],f2[2]);
}
};
Создаём объект и рисуем две проекции на плоскости.
'use strict';
// рисовать будем сразу две картинки,
// объект будет один, а проекций будет много
const canvas1 = document.getElementById('canvas1');
const canvas2 = document.getElementById('canvas2');
// создаём объект
const cube = new Cube(50,50,50,200);
// центр фигуры, вокруг него будем выполнять поворот
const t0 = new Point(150,150,150);
// удалённость центра проекции
const d = 300;
// положение экрана наблюдателя
const tv = new Point(150,150,80);
// угол поворота в градусах
const deg = {x:0,y:1,z:0};
// поворот фигуры и обновление изображения
function repaint() {
cube.rotate(deg, t0);
// рисуем параллельную проекцию
drawFigure(canvas1, cube.projection('parallel', tv));
// рисуем перспективную проекцию
drawFigure(canvas2, cube.projection('perspective', tv, d));
}
// рисуем фигуру по точкам из массива
function drawFigure(canvas, proj) {
let context = canvas.getContext('2d');
// сортируем грани по их наклону
proj.sort((a,b) => b.clock-a.clock);
// очищаем весь холст целиком
context.clearRect(0, 0, canvas.width, canvas.height);
// обходим массив граней куба
for (let i = 0; i < proj.length; i++) {
// обходим массив точек и соединяем их линиями
context.beginPath();
for (let j = 0; j < proj[i].length; j++) {
if (j == 0) {
context.moveTo(proj[i][j].x, proj[i][j].y);
} else {
context.lineTo(proj[i][j].x, proj[i][j].y);
}
}
context.closePath();
// рисуем грань куба вместе с рёбрами
context.lineWidth = 2.2;
context.lineJoin = 'round';
context.fillStyle = '#fff9';
context.strokeStyle = '#222';
context.fill();
context.stroke();
}
}
// после загрузки страницы, задаём частоту обновления изображения 20 Гц
document.addEventListener('DOMContentLoaded',()=>setInterval(repaint,50));
© Головин Г.Г., Код с комментариями, 2023