PyBullet(Python から使える物理シミュレータ)を初めて触ると、多くの人が同じところでつまずきます。箱を空中に置いたのに、落ちない。 原因はたいてい次の3つのどれかです。
| 落ちない原因 | 何が起きているか |
|---|---|
stepSimulation() を呼んでいない |
PyBullet の時間は自動では流れない。呼んだぶんだけ進む |
setGravity() を呼んでいない |
初期状態は無重力 |
| 床を置いていない | こちらは逆に、どこまでも落ちていく |
この記事では、高さ 1 m から小箱を1つ落とすだけのプログラムで、この3点を確かめます。あわせて、
- 1秒後の高さが 0 ではなく 0.0250 m になる理由
- 0.3秒後の高さが自由落下の理論値より 1.8 mm 低い理由(実は2つの原因の足し算)
- 画面を出さずに、落ちる様子を gif に残す方法
まで扱います。必要なのは pip install pybullet numpy matplotlib だけで、この記事のコードだけで動きます。
動作環境
Python 3.10.9
numpy 1.26.4
pybullet 3.2.7
matplotlib 3.7.0
1. PyBullet の時間は、自分で進める
物理エンジン(physics engine:物体にかかる力から、ほんの少し先の位置と速度を計算するプログラム)がやっていることは、パラパラ漫画を1枚ずつ描く作業に近いものです。いまの状態から力を求め、$a = F/m$ で加速度を出し、ほんの少しの時間ぶんだけ速度と位置を更新します。この1枚を「1ステップ」と呼びます。
PyBullet では、この1枚を描く作業は p.stepSimulation() を呼んだときにだけ起きます。
既定では 1ステップ = 1/240 秒(約 0.00417 秒) です。1秒ぶん進めたければ240回呼びます。
for _ in range(240):
p.stepSimulation() # 合計で1秒ぶん進む
時間を自分で回せるので、シミュレーションは止めることも、1ステップごとに位置を覗いて記録することもできます。実機にはできないことです。
2. 世界を作る4つの手順
シミュレーション世界の作り方は、ほぼ定型です。
| 手順 | コード | 忘れると |
|---|---|---|
| ① 接続 | p.connect(p.DIRECT) |
何もできない |
| ② 探索パス | p.setAdditionalSearchPath(pybullet_data.getDataPath()) |
plane.urdf が見つからない |
| ③ 重力 | p.setGravity(0, 0, -9.81) |
無重力のまま、箱は浮く |
| ④ 床 | p.loadURDF("plane.urdf") |
箱がどこまでも落ちる |
補足が2つあります。
-
接続のモードは2つあります。
p.GUIは画面つき、p.DIRECTは画面なしです。違いは見えるかどうかだけで、計算される物理は同じです。この記事では、サーバでも動き、描画のぶん遅くならないDIRECTを使います。 -
PyBullet は z軸が上です。だから重力は z に
-9.81を入れます。
loadURDF() は、URDF(Unified Robot Description Format:ロボットや物体の形を書く XML ファイル)を読み込む関数です。床の plane.urdf も、あとで落とす箱の cube_small.urdf も PyBullet に同梱されています。
3. 全コード:箱を落として記録し、gif に残す
1ファイルにまとめました。drop_box.py として保存し、python drop_box.py で動きます。
# PyBullet で小箱を落とし,高さを記録して理論値と比べ,画面なしで gif に残す
# ライブラリの読み込み
import numpy as np # 数値計算
import pybullet as p # 物理シミュレータ本体
import pybullet_data # PyBullet に同梱されたURDF(床・箱)の置き場
import matplotlib.pyplot as plt # 描画
from matplotlib.animation import FuncAnimation # アニメーション
# 重力加速度(m/s^2)。PyBullet は z軸が上向きなので,下向き=マイナス
GRAVITY_Z = -9.81
# シミュレーションの時間の刻み幅(秒)。PyBullet の既定値
TIME_STEP = 1.0 / 240.0 # 1ステップで進む時間(約0.00417秒)
STEPS_PER_SECOND = 240 # 1秒ぶん進めるのに必要なステップ数
# 実験の条件
DROP_HEIGHT = 1.0 # 箱を落とす高さ(m)
FALL_SECONDS = 1.0 # 落下を観察する時間(秒)
CAPTURE_EVERY = 6 # 何ステップごとに1コマ撮るか
# カメラ(画面を出さずに画像だけを作る)の設定
CAMERA_WIDTH = 480 # 画像の幅(ピクセル)
CAMERA_HEIGHT = 360 # 画像の高さ(ピクセル)
GIF_FPS = 20 # gif の1秒あたりのコマ数
def create_world() -> int:
"""
画面なし(DIRECT)でシミュレーション世界を作り,床を置く
戻り値
シミュレーション世界の識別番号
"""
# ① 接続する。p.GUI なら画面つき,p.DIRECT なら画面なし(計算は同じ)
client_id = p.connect(p.DIRECT)
# ② 同梱URDF(plane.urdf など)を名前だけで読み込めるように探索パスを通す
p.setAdditionalSearchPath(pybullet_data.getDataPath(), physicsClientId=client_id)
# ③ 重力を設定する。これを呼ばないと物は落ちない(初期状態は無重力)
p.setGravity(0.0, 0.0, GRAVITY_Z, physicsClientId=client_id)
p.setTimeStep(TIME_STEP, physicsClientId=client_id)
# ④ 床を置く。床が無いと箱はどこまでも落ちていく
p.loadURDF("plane.urdf", physicsClientId=client_id)
return client_id
def capture_image(client_id: int) -> np.ndarray:
"""
いまのシミュレーションの様子を1枚の画像として取り出す(画面なしで描画)
パラメータ
client_id: シミュレーション世界の識別番号
戻り値
RGB画像(高さ × 幅 × 3 の配列)
"""
# 視点行列:どこから,どこを見るか(注視点・距離・角度で指定)
view_matrix = p.computeViewMatrixFromYawPitchRoll(
cameraTargetPosition=(0.0, 0.0, 0.5), distance=2.0,
yaw=45.0, pitch=-25.0, roll=0.0,
upAxisIndex=2, # z軸を上とみなす
physicsClientId=client_id)
# 投影行列:視野角・縦横比・写る奥行きの範囲(どのレンズを使うか)
projection_matrix = p.computeProjectionMatrixFOV(
fov=60.0, aspect=CAMERA_WIDTH / CAMERA_HEIGHT,
nearVal=0.1, farVal=10.0,
physicsClientId=client_id)
# 画像を取り出す。CPU だけで動く描画方式を明示する
_, _, rgb, _, _ = p.getCameraImage(
width=CAMERA_WIDTH, height=CAMERA_HEIGHT,
viewMatrix=view_matrix, projectionMatrix=projection_matrix,
renderer=p.ER_TINY_RENDERER,
physicsClientId=client_id)
# RGBA(4チャンネル)で返るので,RGB の3チャンネルだけを取り出す
rgb = np.reshape(np.array(rgb, dtype=np.uint8), (CAMERA_HEIGHT, CAMERA_WIDTH, 4))
return rgb[:, :, :3]
def drop_box(client_id: int) -> tuple:
"""
小箱を高さ DROP_HEIGHT から落とし,1ステップごとの高さと途中の画像を記録する
パラメータ
client_id: シミュレーション世界の識別番号
戻り値
(時刻の配列, 高さの配列, 画像のリスト)
"""
box_id = p.loadURDF("cube_small.urdf", [0.0, 0.0, DROP_HEIGHT],
physicsClientId=client_id)
times, heights, frames = [], [], []
total_steps = int(FALL_SECONDS * STEPS_PER_SECOND)
for step in range(total_steps + 1):
# 記録してから進める(逆にすると最初の高さ 1.0 m を取り逃がす)
position, _ = p.getBasePositionAndOrientation(box_id, physicsClientId=client_id)
times.append(step * TIME_STEP)
heights.append(position[2]) # z が高さ
if step % CAPTURE_EVERY == 0:
frames.append(capture_image(client_id))
# 時間を1ステップ(1/240秒)進める。呼ばなければ箱は落ちない
p.stepSimulation(physicsClientId=client_id)
return np.array(times), np.array(heights), frames
def save_gif(frames: list, file_name: str) -> None:
"""
取り出した画像の列を gif アニメーションとして保存する
パラメータ
frames: 画像(RGB配列)のリスト
file_name: 保存先のファイル名
"""
fig, ax = plt.subplots(figsize=(CAMERA_WIDTH / 100, CAMERA_HEIGHT / 100), dpi=100)
ax.axis("off")
ax.set_title("falling box (DIRECT mode)")
image = ax.imshow(frames[0])
def update(index: int) -> tuple:
image.set_data(frames[index])
return (image,)
anim = FuncAnimation(fig, update, frames=len(frames), interval=1000 / GIF_FPS, blit=True)
anim.save(file_name, writer="pillow", fps=GIF_FPS)
plt.close(fig)
print(f"gifを保存しました: {file_name}({len(frames)}コマ)")
def main() -> None:
"""
メイン処理
"""
client_id = create_world()
times, heights, frames = drop_box(client_id)
print(f"落とす前の高さ = {heights[0]:.4f} m")
print(f"{FALL_SECONDS}秒後の高さ = {heights[-1]:.4f} m")
# 0.3秒後(まだ空中にいる)を自由落下の理論値 z = z0 - (1/2) g t^2 と比べる
index = int(0.3 * STEPS_PER_SECOND)
theory = DROP_HEIGHT - 0.5 * abs(GRAVITY_Z) * times[index] ** 2
print(f"0.3秒後の高さ: シミュレーション = {heights[index]:.4f} m, "
f"理論値 = {theory:.4f} m, 差 = {heights[index] - theory:+.4f} m")
save_gif(frames, "falling_box_anime.gif")
p.disconnect(physicsClientId=client_id)
if __name__ == "__main__":
main()
ポイントは drop_box() のループの順番です。
- いまの位置を記録する
- 6ステップに1回、写真を撮る
-
stepSimulation()で1/240 秒進める
「記録してから進める」順にしているので、最初の高さ 1.0 m も記録に残ります。逆にすると、最初の状態を取り逃がします。
画面なしで画像を取り出す仕組み
capture_image() は、2つの行列を作って getCameraImage() に渡しています。
| 行列 | 決めること | 写真でたとえると |
|---|---|---|
| 視点行列(view matrix) | どこから、どこを見るか | 三脚をどこに立て、どちらを向くか |
| 投影行列(projection matrix) | 視野角・縦横比・写る奥行き | どのレンズを使うか |
renderer=p.ER_TINY_RENDERER は、GPU(画像を描く計算が得意な装置)を使わず CPU だけで描く方式です。DIRECT では指定しなくてもこちらが使われますが、GUI で接続すると既定が GPU 描画に変わるので、どちらでも同じ絵が出るように明示しています。
4. 実行結果
落とす前の高さ = 1.0000 m
1.0秒後の高さ = 0.0250 m
0.3秒後の高さ: シミュレーション = 0.5567 m, 理論値 = 0.5585 m, 差 = -0.0018 m
gifを保存しました: falling_box_anime.gif(41コマ)
1行目の前に pybullet build time: … という表示が出ますが、PyBullet を読み込んだときのもので、日付は環境によって変わります。
画面を一度も出さずに、動く絵が手に入りました。41コマを 20 fps で保存しているので、実際の時間の約2倍の長さでゆっくり再生されます。
5. 1秒後の高さが 0 ではなく 0.0250 m になる理由
床に着いたのに高さが 0 にならないのは、getBasePositionAndOrientation() が返すのが箱の中心の位置だからです。cube_small.urdf は一辺 0.05 m の立方体なので、床に着いたとき中心は床から半分の 0.025 m にあります。
返ってきた数字が物体のどこを指しているのかは、シミュレータを使うたびに確かめる必要があります。
6. 理論値より 1.8 mm 低い理由は2つある
0.3 秒後の高さを自由落下の式で計算すると、
$$
z = z_0 - \frac{1}{2} g t^2 = 1.0 - \frac{1}{2} \times 9.81 \times 0.3^2 = 0.5585 \ \text{m}
$$
です。シミュレーションは 0.5567 m なので、1.8 mm 低い位置にいます。この差は、向きが逆の2つの原因に分けられます。
原因① 時間を刻みに区切っているから(理論値より下へ)
PyBullet は半陰的オイラー法(semi-implicit Euler:先に速度を更新し、その新しい速度で位置を進める計算のしかた)で時間を進めます。1刻みぶん速くなった速度で位置を進めるので、箱は理論値より余分に落ちます。このずれは刻み幅 $\Delta t$ に比例し、
$$
-\frac{1}{2} g , t , \Delta t = -\frac{1}{2} \times 9.81 \times 0.3 \times \frac{1}{240} = -0.0061 \ \text{m}
$$
と見積もれます。
原因② 既定で減衰が入っているから(理論値より上へ)
PyBullet は、読み込んだ物体に最初から小さな減衰(damping:速度に応じて動きを弱める効果。空気抵抗に近い)を入れています。そのぶん落下が少し遅れます。
減衰を切って(p.changeDynamics(box_id, -1, linearDamping=0.0) を箱の読み込み直後に足す)、刻み幅を変えて測ると次のようになります。差は「シミュレーション − 理論値」です。
| 刻み幅 $\Delta t$ | 減衰あり(既定) | 減衰なし | 原因①の見積もり |
|---|---|---|---|
| 1/240 秒 | −0.0018 m | −0.0061 m | −0.0061 m |
| 1/480 秒 | +0.0012 m | −0.0031 m | −0.0031 m |
| 1/960 秒 | +0.0028 m | −0.0015 m | −0.0015 m |
減衰を切ると、実測は見積もりと一致します。減衰ありと減衰なしの差は、どの刻みでも +0.0043 m で一定です。つまり、
$$
-0.0018 = \underbrace{-0.0061}{\text{①刻み(}\Delta t\text{ に比例)}} + \underbrace{0.0043}{\text{②減衰(刻みによらない)}}
$$
という足し算でした。
刻みを細かくすれば正確になる、とは限りません。
表の「減衰あり」の列を見ると、刻みを 1/240 → 1/480 → 1/960 と細かくしたのに、差の大きさは 0.0018 → 0.0012 → 0.0028 と、いったん減ってから増えています。①は縮みますが、②は縮まないからです。合わないときは刻みを細かくする前に、差の中身を分けてみてください。
理論値とシミュレーションを重ねると、ほとんど区別がつきません。約 0.45 秒で床に着き、そこから先は水平になっています。
この図は、記事のコードの times と heights を matplotlib で描いたものです(描画コードは省略)。
7. 手元で試すなら
-
p.stepSimulation(...)の行をコメントアウトすると、高さは最後まで 1.0000 m のままです -
p.setGravity(...)の行をコメントアウトしても、やはり落ちません。「落ちない」原因は2種類あります -
p.loadURDF("plane.urdf", ...)をコメントアウトすると、1秒後の高さが大きな負の値になります -
GRAVITY_Zを-1.62(月面)にすると、1秒後にはまだ空中にいます
まとめ
- PyBullet の時間は
stepSimulation()を呼んだぶんだけ進む(1回 = 1/240 秒) - 重力は
setGravity()で設定しないと無重力、床は自分で置く - 返ってくる位置は物体の中心。床の上の小箱なら 0.025 m
- 理論値との差 −1.8 mm は「刻み −6.1 mm」+「既定の減衰 +4.3 mm」
-
DIRECT+getCameraImage()で、画面を出さずに図と gif が作れる
次回は、床と箱の代わりに自分で書いた2軸アームを置きます。今回 plane.urdf と cube_small.urdf を名前だけで読み込んだ、その URDF ファイルを自分で書きます。
ロボットの自作記事まとめ(2軸・3軸・6軸アームから PyBullet まで):
役に立ったら いいね・ストック で応援いただけると、次回の励みになります。



