2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Babylon.jsでガウシアンスプラッティングをアレコレしてみる(4/4)

Posted at

はじめに

この記事は Babylon.js Advent Calendar 2025 の、22日目の記事です

今回は4つに分けて描かせていただいています。
1.Scaniverseによるスキャンと、Babylon.jsで表示する手順
2.フォトグラメトリの特長について
3.ガウシアンスプラットの特長について
4.Babylon.jsでガウシアンスプラットのデータの編集

この記事は4.Babylon.jsでガウシアンスプラットのデータの編集 について記載します。

今回やること

前回の記事でも触れましたが、3DGSのデータは撮影箇所からの見た目を再現するため、
動いている歩行者が特定の角度からのみ見えるように生成されたり(下図)

gaus20251222_01.jpg

建物を野外と屋内の両方撮影した場合に野外から見た時に入り口が黒く写る写真を再現する為、入り口に黒い楕円が生成されたり(下図、右側の画像)
gaus20251222_03.jpg

青空を表示するスプラットが低い位置に生成されて邪魔になったり(下図)
gaus20251222_02.jpg

といった現象が発生します。

今回はこのようなときに不要なスプラットを透明にしてしまう処理について、Babylon.jsで記載してみます。

3DGSのデータをBabylon.jsで編集してみる

ScaniverseでSPZ形式でエクスポートしたデータは、1つのスプラットに対して以下のデータで構成されています。
gaus202512151_3.jpg

Babylon.jsでファイルを読み込んで、範囲指定などで選択スタスプラットの透明度を上げてみましょう。
まず、1回目記事に記載した3DGSを表示をするソースです。
https://ddy.nukenin.jp/Qiita/qiita_20251208.html

<html>
<head>
  <meta charset="utf-8"/>
  <title>Babylon.js Playground</title>
  <meta name="viewport" content="width=device-width,initial-scale=1"/>
	<script src="https://cdn.babylonjs.com/babylon.js"></script>
	<script src="https://cdn.babylonjs.com/loaders/babylonjs.loaders.js"></script>
	<style>
		html,body,#renderCanvas{width:100%;height:100%;margin:0;padding:0;overflow:hidden;background:#000}
	</style>
</head>
<body>
	<canvas id="renderCanvas"></canvas>
</body>
</html>


<script>
    const canvas = document.getElementById("renderCanvas");
    const engine = new BABYLON.Engine(canvas, true);

    const createScene = function () {
        const scene = new BABYLON.Scene(engine);
        scene.clearColor = new BABYLON.Color3(0.8, 0.8, 0.8); // シーンの背景色を設定(任意)

        // カメラ
        const camera = new BABYLON.ArcRotateCamera("camera1", 1, 1, -3, new BABYLON.Vector3(-2, 3, -2), scene);
        camera.setTarget(BABYLON.Vector3.Zero());
        camera.wheelDeltaPercentage = 0.04;
        camera.minZ = 0.1;
        camera.attachControl(canvas, true);

        return scene;
    };

    const init = async () => {
        const scene = createScene(); 
        let splattingMesh; 

        try {
            console.log("SPZファイルの読み込みを開始します...");
            
            const result = await BABYLON.SceneLoader.ImportMeshAsync(
                "", // meshNames
                "https://raw.githubusercontent.com/ykoba079/sample1/master/Qiita/", // rootUrl 
                "takeyabu.spz", // fileName 
                scene 
            );
            const splatMesh = result.meshes[0];
			if (splatMesh) {
				splatMesh.rotation.x = Math.PI;// X軸を中心に180度(π)回転させる
			}
        } catch (error) {
            console.error("SPZファイルの読み込みエラー:", error);
            alert("SPZファイルの読み込み中にエラーが発生しました。コンソールを確認してください。");
        }

        // 読み込み完了後、レンダリングループを開始
        engine.runRenderLoop(function () {
            scene.render();
        });
    };
    
    // 初期化関数を呼び出す
    init();

</script>

