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

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

注意:矩阵乘法顺序很重要!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
投影类型 特点 适用场景
正交投影 直接忽略 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

公式:

$$ 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

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

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 图形的本质是数学变换的叠加
  • 透视投影创造了深度感
  • 光栅化连接了连续世界离散屏幕

参考资料

  1. 3D 旋转矩阵 - Wikipedia
  2. 透视投影 - Wikipedia
  3. Bresenham 算法 - Wikipedia
  4. ASCII Art - Wikipedia