1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

iPhoneで Gaussian Splatting モデルの反射をリアルタイムで更新する

1
Posted at

iPhone上で、撮影済みの3Dシーンを好きな照明で照らし直すビューアをMetalで作った。HDR環境マップを差し替え・回転させると、物体の反射がリアルタイムに追従し、背景にもその環境が描かれる。

metal.gif

なぜリライティングが嬉しいのか(実務・商業的な意味)

通常のGaussian Splattingは「撮影時の光が焼き込まれている」。だから撮った物を別の場所に置くと、ハイライトや陰影が周囲と食い違って嘘くさくなる。リライティングはその光を剥がして材質に戻すので、撮った物をどんな照明下にも置ける。これが実務で効く:

  • EC・商品ビジュアル:商品を一度撮れば、ショールーム/屋外/客の部屋(AR)など任意の光で見せられる。家具・車・宝飾・スニーカーなど「質感が売り」の商材で強い。
  • 映像・バーチャルプロダクション:実物のキャプチャを新しいシーンに、そのシーンの照明(HDRI / LEDウォール)と整合させて配置できる。撮り直し不要。
  • AR / 空間コンピューティング:実部屋に置いた物体は、部屋の光で照らされて初めて馴染む。リライティング=リアルなAR配置の前提条件。
  • ゲーム・リアルタイム3D:焼き込みで固まった見た目ではなく、ゲーム内の動的な光(昼夜・移動光源)に反応する実写アセットになる。
  • コスト:撮影(数分)で得たアセットが、手作業のモデリング+マテリアル制作(数日)と同じように"実運用の照明で使える"ようになる。

要するにリライティングは、「撮った3Dを、実際に使えるアセットに変える」技術だ。本記事は、それをデスクトップ研究(CUDA前提)からiPhone上のリアルタイムMetal実装へ落とし込んだ記録になる。


この記事は、前半に前提知識をまとめ、後半で実装と、ハマったバグ4つを説明する。専門用語は出てきた順に定義していくので、Gaussian SplattingやPBRに馴染みがなくても追えるようにした。

リポジトリ: https://github.com/john-rocky/MetalGaussianSplatRelighting


前提知識

1. 3D Gaussian Splatting とは

写真群から3Dシーンを再構成し、リアルタイムに描画する手法。シーンをsplat(スプラット)と呼ぶ大量の半透明な楕円体の集合で表す。各splatは「位置・形と向き(回転)・色・不透明度」を持つ。描画は、各splatをスクリーンに楕円として投影し、**奥行き順に手前から重ね塗り(アルファ合成)**する。色は見る角度で変わる(視点依存)。

ポイント:通常のGaussian Splattingが持っているのは**「見え方(色)」そのもの**で、撮影時の照明が焼き込まれている。だから後から照明を変えられない。

2. ライティングとリライティング

通常のGSは「その場所をその光で撮ったときの色」を直接学習している。だから照明は固定。

リライタブルGS は発想が違う。splatごとに「色」ではなく**材質(マテリアル)**を分解して持つ:

  • アルベド:照明を取り除いた、素材そのものの地の色
  • 法線:その面が向いている方向
  • ラフネス:表面のザラつき
  • 反射率:鏡面反射の強さ

材質さえあれば、任意の環境光でその場で色を計算し直せる。これがリライト。今回使った Ref-Gaussian がこの材質分解を学習してくれる。


ここまでで「splatは材質を持っていて、新しい環境で照らし直したい」が分かればOK。残りの#3〜#5は、たった1つの問いに答えているだけだ:

その材質と環境から、1ピクセルの色をどう計算するか?


3. 反射は2種類に分けて足す

表面に当たった光の返り方は2通り:

  • 拡散反射:光をあらゆる方向に均して返す → 見えるのはアルベドの色そのもの。映り込みなし。
  • 鏡面反射:特定方向(入射の反射方向)だけ返す → 環境が映り込む

