3D без библиотеки: перспектива и повороты в плоском canvas
Как разложить десяток окон по дуге в объёме, имея только 2D-контекст: матрицы поворота, деление на глубину и вывод аффинного преобразования из спроецированных полуосей.
В конце ролика один сайт превращается в дугу из одиннадцати окон, уходящих в глубину. Выглядит как 3D, сделано в обычном двумерном контексте. Математики здесь ровно на страницу, и она стоит того, чтобы её знать: у 2D-контекста есть аффинное преобразование, а этого достаточно для честной перспективы.
Поворот и проекция
Каждое окно живёт в своей системе координат: центр, три угла поворота, масштаб, глубина. Точку сначала поворачивают тремя обычными матрицами по осям, потом делят на глубину — это и есть перспектива.
Коэффициент деления k = FOCAL / (FOCAL + z). Чем больше z, тем меньше k, тем ближе точка к центру экрана и тем меньше объект. FOCAL — это фокусное расстояние в пикселях; чем оно меньше, тем сильнее перспектива.
1const FOCAL = 1150;
2const EYE = { x: 500, y: 268 };
3
4/** Точка из объёма — на плоскость. */
5function flatten(point: Point3) {
6 const k = FOCAL / (FOCAL + point.z);
7 return { x: EYE.x + point.x * k, y: EYE.y + point.y * k, k };
8}
Главный трюк: матрица из полуосей
Рисовать окно точка за точкой нельзя — внутри него текст, скруглённые углы, полоски. Нужно нарисовать его в своих координатах и наложить преобразование.
Берём три точки: центр окна и концы двух его полуосей — вправо и вниз. Проецируем все три. Разности «конец полуоси минус центр» дают два вектора, и это ровно первые четыре числа аффинной матрицы. Пятое и шестое — спроецированный центр.
1const centre = flatten(turn({ x: 0, y: 0, z: 0 }, rx, ry, rz, site));
2const right = flatten(turn({ x: half.w, y: 0, z: 0 }, rx, ry, rz, site));
3const down = flatten(turn({ x: 0, y: half.h, z: 0 }, rx, ry, rz, site));
4
5// Аффинное преобразование, выведенное из спроецированных полуосей.
6context.setTransform(
7 (right.x - centre.x) / half.w, (right.y - centre.y) / half.w,
8 (down.x - centre.x) / half.h, (down.y - centre.y) / half.h,
9 centre.x, centre.y,
10);
Порядок и глубина
Рисовать надо от дальних к ближним — сортировкой по z перед отрисовкой. Никакого буфера глубины у 2D-контекста нет, порядок вызовов и есть порядок слоёв.
Чтобы дуга читалась как дуга, радиус к вершине приходится увеличивать: перспектива сама сжимает дальние окна к центру, и без компенсации середина провисает. Числа подбирались глазами, а потом проверялись замером: верхние края окон должны образовать симметричный купол.
Реакция на курсор
Окна поворачиваются в сторону курсора, а не двигаются к нему. Сила поворота падает до нуля примерно за две ширины окна и гаснет, когда курсор уже пришёл на окно — иначе получается либо сцена, дрожащая от любого движения мыши, либо особенность в точке нулевого расстояния.
