Babylon.js用のSPZローダを作りたい
先日、NianticがGaussianSplattingの新しい独自フォーマットであるSPZを発表した
JPGのように~って感じの謳い文句だけど、JPGエンコーディングほど難しいことはしていなくて
単純に精度による圧縮を書けているらしい(それでも十分な圧縮率になる)
精度による圧縮は、別にSPZに限らず前からずっとやられていて
例えば.splatでも情報によってはかなり精度を削っているし、
LumaAIがLumaAPIから返すGSデータも、数値のMin,Maxをメタデータに保持しておいて正規化するようなこともやっている
SPZも中身は結構シンプルな気はしているなぁ
sizeが対数関数的に圧縮されているのはなるほどってなった
spzのここ好きポイントはローダがOSSになっていること!
C++で書かれているけど純粋なロジックだけ抽出してあって、
make地獄にならないところ!!たすかる~
libzへの依存だけあるようなので、ここは何とかして解決しなくてはいけない
今回の目的
今回目指す形は、このSPZフォーマットのGSデータをBabylon.jsにロードすること
サブミッションとしては、そういうライブラリを公開すること
そもそも、SPZはNianticStudioや8thwallで使われているので
100%同じようなことをNianticでもやっている
しかしそこの部分のコードは公開されていないというだけで、
不可能なことはないはず
ちなみにspzの話題は7日前くらいにBabylon.js Forumでも上がっていた
色々作戦はあるけど、
ライブラリとして公開することも視野に入れるのであればある程度メンテナンス性も考慮したい
つまり、まだ発表されて間もないSPZのエンコードデコードをTypeScriptで自前で書くようなことは避けたい(普通に自分にできる気がしない)
パフォーマンス面で課題が出てきそうだし、元のソースをキャッチアップしなくちゃいけないし
今考えている構成は下記
意図としては
- SPZのコードを参照しておくことで、SPZに変更があってもSPZをアプデするだけで対応できるようにしたい
- 例えばgit submodule的にリポジトリに含めるなどする
- libzの依存解決はどうしようか
- SPZのcppコードをWASMに変換すればよくない?ってなりそうだけどRustをかましている
- Rustのwasm-bindgenがいい感じな気がしたため
- この記事ではwasm-bindgen(wasm-pack)が使えなさそうとも書いてある
- Emscriptenなんかこわいので別の方法があればそうしたい
まずSPZをRustでWrapする部分はbindgenを使ってコンパイルする感じになる
って思ったけど、これあれか、コンパイルしちゃうと実行可能ファイルが出てくる??
まずは適当に自分で作ったC++のコードを
テキトーに作ったRustコードから呼び出すところをやっていく
基本的にこのチュートリアルからやっていく
bindgenにはclangが必要なので、インストしておく必要があった
まず困ったのがコイツだった
#include <bzlib.h>
自分の開発環境はWinだけど、このヘッダがそもそもない
なのでこのチュートリアル通りにはできないなぁって感じだった
なので、いったんnon-systemな法でやってみる
つまり追加のパッケージとか標準ライブラリとかを使わないやつ
ただ、なんかこの通りにやってもうまくいかなかった
Win特有の問題なのか、どうやら.libが正常に作られていないような挙動を示していた
色々試してみた結果、こちらの記事の方法で呼び出せることが分かった
つまり、Rustのccクレートを使ってC++のコードをコンパイルし、.libを作成
それをリンクしてexeを作る感じ
フォルダ構成とbuild.rs
extern crate bindgen;
extern crate cc;
use std::env;
use std::path::PathBuf;
fn main() {
//println!("cargo:rustc-link-lib=c++");
println!("cargo:rerun-if-changed=hello/hello");
cc::Build::new()
.file("hello/hello.cpp")
//.flag("-std=c++17")
.include("hello")
.compile("hello");
let bindings = bindgen::Builder::default()
.header("hello/hello.hpp")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("Unable to generate bindings");
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("Couldn't write bindings!");
}
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
この状態で
cargo build --lib # libオプションはいらないかも
すると、bindings.rsが生成され、
それをmain.rsから利用できた
この状態で、wasm-bindgenを導入し、wasm-packでビルドできるか試してみた
結果、Winでは無理そうだった
wasmなのでtargetがwasm32-unknown-unknown
としてビルドされるんだけど
この時にccクレートではccコマンドを探しに行っちゃうみたい
Winでは自動でclでコンパイルするようだったんだけど、ターゲットプラットフォームがあれなので、そうなってしまうみたいだった
これはccのオプションにターゲットを指定した時と同じ挙動だった
次のムーブとして、
Cmakeを含むC++のプロジェクトをRUst Bindgenで使ってみる(wasm化はしない)
をやってみる
最終的にlibzはvcpkgからインポートしたいので
cmakeでプロジェクトを整えたうえでzlibのインポートをしてみる
思ったけど、Rustを噛まさない方が簡単なのではないかという気持ちになってきた
いまはEmscriptenなしでclang単体でもwasm出力はできるらしいので、其れでやってみようかなぁ
WinのPowershellではコマンド通り実行してもなんかエラーになっちゃった
色々試した結果、下記のように実行すればよいらしい
これはPowershellが悪いな
clang++ -std=c++14 -o build/hello.wasm --target=wasm32 -nostdlib "-Wl,--no-entry" "-Wl,--export-all" hello/hello.cpp
cppファイルの中身はこんな感じ
attributeかextern"C"をすると、wasmをNode.jsで呼び出したときに関数名が保たれる
#include "hello.hpp"
__attribute__((export_name("hello")))
int hello()
{
return 42;
}
// or
extern "C"
{
int hello()
{
return 42;
}
}
extern "C"
{
int hello();
}
build/hello.wasm
が出力されるので、それをNode.jsで実行するにはこうなる
import fs from "node:fs/promises";
const main = async () => {
const wasmCode = await fs.readFile("./build/hello.wasm");
const { instance } = await WebAssembly.instantiate(wasmCode, {});
const helloFunc = instance.exports.hello;
const result = helloFunc();
console.log(result);
};
main();
node ./main.mjs
実行結果
❯ node .\main.mjs
42
参考:
いろいろ試したものの、ここからclang単体でspzとzlibを使ったC++のネイティブアプリをビルドすることができなかった・・・
spzはちょっと変更を加えてまぁまだしも、やはりzlibをいい感じに食わせる方法が全くわからないといった感じ
ここにきて、もうclangで消耗するくらいなら、
いっそのことcmakeでEmscriptenを使ってwasmを出力するようにしたほうがいいのでは?tって考えになった。いや確かに、今回の目的はspzをwasmで使うことなので
これとは別軸で、spzをRustifyしたくなったな
Rustを使いたい要因にwasm-bindgenというかwasm-packがあったけど
それでやってくれるようなTS型定義ファイル生成をやってくれるツールが
Emscriptenの場合でも使えるらしい
最終的に、これは使わないかな…ってなったけど
Node.jsからwasmビルドできるツールチェーンが公開されていて面白かった
この記事ではまさに、zlibをwasm化しているし、サンプルプロジェクトもある
そしてそのツールはこちら
ただ、記事も5年以上経過、ライブラリも7年前更新なので、厳しそう
binaryenってなんやろ
EmscriptenのGettingStarted見ていたら、公式dockerイメージがあるのを発見、そうだったんだ!
いったんweb.devにあるチュートリアルをやってみる
そしてこれをベースに、zlibを使うC++コードからwasmを出力したり、
実際にspzをwasmにしたりしてみたい
有志で、何人かがemscriptenのdockerイメージを作ていた
とりま、web.devの内容を、公式dockerイメージでやってみたがいい感じに動かせた
こんな感じのbatファイルで実行
docker run^
--rm^
--privileged^
-v /path/to/dir:/src^
emscripten/emsdk^
em++ main.cpp --std=c++17 -o ./build/main.js -s USE_ZLIB=1
priviledgeは、つけるとメモリのエラーを回避できそう(docker toolboxだからかな)
あと、dockerの中でzlibを使う方法だけど、
apt-getで読もうとして失敗して
調べていたら
USE_ZLIB=1
を渡すと通った、マジか
最終的に、spzのspz::normalizeはwasm化して実行できた
しかしなぜかGaussianCloidとかが実行できな
せっかくdockerでやっているので、devcontainerも整えた
と言っても、.devcontainer/devcontainer.json
に以下を追記しただけ
{
"image": "emscripten/emsdk",
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.cpptools-extension-pack"
]
}
}
}
なんならimageしか自分で加筆していない。これだけでいいんだたらもっと早くやっておけばよかったな
色々いじってみた結果、やっぱり出力したwasmを自分でインポートして使うと
なんかエラーになっちゃうけど、
出力されたjsをnode.jsで実行するのであれば、GaussianCloudの圧縮などが普通に動いた、すごい
この人が同じようなkとをやっていた
ESModuleで出力したら動いた!!!
このコードが
#include <emscripten.h>
#include "spz/src/cc/splat-types.h"
#include "spz/src/cc/splat-types.cc"
#include "spz/src/cc/load-spz.h"
#include "spz/src/cc/load-spz.cc"
using namespace std;
// using namespace spz;
extern "C"
{
EMSCRIPTEN_KEEPALIVE
float hoge()
{
spz::GaussianCloud gs = {
1, 0, false, {0.0, 0.0, 0.0}, {1.0, 1.0, 1.0}, {1.0, 0.0, 0.0, 0.0}, {1.0}, {1.0, 1.0, 1.0}, {}};
vector<uint8_t> binGS;
spz::saveSpz(gs, &binGS);
spz::PackedGaussians unpackedGS = spz::loadSpzPacked(binGS);
return unpackedGS.numPoints;
}
}
いける!これはいけるぞ!!
BIND系を何もやらないで関数の引数で渡すと、
js側ではundefinedとして受け取られるらしいな
ということでembindに向き合う
たとえば、今回登場するのはJSにexportする関数と、
vectorと、GaussianCloudである
なのでこんな感じにbind
EMSCRIPTEN_BINDINGS(my_module)
{
emscripten::function("create_test", &create_test);
emscripten::function("hoge", &hoge);
emscripten::function("save_spz", &save_spz);
emscripten::function("load_spz", &load_spz);
register_vector<float>("vector<float>");
register_vector<uint8_t>("vector<uint8_t>");
value_object<GaussianCloud>("GaussianCloud")
.field("numPoints", &GaussianCloud::numPoints)
.field("shDegree", &GaussianCloud::shDegree)
.field("antialiased", &GaussianCloud::antialiased)
.field("positions", &GaussianCloud::positions)
.field("scales", &GaussianCloud::scales)
.field("rotations", &GaussianCloud::rotations)
.field("alphas", &GaussianCloud::alphas)
.field("colors", &GaussianCloud::colors)
.field("sh", &GaussianCloud::sh);
}
vectorの問題
直近で最終的にやりたいのは、JSから.spzのデータバッファを投げて
wasmでGaussianCloudに変換するというものなので、
巨大な配列をJS->C++でやり取りする必要がある
Emscriptenで配列をちゃんとやりとりするには、それ相応の知識が必要
それこそwasmでポインタ操作するわけだ
色々文献を漁ってみて、参考になりそうなリンクがいくつか見つかった
これが本命かな
この人のコメントもリアクションが多い
いや……でもなんか最後きもいな……?
Module.sort([{num: 2}, {num: 1}, {num: 3}]);
=> [{num: 1}, {num: 2}, {num: 3}]
ここも参考になりそう
日本語情報助かる
上の記事めっちゃ分かりやすかった、この方法で行けるなら
C++->JSはこれでやろう
JS側でmallocしてメモリ領域を確保したうえでポインタを渡せば
それが配列として受け取れるってことか
pointer = Module._malloc(Float64Array.BYTES_PER_ELEMENT * float64Array.length);
if (pointer == null) throw new Error("Memory allocation failed.");
Module.HEAPF64.set(float64Array, pointer / Float64Array.BYTES_PER_ELEMENT);
const sumDoubleArray = Module.cwrap("sumDoubleArray", "number", ["number", "number"]);
return sumDoubleArray(pointer, float64Array.length);
そして最後にModule._free(pointer)
逆もしかり、C++でvectorを渡すとそれがnumber型のPointerとして返ってくる
なので、vector(ポインタ)とlengthを渡してやり、
HEAP8からそのバッファを取得するってことだな
完全に理解した
ここのページ通り実装してみたけど動かなかった
どうやらこれはWASM Studioを使っているらしく、ちょっと違うのかな
シンプルにC++は型やユーティリティで融通が利かないからイラっと来る時があるけど、頑張るぞい!
動かないのは具体的には、
JS側でUint8Arrayを引数として渡して、C++側でuint8_t arr[]で受け取ると
なんかUnboudedTypesなるエラーが出るって感じ
UnboundTypeError: Cannot call load_spz due to unbound types: PKh
at new UnboundTypeError (file:///workspaces/rust-bindgen-testbed/emcc-sandbox/build/main.mjs:2033:22)
at throwUnboundTypeError (file:///workspaces/rust-bindgen-testbed/emcc-sandbox/build/main.mjs:2080:13)
at Object.load_spz (file:///workspaces/rust-bindgen-testbed/emcc-sandbox/build/main.mjs:2633:9)
at main (file:///workspaces/rust-bindgen-testbed/emcc-sandbox/index.mjs:19:32)
GaussianCloud load_spz(const int gsPtr, const int length)
{
auto pointer = (uint8_t*)gsPtr;
auto spzBuffer = vector<uint8_t>(pointer, pointer+length);
return spz::loadSpz(spzBuffer);
}
gsPtr = wasmModule._malloc(Uint8Array.BYTES_PER_ELEMENT * gsBinData.length)
if (gsPtr == null) { throw new Error("allocation failed") }
wasmModule.HEAPU8.set(gsBinData, gsPtr / Uint8Array.BYTES_PER_ELEMENT)
const gsCloud = wasmModule.load_spz(gsPtr, gsBinData.length)
console.log(gsCloud);
この組み合わせで動いた!
つまりいったんintで受け取って、ポインタへ変換する
今度はC++の構造体内にあるvectorからJSのTypedArrayを取得する処理
JSでこんな感じにやっても、なんか動かなかった
const gsCloud = wasmModule.load_spz(gsPtr, gsBinData.length)
console.log(gsCloud.positions.data());
// error!
多分data関数がポインタを扱っているため、バインドできなかった的な何かなのかなぁ
gsCloud.positions
はvector<float>ではあるので、
これのポインタを例のごとくintで受け取り、
HEAP32
から受け取る方針にして、いい感じに動くようになった
つまりC++でこんな感じの関数を作成
int vf32_ptr(vector<float> &v)
{
return (int)(v.data());
}
// ...
EMSCRIPTEN_BINDINGS(my_module)
{
emscripten::function("vf32_ptr", &vf32_ptr, allow_raw_pointers());
}
この関数を使ってポインタを取得すれば、あとはバッファからFloat配列を取得できる
const positionPtr = wasmModule.vf32_ptr(gsCloud.positions)
const positionBuf = new Float32Array(wasmModule.HEAP32.buffer, positionPtr, gsCloud.positions.size())
console.log(positionBuf);
Float32Array(2797680) [
-126.175537109375, 204.15625, 0, 126.175537109375,
204.15625, 0, -126.175537109375, -204.15625,
0, 126.175537109375, -204.15625, 0,
0, -126.175537109375, 204.15625, 0,
126.175537109375, 204.15625, 0, -126.175537109375,
-204.15625, 0, 126.175537109375, -204.15625,
204.15625, 0, -126.175537109375, 204.15625,
0, 126.175537109375, -204.15625, 0,
-126.175537109375, -204.15625, 0, 126.175537109375,
-194.1640625, 120, 74.1640625, -120,
74.1640625, 194.1640625, -74.1640625, 194.1640625,
120, 74.1640625, 194.1640625, 120,
0, 240, 0, 74.1640625,
194.1640625, -120, -74.1640625, 194.1640625,
-120, -120, 74.1640625, -194.1640625,
-194.1640625, 120, -74.1640625, -240,
0, 0, -194.1640625, -120,
-74.1640625, -194.1640625, -120, 74.1640625,
-120, -74.1640625, 194.1640625, 0,
0, 240, 120, 74.1640625,
194.1640625, 194.1640625, 120, 74.1640625,
194.1640625, 120, -74.1640625, 120,
74.1640625, -194.1640625, 0, 0,
-240, -120, -74.1640625, -194.1640625,
194.1640625, -120, 74.1640625, 120,
... 2797580 more items
]
いい感じ!
やり方はちょっと違うけど、ここを参考にした
こんな感じにサンプルをロードしたら、ロードできた!
import { readFile } from "fs/promises";
import Module from "./build/main.mjs";
const main = async () => {
const { load_spz } = await Module();
const gsData = await readFile("./spz/samples/racoonfamily.spz");
const gaussianCloud = load_spz(new Uint8Array(gsData.buffer));
console.log(gaussianCloud);
};
main();
進捗報告
これを実行するにあたり、普通の設定だとOOMが起きてしまったので
メモリをランタイムで確保するような設定にしてみた(大丈夫かな)
em++ main.cpp \
--std=c++17 \
-lembind \
-o ./build/main.mjs \
-s USE_ZLIB=1 \
-s WASM=1 \
-s MODULARIZE=1 \
-s EXPORT_ES6=1 \
-s ALLOW_MEMORY_GROWTH # <--
ひとまずこれで第1関門は突破かなぁ
これがブラウザで動くのかとか、vectorの取り回しを気を付けようとか、そういう部分をやっていこう
手元の環境では、embindしたC++のコードをwasmビルドして
.spz形式のGSデータをGaussianCloudとしてロードすることに
Node.jsの環境で成功した
さてここからの残件を整理しよう
GitHubProjectsを作成
残件を整理した
検証開発
-
C++ <-> JSでの配列のやり取りを理解する
- C++へ.spzのUint8Arrayを渡す方法
- JSで受け取ったGaussianCloudのpositionなどの配列を参照する方法
- 出力されたwasmをViteのプロジェクトでロードして動作確認
- tsembindによる型定義出力の対応
repo
- monorepo構成のディレクトリ構造を考える
- CI/CDによるパッケージデプロイ
@spz-wasm/core
- @spz-wasm/coreのプロジェクト作成
- .spz -> GaussianCloudへの変換ができるnpmパッケージ @spz-wasm/coreの作成
@spz-wasm/babylonjs
- @spz-wasm/babylonjsのプロジェクトを作成
- GausianCloud -> Gaussian Splattingのロジック実装
- spz-wasm/babylonjsのAPI設計
- Babylon.js側のnpmパッケージ化
C++/JSでのvectorの受け渡しは、ここのメモにあるリンクを見て勉強かなぁ
今回は、
- JS側からC++関数呼び出しの引数で巨大なUint8Arrayを渡す
- JS側からC++の構造体の中にある巨大なvectorを取得する
という用途なので、完全に双方向同じ処理をしたいわけではない
なんとなく文面上は配列の渡し方を完全に理解した
これを実行してみると
const { HEAP8, _malloc, _free } = await Module();
console.log(HEAP8)
console.log(_malloc);
console.log(_free);
Int8Array(16908288) [
101, 109, 115, 99, 103, 84, 19, 2, -2, -51, -70, -119,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0,
... 16908188 more items
]
undefined
undefined
だった、やはりmallocとかはexportしないといけないか
こうしてみたらいい感じに取得できた
em++ main.cpp \
--std=c++17 \
-lembind \
-o ./build/main.mjs \
-s USE_ZLIB=1 \
-s WASM=1 \
-s MODULARIZE=1 \
-s EXPORT_ES6=1 \
-s ALLOW_MEMORY_GROWTH \
-s EXPORTED_FUNCTIONS="['_malloc', '_free']" # <--
Int8Array(16908288) [
101, 109, 115, 99, 103, 84, 19, 2, -2, -51, -70, -119,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0,
... 16908188 more items
]
[Function (anonymous)]
[Function (anonymous)]
次のタスクはいよいよ本番リポジトリの構築になる
このissue
ん~まぁ最初からガチガチに組まなくてもいいような気はするけど
ある程度想定しておいた方が良いんだろうなぁ
あと、Viteでwasmが読み込めるかは早めに検証しておきたい
これによってはwasmのコンパイルオプションを変更する必要が出てくる
monorepoのディレクトリ構成考えるよ~の会
久しぶりにpnpm-workspaceを使ったモノレポ構成を考えてみる
BayuewJSの時はturborepo&lerna-liteを導入していたが果たして
あんまりturbopackの話を聞かないような気がしたけど、正式版がリリースされていた
turborepoも4日前にリリースされていたし、メンテも続いていそうなので良さそうだな
逆にpnpm-workspaceだけでも開発は事足りるのかな
この記事がシンプルでわかりやすいかも
サンプルもあるし
そういえば面白いなと思ったのが、pnpm catalogという機能を使うと
モノレポ内で共通して使うパッケージのバージョンを統一できるらしい
ばば~っと構築してみたけど、いい感じな気がする
/
├─ packages/
│ ├─ core/
│ └─ babylonjs/
├─ pnpm-workspace.yaml
└─ package.json
こんな感じの構成で、packages以下にはぞれぞれvite のlibraryテンプレートのプロジェクトを作成
pnpm -F @spz-wasm/babylonjs add --workspace @spz-wasm/core
こうしてやればworkspace内のパッケージ依存も記述可能
下記コマンドによって、全部のビルドも可能
pnpm -F @spz-wasm/* run build
あとdtsも出力するようにした
名前をspz loaderにするのはどうかなぁ
名前をどうしようか問題
spz-wasmだと名前かっこいいんだけど、目的をちゃんととらえていないような感じ
自分はspzの完全なwasmラッパーを作りたいわけではなく、
Babylon.jsで読み込めればいいんだよなぁ
ということは、spz-loaderが妥当なのではと思ってきた
うんうん、モノレポでlint/formatとビルドを一括でできるようにもしてみた
名前はキメの問題な気がするので、よりいい名前があるならフランクに変えちゃおう
- spz-loader
@spz-loader/core
@spz-loader/babylonjs
にするか
なんと、Babylon.jsでもspzサポートのドラフトPRが出てしまった😇
TSで記述されているっぽい 従来のFileLoaderに拡張されている感じ
価値は半減しちゃうけど、実装方法違うならまぁいったん作り上げてみるか
- spzの最新に追従しやすい
- (wasmによるパフォーマンス:これは要検討。オーバーヘッドがあったらむしろパフォーマンス悪いかも)
さてここからはspzのC++やwasmの実装をやっていきませう
まずはtestbedのプロジェクトをコピーしてきてwasm出力までをやろうかなぁ
凝ったことはせず、それこそC++の実装は変えず(いらないものだけ削除)、
とにかくwasm出力だけを試す
それからtsembindを試す
そしてViteで使ってみる
C++実装を整えるのも必要だなぁ
ポインタを渡す部分とか
あとspzのGaussianCloudオブジェクトをそのまま使うのではなく、
spz-loader独自のGaussianSplatting用オブジェクトが必要
∵wasm由来のGaussianCloudはdeleteする必要があるため、JS側のメモリにコピーする必要がある
コピーはコストが高いので、
最終的には生のspz/GaussianCloudを触れるようにしておいた方が良さそうではあるな
deleteはユーザの責任になる
disposableとか使いたいわね
オリジナルのGaussianSplattingインターフェースは、いったんViteとの連携ができてからの話になるな
お~、pnpm workspaceで-rでビルドすると、依存関係を無視して実行することもあるんだな
さて、ようやく問題が片付いたので
Cpp/wasmの移植作業をやろう
その前にディレクトリ構成を考えるかぁ
spzやC++、dockerなどはpackages/core/lib/
の中に集約しよう
packages/core/lib/
├─ spz-wasm/
│ ├─ .devcontainer/devcontainer.json
│ ├─ spz/ <------------------------- git submodules
│ ├─ .gitignore
│ ├─ build.sh
│ ├─ main.cpp
│ ├─ index.ts
│ └─ build/ <-------------------------- ignored
│ ├─ main.wasm.js
│ └─ main.wasm.d.ts
└─ index.ts <-------------------------- @spz-loader/core entry point
こんな感じに追加
➜ git submodule add git@github.com:nianticlabs/spz.git packages/core/lib/spz-wasm/spz
Cloning into 'C:/works/misc/spz-loader/packages/core/lib/spz-wasm/spz'...
remote: Enumerating objects: 24, done.
remote: Counting objects: 100% (24/24), done.
remote: Compressing objects: 100% (19/19), done.
remote: Total 24 (delta 3), reused 15 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (24/24), 40.40 MiB | 6.19 MiB/s, done.
Resolving deltas: 100% (3/3), done.
warning: in the working copy of '.gitmodules', LF will be replaced by CRLF the next time Git touches it
WinのPowerShellにおけるdockerのボリュームマウントの時、pwdコマンドのevalがうまくいかない問題は前からちょっと気になっていて、ちゃんとした解決策を思いついていなかった
ただ、pnpmのshell-emulationを使うことで、pwdを正しく評価できるようになり
npm-scriptからdockerによるビルドができるようになった
shell-emulator=true
{
"scripts": {
...,
"build:docker": "docker run --rm -v $(pwd)/lib/spz-wasm/:/src emscripten/emsdk ./build.sh",
},
...
}
そして、ちょっと改変したら
普通にwasmの処理が動いて、spzがブラウザで動いた!!
特にwasm pluginとかも必要なく動いたな……?
まだtsembindも使っていないけど、いったんts-ignoreを使ってESModuleのコードをimportしている感じ
wasmのビルドも、特にSINGLE_FILEにするなどもしていない
見てみると、packages/core/dist/index.js
の中に
application/wasm
としてwasmのb64化されたものが追記されている
ん~まじか、これこそwasm-plugin使わないとできないのかと思ったら
Viteが自動でやってくれたんだろうか、すごいな
ビルド時にlibraryモードを無効化して、previewしてみると
たしかにwasmはwasmでassetsにいることが分かる(spzがなかったのでエラーだったけど)
この感じ、spzのバッファさえ渡せれば普通に動きそうだな
どうやらtsembindはesmoduleに対応していないらしい
やってみたらERR_REQUIRE_ESMというエラーコードが出てきた(これは一般的なエラーコードらしい)
まぁということで、emscriptenのdockerコンテナ上でtypescriptをグローバルインストールして
--emit-tsdオプションを付けたら行けたわ
割といい感じ生成してくれていそうだけどどうだろ
TS型定義生成がうまくいったので、ここからはもうcppやtsのロジックをバリバリ書いていく感じになる(主にtsだけど)
cppではtestbedで使っていた型定義というか、embindを見直した
dtsファイルはこれの通りに生成されるため、名前をVector~~みたいな感じにしたり
あと、C++で扱っているGaussianCloudをRawとし、
TSで扱うGaussianCloudとは別物とした
register_vector<float>("VectorFloat32");
register_vector<uint8_t>("VectorUInt8T");
value_object<GaussianCloud>("RawGaussianCloud")
.field(...);
そして、RawGSCloudとGSCloud変換するようなロジックを作成した
まずRawGSCloudのほうにはC++のvectorで扱っているようなポインタが含まれるので
ポインタからFloat32Arrayをコピーして生成するようにした
/**
* create new Float32Array from cpp FloatVector
* @param wasmModule emscripten main module
* @param vec cpp float32 vector
* @returns copied float32 array
*/
export const floatVectorToFloatArray = (
wasmModule: MainModule,
vec: VectorFloat32,
): Float32Array => {
const pointer = wasmModule.vf32_ptr(vec);
const size = vec.size();
const buffer = new Float32Array(wasmModule.HEAPF32.buffer, pointer, size);
const copiedBuffer = new Float32Array(buffer);
return copiedBuffer;
};
TypedArrayはnewするときに、ArrayBufferを渡すとViewが作られ
TypedArrayを渡すとコピーが作られる仕様なので、
TypedArrayを渡してコピーを作成している
これでRawのほうはDisposeできる(リソースの開放)
GaussianCloudも定義はこんな感じ
とはいえRawのVector部分がFloat32Arrayになっただけではある
export type GaussianCloud = {
numPoints: number;
shDegree: number;
antialiased: boolean;
positions: Float32Array;
scales: Float32Array;
rotations: Float32Array;
alphas: Float32Array;
colors: Float32Array;
sh: Float32Array;
};
そういえばspzのAPIで、
loadSpz
とloadSpzPacked
で何が違うんだろうって思ったら
まずpackedのほうはPackedGaussianCloudを返す。
これはspzファイルの内容をただ読み込んだだけという感じで、
spzのフィールド値が基本的にvector<uint8_t>になっている
これをunpackGaussians関数でunpackすると、数値的に復元されたGaussianCloudが出てくるという感じ
そしてこんな感じの位置付け
-
loadSpzPacked
は、spzをロードしてPackedGaussianCloudを返すまで -
loadSpz
はさらにunpackしてGaussianCloudを返す
という感じなので、基本的にはloadSpz
を使えば良さそう
テスト用にScaniverseでGSデータを取って読み込ませてみた
これはsrc/mainのほうで動いているのでpnpm devで起動していて
assetsフォルダに入ったspzを読み込んでいる様子
うん、点群数は問題なさそう
なんだけど、なんかcolorとかpositionとかが変なデータが入っている
colorに負の値が入ってるのが気になる、なんだこれ
あとなんか全体的にsizeが同じバッファになってる
いったん、issueの内容自体は達成したので、フランクにPRをマージしていく
Babylon.jsでは、最終的にsplat形式のArrayBufferを作成すればGaussianSplattingオブジェクトを作成できる
CedricさんのSplatFileLoaderの中身を参考にデータ変換を行ってみるか
さて、GSCloudからSplatのArrayBufferに変換するコードを書いてみた
`packages/babylonjs/lib/index.ts`のコード
import { GaussianSplattingMesh, type Scene } from "@babylonjs/core";
import { loadSpz, type GaussianCloud } from "@spz-loader/core";
export const createGaussianSplattingFromSpz = async (
data: Uint8Array,
name?: string,
scene?: Scene,
keepInRam?: boolean,
): Promise<GaussianSplattingMesh> => {
const splat = new GaussianSplattingMesh(
name ?? "spz splat",
null,
scene,
keepInRam,
);
const splatBuffer = await parseSpzToSplat(data);
await splat.loadDataAsync(splatBuffer);
return splat;
};
export const parseSpzToSplat = async (
data: ArrayBuffer,
): Promise<ArrayBuffer> => {
const gsCloud = await loadSpz(new Uint8Array(data));
return _convertGaussianCloudToSplatBuffer(gsCloud);
};
const _convertGaussianCloudToSplatBuffer = (
gsCloud: GaussianCloud,
): ArrayBuffer => {
// position(3*f32) + scale(3*f32) + color(4*u8) + rotation(4*u8)
const rowOutputLength = 3 * 4 + 3 * 4 + 4 + 4;
const splatCount = gsCloud.numPoints;
const buffer = new ArrayBuffer(rowOutputLength * splatCount);
const position = new Float32Array(buffer);
const scale = new Float32Array(buffer);
const rgba = new Uint8ClampedArray(buffer);
const rot = new Uint8ClampedArray(buffer);
// positions
for (let i = 0; i < splatCount; i++) {
position[i * 8 + 0] = gsCloud.positions[i * 3 + 0];
position[i * 8 + 1] = gsCloud.positions[i * 3 + 1];
position[i * 8 + 2] = gsCloud.positions[i * 3 + 2];
}
// colors
for (let i = 0; i < splatCount; i++) {
rgba[i * 32 + 24 + 0] = gsCloud.colors[i * 3 + 0];
rgba[i * 32 + 24 + 1] = gsCloud.colors[i * 3 + 1];
rgba[i * 32 + 24 + 2] = gsCloud.colors[i * 3 + 2];
rgba[i * 32 + 24 + 3] = gsCloud.alphas[i];
}
// scales
for (let i = 0; i < splatCount; i++) {
scale[i * 8 + 3 + 0] = gsCloud.scales[i * 3 + 0];
scale[i * 8 + 3 + 1] = gsCloud.scales[i * 3 + 1];
scale[i * 8 + 3 + 2] = gsCloud.scales[i * 3 + 2];
}
// rotations
for (let i = 0; i < splatCount; i++) {
rot[i * 32 + 28 + 1] = gsCloud.rotations[i * 4 + 0] * 127.5;
rot[i * 32 + 28 + 2] = gsCloud.rotations[i * 4 + 1] * 127.5;
rot[i * 32 + 28 + 3] = gsCloud.rotations[i * 4 + 2] * 127.5;
rot[i * 32 + 28 + 0] = gsCloud.rotations[i * 4 + 3] * 127.5;
}
return buffer;
};
使い勝手としてはこんな感じ
const spzData = await fetch(spzPath).then((res) => res.arrayBuffer());
await createGaussianSplattingFromSpz(spzData);
しかし、この状態でdevサーバを開くと
こんな感じのエラーがコンソールに表示された
Server responded with status code 431. See https://vite.dev/guide/troubleshooting.html#_431-request-header-fields-too-large.
ブラウザも大変なことになっていた
調べてみると、どうやらリクエストヘッダが大きすぎるとのこと
なんかリクエストする時にwasmのb64文字列がそのままURLに入ってるもんで、それはヤバいな
見てみると、spz-wasmのbuild/main.mjs
にこんなコードが
bundler-friendlyとはいえ、これが最終的にb64に置き換わっているようで
たしかにこんなに長い文字列のurlだったらクラッシュするだろうなと
ちなみに、解決策としてsrciptsにこんな感じに記述することも可能
しかし、こうしたところで根本解決はせず、普通にURLが長いエラーでクラッシュした
"scripts": {
"dev": "NODE_OPTIONS=--max_http_header_size=12800000 vite",
さて、wasmの取り扱いを今一度考えないとな
いま取れる選択肢は下記
- emscriptenのビルド時にSINGLE_FILE=1をつける
- vite-plugin-wasmを使ってみる
うぉおおお~
positionは合っていそう、RGBとAlphaがな~~
なんかcolorを見ていたら変~~~~な感じのことをしていたので、それをいい感じに補正してやったら
なんかそれっぽいのが出てきた!
だけどなんか回転なのかな、が変、惜しい
positionにはマイナス付けてる部分があるからかな
monorepo構成では共通するconfigファイルを一つのパッケージにまとめようというのはよさそう、やってみたさ
リリースフローに関してはやっぱりlerna-lite/versionがいいかなぁ
表示が変なの、回転が間違っていた!修正したらいい感じに描画された!!
さて、全然目も取れていなかったけど
PRのpushごとにlint/formatとビルドのCIジョブを回したいと思って色々やっていました
当初は、せっかくemscriptenの公式dockerを使っているのでそれを使いたかったけど
なぜか全然GitHub Actions上で動かなかった
おもにPermission系で全然いうことを聞いてくれなくてお手上げになってしまった
なんか、良いところまで来たかなって思ったんだけど
npmでtypescriptをインストしようとしたら/.npmへのアクセス権が無くて失敗したり
最終的にCIは、emscriptenが使えるactionsがあったため、
それを使いつつem++でコンパイルするという形になった
いや~~環境が違ってくるのでちょっと大丈夫かなぁと思うんだけど、
とりあえずしょうがなし・・・
使ったactionsはこれ
普通に動いてるのでいい感じです
バージョン指定もできる
dockerのバージョンもちゃんと指定しないとな
次はメモリリークに対応したい、いやもうこれ対応したらリリースしたいまであるなぁ
これを参考にしたい
scaniverseの表示と比べて、なんかコントラストが弱いのが気になるな?なんだろ
ここに関してちょっと調査をしたところ、どうやらSHの有無や色情報の帯域?によって
色に補正をかけるための係数が違うらしい(↓はload-spz.cc
)
Scale factor for DC color components. To convert to RGB, we should multiply by 0.282, but it can be useful to represent base colors that are out of range if the higher spherical harmonics bands bring them back into range so we multiply by a smaller value.
で、コード中では0.15をかけていたけど、0.282をかけて復元するようにしたら
公式のscaniverseと同じような見た目になった
メモリリーク原因対策の前に、emscriptenのwasmのデバッグについて確認する
このページを参考に
Chrome拡張があるんだな
しかしこれを使うにはcanaryかdevが必要なのかな?
結論、DevやCanaryじゃなくて現行の通常バージョンでも使えた
基本的に拡張機能を入れれば使えそう
だけどDockerやWSLといった、ブラウザを実行している環境ではなく
その環境をマウントしている別の仮想環境などを使っている場合は注意
wasmの場合はsourcemap的な感じでDWARFというデバッグ情報を埋め込むのだけど
そのパスがビルド環境のものなので、いざブラウザ実行環境でパスを参照しようとしたときにエラーになってしまう
例えば自分は、ブラウザはWin11Homeで実行していて
emscriptenを公式のemscripten/emsdkというdockerコンテナ上でビルドしている
そしてカレントディレクトリは/srcにマウントされる仕組みである
そうなったときに、拡張機能のオプションからパスをoverrideするように設定する
するとこんな感じに、ChromeのDevToolsから
wasmのデバッグをC++コードを参照しながらできるのである。すごいな、便利やな~~
あと、Emscriptenのコンパイラオプションで-g
を付けるのを忘れずに
メモリリークの件に関して
ここの分野に詳しくないというのもそうなんだけど、
メモリリーク起きてるのかな?という気になってきた
どうやら@spz-loader/core
のほうでは起きていないような挙動になっていて
@spz-loader/babylonjs
では怪しい
具体的にはリロードのたびにメモリが増える挙動になるものの、
ここではJSのメモリ空間を扱っているので
適切にGCが効く場合もある、みたいな
一番考えていたwasmとのメモリ空間での問題がないのであれば
そこまで大きな問題ではないのではと思って
0.1.0リリース後の対応でもいいような優先度になりました
とはいえ、babylonjsローダが正直普通ではありえんくらいメモリを使っている感じではあるので
早く何とかしたい気持ちではいますね
spz-loaderのnpmオーがないゼーションを取得しました
やはりやっておこうということで、メモリ使用の最適化を行った
確実にコピーが多いだろうなぁって部分を削ったり、
あとめっちゃ聞いたのはEmscriptenのコンパイラプションだった
最終的に採用したオプションたち
em++ main.cpp \
-Oz \
--closure 1 \
--llvm-lto 1 \
-fno-exceptions \
--std=c++17 \
-lembind \
--emit-tsd main.d.ts \
-o ./build/main.mjs \
-s USE_ZLIB=1 \
-s WASM=1 \
-s SINGLE_FILE=1 \
-s MODULARIZE=1 \
-s EXPORT_ES6=1 \
-s EXPORT_NAME="spzwasm" \
-s ALLOW_MEMORY_GROWTH \
-s EXPORTED_FUNCTIONS="['_malloc', '_free']"
これを取り入れたら、もともと肥大しがちだったメモリ使用量が1/2いかになったりめっちゃ良かった
読み込み速度も上がった気がする