なので 色 = 拡散ぶん + 鏡面ぶん。splatの反射率がこの配合(鏡面をどれだけ強くするか)を決める。

そしてラフネスが、鏡面のボケ具合を決める:

  • ラフネス低い → くっきり鏡のように環境が映る
  • ラフネス高い → ボケて、環境の明るい部分がぼんやりした塊に見えるだけ

これは後で重要になる。実際、光沢のある車(ラフネス0.2くらい)を映すと、環境の照明がぼやけた白い塊として動くだけで、梁や窓の形までは映らない。「反射していない」のではなく「ボケた反射」で、これが物理的に正しい。ラフネスをぐっと下げると、同じ車が鏡面になって部屋がはっきり映り込む。

4. 環境(光源)をどう持つか

点電球を置く代わりに、360°の画像を光源にする=各方向からどんな色の光が来るかの一覧。

  • HDR:1を超える明るさを持てる画像(窓や照明は紙より桁違いに明るいので必要)
  • equirect(正距円筒)/ cubemap:その360°画像の保存形式の名前。中身はどちらも「方向 → 光の色」。

環境マップを差し替える=照明を差し替える=リライト、になる。

5. 環境から拡散・鏡面を出す(split-sum)

#3の「拡散ぶん」「鏡面ぶん」を、#4の環境マップから計算したい。素直にやるとピクセルごとに環境を積分することになり毎フレーム重い。そこでUE4の split-sum は、事前に2枚の画像を用意しておく:

  • 拡散用(irradiance):環境を全方向でならした画像 → 法線の方向で1回引けば拡散光。
  • 鏡面用(prefiltered):環境をラフネス別にぼかした画像(ミップに段階的に格納) → 反射方向で1回引けば鏡面光(ボケ段=ラフネス)。

実行時はこの2枚をテクスチャ参照するだけ。重い積分を「事前にボカした画像引き」に置き換える、これがsplit-sumの肝。(補助的に、鏡面の強さを角度で微調整する小さな表=BRDF LUTも事前計算しておく)

今回、この事前計算カーネルをMetalで実装した。

6. deferred shading(splat特有の事情)

splatは半透明で重なるので、隣り合うsplatの法線がバラついてノイジーになりやすい。各splatを個別に陰影計算してから重ねると、そのノイズがそのまま出る。

そこで順番を変える:まず各splatの材質(色・法線・ラフネス等)を画面のバッファ(G-buffer)に貯めてブレンド=ならし、その後ピクセルごとに1回だけ陰影計算する。法線が均された後に計算するのでノイズが減る。Metalでは**tileメモリ(imageblock)**を使い、このバッファをGPU内に置いたまま高速に処理する。

7. 法線と座標系

  • 法線:面が向いている方向の単位ベクトル。反射方向はこれで決まるので、狂うと反射が全部狂う。splatの向き(回転クォータニオン)から復元する。
  • 上方向の規約Y-up(多くのビューア)とZ-up(Blender系)がある。データとビューアで食い違うと物体が倒れる(→バグ3)。

実装:シェーディングの式

前提が揃ったので、Ref-Gaussianの deferred surfel レンダラ(render_surfel)がピクセルごとに計算している式を読む:

