Open91

SOGSについて調べるスレ

にー兄さんにー兄さん

PlayCanvasがサポートしたことで話題になったSOGSの仕組みが気になったので調べてみる

にー兄さんにー兄さん

SOGS、別のGSの論文もヒットするけど、たぶんSelf-Organizing Gaussianのほうだな

にー兄さんにー兄さん

PlayCanvas本家の、SOGS実装プルリク
https://github.com/playcanvas/engine/pull/7510

にー兄さんにー兄さん

プルリクで、データを読み込むことに関して気になるファイルは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を作成しているだけなのでシンプルだな

にー兄さんにー兄さん

ちょっと、一文字変数に対してコメント等で補足がないので読みにくいな……

にー兄さんにー兄さん

src/scene/gsplat/gsplat-sogs-data.jsはしっかりデコード周りの処理が入っているな

にー兄さんにー兄さん

src/scene/gsplat/gsplat-sogs.jsはSogsDataをラップしている感じのデータ構造
そもそもPlayCanvas内部ではSplatとSplatDataというデータ構造が分かれていて、
どちらもSplatResourceの中で扱われる

にー兄さんにー兄さん

まぁだから、SOGS自体のデータ構造について知りたいのであればGsplatSogsDataの中のデコーダ実装を観ればよいのかな

にー兄さんにー兄さん

たぶん、SogsData->Sogs->GsplatResouceという風に右に行くにつれてEnittyに近い概念になっていく

にー兄さんにー兄さん

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側の処理」とは、主に以下の点を指す。

  1. GPUメモリでのデータ保持: Textureオブジェクトとして、圧縮データを直接GPUのVRAM上に保持する。
  2. GPUによる並列処理: reorderGpuMemory内でシェーダー(GPUで実行されるプログラム)を使い、何十万ものスプラットデータを高速に並べ替える。これは典型的なGPGPU(General-Purpose computing on GPU)の活用例。
  3. 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による並べ替え処理は、必須の処理ではなく、オプショナルな最適化である。
  • 目的:
    1. パフォーマンス向上: 3D空間上で近いデータをメモリ上でも近くに配置し、GPUのキャッシュ効率を上げる。
    2. 品質向上: 半透明のスプラットをより正しい順序で描画(ブレンド)し、見た目のアーティファクトを減らす。
  • これがなくても、データのデコードとレンダリング自体は可能。
にー兄さんにー兄さん

SPARKの内部実装も確認してみる
というのも、PlayCanvas以外でSOGS対応しているのはSPARKくらいなんじゃないかな?って感じなので
https://github.com/sparkjsdev/spark

にー兄さんにー兄さん

おそらくsrc\worker.tsの中でデコーダ処理が始まって、その中のdecodePcSogsが呼ばれるのかなぁ

にー兄さんにー兄さん

ここらへんだな

      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;
      }
にー兄さんにー兄さん
PcSogsJson in src\SplatLoader.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.tsunpackPcSogs関数定義

function unpackPcSogs(json: PcSogsJson, extraFiles: Record<string, ArrayBuffer>): Promise<{
    packedArray: Uint32Array;
    numSplats: number;
    extra: Record<string, unknown>;
}>
にー兄さんにー兄さん

meta情報とファイルの一覧を取得(ファイルはバイナリ)して、
packedArrayとextraなどを返す、ほう

にー兄さんにー兄さん

packedArrayは、setPackedSplatScalesのような関数の引数として呼ばれている 何らかの書き込みがされているんだろう

にー兄さんにー兄さん

つまり内部データとして保持しておくのに、デコードした状態の値をArrayBufferに持っているんだな

にー兄さんにー兄さん

ちなみにdecodeImageの実装

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でやっているというもの
なるほどね、そのほうが早いのかな?

にー兄さんにー兄さん

unpackSogsの中身って外観はこんな感じ

要は順番に画像をデコードしてピクセルデータを取得して、それをpackedArrayに詰めていく

にー兄さんにー兄さん

これはmeans(means_l, means_u)=positionのデコード部分

