ASCII 艺术 3D 旋转立方体:从数学原理到代码实现
1. 引言
在终端里看到一个旋转的 3D 立方体,是不是很酷?本文将从零开始,讲解如何用 ASCII 字符实现一个 3D 旋转立方体。
我们会覆盖:
- 3D 坐标系与立方体定义
- 旋转矩阵的数学原理
- 3D 到 2D 的投影变换
- 深度缓冲与字符选择
- 完整的 Python 实现
最终效果:
╱─────────╲
╱ ╲
╱ ╲
│ │
│ ● │
│ │
╲ ╱
╲ ╱
╲─────────╱
2. 3D 坐标系与立方体
2.1 三维坐标系
我们使用右手坐标系:
- X 轴:向右为正
- Y 轴:向上为正
- Z 轴:向前(朝向观察者)为正
graph TD
subgraph 右手坐标系
Y((Y ↑))
X((X →))
Z((Z ↗))
end
style Y fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style X fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
style Z fill:#fff3e0,stroke:#e65100,stroke-width:2px
2.2 立方体的 8 个顶点
立方体边长为 2,中心在原点。8 个顶点坐标:
graph TD
subgraph 立方体顶点
V4["4: (-1, 1, -1)"] --- V5["5: (1, 1, -1)"]
V7["7: (-1, 1, 1)"] --- V6["6: (1, 1, 1)"]
V4 --- V7
V5 --- V6
V0["0: (-1, -1, -1)"] --- V1["1: (1, -1, -1)"]
V3["3: (-1, -1, 1)"] --- V2["2: (1, -1, 1)"]
V0 --- V3
V1 --- V2
V0 --- V4
V1 --- V5
V2 --- V6
V3 --- V7
end
style V4 fill:#e3f2fd,stroke:#1565c0
style V5 fill:#e3f2fd,stroke:#1565c0
style V6 fill:#e3f2fd,stroke:#1565c0
style V7 fill:#e3f2fd,stroke:#1565c0
style V0 fill:#fff3e0,stroke:#e65100
style V1 fill:#fff3e0,stroke:#e65100
style V2 fill:#fff3e0,stroke:#e65100
style V3 fill:#fff3e0,stroke:#e65100
🔵 上层顶点 (4-7),🟠 下层顶点 (0-3)
代码定义:
import numpy as np
# 立方体的 8 个顶点
vertices = np.array([
[-1, -1, -1], # 0: 左下后
[ 1, -1, -1], # 1: 右下后
[ 1, -1, 1], # 2: 右下前
[-1, -1, 1], # 3: 左下前
[-1, 1, -1], # 4: 左上后
[ 1, 1, -1], # 5: 右上后
[ 1, 1, 1], # 6: 右上前
[-1, 1, 1], # 7: 左上前
])
2.3 立方体的 12 条边
graph LR
subgraph 底面
E0["0-1"]
E1["1-2"]
E2["2-3"]
E3["3-0"]
end
subgraph 顶面
E4["4-5"]
E5["5-6"]
E6["6-7"]
E7["7-4"]
end
subgraph 竖直边
E8["0-4"]
E9["1-5"]
E10["2-6"]
E11["3-7"]
end
# 立方体的 12 条边
edges = [
(0, 1), (1, 2), (2, 3), (3, 0), # 底面
(4, 5), (5, 6), (6, 7), (7, 4), # 顶面
(0, 4), (1, 5), (2, 6), (3, 7), # 竖直边
]
3. 旋转变换的数学原理
3.1 什么是旋转矩阵?
旋转矩阵是一个 3×3 的矩阵,用于将 3D 空间中的点绕某个轴旋转。
核心思想:旋转保持点到原点的距离不变,只改变方向。
graph LR
A["点 P(x, y, z)"] --> B["旋转矩阵 R"]
B --> C["点 P′(x′, y′, z′)"]
style A fill:#e3f2fd,stroke:#1565c0
style B fill:#fff3e0,stroke:#e65100
style C fill:#c8e6c9,stroke:#2e7d32
3.2 绕 Z 轴旋转
绕 Z 轴旋转时,Z 坐标不变,X 和 Y 坐标在 XY 平面内旋转。
graph TD
subgraph 旋转前
P1["● P(x, y)"]
end
subgraph 旋转后
P2["● P′(x′, y′)"]
TH["角度 θ"]
end
公式:
x' = x·cos(θ) - y·sin(θ)
y' = x·sin(θ) + y·cos(θ)
z' = z
矩阵形式:
$$ \begin{bmatrix} x^{\prime} \\ y^{\prime} \\ z^{\prime} \end{bmatrix} = \begin{bmatrix} \cos\theta & -\sin\theta & 0 \\ \sin\theta & \cos\theta & 0 \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ z \end{bmatrix} $$
3.3 三轴旋转矩阵汇总
graph TD
subgraph 旋转矩阵
RX["绕X轴
|1 0 0|
|0 cos -sin|
|0 sin cos|"] RY["绕Y轴
|cos 0 sin|
|0 1 0|
|-sin 0 cos|"] RZ["绕Z轴
|cos -sin 0|
|sin cos 0|
|0 0 1|"] end style RX fill:#ffcdd2,stroke:#c62828 style RY fill:#c8e6c9,stroke:#2e7d32 style RZ fill:#e3f2fd,stroke:#1565c0
|1 0 0|
|0 cos -sin|
|0 sin cos|"] RY["绕Y轴
|cos 0 sin|
|0 1 0|
|-sin 0 cos|"] RZ["绕Z轴
|cos -sin 0|
|sin cos 0|
|0 0 1|"] end style RX fill:#ffcdd2,stroke:#c62828 style RY fill:#c8e6c9,stroke:#2e7d32 style RZ fill:#e3f2fd,stroke:#1565c0
3.4 组合旋转
flowchart LR
A["原始点 P"] --> B["绕Z轴旋转
Rz"] B --> C["绕Y轴旋转
Ry"] C --> D["绕X轴旋转
Rx"] D --> E["最终点 P′"] style A fill:#e3f2fd,stroke:#1565c0 style E fill:#c8e6c9,stroke:#2e7d32
Rz"] B --> C["绕Y轴旋转
Ry"] C --> D["绕X轴旋转
Rx"] D --> E["最终点 P′"] style A fill:#e3f2fd,stroke:#1565c0 style E fill:#c8e6c9,stroke:#2e7d32
注意:矩阵乘法顺序很重要!R = Rx · Ry · Rz
def rotation_matrix(angle_x, angle_y, angle_z):
"""
生成绕 X、Y、Z 轴旋转的组合旋转矩阵
旋转顺序:Z → Y → X(先绕 Z 转,再绕 Y 转,最后绕 X 转)
注意:矩阵乘法从右向左应用,所以写法是 Rx · Ry · Rz
"""
# 预计算 sin 和 cos
cx, sx = np.cos(angle_x), np.sin(angle_x)
cy, sy = np.cos(angle_y), np.sin(angle_y)
cz, sz = np.cos(angle_z), np.sin(angle_z)
# 绕各轴旋转矩阵
Rx = np.array([[1, 0, 0], [0, cx, -sx], [0, sx, cx]])
Ry = np.array([[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]])
Rz = np.array([[cz, -sz, 0], [sz, cz, 0], [0, 0, 1]])
# 组合旋转:R = Rx · Ry · Rz
return Rx @ Ry @ Rz
4. 3D 到 2D 的投影
4.1 为什么需要投影?
我们的屏幕是 2D 的,需要把 3D 坐标"压扁"到 2D 平面上。
graph TD
subgraph 3D空间
P3D["点 (x, y, z)"]
end
subgraph 投影
PROJ["投影变换"]
end
subgraph 2D屏幕
P2D["点 (x′, y′)"]
end
P3D --> PROJ --> P2D
style P3D fill:#e3f2fd,stroke:#1565c0
style P2D fill:#c8e6c9,stroke:#2e7d32
4.2 透视投影 vs 正交投影
graph LR
subgraph 正交投影
O1["正方体"] --> O2["正方形"]
end
subgraph 透视投影
P1["正方体"] --> P2["梯形
(近大远小)"] end style O2 fill:#ffcdd2,stroke:#c62828 style P2 fill:#c8e6c9,stroke:#2e7d32
(近大远小)"] end style O2 fill:#ffcdd2,stroke:#c62828 style P2 fill:#c8e6c9,stroke:#2e7d32
| 投影类型 | 特点 | 适用场景 |
|---|---|---|
| 正交投影 | 直接忽略 Z,无立体感 | 工程图纸、2D 游戏 |
| 透视投影 | 近大远小,真实感强 | 3D 游戏、模拟器 |
4.3 透视投影原理
graph TD
subgraph 透视原理
CAM["👁 观察者"]
PLANE["屏幕平面"]
NEAR["近处物体
(投影大)"] FAR["远处物体
(投影小)"] end CAM -->|距离 d| PLANE PLANE --> NEAR PLANE --> FAR style CAM fill:#f3e5f5,stroke:#7b1fa2 style NEAR fill:#c8e6c9,stroke:#2e7d32 style FAR fill:#ffcdd2,stroke:#c62828
(投影大)"] FAR["远处物体
(投影小)"] end CAM -->|距离 d| PLANE PLANE --> NEAR PLANE --> FAR style CAM fill:#f3e5f5,stroke:#7b1fa2 style NEAR fill:#c8e6c9,stroke:#2e7d32 style FAR fill:#ffcdd2,stroke:#c62828
公式:
$$ x_{2d} = x \cdot \frac{d}{z + z_{offset}} \quad y_{2d} = y \cdot \frac{d}{z + z_{offset}} $$
其中:
- d = 焦距(控制透视强度)
- z_offset = 将物体推远,避免除零
def project(point, distance=5, z_offset=3):
"""
将 3D 点投影到 2D 平面
参数:
point: 3D 坐标 [x, y, z]
distance: 焦距,越大越接近正交投影
z_offset: Z 轴偏移,将物体推向远处
"""
x, y, z = point
# 防止除零
denominator = max(z + z_offset, 0.001)
# 透视投影公式
x_2d = x * distance / denominator
y_2d = y * distance / denominator
return (x_2d, y_2d)
5. 屏幕映射与光栅化
5.1 坐标转换流程
flowchart LR
A["3D 坐标
(x, y, z)"] --> B["旋转"] B --> C["投影
(x′, y′)"] C --> D["缩放
(x×scale, y×scale)"] D --> E["居中
(+width/2, +height/2)"] E --> F["屏幕坐标
(整数)"] style A fill:#e3f2fd,stroke:#1565c0 style F fill:#c8e6c9,stroke:#2e7d32
(x, y, z)"] --> B["旋转"] B --> C["投影
(x′, y′)"] C --> D["缩放
(x×scale, y×scale)"] D --> E["居中
(+width/2, +height/2)"] E --> F["屏幕坐标
(整数)"] style A fill:#e3f2fd,stroke:#1565c0 style F fill:#c8e6c9,stroke:#2e7d32
5.2 Bresenham 直线算法
要在两点之间画线,需要光栅化算法:
graph TD
subgraph 光栅化
S["起点 (x0, y0)"]
E["终点 (x1, y1)"]
PIXELS["选择最接近理想直线的像素"]
end
S --> PIXELS
E --> PIXELS
style PIXELS fill:#fff3e0,stroke:#e65100
def draw_line(canvas, x0, y0, x1, y1, char='●'):
"""
Bresenham 直线算法
原理:在离散网格上,选择最接近理想直线的像素点
"""
dx = abs(x1 - x0)
dy = abs(y1 - y0)
sx = 1 if x0 < x1 else -1
sy = 1 if y0 < y1 else -1
err = dx - dy
while True:
set_pixel(canvas, x0, y0, char)
if x0 == x1 and y0 == y1:
break
e2 = 2 * err
if e2 > -dy:
err -= dy
x0 += sx
if e2 < dx:
err += dx
y0 += sy
6. 深度排序与字符选择
6.1 为什么要处理深度?
当多条边重叠时,需要决定显示哪个。
graph LR
subgraph 无深度处理
BAD["看起来很乱
前后重叠"] end subgraph 有深度处理 GOOD["远处的先画
近处的覆盖"] end BAD --> GOOD style BAD fill:#ffcdd2,stroke:#c62828 style GOOD fill:#c8e6c9,stroke:#2e7d32
前后重叠"] end subgraph 有深度处理 GOOD["远处的先画
近处的覆盖"] end BAD --> GOOD style BAD fill:#ffcdd2,stroke:#c62828 style GOOD fill:#c8e6c9,stroke:#2e7d32
6.2 深度字符梯度
graph LR
C1["·"] --> C2["░"] --> C3["▒"] --> C4["▓"] --> C5["█"] --> C6["●"]
L1["远"] -.->|"深度递增"| L6["近"]
style C1 fill:#e0e0e0
style C6 fill:#424242,color:#fff
def get_char_by_depth(z, z_min=-2, z_max=2):
"""根据深度选择字符"""
normalized = (z - z_min) / (z_max - z_min)
normalized = max(0, min(1, normalized))
chars = ['·', '░', '▒', '▓', '█', '●']
index = int(normalized * (len(chars) - 1))
return chars[index]
7. 完整渲染流程
7.1 流程图
flowchart TD
A[开始] --> B[旋转所有顶点]
B --> C[投影到 2D]
C --> D[创建空白画布]
D --> E[按深度排序边]
E --> F{遍历每条边}
F --> G[转换为屏幕坐标]
G --> H[选择深度字符]
H --> I[Bresenham 画线]
I --> J{还有边?}
J -->|是| F
J -->|否| K[绘制顶点]
K --> L[输出到终端]
L --> M[更新旋转角度]
M --> N{用户退出?}
N -->|否| A
N -->|是| O[结束]
style A fill:#e3f2fd,stroke:#1565c0
style O fill:#c8e6c9,stroke:#2e7d32
7.2 完整代码
#!/usr/bin/env python3
"""
ASCII 艺术 3D 旋转立方体
完整实现,可直接运行
"""
import numpy as np
import time
import os
class ASCIICube:
"""ASCII 艺术 3D 旋转立方体"""
def __init__(self, width=70, height=35):
self.width = width
self.height = height
# 立方体顶点(边长 2,中心在原点)
self.vertices = np.array([
[-1, -1, -1], [1, -1, -1], [1, -1, 1], [-1, -1, 1],
[-1, 1, -1], [1, 1, -1], [1, 1, 1], [-1, 1, 1],
], dtype=float)
# 12 条边
self.edges = [
(0,1), (1,2), (2,3), (3,0), # 底面
(4,5), (5,6), (6,7), (7,4), # 顶面
(0,4), (1,5), (2,6), (3,7), # 竖直
]
# 旋转角度
self.ax = self.ay = self.az = 0
self.speed = (0.02, 0.04, 0.01)
# 投影参数
self.distance = 4
self.z_offset = 3
self.scale = 10
def rotation_matrix(self, ax, ay, az):
"""生成组合旋转矩阵"""
cx, sx = np.cos(ax), np.sin(ax)
cy, sy = np.cos(ay), np.sin(ay)
cz, sz = np.cos(az), np.sin(az)
Rx = np.array([[1, 0, 0], [0, cx, -sx], [0, sx, cx]])
Ry = np.array([[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]])
Rz = np.array([[cz, -sz, 0], [sz, cz, 0], [0, 0, 1]])
return Rx @ Ry @ Rz
def project(self, point):
"""3D 点透视投影到 2D"""
x, y, z = point
denom = max(z + self.z_offset, 0.001)
return (x * self.distance / denom,
y * self.distance / denom,
z)
def to_screen(self, x, y):
"""投影坐标转屏幕坐标"""
sx = int(self.width / 2 + x * self.scale)
sy = int(self.height / 2 - y * self.scale)
return (sx, sy)
def get_char(self, z):
"""根据深度选择字符"""
z_norm = (z + 2) / 4
z_norm = max(0, min(1, z_norm))
chars = ['·', '░', '▒', '▓', '█', '●']
return chars[int(z_norm * 5)]
def draw_line(self, canvas, x0, y0, x1, y1, char):
"""Bresenham 直线算法"""
dx, dy = abs(x1-x0), abs(y1-y0)
sx = 1 if x0 < x1 else -1
sy = 1 if y0 < y1 else -1
err = dx - dy
while True:
if 0 <= x0 < self.width and 0 <= y0 < self.height:
canvas[y0][x0] = char
if x0 == x1 and y0 == y1:
break
e2 = 2 * err
if e2 > -dy:
err -= dy
x0 += sx
if e2 < dx:
err += dx
y0 += sy
def render(self):
"""渲染当前帧"""
# 旋转
R = self.rotation_matrix(self.ax, self.ay, self.az)
rotated = [R @ v for v in self.vertices]
# 投影
projected = [self.project(v) for v in rotated]
# 画布
canvas = [[' '] * self.width for _ in range(self.height)]
# 按深度排序边(远的先画)
edge_depths = [(projected[i][2] + projected[j][2]) / 2
for i, j in self.edges]
sorted_edges = sorted(zip(edge_depths, self.edges))
# 绘制边
for z, (i, j) in sorted_edges:
p1, p2 = projected[i], projected[j]
x1, y1 = self.to_screen(p1[0], p1[1])
x2, y2 = self.to_screen(p2[0], p2[1])
char = self.get_char(z)
self.draw_line(canvas, x1, y1, x2, y2, char)
# 绘制顶点
for p in projected:
x, y = self.to_screen(p[0], p[1])
if 0 <= x < self.width and 0 <= y < self.height:
canvas[y][x] = '●'
return '\n'.join(''.join(row) for row in canvas)
def update(self):
"""更新旋转角度"""
self.ax += self.speed[0]
self.ay += self.speed[1]
self.az += self.speed[2]
def run(self, fps=30):
"""运行动画"""
try:
while True:
os.system('cls' if os.name == 'nt' else 'clear')
print(self.render())
print("\n[按 Ctrl+C 退出]")
self.update()
time.sleep(1.0 / fps)
except KeyboardInterrupt:
print("\n再见!👋")
if __name__ == '__main__':
cube = ASCIICube(width=70, height=30)
cube.run(fps=30)
8. 扩展方向
8.1 功能扩展路线图
graph TD
A[线框立方体] --> B[面填充]
B --> C[光照效果]
C --> D[纹理贴图]
A --> E[更多形状]
E --> F[四面体]
E --> G[球体]
E --> H[圆环]
A --> I[交互控制]
I --> J[键盘控制旋转]
I --> K[鼠标拖拽]
style A fill:#c8e6c9,stroke:#2e7d32
style D fill:#f3e5f5,stroke:#7b1fa2
8.2 背面剔除
只渲染朝向观察者的面:
flowchart TD
A[面] --> B[计算法向量]
B --> C{朝向相机?}
C -->|是| D[渲染]
C -->|否| E[跳过]
style D fill:#c8e6c9,stroke:#2e7d32
style E fill:#ffcdd2,stroke:#c62828
9. 核心公式速查
graph TD
subgraph 旋转公式
RX["绕X轴: x′=x, y′=y·cos-sin, z′=y·sin+z·cos"]
RY["绕Y轴: x′=x·cos+z·sin, y′=y, z′=-x·sin+z·cos"]
RZ["绕Z轴: x′=x·cos-y·sin, y′=x·sin+y·cos, z′=z"]
end
subgraph 投影公式
PJ["x_2d = x·d / (z+offset)"]
PK["y_2d = y·d / (z+offset)"]
end
style RX fill:#ffcdd2,stroke:#c62828
style RY fill:#c8e6c9,stroke:#2e7d32
style RZ fill:#e3f2fd,stroke:#1565c0
style PJ fill:#fff3e0,stroke:#e65100
style PK fill:#fff3e0,stroke:#e65100
10. 总结
本文从零实现了一个 ASCII 艺术 3D 旋转立方体,涵盖:
| 知识点 | 说明 |
|---|---|
| 3D 坐标系 | 右手坐标系,X/Y/Z 轴定义 |
| 旋转矩阵 | 绕 X/Y/Z 轴旋转的数学公式 |
| 组合旋转 | 矩阵乘法,顺序很重要 |
| 透视投影 | 近大远小,除以深度 |
| 光栅化 | Bresenham 直线算法 |
| 深度处理 | 按深度排序,远到近绘制 |
关键洞察:
- 3D 图形的本质是数学变换的叠加
- 透视投影创造了深度感
- 光栅化连接了连续世界与离散屏幕