Олег Карнауховweb developerОбсудить проект

Блог

3D без библиотеки: перспектива и повороты в плоском canvas

Как разложить десяток окон по дуге в объёме, имея только 2D-контекст: матрицы поворота, деление на глубину и вывод аффинного преобразования из спроецированных полуосей.

В конце ролика один сайт превращается в дугу из одиннадцати окон, уходящих в глубину. Выглядит как 3D, сделано в обычном двумерном контексте. Математики здесь ровно на страницу, и она стоит того, чтобы её знать: у 2D-контекста есть аффинное преобразование, а этого достаточно для честной перспективы.

Поворот и проекция

Каждое окно живёт в своей системе координат: центр, три угла поворота, масштаб, глубина. Точку сначала поворачивают тремя обычными матрицами по осям, потом делят на глубину — это и есть перспектива.

Коэффициент деления k = FOCAL / (FOCAL + z). Чем больше z, тем меньше k, тем ближе точка к центру экрана и тем меньше объект. FOCAL — это фокусное расстояние в пикселях; чем оно меньше, тем сильнее перспектива.

app/HeroFilm.tsxts
const FOCAL = 1150;
const EYE = { x: 500, y: 268 };

/** Точка из объёма — на плоскость. */
function flatten(point: Point3) {
  const k = FOCAL / (FOCAL + point.z);
  return { x: EYE.x + point.x * k, y: EYE.y + point.y * k, k };
}
EYE — точка, к которой всё сходится: центр сцены, а не центр канваса.

Главный трюк: матрица из полуосей

Рисовать окно точка за точкой нельзя — внутри него текст, скруглённые углы, полоски. Нужно нарисовать его в своих координатах и наложить преобразование.

Берём три точки: центр окна и концы двух его полуосей — вправо и вниз. Проецируем все три. Разности «конец полуоси минус центр» дают два вектора, и это ровно первые четыре числа аффинной матрицы. Пятое и шестое — спроецированный центр.

app/HeroFilm.tsxts
const centre = flatten(turn({ x: 0, y: 0, z: 0 }, rx, ry, rz, site));
const right  = flatten(turn({ x: half.w, y: 0, z: 0 }, rx, ry, rz, site));
const down   = flatten(turn({ x: 0, y: half.h, z: 0 }, rx, ry, rz, site));

// Аффинное преобразование, выведенное из спроецированных полуосей.
context.setTransform(
  (right.x - centre.x) / half.w, (right.y - centre.y) / half.w,
  (down.x  - centre.x) / half.h, (down.y  - centre.y) / half.h,
  centre.x, centre.y,
);
После этого окно рисуется в собственных координатах — как будто оно плоское и смотрит на нас.

Порядок и глубина

Рисовать надо от дальних к ближним — сортировкой по z перед отрисовкой. Никакого буфера глубины у 2D-контекста нет, порядок вызовов и есть порядок слоёв.

Чтобы дуга читалась как дуга, радиус к вершине приходится увеличивать: перспектива сама сжимает дальние окна к центру, и без компенсации середина провисает. Числа подбирались глазами, а потом проверялись замером: верхние края окон должны образовать симметричный купол.

Реакция на курсор

Окна поворачиваются в сторону курсора, а не двигаются к нему. Сила поворота падает до нуля примерно за две ширины окна и гаснет, когда курсор уже пришёл на окно — иначе получается либо сцена, дрожащая от любого движения мыши, либо особенность в точке нулевого расстояния.