
2026/05/14 4:38
週末に完了できる 3D ガウス放射
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
本テキストは、写真リアルティスな 3D シーンを画像データセットから再構築する機械学習ベースの手法である 3D ガウシアンスプラット(3DGS)の教育的実装を紹介しています。レンダリングされた画像と真の写真を最小限の差にすることでこの目的を達成します。従来の三角形を使用するのではなく、この簡易化されたレンダラーは約 1,000 行の C++ コードに基づいて、「ガウシアンスプラット」と呼ばれる確率分布を 2D スクリーン空間に投影した構成要素から成るシーンをレンダリングします。
システムは、Supersplat リポジトリにある
.ply 形式の複雑なデータを読み込み、これを幾何学情報を中心点、透明度、視点依存色のための球調和関数、回転・スケールパラメータ(四元数と対数空間のスケールとして保存されて対称性を確保する)を格納する GaussianSplat オブジェクトにパースします。光沢素材上でリアルなライティングを実現するために、レンダラーは単一の RGB 値ではなく球調和関数を利用します。
技術的には、各 3D ガウシアン分布は線形近似(ヤコビアン)を使用して 2D スクリーン空間に投影され、2D コバリアンス行列が導出されます。スプラットは分布の質量の約 99.7% をカバーする四角形として描画され、固有値分解に基づいて中心点からオフセットされた頂点を持っています。これらの四角形内の各フラグメントは球調和関数を使用して着色され、特定のガウシアン減衰関数に従ってアルファブレンドされます。半透明性を扱うために、CPU はフレームごとにカメラ空間の深さでスプラットをソートして、奥から手前へ描画します。この教育的なバージョンは、元.tile ベースの CUDA レイザライザーではなく、GPU によるソートと標準的な OpenGL を使用しており、核心となる数学的原則の明確さを最優先しています。
本文
導入
3D ガウシアンスプラッティング(3D Gaussian splatting)とは、以下のような問いに対する答えとなる技術です。「あるシーンの写真のデータセットが与えられた場合、それを 3D に再構成するにはどうすればよいでしょうか?」そのためには、多数の異なるカメラアングルに対して、シーンレンダリングを行い、同じ角度で撮影された画像と照合し、レンダリング画像と真の画像(ground truth)の差分を最小化するようにシーンを更新する機械学習アルゴリズムが用いられます。しかし、従来の 3D レンダラとは異なり、3DGS は三角形などの primitives を使用せず、代わりに「Gaussian splat」と呼ばれるオブジェクトを採用しています。これにより、レンダリングアルゴリズムも 3DGS に特化したものとなっています。
本記事では、約 1000 行のコードから簡易的なレンダラを実装することで、3D ガウシアンスプラッティングがどのように機能するかを解説します。主な動機は、3DGS の数式に関する直感的な理解を深めることにあります。線形代数、確率論、およびコンピュータグラフィックスの基礎知識があることを推奨します。
レンダラは C++ と OpenGL を用いて記述されています。コードはすべて GitHub に公開していますが、本チュートリアルではどのグラフィックエンジン(WebGPU、Metal、DirectX など)でも再現できるようにできるだけ一般的な構成にしています。本チュートリアルの後半では、以下のようなガウシアンシーンをリアルタイムでレンダリングできるようになります:
また、WASD キーとマウスを使って操作できるインタラクティブな WebGPU ビジュアル化も用意しています。 本記事はレンダリング部分のみを扱い、トレーニング(学習)部分は含まれません。ただし、Kerbl 他(2023)の元の 3DGS レンダラには、トレーニングパイプラインと密接に関連する技術的な決定事項(微分可能性、半正定値共分散行列の保持など)が存在しており、これらについても触れていきます。
3DGS シーンを読み込む
まず、レンダリングするためのシーンが必要です。本記事執筆時点では、高品質な 3DGS シーンを見つけることは依然として容易ではありませんが、幸いにも「Supersplat」というサイトからスプラットをダウンロードできるようになり、こちらを利用します。今回はトマトのプレートというシーンを採用しますが、これは比較的軽量(約 20 万個のスプラットのみ)であるためです。お好みのシーンをご自由に使用してください。ただし、構築するレンダラは大規模なシーンには最適化されていない点にご留意ください。
次に、Supersplat からダウンロードしたガウシアンスプラットシーンを読み込みます。これによりシーンの向きが正しいか確認でき、また、より重要なのは「スプラットとは実際になのか」という第一歩的な理解を得ることができるからです。一般的な形式は
.ply であり、これ専用のローダーを ply_loader.h に実装しています。このローダーは .ply ファイルを読み込み、GaussianSplat オブジェクトの配列とし、さらにそれを Scene でラップします:
constexpr int SH_COUNT = 16; constexpr int SH_CHANNEL_COUNT = 3; constexpr int SH_FLOAT_COUNT = SH_COUNT * SH_CHANNEL_COUNT; struct GaussianSplat { glm::vec3 centroid = glm::vec3(0.0f); float opacity = 0.0f; std::array<float, SH_FLOAT_COUNT> sphericalHarmonics = {}; std::array<float, 3> scale = {0.0f, 0.0f, 0.0f}; std::array<float, 4> rotation = {1.0f, 0.0f, 0.0f, 0.0f}; }; struct Scene { std::vector<GaussianSplat> splats; };
これらを分解して解説します:
- centroid: スプラットのワールド空間での位置を示します。通常のグラフィックスのモデル−ビュー−プロジェクションパイプライン(Model-View-Projection)では、モデルデータがモデル空間で表現されるのに対し、ここではワールド空間のまま扱われます。したがって、3DGS パイプラインにはモデル行列は存在せず、ビュー行列(3D ワールド空間 → 3D カメラ空間)とプロジェクション行列(3D カメラ空間 → 2D スクリーン空間)のみを用います。
- scale と rotation: スプラットの幾何形状を表しますが、後ほど詳しく説明します。
- opacity と sphericalHarmonics: スプラットの視認性と色を表しますが、これも後ほど詳しく説明します。
トレーニング済み 3DGS シーンにおいては、これらの値はトレーニング期間中に最適化されます:
- 既知のカメラアングルからスプラットをレンダリングし、結果をトレーニング写真と比較し、
- その画像誤差をバックプロパゲーションして、各スプラットの centroid、scale、rotation、opacity、および色係数に反映させることにより行われます。
最初の Sanity Check として、3DGS シーンを読み込み、各スプラットの centroid を
GL_POINTS で描画(例えばポイントクラウドとして)してみましょう:
Scene scene = loadPly("scene.ply"); std::vector<glm::vec3> centroids; centroids.reserve(scene.splats.size()); for (const GaussianSplat& splat : scene.splats) { centroids.push_back(splat.centroid); } GLuint vao = 0; GLuint vbo = 0; glGenVertexArrays(1, &vao); glGenBuffers(1, &vbo); glBindVertexArray(vao); glBindBuffer(GL_ARRAY_BUFFER, vbo); glBufferData(GL_ARRAY_BUFFER, centroids.size() * sizeof(glm::vec3), centroids.data(), GL_STATIC_DRAW); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(glm::vec3), nullptr); glDrawArrays(GL_POINTS, 0, static_cast<GLsizei>(centroids.size()));
色の追加:球調和関数(Spherical Harmonics)
これでポイントクラウドができあがりました。今度は色を追加しましょう!3DGS がそれほど映える理由の一つは、視点依存のカラーを捉えている点にあります。つやのあるオブジェクトのハイライトや微かな反射は、カメラが動くにつれて変化します。これは、単一の RGB 値ではなく球調和関数(SH)として色を保存することで実現されています。これにより、各スプラットの色が視聴方向に依存し、光沢系材料にとって有益となります。
本記事では SH の詳細には立ち入りませんが、直感的には比較的シンプルです:これは球面上の任意の関数を基底関数を使って表現する方法であり、フーリエ級数に似ています。これにより、各スプラットに対して方向 $(\theta, \phi)$ における RGB カラーを出力する関数 $\mathrm{rgb}(\theta, \phi)$ を持つようになります。
その定義は以下の通りです: $$ \mathrm{rgb}(\theta, \phi) = \sum_{\ell=0}^{L}\sum_{m=-\ell}^{\ell} c_{\ell m}Y_{\ell m}(\theta,\phi) $$
ここで、$Y_{\ell m}$ は SH 基底関数、$c_{\ell m}$ はスプラットに保存されている RGB 係数です。
SH についてより視覚的な解説が必要な場合は、「Visual Notes on Spherical Harmonics」をご参照ください。
特定のカメラビューからの RGB を取り戻すためには、カメラからスプラットの centroid への正規化された方向を取り出し、その方向で SH 基底を評価し、各基底値に保存された RGB 係数を掛けた後、結果を加算します。さらに +0.5 のバイアスを適用する(ゼロセントの SH 出力が黒ではなく中間グレーの周りに配置されるため)ことで、[0, 1] にクリッピングします。
シェーダーでの操作としては基本的には以下となります:
vec3 rgb = vec3(0.5); for (int i = 0; i < 16; ++i) { rgb += shCoefficient[i] * shBasis(i, direction); } rgb = clamp(rgb, 0.0, 1.0);
実際のシェーダーでは SH 基底関数を明示的に出力するように書かれていますが、ここではスペース不足で割愛します。これで色付きのポイントクラウドになりました。既に色が視聴方向に基づいてどのように変化するかお分かりいただけるでしょう!
ガウシアン分布
ポイントクラウドを置いて、実際にスプラットの世界に入りましょう。しかしそれには少し数学的な準備が必要です。 3D ガウシアンスプラッティングにおける主な数学的コンセプトは…まさにガウス(Gauss)です!本記事が確率論の講座を提供する意図はありませんが、ガウス分布に関するいくつかの要素については頭においておくことが重要です。
おそらく 1 次元ガウス分布には出会ったことでしょう。これは平均 $\mu\in\mathbb{R}$ と標準偏差 $\sigma \geq 0$ でパラメータ化された確率分布です: $$ p(x) = \frac{1}{\sigma\sqrt{2\pi}}\exp\left(-\frac{(x-\mu)^2}{2\sigma^2}\right) $$
平均に中心を置いた一次元ガウス分布;標準偏差はベル曲線の幅を制御します。
平均 $\mu$ と分散 $\sigma^2$ を持つ 1D ガウス分布
この分布はさらに 2 次元または 3 次元にも拡張できます。d 次元ガウスの確率密度関数は以下の通りです: $$ p(x) = \frac{1}{\sqrt{(2\pi)^d|\Sigma|}} \exp\left(-\frac{1}{2}(x-\mu)^\top\Sigma^{-1}(x-\mu)\right) $$
3 次元では、$\mu$ は 3 次元ベクトルとなり centroid と呼ばれます。これは先に読み込んだ
GaussianSplat.centroid です。$\mu$ の幾何学的解釈は単にワールド空間におけるスプラット的位置です。私たちの標準偏差 $\sigma$ は $3\times 3$ の正半定値行列 $\Sigma$、共分散行列に変化します。
一般的に、平均 $\mu$ と共分散 $\Sigma$ を持つガウスについて話す場合、$\mathcal{N}(\mu,\Sigma)$ と記述します。$\mathcal{N}$ は正規分布を示しており、ガウス分布のもう一つの呼び方です。
これが 2 次元と 3 次元でどのように見えるか:
2D ガウス分布の等密度輪は、その平均を取り囲む同心楕円になります。
2D ガウス分布の等密度輪は、$\Sigma$ の固有ベクトルと固有値から導かれる同心楕円として表されます。
3D ガウス分布は楕体としてワールド空間に浮かびます。
3D ガウス分布は 2D ケースと同様であり:等密度面は楕体となります。
すでに 3D ガウシアンスプラッティングがどのように機能するかを理解できます:
- 3 次元ガウス分布 $\mathcal{N}(\mu, \Sigma)$ からスタート。
- それを 2 次元空間に射影し、2 次元ガウス分布 $\mathcal{N}(\mu_{2D}, \Sigma_{2D})$ に変換。
- $(\mu_{2D}, \Sigma_{2D})$ が生み出す楕円を描画。
3DGS のレンダリングパイプライン:3D ガウスを 2D ガウスに射影し、その 2D フットプリントをピクセルにラスター化します。
スプラットは 3D で確率分布として存在し、スクリーン空間に射影された 2D 分布になり、最後に非常に末尾の段階で初めてピクセルへと変換されます。
シーンの一つ一つのスプラットについてこの操作を繰り返すと、それが 3DGS シーンのレンダリングになります!ただし、ここにはいくつかの難しい部分があります。 この段階で理解すべき主なアイデアは、ステップ 1 と 2 では三角形のような具体的な幾何学的オブジェクトではなく、確率分布と対峙しているという点です。描画可能なジオメトリになるのは最終ステップまで待つ必要があります。これは今時点では少し抽象的に聞こえるかもしれませんが、すぐに説明します!
3D ガウス分布の再パラメータ化
共分散行列は対称であり、かつ半正定値(PSD)である必要があります。直感的には、空間のある軸に沿って引き延ばすことはできますが、負の分散を生み出すことはできません。これは重要です。なぜなら共分散が楕円のサイズと向きを制御するからです。
3D ガウス分布は $\mu\in\mathbb{R}^3$ と $\Sigma\in S_+^3$($3\times 3$ 対称正半定値行列の集合)でパラメータ化されます。$\Sigma$ の重要な性質の一つは、回転行列 $R$ とスケール行列 $S$ が存在して以下の関係が成り立つことです: $$ \Sigma = RS(RS)^\top $$
なぜこれが成立するかは別巻(アペンディクス)を参照してください。この結果の逆も真です:任意の回転行列 $R$ とスケール行列 $S$ に対して、$\Sigma = RS(RS)^\top$ は保証された共分散行列となります。
このコンパクトなプライミティブが 3DGS が実用的である理由の一つです。各スプラットの幾何学は centroid、3 つのスケール、そして rotation で記述されるため、トレーニングコードではスプラットごとの最適化パラメータとしてごく少数のジオメトリパラメータだけを持つことになります。私たちの
GaussianSplat 構造を思い出してください。それは完全な $3\times 3$ の共分散行列ではなく、まさにこの表現(scale と rotation メンバ)を使用しています!
- scale: log-空間の 3 つの float です。
を適用すると、それらはスケール行列 $S$ の対角成分になります。exp(scale) - rotation: クォータニオンをエンコードする 4 つの float です。これは回転行列 $R$ を保存するコンパクトな方法です。クォータニオンの詳細には触れませんが、オンラインで優れたリソースが多数あります。
なぜこれが重要なのか?3DGS シーンはトレーニングループを通じて作成され、特定のカメラアングルでのレンダリング画像と真の画像との誤差をバックプロパゲーションすることで各ガウシアンパラメータを更新されます。もし更新中に $\Sigma$ の要素を直接変更すると、更新後の行列が依然として PSD であるとは保証されず、共分散行列ではなくなる可能性があります。しかし、スケールと回転パラメータを更新すれば、再構築された $\Sigma$ は確実に共分散行列となります。
スプラットのレンダリング
先ほど見たように、私たちのグラフィックプライミティブは確率分布です。点や三角形とは異なり、標準的なラスターパイプラインを使って直接このようなプライミティブを描画することはできません。 スプラットを画面に乗せる単純な方法として、分布からランダムに点をサンプリングし、それらを描画することが考えられます。これは centroid 付近では高密度で、端側では希薄という点の雲のような見た目になります。このアプローチの問題点は非効率でノイズが溜まりやすいことです。各スプラットに対して、楕円が滑らかに見えるだけの十分なランダムサンプルを生成する必要があり、多くのサンプルを使わない限り結果は仍然チカチカします。
我々はすでに 3DGS レンダリングの核となるアイデアを述べています:この 3D 確率分布を持ち込み、2D スクリーン空間に射影し、その後だけ楕円として描画するというものです(これは 3DGS の美しいアイデアです:パイプラインの最終段階まで確率分布として扱い、2D になった時点で初めてその分布を実際レンダリング可能なものへと実体化させます)。
微分可能性に関する注記
元の 3DGS レンダラはトレーニング中に使用されるため微分可能であるように設計されています。本チュートリアルのレンダラは表示専用ですが、これから使う数学はその制約から来ています:スプラットを滑らかに保ち、分布として射影し、最後にピクセルへと変換するというものです。
centroid の射影
ガウシアン centroid をワールド空間からスクリーン空間に射影することは非常に単純です。なぜならそれは単なる 3D 空間の一点だからです。 モデル−ビュー−プロジェクション変換を適用する必要があります。前述した通り、3DGS シーンはすでにワールド空間にあるため、モデル行列は単位行列(つまりモデル変換をスキップできる)となります。次にビュー行列を適用してカメラ空間へ移動させ、プロジェクション行列とペルスペクティブデビジョンによりスクリーン上に移動させます。これで $\mu_{2D}\in\mathbb{R}^2$、すなわちスクリーン空間の 2 次元ベクトルを得ます。
共分散行列の射影
この部分の目標は、ワールド空間で生活していた共分散行列をスクリーン空間で生活する 2D 共分散行列に変換することです。これが 3DGS レンダリングの核心です!
共分散の再構築
最初のステップは、クォータニオンとスケール行列から 3D 共分散行列 $\Sigma$ を再構築することです。前述したように、$\Sigma = RS(RS)^\top$ です。
glm::mat3 buildCovariance(const GaussianSplat& splat) { glm::quat q(splat.rotation[0], splat.rotation[1], splat.rotation[2], splat.rotation[3]); glm::mat3 R = glm::mat3_cast(glm::normalize(q)); // クォータニオンから 3x3 回転行列へ glm::vec3 sigma(std::exp(splat.scale[0]), std::exp(splat.scale[1]), std::exp(splat.scale[2])); glm::mat3 S = glm::mat3(sigma.x, 0.0f, 0.0f, // 対角スケール行列 0.0f, sigma.y, 0.0f, 0.0f, 0.0f, sigma.z); glm::mat3 RS = R * S; return RS * glm::transpose(RS); // Sigma = (RS)(RS)^T }
ビュー変換の適用
ここが楽しい部分です:$\Sigma$ をスクリーン空間にどのように射影するか?再び、標準的な MVP パイプラインを適用したいと考えています。
モデル: 前述した通り、ガウシアンスプラットはすでにワールド空間で定義されているため、モデル行列は単位行列です。
ビュー: MVP パイプラインの次のステップはビュー行列です。標準的なビュー行列は
glm::lookAt() から得られる回転と並進を結合しています:
$$
V =
\begin{pmatrix}
r_x & r_y & r_z & -\mathbf{r}\cdot\mathbf{p} \
u_x & u_y & u_z & -\mathbf{u}\cdot\mathbf{p} \
-f_x & -f_y & -f_z & \mathbf{f}\cdot\mathbf{p} \
0 & 0 & 0 & 1
\end{pmatrix}
$$
ここで $\mathbf{r}$、$\mathbf{u}$、$\mathbf{f}$ はカメラの右軸、上軸、前方軸で、$\mathbf{p}$ はカメラ位置です。並進は centroid 周りの広がりを記述する共分散には影響しないため、ビュー行列の回転成分のみを維持して共分散行列に適用します。数学的には、ビュー行列の上左にある $3\times 3$ ブロックのみを保持することに相当します: $$ M = V_{3\times 3} = \begin{pmatrix} r_x & r_y & r_z \ u_x & u_y & u_z \ -f_x & -f_y & -f_z \end{pmatrix}
glm::mat4 viewMatrix = glm::lookAt(cameraPosition, cameraPosition + cameraForward, cameraUp); // ビュー行列の 3x3 回転ブロックを取り出す(並進列を捨てる)。 glm::mat3 M = glm::mat3(viewMatrix); // 共分散をアイスペースに変換:Sigma' = M Sigma M^T glm::mat3 Sigma_eye = M * Sigma * glm::transpose(M);
この時点で、我々はガウス分布を変換してワールド空間からアイスペース(カメラの前面)へ移動させました。$\Sigma' = M\Sigma M^\top$ を得ています。
待ってください、なぜ $\Sigma' = M\Sigma M^\top$ をするのでしょうか?なぜ通常グラフィックパイプラインで行うような単なる $\Sigma' = M\Sigma$ にしないのでしょうか?その理由は、我々が確率分布と操作しており、具体的な 3D オブジェクトではないという事実にあります。実際、「ガウス分布を $M$ を適用する」と言うのは、この分布に従うすべてのランダムベクトルに線形変換を適用することを意味します。もし $X \sim \mathcal{N}(\mu, \Sigma)$ がガウス分布に従うランダムベクトルならば、任意の行列 $A$ に対して次が成り立ちます: $$ AX \sim \mathcal{N}(A\mu, A\Sigma A^\top) $$
証明は別巻に。したがって、変換されたベクトル $AX$ の共分散は $\Sigma' = M\Sigma M^\top$ となり、これがアイスペースでスプラットの共分散となる理由です。
プロジェクション
グラフィックパイプラインの次のステップはペルスペクティブ投影です。目標は、カメラ空間の 3D ガウスをスクリーン空間の 2D ガウスに射影する関数 $f$ を見つけることです。ペルスペクティブ投影を実行するだけでなく、以下を満たすために $f : \mathbb{R}^3 \rightarrow \mathbb{R}^2$ は線形である必要があります: $$ f(X) \sim \mathcal{N}(\mu_{2D}, \Sigma_{2D}) $$
つまり、射影された分布も依然として 2D ガウスです。線形性が重要です:それが投影後も平均と共分散のみを使うことができるのを可能にします。もし投影が完全に非線形であれば、射影された形状は正確にはガウスではなくなり、単一の 2D 共分散行列ではスプラットを説明するのに十分でなくなります。
問題はペルスペクティブ投影が線形ではないことです。カメラ空間の点 $(x_e, y_e, z_e)$ を $z=n$ のニアプレーンに射影すると、以下のように計算されます: $$ x_p = -\frac{n x_e}{z_e}, \qquad y_p = -\frac{n y_e}{z_e}, \qquad z_p = -\left\lVert (x_e, y_e, z_e)^\top \right\rVert $$
(詳細は songho.ca を参照)。$z_e$ による除算が非線形を生み、射影された分布は一般的にガウスではなくなります。
アイデアはもともと 2001 年の EWA Volume Splatting から来ており、カメラ空間で centroid $\mu_c$ 周りでペルスペクティブ投影の 1 次テイラー展開を使って局所的に線形化することです: $$ f(x) \approx f(\mu_c) + J(\mu_c)(x - \mu_c) $$
ここで $J$ はカメラ空間での centroid $\mu_c$ で評価されたペルスペクティブ投影のヤコビアンです。これは centroid の近傍で有効な良い局所線形近似を与え、ガウス分布の大部分が住んでいる場所に正確に一致します。$\mu_c = (x_c, y_c, z_c)$ で評価されたヤコビアンは: $$ J = \begin{pmatrix} -\frac{f_x}{z_c} & 0 & \frac{f_x x_c}{z_c^2} \ 0 & -\frac{f_y}{z_c} & \frac{f_y y_c}{z_c^2} \ 0 & 0 & 0 \end{pmatrix} $$
ここで $f_x$ と $f_y$ はピクセル単位の焦点距離で、OpenGL プロジェクション行列の要素 $p_{00}$ と $p_{11}$ から派生しています: $$ f_x = p_{00} \frac{W}{2}, \qquad f_y = p_{11} \frac{H}{2} $$
$p_{00} = \frac{1}{\text{aspect}\times\tan(\text{fov}/2)}$ と $p_{11} = \frac{1}{\tan(\text{fov}/2)}$(詳細は songho.ca を参照)。
ゼロになっている最後の行は偶然ではありません:それは 2D への射影時に $z$ 成分を捨てることを反映しており、後で左上の $2\times 2$ ブロックを取り出して $\Sigma_{2D}$ を得られるのを可能にするまさにその点です。Remember: 私たちはまだ分布レベルで作業しています。$\Sigma$ 行列を直接射影しているのではなく、この分布に従うランダムベクトルを射影しているのです。$J$ が $f$ の線形近似であり、任意の行列 $A$ に対して: $$ AX \sim \mathcal{N}(A\mu, A \Sigma A^\top) $$
そしてテイラー近似中の定数オフセットは平均しか変更せず共分散は変更しないため、以下が成り立ちます: $$ f(X) \approx \mathcal{N}\left(f(\mu_c), J\Sigma_{\text{eye}}J^\top\right) $$
前のステップのビュー変換 $M$ と組み合わせると、完全な射影された共分散は: $$ \Sigma_{2D} = J M \Sigma (JM)^\top $$
$J$ の最後の行がゼロ($z$ 成分を捨てる)であるため、最終的な 2D 共分散行列を得るために左上の $2\times 2$ ブロックを取り出します。これで完全な 2D 空間で射影された分布 $\mathcal{N}(\mu_{2D}, \Sigma_{2D})$ を持っています: $$ \mu_{2D} = \operatorname{viewport}\left(\frac{P V \mu}{w}\right), \qquad \Sigma_{2D} = \left(J V_{3\times 3}\right)\Sigma\left(J V_{3\times 3}\right)^\top $$
ここで $P$ はプロジェクション行列、$V$ はビュー行列、$w$ はペルスペクティブデビジョン前のホモジニー座標、$J$ はカメラ空間での centroid で評価されたものです。
2D ガウス分布の描画
今や $\mathcal{N}(\mu_{2D}, \Sigma_{2D})$ を持っています。これで画面にどのように描画すればよいでしょうか? 単純なアプローチとしては、各ピクセルで確率密度関数を評価することですが、これは Yifan 他(2019)などの微分可能ポイントスプラッティング手法で行われていますが非常に遅いです:大多数のピクセルは与えられたスプラットからの寄与は無視できず、評価コストを支払う必要があるからです。元の 3DGS パーの解決策は、スプラットの周りに四角形(クアッド)を構築し、その内側のみフラグメントをシェーディングすることで、それぞれ球調和関数を使って着色するというものです。
境界四角形の構築
ガウス分布によって取り得る値の「多く」が入った四角形を作る必要があります。共分散行列の性質(もう一度!)を使います。$\Sigma_{2D}$ が対称正半定値であるため、固有値は閉じた形で求まります: $$ \lambda_{1,2} = \frac{\mathrm{tr}(\Sigma_{2D})}{2} \pm \sqrt{\frac{\mathrm{tr}(\Sigma_{2D})^2}{4} - \det(\Sigma_{2D})} $$
そして単位固有ベクトル $e_1 = v_1/\lVert v_1\rVert$ において $v_1 = (\Sigma_{12}, \lambda_1 - \Sigma_{11})$、正統性により $e_2 = (e_{1,2}, -e_{1,1})$ です。これらはガウス楕円の主要軸を定義し、固有値は各軸に沿った二乗広がりを与えます。
$e_i$ 沿いで距離 $t$ のガウス値は $\exp\left(-t^2 / 2\lambda_i\right)$ です。スプラットを描画するには $3\sigma$ の半径を使います。1 次元ではガウスの質量の約 99.7% が平均から 3 つの標準偏差以内に位置します;ここではそれを投影された楕円の 2 つの主要軸について独立に適用します。これは厳密な 2D 確率主張ではありませんが、すでにガウシアン寄与が非常に小さい実用的なカットオフを与えます。
したがって、境界四角形の半軸を以下のように定義します: $$ b_1 = 3\sqrt{\lambda_1}e_1, \qquad b_2 = 3\sqrt{\lambda_2}e_2 $$
正確なカットオフは実装の選択です;ここでは元の 3DGS パーと一致するように $3\sigma$ を使用しています。四角形は $b_1$ と $b_2$ で張られる平行四辺形で、三角形に分割されます。その 4 つの隅はテンプレート座標 $p \in \lbrace(-1,-1), (1,-1), (1,1), (-1,1)\rbrace$ パラメータ化されています。
共有される $[-1, 1]^2$ テンプレート四角形は、ピクセル空間の半軸 $b_1 = 3\sqrt{\lambda_1}e_1$ と $b_2 = 3\sqrt{\lambda_2}e_2$ によって伸長と回転されます。
クアッド配置:頂点シェーダー
興味深い実装の詳細として、単に射影された centroid を
gl_Position に書き込んでそこで止めるわけではありません。まずその正規化デバイス座標(NDC)を計算し、その後四角形の 4 つの頂点をオフセットします:
$$
\text{clip} = P \cdot M_{\text{view}} \cdot \mu = (x_c, y_c, z_c, w_c)^\top
$$
$$ \text{ndc} = \left(\frac{x_c}{w_c}, \frac{y_c}{w_c}, \frac{z_c}{w_c}\right) \in [-1,1]^3 $$
我々は
gl_Position に依存するのではなく手動でこれを計算しているのは、クアッドを配置するために NDC 座標が必要だからです。各頂点は centroid からその隅のオフセット $\Delta_{ab} = a b_1 + b b_2$($a, b \in \lbrace -1,1\rbrace$)によってオフセットされます。$\Delta_{ab}$ はピクセル単位なので、NDC への変換を行います:
$$
\Delta_{\text{ndc}} = \left(\frac{\Delta_x}{W/2}, \frac{\Delta_y}{H/2}\right)
$$
これにより最終的な頂点位置が得られます: $$ p_{\text{clip}} = \left(c_{\text{ndc},x} + \Delta_{\text{ndc},x}, c_{\text{ndc},y} + \Delta_{\text{ndc},y}, c_{\text{ndc},z}, 1.0\right) $$
射影された centroid が $c_{\mathrm{ndc}}$ となり、四角形の各頂点はそれぞれ $b_1$ と $b_2$ の隅特有の組み合わせによってオフセットされます(ピクセルから NDC へ変換)。四角形は平面的なので、4 つの頂点すべてで $z$ は同じです。我々は $w = 1.0$ を設定する手動でペルスペクティブデビジョンを既に実行したためです。頂点シェーダーはまた $(p_x, p_y)$ をバリアンとして渡すので、各フラグメントはその四角形内の插補された位置を受けます。
// 頂点ごとの(共有単位四角形) layout(location = 0) in vec2 quadCorner; // (-1,-1), (1,-1), (-1,1), (1,1) // スプラットインスタンス属性 layout(location = 1) in vec3 splatCentroid; // `centroid` は GLSL キーワードです layout(location = 2) in float opacity; layout(location = 3) in vec3 scale; layout(location = 4) in vec4 rotation; out vec2 splatCoord; out vec3 splatColor; out float splatOpacity; uniform mat4 view; uniform mat4 projection; uniform vec2 viewportSize; // (W, H) ピクセル単位 void main() { vec4 clip = projection * view * vec4(splatCentroid, 1.0); vec3 centerCamera = vec3(view * vec4(splatCentroid, 1.0)); vec3 ndc = clip.xyz / clip.w; // Sigma = RS(RS)^T から exp(scale) と rotation を再構築し、 // 次に Sigma_2D = J M Sigma (JM)^T で射影。 mat3 covariance2D = projectCovarianceToScreen(centerCamera, buildCovariance3D()); float a = covariance2D[0][0] + 0.3; float b = covariance2D[1][0]; float d = covariance2D[1][1] + 0.3; // 2x2 共分散の閉じた形の固有値分解。 float determinant = a * d - b * b; float mid = 0.5 * (a + d); float radius = sqrt(max(mid * mid - determinant, 0.0)); float lam1 = max(mid + radius, 0.01); float lam2 = max(mid - radius, 0.01); vec2 e1 = abs(b) > 0.00001 ? normalize(vec2(b, lam1 - a)) : (a >= d ? vec2(1.0, 0.0) : vec2(0.0, 1.0)); vec2 e2 = vec2(-e1.y, e1.x); // ピクセル単位の 3-sigma 主要半軸。 vec2 b1 = 3.0 * sqrt(lam1) * e1; vec2 b2 = 3.0 * sqrt(lam2) * e2; // ピクセルから NDC へ変換された頂点オフセット。 vec2 deltaPixel = quadCorner.x * b1 + quadCorner.y * b2; vec2 deltaNdc = deltaPixel / (viewportSize * 0.5); gl_Position = vec4(ndc.xy + deltaNdc, ndc.z, 1.0); splatCoord = quadCorner; splatColor = evaluateSphericalHarmonics(); splatOpacity = 1.0 / (1.0 + exp(-opacity)); }
クアッド描画:フラグメントシェーダー
テンプレート位置 $(p_x, p_y)$ のフラグメントは、画面位置 $x = c + p_x b_1 + p_y b_2$ にあります。その centroid からの距離(主要軸に沿った)は: $$ d_1 = 3\sqrt{\lambda_1}p_x, \qquad d_2 = 3\sqrt{\lambda_2}p_y $$
$D = \mathrm{diag}(\lambda_1, \lambda_2)$ と $d = (d_1, d_2)^\top$ とすると、このフラグメントでのガウス値は: $$ G(p_x, p_y) = \exp\left(-\tfrac{1}{2}d^\top D^{-1} d\right) = \exp\left(-\frac{1}{2}\left(\frac{d_1^2}{\lambda_1} + \frac{d_2^2}{\lambda_2}\right)\right) $$
我々はガウス密度の $1/\sqrt{2\pi|\Sigma|}$ 正規化を捨てることにします。なぜなら絶対的な確率ではなく 0 から 1 への減衰だけが関心だからです。$d_i = 3\sqrt{\lambda_i}p_i$ を代入すると: $$ G(p_x, p_y) = \exp\left(-4.5(p_x^2 + p_y^2)\right) $$
簡単な sanity check: $G(0,0) = 1$(中心、完全に不透明)かつ $G(\pm 1, \pm 1) = e^{-9} \approx 0$(隅、ほぼ透明)。最終的なフラグメント出力は: $$ \text{fragment} = \left(\mathrm{rgb}, \alpha_{\text{base}}\exp(-4.5(p_x^2+p_y^2))\right) $$
ここで $\mathrm{rgb}$ は SH 色再構築から来ますし、$\alpha_{\text{base}}$ は sigmoid(opacity) です。
in vec2 splatCoord; in vec3 splatColor; in float splatOpacity; out vec4 FragColor; void main() { // G(px, py) = exp(-4.5(px^2 + py^2)). // d_i = 3*sqrt(lambda_i)*p_i を代入して // exp(-d_i^2 / (2*lambda_i)) に導出。 float power = -4.5 * dot(splatCoord, splatCoord); if (power < -9.0) discard; // exp(-9) はほぼ透明 float alpha = min(0.99, splatOpacity * exp(power)); if (alpha < 0.001) discard; FragColor = vec4(splatColor, alpha); }
struct GpuGaussian { glm::vec3 centroid; float opacity; std::array<float, 3> scale; std::array<float, 4> rotation; }; // すべてのスプラットで共有される単位クアッドジオメトリをアップロード。 static const glm::vec2 quadCorners[] = { {-1.0f, -1.0f}, { 1.0f, -1.0f}, {-1.0f, 1.0f}, { 1.0f, 1.0f}, }; GLuint vao, quadVBO, splatVBO; glGenVertexArrays(1, &vao); glBindVertexArray(vao); glGenBuffers(1, &quadVBO); glBindBuffer(GL_ARRAY_BUFFER, quadVBO); glBufferData(GL_ARRAY_BUFFER, sizeof(quadCorners), quadCorners, GL_STATIC_DRAW); glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, nullptr); glEnableVertexAttribArray(0); // 各スプラットインスタンスデータのアップロード(ロケーション 1..4 は除算子 = 1 でバインド)。 glGenBuffers(1, &splatVBO); glBindBuffer(GL_ARRAY_BUFFER, splatVBO); glBufferData(GL_ARRAY_BUFFER, gpuGaussians.size() * sizeof(GpuGaussian), gpuGaussians.data(), GL_DYNAMIC_DRAW); // ... 各属性に対して glVertexAttribPointer + glVertexAttribDivisor(loc, 1) ... // すべてのスプラットを単一のコールで描画。 glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, (GLsizei)gpuGaussians.size());
この時点でシーンは良いように見えますが、まだ何か問題があります:いくつかのスプラットは誤った順序でブレンドされています。各スプラットは半透明のため、フラグメントのブレンド順序が変わると最終色が変化します;何らかのソートが必要です。
スプラットのソート
3DGS レンダラの最終ステップはスプラットのソートです。通常のレンダライザーでは可視性は深度テストでよく処理されますが、3DGS についてはそれだけでは不十分です。なぜならスプラットは半透明であり、ピクセルは同じレイを沿った複数のスプラットから色を受け取ることができるため;最も近いフラグメントの後ろにあるすべてを拒絶するのは間違っています。公式な 3DGS レンダラは、GPU ソートと前側へのブレンドを持つタイルベースの CUDA レンダライザーを使用しています。これはこのチュートリアルの範囲外です。
代わりに、各フレームでスプラットを CPU でカメラ空間深さでソートし、その順序を GPU にアップロードし、その後標準的なアルファブレンドを使って後側から前側へ描画します。これは公式な方法よりも明らかに非効率ですが、それほど大きくないシーンでは問題ありません。OpenGL カメラ空間ではカメラは負の $z$ 軸を見ているため、遠いスプラットはより負の $z$ を持っています。カメラ空間 $z$ でソートすることで遠近順(far-to-near)を得ます:
struct SplatSortEntry { float cameraZ = 0.0f; std::size_t splatIndex = 0; }; void sortSplatsBackToFront(const Scene& scene, const glm::mat4& view, std::vector<SplatSortEntry>& sortEntries) { sortEntries.resize(scene.splats.size()); for (std::size_t i = 0; i < scene.splats.size(); ++i) { glm::vec4 cameraCentroid = view * glm::vec4(scene.splats[i].centroid, 1.0f); sortEntries[i] = {cameraCentroid.z, i}; } std::sort(sortEntries.begin(), sortEntries.end(), [](const SplatSortEntry& a, const SplatSortEntry& b) { return a.cameraZ < b.cameraZ; // 遠い方から先 }); }
その後、各フレームでその順序でアップロード配列を再構築します:
glm::mat4 view = camera.getViewMatrix(); sortSplatsBackToFront(scene, view, sortEntries); sortedGaussians.resize(scene.splats.size()); sortedSphericalHarmonics.resize(scene.splats.size() * SH_FLOAT_COUNT); for (std::size_t outputIndex = 0; outputIndex < sortEntries.size(); ++outputIndex) { const GaussianSplat& splat = scene.splats[sortEntries[outputIndex].splatIndex]; sortedGaussians[outputIndex] = makeGpuGaussian(splat); std::copy(splat.sphericalHarmonics.begin(), splat.sphericalHarmonics.end(), sortedSphericalHarmonics.begin() + outputIndex * SH_FLOAT_COUNT); } uploadSortedSplats(buffers, sortedGaussians, sortedSphericalHarmonics); glDisable(GL_DEPTH_TEST); glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, static_cast<GLsizei>(scene.splats.size()));
これはこのチュートリアルシーンには十分ですが、大規模なシーンではかなり遅くなります。最終結果はこう見えます:
結論
トレーニング済み 3DGS シーンの表示専用レンダラを構築しました:PLY を読み込み、centroid を描画し、視点依存の SH カラーを再構築し、3D 共分散を再構築してそれを 2D 共分散に射影し、各スプラットをスクリーン空間クアッドとして描画し、最後に半透明なスプラットをソートしてブレンド動作が正しくなるようにします。これは依然として意図的にシンプルなレンダラです。元の論文の CUDA レンダライザーは特にタiling、ソート、合成の周りで非常に洗練されています。しかし核心となるアイデアはすでにここに:3DGS シーンは滑らかな 3D 分布を 2D に投影してレンダリングされるのです。
気に入っていただけましたでしょうか!コードは GitHub にあります。また X でフィードバックも歓迎します:@b__feldman
アペンディクス
回転とスケールによる共分散分解
設計により、共分散行列は対称かつ正半定値(PSD)です。対称とは $\Sigma_{ij}=\Sigma_{ji}$ であり、PSD とは $x^\top \Sigma x \geq 0 \quad \forall x \in \mathbb{R}^3$ です。スペクトル定理により、そのような行列は直交基底において対角化でき、以下のように書けます: $$ \Sigma = R \Lambda R^\top $$
ここで $R$ は直交行列で、$\Lambda$ は非負値を持つ対角行列(すなわちスケール行列)、$\Lambda = \operatorname{diag}\left(\lambda_1, \lambda_2, \ldots, \lambda_m\right)$ です。もし $\Lambda^{1/2} = \operatorname{diag}\left(\sqrt{\lambda_1}, \sqrt{\lambda_2}, \ldots, \sqrt{\lambda_m}\right)$ で $S=\Lambda^{1/2}$ を定義すれば、 $$ \Sigma = RS(RS)^\top $$
と書き換えることができます。ここで $R$ は直交で $S$ はスケール行列です。$R$ を回転にできることを選べます:もし $\det R=-1$ なら、$R$ の一つの固有ベクトル列の符号を反転させます。$\Lambda$ が変化しないため、分解によって表される共分散は不変ですが、決定式の符号が変化します。
ガウスベクトルの線形変換
$X \sim \mathcal{N}(\mu, \Sigma)$ をガウスランダムベクトルとし、$Y = AX$ とすると、ここで $A$ は線形変換です。期待値の線形性より: $$ \mathbb{E}[Y] = \mathbb{E}[AX] = A\mathbb{E}[X] = A\mu $$
共分散に対して: $$ \begin{aligned} \mathrm{Cov}(Y) &= \mathbb{E}\left[(Y - A\mu)(Y - A\mu)^\top\right] \ &= \mathbb{E}\left[A(X-\mu)(X-\mu)^\top A^\top\right] \ &= A\mathbb{E}\left[(X-\mu)(X-\mu)^\top\right] A^\top \ &= A\Sigma A^\top \end{aligned} $$
したがって: $$ AX \sim \mathcal{N}(A\mu, A\Sigma A^\top) $$
引用文献
- Kerbl, B., Kopanas, G., Leimkühler, T., and Drettakis, G. (2023). 3D Gaussian Splatting for Real-Time Radiance Field Rendering. ACM Trans. Graph., 42(4).
- Zwicker, M., Pfister, H., van Baar, J., and Gross, M. (2001). EWA Volume Splatting. IEEE Visualization.
- Yifan, W., Serena, F., Wu, S., Öztireli, C., and Sorkine-Hornung, O. (2019). Differentiable Surface Splatting for Point-Based Geometry Processing. ACM Trans. Graph., 38(6).
引用
以下のように引用してください:
Feldman, Benjamin. (May 2026). “3D Gaussian Splatting in a Weekend”. bfeldman.me. https://bfeldman.me/3dgs-weekend/.
または:
@article{feldman2026gs, title = "3D Gaussian Splatting in a Weekend", author = "Feldman, Benjamin", journal = "bfeldman.me", year = "2026", month = "May", url = "https://bfeldman.me/3dgs-weekend/" }