データを読み込んで表示できたので、このデータの中身を更新するだけで~
と最初思ったのですが、読み込んだメッシュはとれ、_splatPositionsはアクセスできるものの肝心の_splatsDataがnullという現象に会いました。

        // GaussianSplattingMesh を取得
        const gsMesh = gs.meshes.find(mesh => mesh instanceof BABYLON.GaussianSplattingMesh);
        if (gsMesh) {
            // splatsData にアクセス
            const spzPoints = gsMesh._splatPositions;
            const spzData = gsMesh._splatsData;

ちょっとこれ判らんなぁとなっていたところ、Discodeで教えていただけました。
spzを読み込むときにKeepInRam:tureと指摘しないと、実データはブラウザのメモリ消費を抑えるためブラウザ内に持ってこずにGPU側にのみ存在していて、それを表示しているようです?そんなのありなの?

gaus20251222_04.jpg

ちなみにPLY形式だとメモリ消費が10倍くらいあるのですがそちらはオプション指定なしでKeepInRam:tureと同じ挙動でした。むぅ。
ともあれ、3DGSのデータを読み込む部分を以下のように変更することで、JavaScriptからデータにアクセス可能になりました。

await BABYLON.LoadAssetContainerAsync(
        "https://raw.githubusercontent.com/ykoba079/sample1/master/babylonbook/babylonbook18/yabu_gaus.spz",
        scene,
        {
            pluginOptions: {
                splat: {
                    keepInRam: true
                },
            },
        }

さて、では範囲を選択して透明にする処理です。
フレームワークのCUBEを作成してギズモを紐づけてCUBEの位置、サイズを変更できるようにします。
(上下反転しているのでちょっとこのままだと使いにくいですが)
トリミングボタンを追加して、クリックするとCUBE内のXYZ座標に存在するスプラットの透明度をMAXにしするよう処理を記載しました。
単純にスプラットをForで回してXYZがCUBE内か判断し、CUBE内であれば透明度の部分のデータを0に更新しています。
この関数でデータを変更した後、splattingMesh.updateData(splattingMesh.splatsData);で表示用のメモリに変更したデータを反映する処理をしています。

// Cube に重なっているスプラットを透明化
function makeSplatsInCubeTransparent(gs, cube) {
    const arrayBuffer = gs.splatsData;
    const positions = new Float32Array(arrayBuffer);
    const view = new DataView(arrayBuffer);

    const bbox = cube.getBoundingInfo().boundingBox;
    var cnt1 = 0;
    for (let i = 0; i < positions.length / 8; i++) {
        const x = positions[i * 8 + 0];
        const y = positions[i * 8 + 1];
        const z = positions[i * 8 + 2];

        const point = new BABYLON.Vector3(x, y, z);
        if (
            point.x >= bbox.minimumWorld.x && point.x <= bbox.maximumWorld.x &&
            point.y >= bbox.minimumWorld.y && point.y <= bbox.maximumWorld.y &&
            point.z >= bbox.minimumWorld.z && point.z <= bbox.maximumWorld.z
        ) {

            const baseIndex = (i * 8 + 6) * 4; // 6番目のfloatが不透明度用と仮定
            const modifiedByte = 0;//透明度
            view.setUint8(baseIndex + 3, modifiedByte);   // 最上位バイトAlpha 0 にして完全透明
            cnt1++;
        }
    }
}

以下が実行結果です。
範囲内のスプラットが透明になって竹藪に穴が開きましたね。
gaus20251222_06.jpg

今回はXYZの指定のみですが、単純なIFで指定しているので例えば色の範囲指定(暗いノイズを狙って消す)など条件を変更してみるのもいいと思います。

このような処理を使って、例えば通行人を削除したり
07_uturikomitrim.jpg

邪魔な空のノイズを除去したり
gaus20251222_07.jpg

建物の入り口にできる黒いモヤを削除したり
gaus20251222_08.jpg

といったことが可能になります。

今回は透明度を上げる処理だけですが、色や場所の変更もできますので色々試してみるのもいいかもしれませんね。

例えば、サイズのみ弄ると楕円の形状が細長くなりプライバシー保護のような加工になったりもします。
gaus20251222_09.jpg

まとめ

反射や水面も写真のように表現できる3DGS凄いなと調べてみると、形状よりも見た目を再現する為に特化した処理を行っていたり、画像のドットのように楕円を大量に配置しているところとかなるほどー・・・と思うところがたくさん見つかり楽しかったです。

ちょっと扱いずらい点もありますが色々使える気もします。

コードは以下となります。
https://ddy.nukenin.jp/Qiita/qiita_20251222.html

<html>
<head>
  <meta charset="utf-8"/>
  <title>Babylon.js Gaussian Splatting Trimmer</title>
  <meta name="viewport" content="width=device-width,initial-scale=1"/>
  <script src="https://cdn.babylonjs.com/babylon.js"></script>
  <script src="https://cdn.babylonjs.com/loaders/babylonjs.loaders.js"></script>
  <script src="https://cdn.babylonjs.com/gui/babylon.gui.min.js"></script>
  <style>
    html,body,#renderCanvas{width:100%;height:100%;margin:0;padding:0;overflow:hidden;background:#000}
  </style>
</head>
<body>
  <canvas id="renderCanvas"></canvas>
</body>
</html>

<script>
const canvas = document.getElementById("renderCanvas");
const engine = new BABYLON.Engine(canvas, true);

const createScene = async function () {
    const scene = new BABYLON.Scene(engine);

    var camera = new BABYLON.ArcRotateCamera("camera1", 1, 1, -3, new BABYLON.Vector3(-2, 3, -2), scene);
    camera.setTarget(BABYLON.Vector3.Zero());
    camera.wheelDeltaPercentage = 0.04;
    camera.minZ = 0.1;
    camera.attachControl(canvas, true);

    // トリミング用の四角
    const box = BABYLON.MeshBuilder.CreateBox("trimBox", { size: 1 }, scene);
    box.scaling.set(1, 1, 1);
    box.position.set(0, 1, 0);
    box.enableEdgesRendering();
    box.edgesWidth = 2.0;
    box.edgesColor = new BABYLON.Color4(1, 0, 0, 1);
    const invisibleMat = new BABYLON.StandardMaterial("invisible", scene);
    invisibleMat.alpha = 0;
    box.material = invisibleMat;

    // ギズモマネージャー
    const gizmoManager = new BABYLON.GizmoManager(scene);
    gizmoManager.positionGizmoEnabled = true;
    gizmoManager.usePointerToAttachGizmos = false;
    gizmoManager.attachToMesh(box);

    // 個別スケールギズモ(矢印反転)
    const scaleGizmo = new BABYLON.ScaleGizmo();
    scaleGizmo.attachedMesh = box;
    scaleGizmo.updateGizmoRotationToMatchAttachedMesh = true;

    // Y軸のギズモメッシュ取得
    const yGizmoRoot = scaleGizmo.yGizmo._rootMesh;
    yGizmoRoot.getChildMeshes().forEach(child => {
        child.scaling.z *= -1; // Z方向を反転
    });

    // X軸の矢印を反転
    const xGizmoRoot = scaleGizmo.xGizmo._rootMesh;
    xGizmoRoot.getChildMeshes().forEach(child => {
        child.scaling.z *= -1;
    });

    // Z軸の矢印を反転
    const zGizmoRoot = scaleGizmo.zGizmo._rootMesh;
    zGizmoRoot.getChildMeshes().forEach(child => {
        child.scaling.z *= -1;
    });

    // GUIボタン
    const advancedTexture = BABYLON.GUI.AdvancedDynamicTexture.CreateFullscreenUI("UI");
    const button = BABYLON.GUI.Button.CreateSimpleButton("btn", "Trim");
    button.width = "160px";
    button.height = "40px";
    button.color = "white";
    button.background = "darkred";
    button.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_BOTTOM;
    button.top = "-10px";
    advancedTexture.addControl(button);

    // 保存ボタン(GUIに追加)
    const saveButton = BABYLON.GUI.Button.CreateSimpleButton("save", "save splat file");
    saveButton.width = "160px";
    saveButton.height = "40px";
    saveButton.color = "white";
    saveButton.background = "darkred";
    saveButton.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_BOTTOM;
    saveButton.top = "-10px";
    saveButton.left = "220px";
    advancedTexture.addControl(saveButton);

    // spz読込
    var splattingMesh;
    var splattingMesh;
    await BABYLON.ImportMeshAsync(
        "https://raw.githubusercontent.com/ykoba079/sample1/master/babylonbook/babylonbook18/yabu_gaus.spz",
        scene,
        {
            pluginOptions: {
                splat: {
                    keepInRam: true
                },
            },
        }
    ).then(function (result) {
        splattingMesh = result.meshes[0];
        splattingMesh.scaling.y *= -1;
    }).catch(function(error) {
        alert(error);
    });

    // ボタンクリックで、Cube に重なるスプラットを透明化
    button.onPointerUpObservable.add(() => {
        makeSplatsInCubeTransparent(splattingMesh, box);
        splattingMesh.updateData(splattingMesh.splatsData);
        splattingMesh.scaling.y = -1; 
    });

    // ボタン押下で .splat ファイルを保存
    saveButton.onPointerUpObservable.add(() => {
        const buffer = splattingMesh?.splatsData;
        const blob = new Blob([buffer], { type: 'application/octet-stream' });
        BABYLON.Tools.Download(blob, "trimmed_output.splat");
    });

    return scene;
};

// Cube に重なっているスプラットを透明化
function makeSplatsInCubeTransparent(gs, cube) {
    const arrayBuffer = gs.splatsData;
    const positions = new Float32Array(arrayBuffer);
    const view = new DataView(arrayBuffer);

    const bbox = cube.getBoundingInfo().boundingBox;
    var cnt1 = 0;
    for (let i = 0; i < positions.length / 8; i++) {
        const x = positions[i * 8 + 0];
        const y = positions[i * 8 + 1];
        const z = positions[i * 8 + 2];

        const point = new BABYLON.Vector3(x, y, z);
        if (
    point.x >= bbox.minimumWorld.x && point.x <= bbox.maximumWorld.x &&
    point.y >= bbox.minimumWorld.y && point.y <= bbox.maximumWorld.y &&
    point.z >= bbox.minimumWorld.z && point.z <= bbox.maximumWorld.z
) {

            const baseIndex = (i * 8 + 6) * 4; // 6番目のfloatが不透明度用と仮定
            const modifiedByte = 1;//透明度
            view.setUint8(baseIndex + 3, modifiedByte);   // 最上位バイト(Alpha)を 0 にして完全透明
            cnt1++;
        }
    }
}

// シーン作成と実行
createScene().then(scene => {
    engine.runRenderLoop(() => {
        scene.render();
    });

    window.addEventListener("resize", () => {
        engine.resize();
    });
});
</script>
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?