fullseye

物理シミュレーションを、そのまま3D Gaussian Splattingにする ―― 「姿勢推定いらず」の3DGSを純PyTorchでRTX 5090に実装した話

TL;DR

この記事は「うまくいった自慢」ではなく、素朴な発想 → 実装 → 失敗 → 原因究明 → 修正の一連を、追試できる形で残すものです。


まず、これは何の話?(3行で)


用語集(先に言葉を揃える)

用語 かみくだき
3DGS(3D Gaussian Splatting) 色付きの「ぼんやりした粒」を大量に空間配置し、重ねて画像化する3D表現。学習で粒の位置・形・色・透明度を最適化する。
スプラッティング(splatting) 3Dの粒を画面に「ぺちゃっと」投影して塗り重ねる描画方式。粒は画面上では2Dの楕円になる。
姿勢(extrinsics、外部パラメータ) カメラが世界のどこに、どちらを向いて置かれているか(回転+位置)。4×4行列で表す。
内部パラメータ(intrinsics、K) 焦点距離や画像中心など、レンズの中の性質。ここでは画角fovyと解像度から計算する。
COLMAP 複数枚の写真だけからカメラ姿勢と粗い3D点群を復元する定番ツール。3DGSの前工程だが遅く、失敗もする。
PSNR(ピーク信号対雑音比) 画像がどれだけ正解に近いかの指標(dB、大きいほど良い)。今回は「学習に使っていない角度」で測る。
hold-out(ホールドアウト、新規視点) 学習に使わず評価だけに取っておくカメラ。ここが良くないと「丸暗記」しただけになる。
densify(デンシファイ、密度化) 足りない所に粒を増やす操作。増やしすぎると過学習の原因にもなる(後述)。

3DGSを3段でかみくだく

第1段(イメージ):霧の粒を空間にたくさん浮かべる。手前の粒から順に色を重ねると、粒の集まりが物体の形に見える。粒一つひとつが「位置・大きさ・向き・色・透明度」を持つ。

第2段(何を学習するのか):正解写真(複数角度)と、粒から描いた画像のを小さくするように、粒のパラメータを少しずつ動かす。これはニューラルネットの学習と同じ「誤差を減らす」作業。ただし学習対象は重みではなく粒そのもの

第3段(なぜ姿勢が要るのか):粒を「画面のどこに」塗るかは、カメラの位置・向き(姿勢)が分からないと決まらない。実写ではこの姿勢が未知なのでCOLMAPで推定する。シミュレータなら姿勢は真値で既知 ―― ここが今回の出発点。


本題:シミュレーションを3DGSデータにする

やることは2つだけ。

  1. シーンの周りにリング状にカメラを並べる(オービット)
  2. 各カメラで 画像・内部K・姿勢(4×4のcamera-to-world) を書き出す。

MuJoCoのカメラは、実は3DGS/NeRF系が使うOpenGL規約(-Zが前方・+Yが上)と同じ座標系。だからcam_xmat(回転)とcam_xpos(位置)を並べるだけで、そのままtransforms.jsontransform_matrixになる。COLMAPは一切登場しない。

# 概念コード(実装はsim_source側)
def camera_to_world(cam):
    R = data.cam_xmat[cam].reshape(3, 3)   # カメラ軸の世界表現 = c2w回転
    t = data.cam_xpos[cam]                  # カメラ位置
    c2w = eye(4); c2w[:3,:3] = R; c2w[:3,3] = t
    return c2w                              # そのままnerfstudio/3DGS形式

# シーンをオービット撮影して 3DGS データセット化
capture_orbit(scene_xml, out_dir, n_views=36, radius=1.3, lookat=(0,0,0.2))
# → out_dir/transforms.json + images/ が出る

「本当に姿勢は正しいのか?」を検証する

姿勢は自己申告では信じない。再投影(reprojection)で機械的に確かめた:

ここが合っていないと以降すべてが崩れるので、最初にここを固めた。


なぜ公式gsplatではなく、純PyTorchで書いたのか(正直な事情)

本当は公式の高速実装gsplatを使いたかった。環境を実測した結果:

項目 結果
GPU RTX 5090(Blackwell, compute capability 12.0)を認識
PyTorch torch 2.11 + cu128 でGPU演算OK
gsplat Pythonパッケージ インストールOK
gsplat のCUDAカーネル “No CUDA toolkit found. gsplat will be disabled.”

gsplatはWindowsでは初回実行時にCUDAカーネルをその場でコンパイルする。そのために CUDAコンパイラnvccMSVCコンパイラcl.exe の両方が要る。nvccは入れられたが、cl.exe(C++コンパイラ)の導入は対話的な管理者昇格(UAC)が必要で、無人では通せなかった。加えてWindows向けのビルド済みwheelも存在しない

そこで方針転換:「まずGPUで3DGSが動く事実」を作るのが先。コンパイル不要の純PyTorch製レンダラなら、いま手元のGPUで即座に検証できる。速度で劣るぶんは小さめのシーンで補い、gsplatは将来の高速バックエンドとして差し替え可能にしておく。

教訓:「速い正解」より「動く事実」を先に置く。土台が動けば、最適化はいつでも足せる。


純PyTorch製3DGSレンダラの中身(数式で説明できる範囲で)

粒(3Dガウシアン)を画面に塗る手順は、ちゃんと式で書ける。

  1. 世界→カメラ変換:OpenGL規約のカメラを、標準的なCV規約(z前方・y下)へ F = diag(1, -1, -1) で揃える。
  2. 投影:カメラ座標 (x, y, z) を画素 u = f_x·x/z + c_x, v = f_y·y/z + c_y に。
  3. 3D共分散 → 2D:粒の形は共分散行列 Σ = R·S·Sᵀ·Rᵀ(Rは向き、Sは大きさ)。これを投影のヤコビアン J で2Dに落とす:Σ' = J·Σ_cam·Jᵀ(2×2)。画面上の楕円が決まる。
  4. アルファ合成:各粒の画素寄与は α = 不透明度 · exp(-½ dᵀ Σ'⁻¹ d)dは画素と粒中心の差)。粒を手前から奥へ深度ソートし、重み_i = α_i · Π_{j<i}(1-α_j) で前から重ねる。

この「重み」をcumprod(累積積)でベクトル化すれば、粒ごとのループなしにGPUで一気に計算できる。タイル分割は省いた簡略版(=小さめシーン向けのPoC実装)。

単体テスト:世界原点に赤い粒を1個置き、正面カメラで描くと、画像中心±0pxに赤が出て、隅は背景色。投影と合成が正しいことを最小構成で固定した。


結果

① 合成シーン(赤球・緑箱・青カプセル)

② 実ロボ go2(MuJoCo Menagerie)

図:go2のターンテーブルGIF(学習済みガウシアンを全周描画) / 左=正解・右=新規視点レンダの比較(※Qiita公開時に画像アップロード)


いちばん大事な話:最初は失敗した(過学習と、その直し方)

最初のgo2は18視点で回した。ログを見て青ざめた:

[iter 200] train=23.6  test=23.6
[iter 360] train=24.6  test=20.7   ← test だけ下がり始める
[iter 600] train=25.6  test=19.5   ← 学習は上がるのに新規視点は悪化

学習視点は良くなるのに、hold-out(新規視点)はどんどん悪くなる ―― 典型的な過学習。原因は、densifyで増やした粒が「学習した角度でだけ辻褄が合う floater(浮遊ゴミ)」になり、見ていない角度で破綻していた。

honestに直した(3点):

  1. 視点を18→36に増やす:新規視点の汎化にいちばん効くレバー。角度の穴を物理的に埋める。
  2. 巨大な粒(floater)をprune:等方スケールが閾値超えの粒を除去。
  3. early-stopping:hold-outが最良の時点のモデルを採用し、末期の劣化を捨てる。

結果、testはtrainに追随して26.5dBまで単調に改善。過学習は消えた。

図:hold-out PSNRの推移(赤破線=18視点で過学習して19.5dBまで低下 / 緑=36視点+early-stopで26.5dBまで改善)。失敗の線を消さずに残すのが本図の主旨。

この「testが下がったら、まずデータ(視点)と正則化(prune/early-stop)を疑う」は、3DGSに限らずあらゆる当てはめで効く教訓。


環境まわりの正直なメモ(追試する人へ)


追記:ネイティブgsplatも通した ―― Windowsの4つの罠

純PyTorch版で成果を出したあと、あきらめていた公式gsplatのネイティブビルドにも成功した(RTX 5090で1秒あたり1532回のラスタライズ)。ここに至るまで、Windows特有の罠を1枚ずつ剥がした。debuggingの記録としてそのまま残す。

# 症状 真因 対処
1 No CUDA toolkit found C++コンパイラcl.exeが無い VS Build Toolsの「C++によるデスクトップ開発」を導入(管理者権限が要る唯一の手作業)
2 torchヘッダで型エラー システムのnvcc 13.3が、torch(CUDA 12.8ビルド)のヘッダと非互換 nvcc 12.8をconda(micromamba)でユーザー空間に導入(管理者権限も不要、バージョンぴったり一致)
3 cudaStream_t s, bool smallbool char に化ける Windowsのrpcndr.h#define small char。torch 2.11がパラメータ名にsmallを使っていた(Windowsバグ) 該当ヘッダに#undef smallを1行
4 cl : error D8021 : '/Wno-attributes' gsplatの既定にGCC用フラグ-Wno-attributesが入っていて、MSVCが拒否 Windowsではそのフラグを外す

教訓No CUDA toolkit foundのような大雑把なエラーの奥には、こういう具体的で小さな原因が積み重なっている。エラーを1枚ずつ剥がす――推測で全部を一度に直そうとせず、いま出ている1つの原因だけを確実に潰して再ビルド、を繰り返すのが結局いちばん速い。特に#3は、コンパイラが吐いたbool charという“ありえない字面”をそのまま読んだのが決め手だった(推測せず、出力の生の姿を見る)。

ネイティブ版はタイル分割CUDAなので、純PyTorchの非タイル実装より桁違いに速く、高解像度・多ガウシアンに耐える。go2を256px・2万ガウシアンで学習しても数十秒で回る。

ネイティブビルドの完全な再現手順は付録(リポジトリのdocs/GSPLAT_NATIVE_WINDOWS.md)に置いた。

動く姿まで再生する ―― sim だからできる「リグ付き3DGS」

静止した3DGSの全周プレビューは綺麗だが、ロボットの本当の価値は動きにある。そこで、動く3DGSまで作った。フルの4D Gaussian Splatting(時間条件つきガウシアン)は重いので、別の道を取った:

ロボットは剛体リンクの集合である、という事実を使う。

  1. 正準ポーズで3DGSを学習する。
  2. 各ガウシアンが、MuJoCoのどのリンク(body)由来かを、セグメンテーション描画で確定する(sim なら1画素ずつ正確に分かる)。
  3. 関節を動かし、各フレームのリンク姿勢(sim の真値)で、そのリンクに属するガウシアンを剛体変換して描き直す。

これは骨格アニメの「スキニング」を、学習した3DGSに対してやるようなもの。姿勢が真値なので破綻しない(実写の動的再構成が難しいのと対照的)。go2 の脚を動かすと、再構成したガウシアンの犬がちゃんと脚を運ぶ。正準学習はわずか4秒、あとは各フレームを剛体変換して描くだけなので軽い。

--motion-file に実際の歩行方策の qpos 軌道を渡せば、本物の歩行を3DGSで再生できる。

ここが「可視化=差別化」の勘所だと思う。実写3DGSは静的シーンが主戦場で、動く被写体は難物。sim は姿勢・深度・材質・接触・関節角のすべてを真値で持っているから、その真値を素直に使うだけで、実写では面倒な動的再構成が自然に手に入る。「ズルできる」ことこそが差別化になる。

次回に続く

物理シミュレータは「真値の宝庫」。姿勢だけでなく、深度・材質・接触・関節角まで全部分かっている。その真値を素直に使うと、実写で難しい前処理が丸ごと消える ―― 今回はその一例でした。


本記事の実装・数式・失敗の記録はすべて実測に基づきます。数値は手元のRTX 5090での再現値で、環境により変わります。

良かったら、いいね♡ をクリックして下さい。次の一本を書く励みになります。