3D-куб в терминале на Python.

Создаем объемный куб, крутим его и отображаем в терминале.

Разберём код из видео как маленький графический движок. У него четыре задачи: описать форму, повернуть её, спроецировать на экран и соединить точки. Никаких 3D-библиотек — только координаты, синус и косинус.

Крутим-вертим кубик.

Можно покрутить ползунки, посмотреть, что будет.

Каркас куба Куб повёрнут вокруг осей X и Y.

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),
#    ...
# ]

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

Ребро1 координата
Диагональ грани2 координаты
Диагональ куба3 координаты

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

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). Для произвольной точки правило выглядит так:

u′ = u cos a − v sin a
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)

Поворот в плоскости YZ При нулевом угле точка находится на положительной полуоси Y. yz

y′ = 1.000 · z′ = 0.000 · радиус = 1

Вернёмся к кубу. Когда крутим его вокруг X, координата x у каждой точки не меняется. Меняются только y и z — как на схеме выше. Затем крутим вокруг Y: теперь y остаётся прежним, а x и z меняются. Применяем два поворота по очереди:

y₁ = y cos a − z sin a
z₁ = y sin a + z cos a
x₂ = x cos a + z₁ sin 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(a) =
100
0cos a−sin a
0sin acos a
RY(a) =
cos a0sin a
010
−sin a0cos a

Две матрицы соответствуют двум шагам: 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 на глубину. Если отодвинуть предмет вдвое дальше, его ширина и высота на изображении уменьшатся вдвое. Это и есть перспективная проекция — способ перенести объёмную сцену на плоскость с учётом расстояния.

Почему именно делим? Представьте луч от камеры к точке. На вдвое большем расстоянии тот же луч отклонится от центра вдвое сильнее. Значит, чтобы узнать положение точки на одном и том же экране, нужно убрать влияние расстояния: разделить её смещение на глубину. Дальше остаётся подобрать размер картинки:

scale = 35 / (z + 5)
X = int(W / 2 + 2x · scale)
Y = int(H / 2 − y · scale)

Одинаковая высота, разная глубина

Слева — отрезок на глубине 3. Справа — такой же, но его можно отодвигать. Поставьте глубину 6: правый станет вдвое меньше на экране, хотя сам отрезок мы не меняли.

Размер двух одинаковых отрезков на экране При одинаковой глубине размеры совпадают. d = 3d = 3

Правый отрезок: 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 — пришли в конец. Координаты любой точки на этом пути считаются так. Такой способ называется линейной интерполяцией:

P(t) = (1 − t)A + tB
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). Тонкая линия показывает точный путь, закрашенные ячейки — куда попадут символы после округления. Уменьшите ползунок до нуля и двигайте вправо: линия будет собираться по шагам.

Пошаговая растеризация линии Отрезок разбит на 13 шагов.

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)

Получилось: задали восемь вершин, повернули их, перенесли на экран и соединили символами. Если заменить список вершин и рёбер, тот же код сможет крутить другую фигуру. Можно попробовать пирамиду.


Ещё материалы: Телеграм · YouTube

← Все статьи