レイトレ合宿11参加記 + 寄り道
2025年11月14日から2025年11月16日まで開催されていたレイトレ合宿11に参加してきました.今年は東京の高尾山付近にて行われました.山の方に見える紅葉が美しかったです.そしてその紅葉景色の中に一行は進みゆくのでした……
レイトレ合宿とは?
レイトレ合宿を詳しく知らない方もいらっしゃると思いますので私の現在の感覚を簡単に説明すると,CGの作品データを自作のCGレンダラと共に提出し,決められた計算機でレンダリングされた結果を鑑賞するという本戦と,他に色々なセミナーやレクリエーション等々がある合宿です.他の方の記事も読んでみると雰囲気がより何となく分かるかもしれません.昨年の参加記の引用です.
レイトレ合宿準備開始まで
レイトレ合宿10では私は交差判定の勉強をそこそこ頑張り,イイ感じになったところで突然に3D空間音響のシミュレーションに手を出してレイトレ合宿で波動シミュをするという奇行に走りました.ですが当時の自分にとっては及第点の出力を出せて満足していました.満足してしまったわけです.その結果がこちらです.

もちろん開発は少しはしていましたが,公開出来ないようなものであったり,gitで管理するほどのものではなかったりしていました.強いてここに書くものと言えば以下のやつです.
ぼんやりとやりたかったやつをお遊び程度に雑実装してみた.ある和音から進行しても致命的ではなさそうな和音を探索して,マシそうなものに重みを付けて遷移する状態機械です(プログラム上で音を出すのがよく分からなかったのでmidi出力にしたけど,実際は永遠に進行し続けられる) pic.twitter.com/B2wVW1is4I
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2025年8月9日
これは大学で履修していた古典和声学を勉強中にこれは状態機械ではないかと(音楽の進行は機械的に定められるものではないというものは理解していますがその上で)感じて,仮に致命的にならない範囲でランダムに進行させてみたらどういう音が鳴るのか面白そうだったので実装したものです.かなり雑に実装したのでバグだったり実装抜けなどで禁則踏んでいる可能性は普通にあります(探索したうえで存在しない場合は禁則を受け入れるみたいな処理を書いた記憶があります).
開発以外には何をしていたのかというと,デジタルのお絵かきにハマっていました.今も描いていてお絵かき歴はもう2年目に突入しました.最近は線画を描かずに最初から面の陰影を描くことで頭の中を描き起こしていく描き方がアツいです(あとで特徴線をちゃんと線で描くこともあります).とはいえYoshi's Dreamという名義で大々的に公開はしていないのでここでは作品例は割愛します(2次創作メインなのも理由としてあります).
他にも稀にDAWを立ち上げて雑に打ち込みをするなどしていました.
昨日久しぶりに打ち込みをしました(高音域がたいへん) pic.twitter.com/qy5Od2i9C3
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2025年8月6日
概して言えば,この1年間は久しぶりに創作側の世界に行ってました.高校時代はよく色んな作品を作っていたのですが大学に入ってからはレンダラの開発が楽しくて技術側の方面に傾倒していました.改めて創作活動は楽しいなという気持ちになりました.
そして,4月に某パズルゲームのとあるシリーズの20年ぶり新作が発表されてそれにのめりこみ(2ヶ月で550時間プレイしていたらしいです),そうこうしていたら院試が大変になり(受かりました),院試が終わったら関東に行く用事があり,その後は色々あって1ヶ月ほど完全に無の期間だったのですが,調子を取り戻して大学の研究室生活とバランスをつかみ始める頃には10月後半になりました.
レンダラ開発開始
いやそろそろまずいよね.ということで新作レンダラもといkagayaki_v4の開発が始まりました.
色々が一旦一段落したのでレンダラを書き始めます
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2025年10月20日
kagayaki_v3はビルド時間が1分以上かかるなどして苦しかったので本当は0から作り直すつもりだったのですが,実装しようと思っていた方針で実装するためには未だに使い慣れていないCUDAのDriver APIを使う必要があることが分かり,流石にリスキーすぎるということでいつも通りRuntime APIで開発を開始しました.実装のベースはkagayaki_v3から引き継ぎました.
リファレンス用実装でコーネルボックスを一旦出した(昨年のレンダラのとりあえず移植みたいなもの)(音響処理は消しました) https://t.co/ds8KsFs0S1 pic.twitter.com/Lydhntvljy
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2025年10月22日
BVH構築処理
さて,真っ先に私が取り組んだのはkagayaki_v3に存在した,オブジェクト数が多くなるとBLASビルドが非常に遅くなるという問題です.BLAS構築関数があるのですが,kagayaki_v3ではオブジェクト単位でBLAS構築関数が呼ばれ,そこではメモリを確保してMortonCodeを計算,ソートして,H-PLOC [CARSTEN BENTHIN, et,al, 2024]を構築することをしていました.特にメモリのバラバラの確保が問題であり,以下に示すほど遅くなっていました.

Shapeと呼んでいるのはオブジェクト単位です.私のレンダラではシーンがScene->Shape->Primitiveという階層構造で管理されています.さて,メモリ確保を一気に行うのは良いとして,MortonCodeのソートもオブジェクト単位ごとではなく全体で一発で行いたいわけです.このために,MortonCodeを64bitにして上位32bitをオブジェクトID, 下位32bitを空間表現(xyz各方向10bitずつ,合計30bit)に割り当てました.これにより,全体を一括でソートしても,あるオブジェクトに含まれるPrimitiveのMortonCodeはソート後の配列で区間を形成します.ただし,オブジェクトIDの為だけにMortonCodeを32bit使用するのは勿体ないのではないかという考えも頭にあります.現時点ではこのままです.さて,MortonCodeの生成までオブジェクト全体で一括に行えたので,あとはH-PLOCの構築に行えばよいだけです.しかし,これは単純に上手くは行きませんでした.LBVHであれば上位32bitをオブジェクトIDに設定したMortonCodeを用いることでオブジェクト間のマージが上手いことできると想像しているのですが,H-PLOCではあくまでLBVHは目印でしかなく,それ以降のplocMerge処理周りで大変なことになってしまいました.
再びBVHが反乱を起こした pic.twitter.com/P3gG1isPWt
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2025年10月27日
色々考えてはみたのですが,現時点ではH-PLOCのBLASを構築が必要なオブジェクト集合に対して一括構築することは出来ておりません.代わりにcudaStreamを用いることである程度の高速化は出来ました.この過程で変な現象を見つけました.具体的なスクリーンショットは残していないのですが,kagayaki_v4でのH-PLOC構築関数の中身をkagayaki_v3から1行しか変えていないのにもかかわらず,kagayaki_v3でのH-PLOCの構築と比較して,オブジェクト数が少ない際に非常に遅くなることがありました.

画像に書かれているとおり,現時点ではこれも未解決です.カーネルの前後の処理は大きく変わっているのが原因なのか,変更したたった1行がそんなに悪影響を及ぼしているのか…… それでもBVH構築処理がオブジェクトを沢山扱う場合でもレイトレ合宿の制限時間に耐えうる速度になったので,次に実装するものに取り掛かりました.
Wavefront Path Tracing
この時点で11/1になっていました.ある意味で今回の開発で一番メインとなったものですが,Wavefront Path Tracingです.Wavefront Path TracingはCUDAのようにSIMTで実行するような計算機により適していそうに見えるパストレーシングの実装方法です.恐らく多くの人々はCUDAでパストレーシングを記述した際には,スレッドをピクセル数分だけ並列に立てて,各スレッドがあるピクセルの色をレイトレで最後まで計算して,レンダリング結果に加えるといったような実装を1つのカーネルで行っていると思います.この方法では例えば処理が完全に終わってやることがないレイと未だに処理中のレイが混在したり,それでなくても処理の過程での分岐処理が避けられなかったりなど,SIMTとは相性が悪いです.Wavefront Path Tracingはその大きなレンダリング処理を細かいカーネルに分割して,なるべく分岐などを軽減できるように実装する手法です.細かく分割したカーネルから次に実行されるカーネルは,あるカーネルの処理が必要であるレイを対応するキューに入れて,キューに入っているレイの本数を元に貪欲法で選択することで選ばれます.とりあえず実装してレンダリングができ,これで私もWavefront Path Tracingを実装した人間になったと喜んでいました.そしてそのまま次の処理に向かいました.大変な問題を残して……
glTFの入力とオブジェクト座標系の変更
kagayaki_v3以前ではシーンの入力にWavefront Objを用いていました.しかし,マテリアル情報の出力に制約があったり,アニメーションするのにキーフレームガン無視で全部のフレームのシーンデータを書き出したり,ワールド座標とローカル座標を結ぶ変換行列を出力しない,カメラの情報を出力できない,などのように色々面倒な点が多いです.そのため,ずっとやろうやろうと言っていたglTFのファイル入力を遂に実装し,この際にkagayaki_v3では並進しか扱えなかったワールド-ローカル座標の変換の処理を書き換えて,行列を用いて変換できるようにしました.これによってオブジェクトがアニメーションする際に,平行移動だけでなく回転や拡大縮小も容易に扱えるようになりました(回転や拡大縮小してもBLASの再構築が不要になった).
glTFの入力には以下のライブラリを使用しました.
glTFの構造は以下を参考にしました.
これらの実装により,扱えるオブジェクトアニメーションの幅が大きく広がりました.カメラ情報も毎度手打ちする必要がなくなったのでとても楽になりました.この実装も簡単に出来たわけではなく,色々なバグを発生させては潰してを繰り返して,2~3日ほどかかりました.順番が前後するのですが,マテリアル実装後にアニメーションの入力を対応させる際には以下のようなバグを引き起こしていました.

これはオブジェクトに対するアニメーションの割り当てを間違えており,回転しながら大きくなっていく不気味な球体のアニメーションがStanford Bunnyにも適用されてしまっていたことが原因でした.
複合BRDFの実装
色んな複合BSDFマテリアルを眺めていたのですが,既に締め切り1週間前なのもあって,3年前に実装したことのあるDisney BRDFを再実装することにしました.とはいっても細かい部分は全部忘れているので,色んな資料を読んでいました.特にGGX周りの話は完全に忘れてしまっていました.今の自分なら一発で出来るだろうと思って実装していましたが全くそんなことは無く,普通にnanが出たり,サンプルした出射ベクトルがメッシュ内部に食い込んだりするのを見落としたり(特に入射ベクトルと出射ベクトルの向きが一致するとハーフベクトルが(0, 0, 0)になってNaNが発生する),接空間の計算をワールド座標系に変換し忘れたりしました.色々やらかしましたが,とりあえず完了しました.


