3D-куб в терминале на Python.
Создаем объемный куб, крутим его и отображаем в терминале.
Разберём код из видео как маленький графический движок. У него четыре задачи: описать форму, повернуть её, спроецировать на экран и соединить точки. Никаких 3D-библиотек — только координаты, синус и косинус.
Крутим-вертим кубик.
Можно покрутить ползунки, посмотреть, что будет.
8 вершин · 12 рёбер
01. Сначала — форма
Представим трёхмерное пространство (каждая точка в нём задаётся тремя координатами x, y, z). В начале координат (0, 0, 0) поместим центр нашего куба, а вершины получат координаты -1 или 1. С помощью вложенных циклов получим 2 * 2 * 2 = 8 вершин.
vertices = [
(x, y, z)
for x in (-1, 1)
for y in (-1, 1)
for z in (-1, 1)
]
# vertices = [
# (-1, -1, -1),
# (-1, -1, 1),
# (-1, 1, -1),
# ...
# ]
Теперь нужны рёбра — линии, соединяющие соседние вершины. Как понять, что линия является именно ребром, а не диагональю грани или куба? Нужно посмотреть, сколько координат вершин отличаются. Если только одна — это ребро. Если две — диагональ грани. Если три — диагональ куба.
С помощью вот такого цикла создадим список рёбер. Каждое ребро — два индекса вершин куба.
edges = [
(i, j)
for i in range(8)
for j in range(i + 1, 8)
if sum(vertices[i][k] != vertices[j][k] for k in range(3)) == 1
]
# edges = [(0, 1), (0, 2), (0, 4), (1, 3), ...]
02. Поворот — это смена координат
Теперь куб нужно повернуть. Чтобы это сделать, нужно по некоторым правилам поменять координаты вершин: провернуть их вдоль окружности.
Начнём с поворота на плоскости. Назовём координаты точки u и v, а угол поворота — a. Синус и косинус помогут посчитать, куда переедет точка. Для точки на окружности радиуса 1 косинус угла даёт координату по горизонтали, а синус — по вертикали. При 0° это (1, 0), при 90° — (0, 1). Для произвольной точки правило выглядит так:
v′ = u sin a + v cos a
Почему в формуле участвуют обе старые координаты? После поворота и горизонтальное, и вертикальное смещение точки влияют на её новое положение. Косинус и синус задают доли каждого смещения, которые попадут на новые оси. u′ и v′ означают новые координаты. При нулевом угле cos a = 1, sin a = 0 — точка остаётся на месте. При 90° cos a = 0, sin a = 1: получаем u′ = −v, v′ = u.
Поворот в плоскости YZ
Смотрим на куб вдоль оси X. Покрутим точку, которая начинает в (1, 0)
y′ = 1.000 · z′ = 0.000 · радиус = 1
Вернёмся к кубу. Когда крутим его вокруг X, координата x у каждой точки не меняется. Меняются только y и z — как на схеме выше. Затем крутим вокруг Y: теперь y остаётся прежним, а x и z меняются. Применяем два поворота по очереди:
z₁ = y sin a + z cos a
z₂ = −x sin a + z₁ cos a
y₂ = y₁
Здесь y₁ и z₁ — координаты после первого поворота, x₂ и z₂ — после второго. Знаки плюс и минус задают, в какую сторону вращаем точку.
Эти же вычисления можно записать таблицами чисел — матрицами. В каждой строке лежит рецепт для одной новой координаты: умножаем три числа строки на x, y и z, затем складываем. Например, строка (0, cos a, −sin a) даёт 0 · x + y cos a − z sin a. Это как раз наша формула для y₁:
Две матрицы соответствуют двум шагам: RX поворачивает вокруг X, RY — вокруг Y. Их можно записать одной строкой: p′ = RY(a) RX(a) p. Здесь p — исходная точка. Читаем справа налево: берём точку, поворачиваем вокруг X, затем вокруг Y.
При этом куб не должен растягиваться. Синус и косинус подобраны так, что расстояния при повороте сохраняются.
В коде делаем ровно то же самое: сначала считаем новые y и z, затем x и z. Запись y, z = (...) удобна тем, что Python сначала посчитает обе новые координаты из старых и только потом заменит значения.
Ещё момент: math.sin и math.cos ждут угол в радианах. Это другая единица измерения угла: полный оборот — 2π радиан, половина оборота — π. Чтобы перевести градусы в радианы, умножаем их на π / 180. В нашем цикле угол сразу задаётся в радианах.
def rotate(x, y, z, a):
y, z = (
y * math.cos(a) - z * math.sin(a),
y * math.sin(a) + z * math.cos(a)
)
x, z = (
x * math.cos(a) + z * math.sin(a),
-x * math.sin(a) + z * math.cos(a)
)
return x, y, z
03. Как глубина становится размером
Крутить точки научились. Теперь нужно показать их на плоском экране. Представим, что смотрим на куб через камеру. Куб стоит в центре, а камера — в точке (0, 0, −5) и смотрит вдоль оси Z. Между ней и центром куба 5 единиц.
У точки с координатой z = −1 расстояние по направлению взгляда будет 4, а у точки с z = +1 — 6. В общем виде эта глубина равна d = z + 5.
Дальние предметы выглядят меньше. Здесь делаем так же: делим x и y на глубину. Если отодвинуть предмет вдвое дальше, его ширина и высота на изображении уменьшатся вдвое. Это и есть перспективная проекция — способ перенести объёмную сцену на плоскость с учётом расстояния.
Почему именно делим? Представьте луч от камеры к точке. На вдвое большем расстоянии тот же луч отклонится от центра вдвое сильнее. Значит, чтобы узнать положение точки на одном и том же экране, нужно убрать влияние расстояния: разделить её смещение на глубину. Дальше остаётся подобрать размер картинки:
X = int(W / 2 + 2x · scale)
Y = int(H / 2 − y · scale)
Одинаковая высота, разная глубина
Слева — отрезок на глубине 3. Справа — такой же, но его можно отодвигать. Поставьте глубину 6: правый станет вдвое меньше на экране, хотя сам отрезок мы не меняли.
Правый отрезок: 100% высоты левого.
Число 35 подбирает общий размер картинки: увеличим его — куб станет крупнее. Теперь нужно разместить картинку посередине терминала. Его ширина W = 80 символов, высота H = 30 строк. Поэтому к горизонтальной координате прибавляем W / 2, а к вертикальной — H / 2.
Перед y стоит минус. В обычной системе координат положительный y идёт вверх, а в терминале номер строки растёт вниз. Вычитаем y, чтобы верх куба остался сверху.
А почему по горизонтали ещё умножаем на 2? Ячейка символа в терминале обычно вытянута вверх: её высота примерно вдвое больше ширины. Растягиваем изображение по горизонтали, чтобы куб не выглядел сжатым. Для другого шрифта этот множитель можно подстроить.
В конце вызываем int: он убирает дробную часть. Нам нужны целые номера столбца и строки — символ нельзя поставить между двумя ячейками.
def project(point):
x, y, z = point
scale = 35 / (z + 5)
return (
int(W / 2 + x * scale * 2),
int(H / 2 - y * scale)
)
Точка не должна попадать в плоскость камеры: её глубина станет нулевой, а делить на ноль нельзя. Здесь камера стоит с запасом. От центра куба до любой вершины примерно 1.73 единицы (точно — √3), а до камеры — 5. Поэтому все вершины при любом повороте остаются перед камерой. Для более сложной сцены пришлось бы отдельно обрабатывать точки слишком близко к камере и за ней.
04. Из геометрии — в символы
Теперь у нас есть точки на экране. Осталось соединить каждую пару, которая образует ребро. Но терминал умеет показывать только отдельные символы. Нужно решить, какие ячейки закрасить между началом A и концом B.
Будем постепенно идти от A к B. Обозначим пройденную долю пути буквой t: 0 — ещё стоим в начале, 0.5 — дошли до середины, 1 — пришли в конец. Координаты любой точки на этом пути считаются так. Такой способ называется линейной интерполяцией:
x(t) = x₁ + (x₂ − x₁)t
y(t) = y₁ + (y₂ − y₁)t, 0 ≤ t ≤ 1
Например, идём от x = 2 до x = 10. Разница равна 8. На четверти пути получим 2 + 8 × 0.25 = 4. С координатой y делаем то же самое.
Осталось выбрать количество шагов. Если по горизонтали нужно пройти 13 ячеек, а по вертикали 6, делаем 13 шагов — берём большее из двух расстояний. Тогда за один шаг ни по одной оси не перепрыгнем больше одной ячейки.
Как линия попадает в сетку
Пойдём от A = (1, 1) к B = (14, 7). Тонкая линия показывает точный путь, закрашенные ячейки — куда попадут символы после округления. Уменьшите ползунок до нуля и двигайте вправо: линия будет собираться по шагам.
t = 1 · последняя ячейка (14, 7)
Перед записью символа проверяем, что x и y находятся внутри экрана. Если часть ребра вышла за край, просто не рисуем её. Иногда из-за округления несколько шагов попадают в одну ячейку — повторно ставим туда тот же символ, ничего страшного. Перевод линии в набор ячеек называется растеризацией.
def line(screen, x1, y1, x2, y2):
steps = max(abs(x2 - x1), abs(y2 - y1), 1)
for i in range(steps + 1):
t = i / steps
x = round(x1 + (x2 - x1) * t)
y = round(y1 + (y2 - y1) * t)
if 0 <= x < W and 0 <= y < H:
screen[y][x] = "█"
Один кадр за другим
Теперь собираем всё в цикл. Создаём экран — список из 30 строк, в каждой по 80 пробелов. Для каждой вершины сначала вызываем rotate, затем project. Получаем восемь точек на экране и соединяем нужные пары через line.
Выводим кадр, немного увеличиваем угол и повторяем. Перед новым кадром отправляем в терминал \033[H — команду вернуть курсор в левый верхний угол. Поэтому кадры не идут бесконечной лентой вниз, а рисуются друг поверх друга. Команда \033[?25l скрывает курсор, чтобы он не мешал, а clear очищает экран перед запуском.
После кадра прибавляем к углу 0.035 радиана — примерно 2°. Чем больше прибавка, тем быстрее вращается куб. Затем time.sleep(0.03) делает паузу в 0.03 секунды. Получается около 33 кадров в секунду или меньше: вычисления и вывод тоже занимают время.
Задние рёбра мы тоже рисуем. Поэтому куб выглядит как проволочный каркас: грани ничего не закрывают. Для нашего примера этого достаточно.
while True:
screen = [[" " for _ in range(W)] for _ in range(H)]
points = [project(rotate(*v, a)) for v in vertices]
for i, j in edges:
line(screen, *points[i], *points[j])
print("\033[H", end="")
print("\n".join("".join(row) for row in screen), end="", flush=True)
a += 0.035
time.sleep(0.03)
05. Полный код
Собираем всё вместе. Скачать cube.py. Запускаем командой python3 cube.py, останавливаем через Ctrl+C.
import math
import time
import os
vertices = [
(x, y, z)
for x in (-1, 1)
for y in (-1, 1)
for z in (-1, 1)
]
edges = [
(i, j)
for i in range(8)
for j in range(i + 1, 8)
if sum(vertices[i][k] != vertices[j][k] for k in range(3)) == 1
]
W, H = 80, 30
def rotate(x, y, z, a):
y, z = (
y * math.cos(a) - z * math.sin(a),
y * math.sin(a) + z * math.cos(a)
)
x, z = (
x * math.cos(a) + z * math.sin(a),
-x * math.sin(a) + z * math.cos(a)
)
return x, y, z
def project(point):
x, y, z = point
scale = 35 / (z + 5)
return (
int(W / 2 + x * scale * 2),
int(H / 2 - y * scale)
)
def line(screen, x1, y1, x2, y2):
steps = max(abs(x2 - x1), abs(y2 - y1), 1)
for i in range(steps + 1):
t = i / steps
x = round(x1 + (x2 - x1) * t)
y = round(y1 + (y2 - y1) * t)
if 0 <= x < W and 0 <= y < H:
screen[y][x] = "█"
os.system("clear")
print("\033[?25l", end="")
a = 0
while True:
screen = [[" " for _ in range(W)] for _ in range(H)]
points = [project(rotate(*v, a)) for v in vertices]
for i, j in edges:
line(screen, *points[i], *points[j])
print("\033[H", end="")
print("\n".join("".join(row) for row in screen), end="", flush=True)
a += 0.035
time.sleep(0.03)
Получилось: задали восемь вершин, повернули их, перенесли на экран и соединили символами. Если заменить список вершин и рёбер, тот же код сможет крутить другую фигуру. Можно попробовать пирамиду.
← Все статьи