F0       = 0.04·(1 − 反射率) + アルベド · 反射率
specular = prefiltered(reflect(V, N), ラフネス) · (F0 · fg.x + fg.y)
final    = (1 − 反射率) · base_color + specular
  • reflect(V, N):視線 V を法線 N で反射した方向。ここで prefiltered(#5)を引く=鏡面に映る環境。
  • fg:BRDF LUT(#5)の引き。
  • final拡散は base_color をそのまま使い、鏡面だけ環境から計算して足す(=#3の「拡散ぶん+鏡面ぶん」)。ここが後のバグ2で効く。

パイプライン全体:

Ref-Gaussian .ply ──▶ 読み込み ──▶ splatごとの材質(法線・ラフネス・反射率・アルベド)
                                        │
HDR環境 ──▶ IBL事前計算 ──▶ prefiltered(鏡面用)+ irradiance(拡散用)+ BRDF LUT
                                        │
                          ┌─────────────┴───────────────┐
                          ▼                              ▼
              G-bufferパス                    postprocessパス
          (色/法線/材質をtileメモリに         (ピクセルごとに split-sum IBL
            ブレンドして貯める=#6)             + skybox合成)

——きれいに聞こえる。動かすまでは。ここからが本題のバグ4つ。


バグ1:法線マップが虹色の砂嵐

シェーディング結果はまだら&チラつくノイズ。「Normal」デバッグ表示(法線を色で可視化したもの)は、滑らかなグラデーションではなく虹色ノイズだった。

最初に思った「2D-surfelの法線は元々ノイジーなんだろう」は間違いで、危うく実機ビルドを無駄に重ねるところだった。

救ったのは規律:まず法線をオフラインで描いて確かめる。各splatの幾何法線(#7:回転クォータニオンから復元し、カメラを向くようflipしたもの)を小さなnumpyスクリプトで合成すると、結果は滑らか(勾配の中央値0.006)。つまりデータは正しく、レンダラ側がバグっている。

犯人:MetalSplatterは読み込み時、キャッシュ効率のためsplatを並べ替える(Mortonコード順、sortByLocality)。このとき splatバッファSH係数バッファは並べ替えるのに、僕が後から足した**マテリアルバッファ(法線が入っている)**は別バッファで、並べ替えていなかった

結果、ソート後は splats[i]materials[別のj] が対応し、全splatが他人の法線を持つ状態になった。色(#1の視点依存色)は並べ替え済みのバッファにあるので整合していて、法線と鏡面だけが壊れて見えた——これが切り分けを難しくした。

修正は1行:

materialsBuffer.values.reorderInPlace(fromSourceIndices: sorted)

教訓:他人のパイプラインに「要素ごとの並列バッファ」を後付けするときは、元データが並べ替えられる箇所を全部直すこと。

バグ2:拡散を環境光でライトし直していた(やってはいけない)

法線を直しても、ボディは「水っぽい」まだらの黄色だった。

僕は拡散項を教科書通り アルベド × irradiance(#5の拡散用画像を掛ける)と書いていた。だがRef-Gaussianの式は、拡散に base_color をそのまま使う——リライトするのは鏡面だけ。クリーム色のボディに自前の irradiance を掛けて、模様を塗っていた。さらに鏡面のF0を、学習済みアルベドではなく視点依存色(撮影時の反射が焼き込まれている)で着色していた。

リファレンスの式に厳密に合わせたら直った。教訓:「正しい」PBRを即興で書く前に、リファレンス実装のソースを読んで1行ずつ合わせること。

バグ3:車が横倒し(Z-up と Y-up)

反射する車を学習させて読み込むと、90°横倒しでレンダリングされた。

推測せず点群のバウンディングボックスを測ると、最短軸がZ(=高さ)、最長がY(=長さ)、暗いタイヤのsplatが−Z側。データはZ-up(#7、Blender系)。ビューアはY-up前提だった。丸いヘルメットでは目立たなかった90°ズレが、車で一気に露呈した。

カメラにZ-up→Y-up補正(X軸まわり−90°)を入れて車は直った。すると2つ目の頭が生えた:今度は背景の環境が90°ズレた。equirect(#4)はY-up前提なのに、skyboxの光線と反射はシーンのZ-upフレームで計算しているからだ。

修正は、環境を引くサンプル方向を環境のY-upフレームに変換し、回転スライダーはシーンの上軸まわりに回すこと:

environmentRotation = Rx(−90°) · Rz(回転スライダー角)

skyboxと反射は同じ行列でサンプルするので必ず一致する。ビルド前にマッピングを数値で確認し、さらにskybox自体を inverse view-projection から光線を再構成するオフライン描画で検証した。これで、以前HDRに焼き込んでいた古い上下反転(二重反転していた)もその場で捕まえられた。

バグ4:「成功」したのに古いコードが動くビルド

向きの修正後、「まだ車が横倒し」と報告が来た。オフライン描画で数学は正しいと証明済みなので、動いているバイナリが古いはず——だがなぜ?

僕は xcodebuild -destination platform=macOS で型チェックしていた。これは #if os(macOS) のブランチとMacのアーキしかコンパイルしない。iOSシミュレータ向けにビルドしたら、既存コードにコンパイルエラーが露呈した:Float16(x).bitPattern[UInt16] 配列へ代入していた箇所だ。

  • arm64(実機):ネイティブの Float16 があり bitPatternUInt16 → 通る
  • シミュレータのx86_64スライス:Float16 がフォールバックし bitPatternUInt32 → 型エラー

iOSビルドが失敗していたので、端末は以前のバイナリを動かし続けていた。[Float16] で直接保持して解決。

教訓:出荷するプラットフォーム向けに型チェックすること。macOS限定の xcodebuild はiOSアプリについて平気で嘘をつく。そして「実機で変わらない」はほぼ常に「バイナリが新しくない」のサイン——ロジックを再デバッグする前にそこを疑う。


本当に効いた方法論

4つのバグに共通するのは、出力を判断する前にground truth(正解)を確立すること。序盤、僕のオフラインnumpyプレビューは過剰に滑らかで、僕をミスリードした。効いたのは:

  • リファレンスレンダラ(Ref-Gaussianの eval.py や学習時の可視化画像)を同じアセットで走らせて比較する。リファレンスが綺麗で自分のが汚ければ、それは自分のレンダラのバグ。
  • 変換(法線・skyboxの光線)をオフラインで厳密に再現し、実機ビルドの前に目で見る
  • クリーンな合成アセット(手で作ったクロム球)で確認し、「レンダラのバグ」と「アセットのバグ」を切り分ける。

これを省いて「綺麗に見える」で済ませたときは、毎回間違っていた。

補足:見え方は「正しい」のか

完成後、「車の反射が地味で、環境を映しているように見えない」と感じた。これはバグではなく、素材の性質だ。整理しておく:

  • 学習済みの車はラフネス約0.2=半光沢。半光沢は環境をぼんやり反射する(鏡ではない)。だから明るい照明がぼやけた塊として映るのが正しい。どのレンダラーでも、同じ環境で照らせば同じように地味に映る。公式レンダラの出力(ground truth)を見ても、再構成自体は綺麗(シャープな車・滑らかな法線)で、素材は問題ない。
  • アプリの「Use trained material」をオフにしてラフネスを下げると、全splatを一律の鏡面に上書きでき、部屋がはっきり映り込む。これは派手だが実車にはない演出。ON=学習した本物の材質、OFF=手動の上書き、という関係。
  • なお材質分解には本質的な曖昧さがある:青い車のアルベドが黄色っぽく分解されることがある(青を「青い光+黄色い素材」に分けてしまう)。inverse rendering では普通で、レンダリング結果自体は綺麗なので実害は小さいが、「物理的に完璧な分解」ではない。

つまり「地味な見え方=素材の正しい挙動」であって、iOS側のレンダラの誤りではない。

結果

反射するRef-Gaussianのシーンを、iPhone上でリアルタイムにリライト:HDR環境を切り替え・回転すると、反射とskyboxが追従する。ベースはMetalSplatter、リライティングモデルはRef-Gaussian、split-sum IBLはUE4由来。

コード・デモ・詳細:https://github.com/john-rocky/MetalGaussianSplatRelighting

次は ARKit の environment probe で実際の環境からsplatを照らす——リライタブルなオブジェクトを自分の部屋に置き、部屋を反射させる。


Swift + Metal / iOSで実装。クレジット:MetalSplatter(Sean Cier, MIT)、Ref-GaussianPoly Haven のHDRI(CC0)。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?