これらの他にもレンダリングするピクセルを動的に選択する処理をお試しで仮実装したり,微妙なバグやメモリ破壊を見つけて修正して,リファクタリングしたりしていたら11月7日になりました.締め切りまであと5日です.本当はこれにNEEを実装すれば完了なのですが,5日あるので他に何か実装したくなりました.
ReSTIRの実装
ReSTIR自体はかなり前から知っていて,時折資料を読んだり,それこそ昨年度のレイトレ合宿でyumcyawizさんがReSTIR DI, GIの話をしていたので何となくの雰囲気はつかんでいました.他にWide BVHとか色々実装してみたかったものもあったのですが,今回はReSTIRを選びました.実装するとなればお気持ちだけではなくちゃんと資料を読み直さないといけません.締め切り5日前,11月7日はひたすら論文やA Gentle Introduction to ReSTIR Path Reuse in Real-Time ( https://dl.acm.org/doi/10.1145/3587423.3595511 ) を読んだり見たりして数式を追っていました.翌日にはNextEventEstimationを実装し,ひとまずここでリファクタリングを行いました.このリファクタリングで1つのバグを埋め込みましたがそれに気づくのは後日です.そして,締め切り3日前,11月9日にReSTIRの実装に取り掛かりました.ひとまずはReSTIR DIで,ベースラインのモンテカルロ法によるリファレンスを書きました

ここからはすぐにWRSを用いたRISによるサンプル選択を実装できました

A Gentle Introduction to...では簡単にBRDFサンプリングをしていたのですが,BRDFサンプリングをするためには交差判定が必要ではないでしょうか?という気持ちになってここでは光源サンプリングしか実装していません.あとBRDFサンプリングすると提案分布がピクセルごとに異なってしまうので適切に管理する必要があると思いました.RISまではすぐに出来たのですが,Spatial Reuseの実装が上手く出来ず,変なバグやバイアスを大量に埋め込みました.

赤い色はnanです.なぜ上手くいってなかったかとかははっきり覚えてないですが,単純に当時は理論の誤解や混乱が多かったように思います.翌日,締め切り2日前の11月10日には修正できました.

temporal reuseは書きはしたのですが,unbiasedに実装するためには前フレームの情報を保持するなど大変面倒であったのでちゃんと実装はせず,とりあえず書いた状態のままです.ズームすると集中線が出現します.

(色がだいぶ離散化されているのはgifに書き出す際に減色したことが原因なのでそこは問題ではないです)法線等でRejectionしていないのですがそのレベルでちゃんとは実装していないです(法線でRejectする理由は今年のレイトレ合宿のyumcyawizさんの発表で納得しました).

とりあえずDIのSpatial Reuseまで実装できたので,DIについてはここで切り上げてGIの方を実装開始しました.せっかくWavefront Path Tracingを実装したのでこれをベースにReSTIR GIも実装しようと設計図を書いたら以下の曼陀羅が出来上がりました.

実装した構造は「ししおどし」をイメージすると分かりやすいと思います.サンプルを生成してトラバースし,トラバースが終了してReservoirに追加します.Reservoirに追加したレイの本数(= サンプル数)だけカウンターを増やします.カウンターが一定数を超えたらTemporal Reuse, Spatial Reuse, Shadingを実行してカウンターを0に戻すという構造です.勿論色々考えるべきことはあって,「こんなにカーネルを分割すべきか」「2次交差点が存在しないサンプルばかりが増えていくのではないか」「カウンターの閾値をどのあたりに設定するのか」などです.今回は時間が無かったのでこの構造で実装を書きました.そこそこ大きい構造なので簡単には実装できず,しかも既存の実装バグがここで顕在化し,「有り得ない動作」が発生しました.

これは簡単にいうと,「X = Aでないならば実行される処理内部で,X = Aである」ということです.楽しいですね.Wavefront Path Tracingの実装で「1次交差点がないなら無視する」みたいなことを実装していたのですが,そこでとある見落としによるバグが発生して,本来ユニークであるIDを持つレイが複数存在し,それが競合することでこうなっていました.これを修正する頃には締め切り当日になっていました.リファレンス実装です.

変なバグを修正できたのでとりあえずやるべきことをやってRISでのサンプル選択はすぐにできました.この時点で締め切り18時間前です.

流石に睡眠を数時間確保し,spatial reuseを実装しようとしましたが,これが上手くいかない.どうしてもバイアスが乗る.論文を読むとヤコビアンの話が書いており,お恥ずかしながらBRDFサンプリング時の諸々が異なることを見落としていたことに気づきました.
締め切り12時間前.
上手くいかない.
処理を眺める.論文を眺める.
締め切り6時間前.
バイアスが取れない.改めて数式を見返した.分からない.アニメーション作成を少し触るがバイアスが気になって戻ってくる.
締め切り4時間前
今回のレンダラにReSTIR GIを組み込むことは諦めました(爆発のGIF).
最後の4時間
実はまだアニメーションを作っていません.構想自体は数日前からあったのですが,テクスチャを自作したり細かいアニメーションを設定したりする時間などは存在するはずもありません.動きや風のある雰囲気のアニメーションを書きたかったので,元々の構想にあった舟など,色々ラフで作成したのですが,まあ微妙でした.最終的にめちゃくちゃシンプルな紙飛行機を飛ばすことにしました.意外としっくりきました.締め切り3時間前からこのアイデアをベースにシーンを作成し始めました.締め切り2時間半前.


過去に使用したシーンを組み込むというアイデアは元々の構想にあったので,過去の提出シーンをコピペして,アニメーションを作成しつつ少しだけ調整を加えました.締め切り1時間半前.

流石に既出のオブジェクトばかりというのもあれなので,大昔に3Dのゲームのステージを作る際に没になっていたオブジェクトを発掘して流用しました.私のイメージではレイトレ合宿は暖かい(暑い)時期に行われているものであったのですが,比較的寒い11月に行われる今回のシーンには多少冬をイメージできる雪のものを選びました.アニメーションを作成し,全体を調整して締め切り1時間前.



さて,ReSTIR DIと通常のレイトレによる間接光を組み合わせて出力テストをしようとしたら大量のNaNが発生しました.軽くチェックはしましたが,なぜ出ているのかすぐに見つけられなかったため,ええいということで,gitから正しく動いていたはずのバージョンを引っ張ってきました.それで読み込ませたらはっきり覚えていませんが少し色々あって,ですが少しの修正で問題なく実行できました.このタイミングで実行時間を確認するためのテスト実行をしつつ,同時並行でIBLの環境光を過去の実装からコピペして実装を汚しました.

IBLは本当に処理を数十行追加するだけで出来るので10分ほどで出来ました.そんなことよりもレンダリングが遅い!こんな遅かったっけ…………
え?制限時間180秒ですよね.半分ぐらいまでしかレンダリングできないんですが……
えっと………
.................
……よし,これならギリ間に合うぞ!提出!!!!!!!(締め切り1分前)
なんとか最低限提出は出来ました.
天の声「提出物間違えてませんか?」
ぼく「そんな」
提出するフォルダに入れていた実行時の設定ファイルが編集前の物となっておりました.

ここで許可を頂き再提出させていただきました.確か元々では600x600という設定でした.これでまあ行けるだろう……と思って久しぶりにゆっくりしていました.シャワーを浴びて戻ってきたらDMが届いていました.

エ!!!!!!!!!!確かにクラッシュすることはあるけどこれは終わったか???と言いながら大急ぎで色々デバッグを始めました.しかしこのクラッシュというのが厄介で,Debugビルドにすると再現しない……とかしていたら次のDMが来ました.

動いたのか……しかしなぜ……?という気持ちで出力を確認しました.確かにレンダリングは出来ていました.良かった…………

......RTCamp11の文字がほぼ映ってない!私はRTCamp10に取り残されてしまいました
(shockerさんには多大なるご迷惑をおかけしました……)
反省
kagayakiはレイトレ用のAPIを使用せずにすべて自分の手で書き上げることを思想とするある意味で勉強用サンドボックス的なレンダラですが,そのような思想で2週間ちょっと触った程度で色々出来るようになるはずもないので夢物語をやる前に普段から積み重ねるべきでした.
今年の目標は計画的な開発及びスライド準備です
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2025年7月29日
これはなんですか?
レイトレ合宿開始!
気を取り直してレイトレ合宿に出発です.生活習慣が壊れていたのですが頑張って早朝に起床して出発して新幹線でスライド資料を作成しながら移動していました.現地に到着するまでは写真を撮るのを忘れておりました......

到着後には自己紹介をし,そのあとすぐに参加者のセミナーが始まりました.昨年は全員分の発表のことを参加記に書いていたのですがあまりに時間がかかってしまったため,今年は特に気になったものを書くことにいたします(どの発表も面白かったです!!).
最初はゆういちろうさんの発表で,レイトレを始めた人々が週末レイトレをしたあとに何をすると良いかの道しるべが書いてありました.非常に有難い資料だと思いました.初学の時にこの資料があったら大変助かっただろうなあと思います.特にNEEのMISところで,じゃあレイを一体何本飛ばすのかみたいな話については自分も全く同じ誤解をしていたことがあり,大変共感できました.NEEとパストレーシングのMISをすると表現するので混乱するのであって,光源サンプリングとBSDFサンプリングをすると言えばまだ良いのかも......と思っています(まずNext Event Estimationが過度にキラキラした名前である).
2025/12/2追記: kinankomotiさんの記事によるとどうやら歴史がある命名のようです.あと光源サンプリングとNEEは異なる概念であるそうです. kinakomoti321.hatenablog.com
うしおさんの発表は論文として出せそうな内容でした.理想的な鏡面や屈折面を経由するレイの,光源からカメラまでのパスを見つける方法であるPath Cutsをベースに,ベクトルの正規化を避けるように再設計したコスト関数を用いて求解の安定性を改善していました.本題とはズレているのですが,区間計算をこのように用いているのを初めて見て,区間計算を用いて他に何か色々面白いことが出来ないか色々考えるのが楽しいです(今までは区間計算は誤差の計算にしか使わないものだとイメージしていました).
yumcyawizさんの発表はReSTIR PTに関する理論と実装の発表でした.自分はまだReSTIR DIとReSTIR GIにしか手を出しておらず,ReSTIR PT(GRIS)の論文は,やりたいことは分かるけど実装やば~~と思ってみていたのですが,遂にこれを実装する人が出てくるのかと思うと同時に,いつかは実装したいと思ってるので本当に有難い......と思っていました.ReSTIRを実装していると数理統計が何も分かっていないことをまざまざと教えられたのでこれも勉強しようと思いました.
がむさんの発表はdraw(tokyo);でのVJをした話でした.draw(tokyo);はTLを見ているとたまに流れてきていつかは行ってみたいイベントです(東京でしかやってないので現状は難しいのですが……).DJはまだ何となくイメージがつかめるのですが(多分),VJは本当にどういう技術でやっているのか何も分かっていないため,VJ側の話を聞けるのはとても貴重でした(意外と人間らしいことが書いていて良かったです).ジェネラティブなアートは好きです.
holeさんの発表は高次元乱数生成に関する話でした.自分の知らない乱数の話が怒涛の如く溢れてきて面白かったです.今でも研究が行われいるのが個人的にはとても意外でした.自分のレンダラはまだ1次元, 2次元乱数を使いまわす構造で本当はよろしくないと思いつつ......でもpentanさんの発表でもありましたが意外と手軽にこういうのに触れられるようになってきているので試してみるのも大変面白そうだと思いました.
1日目のセミナーが終了して,夕食はBBQです!肉を焼きます

セミナーの時に出来なかった質問や,雑談が出来て肉も合わせて美味しかったです.あと最近カメラに興味が出始めていて,皆が(過度な一般化)すごい強そうなカメラを持ってて凄いなあと思いました.私はCanon PowerShot G7X MKIIを使用しています.これはコンパクトであって綺麗に撮影出来て良いのですが,ズーム倍率が4.2倍までしか出来なかったりピントを合わせる距離がかなり制限されていたりで,遠くを撮影するのが難しいなあと最近感じています.
他にも色んな方と色んな話をしていたのですが,Ogakiさんが私の昨年のレイトレ合宿でのFDTD法での音響シミュに関するセミナーの件の話をしてくださったのが嬉しかったです.翌日の本戦で出てきたアニメーションを見たときはなるほど……となりました.他にOgakiさんから,とあることを聞いたのが今回の合宿で一番印象が強かったです.これについて書くのはまた私のレンダラが速くなったいつかにします.
BBQが終わり,次の場所へ向かうと何やら背後から非常に強い気配を感じました.

当然の顔をしてCornell Boxが鎮座していました.ちなみにこれ高さ1.8 mあります.めちゃくちゃデカいです.そんなデカいオブジェを前にして発光ボリュームのレンダリング(焚火)を始めました.




クラフトコーラを片手にBBQと同様にいろんな方と話していました.マシュマロ焼いているのを見るとマシュマロ頼めばよかったなあ~~~と後悔しました.
焚火の後は温泉に向かいました.宿からも近く,とても広かったです.とはいえ初日はそこそこ遅い時間から入浴開始したため,施設自体はゆっくりは出来ませんでした(温泉は堪能しました).ちなみにお風呂上がりのコーヒー牛乳という概念を初めて実践しました.大変美味しかったです.
風呂を上がれば就寝の時間です.......私はまたしてもセミナー資料が完成していないということで資料を作成していました.とはいえ今回はほとんど出来上がっていて,あと結果を示すスライドを作成するだけでした.スライドに載せるデータをちゃんと取るために測定したところ,なんと不思議なことに,Wavefront Path TracingはMegakernelよりも数倍遅いではありませんか.
え?わしこれ本番で提出したんだけど?
てかスライドどうすんのこれ?遅いってなったらなんだ,どういう顔して発表すればいいんだ
いっそのこと何が悪かったのかを有識者に問うて皆さんに知見を共有していただく会にするか?
でもそれもなんかセミナーとしてあれかもしれないので自分で出来る範囲で今から最適化を頑張るしかないよな?てかワシこれ本番で提出したんだけど?
……詳しくはセミナー資料に記しているので詳細は省きますが,実装の改善や最適化をすると深夜4時半に最初よりも3倍速くなりました.
得た知見: 何のアルゴリズムを実装したか自体は勿論重要だが、それを実装したことによってどれくらいの高速化や高精度化がなされたのかを実際に測定せずに次に進むのは愚行である
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2025年11月16日
これはマジで痛感どころじゃない激痛だったので合宿後に自戒としてツイートしたものです(この件に限らず一般的に,です).
2日目
いつものセミナー室に移動して朝食です.”ヤツ”がいました

朝食はサンドイッチです.パンがとてもおいしく,毎日食べたいです.

部屋の窓からは美しい紅葉が見えます.高尾山です.
……今からあの中に行きます.山登りです.3日前に,3日後山登りすることを教えられたときは驚きました.ですがブルーノイズオフセット量を得るときも山登りすることがありますからね.山を登ってより良いサンプル点集合を得ましょう.登山口は宿からはすぐで,自分にとって8年ぶりの山登りが始まりました(最後は中学のときの学校行事の富士登山).流石に富士山に比べたらかなり楽ですが,それでも暫くの間レンダラ開発などで運動不足だったのでそこそこ足腰に来ました.登山路はやはり紅葉が綺麗で,また登るにつれてやはり見える距離も長くなってきました.


1時間半ほど登って頂上に到着しました.登りながら色々と他の方の話も聞けて楽しかったです.

shockerさんの双眼鏡を借りて眺めてみるとなるほど,確かにジグザグの山道がはっきりと富士山に見えました.東京から富士山ってこんなはっきり見えるんですね.すごい.ちなみにカメラでは富士山は撮れませんでした.暫く頂上でのんびりした後,下りながらどこかでご飯を食べようということで下り始めました.上る時には気が付かなかったですが,下る時には太陽の光がはっきりと差し込んでいて,木々の色が綺麗でした


歩きながら撮ったのでピントブレブレですが,意識して見ると意外と山の中って明暗が強く分かれるんだなと思いました(光源環境にもよるとは思いますが).実際に見るともっと魅力的な視界です.
途中で団子を売っているところがあったので購入していただきました.意外と大きくて,タレもしっかり効いていて温かくて美味しかったです.

更に下っていくとロープウェイの乗り場付近までやってきました.ここでは天狗焼きという菓子が売られていました.たい焼きのようなイメージなのですが,中にアツアツの黒豆ベースの餡が詰まっていて,皮はサクサクで出来立てであり,大変美味でした.ちなみに絶対美味しいと思ったので2個買いました.2個とも私の物です.

このままロープウェイで下っていき,12時過ぎに宿(タカオネ)に到着しました.私の感覚では,生活習慣が壊れていたのもあるのですが,16時半ぐらいの気持ちでした.山を登って降りてきたのにまだ12時半前である事実に頭をバグらせながら2日目のセミナーに突入していきます.
いきなりセミナーの内容とは直接関係がないのですが,レイトレ合宿に私と同学年の方が参加されるようになってとても感慨深いです.私は学部2回生の時から合宿に参加していて去年までは参加者の中で一番若かったのですが,遂にそうも言ってられない(何が言いたい)なという気持ちになりました.実はまだフォトンマッピングをしたことが無いのですが,私もコースティクス野郎の端くれなのでどこかでやろうと思います(青い本は持っています).
shockerさんのセミナーについてもですが,剛体シミュレーションもやりたいなと思いつつまだやれてないです.どちらかと言えば今は流体や音響の方に興味があったりするのですが,それもちゃんとやれてないです.これを書きながら意外とやれてないことが山積みであることが分かってきました.
ykozwさんのセミナーは三角関数の近似で,何かしらの展開をするのかなと思ったら多項式系が出てきて,応用数学まわりを少し勉強したときにやったなあという気持ちになりました.しかし,関数の近似誤差の評価ってそんなに細かいんだ......という気持ちになりました.私は何となくでしか誤差評価をしていない......
セミナーが終わると遂にレイトレ合宿11 本戦です.今年は22個のレンダラ + 1個のエキシビジョンが提出されました(写真を撮るのを忘れていた).提出された作品の一覧は以下のリンクから見ることが出来ます.
今回はかなり多くに高得点を付けたような記憶です.どれも良かったので……
作品の中でも特に一番気に入っているのが,eclmistさんのものです.当然のようにノイズが見えないのでレンダラがすごいことは言うまでもないですが,背景のデザインも含めて,ネコチャンのモデリングとリギングも初めてとは思えない大”変”良”す”ぎ”る”ものを制作しており,普通に刺さりました.かわいいね
私のレンダラのプレゼン資料は以下に載せておきます.5分で話す分量ではないだろう.結果としてかなり早口でプレゼンをすることになりました.
意外とこの1枚目の配色好きかもしれません.
全員の作品を見終わってからは夜のセミナーまで自由時間で,昨日と同じ温泉に向かいました.お湯からあがってゆっくりご飯でも食べたかったのですが,ご飯が数十分待つことになるのと,セミナー資料の作り直しの部分がまだ未着手だったので,かなり悔しいですが独りで部屋に戻りました.誰もいない部屋の中では焚火の方で話している人の声が窓の外から聞こえるのみで,そんな部屋でもっとレンダリングを高速化できないかの試行錯誤を孤独に頑張っていました.本戦終わりましたよ?
結果的にさらに高速化することができ,最初に比べると5倍以上速くなり,ようやく普通のMegakernelよりも速くなったということで資料を纏め始めました.資料作成の順番がおかしい.大事なことなので二回目です.
得た知見: 何のアルゴリズムを実装したか自体は勿論重要だが、それを実装したことによってどれくらいの高速化や高精度化がなされたのかを実際に測定せずに次に進むのは愚行である
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2025年11月16日
部屋にkugiさんが帰ってきたので私はひとまず晩御飯を買いに行きました.ここまできてコンビニのご飯ですか.昨年もそうでしたが,あまりにも私のスケジュール管理がダメすぎて合宿の良いところを完全には享受できていない気がします.余裕をもってセミナー資料作成を出来ていないことが共通の原因です.普通に悔しいです.来年こそは………………
そして夜のセミナーです.特に折登さんのセミナー資料が分量がすごくて,発表時点で200ページ以上の資料だったようです(流石に発表は一部のスライドでした).あまりにも凄い.Blenderのレンダリングを自作レンダラで乗っとる折登さんの投稿を以前にX(Twitter)で見たばかりで,あまりにもロマン過ぎるなあと思っていました.
私はこの日の最後の発表でした.実はセミナー資料をまとめ上げた後も最適化を一か所出来ないかを試しており(結果的に遅くなりましたが),その結果を書いて完成としました.これに関連した部分の書き足しで数分開始が遅れてしまいました(申し訳ないです……).発表時間は具体的な数値は覚えていないですが25分ぐらいでした.質疑含めて30分なので本当は22分ぐらいにするべきでしたが,事前の発表練習が出来ておらず,発表時間が予定をはみ出さないか懸念していたのでここについては安心しました.その後,色んな方からWavefront Path Tracingの実装や,実装するうえでの色んな(色んな!)話を聞けて,中々に面白かったのと同時に,結局Wavefront Path TracingはMegakernelと比べてどうなんだろう……という気持ちになりました.まだこの判断をできる時ではないので今後も色々模索してみたいとは思います.
セミナーに関する余談ですが,実はレイトレ合宿11に提出するレンダラとは別に同時並行でもう一つのパストレーシングのレンダラ(kagayakiの新シリーズ)を開発しており,それはまだ完成していないのですが,そちらの開発がきっかけでWavefront Path Tracingを始めました.公開できる段階になったらいずれ開発内容を記事にします.まだまだ勉強が足りていないのでいつになるのか分かりませんが……
さて,私のセミナーが終わったので部屋に戻り,私はどうやら部屋に戻った瞬間に寝落ちしていたようです(あとで歯磨きはしました).睡眠時間を1週間ぐらいずっと削りつづけていたのもそうですが,普通に山登ってますからねこの日.
最終日
あまりに早くも最終日です.この日は朝起きて朝食を食べながらセミナーを聞いていました.朝ごはんはパンでした.写真を撮り忘れておりましたがこちらも大変美味しかったです.
eclmistさんのセミナーは(TRSに限らない)アニメーションをレンダラに実装する話でした.OpenUSDを使われていた記憶で,恐らくこれまでも聞いたことがあるはずなのですが,私は今回初めて知りました.こちらも触ってみた方が良いかなあと言う気持ちになっています(glTFは便利ですがOpenUSDを使ったことが無いので比較できない)
他にも機械学習系のレンダリングに関するセミナーがありました.RenderFormerが数か月前に話題に上がりましたが機械学習でレンダリングを再現するというもので,それに関わるものです.個人的にはレンダリングする時はレンダリング方程式をちゃんと解きたいですが,まだ制約はあるもののある程度こういうことが出来るのは機械学習(トランスフォーマー)凄いなあという気持ちになります.
セミナーが終わると,遂に閉会式です.各参加者の順位が発表されていきます.私は19位で,本記事にある通り中々悲惨なことになっており,昨年よりも大きく順位を落としてしまいましたが,他の方の作品を見てみるとまあ妥当だと思います......ですが開発は楽しかったですし勉強にもなったので,来年はもっと落ち着いてより時間をかけた作品を提出出来たら良いなと思っています(来年の自分がこの文章を良い意味で引用してくれていることを願います.昨年の私より).ちなみに19位の景品はCornell Box靴下でした.家に置いていて今は手元に無いので写真は撮れないのですが,赤色と緑色の,冬にぴったりの厚い靴下です.まるでクリスマスみたいな配色ですがCornell Boxの壁の色です.お間違えの無いように
改めて皆さんの作品に飛べるリンクを再掲しておきます.ネコチャンかわいいね.こういいながら私は犬派です.
他にもセミナー賞や,今年は意気込み賞が発表されました.私の意気込みは「ゆるく頑張ります」だった気がします.11月中旬は非常に忙しくなることが以前から分かっており,本当はここまでガチでレンダラをガリガリする予定ではなかったのですが,レンダラを弄り出すとやはり楽しく,思い切り色々実装してしまうのが人間の性です.直前2週間だけではありますが,割とガチガチに実装してしまいました.
閉会式が終わった後はそのまま解散です.他の方の景品を色々見て回ったり雑談したりしながら,最終的に電車に向かいました.景品は計算機(レトロ)が特に印象的でした.電車でも複数人と一緒に途中まで向かい,途中でそれも解散してレイトレ合宿が終了しました.
......このまま帰宅しても良いのですが,せっかく関東に来たのであちこちに寄り道しながら帰宅することにしました.ここからはしばらく寄り道の話です!
もふもふレンダリング開始!
今回のレイトレ合宿でも毛のレンダリングが行われていました.では私も もふもふレンダリングを見に行きましょう.
レイトレ合宿から帰宅する前に、もふもふレンダリングをしておこうと思いました pic.twitter.com/W6gNpT9Rvo
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2025年11月16日
3か月ぶり2回目のズーラシアです.私は動物園に詳しいわけではないのですが,今までに行ったことのある動物園の中では一番お気に入りです.ゲートをくぐると木々が並んでいる大きなスケールのシーンが続きます.動物を見に行こうと道を歩いている時間は地図の方に目が向きがちですが,ここでは歩いている時の視界も大変嬉しいです.ちなみにあり得ないぐらいデカいので,動物をじっくり見たい方は最低でも2日は確保しましょう.前回来た時に一通り見て回ったので,今回は特に見たい動物を重点的に見ることにしました.
ヤブイヌ舎へ直行すると......!

気温が低くなってきたのもあるのか,爆睡していました.しばらく起きる気配がないのでひとまず別の動物を見てから帰ってくると起きていました!

多分カノンちゃんです.パパマルと一緒に展示されていると思っていたのですが,この日は単独で出ていました.以前来た時は暑すぎて小屋の中で伸びていたので,動いているのを生で観るのは初めてでした.とにかく元気でずっと動き回っていました.



途中で水に落としてしまうこともありました

ヤブイヌは水も平気で泳ぐことも出来るのですが,この季節にもなると冷たそうだなあという人間の視点です.ボールはすぐに回収されました.
この子(カノンちゃん)の他にもパパマルくんと,最近SCZからやってきたフクマルくんがいる(多分展示場の右側の見えないエリア)のですが,時折お互いを呼び合うような鳴き声が聞けました.ヒャンヒャンいいます.キューキューいう鳴き声は色んな子たちから聞いたことがあったのですが,こちらの声は初めて聞きました.それも割と高頻度で聞けました.

ヤブイヌの他にも例えばドール一家をよく見ていました.キツネじゃないよ

私はドールの個体識別は出来ないですが,確か大きい方の展示場にいたので親の方かなと思います.

なかよしです.子供の方はたしかこっちです(記憶があやふや)

写真の他にもまだまだいます.大家族です.ドールの毛は長めで,ヤブイヌとはまた異なったもふもふです.
週末ということで人も多めだったのですが,それでも色々見ることが出来て満足しました.
これで帰宅......と思いきや友人宅へ向かいます.本当は他の友人宅に向かう予定だったのですがそちらに急用が入り行けなくなったので,数日前に連絡したら快諾してくれて大変助かりました.
もふもふレンダリング2日目!
この日は朝早めに起きて,徹夜した友人と共に出発しました.途中で解散し,私はそのまま新幹線に乗り込んで......去年降り立った熱海......ではなく三島に降り立ちます.到着して停車中に目覚め,危うく寝過ごすところでした.思っている以上に東京から静岡って近いんですね……
バスに乗って向かった先は
もふもふレンダリング THE NEXT DAY(入園時に写真撮り忘れにつき過去の写真で代替) pic.twitter.com/q7RkarcYKQ
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2025年11月17日
富士サファリパークも3か月ぶり2回目です.バスが少ないのと駅からかなり離れた場所にあるので,どうしても到着は昼前になります.真っ先に向かったのは

シマハイエナです.夜行性なので,基本的には寝ていて,太陽を浴びながらこっくりこっくりしてました.3か月前に見た個体と恐らく同じで,左耳の先に少し欠けたようなギザギザがあるのが自分の中での特徴です.起き上がると大きく,イケメンです.

ですが性格も大人しく,困り顔のような表情がどこか可愛いです


ところで富士サファリパークということなので富士山が綺麗に見えます.歩いていると木々の隙間から富士山がのぞきます.

他にも紅葉のシーズンなので綺麗な色がついています.

他にも色々撮ってましたが,こちらはリカオンです.


こちらはケープハイラックスの子供たちで,一見ゴワゴワに見えますが意外とモフモフです.

......ただいま

夕方ごろになるとシマハイエナは活発になってきます.気が高まったりすると背中のたてがみが立派に膨らみます.良いもふもふです.


写真に撮ってないですがこのほかにも色んな動物がいて,サファリゾーンもあったりします(サファリパークなので).いろんなモフモフを摂取していたら閉園時間になりました.冬季に入っているので1時間短いということを忘れていました.もっと見ていたかったですが今回はこれで退園しました

退園したところからも富士山が見えます.

そしてようやく帰路につきました.三島で少し買い物をしてから新幹線に乗り込み,帰宅しました.帰宅しながらもこの時は私が立ち上げたサークルの大学祭の準備が色々あって,それのためのデザインとか事務処理とかを締め切り数時間前にやっていました.最後までギリギリすぎる……
さいごに
今回のレイトレ合宿は流石にかなり悔しかったので今後は普段から精進したいです.どこか謙虚さに欠けた開発をしていたところが特に反省点でした.改めて運営の方々,レイトレ合宿11を運営してくださってありがとうございました.参加者の皆さまもお疲れ様でした.とても刺激的で楽しかったです!これからも頑張ります!
雑記: Windows terminal 「無効な"icon"を持つプロファイルが見つかりました」
不幸にもWSL2の環境を破壊しました.Ubuntuの環境を全削除してもう一度導入し,今度は丁寧に仮想環境を立てて開発してました.それ以降,謎のエラーがWindows terminalを開くたびに出てきました.

なんのiconのことですか?Windows側でコードを書いてVisual Studioで実行するたびにこのエラーが表示されるのはストレスなので,どうにかしたいなあと思いました.調べましたが,意外とネットに記事が無いので書きます.
念のため一応
この記事に書かれている内容を実施して不利益が発生した場合も責任は一切取りません.あくまで私はこれで解決したと思われるという雑な備忘録です.
環境
Windows11
やったこと
知人に言われて気づいたのですが,ここにボタンがあったんですね

今は表示されてますが,もともとこのubuntu001, ubuntu002のUbuntuアイコンがありませんでした.

「設定」を押します.左側の画面をスクロールするとubuntu001, ubuntu002の設定がありました.これをクリックして,「アイコン」に表示されているファイルへのパスを確認しましょう.

元々自分はこの部分が明らかに変なパスが入っておりました.Ubuntuのアイコンは全部一緒なので,Ubuntuのアイコンへのパスをコピペしました.ubuntu001, ubuntu002両方に対してコピペしました.
するとエラーが出なくなりました.以上です.でもこれってUbuntu環境追加するたびにこれやるんですか?
レイトレ合宿10参加記
2024/10/11から2024/10/13の期間に行われていたレイトレ合宿10に参加してきました.今年は熱海の初島という島で行われており,去年のお寺での厳かな感じと比較するとすごく𝑳𝒖𝒙𝒖𝒓𝒚な感じでした.現地参加者も多く,非常に良い合宿でした.運営の皆さん本当にお疲れ様でした.
レイトレ合宿とは?
レイトレ合宿を詳しく知らない方もいらっしゃると思いますので私の現在の感覚を簡単に説明すると,CGの作品データを自作のCGレンダラと共に提出し,決められた計算機でレンダリングされた結果を鑑賞するという本戦と,他に色々なセミナーやレクリエーション等々がある合宿です.他の方の記事も読んでみると雰囲気がより何となく分かるかもしれません.
レイトレ合宿まで
……時は2023年の夏,レイトレ合宿9が行われました.初参加の私のレンダラは本番でクラッシュし,作品を提出することが出来ませんでした.当時はCUDAを書き始めたばかりで,慣れてないことを色々やろうとグルグルしてたら空中分解した感じでした.その後,それまでぼんやりとやりたいと思ってたNeRFの実装を行い,この時に実装したFully Fused MLPが良いCUDAの勉強になりました.以降はLBVHを実装して……ImGUI導入してGUIデバッガ書いてデバッグしやすくしたり……交差判定書き直したり……などとしていたら2024年の春でした.
私はレイトレにおいて交差判定処理は非常に重要であると考えており,ここを高速化するということを今年のレンダラの最低要件としていました.特にBVHについてゆる~く色々とサーベイしておりました.また,FPGAを大学のサークルから借りて自作ハードウェアレイトレしたい!とも思っておりました(未実現).アレやりたいコレやりたい!とか言ってたら夏前になりました.
夏前は競技プログラミングの国際大会であるICPCの国内予選に参加するためにチームで集まったり個人で練習,勉強したりすることが増え,あまりレンダラを書いておりませんでした.ICPCの参加記は以下に残しております.
さて,大学の期末試験で大爆発を起こした後,ようやくレンダラを書き始めるぞ!と実装に取り掛かりました.毎回毎回ゼロから書くのもメンドクサイので,GUIレンダラの最低限構造を作成してテンプレート化したところで,インターンが始まりました.インターンは月曜から金曜フルタイムなので,実装に取り組める時間は夜でした.勤務開始時間が遅めだったのもあって案外時間は工面できました.
レンダラ実装!
実装の話をしましょう.今回もやはりOptiXやVulkanなどのレイトレAPIを使用せずにすべて書いてやる!という意気込みで実装を始めました.
最初の頃は「やろうと思えば実装できるけど面倒」ということで実装していなかった処理を書いておりました.例えば2-Levelのシーン表現(シーン→オブジェクト単位→プリミティブ単位)です.2-Levelのシーン表現は結構苦労しました.なるべくコードを共通化させるためにC++のtemplateを使って実装したのですが,この時にちゃんと纏めずに雑に実装したので今のコードはかなり汚いです.また,オブジェクト単位ごとにBVH構築処理を読んでいるので構築も遅いですし,部分的にBVHの構造が制限されるので性能も少し落ちます.改善しないといけない.
他にも,上で述べた「交差判定の高速化」をするために引き続き色々資料を読んでいました.いわゆるBVH STAR論文(D. Meister, S. Ogaki, et.al, A Survey on Bounding Volume Hierarchies for Ray Tracing, EUROGRAPHICS 2021)はBVHの勉強に非常に役立ちました.Ogaki先生は今回のレイトレ合宿にいらっしゃいました.新たなBVH構造を考えてやるぐらいの気持ちで色々やっていましたが,どれも上手くいきませんでした(今後も要検証).また,直近のHPGでH-PLOCというBVH構造が発表されており,パフォーマンスも結構良い感じなのでこれを実装しようと思いました.
詳しい話は書きませんが,結構手こずりました.というのも,動画や論文だけ見ても「言いたいことは分かるけど,このままだと実装しても動かないな」に陥るためです(あと多分論文内の疑似コードのpIDはparent IDではない気がする……).勉強のために論文内で参考文献にあげられているLBVHとPLOC++の論文を読んでコードを書いて色々頑張っていたらこんなBVHが出来ました.

たのしそう.原因は分かれば単純で,shared memoryに乗せて処理をするときに,近傍探索したときはコンパクションを行ってglobal memoryに書き直すのですが,近傍探索しない時にこの書き戻しを忘れており,結果としてデータの整合性を取れず変なバグになっていました.修正結果はこんな感じです.

直りました.大体三角面数150万のオブジェクトもBVH構築時間は18ms程度です.結構速いですが,恐らくもっと速くなる?
他にも色々実装はしているのですが,特に今回は音響処理をしてみたい!ということで音響処理周りのサーベイもしていました.ネットで資料を探そうとしても全然何もないことを知り,とりあえず本を買いました.
特に今回特に参考にしていたのは『音場再現』と『FDTD法で視る音の世界』です.前者は理論寄りで,後者は計算機上で扱うことに重きを置いております.後者を読みながら理論の確認に前者を読んでいました.とはいってもまだまだちゃんと読み進められていないので今後も勉強しようと思います.そもそもの音の伝搬のほかに,それを可聴化する方法も調べておりましたが,これもまだよく分かっておりません.これもTODOです.ていうかレイを飛ばしてください.
直近の大学の電磁界解析演習で使った数値解析ソフトの中身を実装したいなとは少し思っていたので,じゃあFDTD法やるか~~~って勉強し,とりあえず2次元のFDTD法は実装しました.

左は粒子速度で,右は音圧です.これに関しては「本当にこれでいいの?」で雑に書いただけなので,それっぽい挙動してるな~~~ぐらいで定量的評価はしてないです.ごめんなさい.
その後いろいろ勉強しており,CE-FDTD法という手法を知りました.サーベイ中に音空間解析におけるFDTD法を解説している論文を見つけて,そこに書いていました.
斜め方向の境界条件どないなっとる?って思ってましたが(普通に離散化すればいいのですが),次の論文にまとめられておりました.
う~~~ん,やっぱり同じ時間ステップ内部で音圧の値に依存関係生じるの嫌だな~~~でも嘘つきたくないな~~~と言いながらイイ感じに実装しました.これはshared memoryを活用して速く処理できるタイプのアルゴリズムです.

とりあえずボリュームレンダリングを実装して(レイを飛ばした!),スリット実験を実装して波の様子を見ました.3次元波なので少し見にくいですがイイ感じの波動性が確認されてます.
さて,じゃあ可聴化するぞということで,カメラの左右にマイクを設定し,そのマイクのある座標の音圧を取ることでステレオ2ch音源としました.そしてその音圧値をwavファイルに書き出すということをしていました.最初の方は実装ミスでこの世の物とは思えない凄い音が記録されていました.文字通り耳が痛かった.

……この方法は非常に単純な実装で,まったく定位感はありません.本当はカメラのボクセルモデルや人の頭部ボクセルモデルを置いておく,もしくは頭部伝達関数みたいなものを考える方が良いのでしょうが,これらに関しては今後のTODOです.ていうかレイを飛ばしてください.
FDTD法には安定条件があり,高周波数信号を正確に計算するためにはグリッドの解像度を上げる必要があります.こうするとメモリも足りず,計算も遅くなります.そのため,高周波成分に関しては直線近似してレイトレをしてあげる必要がありました(音線法).しかし,実装する時間が無かったため,これに関しては実装してません.レイトレ合宿なのに!?
なんやかんやあってレンダラを一通りまとめて提出しました.シーンも締め切り2日前くらいから本腰を入れて作成しました.

自前の環境で240秒弱で終わることを確認して提出しました.流石に間に合うだろ……w

TLE(Time Limit Exceeded)……!?お前……まさか……
AC,そのまさかだよ
本番環境の計算速度を甘く見積もっておりました.かなしい.
作成物は以下のリンクに投稿しております.
レイトレ合宿10,お疲れ様でした.私は以下の映像(と音声)を254秒でレンダリングして全体11位でした.
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2024年10月13日
昨年度はバグで本番に絵を出せず悔しい思いをしましたが,今年も懲りずにすべてソフト側でフルスクラッチし,昨年度よりも数十倍の高速化を行いました.(リプライに続く)#レイトレ合宿 pic.twitter.com/2FAsr9GiYL
最終的な提出物には音声は含まれないため,レイトレ合宿ホームページには映像のみが記されております(そもそもは映像の綺麗さを競うイベントです).
レイトレ合宿開始!
前日は普通に大学の講義があるので大学への行き帰りでスライドを作成していました.実はレイトレ合宿の初日は普通に講義があり,しかもそれは専門の必修の実習科目であるため,色々と大変でした.
初日
朝から新幹線で熱海まで行きました.集合時間の30分前に到着したのでセミナー資料を書いていました.最初にうでめがねさんと合流し,そこからishiyamaさん,そして新幹線組と合流し,一旦熱海駅周りで昼ご飯を食べることにしました.昼ごはんの写真を撮り忘れたのですが,ラーメンと半チャーハンのセットを食べました.美味しかった.昼ごはんではレンダラの準備に関連する話題が多かったように覚えています.その後売店でみかんサイダーなるものを購入し,移動のバスの中で頂きました.みかんサイダーの味がしました.

バスを乗り終えると熱海の港に到着します.ここからは船での移動です!船での移動は20分ぐらいでした.一番上の階から海の景色を眺めておりました.




船が到着し,一歩踏み出すと初島へようこそのゲートが.そこをくぐって再びバスで移動し,合宿会場のホテルに到着しました.
ホテルはこれまで泊った中で一番豪華で,空間の使い方が凄い構造物でした.こんなところに自分が泊っていいのか……?という気持ちで移動すると何やら怪しげな看板が

なんだここはッ……!怪しいことが中で起きているに違いない!

wow......
凄い景色の会場です.ここがセミナーなどをする場所で,背景に海が見えます.良すぎる.いったん集まった後に各々の部屋に移動しました.

マジか,良すぎる.ここで荷物を整理し,また先ほどの会場に戻りました.開会式を終えて早速セミナーが始まりました.
最初のセミナーはushioさんによる3D Gaussian Splattingの解説で,個人的に3DGSのモチベが高まって論文を読んでいたので凄く興味深い発表でした.多分近い未来にNeRFと同様にフルスクラッチ?実装すると思います.
次はkugiさんによる蛍光に関するセミナーで,蛍光に関する性質がまとめられておりました.私は電気電子工学の人(とは?)で,半導体などの材料の発光などに関する勉強を大学でやってるのでこれも凄く自分の専門に近いなあと感じました.
次はSomatic Cellさんによるニューラルネットワークを用いたBRDFの評価に関する論文の解説でした.これも一回やってみたい~~とぼんやり思っていた手法で一回も試していなかったものだったので,実際に論文として発表されてて,良い精度を出しているのを見ると面白いです.想定以上に学習時間が長いのも驚きました.
休憩をはさんで,OgakiさんによるARMでのSIMDを用いた最適化に関するセミナーでした.結構抽象的な内容ですが,ビット操作処理が特に面白かったです.この時間帯は丁度夕方で,セミナー室から見える後ろの海も夕暮れに染まっていきます.

次はPocolさんによるハードウェア最適化の恩恵を受けるフォトンマッピング法に関するセミナーです.フォトンの密度推定時の半径探索を改善してASを使用してGPUの専用コアで解くというアイデアです.基本的な手法から段階を踏んで説明されており分かりやすいセミナーでした.
次はがむさんによる,つぶやきGLSLの解読でした.つぶやきGLSLというものはTwitter(!)で140字以内270字以内(つまりTwitterのツイートにおさまる範囲)(2024/10/23 22:15更新)でGLSL作品を作るコードゴルフみたいなもので,その制限ゆえに非常に難解な記述がされており,それを読み解くセミナーでした.実際にその場で変更を加えながら出力の変化も見えて面白かったです.
次はこの日の最後のセミナーで,holeさんによるNaNに関する解説でした.NaNの発生条件,qNaNやsNaN,規格などの話があり,コンパイラによっても挙動が違うのはマジか,って感じでした.
セミナーが終わり,さて夕食!というわけで移動の準
「NaNの歌を作りました!」
おもむろに配られるサイリウム
気づけばサイリウムを焚き,腕が動いていた……
備をして,夕食に向かいます.夕食はコース料理で,一品ずつ料理の説明がなされるのが凄い……でした.料理も十分な量で,味もとても良かったです.

夕食の後,入浴を済ませてセミナーの準備をしていましたが,せっかくなので部屋のメンバーと星を見に行きました.屋上に出ると自分たちの他には誰もおらず,涼しい風が吹いてました.遠くには熱海港の街並みが小さく見え,反対方向は真っ暗でした.肉眼でもたくさんの星が夜空に見えており,星空そのものでした.
スマホで空を撮ってみました.

……
NaNが出てるわけじゃないです.一回部屋に戻り,デジカメをもってSomatic Cellさんともう一度屋上に上がりました.Somatic Cellさんの三脚と星空撮影用機材をお借りして何枚か撮ってみました(カメラ: Canon PowerShot G7X).




一通り満足したので部屋に戻りました.3時になっており,皆寝てるか……と思ったらまだ起きていました.セミナー資料つくらないといけないけどまあ正味最終日だし間に合う……と思ってました.まさかあんなことになるなんて……
2日目
朝は皆の動く音で目覚めました.夜型なので......
朝食も豪華で,8品の小鉢が来たときはビックリしました.

さて,朝ごはんを食べると早速セミナーのお時間です.最初はhamityさんの,レンダラ作成記でした.NEEを実装出来ているのであればMISはすぐ出来そうだなと思いました.
次はyumcyawizさんのReSTIRの理論説明と実装の話でした.今回のレイトレ合宿もですが,ReSTIRを実装してみようとは思っているのでこのセミナーを元に勉強していこうと思います.
次はKiuchiさんのレイトレとVRに関するセミナーでした.途中で音響の話になったときに自分の今回のセミナーテーマ(空間音響処理)とまさか重複する内容が出るとは,と思いました(こちらは音線法に関する話だったのでちゃんとレイトレである……)
次はishiyamaさんのレイトレで解ける問題に関するセミナーでした.こちらも音線法の話になり,こんなに被ることある?と思っていました.光の幾何近似の妥当性の説明も書いていて,少し前に勉強したときの内容を思い出しました(左貝 潤一『光学の基礎』1997.を途中まで読んで放置しているのを思い出した……).
次はshockerさんのGPGPUでのOne Sweep Radix Sortアルゴリズム,Decoupled Look Backについてのセミナーです.現在Sortの実装をすべてthrustに任せっきりなので今後は自分で書き直そうと思っていたところでした.内容も丁寧に書かれています.
次は午前の最後のセミナーで,elcmistさんのリアルタイムなポータルエフェクト(BRDF?)に関するセミナーでした.最近BlenderのBRDFに追加されたポータルBRDFを見たばかりだったのですが,リアルタイムで出来るとは思ってませんでした.
さて,昼ご飯はホテルの外で食べることになりました.どこに行こうか……ととりあえずご飯屋が沢山ある港のあたりまで皆と(!)歩いていく途中でイイ感じの坂があり,良い景色が見えました.


ご飯屋についたものの,これが混んでました.とりあえず全部見て時間的にも良さそうなところでご飯を食べました.

刺身の身が厚くて美味しかったです.本当は少しだけプールに入りたかったのですが時間的に厳しそうだったので諦めました.
午後のセミナーです.最初はたたさんの……音線法に関するセミナーです.そんな,私のセミナーで話せる内容が遂に波動解析だけになってしまった.もっとも音線法のスライドは用意していなかったのですが(ええ).事前に打ち合わせたわけでも無いのに音に関連した話があったセミナーが3つもあったのは驚きでした.
次に,kiNaNkomotiさんのProjective Geometric Algebra入門でした.これは射影幾何代数という数学の分野らしく,幾何的な操作を代数的に扱う数学みたいです.私はこの分野を知らなかったのですが,代数演算によるブール演算や交差判定の表現は非常に面白かったです.
次に,NishikiさんによるHash Tableの話でした.Hash Tableは大学の講義で扱われており,(結構忘れていたのですが)復習になりました.記憶が混じっていなければ応用先の話についても書かれており,InstantNGPで使われているHashTableの話を質問しておけば良かったと思っています(スライドが公開されたらもう一度読み返してみます)
次に,elkさんによる光源のサンプリングの話でした.これは点光源の話なのですが,私がNEEで面光源をサンプリングするときには光源の強さ(と面積の積)で重みづけしていました.BVHのサンプリングの話は初めて知りました.
次に,ykozwさんの,逆数2重根の近似の話で,そこに現れる非常に不思議なマジックナンバー0x5F3759DFについて考察するという話でした.簡単なモデルを置いてニュートン法でパラメータを探索すると良いマジックナンバーを得ることが出来るっぽいのですが,このタイトルの値は結局のところ不明らしいです.調べてみると1700年ごろにはニュートン法という概念は存在していたらしく,では指標を変えたらこうなったのかもしくは本当に良い感じの値を選んだのか……
次がこの日の最後のセミナーで,Pentanさんによる最適化によるRay-AABBの交差判定(の嬉しくなかった話)でした.概念としては今まで頭になかったもので,個人としては斬新ではあったのですが,やはり遅いらしく無念……です.ただ他に何か応用先があるかなあとは考えてみようと思います.
セミナーが終わり,レクリエーションのお時間です.わいわい.昨年度の雰囲気を知っているので今年はどんなものが出てくるんだろうと思っていたら突然配布される箱,蓋を開けると……

なんだこれは.とんでもないことが始まりそうです.中身は500ピースのCrytek Sponzaで,これを時間内にどれだけ組めるかというバトルでした.制限時間が短いため,もう右端を組むことは難しいと判断し,左側の色がついているところを組もうということで,2人が色で分類し,1人がパズルを解いていくという構造で進めていきました.人数も3人であり,何となくICPCを彷彿とさせました.

時間切れです.私のチームは確か3位か4位だったと思います.優勝したチームにはなんと!あの!
(ここに画像を貼り付けたかったが,写真を取り忘れていた)
Cornell Boxのお手軽ジグソーパズルでした.これならいつでも遊べて何なら外出のお供にも出来ると思うのでとても良い景品だなと思いました.
さて,レクリエーションが終わったら遂に本戦です.今回は22個のレンダラが提出されたそうです.てか普通に自作レンダラが22個集まる合宿って凄すぎるな.どれも面白い発表でした.発表を聴いて初めて気づくような技術が組み込まれていたりして,自分の目もまだまだだと思いました.今回はeclmistさん,shockerさん,Ogakiさんの作品を特に高く評価しました.
eclmistさんの作品はそもそもレンダリング結果が綺麗なのもですが,映像の世界観が凄く好きでした.環境テクスチャの途中で切り替えるところや,発表で話されていたGUIエディターもですが,これは内部構造がしっかりと整理されていないと途中で壊れて投げ出してしまいそうだ……と思いました.
Ogakiさんの作品を見たときの純粋な感想ですが,CPUでこんなことできるんだ……でした.Ogakiさんが話されるレンダラ実装の哲学には凄く共感できるので,より一層精進したいと思いました.
shockerさんの作品は純粋に画が綺麗でした.まず私はコースティクス大好き人間なのですが,それが時間方向に安定して見えていて,さらに疑似的ではなくスペクトラルレンダリングによる分光模様が見えているのが凄く良かったです.さらにはこれを側面から見てボリュームレンダリングで光路を可視化するというものは視覚的にも凄く良いものでした.
色々な発表を聴いていると私の名前が呼ばれました.私は11位のようでした.昨年度はバグでクラッシュしており順位が付かなかったので,今年はちゃんとこういう形で順位が決まるのは嬉しかったです.来年はもっと良い順位を取れるポテンシャルを持たせたいです.私のレンダラ紹介スライドは次のリンクにあります.
ちなみに自分のレンダラには8点を付けました(満足度としては90%程度だと思っていたので,補正をして8点).来年の自分は何点を付けるでしょうか.
本戦が終わり,晩御飯でした.和食で,どれも美味しかったです.最後の氷菓子も非常に濃厚で良かったです.

さて,晩御飯が終わり,部屋へ戻って自由時間でした.私に自由などなく,セミナーの資料を書いていました.どうして毎日チョットずつやらなかったんですかァァァ…ッッ
その私の隣では例の500ピースジグソーパズル Round2が繰り広げられていました.たまに覗きに行ったら少しずつ進んでて面白かったです.さて,諸々の後,再び部屋に戻ってセミナー資料を書いていたのですが,なんだかんだでここ6日ぐらい睡眠時間4時間半とかで暮らしていたので流石に体力の限界が来たみたいです.途中で視界が揺れ始めたあたりで限界を感じて仮眠をとることにしました.揺れの周期は0.4秒ぐらいでした.文字を読むのが結構大変だったのでここで止めたのは結果的に良かったと思います.アラームを大量に付けてソファで仮眠しました.

起きないと終わる……という気持ちでアラームに起こされました.起きた自分はまずアラームを全部止めるところまでは順調だったのですが,なぜかソファに再吸収されていきました.ヘテロ接合構造でもあったんですかね.
最終日
……
……
んっ……外が明るい……
明るい!?
終わった____
起きたら6時でした.流石にヤバいということで急いでスライドの続きを作り始めました.……そして時間は8時,流石に発表に行かないといけない.
ということで凄く散らかっている資料のまま発表が始まりました.表紙に私の名前さえも書いていない.
セミナー最終日! #レイトレ合宿 pic.twitter.com/GWcTN1O2RG
— がむ (@gam0022) 2024年10月12日
(がむさんのツイートを引用)
本当はちゃんと全部説明したかったのですが,凄く表面的な説明に終わってしまったのはかなり悔やまれます.セミナー資料自体はクオリティの面で公開しませんが,いずれにしてもこの分野はネットで見える記事が少なすぎて取り掛かりが難しいので,よりロバストな実装が出来たらいつか記事にしようと思います.
私の発表が終わり,次のセミナーはうでメガネさんによる,レンダラ間での単位の話でした.この辺は実装者目線では結構重要な問題で,たまにアーティスト目線のパラメータ名(「強さ」みたいな)があると(直観的に操作は出来ますが)デバッグ時に困ることがあります.
次はZinさんによるHierarchical Sample Warpingの精度に関するセミナーでした.私はこれは恐らくちゃんと理解は出来ていないのですが,誤差の分布を見ていたいなと思っていました.記憶が正しければdoubleで計算されているので数値精度に関してはある程度は問題ないとは思うのですが,数値精度的に難しい非常に大きな値が入りうる場合があったのかとかはセミナー資料をもう一度じっくり読み返してみないと分からないです......
次はPheemaさんによるリー代数による3次元回転でした.数学第二弾ということで,回転操作を扱える代数演算の話でした.私は回転といえば行列でのアフィン変換やクォータニオンしか知らなかったのですが,いろんな方法があるんだと勉強になりました.この行列のことは覚えています.
次が最後のセミナーで,ハガさんによるマイコンでのレイトレです.電気電子工学科の私は歓喜しました.実際にデモも提示され,このハードウェア上で計算されているんだなあと感慨深い気持ちになりました.私も最初の方に書きましたがFPGAでレイトレをしたいです(現時点で何で詰まっているかというと,通信です).
全てのセミナーが終わり,片づけです.この片付けの時に,例の500ピースジグソーパズルを私が持ち帰ることになりました.

片付けを済ませてセミナー室へ行き,閉会式が始まりました.
#レイトレ合宿 pic.twitter.com/oKtpjaPQQV
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2024年10月13日
セミナー賞や本戦の順位などが発表されました.本戦の順位によって景品が与えられます.本戦1位はshockerさん,セミナー1位はholeさんでした.おめでとうございます!
私は11位で,Monte Carloと書かれたポーカーのコインを頂きました.

見た目に寄らず意外と重いです.これを集めると,装備しなくてもサンプリング戦略スキルが向上しそうです.最後にいくつか写真を撮ってホテルを後にしました.


港に到着しましたが,やはり3連休というのもあり,混んでました.ご飯を食べるのは諦めて,船を待っている途中にお土産を買いました.ほかにもアイスクリームを買いました.


船に乗るか~~って向かうとすごい列があり,乗れるか不安でしたが何とか乗れました.行きの時には最上階で載ったので今度は最下階で乗ろうと思ったのですが,外が全く見えずただ揺れているだけで,ここにいたら僕でも普通にきついかもになったので外に出ました.


港に着いたらバス移動をして熱海に到着しました.そこで解散しました.本当は伊豆の方に行ってみたいと思っていたのですが,意外と遠くて厳しいなあという結論に至り,寄り道せずにそのまま帰宅しました.途中でレンダリング結果を簡単な説明と共にツイートし,これでひと段落だと思いました.
......
まだやることがあります.
対戦!500ピースのCrytek Sponzaパズル!
私は部屋の皆の意思を受け継いでパズルを持ち帰りました.これを済ませないことには.
帰宅したので意志を受け継ぎます#レイトレ合宿 pic.twitter.com/cYtzxkAE80
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2024年10月13日
帰宅して晩御飯を食べて,机の上を掃除してスペースを作り,そこに持ち帰ったパズルを広げました.結構崩れてしまっており,マジか~~って感じでしたが,やるしかないという気持ちで始めました.
3時間経過 pic.twitter.com/4z9D8TpdHs
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2024年10月13日
最初の方はまあ普通にパズルなのと,一回やってるのもあって結構スムーズに行きました.一旦ここで寝ました.
さらに2時間経過 pic.twitter.com/t4LltO5Qf4
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2024年10月14日
昼に起き,早速取り組み始めました.微妙に見える赤いカーテンの模様を頼りに組み合わせていきました.
Crytek Sponzaパズル(500 pieces)完成!
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2024年10月14日
所要時間: 7時間 #レイトレ合宿 pic.twitter.com/0QxcQ1uvw9
合計7時間で完成しました.右半分はマジで情報が少なくて,形状でグループ分けしてその中身を探索してました.Top-down型のクラスタリングですね.
倒すことを想定されていないゲームのボスを,無理やり倒し切った感覚でした.楽しかったです.
……
もうひとつやるべきことがあるかも!
対戦!昨年度のレンダラ
記事を書いている時点での今年のレンダラと,昨年度提出したレンダラの比較をざっくりしてみようと思いました.シーンは昨年度の提出したシーンで,アニメーションは当時はハードコードで今回はBlenderからの出力なので,両者は完全に一致しませんが何となく比較しました.レンダリング自体の設定は同じで,レンダリング時間が昨年度の規定(300秒)を超えない最大のサンプル数で比較しました.
[昨年度の結果]
遅ればせながら……まだNaNなどが出てますが一旦ここで映像を出します.#レイトレ合宿 9では以下の動画の前半を出す予定でした.レンダラは全てCUDAでファイルのIOを除きフルスクラッチ実装しております.NEEなどの工夫をした1sppレイトレの映像にSVGFを適用することでこの映像を得ました.(続く) https://t.co/W2jKDtn9Mz pic.twitter.com/d2VhsMRLLX
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2023年9月5日
[今年度の結果]
今年のレンダラで昨年度のシーンを制限時間300秒でレンダリング(アニメーションは微妙に違うけど参考程度)
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2024年10月15日
昨年度は1sppで何とかだったけど今回は29sppでも間に合った https://t.co/leZz4VJEga pic.twitter.com/XDVocuzCf9
明らかに違いますね.正直なところ昨年度はフィルタリングを強烈に掛けて無理やりノイズを消してるので見た目が(これはレイトレ……?)って感じです.今年は軽めにフィルタリングしてるのでちゃんと影などが保持されています.そもそもの話ではフィルタリングをしない方がまあ好ましいので……
さて,前回は1sppが上限で色々頑張ってましたが,今年のレンダラでは29sppまで回せました.単純計算では29倍の速度向上となります.また,前回のレンダラはCPU側でSAH-basedのBVHをTop-downで愚直構築しているので,構築に2秒かかってました.今回はH-PLOCをGPU上で構築しており,構築に要した時間は1.64msでした.レンダラの速度性能向上の理由はBVHもありますが,交差判定の処理を大きく書き直したのも影響していると思います.まあ昨年度が遅すぎたのもありますが,交差判定処理はレイトレの実行時間にクリティカルに影響するのでこんなことになります.ここは今後も自分で書きたい.ほかにもレイトレのLaunch部分の処理を書き直していることによる数十%程度の処理性能向上があります.ここもより良い処理方法を考えているので今後も改善していきます.
来年の自分が今年度の私のレンダラを大批判してくれることを期待してます.余談ですが,OBJファイルでのカメラのアニメーション出力は,三角面を設定し,カメラにconstraintを追加してこの三角面に設定し,この三角面情報をOBJ読み込みしてその三角面情報からカメラの姿勢を設定しました.

最後に
とても3日間とは思えないボリュームの合宿でした.運営の皆さん,本当にありがとうございました.来年はもっと強くなって帰ってこようと思います.自分で新しい手法を作り出すぐらいの勢いで色んなアルゴリズムやデータ構造を貪欲(一般用語)に使って高速化していこうと思います.また,高速化を今年はメインで考えましたが,やはり表現力が少し乏しいと感じたので新たなマテリアルなどを追加する方向性も再び持とうと思います.これからも頑張ります!
ICPC2024 国内予選参加記 (Viridian: Yoshi's Dream視点)
チーム名Viridianで,ICPC2024 国内予選に参加してきました.これが初参加で,国内全体では33位,大学内では5位となり,予選突破とはならず悔しい気持ちもありますが,楽しかったです.
参加まで
大学に入学するタイミングでプログラミングを始めると同時に競プロの世界に入り,それから先輩が定期的に何かしらに参加してるなあと思ってたのがICPCでした.これまでは参加しようにもメンバーが見つからないので何もしてなかったのですが,今年は私の所属している技術系サークル(KMC)から辺を結ぶことが出来たので参加させていただきました.
チーム名決め
対面初めましての後,とりあえずチーム名を決めようということでランダムに複数のカッコ良さそうな単語を組み合わせようにも,まあ中二病みたいな名称にしかならず,アイデアを出すために呼び出したChatGPTくんも中二病に成り果ててしまいました.その後なんやかんやで,チームメンバのAtCoder(Algo)の色を混ぜた色をチーム名にしようとしました.チームメンバのうち2人が緑で,1人が水色であったため,これを混ぜた色の名前はいかなるものであるかを調べ,その結果得られたのがViridianでした.私は緑です.
ちなみにこんな色らしいです.
来年に同じメンバーで出る場合はチーム名が変わっているはずです(仮に黄色と青色を足せば緑になるなあ)
本番直前
私は本番当日にも大学の科目で実験があるので,開始30分前まで普通に同軸ケーブルの線路における信号の時間発展,電磁波(光)の偏光に関わる特性を測定してました.実験が終わると即会場に向かい,何とか間に合うことが出来ました.実験リストの中で一番軽い実験をこの日に割り当てられていたのは幸運でした.
会場に着き,とりあえずお茶とコーヒーを買うために自販機に向かうと目の前でメンテナンスが始まりました.いつもお世話になっております......何とか開始前には購入できたので準備が完了しました.机の上にはお菓子が並んでいたので有難く頂戴いたしました.本番はぬるっと始まり,しかし厳密にタイマースタートです.
本番
チームメンバの一人がA問題を解いている間に分割タブで私はB問題を考察していました.A問題は3分半で通って,B問題も特にこれと言って工夫する必要もないので交代し,私が実装を始めました.
B問題
2人の走者A, Bがいて,$n$分までの1分ごとにおける二人の走行距離が与えられるので,AがBを追い越す回数と追い越される回数の和を答える問題です.これは実装するだけと言ってしまうとあれなのですが,カウントするのは「A/Bがリードしている状態」から「B/Aがリードしている状態」への遷移回数であり,出走時点では「どちらもリードしていない状態」となることに注意が必要です.
さてさて,ということでサンプルを試すと合いません……何ミスったん?って思ってたら与えられた配列は「1分間の走行距離」なので累積和を取ってませんでした.累積和を取って17分時点でACしました.正直13分は時間を掛けすぎです.
さて,ちょうどメンバがC問題の考察が終わったぐらいの時間であり,そのまま実装担当に交代しました.その間に私はD問題の問題を読んでいました.
D問題概要
障害物が[0, 100]^2の空間に個存在する(
).ある虫(うーん!w)は最初点
におり,
軸正の方向を向いている.この虫は常に向いている方向に進むが,障害物にぶつかると現在の進行方向の左側を向いて再び直進する.さて,この虫は
進んだ際にどの座標にいるか求めよ(
).
デカいなあ……って思っていくつかの案をだしました.
・が小さいので注目するのは障害物の近傍の4点のみで良さそう?
・いやそもそも障害物の存在範囲狭いし,すぐ(高々101 * 101 * 4)にループ突入もしくは発散しそうやな
ということで「頑張るだけ」という結論に至った時点(開始25分)でC問題が通りました.交代し,僕が実装開始し,メンバはE問題の考察に入りました.
D問題実装
まあ沼る未来しか見えないので,ゆっくりでも良いのでせめて自分が迷わないコードを書くと決めて実装開始しました.
(数十分経過)
実装終わったので実行するとなんかエラー落ちしたり微妙に違ったりして苦しいきもちになり,ちょうどメンバもE問題の考察と実装メモが完了したので私は物理プリントデバッグを開始し,実装を交代しました.

まずそもそもxとyの入力の受け取りが間違ってました.また,while文の判定変数更新してないという凡ミスをしていました.E問題が一発ACしてくれたのですぐに交代し,プリントデバッグで見つけた問題点と微妙なミス(ループの距離の処理など)を修正するとサンプルがすべて通ったので,恐怖を感じながら提出したらACでした.実装力をもっと高めたい……!この時点で105分経過しておりました.
さて,完全に実装キューが無くなり,メンバが既にF問題に取り組んでいたので私はG問題に取り組んでいました.
G問題の概要
長さの整数列
が与えられる.長さ
の整数列
であって,以下を満たすものが存在すればその
を出力せよ.
・ (要素ごとの和)
・は整数の多重集合として見ると同じものとなる.
全く歯が立ちませんでした.とりあえず総和が奇数であれば実現不可というのは分かりますが,それ以降は一瞬循環で配置すればいいのでは?と思ってから明後日の方向へ発散していきました.DPという文字が全く頭に出てこなかったので完全に実力不足です.強くなります.
G問題で思ってたこと
例えばA = {1, 3, 7, 9}の時,この配列の平均は5で,最大値9 + 平均5 = 14をXの左端に置く.
= {14,
,
,
}, Y = {-13,
,
,
}
14をYの真ん中の左に置く.埋められる穴を埋める.
= {14, -11,
,
}, Y = {-13, 14,
,
}
-11をYの右端におく.埋められる穴を埋める.
= {14, -11, -13, 20},
= {-13, 14, 20, -11}
出来上がり(結局このことは解説に書いてる「循環」の存在の特別な話であると思う)
G問題は完敗です.残り数十分の時点でF問題の考察に参入すべきではあったと反省してます.
終了数分前にF問題の実装が完了したらしいので投げてみると,WAでした......
チームメンバー「?もっかい出してみるか」
~2WA~
終了!w
終了後
終了後初めて順位表を見て,全体として上の方にいるなあと思った一方で,学内の上位3チームが7位以内に固まっているのをみて横浜へ行く難易度高い~~~って思いました.もっと強くなります.そのあとは懇親会があり,今回参加されていた人々とピザなどを食べながら色々話すことができ,とても良かったです.本当は2次会にも行きたかったのですが終電があったため,そのまま帰宅しました.
最後に
チームでやる競プロはとても楽しかったです.チームメンバーや運営,コーチの方,色々と有難うございました.来年もぜひ参加したいと思います.つい先ほどのABCで大爆散をかましましたが,くじけずに努力していきます;;
順位表,風速
以下に示す通りでした.
順位表:

風速:

なお,風速は以下のサイトを参照しました.
ICPC2022 国内予選 D問題
解説無しで牛歩考察牛歩実装でなんとかAC出来て楽しいのでメモに残す.
問題
以下のリンクのD問題.
icpc.iisf.or.jp
問題のふんわり考察
における単調増加列
・がゲートに入った際に,分割された後のすべてのグループが単調増加列であった場合,入場時にはすべてソートされた順番で入場する.なぜなら,各グループの先頭は各グループの最小値であり続けるためである.
・逆にこのような状況でが単調増加列ではない場合,このような
は実現できない.
における単調減少列
・における単調減少列は,それをそのままグループにするとゲートの先でそのままの順番の列(
に現れる単調減少列そのまま)で入場する.なぜなら先頭の値は後続する値よりも大きいかつ任意の他のグループの先頭より小さいため,先頭の値が選択される時点で次に選ばれるのはそれの後続となるためである.
・言い換えれば,における単調減少列が
において「そのままの順番で」現れる場合,その単調減少列は必ず同じグループに入れられる必要がある.
→ と
の両方に現れる単調減少列は,結局その先頭の文字のみと等価である.
→ と
の両方に現れる単調減少列をともにその先頭の文字で置き換えてもよさそう.
・逆に,に現れない単調減少列は
において実現できない.なぜなら,
に現れない時点で2つのグループが必要となるが,その際に先頭の文字でソートされてしまい,単調減少列とならないためである.
整理
・と
にある単調減少列は先頭の値で置き換えできる.この操作は単調減少列を減らしていく操作とも見える.
・の単調減少列がこれ以上減らせない時に
にまだ単調減少列がある場合,そのような
は実現不可
・の残っている単調減少列をすべてバラにしてみるとこれはまあ単調増加列だなあ.
・のグループがすべて単調増加列である場合は
がソートされた状態になる.
手続き
考察の内容より,以下の処理が浮かぶ.
(1): 以下の処理を繰り返す.
(1.1): における単調減少列の集合を取り出す.
(1.2): において
から取り出した単調減少列の部分列となっている場所を取り出す.これを
と
の共通部分単調減少列と呼ぶ.
(1.3): 共通部分単調減少列の長さがすべて1であれば,がソートされているかを確認する.されていない場合は答えは0である.されている場合はここで処理(1)を終了
(1.4): sとtの共通部分単調減少列を,それぞれの共通部分単調減少の先頭で置き換える.(1.1) にもどる.
(2): に含まれる極大な単調増加列の集合について,各単調増加列の長さを|Gn|とすると,この問題は「Gnを複数個に分割することを繰り返した際に,結果的に分割数が
以下となる場合の数の計算」となります.これはdpにより計算できます.
dp[i][j]: i個めのグループを見たときのj個のグループが存在する分割方法
そうすると,
dp[i+1][j+l] += dp[i][j] * |Gi-1| \mathrm{C}_{l-1}
と遷移できる.
具体例での簡単な確認
Sample1
4 2 3 5 1 → 1 3 4 2 5
まず両者に含まれる共通単調減少列を先頭の値で置き換えて,([4, 2] -> [4])
4 3 5 1 → 1 3 4 5
ここで,はソートされているのでこの
は実現可能.
における極大な単調増加列の集合は,{{4}, {3, 5}, {1}}である.あとはdpでよしなに
Sample5
2 1 4 3 → 1 4 3 2
共通単調減少列を見る.[4, 3] → [4]と置きかえて
2 1 4 → 1 4 2
もっかい共通単調減少列を見る。そんなものはない...だがソートもされていないからには...どうする?!
これは実現不可能であるので0を出力する.
追加
2 6 1 5 3 4 → 2 5 3 4 6 1
共通単調減少列を見る.[6, 1] → [6], [5, 3]→[5]へと置き換える.
2 6 5 4 → 2 5 4 6
もう一度見る.[5, 4] → [5]へと置き換える.
2 6 5 → 2 5 6
はソートされている.
における極大な単調増加列の集合は{{2, 6}, {5}}である.
実装の一部
solveの部分だけ載せておきます(includeなどは載せていません).変な実装ですね
enum class State{ NG, TryAgain, End }; State Find_DecArray_and_erase_if_exists_in_both(int64_t n, vector<int64_t> &s, vector<int64_t> &t) { // Sの[L, L+Num)が単調減少列 // {L, Num} vector<pair<int, int>> S_DecreasingArray; bool isDecreasing = false; int start_decreasing_idx; for(int i = 1; i < n; i++) { if(s[i-1] > s[i]) { if(!isDecreasing) { isDecreasing = true; start_decreasing_idx = i-1; } } else { if(isDecreasing) { isDecreasing = false; S_DecreasingArray.emplace_back(start_decreasing_idx, i - start_decreasing_idx); } } } if(isDecreasing) { S_DecreasingArray.emplace_back(start_decreasing_idx, n - start_decreasing_idx); } // Tに同じ減少列があるかを確認する. // 長さ2以上の連続共通部分列があれば先頭の文字以外を溶解する. bool Exist_DissolveAble = false; // {{Ls, Lt}, Num} // Num : 以下を満たす最大 // S[Ls, Ls+Num) == T[Lt, Lt+Num): 単調減少列 vector<pair<pair<int, int>, int>> MatchLengthList(S_DecreasingArray.size()); for(int i = 0; i < S_DecreasingArray.size(); i++) { // S[L, R)がTにおいてどれほど一致するかを調べる int S_DecArraySize = S_DecreasingArray[i].second; int S_DecArrayBeginIdx = S_DecreasingArray[i].first; int S_MaxMatch_DecStartMatchingIdx; int T_MaxMatch_DecStartMatchingIdx; int MaxMatchLength = 1; // S[Begin, Begin+Size)とT[k, k+Size)を比較する.. for(int k = -S_DecArraySize+1; k < n; k++) { int MatchCount = 0; int S_StartMatchingIdx; int T_StartMatchingIdx; for(int j = 0; j < S_DecArraySize; j++) { if(k + j < 0) { continue; } if(k + j >= n) { break; } if(s[S_DecArrayBeginIdx + j] == t[k + j]) { if(MatchCount == 0) { S_StartMatchingIdx = S_DecArrayBeginIdx + j; T_StartMatchingIdx = k + j; } MatchCount++; } else { if(MatchCount != 0) { break; } } } if(MaxMatchLength <= MatchCount) { S_MaxMatch_DecStartMatchingIdx = S_StartMatchingIdx; T_MaxMatch_DecStartMatchingIdx = T_StartMatchingIdx; MaxMatchLength = MatchCount; } } MatchLengthList[i] = {{S_MaxMatch_DecStartMatchingIdx, T_MaxMatch_DecStartMatchingIdx}, MaxMatchLength}; if(MaxMatchLength > 1) { Exist_DissolveAble = true; } } // 2個以上の要素を持つ共通部分列が存在しない場合,tがソート済みかを確認 if(!Exist_DissolveAble) { if(!is_sorted(t.begin(), t.end())) { return State::NG; } else { return State::End; } } // 溶解処理 // sにおける共通部分列の開始インデックスをソート sort(MatchLengthList.begin(), MatchLengthList.end()); vector<int64_t> new_s; int idx_MatchLength = 0; for(int i = 0; i < n; i++) { new_s.push_back(s[i]); if(idx_MatchLength < MatchLengthList.size() && i == MatchLengthList[idx_MatchLength].first.first) { i += MatchLengthList[idx_MatchLength].second - 1; idx_MatchLength++; } } // tにおける共通部分列の開始インデックスをソート for(auto& d : MatchLengthList) { swap(d.first.first, d.first.second); } sort(MatchLengthList.begin(), MatchLengthList.end()); vector<int64_t> new_t; idx_MatchLength = 0; for(int i = 0; i < n; i++) { new_t.push_back(t[i]); if(idx_MatchLength < MatchLengthList.size() && i == MatchLengthList[idx_MatchLength].first.first) { i += MatchLengthList[idx_MatchLength].second - 1; idx_MatchLength++; } } s = new_s; t = new_t; return State::TryAgain; } const int MAX_C = 10100; mint Com[MAX_C][MAX_C]; void calc_com() { memset(Com, 0, sizeof(Com)); Com[0][0] = 1; for (int i = 1; i < MAX_C; ++i) { Com[i][0] = 1; for (int j = 1; j < MAX_C; ++j) { Com[i][j] = (Com[i-1][j-1] + Com[i-1][j]); } } } vector<int> Output; void solve(int64_t n, int64_t k ,vector<int64_t> &s, vector<int64_t> &t) { State St; while(true) { St = Find_DecArray_and_erase_if_exists_in_both(s.size(), s, t); if(St != State::TryAgain) { break; } } if(St == State::NG) { Output.emplace_back(0); cout << 0 << endl; return; } vector<mint> Groups; mint g_size = 1; for(int i = 1; i < s.size(); i++) { if(s[i-1] > s[i]) { Groups.push_back(g_size); g_size = 1; } else { g_size++; } } Groups.push_back(g_size); vector<vector<mint>> dp(Groups.size() + 1, vector<mint> (k+1)); vector<vector<bool>> isValid(Groups.size() + 1, vector<bool> (k+1, false)); isValid[0][0] = true; dp[0][0] = 1; for(int i = 0; i < Groups.size(); i++) { for(int j = 0; j < k; j++) { for(int l = 1; l <= Groups[i].val(); l++) { if(j + l > k) { break; } if(isValid[i][j]) { dp[i+1][j+l] += dp[i][j] * Com[Groups[i].val()-1][l-1]; isValid[i+1][j+l] = true; } } } } mint Ans = 0; for(int i = 0; i <= k; i++) { Ans += dp[Groups.size()][i]; } cout << Ans.val() << endl; Output.push_back(Ans.val()); }
なお,二項係数の実装は以下の記事から引用してます.
CUDA C++でNeRFをほぼ0から実装してみた(Part3/3): NeRF編
NeRF編: 概要
これまでに説明したMLP,エンコーダーに加えて,ボリュームレンダリング(RGB)とその誤差逆伝播,さらにはOccupancy Gridに関する私の実装を説明し,最終的にどのようにNeural Radiance Fieldsが形成されていくかを確認します.
NeRF編: はじめに
Part1, Part2では下準備としてMLPとMultiresolutioin Hash Encoding, Spherical Harmonic Encodingを実装しました.Part3では本格的にNeRFの実装に入っていきます.Part2の最後に画像の近似を行いましたが,あれは2次元平面内部におけるRGB色(RGB場とでも言いましょうか)を近似していくものでした.2次元画像近似は非常に簡単に行えましたが,3次元となった場合はなかなかに複雑化します.それは3次元の状態をそもそも可視化する手法が一筋縄ではいかないためです.色々な手法が提案されてきましたが,その1つにNeRF [Mildenhall et al, 2020]があります.これは煙などの媒質の表現に用いられるボリュームレンダリングを利用したもので,その媒質に関するパラメーターを最適化することにより,空間内部の媒質の状態を近似するというものです.2022年にNVIDIAの発表したInstant NeRFにおいてはOccupancy Gridというものが導入され,それによってボリュームレンダリングのサンプリング効率の向上が実現されました.ではそれぞれの実装を説明していきます.(空間内部の「状態」を近似と書いているのは,空間内部に存在する「物体表面の再構築」と区別するためですが,Occupancy Gridを使っている以上,結局NeRFも後者を行っているのですかね......)
念のため......
内容には注意はしておりますが,記事に誤り等があれば指摘していただけると幸いです.
NeRFの手続き
NeRFと聞くと身構えてしまいそうですが,実はそこまで複雑なことはしていません.まずはざっくりと雰囲気を書くと,
(0): カメラと画像のセットを用意する
(1): カメラから画像のピクセルに対応する方向にレイ(光線)を飛ばす
(2): レイの経路上の点をサンプリング
(3): サンプル点の座標,そしてレイの向きをニューラルネットワーク(NN)に入力
(4): NNを実行し,サンプル点における「色」と「密度」を計算
(5): 得られたサンプル点の情報からボリュームレンダリング方程式を計算
(6): 計算結果と対応するピクセルの色を比較し,誤差を計算
(7): ボリュームレンダリングの処理を逆方向に行う
(8): NNを誤差逆伝播
(9): NN最適化
(10): Occupancy Gridの最適化
となっております.こういう書き方をしているので順番から説明すべきではありますが,実装の説明をする前にボリュームレンダリングについて軽く触れておきます.
ボリュームレンダリング
NeRFではRGBレンダリングとしてボリュームレンダリングを行います.といってもパストレーシングでやるようなランダムウォーク的な経路追跡をするわけではなく, 単純にレイ(半直線)上のランダムにサンプリングされたサンプル点における放射輝度の変化を追うものです(また,距離関数を使用したレイマーチングではありません.).本来はちゃんと導出するのが良いですし,そもそも光線としての光(幾何光学)の妥当性を書くべきだとは思うのですが,今回はそれを他の記事に任せてフロントエンドだけ書きます.
今回は次の現象を扱います.
・媒質の吸収による放射輝度の減衰
・媒質の散乱による放射輝度の減衰
・媒質の発光(emission)や散乱(in-scattering)による放射輝度の増加
レイ上のある異なる2点を,カメラから近い順にt_near,t_far とします.この時, t_near, t_far における媒質によるカメラに入射するレイの放射輝度への関与は
ここで,はカメラからレイ上のある点までの距離で,
はその点の座標,つまりレイの原点と向きをそれぞれ
として,
です.そして,
はその点における媒質の「密度」で,
はその点における媒質の「色」です.より正確に書くと,「密度」は光学的な消散係数にあたり,「色」は媒質内の粒子における散乱(in-scattering)や発光(emission)による放射輝度への増加に関わるパラメーターにあたります.図に描くとこんな感じのイメージです.

カメラ側から見た時,媒質の「密度」が十分大であれば媒質の「表面」しか見ることが出来ません.これは「表面」より向こう側の放射輝度によるカメラに入射するレイへの寄与が0であるためです.逆に,「密度」が非常に小さい場合,媒質の向こう側が透過して見えます.イイ感じにパラメータを設定してあげると現実世界の空間をある程度近似できそうだというのは何となく想像できます.
計算機でボリュームレンダリングを行う
先ほど示した積分を計算機上で解くためには,理想はレイを無限に分割してやるのが良いのですが,そんなことは出来ないため,有限のサンプル点を使用して近似的に積分を行います.

先ほどの式を離散的に書き直すと図の中に示す式になります.理論的な計算は省略します.["Optical Models for Direct Volume Rendering", Nelson Max, 1995]
このは先ほどの積分の式と同じく「どれだけその点におけるレイの放射輝度がカメラに入るレイの放射輝度に寄与しているか」を表します.さて,このように有限のサンプル点をサンプリングしてあげる必要があります.レンダリングの詳しい実装は後ほど行います.
NeRFの実装: (0): カメラと画像のセットを用意する
さて,やっていきましょう.ただロードすればいいと言われればそうなのですが,学習データと教師データのセットを意識してデータを保持してあげると取り扱いが容易になります.今回の実装においては,最初に画像と対応するカメラの姿勢を一気に全部ホスト側のメモリ上にロードします.そして,それをもとにして,「(画像ID, ピクセル座標)- ピクセルの色」を入力-教師のペアとして構造体にしておきます.次の構造体を書きました.
// 各スレッドではこの構造体1つにしかアクセスしないのでAoSの方が良いと考えた // [31:21]: PixIndexX // [20:10]: PixIndexY // [9:0] : ImageID struct PixelInfo { //int ImageID; //int PixIndexX; //int PixIndexY; unsigned int PixInfo = 0; Vec3h Color; MNPT_HOST_DEVICE PixelInfo(unsigned int PixInfo = 0) : PixInfo(PixInfo), Color() {} MFFM_HOST void SetUp(unsigned int ImID, unsigned int IndexX, unsigned int IndexY, Vec3h PixColor) { if (ImID >= 1024) { printf("Too large ImageID was set to PixelInfo\n"); exit(1); } if (IndexX >= 2048) { printf("Too large ImageWidth was set to PixelInfo\n"); exit(1); } if (IndexY >= 2048) { printf("Too large ImageHeight was set to PixelInfo\n"); exit(1); } PixInfo = 0; PixInfo = PixInfo | (IndexX << 21) | (IndexY << 10) | ImID; Color = PixColor; } MFFM_HOST void SetUp(int ImID, int IndexX, int IndexY, __half* PixColor) { if (ImID >= 1024) { printf("Too large ImageID was set to PixelInfo\n"); exit(1); } if (IndexX >= 2048) { printf("Too large ImageWidth was set to PixelInfo\n"); exit(1); } if (IndexY >= 2048) { printf("Too large ImageHeight was set to PixelInfo\n"); exit(1); } PixInfo = 0; PixInfo = PixInfo | (IndexX << 21) | (IndexY << 10) | ImID; Color.from_half(PixColor); } MFFM_HOST_DEVICE unsigned int PixIndexX() { unsigned int ret = (PixInfo >> 21); // [31:21] return ret; } MFFM_HOST_DEVICE unsigned int PixIndexY() { unsigned int ret = ((PixInfo & 0x001FFC00) >> 10); // [20:10] return ret; } MFFM_HOST_DEVICE unsigned int ImageID() { unsigned int ret = (PixInfo & (0x000003FF)); // [9:0] return ret; } };
ただ単純に入れているだけなのですが,メモリ節約のために32bitの変数を切り分けて情報を保存しておきました.画像IDには10bit,画像上のピクセル座標にはそれぞれ11bitを使用しています.また,Vec3hという,半精度小数点を3個内蔵するベクトル構造体に色の情報を保存しておきます.Vec3やVec3hに関する具体的な説明はのちに行います.画像IDからカメラの姿勢にアクセスすることができ,そしてピクセル座標からカメラから飛ばすレイ(後述します)を計算でき,そして色の情報から教師データにアクセスできる,というビジョンです.ちなみにレイ-ピクセルの色というペアだとダメなのかという問いがあり得ますが,GPU上のメモリ(10GB)に載りませんでした.
このようにしてロードしたデータをGPU上に送り,学習時にはシャッフルを施して多様なレイを学習時に与えることを考えます.このあたりの処理はthrustライブラリの力を借りました.ここが「ほぼ0から実装してみた」になっている要因の一つです.
... thrust::default_random_engine g; thrust::shuffle(D_thrust_TargetPixelInfo.begin(), D_thrust_TargetPixelInfo.end(), g); ... if (IndexOfPixelInfo + nMaxPixelPerBatch > D_thrust_TargetPixelInfo.size()) { thrust::shuffle(D_thrust_TargetPixelInfo.begin(), D_thrust_TargetPixelInfo.end(), g); IndexOfPixelInfo = 0; } ...
全部のピクセルを1イテレーションで全て入力するなんてことはしません.nMaxPixelPerBatchで一回のイテレーションで使用するピクセル数を決めておきます.そして,前シャッフルされてから現在までに使用されているピクセル数をIndexOfPixelInfoで保持しておきます.このif文がおこなっていることは,前回シャッフルしてから初めて,未使用のピクセル数がnMaxPixelPerBatch未満であれば再びシャッフルを行う,ということです.つまりはシャッフルしなおしているだけです.図を置いておきます.

NeRFの実装: (1): カメラから画像のピクセルに対応する方向にレイ(光線)を飛ばす
私たちは光を直線として扱う,すなわちレイ(光線)という概念に馴染みきっております.カメラからレイを飛ばすと言いますが,実際にはカメラに対応する方向から入射してくる光の経路を追っているという方が正しいでしょう.結局何が言いたいかというと,カメラから目的のピクセルの場所に半直線をのばし,その半直線を光路としてカメラに入射してくるレイの放射輝度を求めたいです.なので,その半直線がいかなるものであるかを計算する必要があります.今回は単純にピンホールカメラを考えましょう.

まあCGソフトでよく見る感じのカメラではないでしょうか.基本姿勢としてはカメラの原点(CamOrg),カメラの向いている方向(CamDir),カメラの上向き(CamUp)となります.そして,原点からCamDir方向にCamToScreenDistだけ離れた場所にスクリーンがあると考えてください.スクリーン上の点と,その点を通ってカメラの原点に入り込むレイは一対一に対応します(レイが直線であるため).このようなレイの持つ放射輝度がカメラの写す画となります(細かい話は置いておきます).では,そのようなレイを求めます.図のようにカメラの原点から出てスクリーン上の座標(IndexX, IndexY)を通る半直線(橙の点線)は,まずスクリーンの中央に伸ばしたベクトル(赤色の矢印)にスクリーン上でのX方向(緑色の矢印),Y方向のベクトル(青色の矢印)を足し合わせてあげることで求まります.
では実装するうえで便利な構造体を置いておきましょう.
Vec3構造体
名前の通り,浮動小数点の3次元ベクトルを処理するための構造体です.
struct Vec3 { float x, y, z; float align; KGYK_HOST_DEVICE Vec3(const float x = 0.0f, const float y = 0.0f, const float z = 0.0f, const float align = 0.0f) : x(x), y(y), z(z), align(align) {} KGYK_HOST_DEVICE Vec3 operator+(const Vec3& b) const { return { x + b.x, y + b.y, z + b.z }; } KGYK_HOST_DEVICE Vec3 operator-(const Vec3& b) const { return { x - b.x, y - b.y, z - b.z }; } KGYK_HOST_DEVICE Vec3 operator*(const float& b) const { return { x * b, y * b, z * b }; } KGYK_HOST_DEVICE Vec3 operator/(const float& b) const { return { x / b, y / b, z / b }; } KGYK_HOST_DEVICE float length_squared() const { return x * x + y * y + z * z; } KGYK_HOST_DEVICE float length() const { return sqrtf(length_squared()); } // Vec3 to float* KGYK_HOST_DEVICE void to_float(float* Dst) { Dst[0] = x; Dst[1] = y; Dst[2] = z; } KGYK_HOST_DEVICE void from_float(float* Src) { x = Src[0]; y = Src[1]; z = Src[2]; } KGYK_HOST_DEVICE float operator[](size_t idx) { return *((float*)this + idx); } KGYK_HOST_DEVICE float at(size_t idx) { if (idx == 3) printf("WARNING: Accessing to Padding Area of Vec3 in Vec3::at()\n"); if (idx > 3) printf("ERROR: Out-of-range access in Vec3::at()\n"); return *((float*)this + idx); } }; KGYK_HOST_DEVICE Vec3 operator*(float t, const Vec3 v) { return v * t; } KGYK_HOST_DEVICE bool operator==(const Vec3 a, const Vec3 b) { return (a.x == b.x && a.y == b.y && a.z == b.z); } KGYK_HOST_DEVICE bool operator!=(const Vec3 a, const Vec3 b) { return (a.x != b.x || a.y != b.y || a.z != b.z); } // 長さ1に正規化する KGYK_HOST_DEVICE inline Vec3 normalize(const Vec3& b, const char* text = "") { if (b.length() == 0) { printf("Vec3 Warning: 0-size vector normalization. %s\n", text); return { 10000, 0, 0 }; } return Vec3(b / b.length()); } // AABB内部のベクトルbについて,そのAABBを[0,1]^3と正規化したときのベクトルbを求める. KGYK_HOST_DEVICE inline Vec3 normalize_by_range(const Vec3& b, Vec3& pmin, Vec3& pmax) { Vec3 ret; ret.x = normalize(b.x, pmin.x, pmax.x, 0.0f, 1.0f); ret.y = normalize(b.y, pmin.y, pmax.y, 0.0f, 1.0f); ret.z = normalize(b.z, pmin.z, pmax.z, 0.0f, 1.0f); return ret; } KGYK_HOST_DEVICE inline const Vec3 multiply(const Vec3& v1, const Vec3& v2) { return Vec3(v1.x * v2.x, v1.y * v2.y, v1.z * v2.z); } KGYK_HOST_DEVICE inline const Vec3 divide(const Vec3& v1, const Vec3& v2) { return Vec3(v1.x / v2.x, v1.y / v2.y, v1.z / v2.z); } KGYK_HOST_DEVICE inline float dot(const Vec3& v1, const Vec3& v2) { return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z; } KGYK_HOST_DEVICE inline float absdot(const Vec3& v1, const Vec3& v2) { return std::abs(dot(v1, v2)); } KGYK_HOST_DEVICE inline Vec3 cross(const Vec3& v1, const Vec3& v2) { return Vec3( (v1.y * v2.z) - (v1.z * v2.y), (v1.z * v2.x) - (v1.x * v2.z), (v1.x * v2.y) - (v1.y * v2.x)); } KGYK_HOST_DEVICE inline const Vec3 exponential(const Vec3& v) { return Vec3(exp(v.x), exp(v.y), exp(v.z)); } // "TextBefore v.x v.y v.z TextAfter\n" KGYK_HOST_DEVICE inline void printVec3(const Vec3& v, const char* TextBefore = "", const char* TextAfter = "") { printf("%s %f %f %f %s\n", TextBefore, v.x, v.y, v.z, TextAfter); }
KGYK_HOST_DEVICE……等はCUDAのhostやdevice等の修飾子です.特に書くこともないですので説明は省略します.ちなみにメンバ変数のalignは構造体サイズの32byteアラインメントです.Vec3hはこれらを半精度で行う構造体です.
Ray構造体
レイの構造体です.
/* * レイの情報を持つ構造体 * * dir: レイの方向ベクトル * org: レイの原点位置ベクトル */ struct NeRFRay { Vec3 dir; Vec3 org; int nSample; int SampleBeginIdx; float tmin, tmax, pad1, pad2; MNPT_HOST_DEVICE NeRFRay(const Vec3& dir = {1,0,0}, const Vec3& org = {0,0,0}) : org(org), dir(dir) {} };
nSample,SampleBeginIdx, tmin, tmaxはこの後使用する際に説明します.
Camera構造体
さて,ではカメラの実装に取り掛かりましょう.
struct NeRFCamera { size_t Image_height; size_t Image_width; Vec3 CamOrg; Vec3 CamDir; float CamToScreenDist; Vec3 ScreenXDir; Vec3 ScreenYDir; float PixelSize = 0.0f; MNPT_HOST_DEVICE NeRFCamera(const Vec3& CamDir, const Vec3& CamOrg, const float& CamToScreenDist) : Image_height(0), Image_width(0), CamDir(CamDir), CamOrg(CamOrg), CamToScreenDist(CamToScreenDist) {} // 指定されたカメラの情報からカメラの姿勢を求める MNPT_HOST void SetUp(float rotateX, float rotateY, float rotateZ) { const Vec3 CamUp = rotate(DEG2RAD(rotateX), DEG2RAD(rotateY), DEG2RAD(rotateZ), normalize({ 0.0, 1.0, 0.0 })); CamDir = rotate(DEG2RAD(rotateX), DEG2RAD(rotateY), DEG2RAD(rotateZ), normalize({ 0.0, 0.0, -1.0 })); ScreenXDir = cross(CamDir, CamUp); ScreenYDir = CamUp; PixelSize = 36.0f / (float)Image_height; } MNPT_HOST void SetUp(const size_t InputImage_height, const size_t InputImage_width, std::vector<std::vector<float>>& TransformMatrix, float FOVY) { // 入力画像のサイズをカメラに記録 Image_height = InputImage_height; Image_width = InputImage_width; // 変換行列によりカメラの姿勢を計算 Vec3 CamUp = rotate(TransformMatrix, normalize({ 0.0, 1.0, 0.0 })); CamDir = rotate(TransformMatrix, normalize({ 0.0, 0.0, -1.0 })); std::vector<std::vector<float>> Pos(4, std::vector<float>(1)); std::vector<std::vector<float>> Base{{0.0f}, { 0.0f }, { 0.0f }, { 1.0f }}; Pos = multiply_matrix(TransformMatrix, Base); CamOrg = { Pos[0][0], Pos[1][0], Pos[2][0] }; float top = 18.0f; ScreenYDir = CamUp; ScreenXDir = cross(CamDir, CamUp); PixelSize = 2 * top / (float)Image_height; CamToScreenDist = top / tanf(FOVY/2.0f); } MNPT_HOST void SetUp(Camera* Cam) { Image_height = ImSizeY; Image_width = ImSizeX; CamOrg = Cam->CamOrg; CamDir = Cam->CamDir; CamToScreenDist = Cam->CamToScreenDist; ScreenXDir = Cam->ScreenXDir; ScreenYDir = Cam->ScreenYDir; PixelSize = Cam->PixelSize; } // 画像上のピクセルインデックス{IndexX, IndexY}に対してのカメラからの1次レイを求める MNPT_DEVICE inline NeRFRay Generate1stRay(const int IndexX, const int IndexY) { Vec3 RayDir = CamDir * CamToScreenDist + ScreenXDir * ((float)IndexX - Image_height / 2.0f) * PixelSize + ScreenYDir * (Image_width / 2.0f - (float)IndexY) * PixelSize; RayDir = normalize(RayDir, "FirstRayGen"); return {RayDir, CamOrg}; } };
これは多少見ておきましょう.
struct NeRFCamera { size_t Image_height; size_t Image_width; Vec3 CamOrg; Vec3 CamDir; float CamToScreenDist; Vec3 ScreenXDir; Vec3 ScreenYDir; float PixelSize = 0.0f; ...
Image_heightやImage_widthはこのカメラの映し出す画像のサイズを指します.もっと言えばスクリーンのアスペクトを定義すると言っても良いでしょう.
PixelSizeはスクリーン上のピクセルサイズです.実はスクリーンのY方向のサイズを36に固定しております.つまり,PixelSizeは36/Image_heightとなります.この36という値は特に意味はなく,色々試した結果イイ感じの値というだけです.
後に続くSetUp関数は入力されたカメラの姿勢からメンバ変数を計算してあげる関数です.説明は省略しますが,rotate()は回転行列を作用させる関数です.
では本題のレイの計算に入ります.
// 画像上のピクセルインデックス{IndexX, IndexY}に対してのカメラからの1次レイを求める MNPT_DEVICE inline NeRFRay Generate1stRay(const int IndexX, const int IndexY) { Vec3 RayDir = CamDir * CamToScreenDist + ScreenXDir * ((float)IndexX - Image_height / 2.0f) * PixelSize + ScreenYDir * (Image_width / 2.0f - (float)IndexY) * PixelSize; RayDir = normalize(RayDir, "FirstRayGen"); return {RayDir, CamOrg}; }
図中に示した矢印を赤緑青と上から順番に加算していることが分かると思います.細かいことを言えばピクセルの中心に当てるためには多少調整がいるのですが,まあしなくても良いです.計算したレイの長さを1に正規化してあげるのを忘れないようにしましょう.さて,ピクセル座標が分かればレイを計算できるようになりました.ここで先ほど入力データと教師データの対の構造体をつくっていました.ではこの構造体からレイを計算してあげましょう.
// カメラの情報からレイのバッチを作成する. // これによって(レイ -- ピクセル色)という(入力データ--教師データ)の構造が構築される __global__ void NeRF_GenerateRay(const int nPixel, NeRFCamera* Cam, PixelInfo *PixInfo, NeRFRay* GeneratedRay) { int PixelId = blockIdx.x * blockDim.x + threadIdx.x; if (PixelId >= nPixel) { return; } PixelInfo PixelInfo_this_thread = PixInfo[PixelId]; int ImageId = PixelInfo_this_thread.ImageID(); int PosX = PixelInfo_this_thread.PixIndexX(); int PosY = PixelInfo_this_thread.PixIndexY(); // このピクセルにおけるRay NeRFRay Ray = Cam[ImageId].Generate1stRay(PosX, PosY); GeneratedRay[PixelId] = Ray; }
細かく見ましょう.
__global__ void NeRF_GenerateRay(const int nPixel, NeRFCamera* Cam, PixelInfo *PixInfo, NeRFRay* GeneratedRay) { ...
nPixel: 1イテレーションで使用するピクセル数です.さっき書いたnMaxPixelPerBatchがこれにあたります.
Cam: カメラ配列です.i番目の要素は画像IDがiの画像を撮影したカメラ情報を持つ(NeRF)Camera構造体です.
PixInfo: (画像ID, ピクセル座標)- (教師画像の色)を対として持つ構造体です.
GeneratedRay: 計算されたレイが格納される場所
... int PixelId = blockIdx.x * blockDim.x + threadIdx.x; if (PixelId >= nPixel) { return; } ...
いつものやつです.スレッド数がnPixelを溢れた分は帰らせる処理です.
... PixelInfo PixelInfo_this_thread = PixInfo[PixelId]; int ImageId = PixelInfo_this_thread.ImageID(); int PosX = PixelInfo_this_thread.PixIndexX(); int PosY = PixelInfo_this_thread.PixIndexY(); // このピクセルにおけるRay NeRFRay Ray = Cam[ImageId].Generate1stRay(PosX, PosY); GeneratedRay[PixelId] = Ray; ...
スレッドIDに対応するPixelInfoにアクセスし,情報を読み出し,それを元にしてレイを計算しています.
なお,先程のシャッフルの際に「すべてのピクセルを1イテレーションでは使用しない」と書いていましたが,イテレーションに関わらずPixelInfoへのアクセスインデックスが0以上nPixel未満となっており,怪しそうですが,カーネル実行時に次のような引数を与えてPixelInfoポインタの先頭をずらしています.
// レイを計算する NeRF_GenerateRay <<<nMaxPixelPerBatch / 512, 512, 0, stream_train >>> ( nMaxPixelPerBatch, D_NeRFCAM, thrust::raw_pointer_cast(&D_thrust_TargetPixelInfo[IndexOfPixelInfo]), thrust::raw_pointer_cast(&D_RayBatch[0]) ); gpuErrchk(cudaGetLastError()); gpuErrchk(cudaStreamSynchronize(stream_train));
IndexOfPixelInfoが何たるやは先ほどPixelInfoの説明をした際のシャッフルの話で記述した通りです.また,thrust::raw_pointer_cast()はthrust::device_vectorのデバイスメモリ上でのポインタを返してくれる嬉しい関数です.
NeRFの実装: (2): レイの経路上の点をサンプリング
さあ,下準備も済んでようやくNeRFらしくなってきました.レイの経路上の点をサンプリングするうえではサンプリングする領域を決める必要があります.NeRFを生成する空間をAABB(Axis-Aligned-Bounding-Box),つまりxyz軸に辺がそれぞれ平行である直方体,で定義してあげると計算が楽です.言わずもがなですが,サンプリングする領域はレイの経路の内,このAABB内部にある線分でしょう.ではAABBとレイの交差判定を行いましょう.そのために,AABBの構造体が必要です.
struct NeRFAABB { Vec3 PosMin; Vec3 PosMax; MNPT_HOST_DEVICE NeRFAABB(const Vec3 PosMin = Vec3(SINF, SINF, SINF), const Vec3 PosMax = Vec3(BINF, BINF, BINF)) : PosMin(PosMin), PosMax(PosMax) {} MNPT_DEVICE bool willIntersectWithAABB(NeRFRay& Ray) { float t_max = 1e16f; float t_min = -1e16f; #pragma unroll for (int i = 0; i < 3; i++) { float t1, t2, t_near, t_far; if (i == 0) { if (abs(Ray.dir.x) < 1e-5) { // x軸に垂直な平面上のレイを飛ばす場合 if (PosMin.x > Ray.org.x || PosMax.x < Ray.org.x) return false; else continue; } t1 = (PosMin.x - Ray.org.x) / Ray.dir.x; t2 = (PosMax.x - Ray.org.x) / Ray.dir.x; } else if (i == 1) { if (abs(Ray.dir.y) < 1e-5) { // Y軸に垂直な平面上のレイを飛ばす場合 if (PosMin.y > Ray.org.y || PosMax.y < Ray.org.y) return false; else continue; } t1 = (PosMin.y - Ray.org.y) / Ray.dir.y; t2 = (PosMax.y - Ray.org.y) / Ray.dir.y; } else { if (abs(Ray.dir.z) < 1e-5) { // Z軸に垂直な平面上のレイを飛ばす場合 if (PosMin.z > Ray.org.z || PosMax.z < Ray.org.z) return false; else continue; } t1 = (PosMin.z - Ray.org.z) / Ray.dir.z; t2 = (PosMax.z - Ray.org.z) / Ray.dir.z; } t_near = min(t1, t2); t_far = max(t1, t2); t_max = min(t_max, t_far); t_min = max(t_min, t_near); if (t_min > t_max) return false; } Ray.tmin = max(t_min, 1e-4f); Ray.tmax = t_max; return true; } };
Part2でも示しましたが,AABBを定義するにはすべての軸において座標が小さい方の点の座標,そしてその対角点(すべての軸において座標が大きい方の点)の座標があれば十分です.これがPosMinとPosMaxです.さて,肝心の交差判定の具体的な説明は以下の記事に任せて省略します.
さて,先程(NeRF)Ray構造体のところでメンバ変数にtmin, tmaxとありました.これはレイの経路においてAABB内部にある,レイの原点からの距離tの最小値と最大値を保存します.私の実装における
... Ray.tmin = max(t_min, 1e-4f); Ray.tmax = t_max; ...
がそれです.ちなみにt_minが0や負になってほしくはないのでその場合は微小値を入れておきます.図で描くと次の感じです.

CMOSインバーターみたいなのはカメラです.t_min や t_maxが無い場合はそのレイに関してはサンプリングする必要がありません.
さて,サンプリングする領域が求まったのでサンプリングしていきましょう.サンプリングする処理を見ていきましょう.
// サンプル点を求める /* * ミニバッチに関する処理 * Rays: 関数内部でインデックス調整(入力には[0]を先頭としたポインタを渡す) * Pos: 関数内部でインデックス調整(入力には[0]を先頭としたポインタを渡す) * Dir: 関数内部でインデックス調整(入力には[0]を先頭としたポインタを渡す) */ __global__ void NeRF_GenerateSample ( const uint32_t nMaxBatch, const uint32_t nMaxPixelBatch, NeRFInfo* SamplingInfo, NeRFRay* Rays, float* Pos, float* Dir, OccupancyGrid* Grids, const uint32_t epoch ) { uint32_t threadID = blockIdx.x * blockDim.x + threadIdx.x; if (threadID >= nMaxPixelBatch) { return; } // レイ上の点をサンプリングする NeRFRay* Ray = &Rays[threadID]; const Vec3 AABBMin = { SamplingInfo->AABB_pMin.x,SamplingInfo->AABB_pMin.y,SamplingInfo->AABB_pMin.z }; const Vec3 AABBMax = { SamplingInfo->AABB_pMax.x,SamplingInfo->AABB_pMax.y,SamplingInfo->AABB_pMax.z }; uint32_t nAcceptedSample = 0; // AABBとの交差判定 NeRFAABB NeRFBox = { AABBMin, AABBMax}; if (!NeRFBox.willIntersectWithAABB(*Ray)) { Ray->nSample = 0; return; } float t_min = Ray->tmin; float t_max = Ray->tmax; const float dt = (t_max - t_min) / NERF_MAX_SAMPLE_PER_RAY; // 0になった場合プログラムが停止してしまうため if (dt < SINF) { Ray->nSample = 0; return; } // RNG curandState state, state_old, state_firstAccept; curand_init(epoch, threadID, 0, &state); // Uniform sampling int index = 0; int index_firstAccept = 0; constexpr float Throughput_thres = 0.01f; float Throughput_hat = 1.0f; // これがThroughput_thresを下回れば中断 Vec3 LastSamplePos; // 前半 //////////////////////////////////////////////////////////////////////////////////////////////////////// // とりあえずどのレイがどれぐらいサンプル点を取るかを求める // それによってMLPへの入力データのどの領域にサンプル点の情報が保存されるかを計算する. while (nAcceptedSample < NERF_MAX_SAMPLE_PER_RAY) { const float rnd = 0.5f;// curand_uniform(&state); float t1 = t_min + SINF + dt * index; float t2 = t_min + SINF + (dt * (index+1)); float t = normalize(rnd, 0.0f, 1.0f, t1, t2); Vec3 s_Pos = Ray->org + Ray->dir * t; // AABBの領域に収める if (t > t_max) { break; } // 念のため s_Pos.x = clamp(s_Pos.x, AABBMin.x + SINF, AABBMax.x - SINF); s_Pos.y = clamp(s_Pos.y, AABBMin.y + SINF, AABBMax.y - SINF); s_Pos.z = clamp(s_Pos.z, AABBMin.z + SINF, AABBMax.z - SINF); // Occupancy Gridにおいて「物体が無い」と判断された場合はスキップする if (epoch > 16 && !Grids[0].is_Occupied(s_Pos)) { index++; continue; } else { if (nAcceptedSample == 0) { index_firstAccept = index; } nAcceptedSample++; } // Throughputの更新 if (epoch > 32 && nAcceptedSample >= 2) { Vec3 FromLastSample = { s_Pos.x - LastSamplePos.x, s_Pos.y - LastSamplePos.y, s_Pos.z - LastSamplePos.z }; Throughput_hat *= expf(-1.0f * Grids[0].Float_at(s_Pos) * FromLastSample.length()); } // それ以降のサンプル点の寄与の見込みが小さい場合は打ち切る if (Throughput_hat < Throughput_thres) { break; } //state_old = state; LastSamplePos = s_Pos; index++; } // サンプリングが完了した // Dir配列とOrg配列にサンプル情報を記録する(配列上で連続となるようにする) // 格納先の配列におけるインデックス範囲を記録 : [begin, end] int begin_idx = atomicAdd(&SamplingInfo->nSamples, nAcceptedSample); Ray->nSample = nAcceptedSample; Ray->SampleBeginIdx = begin_idx; // 後半 /////////////////////////////////////////////////////////////////////////////////////////////////// // サンプルの保存インデックスがMaxBatchを超えている場合はそのレイを捨てる if (begin_idx + nAcceptedSample >= nMaxBatch) { Ray->nSample = 0; atomicAdd(&SamplingInfo->nSamples, -nAcceptedSample); return; } atomicAdd(&SamplingInfo->RayNum, 1); // はじめてサンプルを得たところまでスキップ index = index_firstAccept; state = state_firstAccept; nAcceptedSample = 0; Throughput_hat = 1.0f; while (nAcceptedSample < NERF_MAX_SAMPLE_PER_RAY) { const float rnd = 0.5f;//curand_uniform(&state); float t1 = t_min + SINF + dt * index; float t2 = t_min + SINF + (dt * (index+1)); float t = normalize(rnd, 0.0f, 1.0f, t1, t2); Vec3 s_Pos = Ray->org + Ray->dir * t; // AABBの領域に収める if (t > t_max) { break; } // 念のため s_Pos.x = clamp(s_Pos.x, AABBMin.x + SINF, AABBMax.x - SINF); s_Pos.y = clamp(s_Pos.y, AABBMin.y + SINF, AABBMax.y - SINF); s_Pos.z = clamp(s_Pos.z, AABBMin.z + SINF, AABBMax.z - SINF); // Occupancy Gridにおいて「物体が無い」と判断された場合はスキップする if (epoch > 16 && !Grids[0].is_Occupied(s_Pos)) { index++; continue; } else { Ray->dir.to_float(Dir + 3 * (begin_idx + nAcceptedSample)); s_Pos.to_float(Pos + 3 * (begin_idx + nAcceptedSample)); nAcceptedSample++; } // Throughputの更新 if (epoch > 32 && nAcceptedSample >= 2) { Vec3 FromLastSample = { s_Pos.x - LastSamplePos.x, s_Pos.y - LastSamplePos.y, s_Pos.z - LastSamplePos.z }; Throughput_hat *= expf(-1.0f * Grids[0].Float_at(s_Pos) * FromLastSample.length()); } // それ以降のサンプル点の寄与の見込みが小さい場合は打ち切る if (Throughput_hat < Throughput_thres) { break; } LastSamplePos = s_Pos; index++; } }
さて,とてつもなく長いコードが出てきました.部分で見ていきましょう.
__global__ void NeRF_GenerateSample ( const uint32_t nMaxBatch, const uint32_t nMaxPixelBatch, NeRFInfo* SamplingInfo, NeRFRay* Rays, float* Pos, float* Dir, OccupancyGrid* Grids, const uint32_t epoch ) { ...
nMaxBatch: すべてのレイに対してサンプリングする点の総和の上限(NNに入力するバッチサイズの上限)
nMaxPixelBatch: ピクセル数の上限(このイテレーションで考慮するレイの本数)
NeRFInfo* SamplingInfo: サンプリングに関わる情報を記録する構造体
Rays: レイが保存されている配列
Pos, Dir: サンプル点における座標とレイの方向を記録する配列
Grids: Occupancy Gridの情報(最後に説明します)
nMaxPixelBatchとnMaxBatchが両方ある理由ですが,実際の処理においては本当にnMaxBatchだけサンプル点をサンプリングするわけではありません.この話は今回のNeRFのコード設計に大きくかかわっていることです.具体的な理由はこの後で説明します.
さて,ここで出てきた構造体NeRFInfoについて書いておきます.
struct NeRFInfo { uint32_t nSamples = 0; uint32_t RayNum = 0; float3 AABB_pMin = { -1.0f, -1.0f, -1.0f }; float3 AABB_pMax = { 1.0f, 1.0f, 1.0f }; // GUI uint32_t epoch = 0; float Loss = 0.0f; uint32_t BatchSize = 0; uint32_t nRay = 0; bool StopTrain = false; bool StopRender = false; };
nSamplesはすぐ後で説明します
RayNum: 最終的に使用することとなったレイの本数(詳しいことはすぐ後で説明します)
AABB_pMin, AABB_pMax: NeRFを生成するAABBの設定です.
これ以降のメンバ変数はNeRFの実装に直接かかわってはこないので省略します.(GUI上で情報を得るため等の為の変数です)
さて,さっきから「すべてをサンプリングしない」とか「最終的に使用することとなったレイの本数」とかいう理解を拒ませる変なことを言ってますが,さっさと明らかにしておきます.今回の関数は「各レイにおけるサンプル数を動的に決定する」実装を行っています.具体的な処理の流れを確認しましょう.まず実装の上で次を前提としています.

各レイはNERF_MAX_SAMPLE_PER_RAYという即値よりも多くサンプル点をサンプリングすることを許されません.しかし,すべてのレイがNERF_MAX_SAMPLE_PER_RAYもサンプル点を必要としているわけではありません.例えば,次の図に示すような場合,レイに対するサンプル数が少なくて済みます.

さらに,MLPに入力できるバッチサイズの上限をnMaxBatchとして,具体的には221として固定しております.また,学習の速度を上げたいのでなるべく多くのレイをバッチにはめ込みたいという欲望があります.仮にすべてのレイがNERF_MAX_SAMPLE_PER_RAY個のサンプル点を取ると決めつけてしまうと実装は楽になりますが,かなりレイの数が限られてしまいます.逆に,各レイの持つサンプル点の個数を動的になるべく最小限に抑えてあげることにより,実装は少しめんどくさいですがバッチになるべく多くのレイが収まってくれます.
各レイについて動的にサンプリングを行うとしましたが,じゃあ一体どうやって「扱いやすいデータ」とするのかという疑問が浮かびます.まあ実装してみると分かりますが,次のような問題が発生します.

簡単な実装をするとすれば,各レイに対してNERF_MAX_SAMPLE_PER_RAY要素だけサンプル点情報の領域を確保しておき,余った分は空白としておく,となるでしょう.しかし,これは全くもってメモリ領域の無駄であり,さらに言えばNNに入力するデータとしてはバッチに空白のデータが入り込んでしまい,正しい処理が出来ません.そのため,理想としては下側のように各レイでのサンプル点がメモリ上で連続に配置されている状態となります.
さて,各レイの処理は並列に行っています.ですが,このままでは処理中のスレッドにとって「今自分が処理しているサンプル点の情報をメモリ上のどこに書き込めばいいのか」が不明です.並列処理を諦めることになるのでしょうか.いいえ,実は同じ処理を2回繰り返すことでほとんど並列性を保って実装できます.前半では各スレッドが「自分が何個サンプリングすることになって,サンプル点情報を書き込む配列のどこを始点として書き込めばいいのか」を決定します.

そして後半では「前半と全く同じサンプリングを行い,決めた領域に書き込む」ということを行います.

では具体的な実装を確認していきます.
... uint32_t nAcceptedSample = 0; ...
nAcceptedSample: このレイにおけるサンプリングすると決定されたサンプル点の数を記録します
// AABBとの交差判定 NeRFAABB NeRFBox = { AABBMin, AABBMax}; if (!NeRFBox.willIntersectWithAABB(*Ray)) { Ray->nSample = 0; return; } float t_min = Ray->tmin; float t_max = Ray->tmax;
そもそもNeRFを生成する領域(AABB)とレイが交差しなければそのレイについては一切のサンプリングを行いません.なので交差判定を行って処理の必要性,そしてレイの経路上でAABBの内部にある領域を求めておきます.また,(NeRF)Rayのメンバ変数にあったnSampleは,この後も書きますが「そのレイが持つサンプル点の数」です.AABBとの交差が確認されないレイは0となります.
const float dt = (t_max - t_min) / NERF_MAX_SAMPLE_PER_RAY; // 0になった場合プログラムが停止してしまうため if (dt < SINF) { Ray->nSample = 0; return; }
さて,サンプリングを行います.今回は単純に,AABB内部にあるレイの経路をNERF_MAX_SAMPLE_PER_RAY等分し,等分された一つの経路上の点をサンプル点としてふさわしいかを考慮することにします.なので「ステップサイズ」としてdtを設定してあげます.ちなみにコーナーケースとして,「レイがAABBの端ぎりぎりに入射した場合」はdtが微小となります.この場合,最悪プログラムがこの後のwhile文内でスタックするので微小の場合はやはりサンプリングせずにreturnしておきます.
// RNG curandState state, state_old, state_firstAccept; curand_init(epoch, threadID, 0, &state);
これは乱数生成のためのCUDAライブラリですが,現状の実装では使わないので無視してください(経路上の点をランダムサンプリングするやり方もありますが,今回は等間隔サンプリングとします.ちなみにInstantNGPの論文では他にも色々サンプリング手法が書かれています).
// Uniform sampling int index = 0; int index_firstAccept = 0; constexpr float Throughput_thres = 0.01f; float Throughput_hat = 1.0f; // これがThroughput_thresを下回れば中断 Vec3 LastSamplePos;
index: 何個目の等間隔サンプル点を考えているか,つまり考えているサンプル点のインデックスです.
index_firstAccept: 後半の処理では全く同じサンプリングをするので,前半で初めてサンプリングが成立したインデックスを記録しておきます.
Throughput_thres: 先ほどの例の図に示したcase2のように,寄与が非常に小さい場合は無視しても問題ありません.なのでその閾値を設定しておきます.
Throughput_hat: この寄与の大きさはNNを通さないと分からないので,Occupancy Gridに保存されている情報を用いて「ざっくりと」推定しておきます
LastSamplePos: 寄与の大きさを計算するためには前のサンプルからの距離が必要です.
// 前半 //////////////////////////////////////////////////////////////////////////////////////////////////////// // とりあえずどのレイがどれぐらいサンプル点を取るかを求める // それによってMLPへの入力データのどの領域にサンプル点の情報が保存されるかを計算する. while (nAcceptedSample < NERF_MAX_SAMPLE_PER_RAY) {
サンプル数がNERF_MAX_SAMPLE_PER_RAYを超えたらもうそれ以上サンプリングさせないようにします(なお実装上,バグが無ければありえません)
const float rnd = 0.5f;// curand_uniform(&state); float t1 = t_min + SINF + dt * index; float t2 = t_min + SINF + (dt * (index+1)); float t = normalize(rnd, 0.0f, 1.0f, t1, t2);
サンプル点のカメラからの距離を決めてあげます.index番目のサンプル点は[t1, t2]の領域にあります.今回はここの中心を使ってあげます.つまり,サンプル点のカメラからの距離はとなっています.
Vec3 s_Pos = Ray->org + Ray->dir * t; // AABBの領域に収める if (t > t_max) { break; } // 念のため s_Pos.x = clamp(s_Pos.x, AABBMin.x + SINF, AABBMax.x - SINF); s_Pos.y = clamp(s_Pos.y, AABBMin.y + SINF, AABBMax.y - SINF); s_Pos.z = clamp(s_Pos.z, AABBMin.z + SINF, AABBMax.z - SINF);
s_Posはサンプル点の座標を記録する変数です.
サンプル点の座標がt_maxを超えた場合は,これ以降のindexにおいてサンプル点がAABBの内部に戻ってくることはあり得ないのでbreakしてあげます.
もしものため,s_Posの座標が絶対にAABBの内部に存在するようにしておきます.ちなみにこれをしなくても外部にあることはあり得ないはずです.
// Occupancy Gridにおいて「物体が無い」と判断された場合はスキップする if (epoch > 16 && !Grids[0].is_Occupied(s_Pos)) { index++; continue; } else { if (nAcceptedSample == 0) { index_firstAccept = index; } nAcceptedSample++; }
Occupancy Gridの話は最後にしますが,ここでやっているのは「Occupancy Gridに格納されているデータを参照し,そのサンプル点において「物体が存在する」,言い換えると媒質の密度がある程度高いと判定されない場合はサンプル点を無視する」ということをやっています.結局は「カメラに入ってくるレイに関与しないサンプル点を無視する」ということです. 無視しない場合はサンプリングが成立するので,初めてのサンプリング成立であればインデックスを保存し,そしてnAcceptedSampleに1を足してあげます.
// Throughputの更新 if (epoch > 32 && nAcceptedSample >= 2) { Vec3 FromLastSample = { s_Pos.x - LastSamplePos.x, s_Pos.y - LastSamplePos.y, s_Pos.z - LastSamplePos.z }; Throughput_hat *= expf(-1.0f * Grids[0].Float_at(s_Pos) * FromLastSample.length()); } // それ以降のサンプル点の寄与の見込みが小さい場合は打ち切る if (Throughput_hat < Throughput_thres) { break; }
コメントの通りです.
LastSamplePos = s_Pos; index++;
さて,サンプリングが成立しました.なのでLastSamplePosを更新し,そしてwhile文の終わりなのでindexに1足してあげて次の等間隔領域に移ります.
// サンプリングが完了した // Dir配列とOrg配列にサンプル情報を記録する(配列上で連続となるようにする) // 格納先の配列におけるインデックス範囲を記録 : [begin, end] int begin_idx = atomicAdd(&SamplingInfo->nSamples, nAcceptedSample); Ray->nSample = nAcceptedSample; Ray->SampleBeginIdx = begin_idx;
さて,前半のサンプリングの処理が終わりました.ここで各スレッドは「自分がサンプル点情報を格納する配列に,「どこから」「何個」サンプル点情報を保存するか」を知る必要があります.なので,説明時に出てきたCounterにどれだけサンプリングが成立したかを教えてあげます.このCounterはNeRFInfoのメンバ変数であるSamplingInfo.nSamplesです.SamplingInfo.nSamplesは現在既に何個のサンプリングが他のスレッドで成立してきたか(how many samples "have been" generated)をbegin_idxへと格納すると同時に処理中のスレッドが何個サンプル点を得たかを聞き,加算します.勿論,この処理はatomicで行われる必要があります.こうすることでCounterが更新され,そしてスレッドが「配列のどこから格納すればいいのか」を決定できます.
ここで,Rayのメンバ変数であるnSampleとSampleBeginIdxにそのスレッドが「どこから」「何個」サンプル点情報を保存したかを記憶させておきます.これは後にボリュームレンダリングを行う際に必要となる情報です.
// 後半 /////////////////////////////////////////////////////////////////////////////////////////////////// // サンプルの保存インデックスがMaxBatchを超えている場合はそのレイを捨てる if (begin_idx + nAcceptedSample >= nMaxBatch) { Ray->nSample = 0; atomicAdd(&SamplingInfo->nSamples, -nAcceptedSample); return; } atomicAdd(&SamplingInfo->RayNum, 1);
さて,後半の開始です.この処理の最初に,サンプル点の総数には上限があり,それがnMaxBatchで定義しておく,とありました.その部分の処理を行います.各スレッドが自分のbegin_idxとnAcceptedSampleから配列上に書き込むインデックスの範囲を知ることが出来ます.それがnMaxBatchを超えていた場合は,サンプル点情報の登録をキャンセルします.そして,キャンセルが発生したのでSamplingInfo->nSamplesからそのスレッドにおけるnAcceptedSampleを引いておきます.ちなみにここでスレッド同期の問題が不安として頭をよぎりましたが,「nMaxBatchから溢れている状態」となっているのが端の問題であるので多分大丈夫なはずです.
// はじめてサンプルを得たところまでスキップ index = index_firstAccept; state = state_firstAccept; nAcceptedSample = 0; Throughput_hat = 1.0f;
後半のサンプリングは前半と全く同じものです.やりましょう.ほとんどが同じなので説明は省略しますが,一か所だけ
// Occupancy Gridにおいて「物体が無い」と判断された場合はスキップする if (epoch > 16 && !Grids[0].is_Occupied(s_Pos)) { index++; continue; } else { Ray->dir.to_float(Dir + 3 * (begin_idx + nAcceptedSample)); s_Pos.to_float(Pos + 3 * (begin_idx + nAcceptedSample)); nAcceptedSample++; }
前半ではLastSamplePosに格納していましたが,今度はサンプル点の情報を格納する配列,即ちDirとPosに保存します.以上でサンプル点の情報をメモリ上で連続に配置することが出来ました.そして,このままNNに入力することが出来ます.なお,前半の際に静的に確保しているメモリ領域にサンプル点情報を保存すれば後半は不要になりますが,メモリ領域を圧迫しますし非効率なメモリ消費がかなり多くなります.
NeRFの実装: (3)と(4): NNの処理
サンプル点における「座標」と「方向」を入力し,サンプル点における「色」と「密度」を推定するNNを実装する必要があります.InstantNGPの論文では次のネットワークが推されています.

FCというのは全結合層で,活性化関数を下に付記しています.ReLUじゃなくてLeakyReLUを使用しても良いです.図の表記にクセがある(作ったのは半年前の私ですが)ので念のため言葉でも説明しておくと,
図中左半分は「密度」を推定するネットワークで,Density MLPと呼ぶことにし,次の構造を取ります.
入力はサンプル点の座標で,これをMultiresolution Hash Encodingに通しておきます.として32次元の出力にします(厳密ではないので異なっていてもまあ良いと思います).
MLP: 入力層32次元, 隠れ層64次元,出力層16次元,隠れ層は1層,活性化関数はすべてReLUないしLeakyReLU,そしてexp(出力層の一つ目の要素)を「密度」の推定値とします.
そして後半ではサンプル点の「色」を推定します.Color MLPとします.ここでサンプル点におけるレイの方向をまでのSpherical Harmonic Encoding(実数球面調和関数の基底に変換)を通して16次元にしておきます.そしてdensity networkの出力である16次元(勿論1要素目はexp activationされていない状態のものです)のベクトルと結合し,合わせて32次元としておきます.
MLP: 入力層32次元, 隠れ層64次元,出力層16次元,隠れ層は2層,活性化関数は入力層と隠れ層はReLUないしLeakyReLU,そして出力層はSigmoid(ロジスティック)とします.
なお,私の実装ではColor MLPの隠れ層の数を1にしています.では実装を見ましょう.
template <const uint32_t indim_den, const uint32_t hiddendim_den, const uint32_t outdim_den, const uint32_t nHiddenLayer_den, const uint32_t indim_col, const uint32_t hiddendim_col, const uint32_t outdim_col, const uint32_t nHiddenLayer_col> __global__ void MFFM_NeRF_Forward ( NeRFInfo* SamplingInfo, EncoderInfo* EncInfo_den, EncoderInfo* EncInfo_col, Encoder Encoder_den, Activation ActHid_den, Activation ActOut_den, Encoder Encoder_col, Activation ActHid_col, Activation ActOut_col, float* Pos, float* Dir, __half* weights_den, __half* buffers_den, __half* weights_col, __half* buffers_col, float* Density, float* Color) { const int BatchSize = SamplingInfo->nSamples; // 即値 constexpr uint32_t indim_den_aligned = next_multiple(indim_den, TENSOR_ROW); constexpr uint32_t hiddendim_den_aligned = next_multiple(hiddendim_den, TENSOR_ROW); constexpr uint32_t outdim_den_aligned = next_multiple(outdim_den, TENSOR_ROW); constexpr uint32_t indim_col_aligned = next_multiple(indim_col, TENSOR_ROW); constexpr uint32_t hiddendim_col_aligned = next_multiple(hiddendim_col, TENSOR_ROW); constexpr uint32_t outdim_col_aligned = next_multiple(outdim_col, TENSOR_ROW); const uint32_t maxdim_den = max(indim_den_aligned, max(hiddendim_den_aligned, outdim_den_aligned)); extern __shared__ __half shmem[]; __half* intermediate_col = shmem; __half* intermediate_den = shmem; __half* intermediate_dirInput = shmem + (outdim_den_aligned + SKEW) * ONEBATCH_SIZE; //////////////////////////////////////////////////////// Density MLP ////////////////////////////////////////////////////////////////////////// // 入力のロード // エンコーダー無しの場合はこの時点でSKEWを与える // エンコーダーありの場合はSKEWを与えない(ただのコピー) if (Encoder_den == Encoder::None) { load_input(indim_den, indim_den_aligned + SKEW, Pos, intermediate_den); } else { load_input(3, 3, Pos, intermediate_den); } // Encode Encode<indim_den_aligned>(Encoder_den, *EncInfo_den,intermediate_den, false, SamplingInfo->AABB_pMin, SamplingInfo->AABB_pMax); // Density MLP Kernel_Debug_train_forward<indim_den, hiddendim_den, outdim_den, nHiddenLayer_den>(BatchSize, ActHid_den, ActOut_den, intermediate_den, weights_den, buffers_den); // Density MLPの出力の1つめの要素をDensityとして保存 store_intermediate<float>(outdim_den_aligned + SKEW, 1, intermediate_den, Density); //////////////////////////////////////////////////////// END: Density MLP ////////////////////////////////////////////////////////////////////// __syncthreads(); // 必要 //////////////////////////////////////////////////////// Color MLP ////////////////////////////////////////////////////////////////////////// // 入力のロード // エンコーダー無しの場合はこの時点でSKEWを与える // エンコーダーありの場合はSKEWを与えない(ただのコピー) if (Encoder_col == Encoder::None) { load_input(indim_col - outdim_den, indim_col_aligned - outdim_den_aligned + SKEW, Dir, intermediate_dirInput); } else { load_input(3, 3, Dir, intermediate_dirInput); } // Encode Encode<indim_col_aligned - outdim_den_aligned>(Encoder_col, *EncInfo_col, intermediate_dirInput, false, SamplingInfo->AABB_pMin, SamplingInfo->AABB_pMax); __syncthreads(); // Density MLPの出力をconcatする. ConcatShmem<outdim_den, indim_col - outdim_den>(intermediate_den, intermediate_dirInput, intermediate_col); __syncthreads(); // Color MLP Kernel_Debug_train_forward<indim_col, hiddendim_col, outdim_col, nHiddenLayer_col>(BatchSize, ActHid_col, ActOut_col, intermediate_col, weights_col, buffers_col); // 結果を保存 store_intermediate<float>(outdim_col_aligned + SKEW, 3, intermediate_col, Color); //////////////////////////////////////////////////////// END: Density MLP ////////////////////////////////////////////////////////////////////// }
特に複雑なことはしていないので軽く見ていきましょう.
template <const uint32_t indim_den, const uint32_t hiddendim_den, const uint32_t outdim_den, const uint32_t nHiddenLayer_den, const uint32_t indim_col, const uint32_t hiddendim_col, const uint32_t outdim_col, const uint32_t nHiddenLayer_col>
この関数ではdenとついているものはDensity MLPに関わる値で,colとついているのはColor MLPに関わる値です.indimは入力層の次元,hiddendimは隠れ層の次元,outdimは出力層の次元,nHiddenLayerは隠れ層の数です.
... __global__ void MFFM_NeRF_Forward ( NeRFInfo* SamplingInfo, EncoderInfo* EncInfo_den, EncoderInfo* EncInfo_col, Encoder Encoder_den, Activation ActHid_den, Activation ActOut_den, Encoder Encoder_col, Activation ActHid_col, Activation ActOut_col, float* Pos, float* Dir, __half* weights_den, __half* buffers_den, __half* weights_col, __half* buffers_col, float* Density, float* Color) { const int BatchSize = SamplingInfo->nSamples; ...
SamplingInfo: サンプリング処理で出てきたものと同じです.この構造体に「NN実行時のバッチサイズ」と「NeRFを生成するAABBの情報」が保存されているのでそれを読み込むために使用します.最後の行でBatchSizeに読み込ませているのが分かると思います.
EncInfoというのはエンコーダーに関する情報が入っていますが,まだ実装途中(Part2で軽く触れた内容です)なので無視してください.今回の実装では考えなくていいです.
Encoder, Activationというのは次に示すものです
enum class Encoder { None, Frequency, SH, // Spherical Harmonic HashGrid, // Multiresolution Hash Encoding UniformGrid }; enum class Activation { ReLU, LeakyReLU, Sigmoid };
Pos, Dirはサンプリング処理で得られたサンプル点の情報です.
weightsは全結合層のパラメーターです.
bufferは逆伝播時に使用する順伝播時の各層の入出力を保存しておくための配列です.
Density, ColorはNNの出力(「密度」「色」)を記録するための配列です.
... // 即値 constexpr uint32_t indim_den_aligned = next_multiple(indim_den, TENSOR_ROW); ... const uint32_t maxdim_den = max(indim_den_aligned, max(hiddendim_den_aligned, outdim_den_aligned)); ...
即値を計算していますが詳しい話はPart1を参照してください.
extern __shared__ __half shmem[]; __half* intermediate_col = shmem; __half* intermediate_den = shmem; __half* intermediate_dirInput = shmem + (outdim_den_aligned + SKEW) * ONEBATCH_SIZE;
1行目はshared memoryを動的に確保している部分です.処理中の特徴ベクトルであるintermediateをshared memoryに載せてやるという意図です.実際のところDensity MLPとColor MLPでは同じポインタを使用しますが,実装上区別したいので異なる名前を付けています.なおintermediate_dirInputについてですが,サンプル点の方向ベクトルはDensity MLPの処理を終えた後にshared memoryにロードします.Density MLPの出力がshared memoryに載ったままロードするので,Density MLPの出力を破壊しないように後ろ側の空きスペースにロードします.
//////////////////////////////////////////////////////// Density MLP ////////////////////////////////////////////////////////////////////////// // 入力のロード // エンコーダー無しの場合はこの時点でSKEWを与える // エンコーダーありの場合はSKEWを与えない(ただのコピー) if (Encoder_den == Encoder::None) { load_input(indim_den, indim_den_aligned + SKEW, Pos, intermediate_den); } else { load_input(3, 3, Pos, intermediate_den); } // Encode Encode<indim_den_aligned>(Encoder_den, *EncInfo_den,intermediate_den, false, SamplingInfo->AABB_pMin, SamplingInfo->AABB_pMax); // Density MLP Kernel_Debug_train_forward<indim_den, hiddendim_den, outdim_den, nHiddenLayer_den>(BatchSize, ActHid_den, ActOut_den, intermediate_den, weights_den, buffers_den); // Density MLPの出力の1つめの要素をDensityとして保存 store_intermediate<float>(outdim_den_aligned + SKEW, 1, intermediate_den, Density); //////////////////////////////////////////////////////// END: Density MLP //////////////////////////////////////////////////////////////////////
Density MLPの処理です.今回は必ずエンコーダーを通すので最初のif文はelseを通ります.現状実装しているエンコーダーは全て入力次元を3と仮定しているのでここでは3次元の入力としてハードコードしてロードしてますが,変更を加える予定です.load_input()はPart1を参照してください.やっていることとしては,3次元の入力(座標)をintermediate(shared memory)にロードしているだけです.3次元なのでバンクコンフリクトを避けるためのSKEWは入れません.つまりただのコピーです.
Encodeというのは次の関数です.
template <const uint32_t indim_aligned> MFFM_DEVICE void Encode(Encoder encoder, EncoderInfo &Info, __half* intermediate, bool isInference, const float3 InputRangeMin = { 0,0,0 }, const float3 InputRangeMax = { 1,1,1 }, float* MHEBufferSTU = nullptr, unsigned int* MHEBufferIdxHT = nullptr) { const int bx = blockIdx.x; const int tx = threadIdx.x; const int ty = threadIdx.y; switch (encoder) { case Encoder::None: // NOP break; case Encoder::Frequency: // not implemented... break; case Encoder::SH: SH::Encode_SH_L4(intermediate, intermediate); break; case Encoder::HashGrid: MHE::Encode<indim_aligned>(InputRangeMin, InputRangeMax, intermediate, intermediate); //MHE2::Encode_inside_kernel<MHE_F>(*Info.MHEInfo, InputRangeMin, InputRangeMax, intermediate, intermediate); break; case Encoder::UniformGrid: UGE::Encode(*Info.UGEInfo, intermediate, intermediate); break; default: printf("Invalid Encoding type\n"); break; } __syncthreads(); }
今回はMultiresolution Hash Encodingを使用するのでHashGridの部分に入ります.Part2に示したEncode関数を使用します.SamplingInfoに保存している「NeRFを生成するAABBの情報」をMultiresolution Hash Encodingのエンコード領域に設定しているのが分かると思います.Part2を参照すれば具体的に何をしているかが分かるはずです.
さて,エンコードが終わったのでMLPに入力します.関数名がかなり酷いですがKernel_Debug_train_forward()がこの処理をしています.詳しい処理はPart1にあります.(Debug用の関数が最新版に成り果てました……) MLPの処理が終わったので1要素目をDensityに関わる値として保存しておきます.正確にはここでExp Activationをした方が良いのですが,今回は1要素目だけをActivationする特殊な例として扱い,ボリュームレンダリングを行う際にExp Activationしてあげることにします.store_intermediateを使用して保存してあげます.この関数もPart1に書かれています.
__syncthreads(); // 必要
ブロックレベルでの同期を取りましょう.
Color MLPも基本的に同じことをしますが,先程の図に示した結合処理を行っておく必要があります.
...
// Density MLPの出力をconcatする.
ConcatShmem<outdim_den, indim_col - outdim_den>(intermediate_den, intermediate_dirInput, intermediate_col);
...
Color MLPの入力次元はDensity MLPの出力次元とエンコードされた方向ベクトルの次元の和であるので,言い換えるとoutdim_den次元のベクトル(Density MLPの出力)と indim_col - outdim_den次元のベクトル(エンコードされた方向ベクトル)の結合を行うことになる,というのがtemplate引数の部分です.具体的にこの関数の中身を見ましょう
/////////////////////////////// CONCAT AND DIVIDE /////////////////////////////////////////////////// /* * 1: shmem1 * 2: shmem2 * s: SKEW * 1s1s1s1s1s...1s1s1s1s2s2s2s2s2s2s...2s2s2s2s → 12s12s12s12s12s12s....12s12s12s12s12s * 番地的にはshmem = shmem1 */ template <const uint32_t dim1, const uint32_t dim2> MFFM_DEVICE void ConcatShmem(__half* shmem1, __half* shmem2, __half* shmem) { // 即値 constexpr uint32_t dim1_aligned = next_multiple(dim1, TENSOR_ROW); constexpr uint32_t dim2_aligned = next_multiple(dim2, TENSOR_ROW); constexpr uint32_t newDim = dim1 + dim2; constexpr uint32_t newDim_aligned = next_multiple(newDim, TENSOR_ROW); const int bx = blockIdx.x; const int tx = threadIdx.x; const int ty = threadIdx.y; const int warp_index_required = ONEBATCH_SIZE / 32; if (ty >= warp_index_required) { return; } float tmp[dim1 + dim2]; __syncthreads(); for (int i = 0; i < dim1; i++) { tmp[i] = shmem1[(dim1_aligned+SKEW) * 32 * ty + (dim1_aligned+SKEW) * tx + i]; } for (int i = 0; i < dim2; i++) { tmp[dim1 + i] = shmem2[(dim2_aligned+SKEW) * 32 * ty + (dim2_aligned+SKEW) * tx + i]; } __syncthreads(); for (int i = 0; i < dim1 + dim2; i++) { shmem[(newDim + SKEW) * 32 * ty + (newDim + SKEW) * tx + i] = (__half)tmp[i]; } for (int i = 0; i < SKEW; i++) { shmem[(newDim + SKEW) * 32 * ty + (newDim + SKEW) * tx + i + dim1 + dim2] = (__half)0.0f; } __syncthreads(); }
Part1を読んだ方であれば何をしているのか分かると思います.複雑そうに見えますが単純にconcatしているだけです.結合前の2つのベクトルは共に[要素][空白][要素][空白]......の構造を取っており,それを[要素1][要素2][空白][要素1][要素2][空白]......と組み直すのがこの関数の処理です.
NeRFの実装: (5)~(7): ボリュームレンダリングの順方向と逆方向
一気に逆方向まで処理してしまいます.最初に示したボリュームレンダリングをやるという話です.NNを通した結果,「色」と「密度」が得られています.説明の時に記した図を再掲します.

この式に代入するだけで計算できます.そして,NNへの逆伝播を行うために,「色」と「密度」への勾配の値を計算する必要があります.詳しい導出はしませんが,勾配は次の式で行えます.
後はこれを実装するだけです.やりましょう.
__global__ void CalculateOutputAndLoss ( const uint32_t nPixelBatch, NeRFInfo* SamplingInfo, float* Pos, NeRFRay* Rays, float* Density, float* Color, float* Output, PixelInfo* Target, float* Loss, bool NoLossCalc = false ) { uint32_t threadID = blockIdx.x * blockDim.x + threadIdx.x; if (threadID >= nPixelBatch) { return; } // 本スレッドでのポインタ等 NeRFRay* Ray = &Rays[threadID]; const uint32_t IdxBias = Ray->SampleBeginIdx; const uint32_t nSample = Ray->nSample; float* pos = Pos + 3 * IdxBias; float* c = Color + 3 * IdxBias; float* sigma = Density + IdxBias; float* out = Output + 3 * threadID; Vec3h ans = Target[threadID].Color; float* err = Loss + 3 * threadID; // サンプル点の厚み(次のサンプル点までの距離) float dt; // Throughput float T = 1.0f; for (int rgb = 0; rgb < 3; rgb++) { out[rgb] = 0.0f; if (!NoLossCalc) { err[rgb] = 0.0f; } } // Sigma Activation for (int s = 0; s < nSample; s++) { NeRF_SigmaExpActivation(sigma[s]); // exp activation } // Calc Output for (int s = 0; s < nSample; s++) { if (s != nSample - 1) { Vec3 ToNextSample = { pos[3 * (s + 1) + 0] - pos[3 * (s + 0) + 0], pos[3 * (s + 1) + 1] - pos[3 * (s + 0) + 1], pos[3 * (s + 1) + 2] - pos[3 * (s + 0) + 2], }; dt = ToNextSample.length(); } else { dt = 0.0f; // 最後のサンプルの厚みを0とする NOTE: これが正しいのかは分からない } // Radiance_i = T_i * c_i * (1 - exp(-sigma_i*dt_i)) for (int rgb = 0; rgb < 3; rgb++) { out[rgb] += T * c[3*s + rgb] * (1.0f - expf(-sigma[s] * dt)); } T *= expf(-sigma[s] * dt); } if (nSample == 0) { for (int rgb = 0; rgb < 3; rgb++) { out[rgb] = 0.0f; err[rgb] = 0.5f * (out[rgb] - (float)ans[rgb]) * (out[rgb] - (float)ans[rgb]); } return; } if (NoLossCalc) { return; } float dLdout[3] = {0.0f,0.0f,0.0f}; // Calc Loss for (int rgb = 0; rgb < 3; rgb++) { Calc_HuborLoss(out[rgb], (float)ans[rgb], dLdout[rgb]); } // MSE for (int rgb = 0; rgb < 3; rgb++) { err[rgb] = 0.5f * (out[rgb] - (float)ans[rgb]) * (out[rgb] - (float)ans[rgb]); } // Calc dLdColor and dLdDensity float dLdc[3] = {0,0,0}; float dLdsigma = 0.0f; float suffix[3] = {out[0], out[1], out[2]}; T = 1.0f; for (int s = 0; s < nSample; s++) { if (s != nSample - 1) { Vec3 ToNextSample = { pos[3 * (s + 1) + 0] - pos[3 * (s + 0) + 0], pos[3 * (s + 1) + 1] - pos[3 * (s + 0) + 1], pos[3 * (s + 1) + 2] - pos[3 * (s + 0) + 2], }; dt = ToNextSample.length(); } else { dt = 0.0f; // 最後のサンプルの厚みを0とする NOTE: これが正しいのかは分からない } // dLdc_i = T_i * (1 - exp(-sigma_i*dt_i)) * dLdout for (int rgb = 0; rgb < 3; rgb++) { dLdc[rgb] = T * (1.0f - expf(-sigma[s] * dt)) * dLdout[rgb]; } // Suffix_i := C_hat - C_1 - C_2 - ... - C_i for (int rgb = 0; rgb < 3; rgb++) { suffix[rgb] -= T * c[3 * s + rgb] * (1.0f - expf(-sigma[s] * dt)); } // T_{i+1} T *= expf(-sigma[s] * dt); // dLdsigma = dt_i * (T_{i+1} * c_i - Suffix_i) * dLdout for (int rgb = 0; rgb < 3; rgb++) { dLdsigma += dt * (T * c[3*s + rgb] - suffix[rgb]) * dLdout[rgb]; } // 保存 for (int rgb = 0; rgb < 3; rgb++) { c[3*s + rgb] = dLdc[rgb]; dLdc[rgb] = 0.0f; } NeRF_SigmaExpDerivative(dLdsigma, sigma[s]); sigma[s] = dLdsigma; dLdsigma = 0.0f; } }
部分的にみましょう.
__global__ void CalculateOutputAndLoss ( const uint32_t nPixelBatch, NeRFInfo* SamplingInfo, float* Pos, NeRFRay* Rays, float* Density, float* Color, float* Output, PixelInfo* Target, float* Loss, bool NoLossCalc = false ) { ...
nPixelBatch: 使用したレイの本数(ピクセルの数)
SamplingInfo: サンプリング処理で保存した,そのレイに関わるサンプル点情報が「どこから」「何個」あるかを使用します
Pos: サンプル点間の距離がボリュームレンダリングに必要です
Rays, Density, Color: 名前の通りです.ただし,入力時は順方向のデータが入っているDensity, Colorには最終的に勾配のデータを格納して返します.
Output: ボリュームレンダリングの計算結果のRGB色を格納します
Target: PixelInfo構造体には対応する教師データ(RGB色)が格納されています
Loss: 誤差情報を記録する配列です
NoLossCalc : 推論処理では誤差を計算する必要がありません.それを制御するフラグです.
// 本スレッドでのポインタ等 NeRFRay* Ray = &Rays[threadID]; const uint32_t IdxBias = Ray->SampleBeginIdx; const uint32_t nSample = Ray->nSample; float* pos = Pos + 3 * IdxBias; float* c = Color + 3 * IdxBias; float* sigma = Density + IdxBias; float* out = Output + 3 * threadID; Vec3h ans = Target[threadID].Color; float* err = Loss + 3 * threadID;
配列におけるデータの内,処理中のスレッドで扱うレイに関するデータのポインタをあらかじめ計算しておくことで実装ミスが減ります.
// サンプル点の厚み(次のサンプル点までの距離) float dt; // Throughput float T = 1.0f;
コメントの通りです.
for (int rgb = 0; rgb < 3; rgb++) { out[rgb] = 0.0f; if (!NoLossCalc) { err[rgb] = 0.0f; } }
0初期化しておきましょう.推論でない場合は誤差も0初期化します.
// Sigma Activation for (int s = 0; s < nSample; s++) { NeRF_SigmaExpActivation(sigma[s]); // exp activation }
さて,NNの実装時には特殊であるため,レンダリング時にexp activationをするとしました.
MFFM_DEVICE void NeRF_SigmaExpActivation(float &sigma) { sigma = expf(clamp(sigma, -15.0f, 15.0f)); }
単純にexpを取っているだけです.なお,計算時の爆発を避けるため,指数部分を15で抑えています.
// Calc Output for (int s = 0; s < nSample; s++) { if (s != nSample - 1) { Vec3 ToNextSample = { pos[3 * (s + 1) + 0] - pos[3 * (s + 0) + 0], pos[3 * (s + 1) + 1] - pos[3 * (s + 0) + 1], pos[3 * (s + 1) + 2] - pos[3 * (s + 0) + 2], }; dt = ToNextSample.length(); } else { dt = 0.0f; // 最後のサンプルの厚みを0とする NOTE: これが正しいのかは分からない } // Radiance_i = T_i * c_i * (1 - exp(-sigma_i*dt_i)) for (int rgb = 0; rgb < 3; rgb++) { out[rgb] += T * c[3*s + rgb] * (1.0f - expf(-sigma[s] * dt)); } T *= expf(-sigma[s] * dt); }
ボリュームレンダリングの順方向です.
まずはサンプル点間の距離(先ほど示した図中の)を計算しますが,最後のサンプル点については距離を定義できません.今回は最後のサンプル点については
としました.
そして,図に示した式に代入してあげます.RGB独立に代入してあげていいです.
if (nSample == 0) { for (int rgb = 0; rgb < 3; rgb++) { out[rgb] = 0.0f; err[rgb] = 0.5f * (out[rgb] - (float)ans[rgb]) * (out[rgb] - (float)ans[rgb]); } return; } if (NoLossCalc) { return; }
サンプル数が0の場合(そもそもAABBと交差しなかった,もしくはサンプリングをキャンセルした場合)には結果を{0,0,0}として返します.誤差計算はあんまり妥当ではないです.
推論処理である場合は今後の処理は不要なので返します.
float dLdout[3] = {0.0f,0.0f,0.0f}; // Calc Loss for (int rgb = 0; rgb < 3; rgb++) { Calc_HuborLoss(out[rgb], (float)ans[rgb], dLdout[rgb]); } // MSE for (int rgb = 0; rgb < 3; rgb++) { err[rgb] = 0.5f * (out[rgb] - (float)ans[rgb]) * (out[rgb] - (float)ans[rgb]); }
ボリュームレンダリングの結果と教師画像のデータの誤差を計算します.誤差関数はHubor_Lossで固定してます.
// Calc dLdColor and dLdDensity float dLdc[3] = {0,0,0}; float dLdsigma = 0.0f; float suffix[3] = {out[0], out[1], out[2]}; T = 1.0f; for (int s = 0; s < nSample; s++) { if (s != nSample - 1) { Vec3 ToNextSample = { pos[3 * (s + 1) + 0] - pos[3 * (s + 0) + 0], pos[3 * (s + 1) + 1] - pos[3 * (s + 0) + 1], pos[3 * (s + 1) + 2] - pos[3 * (s + 0) + 2], }; dt = ToNextSample.length(); } else { dt = 0.0f; // 最後のサンプルの厚みを0とする NOTE: これが正しいのかは分からない } // dLdc_i = T_i * (1 - exp(-sigma_i*dt_i)) * dLdout for (int rgb = 0; rgb < 3; rgb++) { dLdc[rgb] = T * (1.0f - expf(-sigma[s] * dt)) * dLdout[rgb]; } // Suffix_i := Radiance_hat - Radiance_1 - Radiance_2 - ... - Radiance_i for (int rgb = 0; rgb < 3; rgb++) { suffix[rgb] -= T * c[3 * s + rgb] * (1.0f - expf(-sigma[s] * dt)); } // T_{i+1} T *= expf(-sigma[s] * dt); // dLdsigma = dt_i * (T_{i+1} * c_i - Suffix_i) * dLdout for (int rgb = 0; rgb < 3; rgb++) { dLdsigma += dt * (T * c[3*s + rgb] - suffix[rgb]) * dLdout[rgb]; } // 保存 for (int rgb = 0; rgb < 3; rgb++) { c[3*s + rgb] = dLdc[rgb]; dLdc[rgb] = 0.0f; } NeRF_SigmaExpDerivative(dLdsigma, sigma[s]); sigma[s] = dLdsigma; dLdsigma = 0.0f; }
順方向と同じようにして,先程示した計算式に代入しているだけです.式中のは実装中のSuffix_iのことです.NeRF_SigmaExpDerivativeはexp Activationの逆方向です.
MFFM_DEVICE void NeRF_SigmaExpDerivative(float& dLdsigma, float sigma) { dLdsigma = dLdsigma * sigma; }
これでボリュームレンダリングの逆方向が終わりました.dLdcとdLdsigmaは処理が終わるごとに0に戻してあげてください.
NeRFの実装: (8): NNを誤差逆伝播
さて,ボリュームレンダリングの逆方向の処理を行い,NNには「色」と「密度」に関する勾配情報が入ってきました.ということで,この勾配情報を元に誤差逆伝播を行います.InstantNGPの論文にて推されているNNの構造を再掲します.

順方向時にはDensity MLP -> Color MLPの順番で処理をしたので逆伝播時には逆に処理します.
template <const uint32_t indim_den, const uint32_t hiddendim_den, const uint32_t outdim_den, const uint32_t nHiddenLayer_den, const uint32_t indim_col, const uint32_t hiddendim_col, const uint32_t outdim_col, const uint32_t nHiddenLayer_col> __global__ void MFFM_NeRF_Backward ( const int epoch, NeRFInfo* SamplingInfo, EncoderInfo* EncInfo_den, EncoderInfo* EncInfo_col, Optimize Optim, Encoder Encoder_den, Activation ActHid_den, Activation ActOut_den, Encoder Encoder_col, Activation ActHid_col, Activation ActOut_col, float* Pos, float* Dir, __half* weights_den, __half* buffers_den, __half* weights_col, __half* buffers_col, float* dLdDensity, float* dLdColor, float* LossDerivativeSumALL_den, float* AdditionalParam_den, float* LossDerivativeSumALL_col, float* AdditionalParam_col, float* dLdInput_den = nullptr ) { const int BatchSize = SamplingInfo->nSamples; // 即値 constexpr uint32_t indim_den_aligned = next_multiple(indim_den, TENSOR_ROW); constexpr uint32_t hiddendim_den_aligned = next_multiple(hiddendim_den, TENSOR_ROW); constexpr uint32_t outdim_den_aligned = next_multiple(outdim_den, TENSOR_ROW); constexpr uint32_t indim_col_aligned = next_multiple(indim_col, TENSOR_ROW); constexpr uint32_t hiddendim_col_aligned = next_multiple(hiddendim_col, TENSOR_ROW); constexpr uint32_t outdim_col_aligned = next_multiple(outdim_col, TENSOR_ROW); const uint32_t maxdim_col = max(indim_col_aligned, max(hiddendim_col_aligned, outdim_col_aligned)); extern __shared__ __half shmem[]; __half* intermediate_den = shmem; __half* intermediate_col = shmem; __half* intermediate_InDir = shmem + (outdim_den_aligned + SKEW) * ONEBATCH_SIZE; __half* LossDerivativeSumOfBlock = shmem + (maxdim_col + SKEW) * ONEBATCH_SIZE; ////////////////////////////////////////////// Color MLP //////////////////////////////////////////////////////////////////////// const int newDim = outdim_col_aligned + SKEW; load_input(outdim_col, newDim, dLdColor, intermediate_col); Kernel_Debug_train_backward<indim_col, hiddendim_col, outdim_col, nHiddenLayer_col> ( BatchSize, Optim, ActHid_col, ActOut_col, epoch, intermediate_col, LossDerivativeSumOfBlock, weights_col, buffers_col, LossDerivativeSumALL_col, AdditionalParam_col, false ); // Divide DivideShmem<outdim_den, indim_col - outdim_den>(intermediate_den, intermediate_InDir, intermediate_col); // // 入力をロード if (Encoder_col == Encoder::HashGrid || Encoder_den == Encoder::UniformGrid) { load_input(3, 3, Dir, intermediate_InDir + (indim_col_aligned + SKEW) * ONEBATCH_SIZE); } BackPropEncoder<indim_col_aligned>(epoch, Encoder_col, *EncInfo_col, intermediate_InDir, SamplingInfo->AABB_pMin, SamplingInfo->AABB_pMax); ////////////////////////////////////////////// END: Color MLP //////////////////////////////////////////////////////////////////// // この時点で破壊が許容される領域: // ・LossDerivativeSumOfBlock // ・intermediate_InDir // ・ // // dLdSigmaの誤差伝搬 const int threadID_BlockScope = 32 * threadIdx.y + threadIdx.x; const int threadID_global = ONEBATCH_SIZE * blockIdx.x + threadID_BlockScope; if (32 * threadIdx.y + threadIdx.x < ONEBATCH_SIZE) { intermediate_den[(outdim_den_aligned + SKEW) * threadID_BlockScope] = intermediate_den[(outdim_den_aligned + SKEW) * threadID_BlockScope] + (__half)dLdDensity[threadID_global]; } __syncthreads(); ////////////////////////////////////////////// Density MLP /////////////////////////////////////////////////////////////////////// Kernel_Debug_train_backward<indim_den, hiddendim_den, outdim_den, nHiddenLayer_den> ( BatchSize, Optim, ActHid_den, ActOut_den, epoch, intermediate_den, LossDerivativeSumOfBlock, weights_den, buffers_den, LossDerivativeSumALL_den, AdditionalParam_den, false ); // 入力をロード if (Encoder_den == Encoder::HashGrid || Encoder_den == Encoder::UniformGrid) { load_input(3, 3, Pos, intermediate_den + (indim_den_aligned + SKEW) * ONEBATCH_SIZE); } BackPropEncoder<indim_den_aligned>(epoch, Encoder_den, *EncInfo_den, intermediate_den, SamplingInfo->AABB_pMin, SamplingInfo->AABB_pMax); ////////////////////////////////////////////// END: Density MLP ////////////////////////////////////////////////////////////////// // Density MLPの入力側の誤差を保存する if (dLdInput_den != nullptr) { store_intermediate<float>(indim_den_aligned + SKEW, indim_den, intermediate_den, dLdInput_den); } }
やるだけと言ってしまえばそうなのですが,軽く説明しておきます.
template <const uint32_t indim_den, const uint32_t hiddendim_den, const uint32_t outdim_den, const uint32_t nHiddenLayer_den, const uint32_t indim_col, const uint32_t hiddendim_col, const uint32_t outdim_col, const uint32_t nHiddenLayer_col>
順伝播時と同じです.("in", "out"は順伝播時の定義と同じです)
__global__ void MFFM_NeRF_Backward ( const int epoch, NeRFInfo* SamplingInfo, EncoderInfo* EncInfo_den, EncoderInfo* EncInfo_col, Optimize Optim, Encoder Encoder_den, Activation ActHid_den, Activation ActOut_den, Encoder Encoder_col, Activation ActHid_col, Activation ActOut_col, float* Pos, float* Dir, __half* weights_den, __half* buffers_den, __half* weights_col, __half* buffers_col, float* dLdDensity, float* dLdColor, float* LossDerivativeSumALL_den, float* AdditionalParam_den, float* LossDerivativeSumALL_col, float* AdditionalParam_col, float* dLdInput_den = nullptr )
epoch: イテレーション回数
SamplingInfo: 順伝播時の説明参照
EncInfo: 無視してください
Optimize: 最適化関数の設定です.
enum class Optimize { GD, Adam };
LossDerivativeSumALL, AdditionalParamは詳しい話はPart1, Part2を参照してください(パラメーターの勾配とAdamのmとvです)
dLdInput_den: 無視してください(入力側への勾配伝搬です)
後は順伝播の逆向きをやるだけです.基本的に実装を読めばそのまんまの処理をしていることが分かるはずです.
... ////////////////////////////////////////////// Color MLP //////////////////////////////////////////////////////////////////////// const int newDim = outdim_col_aligned + SKEW; load_input(outdim_col, newDim, dLdColor, intermediate_col); Kernel_Debug_train_backward<indim_col, hiddendim_col, outdim_col, nHiddenLayer_col> ( BatchSize, Optim, ActHid_col, ActOut_col, epoch, intermediate_col, LossDerivativeSumOfBlock, weights_col, buffers_col, LossDerivativeSumALL_col, AdditionalParam_col, false ); // Divide DivideShmem<outdim_den, indim_col - outdim_den>(intermediate_den, intermediate_InDir, intermediate_col); ...
「色」の勾配をshared memoryにロードし(load_input()),MLPの誤差逆伝播をし(Kernel_Debug_train_backward()),そして順伝播時にはColor MLPの入力を「Density MLPの出力」に「エンコードしたレイの方向ベクトル」を結合したものなので,ここでばらしてあげます
/* * 1: shmem1 * 2: shmem2 * s: SKEW * 12s12s12s12s12s12s....12s12s12s12s12s -> 1s1s1s1s1s...1s1s1s1s2s2s2s2s2s2s...2s2s2s2s * 番地的にはshmem = shmem1 */ template <const uint32_t dim1, const uint32_t dim2> MFFM_DEVICE void DivideShmem(__half* shmem1, __half* shmem2, __half* shmem) { // 即値 constexpr uint32_t dim1_aligned = next_multiple(dim1, TENSOR_ROW); constexpr uint32_t dim2_aligned = next_multiple(dim2, TENSOR_ROW); constexpr uint32_t nowDim = dim1 + dim2; constexpr uint32_t nowDim_aligned = next_multiple(nowDim, TENSOR_ROW); const int bx = blockIdx.x; const int tx = threadIdx.x; const int ty = threadIdx.y; const int warp_index_required = ONEBATCH_SIZE / 32; if (ty >= warp_index_required) { return; } float tmp[dim1 + dim2]; __syncthreads(); for (int i = 0; i < dim1 + dim2; i++) { tmp[i] = shmem[(nowDim_aligned+SKEW) * 32 * ty + (nowDim_aligned + SKEW) * tx + i]; } __syncthreads(); for (int i = 0; i < dim1; i++) { shmem1[(dim1_aligned + SKEW) * 32 * ty + (dim1_aligned + SKEW) * tx + i] = (__half)tmp[i]; } for (int i = 0; i < dim2; i++) { shmem2[(dim2_aligned + SKEW) * 32 * ty + (dim2_aligned + SKEW) * tx + i] = (__half)tmp[i + dim1]; } for (int i = 0; i < SKEW; i++) { shmem1[(dim1_aligned + SKEW) * 32 * ty + (dim1_aligned + SKEW) * tx + dim1 + i] = (__half)0.0f; shmem2[(dim2_aligned + SKEW) * 32 * ty + (dim2_aligned + SKEW) * tx + dim2 + i] = (__half)0.0f; } __syncthreads(); }
Concatの逆の処理をしているだけなので説明は省略します.さて,今回は方向ベクトルのエンコーダーに誤差逆伝播は不要ですが,仮に必要な場合があったとして,その場合は処理を行う必要があります.そこで
... // 入力をロード if (Encoder_col == Encoder::HashGrid || Encoder_den == Encoder::UniformGrid) { load_input(3, 3, Dir, intermediate_InDir + (indim_col_aligned + SKEW) * ONEBATCH_SIZE); } BackPropEncoder<indim_col_aligned>(epoch, Encoder_col, *EncInfo_col, intermediate_InDir, SamplingInfo->AABB_pMin, SamplingInfo->AABB_pMax); ...
例えばMultiresolution Hash Encodingの誤差逆伝播時には入力データを必要とするので,ロードしてあげます.BackPropEncoder()においてエンコーダーの誤差逆伝播を行います.
template <const uint32_t indim_aligned> MFFM_DEVICE void BackPropEncoder(const int epoch, Encoder encoder, EncoderInfo& Info, __half* intermediate, const float3 InputRangeMin = { 0,0,0 }, const float3 InputRangeMax = { 1,1,1 }, float* MHEBufferSTU = nullptr, unsigned int* MHEBufferIdxHT = nullptr) { const int bx = blockIdx.x; const int tx = threadIdx.x; const int ty = threadIdx.y; switch (encoder) { case Encoder::None: // NOP break; case Encoder::Frequency: // NOP break; case Encoder::SH: // NOP break; case Encoder::HashGrid: MHE::Propagate_backward(InputRangeMin, InputRangeMax, intermediate, intermediate + (indim_aligned + SKEW) * ONEBATCH_SIZE, intermediate + (indim_aligned + SKEW) * ONEBATCH_SIZE + 3 * ONEBATCH_SIZE); //MHE2::Propagate_backward_inside_kernel<MHE_F>(*Info.MHEInfo, InputRangeMin, InputRangeMax, intermediate, intermediate + (indim_aligned + SKEW) * ONEBATCH_SIZE, intermediate + (indim_aligned + SKEW) * ONEBATCH_SIZE + 3 * ONEBATCH_SIZE); break; case Encoder::UniformGrid: UGE::BackPropagate(*Info.UGEInfo, intermediate + (indim_aligned + SKEW) * ONEBATCH_SIZE, intermediate); break; default: printf("Invalid Encoding type\n"); break; } __syncthreads(); }
Multiresolution Hash EncodingのPropagate_backward()はPart2に示しました.(詳しくはPart1,2を参照してください)あとはこれの組み合わせです.これでNNの誤差逆伝播が終わり,あとはパラメーターを最適化するだけです.
NeRFの実装: (9): NN最適化
説明は省略します.Part1, Part2を参照してください.
// 最適化のみ template <const uint32_t indim, const uint32_t hiddendim, const uint32_t outdim, const uint32_t nHiddenLayer> __global__ void MFFM_Optimize(bool willOptimizeInsideOneKernel, const uint32_t BatchSize, EncoderInfo* EncInfo, Encoder Encoder, Optimize Optim, const int epoch, __half* weights, float* LossDerivative, float* AdditionalParam) { Optimize_MLP<indim, hiddendim, outdim, nHiddenLayer>(willOptimizeInsideOneKernel, BatchSize, Optim, epoch, weights, LossDerivative, AdditionalParam); OptimizeEncoder(Encoder, *EncInfo, Optim, BatchSize, epoch); }
MFFM_DEVICE void OptimizeEncoder(Encoder encoder, EncoderInfo &Info, Optimize Optim, const uint32_t BatchSize, const int epoch) { switch (encoder) { case Encoder::None: // NOP break; case Encoder::Frequency: // NOP break; case Encoder::SH: // NOP break; case Encoder::HashGrid: MHE::Optimization(BatchSize, Optim, epoch); //MHE2::Optimization(Info.MHEInfo, BatchSize, Optim, epoch); break; case Encoder::UniformGrid: UGE::Optimization(epoch, BatchSize, Optim, *Info.UGEInfo); break; default: printf("Invalid Encoding type\n"); break; } __syncthreads(); }
NeRFの実装: (10): Occupancy Gridの最適化
さて,サンプリング処理にも出てきましたが,まずそもそもOccupancy Gridとはなんなんだっていう話です.InstantNGPの論文のAppendix-E.2に書かれているのですが,簡単に言うと「空間に「物体」が存在するかどうかの情報を保持するボクセル」です.2種類のOccupancy Gridがあり,一つは「「物体」が存在するか」という01の情報で,もう一つは「どれくらいの「密度」であるか」というfloatの情報です.前者をBit_OccupancyGridとし,後者をFloat_OccupancyGridとします.どういう設計かの概念を図にしておきます.

グリッド内部のあるランダムに選んだサンプル点における「密度」を計算し,それをFloat_OccupancyGridに反映させた結果,左上のようなFloat_OccupancyGridが得られて,そしてそれを元にして何かしらの判定を行った結果その右に示すBit_OccupancyGridが得られたとします.そして,サンプリング処理で以下の処理がありましたね
... // Occupancy Gridにおいて「物体が無い」と判断された場合はスキップする if (epoch > 16 && !Grids[0].is_Occupied(s_Pos)) { index++; continue; } ...
レイ上の点をサンプリングした際にそのサンプル点が果たしてどれだけ不透明であるかをOccupancyGridを用いて判定します.これによって弾かれたサンプル点が図中の青い点で,弾かれずに採用されたサンプル点が橙の点で表されています.このようにすることで,「有効なサンプル点」を増やすことが出来てサンプル効率の向上につながる,というのが簡単なお気持ちです.何もないところサンプリングしても何も得られませんので(屈折率とかの場を作ってそれに応じて屈折させてみるのも面白そうですけどね).
さて,じゃあOccupancy Gridをどうやって構築してあげたらいいんだって話です.InstantNGPの論文が言うことには
16イテレーションごとにOccupancy Gridを更新する
(1): Float_OccupancyGridの各値を0.95倍する
(2): M個のグリッドを選んで.max(現在の値,グリッド内部のサンプル点をNNに入れた結果得られる「密度」の値)へと更新する
(3): Float_OccupancyGridの各値がより小さければ対応するBit_OccupancyGridを0,そうでければ1にする
なお,(2)のMは最初の256イテレーションは全てのグリッド数だけ一様サンプリング,それ以降は全てのグリッド数の半分を,半分は全てのグリッドから一様サンプリングし,残り半分はBit_OccupancyGridの中身が1になっているグリッドから一様サンプリングする.
ということらしいです.ですが実装コストの面で少しだけ簡略化します.(2)のサンプリングを,サンプル数は同じくして,ただしイテレーション回数に関わらず全てのグリッドから一様サンプリングします(なお,棄却法を使えば論文通りの実装が出来ます).
また,(3)の通りに実装すると学習が不安定だったので,今回の実装では別の判定方法を取りました.
では,実装を見ていきましょう.
Occupancy Gridの構造体
実装を容易にするためにOccupancy Gridの構造体を作っておきます.
struct OccupancyGrid { int GridID; bool* BitGrid; float* FloatGrid; Vec3 pMin; Vec3 pMax; float ave; MFFM_HOST_DEVICE OccupancyGrid(const int GridID = 0, const Vec3 pMin = { 0,0,0 }, const Vec3 pMax = { 1,1,1 }) : GridID(GridID), pMin(pMin), pMax(pMax), ave(0.0f) {} MFFM_DEVICE unsigned int PosToIdx(Vec3& pos) { return CalcMortonCode7bit(pos, pMin, pMax); } MFFM_DEVICE bool& Bit_at(Vec3& pos) { return BitGrid[CalcMortonCode7bit(pos, pMin, pMax)]; } MFFM_DEVICE float& Float_at(Vec3& pos) { return FloatGrid[CalcMortonCode7bit(pos, pMin, pMax)]; } MFFM_DEVICE bool is_Occupied(Vec3& pos) { return BitGrid[CalcMortonCode7bit(pos, pMin, pMax)]; } };
GridID: 論文中では複数のOccupancyGridを作ることを考慮しており,今回の実装でも番号だけ振っておきます.なお今回は1つしか使用しません.
BitGrid, FloatGrid: Bit_OccypancyGrid と Float_OccupancyGridです.
pMin, pMax: Gridの拡がる空間を表します(AABBです).
ave: FloatGridの各グリッドの中身の値の平均値です.先ほど示した(3)の判定に使用します.
初期化は次のように行います.
__global__ void SetUpOccupancyGrid(int ID, NeRFInfo* Info, OccupancyGrid* Grids, bool* BitGridPtr, float* FloatGridPtr) { Grids[ID].GridID = ID + 1; float power = powf(2, ID); Grids[ID].BitGrid = BitGridPtr; Grids[ID].FloatGrid = FloatGridPtr; Grids[ID].pMin = { Info->AABB_pMin.x * power, Info->AABB_pMin.y * power,Info->AABB_pMin.z * power }; Grids[ID].pMax = { Info->AABB_pMax.x * power, Info->AABB_pMax.y * power,Info->AABB_pMax.z * power }; }
BitGridPtr, FloatGridPtrはカーネル外部でthrust::device_vectorで定義している配列のポインタです.
pMinとpMaxは論文通りに実装しましたが,今回は結局1つめのOccupancyGridのみ使用するのでGridの領域は[-1,1]^3となります.
さて,Occupancy Gridの中身は単純な3次元配列を1次元に直したような配列では持たずに,MortonCode(Z-order curve)に載せます.
今回は一辺が128要素のOccupancyGridを使用するので,各方向7bitで処理すればよいです.MortonCodeを計算する関数を置いておきます.
// 1111 -> 1001001001 KGYK_HOST_DEVICE unsigned int ExpandBits(unsigned int v) { v = (v * 0x00010001u) & 0xFF0000FFu; v = (v * 0x00000101u) & 0x0F00F00Fu; v = (v * 0x00000011u) & 0xC30C30C3u; v = (v * 0x00000005u) & 0x49249249u; return v; }
これは元のビット列に2マス隙間を開ける関数です.そして,これを用いて次のようにすることで各方向128分割したグリッドのインデックスを32bitで表現できます.
MFFM_DEVICE unsigned int CalcMortonCode7bit(Vec3 position, Vec3 pMin, Vec3 pMax) { unsigned long long int MortonCode; // positionを[0, 1]^3の範囲に圧縮する float x = normalize(position.x, pMin.x, pMax.x, 0.0f, 1.0f); float y = normalize(position.y, pMin.y, pMax.y, 0.0f, 1.0f); float z = normalize(position.z, pMin.z, pMax.z, 0.0f, 1.0f); x = min(max(x * 128, 0.0f), 127.0f); y = min(max(y * 128, 0.0f), 127.0f); z = min(max(z * 128, 0.0f), 127.0f); unsigned int xx = ExpandBits((unsigned int)x); unsigned int yy = ExpandBits((unsigned int)y); unsigned int zz = ExpandBits((unsigned int)z); MortonCode = xx * 4 + yy * 2 + zz; return MortonCode; }
この関数自体は以前にLinear-BVHというデータ構造を実装した際に参考にした次の記事に載っていたものを元にしています.
このようにしてインデックスを定義してあげることで,グリッド内で近いところにある2ブロックはメモリ上で近いところとなる,というわけです.さて,OccupancyGridの要素へのアクセスは,(座標)をMortonCodeに変換し,そのMortonCodeをインデックスとして指定するだけです.それらの処理がPosToIdx(), Bit_at(), Float_at()となっています.そして先に書いておくと,is_Occupied()はBitGridの要素が1であるかを判定しているだけです(図に書いた通りの処理です).
では更新処理を書いてあげましょう.OccupancyGridの初期値は全て0です.
(1): すべて0.95培
// 全てのブロックの値を0.95倍する // 全てのブロック数スレッドを立てる __global__ void NeRF_DecayOccupancyGrid(const int K, OccupancyGrid* Grids) { uint32_t threadID = blockIdx.x * blockDim.x + threadIdx.x; if (threadID >= K * 128 * 128 * 128) { return; } const uint32_t GridID = threadID / (128 * 128 * 128); const uint32_t BlockID = threadID % (128 * 128 * 128); Grids[GridID].FloatGrid[BlockID] *= 0.95f; if (BlockID == 0) { Grids[GridID].ave *= 0.95f; } }
128 * 128 * 128 * Kだけスレッドを立てて処理します.(K: OccupancyGridの数.今回は1個としています)
単純に0.95掛けて行っているだけです.また,平均値aveにも0.95を掛けておきます(各Grid内のブロック1つ目を担当するスレッドが行います)
(2): M個サンプリングし,色々やる
この処理は中々めんどくさいです.ランダムに選ばれたM個のブロックにスレッドを割り振り,各スレッドが自分の担当するOccupancyGridにおけるあるブロック内部にあるランダムな点をサンプリングし,NNモデルを通して「密度」の値を得るのですが,M個のサンプリングをまずは行う必要があります.これは簡単のため一様に選ばせます.つまり1~128 * 128 * 128 * Kの順列をシャッフルして最初のM個を選択します.
... // (2) // M個のブロックを選ぶ(ここでは前半と後半で分けずに全て一様サンプリングする) const uint32_t M = (epoch <= 256) ? nBlockALL : nBlockALL / 2; ... static thrust::device_vector<int> Permutaion(nBlockALL); if (epoch == 16) { thrust::sequence(Permutaion.begin(), Permutaion.end()); } if (epoch > 256) { thrust::default_random_engine g; g.seed(epoch + 1); thrust::shuffle(Permutaion.begin(), Permutaion.end(), g); } NeRF_SamplePositionInsideGrid <<<M / 512, 512, 0, Stream >>> (epoch, K, thrust::raw_pointer_cast(&Grids[0]), thrust::raw_pointer_cast(&Permutaion[0]), thrust::raw_pointer_cast(&Position[0])); gpuErrchk(cudaGetLastError()); gpuErrchk(cudaStreamSynchronize(Stream)); ...
Mの決定については論文に従います.また,nBlockALL = 128 * 128 * 128 * Kです.Permutation配列をシャッフルして最初のM個を選択することによりインデックスのサンプリングが完了します.なお,最初の256イテレーションは結局全部サンプリングするのでシャッフルしません
Occupancy Gridのブロックのサンプリングが終わったら今度はブロック内部の点をサンプリングします.ここはCUDAカーネルでM個並列に行います.これは単純に自分の担当しているOccupancyGridにおけるブロックが,NeRFを生成するAABB内部ではどの領域に対応しているのかを計算し,xyz各軸方向に対して乱数を用いて座標を選ぶというやり方で行きます.
// M個グリッドをサンプリングする // M個のスレッドを立てる // Indexにはサンプリングするグリッド上の座標が1次元のインデックスで記録されている // よってグリッド上では(x,y,z)をIndexから計算する // Indexに入っているインデックスを使用しても実際に(x,y,z)にはアクセスできないことに注意(MortonCodeでOccuoancyGridのインデックスが定義されているためである) // そのため,サンプル点から得たdensityを元に更新するOccupancyGridのインデックスはサンプル点の座標から計算する __global__ void NeRF_SamplePositionInsideGrid(const int seed, const int K, OccupancyGrid* Grids, const int* Index, float* Position) { uint32_t threadID = blockIdx.x * blockDim.x + threadIdx.x; if (threadID >= K * 128 * 128 * 128) { return; } const int Idx = Index[threadID]; const uint32_t GridID = Idx / (128 * 128 * 128); const uint32_t BlockID = Idx % (128 * 128 * 128); // サンプルするグリッドの最小座標 Vec3 pMinGrid = Grids[GridID].pMin; Vec3 pMaxGrid = Grids[GridID].pMax; // 今回サンプルする領域 const uint32_t IdxX = BlockID % 128; const uint32_t IdxY = (BlockID / 128) % 128; const uint32_t IdxZ = (BlockID / 128 / 128); // サンプルするブロックの一辺の長さ(Assumption: ブロックは立方体) const float Range = (pMaxGrid.x - pMinGrid.x) / 128; // サンプルするブロックの最小座標 const float Xmin = pMinGrid.x + Range * IdxX; const float Ymin = pMinGrid.y + Range * IdxY; const float Zmin = pMinGrid.z + Range * IdxZ; // RNG curandState state; curand_init(seed, threadID, 0, &state); // (0,1] float t = curand_uniform(&state); // サンプルする座標 const float s_PosX = Xmin + t * Range; const float s_PosY = Ymin + t * Range; const float s_PosZ = Zmin + t * Range; // MLP入力のthreadID番目にサンプル点の情報を登録 Position[3 * threadID + 0] = s_PosX; Position[3 * threadID + 1] = s_PosY; Position[3 * threadID + 2] = s_PosZ; }
コメントに書いているとおりです.ちなみに本当はこの実装はあまりよろしくなくて,使用するOccupancyGridの拡がっている領域が[-1, 1]^3であり,これはNeRFのAABBと(偶然!)一致しているため,特に正規化を行わずして直接サンプル点の座標の「密度」を評価できます.真似はしない方が良いです.
サンプル点の座標が得られたため,「密度」のみを推定するNNに入れておきます.
template <const uint32_t indim_den, const uint32_t hiddendim_den, const uint32_t outdim_den, const uint32_t nHiddenLayer_den> __global__ void MFFM_NeRF_InferenceDensityOnly ( NeRFInfo* SamplingInfo, EncoderInfo* EncInfo_den, Encoder Encoder_den, Activation ActHid_den, Activation ActOut_den, float* Pos, __half* weights_den, float* Density) { // 即値 constexpr uint32_t indim_den_aligned = next_multiple(indim_den, TENSOR_ROW); constexpr uint32_t hiddendim_den_aligned = next_multiple(hiddendim_den, TENSOR_ROW); constexpr uint32_t outdim_den_aligned = next_multiple(outdim_den, TENSOR_ROW); const uint32_t maxdim_den = max(indim_den_aligned, max(hiddendim_den_aligned, outdim_den_aligned)); extern __shared__ __half shmem[]; __half* intermediate_den = shmem; //////////////////////////////////////////////////////// Density MLP ////////////////////////////////////////////////////////////////////////// // 入力のロード // エンコーダー無しの場合はこの時点でSKEWを与える // エンコーダーありの場合はSKEWを与えない(ただのコピー) if (Encoder_den == Encoder::None) { load_input(indim_den, indim_den_aligned + SKEW, Pos, intermediate_den); } else { load_input(3, 3, Pos, intermediate_den); } // Encode Encode<indim_den_aligned>(Encoder_den, *EncInfo_den, intermediate_den, true, SamplingInfo->AABB_pMin, SamplingInfo->AABB_pMax); // Density MLP Kernel_Debug_inference<indim_den, hiddendim_den, outdim_den, nHiddenLayer_den>(ActHid_den, ActOut_den, intermediate_den, weights_den); // Density MLPの出力の1つめの要素をDensityとして保存 store_intermediate<float>(outdim_den_aligned + SKEW, 1, intermediate_den, Density); //////////////////////////////////////////////////////// END: Density MLP ////////////////////////////////////////////////////////////////////// }
やっていることは通常のNNの順伝播の実装と同じです.また,バッチサイズは言うまでもありませんがMです.
これによって「密度」が推定されたのでこれを用いてFloat_OccupancyGridを更新します.
// サンプリングしたM個のブロックを更新する // M個のスレッドを立てる // Indexには単純な1次元化した配列のインデックスが格納されている: [x][y][z] -> [idx] // それゆえ,OccupancyGridへのアクセスはサンプル点の座標から行う(MortonCodeでアクセス) __global__ void NeRF_UpdateOccupancyGrid_FloatGrids(const int M, OccupancyGrid* Grids, const float* Density, const int* Index, const float* Pos) { uint32_t threadID = blockIdx.x * blockDim.x + threadIdx.x; if (threadID >= M) { return; } Vec3 SampledPos = { Pos[3 * threadID], Pos[3 * threadID + 1], Pos[3 * threadID + 2] }; int Idx = Index[threadID]; const uint32_t GridID = Idx / (128 * 128 * 128); const float old = Grids[GridID].Float_at(SampledPos); Grids[GridID].Float_at(SampledPos) = max(old, Density[threadID]); atomicAdd(&Grids[GridID].ave, (Grids[GridID].Float_at(SampledPos) - old) / (128 * 128 * 128)); }
サンプリング時に使用したM個のOccupancyGridのブロックを更新します.処理自体は先ほど示した通り,現在の値と推定値のmaxに更新する,です.今思ったのですが,Exp Activationし忘れてましたね.これが論文通りの(3)が上手く動かない原因かもしれません.あとで試します.ここで更新差分を元にして,aveも更新しておきます.注意点として,Indexではなくて座標(Pos)からMortonCodeを計算してOccupancyGridにアクセスしないとダメです(OccupancyGridの構築の仕方を思い出してください)(1敗).以上でFloat_OccupancyGridの処理は終わりです.
(3): Bit_OccupancyGridの更新
これは簡単です.閾値を超えているか超えていないかで0と1にするだけです.
// 全てのグリッドを更新する // 全てのグリッド数だけスレッドを立てる. __global__ void NeRF_UpdateOccupancyGrid_BitGrids(const int K, OccupancyGrid* Grids) { uint32_t threadID = blockIdx.x * blockDim.x + threadIdx.x; if (threadID >= K * 128 * 128 * 128) { return; } const uint32_t GridID = threadID / (128 * 128 * 128); const uint32_t BlockID = threadID % (128 * 128 * 128); //constexpr float thres = 0.1f * 1024.0f / 1.732050807f; const float thres = 2.0f * Grids[GridID].ave; if (Grids[GridID].FloatGrid[BlockID] > thres) { Grids[GridID].BitGrid[BlockID] = true; } else { Grids[GridID].BitGrid[BlockID] = false; } }
これはFloatGridの値がthres,ここではaveの2倍を超えているかどうかで閾値判定し,対応するBit_OccupancyGridの値を書き換えています.ちなみにこの2倍という値は特に意味はなく,学習が安定した値というだけです.ちなみにこの設定を間違えるとCUDAカーネルが実行時エラー出してプログラムが落ちます(例外処理してないので……).以上でOccupancy Gridの処理は終わりです.
ちなみに
Occupancy Gridを実装しなくてもある程度絵が出ると思っていましたが意外とそういうことはなく,Occupancy Gridを実装しないと学習精度がかなり低い状態が続きます.なので頑張って最後まで実装しきってください.Occupancy Grid自体は強力な手法です.
NeRFの実行
本当にお疲れ様です.以上でNeRFの実装は終わりました.本当はフロントエンドの実装も見せるべきなのですが,かなり説明が大変な複雑化をしているので(主にGUIのせい)ここでは説明を省略します.自分で実装してみると途方に暮れることは少なくともなく,これまで説明したパーツに相当するものを用いてちゃんと設計できると思います.意外とバグりやすいのでちゃんと実装ノート等に纏めた方が良いです.
さて,NeRFの実装が終わったのでどういう風に学習してくれるかを確認しましょう.まずはお決まりのLegoの空間を近似します.学習データはNVIDIAの出しているInstant NeRFのデモソフトにくっついてきたものを使用します.

この子たちですね.jsonファイルで画像とカメラの姿勢等を指定してファイルに読み込ませます.まずは改めて実行の設定を確認しましょう(図中に示したColor MLPの構成と少し異なります).
ボリュームレンダリングに関わる設定
NERF_MAX_SAMPLE_PER_RAY: 512
学習データ
画像: 100枚
サイズ: 800x800
NNの設定
DensityMLP:
InDim: 32
HiddenDim: 64
OutDim: 16
nHiddenLayer: 1
活性化関数: ReLU
エンコーダー: HashGrid
HashGrid: L = 16, F = 2, T = 220, Nmin = 2, b = 2.2
ColorMLP:
InDim: 32
HiddenDim: 64
OutDim: 3
nHiddenLayer: 1
活性化関数: 入力層と隠れ層: ReLU / 出力層: Sigmoid(Logistic)
エンコーダー: Spherical Harmnic ()
最適化関数: Adam
誤差関数; Hubor_Loss
学習率: 0.07
NeRFの設定
AABB: [-1,1]^3
OccupancyGridの数: 1個 / 領域: [-1,1]^3
さて,では結果を見ていきましょう.とりあえず10000イテレーション回してみて出力画像の変化を見ました.

最初は何も見えませんが,モヤモヤし始めて目的の状態に近似されていくのが分かります.本当は動画で載せたいのですが,容量の問題で載せられないので最後にTwitterにあげておいた動画へのリンクを纏めて貼っておきます.今回の実装ではGUI上で動かしており,純粋な学習のみの処理時間を計測することは現状では不可能なのですが,GUI上のカメラにほとんどNeRFを生成するAABBを映していない状態で,約27秒で1000イテレーション回ります.学習処理だけであればもっと速いはずです.
せっかくなので,レイ1本あたりのサンプル点数の平均値でもグラフにプロットしておきます.
最初は設定した通り512サンプルきっかり全部サンプリングしてくれています.そしてiter = 16とiter = 32において激しく減少していることが分かります.これは16イテレーションごとにOccupancy Gridを更新し,サンプリング中にサンプルが弾かれやすくなっているためです.もっと縦軸の範囲を狭く,横軸の範囲をのばして見てみましょう.
もちろんサンプリングに使用するバッチによって多少の変化はあるのですが,全体として減少する傾向にあることが分かります.
他のデータセットも試してみましょう.
これはマイクですね.原著NeRFのプロジェクトページで公開されているデータセットにBlenderで開ける3Dモデルファイルがあるのですが,それをBlender上で多少編集し,100視点ランダムに選んでレンダリングしました.この作業は次の記事を参考にしました.
qiita.com
得られた画像群はこんな感じです.
さて,設定は先ほどと全く同じです.なお,画像のサイズが1024x1024になっております.

同様の変化を取っています.マイクの金網の部分も最終的には再現できていることが見えると思います.
最後に,同じデータセットにあるDrumを同様にしてレンダリングして入力データを作成しました.

さて,マイクと同じくして学習させました.

他の2つと比べると少し精度は低めですね.細い物体(高周波成分)の学習にはなかなか手こずります.ちなみにこれ床の色を黒にすると椅子等が上手く学習されません(色としての知覚上,床も椅子も黒に見えると区別がつかないためだと思います).
最後に動画への埋め込みリンクを載せておきます.録画したのは少し前となるので全く同じ実装ではないですが,今でも同じような(改善された)結果が得られます.
Lego pic.twitter.com/bHjFGaqgQu
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2024年2月23日
Mic pic.twitter.com/pVpr4CQhW6
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2024年2月23日
Drum pic.twitter.com/1EWtaT5Xgz
— Yoshi’s Dreann (@TTRS_Yoshi_CG) 2024年2月23日
今よりかは多少遅いですが,まあでも数分でイイ感じに学習できています.現状の私の実装ではかなり荒いところもあるので改善はまだまだ出来そうです.
さいごに
Part1ではMLPの,Part2ではエンコーダーの,Part3ではNeRF全体の実装を示し,説明しました.こういう記事を書くのは初めてなのですが,記事を書いて初めて気づくバグや自分の理解不足な箇所とかを洗い出せてなかなか良い作業ではありました(記事書くために全部で数十時間かかったのでコスパといえばどうなのかとなりますが).さて,NeRF自体がどういうものであるか,というものは論文を読めば大体わかりますが,いざ実装をしてみると意外と手が動きません.そもそも現在では実装しなくても他人が実装したプログラムやライブラリがあります.しかし,何かを実装するということは実装対象をある意味で言語化的にアウトプットする作業とも言え,ぼんやりとした理解の存在を前面に押し出してくれます.一度は泥臭く実装してみるのも良いと思います.
実装に際してUshioさんには色々と助けていただきました.ありがとうございました.
参考文献
・NeRF: Representing Scenes as Neural Radiance Fields for View Synthesis [Ben Mildenhall et al. ECCV2020]
・Instant Neural Graphics Primitives with a Multiresolution Hash Encoding [Thomas Müller et al. SIGGRAPH2022]
・Optical Models for Direct Volume Rendering [Nelson Max. 1995]
・マルペケつくろーどっとコム その18 直線とAABB
・Bounding Volume Hierarchy (BVH) の実装 - 交差判定編
・memoRANDOM ボリュームレンダリング方程式 (Volume Rendering Equation) 1
・NVIDIA Developer Blog, Thinking Parallel, Part III: Tree Construction on the GPU
・live2d_dev NeRF検討用データセットをBlender Pythonスクリプトで作成する方法
・Ushioさんのredpillの実装と解説スライド
CUDA C++でNeRFをほぼ0から実装してみた(Part2/3): エンコーダー編
エンコーダー編 概要
ニューラルネットワークの入力データをエンコーダーに通し,ベクトルの次元を上げることでネットワークの学習効率,精度を向上させることが出来る場合があります.本記事ではInstant NeRFにおいて使用されている2種類のエンコーダーをそれぞれ説明した後に私の実装を説明し,その効果を確認します.
エンコーダー編 はじめに
ニューラルネットワークはある入力のデータ(ベクトル)を元にして様々な計算処理を施し,最終的に目的のデータ(ベクトル)を回帰推定する手法です.ここで,入力層の次元が低い場合は,その結果を正確に推定することが難しくなります [Gehring et al. 2017. Convolutional Sequence to Sequence Learning].詳しいことは3編で書きますが,NeRFにおいてはニューラルネットワークの入力として,3次元空間内での場所と方向という6次元のデータを使用し,問題となっているサンプル点の「色 (3次元)」と「濃度 (1次元)」を推定します.6次元というのは非常に小さく,これだけの情報でNeRFのネットワークを最適化するのは不可能と言っても過言ではないと思います.そこで用いられるのがエンコーダーです.今回実装したNeRFにおいてはPositional EncodingとしてMultiresolution Hash Encodingを,Directional Encodingとして球面調和関数(Spherical Harmonic Encodingと私は呼んでいます)を使用しました.
原著のNeRF [Mildenhall et al. 2020]においては,sinとcosを繰り返すエンコーダーが使用されております.このエンコーダーは "Attention Is All You Need" [Vaswani et al. 2017. ]において使用されているものと同様の物で,処理的には入力データをsinとcosカーブ上の値(フーリエ級数展開的には基底に)に「マッピング」するといったものです.このエンコーダーを使用し,原著のNeRFは確かに3次元形状を近似することが出来ましたが,精度よく学習させるためにエンコーダー後続のネットワークが巨大となり,学習コストが高いなどの課題がありました.2022年にNVIDIAより発表されたMultiresolution Hash Encoding [Müller et al. 2022]はエンコーダーの一つで,これによってNeRF等の学習が非常に高精度,高速化されました.また,原著NeRFでは3次元空間での方向ベクトルを先程のsinとcosのエンコーダー(Frequency Encodingと呼んでいます)に通していましたが,これを球面調和関数に通してエンコードするという手法がMultiresolution Hash Encodingの論文におけるNeRFの実装に使用されており,今回の私の実装もそれにならって球面調和関数によるエンコーダーを使用しました.それぞれを説明していきます.
念のため
内容には気を付けているのですが誤り等があれば教えていただけると幸いです.
Multiresolution Hash Encodingの順伝播の手続き
先述した通り,Positional Encodingの一つです.入力データは空間における座標となります.くどく書けば,この座標を高次元の特徴ベクトルに変換します.最初は簡単のため,2次元で考えることにしましょう.つまり,ある正方形の内部にサンプル点が存在するとします.これは次の図の左側の状態を指します.サンプル点が橙の丸で表されております.

ここで,「レベル」の概念を説明しておきます.今回のエンコーダーではこの正方形に含まれるサンプル点を「様々な解像度レベルで解釈」します.よって,入力座標の存在する正方形を,レベルに応じて色んな解像度で分割します.これが上に示されている図の真ん中の状態です.例えば一番上のレベル()では正方形を2分割しており,
では正方形を3分割しております.そして,サンプル点(橙の丸印)がどの正方形に含まれるかを考えます.これが先程示した図において右側の状態です.
さらに,せっかく分割したので分割した正方形の頂点(格子点)に番号を振っておきましょう.また,このサンプル点が正方形内部のどんな位置にあるのかを表現しましょう.を0以上1以下の実数として,次の図のようになります.(各レベルにおいて番号や記号は独立です)

つまりレベル1においてはサンプル点は頂点(1, 0) (2, 0) (1, 1) (2, 1)のなす正方形内部にあり,レベル2においてはサンプル点は頂点(2, 0), (3, 0), (2, 1), (3, 1)のなす正方形内部にあります.
じゃあ次に,この頂点番号をとあるハッシュ関数に入れてぐちゃぐちゃにしてあげましょう.ハッシュ関数をとします.整数の座標
を入れると整数
が返ってくると思ってください.すると,レベル1においてはサンプル点は
のIDがふられた正方形内部にあり,レベル2においては
のIDが降られた正方形内部にあります.
……一体何のためにこんなことしてるんだって思われている気がします.では,ここで「特徴ベクトルが格納されたテーブル」をレベルごとに用意します.はい,このテーブルに先ほど計算したハッシュ関数の出力をインデックスとしてアクセスするのです.すると,「格子点にテーブル上の特徴ベクトルが対応」します.そして,その特徴ベクトルを何かしらの方法で,今回はバイリニア補完で補完するのです.図で表すと次の通りです.

スペースの都合上,正確な図には出来なかったのですが,ここで使用するテーブルはレベルごとに独立の物を使用します.つまりレベルが違えば使用するテーブルは異なります.なお,テーブル上の特徴ベクトルの次元をとしています.
これまでの処理によって,各レベルにおけるサンプル点の持つ特徴ベクトルがそれぞれ求まりました.では最後に,これらを結合してあげます.これによって得られるベクトルが,このエンコーダーの出力です.

ちなみに論文ではさらにこれに追加のベクトルを結合することもあると書いていますが,少なくともNeRFにおいては使用しませんし,やることは単純なので本記事では省略します.
さて,これまでの処理をもう一度確認しておきましょう(厳密な処理ではなく,雰囲気です).
(1) (x, y) = (サンプル点の座標)
<(2) レベル数だけ繰り返す>
(2.1) レベルに対応する解像度で正方形を分割する
(2.2) 分割された小正方形のうち,どの小正方形に(x,y)が囲まれているかを求める
(2.3) (x, y)が小正方形のどのあたりにあるかを求める(s, t)が求まる
(2.4) 求めた小正方形の各頂点番号をハッシュ関数に入れ,テーブル上のIDを得る
(2.5) 求まったIDからテーブルの特徴ベクトルを各頂点に読みだす
(2.6) 読みだした特徴ベクトルを補完し,これをサンプル点の特徴ベクトルとする
<END: (2) レベル数だけ繰り返す>
(3) 各レベルでそれぞれ得られたサンプル点の特徴ベクトルを結合する
以上で2次元の場合のMultiresolution Hash Encodingの処理は完了しました.このエンコード処理によって,入力データがの2次元だったところが,レベル数を
,テーブル上の特徴ベクトルの次元を
として
次元となりました.
記事書いておいてなんですが……
自分のMultiresolution Hash Encodingの実装は現在書き直している途中です.理由としてはかなり乱れているためです.今回は書き直す前のコードで説明しますが,(もちろんこの記事に限った話ではないことですが)この記事に載せているコードをコピペするのではなく,実装の流れを確認して実装自体はそちらで行っていただくことをお勧めします.実装が改善された際に時間があれば書き直すかもしれません(このセクションが存在している限りは書き直されておりません).
Multiresolution Hash Encodingの実装: 順方向
さて,先ほどは2次元の入力に対して処理を行いましたが,実際のNeRFの入力は3次元です.ちなみにここを2次元にした場合は2次元のデータの近似(画像等)になります.さて,3次元を入力とする場合はエンコードの処理を3次元に対応させる必要があります.なので,これまで正方形として扱っていた個所を立方体として扱う必要があります.それを踏まえたうえで,まずは順伝播から実装を解説していきます.
私の実装をまず示します.
/* Encode処理を行う (1) 各スレッドは1バッチの処理を行う(入力データは3次元であるという仮定を設ける) (1.1) 各レベルに対して次の処理を行う (1.1.1) 注目している座標が格子上ではどの8つの格子点に囲まれているかを求める (1.1.2) 各8格子点のHashTable上におけるインデックスを求める (1.1.3) もとめた8格子点における特徴ベクトルをHashTableより読みだす (1.1.4) 注目している座標が8格子点上でどの位置にあるか(s, t, u)を求める (1.1.5) (s, t, u)の値から特徴ベクトルを補完する コメント: 得られる特徴ベクトルは長さFである (1.2) 各レベルに対して(1.1)を行った結果,長さFの特徴ベクトルがL個得られる.それを繋げる(concat) (1.3) 長さEの追加特徴ベクトルをさらに(1.2)で得られた長さL*Fのベクトルに繋げる(concat) */ template <const uint32_t indim_aligned> MFFM_DEVICE void Encode(const float3 InputRangeMin, const float3 InputRangeMax, __half* Input, __half* Encoded) { const int bx = blockIdx.x; const int tx = threadIdx.x; const int ty = threadIdx.y; const int global_threadId = bx * ONEBATCH_SIZE + 32 * ty + tx; const int block_threadId = 32 * ty + tx; // 1ブロック128バッチを担当する if (32 * ty + tx >= ONEBATCH_SIZE) { return; } unsigned int* NeighborPos_base = (unsigned int*)((__half*)Input + (indim_aligned + SKEW) * ONEBATCH_SIZE); unsigned int* NeighborPos = NeighborPos_base + (8 * 3) * block_threadId; float* NeighborFeatureVec_base = (float*)(NeighborPos_base + (8 * 3) * ONEBATCH_SIZE); float* NeighborFeatureVec = NeighborFeatureVec_base + (8 * MHE_F + SKEW) * block_threadId; float* stu_base = (NeighborFeatureVec_base + (8 * MHE_F + SKEW) * ONEBATCH_SIZE); float* stu = stu_base + 3 * block_threadId; // 入力のロード float x = normalize(Input[3 * block_threadId + 0], (__half)InputRangeMin.x, (__half)InputRangeMax.x, (__half)0.0f, (__half)1.0f); float y = normalize(Input[3 * block_threadId + 1], (__half)InputRangeMin.y, (__half)InputRangeMax.y, (__half)0.0f, (__half)1.0f); float z = normalize(Input[3 * block_threadId + 2], (__half)InputRangeMin.z, (__half)InputRangeMax.z, (__half)0.0f, (__half)1.0f); // ロードは必ず先に終わらせる __syncthreads(); // (1.1) #pragma unroll for (int l = 0; l < MHE_L; l++) { // (1.1.1) //unsigned int NeighborPos[8*3]; Calc_NeighborVectorIndex(l, x, y, z, NeighborPos); // 近傍格子点における特徴ベクトルを求める (1.1.2) (1.1.3) //float NeighborFeatureVec[8*MHE_F];// [8][F] #pragma unroll for (int i = 0; i < 8; i++) { // (1.1.2) unsigned int IndexOnHashTable = Calc_IndexOnHashTable(NeighborPos + 3 * i); // (1.1.3) Get_FeatureVectorOnHashTable(l, IndexOnHashTable, NeighborFeatureVec + MHE_F * i); } const int Buffer_stu_idx = PosToIdx2D(global_threadId, l, MHE_L) * 3; // 近傍格子点における特徴ベクトルから入力座標に対応する特徴ベクトルを求める (1.1.4) (1.1.5) // concatも行っていく (1.2) // SKEWを与えることに注意(出力データはL*F+E+SKEWとなる) Calc_CurrentFeatureVector(l, x, y, z, NeighborPos, NeighborFeatureVec, Encoded + block_threadId * (MHE_L * MHE_F + SKEW) + l * MHE_F, stu); // 今回は(1.3)は行わない } __syncthreads(); }
Part1と同様に部分部分で見ていきましょう.
template <const uint32_t indim_aligned> MFFM_DEVICE void Encode(const float3 InputRangeMin, const float3 InputRangeMax, __half* Input, __half* Encoded) { ...
・indim_aligned: MLPへの入力次元,つまりエンコーダーの出力次元を16の倍数に整形したものです.ちなみに必ずnext_multiple(LF, 16)となります……(じゃあconstexprで即値にすればいいのでは?)
・MFFM_DEVICE: CUDAの修飾子であるdeviceをdefineで置いたものです.
・InputRangeMin: サンプル点の座標が存在する領域はいわゆるAABB(Axis-Aligned-Bounding-Box),つまりxyz軸に平行な辺で構成された直方体です.その直方体のxyz座標の各々最小値が格納されています.
・InputRangeMax: InputRangeMinと同様にして,直方体のxyz座標の各々最大値が格納されています.
・Input: サンプル点の座標が格納された配列(の頭を指すポインタ)です.これはshared memoryに載っています.
・Encoded: エンコードされたデータの格納先です.ポインタとしてはInputと同じにしています.(shared memoryの容量が小さいためです)
... const int bx = blockIdx.x; const int tx = threadIdx.x; const int ty = threadIdx.y; const int global_threadId = bx * ONEBATCH_SIZE + 32 * ty + tx; const int block_threadId = 32 * ty + tx; ...
・global_threadID: デバイス全体で見たスレッドIDです.カーネル実行の設定はPart1を参照してください.
・block_threadId: ブロック単位で見たスレッドIDです.
... // 1ブロック128バッチを担当する if (32 * ty + tx >= ONEBATCH_SIZE) { return; } ...
Part1を読めば詳しくは分かりますが,各ブロックは128バッチを処理します.なのでブロック単位で見たスレッドID(zero-indexed)が128以上のものは帰します.
... unsigned int* NeighborPos_base = (unsigned int*)((__half*)Input + (indim_aligned + SKEW) * ONEBATCH_SIZE); unsigned int* NeighborPos = NeighborPos_base + (8 * 3) * block_threadId; float* NeighborFeatureVec_base = (float*)(NeighborPos_base + (8 * 3) * ONEBATCH_SIZE); float* NeighborFeatureVec = NeighborFeatureVec_base + (8 * MHE_F + SKEW) * block_threadId; float* stu_base = (NeighborFeatureVec_base + (8 * MHE_F + SKEW) * ONEBATCH_SIZE); float* stu = stu_base + 3 * block_threadId; ...
うわあ……って感じです.えっと,実装中に使用する配列をshared memoryに載せようと努力しています.普通に静的配列として確保した方がいいと思います.細かい説明は出番が来た時にします.
... // 入力のロード float x = normalize(Input[3 * block_threadId + 0], (__half)InputRangeMin.x, (__half)InputRangeMax.x, (__half)0.0f, (__half)1.0f); float y = normalize(Input[3 * block_threadId + 1], (__half)InputRangeMin.y, (__half)InputRangeMax.y, (__half)0.0f, (__half)1.0f); float z = normalize(Input[3 * block_threadId + 2], (__half)InputRangeMin.z, (__half)InputRangeMax.z, (__half)0.0f, (__half)1.0f); ...
入力座標をロードしておきます.ただし,サンプル点の座標が[[min.x, max.x], [min.y, max.y], [min.z, max.z]]に存在している状態だと面倒なので,ここでこの座標を[0, 1]^3に正規化しておきます.normalize関数は次の通りです.
// [SrcMIN, SrcMAX] -> [DstMIN, DstMAX] template<typename T> __host__ __device__ T normalize(T val, T SrcMIN, T SrcMAX, T DstMIN, T DstMAX) { T DstRange = DstMAX - DstMIN; T SrcRange = SrcMAX - SrcMIN; if (SrcRange == (T)0.0f) SrcRange = (T)1e-6f; T t = (val - SrcMIN) / SrcRange; return DstMIN + t * DstRange; }
1次元の値に対して,元の最小値SrcMIN, 最大値SrcMAXの線分を点valで内分する際に比がどうなっているかを求め,それを出力側の最小値と最大値の線分に適用している感じです.ゼロ除算を避けるための処理はしていますが,エラー処理はしてません.
... // ロードは必ず先に終わらせる __syncthreads(); ...
ブロック単位で同期を行います.ブロック内部の速いスレッドがエンコード結果を書き込む際に,ブロック内部の遅いスレッドが入力座標を読み出しが終わっていることを保証するためです.入力データ,出力データはshared memory(ブロックごとに独立)に載っているため,これで大丈夫です.
... // (1.1) #pragma unroll for (int l = 0; l < MHE_L; l++) { ...
レベルの数だけ繰り返します.
... // (1.1.1) //unsigned int NeighborPos[8*3]; Calc_NeighborVectorIndex(l, x, y, z, NeighborPos); ...
現在のレベルにおいて,サンプル点がどの小立方体内部にあるかを求めます.正確に言えば,サンプル点を包含する小立方体の各頂点のxyz各軸方向における頂点番号を求めます.NeighborPosは求めた各軸方向の頂点番号を格納する配列で,一頂点あたり3次元の頂点番号をもち,立方体は8頂点で構成されるので,uint32型8*3の容量が必要です.では,Calc_NeighborVectorIndexの処理を見ましょう.
// レベルlevelにおける座標{x, y, z}の近傍格子点を求める MFFM_DEVICE inline void Calc_NeighborVectorIndex(int level, float x, float y, float z, unsigned int* NbVecIdx) { const unsigned int Nl = (unsigned int)(MHE_Nmin * powf(MHE_b, level)); // 格子の1マスの大きさ float K = 1.0f / (float)Nl; NbVecIdx[0] = (int)(x / K); NbVecIdx[1] = (int)(y / K); NbVecIdx[2] = (int)(z / K); NbVecIdx[3] = NbVecIdx[0] + 1; NbVecIdx[4] = NbVecIdx[1]; NbVecIdx[5] = NbVecIdx[2]; NbVecIdx[6] = NbVecIdx[0]; NbVecIdx[7] = NbVecIdx[1] + 1; NbVecIdx[8] = NbVecIdx[2]; NbVecIdx[9] = NbVecIdx[0] + 1; NbVecIdx[10] = NbVecIdx[1] + 1; NbVecIdx[11] = NbVecIdx[2]; NbVecIdx[12] = NbVecIdx[0]; NbVecIdx[13] = NbVecIdx[1]; NbVecIdx[14] = NbVecIdx[2] + 1; NbVecIdx[15] = NbVecIdx[0] + 1; NbVecIdx[16] = NbVecIdx[1]; NbVecIdx[17] = NbVecIdx[2] + 1; NbVecIdx[18] = NbVecIdx[0]; NbVecIdx[19] = NbVecIdx[1] + 1; NbVecIdx[20] = NbVecIdx[2] + 1; NbVecIdx[21] = NbVecIdx[0] + 1; NbVecIdx[22] = NbVecIdx[1] + 1; NbVecIdx[23] = NbVecIdx[2] + 1; }
図を交えて説明しましょう.やってることは2次元での説明を3次元にしただけです.

手続きの説明は2次元で行っていました.サンプル点の存在する正方形を小正方形に分割し,どの小正方形に包含されるかを求めました.しかし今回は3次元でやるので,立方体を小立方体に分割し,どの小立方体にサンプル点が含まれるかを計算する必要があります.ここで,先ほどをロードする際に,座標を[0, 1]^3にスケーリングしました.なので,全体の立方体の一辺の大きさは1です.そして,それをレベル
については解像度
だけ分割します.ここで,解像度はレベルに対応した解像度としたいので,次の式で解像度を計算します(説明時はレベルを1-indexedで扱っていますが,計算式や実装上は0-indexedです).
ここで,は最小解像度,
は解像度のスケーリング指数,
はレベル番号を意味します.レベルが高くなるにつれて指数関数的に解像度が増加します.
さて,この解像度のもとで,小立方体の一辺の長さ(とします)は次のように求まります.
そして,次が成立しています.はサンプル点の座標です.
このは小立方体において各軸小さい側の頂点の番号を表しています.つまり,先ほど示した図において,(
)が([0], [1], [2])です.残りの7頂点はこの頂点番号に1足したり足さなかったり......で求められます.以上がCalc_NeighborVectorIndexの処理です.続きを見ていきましょう.
// 近傍格子点における特徴ベクトルを求める (1.1.2) (1.1.3) //float NeighborFeatureVec[8*MHE_F];// [8][F] #pragma unroll for (int i = 0; i < 8; i++) { // (1.1.2) unsigned int IndexOnHashTable = Calc_IndexOnHashTable(NeighborPos + 3 * i); // (1.1.3) Get_FeatureVectorOnHashTable(l, IndexOnHashTable, NeighborFeatureVec + MHE_F * i); }
先ほどの処理でサンプル点を包含する小立方体の各頂点番号が分かりました.これをそれぞれハッシュ関数に入力し,テーブル上のインデックスを計算します(Calc_IndexOnHashTable).そして,そのテーブル上のそのインデックスに保存されている特徴ベクトルを読み出します(Get_FeatureVectorOnHashTable).読みだした特徴ベクトルはNeighborFeatureVecに保存されます.8頂点分の特徴ベクトルを保存するのでfloat8F個の容量となっております.
ではCalc_IndexOnHashTableから見ていきましょう.
// 頂点インデックス(3d)からHashTable上のインデックスをハッシュ関数により計算する MFFM_DEVICE inline unsigned int Calc_IndexOnHashTable(unsigned int* VertexIndex) { const unsigned long long int p[3] = { 1, 2654435761, 805459861 }; unsigned long long int h = 0; for (int i = 0; i < 3; i++) { h = h ^ (VertexIndex[i] * p[i]); } h %= MHE_T; return (unsigned int)h; }
これはハッシュ関数を示した方が速いですね.次の関数がテーブル上のインデックスを出力するハッシュ関数です.
なお,は排他的論理和(XOR)で,Tはテーブルのサイズ,すなわち特徴ベクトルの本数です.この式に出てくる2654435761や805459861は論文において記されていた値ですが,大きな素数が使用されます.この計算によってテーブルの特徴ベクトルをロードする準備が整いましたのでロードします.Get_FeatureVectorOnHashTableを見ていきましょう.
// VにHashTable上の特徴ベクトルを書き出す MFFM_DEVICE inline void Get_FeatureVectorOnHashTable(int level, unsigned int index, float* V) { #pragma unroll for (int i = 0; i < MHE_F; i++) { V[i] = HashTable.at(level, index, i); } }
level: これまで何度も出てきている「レベル」です.
index: 先ほどのハッシュ関数によって計算されたインデックスです
V: テーブルを書きだす先の配列です.
MHE_F: 説明の際に示した,テーブルにおける特徴ベクトルの次元を表すのことです.
さて,ここでも触れるべき点があります.しかしこの部分は実装の幅を狭めるところなので真似はしないほうがいいです.コードを見てわかる通り,HashTableは何かしらの構造体として,グローバルのスコープで保持されていますね.ではその部分の実装を見ていきましょう.
enum class Initialize {
Uniform,
Load_from_file,
Zero
};
struct Tb {
float Data[MHE_L * MHE_T * MHE_F];
MFFM_DEVICE void init(Initialize initialize, unsigned int seed = 1, float* LoadedData = nullptr) {
const int index = blockIdx.x * blockDim.x + threadIdx.x;
switch (initialize) {
case(Initialize::Zero):
Data[index] = 0.0f;
break;
case(Initialize::Uniform):
curandState state;
curand_init(seed, index, 0, &state);
float rnd = curand_uniform(&state);
Data[index] = normalize(rnd, 0.0f, 1.0f, -1e-4f, 1e-4f);
break;
case(Initialize::Load_from_file):
Data[index] = LoadedData[index];
break;
default:
printf("Invalid MHE initializer\n");
break;
}
}
MFFM_DEVICE float at(const uint32_t idxL, const uint32_t idxT, const uint32_t idxF) {
return Data[MHE_T * MHE_F * idxL + MHE_F * idxT + idxF];
}
MFFM_DEVICE float* ptr_at(const uint32_t idxL, const uint32_t idxT, const uint32_t idxF) {
return Data + MHE_T * MHE_F * idxL + MHE_F * idxT + idxF;
}
};
MFFM_DEVICE Tb HashTable;
MFFM_DEVICE Tb dLdHashTable;
MFFM_DEVICE Tb v_Buffer_HashTable;
MFFM_DEVICE Tb m_Buffer_HashTable;
__global__ void init_HashTable() {
HashTable.init(Initialize::Uniform);
dLdHashTable.init(Initialize::Zero);
v_Buffer_HashTable.init(Initialize::Zero);
m_Buffer_HashTable.init(Initialize::Zero);
}
以上がテーブルの構造体周りの処理です.難しいことはしていないので軽く説明するにとどめます.
float Data[MHE_L * MHE_T * MHE_F];
保持しているデータはレベル数,テーブルのサイズ
,テーブルの特徴ベクトルの次元
の積である
要素のfloat配列です.これに対して,init関数では様々な初期化を行います.at関数は(レベル,テーブル上のインデックス,特徴ベクトル上のインデックス)をもとにしてテーブルの要素にアクセスする関数です(今気づきましたが参照してないですね).ptr_at関数は同様の要素を指すポインタにアクセスします.そして,テーブルの本体(HashTable),勾配を記録するdLdHashTable,Adam Optimizerのためのm_Buffer_HashTableとv_Buffer_HashTableがあります.init_HashTableにおいてそれぞれを初期化します.
これは重要なのですが,テーブル本体は初期値を[-1e-4, 1e-4]の範囲における一様乱数で初期化します.それ以外は普通に0初期化します.
さて,先ほどのGet_FeatureVectorOnHashTableにおける処理はこれでわかると思います.しかし,このようにグローバルなものとして定義すると,ニューラルネットワーク内部において1つしかMultiresolution Hash Encodingを使用できないという制約を抱えることになるため,避けた方がいいでしょう.現在主にこの周りの書き直しをしております.
説明の枝が長くなりましたが本筋の解説に戻りましょう.現在どこまでやったかというと,サンプル点の座標を包含する小立方体を求め,その頂点番号をハッシュ関数にいれてテーブル上のインデックスを計算し,そのインデックスに対応するテーブル上の特徴ベクトルを読みだしたところです.ということで,続きを見ていきましょう.
... // 近傍格子点における特徴ベクトルから入力座標に対応する特徴ベクトルを求める (1.1.4) (1.1.5) // concatも行っていく (1.2) // SKEWを与えることに注意(出力データはL*F+E+SKEWとなる) Calc_CurrentFeatureVector(l, x, y, z, NeighborPos, NeighborFeatureVec, Encoded + block_threadId * (MHE_L * MHE_F + SKEW) + l * MHE_F, stu); ...
この部分の処理では「各頂点にロードした特徴ベクトルの補完によるサンプル点における特徴ベクトルの計算」を行い,それを「レベル番号に対応したメモリ領域に保存(即ち結合と同義)」しています.
Encoded + block_threadId * (MHE_L * MHE_F + SKEW) + l * MHE_Fは,サンプル点におけるレベルlの特徴ベクトルを保存するポインタの先頭を指しています.1バッチ辺りのエンコード結果の特徴ベクトルはLF次元であり,shared memoryのバンクコンフリクトを避けるためにSKEWを与えるため,結局LF+SKEW次元となります.なので,スレッド番号にLF+SKEWを掛けてあげて,さらに各レベルではF次元の特徴ベクトルが得られるのでレベル番号にFを掛けてます.
stuは,いや本当にごめんなさいなんですけど,バイリニア補完の係数を載せる配列です.いや,関数内部で静的配列として確保してくださいね.
というわけで関数の中身を見ましょう.
// 近傍点の特徴ベクトルからEncoder入力座標における特徴ベクトルをバイリニア補完する MFFM_DEVICE inline void Calc_CurrentFeatureVector(int level, float x, float y, float z, unsigned int* NbVecIdx, float* NbFeatureVec, __half* CurFeatureVec, float* stu) { const unsigned int Nl = (unsigned int)(MHE_Nmin * pow(MHE_b, level)); // 格子の1マスの大きさ float K = 1.0f / (float)Nl; // バイリニア補完係数 stu[0] = (x - K * (float)NbVecIdx[0]) / K; stu[1] = (y - K * (float)NbVecIdx[1]) / K; stu[2] = (z - K * (float)NbVecIdx[2]) / K; // 3d-バイリニア補完 #pragma unroll for (int i = 0; i < MHE_F; i++) { CurFeatureVec[i] = __float2half((1 - stu[0]) * (1 - stu[1]) * (1 - stu[2]) * NbFeatureVec[i] + stu[0] * (1 - stu[1]) * (1 - stu[2]) * NbFeatureVec[MHE_F + i] + (1 - stu[0]) * stu[1] * (1 - stu[2]) * NbFeatureVec[2 * MHE_F + i] + stu[0] * stu[1] * (1 - stu[2]) * NbFeatureVec[3 * MHE_F + i] + (1 - stu[0]) * (1 - stu[1]) * stu[2] * NbFeatureVec[4 * MHE_F + i] + stu[0] * (1 - stu[1]) * stu[2] * NbFeatureVec[5 * MHE_F + i] + (1 - stu[0]) * stu[1] * stu[2] * NbFeatureVec[6 * MHE_F + i] + stu[0] * stu[1] * stu[2] * NbFeatureVec[7 * MHE_F + i]); } }
を求めるところまではCalc_NeighborVectorIndexでやったのと同じです.後半を図で説明します.

Calc_NeighborVectorIndexにても書きましたが,この小立方体においてすべての座標が小さい頂点(特徴ベクトルV[0]がある頂点)の座標は,です.また,この小立方体の一辺の長さはKです.つまり,このサンプル点の座標から
を引いたベクトルをKで割るとサンプル点が立方体の各軸方向についてどの場所にあるかを表現することが出来ます.つまり,次の式によりバイリニア補完係数を求めています.
あとは図中の式に従って補完しましょう.
お疲れ様です.これでMultiresolution Hash Encodingの順方向が実装出来ました.
Multiresolution Hash Encodingの手続き: 逆方向
さて,実は逆方向はかなり簡単です.今回求める勾配(即ち更新パラメーター)はテーブル上の特徴ベクトルです.順方向でどのような操作をしたかを思い出しましょう.「サンプル点を含む小立方体の各頂点に対応する特徴ベクトルを読み出し,バイリニア補完により出力層を計算」しました.つまり,これの逆伝播としては,「バイリニア補完の逆伝播計算をし,各頂点に対応する特徴ベクトルの勾配を計算」することです.図中のバイリニア補完の式から,次の微分が出来ます.記号は先ほどの図に出てくる計算式を参照してください(は入力ではないです)
よって,バイリニア補完の係数を出力層に流れ込んできた勾配に掛けてあげれば良いだけです.
Multiresolution Hash Encodingの実装: 逆方向
/* * 誤差逆伝播 * shared memory: * - dEdOut: 誤差.サイズ(INDIM_ALIGNED+SKEW) * ONEBATCH_SIZE * - additional_shmem: 余分なshared memory.dLdVの格納に使用する * サイズ/thread: 8*MHE_F+SKEW * 始点: (8*MHE_F+SKEW)*(block_threadIdx) * (1) スレッドごとのエンコーダー出力層の誤差を全スレッドに対するエンコーダー出力層の誤差から読みだす * (2) 各レベルごとに次の処理を行う * (2.1) スレッドごとのエンコーダー出力層の誤差をレベルごとに分割する * (2.2) 順伝播時に記録したBuffer.s/t/uを読み込み,近傍点の特徴ベクトルの補完係数を求める * (2.3) 各近傍点について次の処理を行う * (2.3.1) dEdV[k]を求める * (2.3.2) dEdV[k]のHashTable上でのインデックスをBufferから読みだす * (2.3.3) 近傍点に対応するHashTableの要素の誤差を記録する */ MFFM_DEVICE void Propagate_backward(const float3 InputRangeMin, const float3 InputRangeMax, __half* dEdOut, __half* Buffer_Input, __half* additional_shmem) { const int bx = blockIdx.x; const int tx = threadIdx.x; const int ty = threadIdx.y; // 1ブロック128バッチを担当する if (32 * ty + tx >= ONEBATCH_SIZE) { return; } const int global_threadId = bx * ONEBATCH_SIZE + 32 * ty + tx; const int block_threadId = 32 * ty + tx; // s, t, uは再計算する方が速い // 入力のロード float x = normalize(Buffer_Input[3 * block_threadId + 0], (__half)InputRangeMin.x, (__half)InputRangeMax.x, (__half)0.0f, (__half)1.0f); float y = normalize(Buffer_Input[3 * block_threadId + 1], (__half)InputRangeMin.y, (__half)InputRangeMax.y, (__half)0.0f, (__half)1.0f); float z = normalize(Buffer_Input[3 * block_threadId + 2], (__half)InputRangeMin.z, (__half)InputRangeMax.z, (__half)0.0f, (__half)1.0f); __syncthreads(); // (1) SKEWに注意(次元はL*F+E+SKEW) __half* dEdOutLF = dEdOut + (MHE_L * MHE_F + SKEW) * block_threadId; __half* dLdV = additional_shmem + (8 * MHE_F + SKEW) * (block_threadId); // (2) #pragma unroll for (int l = 0; l < MHE_L; l++) { // s, t, u, indexOnHashTableを求める処理 - レベルLの格子における近傍点の座標 /////////////// unsigned int NeighborPos[8 * 3]; Calc_NeighborVectorIndex(l, x, y, z, NeighborPos); const unsigned int Nl = (unsigned int)(MHE_Nmin * pow(MHE_b, l)); // 格子の1マスの大きさ const float K = 1.0f / (float)Nl; // バイリニア補完係数 const float s = (x - K * (float)NeighborPos[0]) / K; const float t = (y - K * (float)NeighborPos[1]) / K; const float u = (z - K * (float)NeighborPos[2]) / K; //////////////////////////////////////////////////////////////////////////////////// // (2.1) __half* dEdOut_LvWise = dEdOutLF + l * MHE_F; // (2.2) const float weight[8] = { (1 - s) * (1 - t) * (1 - u), s * (1 - t) * (1 - u), (1 - s) * t * (1 - u), s * t * (1 - u), (1 - s) * (1 - t) * u , s * (1 - t) * u , (1 - s) * t * u , s * t * u }; // (2.3) #pragma unroll for (int k = 0; k < 8; k++) { // IndexOnHashTableを求める. unsigned int IndexOnHashTable = Calc_IndexOnHashTable(NeighborPos + 3 * k); // (2.3.1) #pragma unroll for (int f = 0; f < MHE_F; f++) { dLdV[k * MHE_F + f] = weight[k] * (float)dEdOut_LvWise[f]; } // (2.3.3) #pragma unroll for (int f = 0; f < MHE_F; f++) { atomicAdd(dLdHashTable.ptr_at(l, IndexOnHashTable, f), dLdV[k * MHE_F + f]); //*dLdHashTable.ptr_at(l, IndexOnHashTable, f) += (float)dLdV[k * MHE_F + f]; } } } }
見ていきましょう.
MFFM_DEVICE void Propagate_backward(const float3 InputRangeMin, const float3 InputRangeMax, __half* dEdOut, __half* Buffer_Input, __half* additional_shmem) { ...
・dEdOut: 出力層に流れ込んできた勾配
・Buffer_Input: 順方向の際の入力データです.即ちサンプル点の座標です
・additional_shmem: 酷い実装の片鱗です.演算時のデータをshared memoryに載せるために空いているshared memoryの領域を持ってきます.無視しても良いです.
... const int bx = blockIdx.x; ... float z = normalize(Buffer_Input[3 * block_threadId + 2], (__half)InputRangeMin.z, (__half)InputRangeMax.z, (__half)0.0f, (__half)1.0f); __syncthreads(); ...
順伝播と同じです.バイリニア補完の係数を求めるために順伝播の処理を部分的に行っています.s, t, uを保存しておくよりもこちらの方が綺麗に実装できると思います.
// (1) SKEWに注意(次元はL*F+E+SKEW) __half* dEdOutLF = dEdOut + (MHE_L * MHE_F + SKEW) * block_threadId; __half* dLdV = additional_shmem + (8 * MHE_F + SKEW) * (block_threadId);
・dEdOutLF: 出力層に流れ込んできた勾配データで,実行スレッドに対応する勾配データの先頭を指すポインタ
・dLdV: ああ,各頂点に対応する特徴ベクトルの勾配を保存する領域です.静的配列として確保した方が良いと思います.あと,誤差関数がLとEで表記揺れしていますが気にしないでください......
... // (2) #pragma unroll for (int l = 0; l < MHE_L; l++) { ...
レベルごとに行います.
... // s, t, u, indexOnHashTableを求める処理 - レベルLの格子における近傍点の座標 /////////////// ... const float u = (z - K * (float)NeighborPos[2]) / K; //////////////////////////////////////////////////////////////////////////////////// ...
コメントの通りです.順伝播と同じ処理なので省略します.
...
// (2.1)
__half* dEdOut_LvWise = dEdOutLF + l * MHE_F;
...
各レベルごとに処理をしたいので,処理中のレベルに対応した出力層に流れ込んできた勾配を指すポインタを計算します.1レベルごとにF要素を処理しているのでレベル番号にFを掛けてます.
// (2.2) const float weight[8] = { (1 - s) * (1 - t) * (1 - u), s * (1 - t) * (1 - u), (1 - s) * t * (1 - u), s * t * (1 - u), (1 - s) * (1 - t) * u , s * (1 - t) * u , (1 - s) * t * u , s * t * u };
バイリニア補完の係数ですね.これで準備が整いました.一気に行きましょう.
// (2.3) #pragma unroll for (int k = 0; k < 8; k++) { // IndexOnHashTableを求める. unsigned int IndexOnHashTable = Calc_IndexOnHashTable(NeighborPos + 3 * k); // (2.3.1) #pragma unroll for (int f = 0; f < MHE_F; f++) { dLdV[k * MHE_F + f] = weight[k] * (float)dEdOut_LvWise[f]; } // (2.3.3) #pragma unroll for (int f = 0; f < MHE_F; f++) { atomicAdd(dLdHashTable.ptr_at(l, IndexOnHashTable, f), dLdV[k * MHE_F + f]); //*dLdHashTable.ptr_at(l, IndexOnHashTable, f) += (float)dLdV[k * MHE_F + f]; } }
各頂点に対応する特徴ベクトルの勾配を求めるため,各頂点に注目して処理していきます.まず,頂点とテーブル上の特徴ベクトルを対応させるために,順伝播と同じようにインデックスを求めます.そして,先程示した勾配を求める式に代入し,頂点に対応する特徴ベクトルの勾配を求めます.そして最後に,その勾配を,勾配を記録するテーブルの構造体であるdLdHashTableにatomicAddによりaccumulateします.(正直アクセスが疎なのでatomicじゃなくても耐えるのでは?と思っていますが,確証がないのでちゃんとatomicにしてます).以上で逆方向の処理は完了です.
Multiresolution Hash Encodingの実装: 最適化
実は現状の実装におけるボトルネックです.テーブル上のすべてのパラメーターに対して最適化処理を行います.
///////////////////////////////// OPTIMIZATION IMPLEMENTATION ////////////////////////////////////////////////////////////////////// MFFM_DEVICE void Optimization(const uint32_t BatchSize, Optimize optimize, const int epoch) { int bx = blockIdx.x; int tx = threadIdx.x; int ty = threadIdx.y; const int nThreads = 32 * blockDim.y * gridDim.x; const int threadId = blockIdx.x * 32 * blockDim.y + 32 * ty + tx; const int WeightSize_this_layer = MHE_L * MHE_T * MHE_F; #pragma unroll for (int i = threadId; i < WeightSize_this_layer; i += nThreads) { float dL = dLdHashTable.Data[i]; if (!isfinite(dL)) { dLdHashTable.Data[i] = 0.0f; continue; } dL = dL / (float)BatchSize; switch (optimize) { case(Optimize::GD): HashTable.Data[i] = HashTable.Data[i] - (float)LEARNINGRATE * dL; break; case(Optimize::Adam): if (!AdamOptimize(m_Buffer_HashTable.Data[i], v_Buffer_HashTable.Data[i], dL, HashTable.Data[i], epoch)) { // printf("%d %f %f \n", idx, (float)AdditionalParam[2 * idx], (float)AdditionalParam[2 * idx + 1]); } break; default: printf("Invalid Optimization Type\n"); break; } dLdHashTable.Data[i] = 0.0f; } __syncthreads(); }
やっていることはPart1における全結合層のパラメーターの最適化と同じなので説明は省略します.実際に使用されるテーブル上のパラメーターは限られるのでそれだけ更新するという実装にしようとしていますが現状ではまだ上手くいってません.以上でMultiresolution Hash Encodingの実装が完了しました.
Spherical Harmonic Encodingについて
球面調和関数の基底を用いてエンコードするというものです.球面調和関数は物性とか微分方程式とかの講義で触れられた記憶がありますが,正直なところ式だけ提示されても何も分かりません.まずそもそも球面調和関数を使用してエンコードするというのは何故なのか,いったい何の意味があるのかという疑問が出てきます.例えば3DCGの分野でも光源系の表現などに使用している論文が2000年ごろにありましたが,まあ読んでも詳細には何をしているか分かりませんでした.というわけで土日を溶かして数学をしました.そのうえで自分の理解を述べます.実装だけが目的であれば飛ばしても大丈夫です.
球面調和関数の導出とその正規直交性(と完全性)
を考えます.これは極座標の関係
と書き換えられます(ここの導出はかなり面倒なので省きます).変数分離法により変形していきます.まず,次を認めます.
これを先程の式に代入し,両辺をで割って整理すると,次式が得られます.
ここで,左辺はのみの式,右辺は
のみの式となり,この等号がどのような
に対しても成り立つので,両辺は定数となります.
を定数として,
とします.今回は2つめの式に注目します.さらに次を認めます.
これを先程の2つめの式に代入し,同様に整理すると,次が得られます.
左辺はのみの式,右辺は
のみの式になっていますね.また,
は周期的な関数であるとすれば,
を整数として,
として書けます.1つ目の式より,を任意定数として,
と書けます(一般解ではないです.共役な基底があります).また,2つ目の式に対して,
を代入すると,
両辺を[ tex: (1-x2) ]で割ると,('はxによる微分を意味します)
ですね.これはのときルジャンドル方程式,そうでない場合ルジャンドル培方程式と言って名前がついてます.ここで,
としておくことにします.
この世界には次の式が浮かんでくる人がいるみたいです.
これを先程の式に代入すると次が得られます.
フロベニウスの方法(級数法)を使用してこの微分方程式を解きます.が次の級数の形で与えられるとします.
の時,xの次数が2以上の式を考えると,次の漸化式が得られます.
ここで,の範囲で
には収束してもらうため,
を満たすようにしたいです.ここでをm以上の整数として,
とします.これを上の式に代入することにより,
が先程の条件を満たしてくれます.
の時,xの次数が1以上の式を比較することにより,
が得られます.同様の議論が出来ます.さて,ルジャンドル培方程式を改めて書き直しましょう.
の時,上式は
となります(ルジャンドル方程式).この両辺をm回微分しましょう.ライプニッツの公式
を利用します.
となります.実はこの式は先ほど出てきたに関する微分方程式と同じですね(
).これより,
と書けるらしいですがこの(-1)のべき乗の項はまだよく分かってないです.コンドン-ショートレー位相と呼ぶらしいですが,AMS-55という定義があるらしいとかなんとか……とにかく,これにロドリゲスの公式
を代入することにより,
であり,さらに
となります.これをルジャンドル陪関数と呼びます.ここで,これまでは非負のについて計算していたので,これを負の
についても拡張します.(本当にこんな拡張していいのかという疑問がまだ解決できておりませんが......)
ひたすら計算します.ライプニッツの公式を使用して,
ここで,[tex: (1+x)l, (1-x)l]の最高次数はともになので,
回微分すると0となります.また,努力により,この微分は計算出来て,
これより,
ここで,直前の努力により得られた式と括弧内の式を見比べると,
ライプニッツの公式より,
さて,準備が整ったのでルジャンドル陪関数の式に適用します.
これによってが正の場合と負の場合の対応が付きました.この式はまた後に使うこととして,ここでルジャンドル陪関数の直交性を確認しましょう.
記号を次のように省略することとします.
の定義域は[-1, 1]であるため,-1から1までの積分を行います.
は積分の部分です.これを計算しましょう.ただし,
とします.s回だけ部分積分したときの式は
このは実は消えます.[tex: (x2-1)]の項が生きているうちは1と-1を代入すると0になるので,
となりますね.この考え方を利用して,[tex: D^{m+q-i}Rq]の部分に注目します.の時,つまり
の時はこの微分の結果に[tex: (x2-1)]の項が生きているので,結局1と-1を代入するとこの計算結果は0となり,
となります.次に,ライプニッツの公式を用いて,[tex: D^{i-1} (Rm D^{m+p} Rp)]の部分に注目すると,
[tex: D^{i-1-r} Rmの部分に注目します.の時,つまり
の時は
に現れるすべての項において[tex: (x2-1)]の項が生存します.そのため,
となります.先ほど得られた計算結果と合わせると,確かに全てのiについてが満たされていることが分かります.結局,
ここでであるので,
の積分は
まで部分積分できます.
とすると,ライプニッツの式を利用して
さて,は
に関する2次多項式でした.なので[tex: Rm, Rp]はそれぞれ次数が
です.つまり次数よりも多く微分するとこれらは0になります.つまり,
ここで,p < qの時を考えると,なんと先ほどの議論よりどのにも値は現れません.つまり,
となります.
のときは
を満たすrのみに値が現れうるので,
となります.さらに,
であるので,に注意して,
であるので,
と求まります.以上の議論より,クロネッカーのデルタを用いて,
以上より,ルジャンドル陪関数が直交性を持つことが分かりました.いったんまとめましょう.元々は最初に示したラプラス方程式のを求めていました.これまでの議論より,
です.ここで,正規化を与えることを考えます.つまり,
を満たさせることとします.
これらよりが求まり,
は次のように書けます.
さて,これの積を改めて[tex: Y_lm(\theta, \phi)]と書いて,
先ほど示したルジャンドル陪関数のの正負に関する関係
より,
であるので,負のを考慮した式は
となります.これを球面調和関数と言います.ルジャンドル陪関数[tex: P_lm(\cos\theta)]のに関する直交性と先程の正規化の処理に加えて,
の直交性
より,球面調和関数には正規直交性が成立します.また,球面調和関数には完全性があり,球面上の連続で滑らかな関数が球面調和関数系の線形結合
として一意に表せます.つまり,球面上で定義される関数を展開できるということです.実数上の関数を級数展開するあれと同じですね.私の知識では完全性の証明をすることは出来ませんでした.無念(ワイエルシュトラスの近似定理なるものを用いて色々やってる証明を見つけましたが理解できませんでした......)
さて,現状の球面調和関数系を使用しても良いのですが,近似する対象が実関数である場合は実数の球面調和関数を基底として扱いたいです.これをとして,
と定義してあげることで実数球面調和関数が得られます.
エンコーダーとしての球面調和関数
長くなりましたが,Spherical Harmonic Encodingでは視線の方向をエンコードします.ここで,視線の方向は長さが1の3次元ベクトルです.これは半径が1の球面上の点と見ることが出来ます.つまり,視線の方向を入力とする関数は,単位球面上を定義域とする関数として見ることが出来ます.ここで,球面調和関数の完全性より,球面上で定義される関数が球面調和関数の(無限の)基底の線形結合で表せました.全結合層は(有限の)基底を線形結合する(ことにより関数の応答を近似する)ということを考えると,Spherical Harmonic Encodingは「視線の方向の基底を変換し,よりパラメーター次元を増やすものである」と考えられると私は解釈しています.ただし,この結論に関しては参考文献などがあるわけではないので違うかもしれません.
Spherical Harmonic Encodingの実装
さてさて,では実装に取り掛かりましょう.これまでの理論なしにも実装自体は簡単に出来ますので実装を説明します.
MFFM_DEVICE void Encode_SH_L4(__half* input, __half* Out) { const int bx = blockIdx.x; const int tx = threadIdx.x; const int ty = threadIdx.y; const int inidx = 3 * (32 * ty + tx); const int outidx = (16 + SKEW) * (32 * ty + tx); // 1ブロック128バッチを担当する if (32 * ty + tx >= ONEBATCH_SIZE) { return; } const __half x = input[inidx + 0]; const __half y = input[inidx + 1]; const __half z = input[inidx + 2]; const __half xx = x * x; const __half yy = y * y; const __half zz = z * z; const __half xy = x * y; const __half xz = x * z; const __half yz = y * z; const __half xyz = x * y * z; const __half r2 = 1.4142135623730950488016887242097f; const __half r3 = 1.7320508075688772935274463415059f; const __half r5 = 2.2360679774997896964091736687313f; const __half r7 = 2.6457513110645905905016157536393f; const __half r15 = 3.8729833462074168851792653997824f; const __half r21 = 4.582575694955840006588047193728f; const __half r35 = 5.9160797830996160425673282915616f; const __half r105 = 10.246950765959598383221038680521f; const __half rpi = 1.7724538509055160272981674833411f; __syncthreads(); // L = 0 Out[outidx + 0] = (__half)1.0f / ((__half)2.0f * rpi); // L = 1 Out[outidx + 1] = (r3 / ((__half)2.0f * rpi)) * y; Out[outidx + 2] = (r3 / ((__half)2.0f * rpi)) * z; Out[outidx + 3] = (r3 / ((__half)2.0f * rpi)) * x; // L = 2 Out[outidx + 4] = (r15 / ((__half)2.0f * rpi)) * xy; Out[outidx + 5] = (r15 / ((__half)2.0f * rpi)) * yz; Out[outidx + 6] = (r5 / ((__half)4.0f * rpi)) * ((__half)3.0f * z * z - (__half)1.0f); Out[outidx + 7] = (r15 / ((__half)2.0f * rpi)) * xz; Out[outidx + 8] = (r15 / ((__half)4.0f * rpi)) * (xx - yy); // L = 3 Out[outidx + 9] = (r2 * r35 / ((__half)8.0f * rpi)) * y * ((__half)3.0f * xx - yy); Out[outidx + 10] = (r105 / ((__half)2.0f * rpi)) * xyz; Out[outidx + 11] = (r2 * r21 / ((__half)8.0f * rpi)) * y * ((__half)-1.0f + (__half)5.0f * zz); Out[outidx + 12] = (r7 / ((__half)4.0f * rpi)) * z * ((__half)5.0f * z * z - (__half)3.0f); Out[outidx + 13] = (r2 * r21 / ((__half)8.0f * rpi)) * x * ((__half)-1.0f + (__half)5.0f * zz); Out[outidx + 14] = (r105 / ((__half)4.0f * rpi)) * (xx - yy) * z; Out[outidx + 15] = (r2 * r35 / ((__half)8.0f * rpi)) * x * (xx - (__half)3.0f * yy); for (int i = 16; i < 16 + SKEW; i++) { Out[outidx + i] = 0.0f; } }
部分的にみていきましょう.
MFFM_DEVICE void Encode_SH_L4(__half* input, __half* Out) { ...
・input: 視線の方向が(x, y, z)の形で格納されています.
・Out: エンコード結果を格納するポインタです.
const int bx = blockIdx.x; const int tx = threadIdx.x; const int ty = threadIdx.y; const int inidx = 3 * (32 * ty + tx); const int outidx = (16 + SKEW) * (32 * ty + tx);
・inidx: 実行中のスレッドにて処理する入力ベクトルを指すポインタへアクセスするためのインデックスです.
・outidx: 実行中のスレッドにてエンコード結果を格納するポインタへアクセスするためのインデックスです.
// 1ブロック128バッチを担当する if (32 * ty + tx >= ONEBATCH_SIZE) { return; }
Multiresolution Hash Encodingと同じことをしています.
const __half x = input[inidx + 0]; const __half y = input[inidx + 1]; ... const __half rpi = 1.7724538509055160272981674833411f;
入力ベクトルをロードし,各計算に必要な定数を置いてます.
__syncthreads();
Multiresolution Hash Encodingと同じ役割です.
... // L = 0 Out[outidx + 0] = (__half)1.0f / ((__half)2.0f * rpi); ... Out[outidx + 15] = (r2 * r35 / ((__half)8.0f * rpi)) * x * (xx - (__half)3.0f * yy); ...
実数球面調和関数の基底を計算しています.今回はまで計算します.
for (int i = 16; i < 16 + SKEW; i++) { Out[outidx + i] = 0.0f; }
SKEWの部分を0埋めしてます.
以上です.当然学習パラメーターはありません.基底の計算式は理論のところで示した実数球面調和関数に対して座標系を極座標から直交座標系へ変換してあげれば良いのですが,面倒なので球面調和関数表を参照しましょう.Wikipediaにもあります.
Multiresolution Hash Encodingによる2次元画像の近似(2次元の関数の近似)
まずはこちら側のエンコーダーによる効果を見ていきましょう.2次元画像は「座標(2次元)を与えると色(RGBとします)を返す関数」として見ることが出来ます.では,次の画像を近似しましょうかね.

画像サイズは256x256です.4年ぐらい前にBlenderで作った3Dモデルのレンダリング画像です.画像上の座標を[0, 1]に正規化して入力しました.なお,3DのMultiresolution Hash Encodingなのでz座標が求められますが,これを0.5と固定しました.さて,次の設定で学習しました.
Multiresolution Hash Encodingのパラメーター
MLPのパラメーター
隠れ層次元: 64
出力層次元: 3
隠れ層の数: 4
入力層と隠れ層の活性化関数: ReLU
出力層の活性化関数: Sigmoid
学習設定
誤差関数はHubor(閾値0.05)
最適化関数がAdamの場合は学習率0.01
最適化関数がGradient Descendantの場合は学習率2.0
結果を見ていきましょう.まずは誤差の変化は次の図のようになりました.誤差は平均二乗誤差です.

各最適化関数による学習中の出力は次の画像に示す通りです.各画像内の左上にある数値はその画像を出力したときのイテレーションです.
Gradient Descendant

Adam

両者の学習する過程はかなり異なっていて面白いですね.それよりも,Part1では簡単な1次元関数でも近似させるのが困難であったのに,Multiresolution Hash Encodingを通すことで非常に近似精度が向上しましたね.では,エンコーダー無しの場合を見ていきましょう.画像上の座標を[-0,0001, 0.0001]に正規化してNNに入力しました.
MLPのパラメーター
隠れ層次元: 64
出力層次元: 3
隠れ層の数: 12
入力層と隠れ層の活性化関数: ReLU
出力層の活性化関数: Sigmoid
学習設定
誤差関数はHubor(閾値0.05)
最適化関数がAdamの場合は学習率0.01
最適化関数がGradient Descendantの場合は学習率0.5

Gradient Descendant
Adam

近づいてはいますが,限界にぶつかっているように見えます.
今回は256x256の画像で確認しましたが,これをもっとピクセル数が多い画像に対しても行うことが可能です.こうしてみると,非常に強力な手法なのですが,苦しい点も勿論あります.一つは言うまでもありませんが,メモリ消費が激しいことです.そしてもう一つですが,かなり処理が遅いです.主な理由はメモリアクセスに起因しております.ハッシュ関数を利用してアクセスしているため,まずキャッシュのヒット率が辛いことになってます.そして,グローバルメモリとのやり取りが非常に多くなります.特にテーブル上のパラメーターが多いので最適化処理でかなり遅くなります.ざっくり計測した感じでは今回の画像近似においては学習処理では実行時間の90%ぐらいが最適化処理に持って行かれてます.推論処理だと処理時間の80%程度がMultiresolution Hash Encodingに持って行かれています.ただ,実装に関しては改善できる点も多いのでこの値はあまり参考にしなくていいと思います.ただしやはり遅いには遅いです.
Sphrerical Harmonic Encodingによる球面上の関数の近似
球面上の関数を近似しましょう.を単位球面上の座標として,
……特に意味はないです.さて,次の設定で近似しました.
MLPのパラメーター
隠れ層次元: 64
出力層次元: 1
隠れ層の数: 1
入力層と隠れ層の活性化関数: ReLU
出力層の活性化関数: Sigmoid
学習設定
誤差関数はHubor(閾値0.05)
最適化関数: Adam
学習率: 0.025
エンコーダーありとエンコーダーなし,両方について上記の設定で実行しました.誤差の発展は次の通りです.

MLP側の層がかなり小さいため,エンコーダー無しでは近似が厳しそうであることが見えます.次に,エンコーダーを使用した場合の近似の進む様子を見ます.次の図に出てくるプロット点は
を3D空間上にプロットしており,黄色い点群が推定,黒い点群が真値です.

いい感じですね.ちなみに点群はBlenderで表示しており,プログラムからPythonスクリプトで頂点を追加する関数をテキストで出力してBlenderのPythonスクリプトに貼り付けて実行することにより点群を作っています.
エンコーダー編 さいごに
2編ではInstant NeRFにおいて使用されているエンコーダーについて説明しました.これによりニューラルネットワークの基礎部分は完成しました.3編では遂にNeRFの実装に入ります.今回行った画像近似は2次元の近似です.しかし私たちが目にしているのは3次元の空間であり,今度はこれを近似する必要があります.しかし画像の近似とは異なり,3次元空間の近似はその空間の可視化(レンダリング)が容易ではなく,それゆえに色んな概念を使用してその近似が試みられています.その一つがNeRFです.詳しい話はPart3でやりましょう.
参考資料
・ Convolutional Sequence to Sequence Learning
・ Attention Is All You Need
・ NeRF: Representing Scenes as Neural Radiance Fields for View Synthesis
・ Instant Neural Graphics Primitives with a Multiresolution Hash Encoding
・ Optim Tech Blog. Instant NeRF の心臓、Multiresolution Hash Encoding をシンプルに実装しつつ2次元画像で試してみる
・ Mathematical Methods for Physicists A Comprehensive Guide Seventh Edition 2012
・ 宇宙物理メモ ルジャンドル陪関数
・ 球面調和関数表 Wikipedia
・ Spherical Harmonic Lighting: The Gritty Details
・ Precomputed Radiance Transfer for Real-Time Rendering in Dynamic, Low-Frequency Lighting Environments