@lumaai/luma-webのBabylon.js版を作る
@lumaai/luma-webを触っておりました
パッケージ本体はこちら
残念ながらこのパッケージ自体はOSSではないらしく、中身は見れないんだけど
実際に使って見てネットワーク通信部分とかを見て見ると、どこと通信して何を取ってきているのかが分かったので
いったん必要なデータの取得はできそうだなぁとなった
3DGSデータの表示に必要なのは.plyとか.splatになるわけで
現状Babylon.jsはply読み込みに対応したので、どうにかしてuuidからplyを取得出来ればOKということになる
https://webapp.engineeringlumalabs.com/api/v3/captures/{slug}/public
というエンドポイントにアクセスすればそれっぽいデータが取得出来るっぽい
最後のpublicがカギで、これが無いとCORSでエラーになる
現状docsなどがあるLumaAI APIはv2なんだけど、これはv3なんですね
あとcaptureではなくcapturesに変わってる
全面的に使ってよいのか微妙なのが微妙ですが、いったんこれでやってみましょう
luma-webがそうであるように、Publicな3DGSに対して使えるようにする
あ”っっ、これ.ply.zipじゃねーか!
ここまでをいったん整理
LumaAI API v3で返ってくるplyは2種類
- type:
gaussian_splatting_point_cloud.ply
- type:
gs_point_cloud
1に関していうと、以下の特徴がある
- .plyではなく.ply.zipが返ってくる
- おそらく3DGSの元データっぽい
- サイズが巨大
- おそらくzipになってる理由がこれ
- zipを解凍すればBabylon.jsのSPLATFileLoaderを使って読み込み可能
- 手元で試したときはfflateを使って解凍した
- 巨大なファイルのダウンロードと解凍により、表示されるまで数十秒かかる
2に関していうと以下の特徴がある
- サイズが小さい
- .plyが返ってくる
- おそらくGSのデータであるが、SPLATFileLoaderでは読み込めない
- ヘッダ情報を見て見ると、通常の.plyがfloatなのに対してushortやucharになっている
- このせいでSPLATFileLoaderではエラーになる
1に関して、頑張ってzip解凍まで実装すれば
動作はめっちゃ襲おいけどGSの表示はできている
めっちゃおそいけど
API v3の利用について公式Discordに問い合わせてみた
gaussian_splatting_point_cloud.ply(以下 形式①)とgs_point_cloud(以下 形式②)の違いは
フィールドデータの種類とデータ型
とくにfloatをushortやucharに、どのようにマッピングしているのか気になる
これは数値を見ながら推測しなくちゃなので、バイナリ形式をascii形式に変換して
数値を比べてみるかな
数値のマッピングについて調べていたら、meta情報を含んだJSONファイルを発見した
.ply同様にAPIレスポンスで取得できる
JSONの例(一部抜粋)
{
...,
"bounds": {
"xyz_min": [
-99.99710083007812,
-99.99878692626953,
-99.99800109863281
],
"xyz_max": [
99.99812316894531,
99.9970474243164,
99.99800109863281
],
"f_dc_min": [
-11.302934646606445,
-11.037412643432617,
-12.602523803710938
],
"f_dc_max": [
7.605276107788086,
7.917906284332275,
7.743527412414551
],
"opacity_min": [
-5.537333965301514
],
"opacity_max": [
11.465744018554688
],
"scale_min": [
-13.708109855651855,
-21.529224395751953,
-16.2803955078125
],
"scale_max": [
0.8483610153198242,
0.6941477060317993,
0.6941477060317993
],
"rotation_min": [
-0.6299273371696472,
-1.2513082027435303,
-1.230669379234314,
-1.2749708890914917
],
"rotation_max": [
2.2383224964141846,
1.5769015550613403,
1.4272433519363403,
1.4079049825668335
]
}
},
...
}
データフェッチの処理フローはこんな感じか
みつけた上記の方法を使ってLumaAIのplyファイルをsplat形式に変換してBabylonのGaussianSplattingに読み込ませたところ、無事に読み込みに成功した
最終的に出来上がったLuma ply -> splatに変換するメソッドはこちら
convertLumaPlyToSplatData関数
/**
* Code from https://github.com/dylanebert/gsplat.js/blob/main/src/loaders/PLYLoader.ts Under MIT license
* Loads a .ply from data array buffer
* if data array buffer is not ply, returns the original buffer
*/
export const convertLumaPlyToSplatData = (
data: ArrayBufferLike,
metadata: IGSPointCloudMeta
): ArrayBuffer => {
const ubuf = new Uint8Array(data);
const header = new TextDecoder().decode(ubuf.slice(0, 1024 * 10));
const headerEnd = "end_header\n";
const headerEndIndex = header.indexOf(headerEnd);
if (headerEndIndex < 0 || !header) {
return data;
}
const vertexCount = parseInt(/element vertex (\d+)\n/.exec(header)![1]);
let rowOffset = 0;
const offsets: Record<string, number> = {
double: 8,
int: 4,
uint: 4,
float: 4,
short: 2,
ushort: 2,
uchar: 1,
};
type PlyProperty = {
name: string;
type: string;
offset: number;
};
const properties: PlyProperty[] = [];
const filtered = header
.slice(0, headerEndIndex)
.split("\n")
.filter((k) => k.startsWith("property "));
for (const prop of filtered) {
const [_p, type, name] = prop.split(" ");
properties.push({ name, type, offset: rowOffset });
if (!offsets[type]) throw new Error(`Unsupported property type: ${type}`);
rowOffset += offsets[type];
}
const rowLength = 3 * 4 + 3 * 4 + 4 + 4;
const SH_C0 = 0.28209479177387814;
const dataView = new DataView(data, headerEndIndex + headerEnd.length);
const buffer = new ArrayBuffer(rowLength * vertexCount);
const q = new Quaternion();
for (let i = 0; i < vertexCount; i++) {
const position = new Float32Array(buffer, i * rowLength, 3);
const scale = new Float32Array(buffer, i * rowLength + 12, 3);
const rgba = new Uint8ClampedArray(buffer, i * rowLength + 24, 4);
const rot = new Uint8ClampedArray(buffer, i * rowLength + 28, 4);
let r0: number = 255;
let r1: number = 0;
let r2: number = 0;
let r3: number = 0;
for (
let propertyIndex = 0;
propertyIndex < properties.length;
propertyIndex++
) {
const property = properties[propertyIndex];
let value;
let remapFunc: (x: number, y: number) => number;
switch (property.type) {
// case "float":
// value = dataView.getFloat32(property.offset + i * rowOffset, true);
// break;
// case "int":
// value = dataView.getInt32(property.offset + i * rowOffset, true);
// break;
case "ushort":
value = dataView.getUint16(property.offset + i * rowOffset, true);
remapFunc = remapValue(value)(0, 65535);
break;
case "uchar":
value = dataView.getUint8(property.offset + i * rowOffset);
remapFunc = remapValue(value)(0, 255);
break;
default:
throw new Error(`Unsupported property type: ${property.type}`);
}
const {
f_dc_max,
f_dc_min,
opacity_max,
opacity_min,
rotation_max,
rotation_min,
scale_max,
scale_min,
xyz_max,
xyz_min,
} = metadata.gaussians.bounds;
switch (property.name) {
case "x":
position[0] = remapFunc(xyz_min[0], xyz_max[0]);
break;
case "y":
position[1] = remapFunc(xyz_min[1], xyz_max[1]);
break;
case "z":
position[2] = remapFunc(xyz_min[2], xyz_max[2]);
break;
case "scale_0":
scale[0] = Math.exp(remapFunc(scale_min[0], scale_max[0]));
break;
case "scale_1":
scale[1] = Math.exp(remapFunc(scale_min[1], scale_max[1]));
break;
case "scale_2":
scale[2] = Math.exp(remapFunc(scale_min[2], scale_max[2]));
break;
case "red":
rgba[0] = value;
break;
case "green":
rgba[1] = value;
break;
case "blue":
rgba[2] = value;
break;
case "f_dc_0":
rgba[0] = (0.5 + SH_C0 * remapFunc(f_dc_min[0], f_dc_max[0])) * 255;
break;
case "f_dc_1":
rgba[1] = (0.5 + SH_C0 * remapFunc(f_dc_min[1], f_dc_max[1])) * 255;
break;
case "f_dc_2":
rgba[2] = (0.5 + SH_C0 * remapFunc(f_dc_min[2], f_dc_max[2])) * 255;
break;
case "f_dc_3":
rgba[3] = (0.5 + SH_C0 * value) * 255;
break;
case "opacity":
rgba[3] =
(1 / (1 + Math.exp(-remapFunc(opacity_min[0], opacity_max[0])))) *
255;
break;
case "rot_0":
r0 = remapFunc(rotation_min[0], rotation_max[0]);
break;
case "rot_1":
r1 = remapFunc(rotation_min[1], rotation_max[1]);
break;
case "rot_2":
r2 = remapFunc(rotation_min[2], rotation_max[2]);
break;
case "rot_3":
r3 = remapFunc(rotation_min[3], rotation_max[3]);
break;
}
}
q.set(r1, r2, r3, r0);
q.normalize();
rot[0] = q.w * 128 + 128;
rot[1] = q.x * 128 + 128;
rot[2] = q.y * 128 + 128;
rot[3] = q.z * 128 + 128;
}
return buffer;
};
ここからライブラリ公開の準備を進めていく
今回はViteテンプレートのlibraryを使ってやっていたのだけど、vite-plugin-dts
を入れるだけではうまく出力されなかった
そこでvite.config.ts
, tsconfig.json
を変更することに
tsconfig.ts
は以下の変更をした
- noEmitsをコメントアウト
- declarationをtrueに
- emitDeclarationOnlyをtrueに
- outDirを
./dist
に
vite.config.ts
は以下のように変更
import { resolve } from "path";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
plugins: [dts()],
build: {
lib: {
entry: resolve(__dirname, "lib/index.ts"),
name: "luma-splatting-for-babylonjs",
fileName: "index",
formats: ["es", "umd"],
},
rollupOptions: {
external: ["@babylonjs/core"],
output: {
globals: {
"@babylonjs/core": "BABYLON",
},
},
},
},
});
残タスクはこちら
- pnpm linkでローカルからパッケージインストール試してみる
- READMEの追加
- npm publish
いったん公開までしておいて、そのあとにやりたいこと一覧はこちら
- lernaによるリリースフロー確立
- GitHub Actions整備
- eslintとprettier整える
- docコメントつける
懸念事項として、今回はBabylon.jsをdevDependencyにしているので
インストールする側がBabylonを別途インストールしていれば使える想定だけど
それがちゃんと動くのか
READMEには、公開設定済みのモデルで使えますって言わないとな
# lumas-splatting-for-babylonjs内で
pnpm link --global
# テストプロジェクト内で
pnpm add -D @babylonjs/core
pnpm link --global luma-splatting-for-babylonjs
これで普通に使えた
最低限の機能を実装して、とうとう公開!
やりたいことをタスク化した
そしてProjectsを作った
いったんリリースしたし、クローズ