iPhone上で、撮影済みの3Dシーンを好きな照明で照らし直すビューアをMetalで作った。HDR環境マップを差し替え・回転させると、物体の反射がリアルタイムに追従し、背景にもその環境が描かれる。
なぜリライティングが嬉しいのか(実務・商業的な意味)
通常の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がありbitPatternはUInt16→ 通る - シミュレータのx86_64スライス:
Float16がフォールバックしbitPatternがUInt32→ 型エラー
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-Gaussian、Poly Haven のHDRI(CC0)。