まずl,uのの意味が何だろうと思っていたら、たぶんupperとlowerなんかな
おそらくpositionはfloat16にエンコードされるんだけど、画像のピクセルの各チャネルはu8なので、
f16をu8×2に分解して保存している。それがu,lの意味だな

これはplaycanvasエンジンのデコーダでも同じ

にー兄さんにー兄さん

SOGSの特徴として、各テクスチャには[0,1)の値(線形補完のt値)が保存してあって、
meta情報のmin,maxから値を復元する方法がとられていそう

にー兄さんにー兄さん

positionに嚙まされている式が何だろうって気になったのでプロット
なるほど、これは0付近の値は解像度高くするためのアレかな

にー兄さんにー兄さん

ChatGPTの回答


✨ 何に使えるの?
この関数は、よく以下のような文脈で登場するよ:

✅ 1. スムーズな非線形増幅
この式は、x の値が大きくなるにつれて 指数的に増加する。でも、x=0 近傍ではほぼ線形にふるまう。
→ つまり「小さい値はそのまま、大きい値はぐぐっと拡大したい」ときに便利。

たとえば:

オーディオエフェクト(例えばコンプレッサやエンベロープ)

UIのスライダーの入力変換(滑らかに大きくする)

機械学習などでの活性化関数に似た振る舞い

✅ 2. Softplusの変種として
これは実は softplus 関数(log(1 + exp(x)))の仲間みたいなもので、ある意味で sinh や tanh とも形が似てる。
でもこれは左右対称(奇関数)で、x=0 を中心に連続かつ滑らかな関数だよ。

にー兄さんにー兄さん

この関数とロジット関数の比較

ロジット関数はシグモイド関数の逆関数なので、値域が[0,1)になってる
一方関数fはたしかに大きな値になれば無限に発散するんだけど、値域は(-\infty, \infty)になっている

にー兄さんにー兄さん

つまりこの関数がエンコードの時に使われているということ
気持ちとしては大きな値を小さな値にエンコードすることになる(logなので収束はしない)

にー兄さんにー兄さん

SPARKのデコード実装を見ていると下記がわかった

実数 対数ベース position(mean)
正の実数 対数関数 scale
0-1の実数 シグモイド関数 color(sh0)
f(x) = \begin{cases} \log (x+1) & (x \geq 0) \\ -\log (1-x) & (x \lt 0) \end{cases}
にー兄さんにー兄さん

PlayCanvasが出している3DGSのファイルを変換できるCLI、
内部でjsdomとwebgpuパッケージを使ってPlayCanvasEngineを動かしている
面白い実装だ
https://github.com/playcanvas/splat-transform/blob/main/src/gpu/gpu-device.ts

にー兄さんにー兄さん

基本的なエンコードの思想というか、ロジックはある程度分かってきた
しかし、SHの仕様がまだわかっていないので調査していく

にー兄さんにー兄さん

まずSH関連のファイルとして、shN_centroids.webpshN_labels.webpがあるんだよな
なんかコードをざっくり見た感じ、labelsから座標値を割り出してcentroidsから取得していそうだけど、どうなってるんだろう

にー兄さんにー兄さん

はがさんのサンプルコードにあったサンプルデータ
これだとshNに関するmeta.jsonの記載はこう

にー兄さんにー兄さん

45という数値は、SHが3次までをサポートしていた場合の項数の合計だよな
で、185,761はガウシアンの数

にー兄さんにー兄さん

いや、もちっとあったな

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);
にー兄さんにー兄さん

記事曰くk-meansとは

k-meansは与えられたデータをk個のグループに分けるクラスタリングの手法です。k-meansではデータの類似度に基づいてグループ分けを行います。類似度はクラスタの中心(後述)とデータの距離が近ければ類似度が高いと判定します。具体的なアルゴリズムは次の章で説明します。

らしい

にー兄さんにー兄さん

あ~~~この記事のk-meansアルゴリズムの説明めっちゃわかりやすいな
反復処理によってクラスタを分けるんだ
もしかしたらpositionでもそれが行われているのかもね

にー兄さんにー兄さん

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
...