SOGSについて調べるスレ
PlayCanvasがサポートしたことで話題になったSOGSの仕組みが気になったので調べてみる
参考になりそうなリンクたち
SOGS、別のGSの論文もヒットするけど、たぶんSelf-Organizing Gaussianのほうだな
PlayCanvasへの実装はこの方の貢献が大きいらしい
NotebookLMを作ってみた
左右に振ると、特徴的な描画挙動をする
PlayCanvas本家の、SOGS実装プルリク
プルリクで、データを読み込むことに関して気になるファイルは3つ
src/framework/parsers/sogs.js
src/scene/gsplat/gsplat-sogs-data.js
src/scene/gsplat/gsplat-sogs.js
src/framework/parsers/sogs.js
は、テクスチャアセットを読み込むことで、GSplatSogsDataとGsplatResoucesを作成しているだけなのでシンプルだな
ちょっと、一文字変数に対してコメント等で補足がないので読みにくいな……
いや、position, rot, scale, color, shか
src/scene/gsplat/gsplat-sogs-data.js
はしっかりデコード周りの処理が入っているな
src/scene/gsplat/gsplat-sogs.js
はSogsDataをラップしている感じのデータ構造
そもそもPlayCanvas内部ではSplatとSplatDataというデータ構造が分かれていて、
どちらもSplatResourceの中で扱われる
まぁだから、SOGS自体のデータ構造について知りたいのであればGsplatSogsDataの中のデコーダ実装を観ればよいのかな
たぶん、SogsData->Sogs->GsplatResouceという風に右に行くにつれてEnittyに近い概念になっていく
そういえばSOGSではcolorをsh0として扱っているんだな
GeminiCLIにGsplatSogsDataを呼んでもらった
間違ってる部分もあるけどだいたいあってる
ファイルの目的
このファイルは、SOGS (Splat On Gaussian Splat) という形式で圧縮されたGaussian
Splattingのデータを読み込み、デコード(伸長)し、管理するためのものです。Gaussian
Splattingは3Dシーンを表現するための技術で、SOGSはそのデータを効率的に保存するための圧縮形式の一つです。
主な機能
このファイルは主に2つのクラスを定義しています。
1. `GSplatSogsData` クラス
* SOGS形式のデータ全体を管理するコンテナクラスです。
* スプラット(シーンを構成する点のようなもの)の位置、回転、スケール、色、不透明度などの圧縮されたデータを、GPUのテクスチャとして保持します。
* `decompress()` メソッド: このクラスの最も重要な機能の一つです。GPU上のテクスチャに保存されている圧縮データをCPUに読み戻し、完全に伸長された(誰で
も読める形式の)GSplatDataオブジェクトに変換します。
* `reorderData()` / `reorderGpuMemory()` メソッド: レンダリングのパフォーマンスを向上させるために、スプラットのデータを「Morton順序」と呼ばれる順番
に並べ替えます。これにより、カメラに近いデータがメモリ上で近くに配置され、効率的な描画が可能になります。
* calcAabb(): 全てのスプラットを囲む境界ボックス(AABB)を計算します。
2. `GSplatSogsIterator` クラス
* 個々のスプラットデータにアクセスするためのイテレータ(反復処理用の補助クラス)です。
* read(index) メソッドは、指定されたインデックスのスプラットデータをテクスチャから読み出し、圧縮された値(多くは整数)から元の3D座標、回転(クォータ
ニオン)、スケール、色(RGBA)などの浮動小数点数へとデコードします。
* このデコード処理では、量子化(値を特定の範囲の整数に丸めること)や対数スケールなどの様々な圧縮技術が使われており、それらを元に戻す計算が行われてい
ます。
処理の概要
このコードが使われる際の一般的な流れは以下のようになります。
1. SOGSファイルから圧縮データを読み込み、GSplatSogsDataオブジェクトを作成します。データはGPUテクスチャとして保持されます。
2. (オプション)reorderData()を呼び出して、レンダリング効率のためにデータを並べ替えます。
3. レンダリング時には、GPUが直接これらのテクスチャデータを読み込んで高速にシーンを描画します。
4. もしCPU側でスプラットの正確な情報が必要になった場合(例えば、物理演算や他の形式へのエクスポートなど)、decompress()メソッドを呼び出して、誰でも扱える
形式にデータを伸長します。
要約すると、このファイルは効率的なデータ形式であるSOGSを扱うための心臓部であり、データの読み込み、GPU上での管理、高速な並べ替え、そして必要に応じた完全
なデコードといった機能を提供しています。
GeminiCLIと会話したことを調査レポートにしてまとめてもらった
GeminiCLIの調査レポート
gsplat-sogs-data.js の調査メモ
1. ファイルの概要
-
ファイルパス:
src/scene/gsplat/gsplat-sogs-data.js
- 目的: SOGS (Splat On Gaussian Splat) という形式で圧縮されたGaussian Splattingのデータを読み込み、デコードし、レンダリングに適した形式で管理する。
2. 主要な機能とクラス
GSplatSogsData
クラス
- SOGSデータ全体を管理するコンテナ。
- 位置、回転、スケール、色などの圧縮データをGPUテクスチャとして保持する。
-
decompress()
: GPU上の圧縮データをCPUで読み取り、完全に伸長された汎用的なデータ形式 (GSplatData
) に変換する。CPUベースの処理。 -
reorderData()
/reorderGpuMemory()
: レンダリング性能と品質向上のため、スプラットデータをMorton順序に並べ替える。これはGPUシェーダーを利用した最適化処理。
GSplatSogsIterator
クラス
- 個々の圧縮されたスプラットデータにアクセスし、デコードするためのイテレータ。
- 量子化や対数スケールなどの圧縮を元に戻し、元の浮動小数点数データを復元する計算ロジックを持つ。
3. GPU側の処理について
このコードにおける「GPU側の処理」とは、主に以下の点を指す。
-
GPUメモリでのデータ保持:
Texture
オブジェクトとして、圧縮データを直接GPUのVRAM上に保持する。 -
GPUによる並列処理:
reorderGpuMemory
内でシェーダー(GPUで実行されるプログラム)を使い、何十万ものスプラットデータを高速に並べ替える。これは典型的なGPGPU(General-Purpose computing on GPU)の活用例。 -
GPUリソース管理:
RenderTarget
や各種デバイス設定を使い、シェーダーの実行環境を整える。
4. Rustによる再実装の可能性
- 結論: 条件付きで可能だが、完全な置き換えは現実的ではない。
-
Rust化が有効な部分:
-
decompress
メソッド内で行われるような、CPU負荷の高いデータデコード処理。 - Mortonコードの生成ロジック。
- これらをWebAssemblyにコンパイルすることで、JavaScriptよりも高速な実行が期待できる。
-
-
Rust化が困難な部分:
-
Texture
の作成、シェーダーの実行、RenderTarget
の操作など、PlayCanvasのグラフィックスデバイス (device
) を介したGPUとの直接的なやり取り。 - WebAssemblyはサンドボックス内で実行されるため、ブラウザのJavaScriptが管理するGPUコンテキストに直接アクセスできない。
-
-
現実的なアプローチ:
- CPU負荷の高い計算部分をRust (WebAssembly) のライブラリとして実装。
- JavaScript側は「接着剤」として、GPU操作を担当しつつ、必要に応じてRustライブラリを呼び出して計算を委譲するハイブリッド構成が最適。
5. データ並べ替えの役割
-
reorderData
による並べ替え処理は、必須の処理ではなく、オプショナルな最適化である。 -
目的:
- パフォーマンス向上: 3D空間上で近いデータをメモリ上でも近くに配置し、GPUのキャッシュ効率を上げる。
- 品質向上: 半透明のスプラットをより正しい順序で描画(ブレンド)し、見た目のアーティファクトを減らす。
- これがなくても、データのデコードとレンダリング自体は可能。
Rustでデコーダ実装をしてwasm経由で読み込むって言うのをやってみたさがあって
調べていたらimage-rsの中にwebpエンコーダ/デコーダがあったので使えそうだな
こっちは大元 star数がすごいな
SPARKの内部実装も確認してみる
というのも、PlayCanvas以外でSOGS対応しているのはSPARKくらいなんじゃないかな?って感じなので
おそらくsrc\worker.ts
の中でデコーダ処理が始まって、その中のdecodePcSogsが呼ばれるのかなぁ
SPARKでは普通のSOGSとZip化されたSOGSが読める
ここらへんだな
case "decodePcSogs": {
const { fileBytes, extraFiles } = args as {
fileBytes: Uint8Array;
extraFiles: Record<string, ArrayBuffer>;
};
const json = JSON.parse(
new TextDecoder().decode(fileBytes),
) as PcSogsJson;
const decoded = await unpackPcSogs(json, extraFiles);
result = {
id,
numSplats: decoded.numSplats,
packedArray: decoded.packedArray,
extra: decoded.extra,
};
break;
}
case "decodePcSogsZip": {
const { fileBytes } = args as { fileBytes: Uint8Array };
const decoded = await unpackPcSogsZip(fileBytes);
result = {
id,
numSplats: decoded.numSplats,
packedArray: decoded.packedArray,
extra: decoded.extra,
};
break;
}
unpackPcSogs
関数は、src\pcsogs.ts
にあります
export type PcSogsJson = {
means: {
shape: number[];
dtype: string;
mins: number[];
maxs: number[];
files: string[];
};
scales: {
shape: number[];
dtype: string;
mins: number[];
maxs: number[];
files: string[];
};
quats: { shape: number[]; dtype: string; encoding?: string; files: string[] };
sh0: {
shape: number[];
dtype: string;
mins: number[];
maxs: number[];
files: string[];
};
shN?: {
shape: number[];
dtype: string;
mins: number;
maxs: number;
quantization: number;
files: string[];
};
};
pcsogs.ts
のunpackPcSogs
関数定義
function unpackPcSogs(json: PcSogsJson, extraFiles: Record<string, ArrayBuffer>): Promise<{
packedArray: Uint32Array;
numSplats: number;
extra: Record<string, unknown>;
}>
meta情報とファイルの一覧を取得(ファイルはバイナリ)して、
packedArrayとextraなどを返す、ほう
入力は直感的だけど出力は何だ?
packedArrayは、setPackedSplatScales
のような関数の引数として呼ばれている 何らかの書き込みがされているんだろう
setPackedSplatXxx
系の関数は、src\utils.ts
に入ってる
なるほど
つまり内部データとして保持しておくのに、デコードした状態の値をArrayBufferに持っているんだな
packedArray、spzのZIP化していないバージョンみたいな感じだな
ちなみにdecodeImageの実装
async function decodeImage(fileBytes: ArrayBuffer) {
if (!offscreenGlContext) {
const canvas = new OffscreenCanvas(1, 1);
offscreenGlContext = canvas.getContext("webgl2");
if (!offscreenGlContext) {
throw new Error("Failed to create WebGL2 context");
}
}
const imageBlob = new Blob([fileBytes]);
const bitmap = await createImageBitmap(imageBlob, {
premultiplyAlpha: "none",
});
const gl = offscreenGlContext;
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
const framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
texture,
0,
);
const data = new Uint8Array(bitmap.width * bitmap.height * 4);
gl.readPixels(
0,
0,
bitmap.width,
bitmap.height,
gl.RGBA,
gl.UNSIGNED_BYTE,
data,
);
gl.deleteTexture(texture);
gl.deleteFramebuffer(framebuffer);
return { rgba: data, width: bitmap.width, height: bitmap.height };
}
これはOffscreenCanvasのWebGL2コンテキストをとってきて、WebGLTextureを作成してWebGL APIからピクセル情報をとってきている、正確にはデコードをWebGLでやっているというもの
なるほどね、そのほうが早いのかな?
まぁ今回はCPU側で実装してみようかな
unpackSogsの中身って外観はこんな感じ
要は順番に画像をデコードしてピクセルデータを取得して、それをpackedArrayに詰めていく
これはmeans(means_l, means_u)=positionのデコード部分
まずl,uのの意味が何だろうと思っていたら、たぶんupperとlowerなんかな
おそらくpositionはfloat16にエンコードされるんだけど、画像のピクセルの各チャネルはu8なので、
f16をu8×2に分解して保存している。それがu,lの意味だな
これはplaycanvasエンジンのデコーダでも同じ
今更だけど、SOGSのspecってどこかに書いてあるのかな?
今のところPlayCanvasとSPARKの実装から仕様を読み取っている感じなんだけど
positionに嚙まされている式が何だろうって気になったのでプロット
なるほど、これは0付近の値は解像度高くするためのアレかな
ChatGPTの回答
✨ 何に使えるの?
この関数は、よく以下のような文脈で登場するよ:
✅ 1. スムーズな非線形増幅
この式は、x の値が大きくなるにつれて 指数的に増加する。でも、x=0 近傍ではほぼ線形にふるまう。
→ つまり「小さい値はそのまま、大きい値はぐぐっと拡大したい」ときに便利。
たとえば:
オーディオエフェクト(例えばコンプレッサやエンベロープ)
UIのスライダーの入力変換(滑らかに大きくする)
機械学習などでの活性化関数に似た振る舞い
✅ 2. Softplusの変種として
これは実は softplus 関数(log(1 + exp(x)))の仲間みたいなもので、ある意味で sinh や tanh とも形が似てる。
でもこれは左右対称(奇関数)で、x=0 を中心に連続かつ滑らかな関数だよ。
この関数とロジット関数の比較
ロジット関数はシグモイド関数の逆関数なので、値域が
一方関数fはたしかに大きな値になれば無限に発散するんだけど、値域は
余談だけどGoogleのプロットわかりやすいな
逆関数を計算するとこうなる
つまりこの関数がエンコードの時に使われているということ
気持ちとしては大きな値を小さな値にエンコードすることになる(logなので収束はしない)
いや、今見たら
そしてPc/SOGS見てたら、これになるやんって気づいた
ここに同じ式がある
学びだね
ちゃっぴーに聞くと見事に出してくれるな やっぱよく使われるんだね
SPARKのデコード実装を見ていると下記がわかった
実数 | 対数ベース | position(mean) |
正の実数 | 対数関数 | scale |
0-1の実数 | シグモイド関数 | color(sh0) |
PlayCanvasが出している3DGSのファイルを変換できるCLI、
内部でjsdomとwebgpuパッケージを使ってPlayCanvasEngineを動かしている
面白い実装だ
基本的なエンコードの思想というか、ロジックはある程度分かってきた
しかし、SHの仕様がまだわかっていないので調査していく
まずSH関連のファイルとして、shN_centroids.webp
とshN_labels.webp
があるんだよな
なんかコードをざっくり見た感じ、labelsから座標値を割り出してcentroidsから取得していそうだけど、どうなってるんだろう
はがさんのサンプルコードにあったサンプルデータ
これだとshNに関するmeta.jsonの記載はこう
45という数値は、SHが3次までをサポートしていた場合の項数の合計だよな
で、185,761はガウシアンの数
SPARKにおけるshNの処理ってここまでなんだよね
いや、もちっとあったな
if (json.shN) {
const useSH3 = json.shN.shape[1] >= 48 - 3;
const useSH2 = json.shN.shape[1] >= 27 - 3;
const useSH1 = json.shN.shape[1] >= 12 - 3;
if (useSH1) extra.sh1 = new Uint32Array(numSplats * 2);
if (useSH2) extra.sh2 = new Uint32Array(numSplats * 4);
if (useSH3) extra.sh3 = new Uint32Array(numSplats * 4);
const sh1 = new Float32Array(9);
const sh2 = new Float32Array(15);
const sh3 = new Float32Array(21);
const shN = json.shN;
const shNPromise = Promise.all([
decodeImage(extraFiles[json.shN.files[0]]),
decodeImage(extraFiles[json.shN.files[1]]),
]).then(([centroids, labels]) => {
for (let i = 0; i < numSplats; ++i) {
const i4 = i * 4;
const label = labels.rgba[i4 + 0] + (labels.rgba[i4 + 1] << 8);
const col = (label & 63) * 15;
const row = label >>> 6;
const offset = row * centroids.width + col;
for (let d = 0; d < 3; ++d) {
if (useSH1) {
for (let k = 0; k < 3; ++k) {
sh1[k * 3 + d] =
shN.mins +
((shN.maxs - shN.mins) * centroids.rgba[(offset + k) * 4 + d]) /
255;
}
}
if (useSH2) {
for (let k = 0; k < 5; ++k) {
sh2[k * 3 + d] =
shN.mins +
((shN.maxs - shN.mins) *
centroids.rgba[(offset + 3 + k) * 4 + d]) /
255;
}
}
if (useSH3) {
for (let k = 0; k < 7; ++k) {
sh3[k * 3 + d] =
shN.mins +
((shN.maxs - shN.mins) *
centroids.rgba[(offset + 8 + k) * 4 + d]) /
255;
}
}
}
if (useSH1)
encodeSh1Rgb(extra.sh1 as Uint32Array, i, sh1, splatEncoding);
if (useSH2)
encodeSh2Rgb(extra.sh2 as Uint32Array, i, sh2, splatEncoding);
if (useSH3)
encodeSh3Rgb(extra.sh3 as Uint32Array, i, sh3, splatEncoding);
}
});
promises.push(shNPromise);
}
extraって何だろうって思ったら、いまのところshNにしか使われていなさそう
json.shNの型定義
shN?: {
shape: number[];
dtype: string;
mins: number;
maxs: number;
quantization: number;
files: string[];
} | undefined
球面調和関数をちゃんと理解していないので、これがどうなのかわからない
if (useSH1) extra.sh1 = new Uint32Array(numSplats * 2);
if (useSH2) extra.sh2 = new Uint32Array(numSplats * 4);
if (useSH3) extra.sh3 = new Uint32Array(numSplats * 4);
centroidで検索したら、この記事がヒットした
k-means、SOGSの文脈で聞いたことがあるな
しかもmeansって、なんか画像の名前でもあったような positionのやつ
記事曰くk-meansとは
k-meansは与えられたデータをk個のグループに分けるクラスタリングの手法です。k-meansではデータの類似度に基づいてグループ分けを行います。類似度はクラスタの中心(後述)とデータの距離が近ければ類似度が高いと判定します。具体的なアルゴリズムは次の章で説明します。
らしい
あ~~~この記事のk-meansアルゴリズムの説明めっちゃわかりやすいな
反復処理によってクラスタを分けるんだ
もしかしたらpositionでもそれが行われているのかもね
実際、means_uのほうはこんな感じで、境界がわかりやすい
NotebookLMに質問したら、なんかそれっぽい記述があった
ソースでは、ガウシアンの圧縮における「従来のコーディング手法」の一つとして、クラスタリングやベクトル量子化に触れています。例えば、類似した色のガウシアンをクラスター化し、コードブックを作成してインデックスを割り当てることで、ファイルサイズを削減する方法があることが示唆されています
labelsってここでしか使われていないな
const label = labels.rgba[i4 + 0] + (labels.rgba[i4 + 1] << 8);
しかもこの式的に、まずlabelsはRとGしか使われていなくて、
Gがupper、Rがlowerを保存している16bitの値であることがわかる
実際画像を見ても、茶色っぽいことからRBしか使われていなさそう
labels.rgbs
はUint8Arrayなので整数値
なのでlabelには16bit整数UShortが出力されるはず
ここの部分
const label = labels.rgba[i4 + 0] + (labels.rgba[i4 + 1] << 8);
const col = (label & 63) * 15;
const row = label >>> 6;
const offset = row * centroids.width + col;
例えばiを0~100まで変化させるとしてi&63
を計算すると、
最初に0が63個並んで、そのあと63が100個まで(37個?)並ぶ
0と63が63個ずつ連続で並んで切り替わる感じ
63*15は何を示しているんだろうな
ちなみに945になる
このような数列(col, row, offset)
が得られる
出力結果
> for(let i=0; i<3000; i++){
... const col = (i & 63)*15
... const row = i >>> 6
... offset = row * 960 + col
... console.log(col, row, offset)
... }
0 0 0
15 0 15
30 0 30
45 0 45
60 0 60
75 0 75
90 0 90
105 0 105
120 0 120
135 0 135
150 0 150
165 0 165
180 0 180
195 0 195
210 0 210
225 0 225
240 0 240
255 0 255
270 0 270
285 0 285
300 0 300
315 0 315
330 0 330
345 0 345
360 0 360
375 0 375
390 0 390
405 0 405
420 0 420
435 0 435
450 0 450
465 0 465
480 0 480
495 0 495
510 0 510
525 0 525
540 0 540
555 0 555
570 0 570
585 0 585
600 0 600
615 0 615
630 0 630
645 0 645
660 0 660
675 0 675
690 0 690
705 0 705
720 0 720
735 0 735
750 0 750
765 0 765
780 0 780
795 0 795
810 0 810
825 0 825
840 0 840
855 0 855
870 0 870
885 0 885
900 0 900
915 0 915
930 0 930
945 0 945
0 1 960
15 1 975
30 1 990
45 1 1005
60 1 1020
75 1 1035
90 1 1050
105 1 1065
120 1 1080
135 1 1095
150 1 1110
165 1 1125
180 1 1140
195 1 1155
210 1 1170
225 1 1185
240 1 1200
255 1 1215
270 1 1230
285 1 1245
300 1 1260
315 1 1275
330 1 1290
345 1 1305
360 1 1320
375 1 1335
390 1 1350
405 1 1365
420 1 1380
435 1 1395
450 1 1410
465 1 1425
480 1 1440
495 1 1455
510 1 1470
525 1 1485
540 1 1500
555 1 1515
570 1 1530
585 1 1545
600 1 1560
615 1 1575
630 1 1590
645 1 1605
660 1 1620
675 1 1635
690 1 1650
705 1 1665
720 1 1680
735 1 1695
750 1 1710
765 1 1725
780 1 1740
795 1 1755
810 1 1770
825 1 1785
840 1 1800
855 1 1815
870 1 1830
885 1 1845
900 1 1860
915 1 1875
930 1 1890
945 1 1905
0 2 1920
15 2 1935
30 2 1950
45 2 1965
60 2 1980
75 2 1995
90 2 2010
105 2 2025
120 2 2040
135 2 2055
150 2 2070
165 2 2085
180 2 2100
195 2 2115
210 2 2130
225 2 2145
240 2 2160
255 2 2175
270 2 2190
285 2 2205
300 2 2220
315 2 2235
330 2 2250
345 2 2265
360 2 2280
375 2 2295
390 2 2310
405 2 2325
420 2 2340
435 2 2355
450 2 2370
465 2 2385
480 2 2400
495 2 2415
510 2 2430
525 2 2445
540 2 2460
555 2 2475
570 2 2490
585 2 2505
600 2 2520
615 2 2535
630 2 2550
645 2 2565
660 2 2580
675 2 2595
690 2 2610
705 2 2625
720 2 2640
735 2 2655
750 2 2670
765 2 2685
780 2 2700
795 2 2715
810 2 2730
825 2 2745
840 2 2760
855 2 2775
870 2 2790
885 2 2805
900 2 2820
915 2 2835
930 2 2850
945 2 2865
0 3 2880
15 3 2895
30 3 2910
45 3 2925
60 3 2940
75 3 2955
90 3 2970
105 3 2985
120 3 3000
135 3 3015
150 3 3030
165 3 3045
180 3 3060
195 3 3075
210 3 3090
225 3 3105
240 3 3120
255 3 3135
270 3 3150
285 3 3165
300 3 3180
315 3 3195
330 3 3210
345 3 3225
360 3 3240
375 3 3255
390 3 3270
405 3 3285
420 3 3300
435 3 3315
450 3 3330
465 3 3345
480 3 3360
495 3 3375
510 3 3390
525 3 3405
540 3 3420
555 3 3435
570 3 3450
585 3 3465
600 3 3480
615 3 3495
630 3 3510
645 3 3525
660 3 3540
675 3 3555
690 3 3570
705 3 3585
720 3 3600
735 3 3615
750 3 3630
765 3 3645
780 3 3660
795 3 3675
810 3 3690
825 3 3705
840 3 3720
855 3 3735
...
PlayCanvasからSOGについてのブログが出てた