我一直觉得,很多人在听到“Python 写物理引擎”时,第一反应是“这台机器怕不是要烧起来”。毕竟 Python 是出了名的解释执行、循环慢、GIL 锁死,拿它去跑逐帧的物理模拟,听起来确实像是在做性能自杀。但如果你从一开始就把所有物理状态组织成 NumPy 数组,让热循环下沉到 C 层,情况就完全不同了。这篇文章我想聊的,就是如何用 Python + NumPy 从零搭一个能用于游戏开发、机器人控制与 VR 交互原型的物理模拟引擎,包括最核心的数值积分、碰撞处理、约束求解、性能优化,以及一条条踩出来的经验。
先说结论:用 NumPy 写物理引擎,本质不是“用 Python 写引擎”,而是“用 Python 脚本当指挥中心,让编译好的 C 代码干重活”。位置、速度、受力全部存在连续内存里,一次加法直接操作整个数组,手感上像在写 Python,吞吐量却接近 C。这篇文章适合三种人:想快速验证物理机制的游戏开发者、需要搭建简化动力学仿真环境的机器人研究者、以及所有好奇“NumPy 为什么能加速”的人。下面我从最底层的设计思路开始,逐步展开。
1. 为什么物理引擎要拥抱 NumPy:一场关于“慢语言”的误会
1.1 “慢语言”与“快数组”:被误解的事实
Python 慢,这个结论本身没错。问题在于,很多人的慢是慢在写法上——三层 for 循环嵌套,逐个粒子更新状态。每次循环都要做解释器调度、对象属性查找、数值装箱拆箱,跑 100 万次,再快的机器也扛不住。
但 NumPy 的核心逻辑是预编译的 C 和 Fortran 代码,它把“对每个元素执行同一操作”的任务批量完成。用 NumPy 写物理引擎时,你的循环通常只发生在以下两处:一是每个时间步的全局更新,二是少量约束迭代。真正逐粒子的操作,全部通过向量化表达式完成。
我用一个简单例子验证过。100 万个粒子,每个粒子有位置、速度、外力,做一次最基础的“受力更新速度,速度更新位置”:
import numpy as np import time n = 1_000_000 positions = np.random.rand(n, 3) velocities = np.random.rand(n, 3) * 0.1 forces = np.random.rand(n, 3) mass = np.ones(n) dt = 0.016 # 纯 Python 写法:先转列表,再逐粒子更新 t0 = time.perf_counter() pos_list = positions.tolist() vel_list = velocities.tolist() f_list = forces.tolist() for i in range(n): ax = f_list[i][0] / mass[i] ay = f_list[i][1] / mass[i] az = f_list[i][2] / mass[i] vel_list[i][0] += ax * dt vel_list[i][1] += ay * dt vel_list[i][2] += az * dt pos_list[i][0] += vel_list[i][0] * dt pos_list[i][1] += vel_list[i][1] * dt pos_list[i][2] += vel_list[i][2] * dt t1 = time.perf_counter() print("纯 Python 耗时:", t1 - t0, "秒") # NumPy 写法 t0 = time.perf_counter() acceleration = forces / mass[:, np.newaxis] velocities += acceleration * dt positions += velocities * dt t1 = time.perf_counter() print("NumPy 耗时:", t1 - t0, "秒")我自己机器上测出来的差距大概是几十倍到上百倍。这不是说 Python 开发者笨,而是“命令式循环”和“数组表达式”属于完全不同的执行模型。理解这一点,是设计高性能物理模拟的第一个门槛。
1.2 物理世界天然是列式数据
很多初学者写物理模拟,第一直觉是建一个 Particle 类,里面放 x、y、z、vx、vy、vz,然后 new 一万个对象,塞进列表。这在 Python 里是灾难。每个对象都有独立的 dict 存储属性,内存碎片化严重,遍历列表等于不停地在指针之间跳来跳去,CPU 缓存利用率极低。
NumPy 的思路是“列式存储”:不再维护一背包粒子对象,而是维护几个大数组。x 是全体粒子 x 坐标的数组,y 是全体粒子 y 坐标的数组,或者更常见的,直接用一个(N, 3)的二维数组存所有位置。数组里每个元素紧挨着排布在连续内存上,做一次加法时,CPU 可以顺序读、顺序写,缓存命中率完全不同。
用生活化类比就是:你有一百个快递要送,一个地址一张纸,挨个翻查效率低;不如拉一张大表,所有门牌号都在同一列里,一列一列处理。物理模拟本质上是批量计算,天然适合这种“拉表”式数据组织。
1.3 向量化带来的第一道性能红利
向量化还意味着你可以直接利用 CPU 的 SIMD 指令。现代处理器支持单条指令同时处理多个浮点数,NumPy 底层会在合适的时机做这种优化。你不需要手动写汇编,只需要把你的计算表达成“数组加数组”“数组乘标量”这种形式,剩下的交给底层。
当然,向量化不是银弹。它适合“结构一致、计算同构”的任务,而不适合“大量分支、稀疏操作”的任务。物理模拟正好是前者的典型代表:每帧每个粒子都执行同样的牛顿第二定律,无非是力不同、质量不同,完全可以用同一条数组表达式覆盖。
2. 状态量的数组化设计:把物理场装进内存
2.1 最小状态集:位置、速度与力
一个刚体或粒子系统,核心状态量其实就三个:位置、速度、外加合力。质量可以视为常数。用 NumPy 组织起来极其简单:
N = 10000 positions = np.zeros((N, 3), dtype=np.float64) velocities = np.zeros((N, 3), dtype=np.float64) forces = np.zeros((N, 3), dtype=np.float64) mass = np.full(N, 1.0, dtype=np.float64)这里有几个细节值得注意:
dtype默认就是float64,显式写出来是为了提醒自己“一切都是连续内存块”。(N, 3)的排布有一个额外好处:调用forces[:, 1] += ...可以直接处理所有粒子的 Y 分量,比如施加统一重力。- 力的数组每帧都要清零重建,所以
forces[:] = 0.0会比forces = np.zeros_like(forces)更好,因为前者原地清零,不触发新内存分配。
我见过不少新手在循环里反复执行positions = np.array([...]),每帧创建新数组,导致内存申请和释放的 overhead 非常大。正确做法是初始化一次,之后所有更新都靠+=和*=完成,保持数组身份不变。
2.2 力模型不是循环,是数组表达式
最常见的三种力——重力、线性阻力、弹簧力——在 NumPy 里都只有几行。
gravity = np.array([0.0, -9.8, 0.0]) k_drag = 0.1 forces[:] = 0.0 forces[:, 1] += mass * gravity[1] # 重力:F = m * g forces -= k_drag * velocities # 线性阻力:F = -k * v弹簧力稍微复杂一点,因为需要计算粒子对之间的相对位置。做法是维护两个索引数组src和dst,分别表示弹簧两端的粒子编号,然后通过高级索引一次性取出两端位置:
src = np.array([0, 1, 2, ...], dtype=np.int32) dst = np.array([1, 2, 3, ...], dtype=np.int32) delta = positions[src] - positions[dst] dist = np.linalg.norm(delta, axis=1, keepdims=True) direction = delta / (dist + 1e-12) # 加一点保护,避免除零 spring_force_magnitude = -k * (dist - rest_length) forces[src] += direction * spring_force_magnitude forces[dst] -= direction * spring_force_magnitude这里每一步都是对整个弹簧集合起作用的,即使有几万条弹簧,也只是一次数组运算,而不是一个 Python 循环。
2.3 心智模型:对数组说话,而不是对粒子说话
写熟之后,你会形成一种新的心智模型:不再问“这个粒子受到哪些力”,而是问“这一批粒子的某个分量整体满足什么关系”。比如:
- “所有低于地面的粒子,把 Y 坐标设回 0”——这不是 if 语句,是布尔掩码。
- “所有弹簧的当前长度小于自然长度,就施加推力”——这不是分支,是符号运算。
- “把所有粒子的速度同时加上重力加速度乘 dt”——这不是循环,是一行乘法加法。
这个转变对工程实现很重要。只要你开始像操作电子表格一样操作物理数组,写物理引擎的难度会大幅下降。
3. 积分器选型:半隐式欧拉为什么是默认答案
3.1 三种积分器的对比
物理引擎的核心循环就是不断积分:知道加速度,算速度,算位置。常见选择有三种:
| 积分器 | 更新顺序 | 稳定性 | 适用场景 |
|---|---|---|---|
| 显式欧拉 | 先算位置再算速度,都基于旧状态 | 能量容易漂移,弹簧系统会越跑越“兴奋” | 简单的学习演示,不推荐用于引擎 |
| 半隐式欧拉(semi-implicit Euler) | 先更新速度,再用新速度更新位置 | 相对稳定,系统呈轻微耗散趋势 | 游戏开发、机器人仿真的默认选项 |
| 速度 Verlet | 先更新半速,再更新位置,再用新加速度更新半速 | 对振荡系统更稳定,能量守恒性更好 | 分子动力学、布料模拟 |
为什么显式欧拉不稳定?因为它用“旧速度”推动“新位置”,相当于每个时间步都在给系统强行注入误差,在弹簧这种周期性运动里,误差会累积成震荡发散。半隐式欧拉先用新速度推动位置,等于把一部分未来信息提前纳入计算,无形中给系统加了阻尼。
3.2 半隐式欧拉的五行实现
实现极简:
def integrate(positions, velocities, forces, inv_mass, dt): acceleration = forces * inv_mass[:, np.newaxis] velocities += acceleration * dt positions += velocities * dt forces[:] = 0.0注意这里用了inv_mass(逆质量)而不是直接forces / mass。原因后面会讲。这五行的顺序不能乱:一定是先速度后位置。如果你把顺序写反,就退化成显式欧拉。
3.3 步长与刚度的平衡术
积分步长dt不是随意定的。物理系统有一个特征频率,比如一根弹簧:刚度k=100,质量m=1,角频率ω=sqrt(k/m)=10 rad/s,周期约 0.63 秒。为了保证数值稳定性,dt至少要小于振荡周期的几十分之一,经验上取1/(10ω)以下,也就是 0.01 秒级别。弹簧越硬,dt必须越小。这也是为什么布料模拟里弹簧刚度过大时,布会直接炸开——不是物理规则错了,是数值方法跟不上。
我的经验法则是:先按目标帧率选dt(比如 60 FPS 则dt=1/60≈0.0167),然后反推系统里允许的最大弹簧刚度。如果刚度过高,要么降低刚度,要么减少dt,在游戏原型里可以直接做“每帧内部跑多个物理子步”:
substeps = 4 sub_dt = dt / substeps for _ in range(substeps): integrate(...)这样物理精度提高了,但计算量也翻倍。怎么取舍,取决于你对实时性的要求。
4. 碰撞检测与响应:复杂度才是真正的坎
4.1 为什么碰撞是必需品
一个只会飘粒子的“物理引擎”,本质上只是个动画播放器。真正的物理交互来自碰撞:球落在地上弹起、布料搭在桌上、机械臂末端触碰物体。在游戏和机器人场景里,碰撞决定了一切“接触感”。它也是从“数组计算”走向“算法设计”的关键分水岭。
4.2 朴素最近邻与广播的威力
最容易想到的碰撞检测,是遍历所有粒子对,判断距离是否小于半径之和。NumPy 里可以一行广播得到所有粒子对距离:
diff = positions[:, np.newaxis, :] - positions[np.newaxis, :, :] dist_sq = np.sum(diff * diff, axis=2) np.fill_diagonal(dist_sq, np.inf) pairs = np.argwhere(dist_sq < collision_threshold_sq)这写法优雅、直观,但只能在小规模下使用。原因无他:复杂度是 O(N²),内存也是 O(N²)。当 N=10000 时,dist_sq是一个 10000×10000 的 float64 矩阵,占用约 800 MB;diff更是要到 2.4 GB。N=100000 时,数据量直接到 TB 级,内存立刻爆炸。
4.3 空间哈希:把复杂度拖回地面
工程上更实用的方案是空间哈希。思路是把三维空间切成固定大小的网格,每个粒子只和自己所在格子及相邻格子里的粒子做碰撞检测。这样,平均复杂度降为 O(N×每个格子内粒子数),在粒子稀疏分布时接近线性。
def build_spatial_hash(positions, cell_size): cell_coords = np.floor(positions / cell_size).astype(np.int32) buckets = {} for idx, cell in enumerate(cell_coords): key = (cell[0], cell[1], cell[2]) buckets.setdefault(key, []).append(idx) return buckets检测碰撞时,遍历每个格子,再检查自身和周围 26 个相邻格子的粒子对。这个 Python 循环仍然存在,但每个格子里粒子数很少,循环总量可控。如果还想继续提速,可以把这个函数用 Numba 的@njit编译,后面会提到。
4.4 碰撞响应:修位置、反弹速度
找到碰撞对之后,关键是处理“穿透”。大多数情况下,模型已经发生了微小穿透,只靠速度反弹是不够的。两步走:
for i, j in contact_pairs: delta = positions[j] - positions[i] dist = np.linalg.norm(delta) if dist < 2 * radius: normal = delta / (dist + 1e-12) overlap = 2 * radius - dist positions[i] -= normal * overlap / 2 positions[j] += normal * overlap / 2 rel_vel = velocities[j] - velocities[i] vn = np.dot(rel_vel, normal) if vn < 0: impulse = -vn * (1 + restitution) velocities[i] -= normal * impulse / 2 velocities[j] += normal * impulse / 2先修正位置,再用相对速度沿法线方向做弹性反弹。restitution是恢复系数,0 表示完全非弹性,1 表示完全弹性。工程里通常取值 0.2~0.8,既能表现碰撞,又不至于让物体弹个没完。
这个循环是 Python 层逐对处理的,对几百对碰撞来说没问题。当碰撞对上千时,就要考虑向量化或 Numba。物理引擎的优化通常从这里开始。
5. 性能优化实操:从“能跑”到“跑得快”
5.1 消灭 Python 层循环
性能优化第一条:检查所有模拟热路径里的 Pythonfor,能换成数组表达式的坚决换掉。最典型的例子是边界碰撞处理。
慢写法:
for i in range(N): if positions[i, 1] < 0: positions[i, 1] = 0 velocities[i, 1] *= -0.5向量化写法:
below_ground = positions[:, 1] < 0 positions[below_ground, 1] = 0 velocities[below_ground, 1] *= -0.5前者是逐粒子判断,后者是一次布尔掩码加两次批量赋值。效果完全一样,但后者在处理百万粒子时优势巨大。
条件分支很多时候也可以用np.where或乘法遮挡表达:
# 阻尼力只在速度超过阈值时施加 damping = np.where(np.abs(velocities) < threshold, 0.0, k_drag) forces -= damping * velocities5.2 预分配内存:一次申请,反复使用
Python 的 GC 会对小对象的创建销毁造成额外开销。哪怕 NumPy 数组底层不参与 GC,但每帧np.zeros_like新开一个数组,依然要付出系统内存分配的成本。正确姿势是:所有中间量在初始化阶段就申请好,后续用np.multiply、np.add的out参数原地写入。
delta_v = np.empty_like(velocities) np.multiply(acceleration, dt, out=delta_v) velocities += delta_v同样地,forces[:] = 0.0与forces = np.zeros_like(forces)的区别就在于此:前者复用原内存,后者重新分配。
5.3 数据布局:AoS 还是 SoA
我在前面已经提到了列式存储。这里展开说下两个方案:
- AoS(Array of Structures):
particles = np.zeros((N, 3)),每个粒子的三个分量挨在一起。写起来直观,提取单个粒子的速度容易。 - SoA(Structure of Arrays):
vx = np.zeros(N); vy = np.zeros(N); vz = np.zeros(N),每个分量单独一个数组。
物理模拟里我倾向于 SoA 或“类 SoA”的二维数组,因为向量化运算时,对连续分量的批量操作更容易命中缓存。如果你只关心位置和速度的数组表达式,(N, 3)已经够用。但当粒子数量极大、并且你频繁只操作某一个分量时,拆开存会有额外收益。
大家可以根据需要做实验,不必盲从一个方案。我自己在 50 万粒子规模下测过,SoA 的带宽利用率大约比 AoS 高 20%-30%,但代码可读性差一些。原型阶段用(N, 3),性能优化阶段再根据热点迁移也不迟。
5.4 当 NumPy 不够用:Numba 与 JAX 的接力
如果算法里确实存在需要大量分支的循环(比如碰撞对处理、约束迭代),NumPy 很难优雅表达,代码反而更乱。这时候我用 Numba。它的@njit装饰器可以直接把 Python 函数编译成机器码,对循环的加速可以达到几十倍:
from numba import njit @njit def satisfy_constraints_nb(positions, constraint_pairs, rest_lengths, iterations): for _ in range(iterations): for idx in range(constraint_pairs.shape[0]): a, b = constraint_pairs[idx] delta = positions[b] - positions[a] dist = np.sqrt(delta[0] ** 2 + delta[1] ** 2 + delta[2] ** 2) if dist < 1e-12: continue correction = (dist - rest_lengths[idx]) / dist positions[a] += delta * correction * 0.5 positions[b] -= delta * correction * 0.5写法看起来像 Python,实际跑起来接近 C。另一个选择是 JAX,它的vmap、jit和 GPU 支持在科学计算里很有潜力,但引入的编译依赖和心智成本更高。我的建议是:先用 NumPy 把模型搭通,再针对热点函数上 Numba,不要一开始就上重型工具。
6. 实战:一个可运行的粒子布料模拟引擎
6.1 布料建模
布料可以抽象成一个网格粒子系统:每个网格点是粒子,相邻粒子之间用弹簧连接。为了简单又不失去布料的基本特性,只需要两类弹簧:水平方向连接同行相邻点,垂直方向连接同列相邻点。再加固定点(布的悬挂点)和重力,就是一个最早期的布料模拟雏形。
6.2 约束迭代与弹簧力的区别
很多人会用上一章提到的弹簧力来做,但布料模拟里更推荐直接用“距离约束 + 迭代求解”。区别在于:弹簧力是“柔”的,刚度不够时布会像橡皮泥,刚度过高时数值爆炸;距离约束是“硬”的,每次直接把两个粒子的距离拉回自然长度,并且通过多轮迭代让所有约束相互协调。
实现方式是:对每条弹簧,算出当前两端距离和自然长度的偏差,按两端质量的倒数比例把位置修正掉。固定点质量设为 0,不参与移动。整个算法的核心就是“位置投影”。
6.3 完整代码与调参记录
下面是一个 20×20 粒子布料的完整实现,跑起来可以看到垂坠效果:
import numpy as np ROWS, COLS = 20, 20 DT = 0.005 SUBSTEPS = 4 ITERATIONS = 5 grid_x, grid_z = np.meshgrid(np.linspace(0, 1, COLS), np.linspace(0, 1, ROWS)) positions = np.stack([grid_x.ravel(), np.zeros(ROWS * COLS), grid_z.ravel()], axis=1).astype(np.float64) velocities = np.zeros_like(positions) mass = np.ones(ROWS * COLS) # 固定布料左上角和右上角两个悬挂点 mass[0] = 0.0 mass[COLS - 1] = 0.0 inv_mass = np.zeros_like(mass) safe = mass > 0 inv_mass[safe] = 1.0 / mass[safe] # 生成弹簧约束 constraint_pairs = [] for r in range(ROWS): for c in range(COLS - 1): a = r * COLS + c b = r * COLS + c + 1 constraint_pairs.append((a, b)) for r in range(ROWS - 1): for c in range(COLS): a = r * COLS + c b = (r + 1) * COLS + c constraint_pairs.append((a, b)) constraint_pairs = np.array(constraint_pairs, dtype=np.int32) rest_lengths = np.linalg.norm( positions[constraint_pairs[:, 0]] - positions[constraint_pairs[:, 1]], axis=1 ) def apply_forces(forces): forces[:] = 0.0 forces[:, 1] += mass * (-9.8) forces -= 0.05 * velocities def satisfy_constraints(): for _ in range(ITERATIONS): delta = positions[constraint_pairs[:, 0]] - positions[constraint_pairs[:, 1]] dist = np.linalg.norm(delta, axis=1) correction = (dist - rest_lengths) / (dist + 1e-12) wa = inv_mass[constraint_pairs[:, 0]] wb = inv_mass[constraint_pairs[:, 1]] total = wa + wb factor_a = correction * wa / (total + 1e-12) factor_b = correction * wb / (total + 1e-12) positions[constraint_pairs[:, 0]] -= (delta.T * factor_a).T positions[constraint_pairs[:, 1]] += (delta.T * factor_b).T for step in range(300): sub_dt = DT / SUBSTEPS for _ in range(SUBSTEPS): forces = np.zeros_like(positions) apply_forces(forces) acceleration = forces * inv_mass[:, np.newaxis] velocities += acceleration * sub_dt positions += velocities * sub_dt satisfy_constraints() # 简单地面碰撞 below = positions[:, 1] < 0.0 positions[below, 1] = 0.0 velocities[below, 1] *= -0.3代码里最值得注意的是(delta.T * factor_a).T:delta是 (K, 3),factor_a是 (K,),要按粒子的行分别缩放,需要先转置再乘,再转置回来。当然用delta * factor_a[:, np.newaxis]更直观,我这里展示的是另一种写法,两者等价。
调参经验:ITERATIONS越大,布越“刚”,5 次能满足多数视觉需求;SUBSTEPS越大,系统越稳定,但每帧耗时成倍增加;DT=0.005时布料下坠自然,如果调大突然飞起来,优先看是不是固定点没设对,其次降低DT。
7. 从原型到产品:游戏、机器人和 VR 里的实际用法
7.1 游戏开发:Python 原型与 C++ 落地的分工
很多人会问:游戏引擎内部都有现成的物理后端,为什么还要自己写?答案是“原型验证”。当你设计一个新的关卡机制,比如“玩家拉动绳索导致吊桥翻转”,只想快速验证手感,没必要在 Unity 或 Unreal 里折腾可视化脚本、物理材质、骨骼约束。用 Python + NumPy 跑一个简化模型,几小时就能把参数范围和玩法机制摸清楚,再回到 C++ 里按同样的逻辑实现。
我自己做过一次绳索物理验证,Python 版本跑了 5000 个约束点,在交互式调节参数时依然能实时更新。这个速度对原型足够。
7.2 机器人控制:仿真先行,真机微调
机器人领域更看重的是“控制策略验证”。直接在真机上试算法,风险大、成本高。常见的做法是搭一个简化的刚体/软体仿真环境,把状态向量给控制器,控制器输出力矩,再反馈回仿真。Python + NumPy 可以快速搭出这类闭环。PyBullet 这类成熟仿真器底层也是 C++,但你能用 NumPy 自己搭一个“极简仿真器”,专门针对某个机械臂的特定关节动力学,这在研究侧非常常见。
7.3 VR 交互:16 毫秒的苛刻预算
VR 的刷新率普遍是 90Hz 或 120Hz,意味着留给物理计算的时间往往不到几毫秒。纯 Python 引擎做重型刚体模拟不现实,但做简单的物体交互(抓取、投掷、震动反馈)是可行的。关键在于:物理渲染的物体数量要克制,碰撞检测用空间哈希而不是广播,dt要固定并用子步控制稳定性。我见过一个 Demo,用 NumPy 驱动 200 个刚体的简单碰撞,再配合 GPU 渲染,在 VR 原型机里能稳定跑满 90 帧。
7.4 我踩过的那些坑
最后集中整理几个我在实际调试中踩得最深、也最容易被新手忽视的坑:
- 固定点质量设为 0,但惯性计算时忘了处理。
forces / mass直接除零,得到 NaN,然后整个物理世界瞬间变成“一锅粥”。正确做法是像本文代码里一样,用inv_mass并先屏蔽质量为零的点。 - dt 不一致导致结果完全不可复现。游戏渲染帧率是波动的,物理模拟必须用固定
dt,否则同样的场景跑两次结果不一样。控制逻辑也容易在帧率波动时失稳。 - 朴素碰撞检测的内存爆炸。小规模用广播很爽,规模上去了要用空间哈希,不然代码还没跑到响应阶段就 OOM。
- 约束迭代时除以距离容易除零。两个粒子完全重合时,
dist=0,修正量变成 NaN。所有涉及范数的除法,都要加一个小量1e-12做保护。 - NumPy 版本与随机数种子。如果你在模拟里用了
np.random,不同 NumPy 版本的默认随机算法可能不同,导致随机初始条件不一致。需要精确复现时,明确指定np.random.default_rng(seed)。
我个人的习惯是:任何涉及物理模拟的实验代码,第一步先写一个“烟雾测试”——放两个粒子、一根弹簧、一块地面,让系统跑几百步,看能量是否单调或不合理地增长。这种小测试能过滤掉 90% 的积分和约束实现的低级错误,比直接在完整场景里排查省事得多。物理引擎这东西,表面上是数学,实际写起来全是工程细节。把数组组织好,把算法复杂度降下来,再根据热点逐步优化,这条路走通之后,你会发现在很多需要快速验证想法的场合,Python + NumPy 远比想象中能打。