はじめに
近年では、3Dでオブジェクトや環境をキャプチャできるデバイスが増え、より手頃な価格で利用できるようになってきました。
このような3Dスキャンにおいて、Photogrammetry(フォトグラメトリー)やGaussian Splatting(ガウシアン・スプラッティング)は、特に人気の高い形式です。
そこで私たちは、追加のインストールなしで、Webブラウザ上でそのまま動作する3Dスキャンデータのインタラクティブなビューワを試作しました。
このようなビューワは、さまざまな業界で活用でき、スキャンしたオブジェクトを魅力的かつ簡単に3D表示することが可能になります。
 
    
使用した技術
このプロジェクトでは、3Dスキャンデータの表示に以下のオープンソースコンポーネントを活用しました:
- model-viewer(Google製) Photogrammetry(フォトグラメトリー)で得られた.glbファイルをWebブラウザ上で簡単に表示できるJavaScriptライブラリです。<model-viewer>タグ一つでインタラクティブな3D表示が可能になります。
- aframe-gaussian-splatting(quadjr氏によるA-Frame拡張) Gaussian Splattingで生成された.plyファイルを表示するためのA-Frameベースのコンポーネントです。私たちの実装では、このライブラリのコードを直接変更せず、HTMLとJavaScriptの外部ロジックから制御・拡張しました。
実装の概要
本アプリケーションは、1ページ内に2種類の3Dスキャン形式(PhotogrammetryとGaussian Splatting)を同時に表示できる構成で開発されました。
具体的には、以下の2つのファイル形式に対応しています:
- .glb:Photogrammetry(フォトグラメトリー)で生成されたモデル
- .ply:Gaussian Splatting(ガウシアン・スプラッティング)で生成されたモデル
両方のビューワは同一ページ内に独立して配置されており、ページを再読み込みすることなく、複数ファイルを切り替えながら比較・確認することができます。
また、ユーザーが簡単に試せるように、ドラッグ&ドロップ操作でファイルを読み込むUIを実装しました。手元にあるファイルをすぐに表示できるため、体験性の高い仕組みになっています。
依存ライブラリとスクリプトの読み込み
このビューワは1ページで完結する構成であるため、使用するライブラリをすべてCDNまたはローカルから読み込む必要があります。
- 
        <!-- model-viewer for GLB -->
- 
        <script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
- 
        <!-- A-Frame and Gaussian Splatting plugin -->
- 
        <script src="https://aframe.io/releases/1.4.2/aframe.min.js"></script>
- 
        <script src="./aframe-gaussian-splatting-main/index.js"></script>
補足ポイント:
- model-viewerはCDNから直接読み込んでおり、リポジトリのクローンや改変は不要です。
- 一方、.plyファイルの表示に使用したaframe-gaussian-splattingはローカルにクローンしてプロジェクトに組み込んでいます(ただしコードの改変は行っていません)。
1. .glbファイルの表示(Photogrammetry)
Photogrammetryで作成された.glbファイルの表示には、Google製の <model-viewer> ライブラリを使用しています。
 
    
実装のポイント:
- ドラッグ&ドロップによる読み込み対応
    ユーザーが.glbファイルをビューワにドラッグ&ドロップすると、自動的に表示されるように設定しています:
- 
        <h1>GLB Viewer</h1>
- 
        <model-viewer id="viewer" alt="3D model" camera-controls auto-rotate ar ar-modes="webxr scene-viewer quick-look">
- 
        <div class="drop-text" id="viewerDropText">
- 
        Drop your <strong> .glb </strong> file here
- 
        </div>
- 
        </model-viewer>
- 
        <script>
- 
        // GLB drag & drop
- 
        const viewer = document.getElementById('viewer');
- 
        const viewerDropTxt = document.getElementById('viewerDropText');
- 
        viewer.addEventListener('dragover', e => e.preventDefault());
- 
        viewer.addEventListener('drop', e => {
- 
        e.preventDefault();
- 
        const file = e.dataTransfer.files[0];
- 
        if (file && file.name.toLowerCase().endsWith('.glb')) {
- 
        viewer.src = URL.createObjectURL(file);
- 
        }
- 
        viewerDropTxt.classList.add('hidden');
- 
        });
- 
        </script>
- 直感的なUI構成
ファイルをドロップするよう促すガイドテキストを表示し、読み込み完了後は非表示に切り替わります。
 
    
- ライブラリの改変なし
    <model-viewer>のソースコードには一切手を加えず、すべてHTMLとJavaScript側で制御しています。
2. .plyファイルの表示(Gaussian Splatting)
Gaussian Splattingで生成された.plyファイルは、描画処理がより複雑なため、実装難易度は高めです。
表示には aframe-gaussian-splatting を利用し、ライブラリ本体には改変を加えずに統合しました。
 
    
実装のポイント:
- ドラッグ&ドロップによる読み込み
.plyファイルをA-Frameベースのビューワにドロップすることで、JavaScript経由で読み込まれます。
- カメラ初期化処理の追加
新しいモデルを読み込む際にカメラの位置・回転を初期化し、常に見やすい位置から表示を開始するようにしました。
- Canvasへのフォーカス制御
キーボード操作が有効になるよう、クリックやタップ時に自動でCanvasにフォーカスを当てる処理を追加しました。
- 
        <h1>Gaussian Splat Viewer (.ply)</h1>
- 
        <div class="viewer-container" id="ply-drop-zone">
- 
        <a-scene embedded stats="false" style="width:100%; height:100%; margin:0;" tabindex="0" id="plyScene">
- 
        <a-entity id="plyEntity" gaussian_splatting position="0 0.5 0" scale="1 1 1">
- 
        </a-entity>
- 
        <!-- Fixed camera behind looking at origin -->
- 
        <a-entity id="plyCamera" camera position="0 0.5 0.2" look-controls="pointerLockEnabled: false"></a-entity>
- 
        <a-sky color="#000"></a-sky>
- 
        </a-scene>
- 
        <!-- Drop your .ply file here -->
- 
        <div class="drop-text" id="plyDropText">
- 
        Drop your <strong> .ply </strong> file here
- 
        </div>
- 
        </div>
- 
        <script>
- 
        // GLB drag & drop
- 
        const viewer = document.getElementById('viewer');
- 
        const viewerDropTxt = document.getElementById('viewerDropText');
- 
        viewer.addEventListener('dragover', e => e.preventDefault());
- 
        viewer.addEventListener('drop', e => {
- 
        e.preventDefault();
- 
        const file = e.dataTransfer.files[0];
- 
        if (file && file.name.toLowerCase().endsWith('.glb')) {
- 
        viewer.src = URL.createObjectURL(file);
- 
        }
- 
        viewerDropTxt.classList.add('hidden');
- 
        });
- 
        // PLY drag & drop
- 
        const plyDrop = document.getElementById('ply-drop-zone');
- 
        const plyEntity = document.getElementById('plyEntity');
- 
        const plyDropText = document.getElementById('plyDropText');
- 
        const plyScene = document.getElementById('plyScene');
- 
        // 1) Wait for A-Frame to render the canvas
- 
        plyScene.addEventListener('renderstart', () => {
- 
        // 1.1) Capture the actual <canvas>
- 
        const canvas = plyScene.renderer.domElement;
- 
        // 1.2) Make it focusable
- 
        canvas.setAttribute('tabindex', '0');
- 
        canvas.style.outline = 'none';
- 
        // 1.3) On click or touch, give it focus
- 
        ['mousedown', 'touchstart', 'click'].forEach(evt =>
- 
        canvas.addEventListener(evt, () => canvas.focus())
- 
        );
- 
        });
- 
        // 2) On container click, focus the canvas
- 
        plyDrop.addEventListener('click', () => {
- 
        const canvas = plyScene.renderer.domElement;
- 
        if (canvas) canvas.focus();
- 
        });
- 
        // Reference to camera entity
- 
        const cameraEl = document.getElementById('plyCamera');
- 
        let cameraObj;
- 
        // 3) Drag & drop PLY
- 
        plyDrop.addEventListener('dragover', e => e.preventDefault());
- 
        plyDrop.addEventListener('drop', e => {
- 
        e.preventDefault();
- 
        const file = e.dataTransfer.files[0];
- 
        if (!file || !file.name.toLowerCase().endsWith('.ply')) {
- 
        alert('Please drop a valid .ply file.');
- 
        return;
- 
        }
- 
        plyDropText.classList.add('hidden');
- 
        // Clean scene
- 
        while (plyEntity.object3D.children.length) {
- 
        plyEntity.object3D.remove(plyEntity.object3D.children[0]);
- 
        }
- 
        // Reset camera position & rotation
- 
        const camEl = document.getElementById('plyCamera');
- 
        camEl.setAttribute('position', '0 0.5 0.2');
- 
        camEl.object3D.rotation.set(0, 0, 0);
- 
        const lc = camEl.components['look-controls'];
- 
        if (lc) {
- 
        lc.pitchObject.rotation.set(0, 0, 0);
- 
        lc.yawObject.rotation.set(0, 0, 0);
- 
        }
- 
        camEl.setAttribute('rotation', '0 0 0');
- 
        // Load new splat
- 
        const blobURL = URL.createObjectURL(file);
- 
        plyEntity.setAttribute('gaussian_splatting', `src: ${blobURL}`);
- 
        // Refocus canvas for keyboard
- 
        const canvas = plyScene.renderer.domElement;
- 
        if (canvas) canvas.focus();
- 
        });
- 
        </script>
- キーボード操作によるカメラ移動
W/A/S/DやShiftキーを使ってカメラを移動する独自のキーボード制御機能を実装しました。自由に視点を動かすことができます。
- 
        <script>
- 
        // 4) Manual keyboard handler
- 
        let keyboardEnabled = false;
- 
        plyDrop.addEventListener('click', () => {
- 
        if (!keyboardEnabled) {
- 
        keyboardEnabled = true;
- 
        cameraObj = cameraEl.object3D;
- 
        window.addEventListener('keydown', onKeyDown);
- 
        plyDropText.textContent = 'Keyboard enabled: use W/A/S/D or arrows';
- 
        }
- 
        });
- 
        function onKeyDown(e) {
- 
        const step = 0.02;
- 
        if (!cameraObj) return;
- 
        switch (e.key.toLowerCase()) {
- 
        case 'w':
- 
        if (e.shiftKey) {
- 
        cameraObj.translateY(step);
- 
        } else {
- 
        cameraObj.translateZ(-step);
- 
        }
- 
        break;
- 
        case 's':
- 
        if (e.shiftKey) {
- 
        cameraObj.translateY(-step);
- 
        } else {
- 
        cameraObj.translateZ(step);
- 
        }
- 
        break;
- 
        case 'a':
- 
        cameraObj.translateX(-step);
- 
        break;
- 
        case 'd':
- 
        cameraObj.translateX(step);
- 
        break;
- 
        }
- 
        }
- 
        </script>
- A-Frameのステータスパネルを非表示化
デフォルトで表示される開発者向けパネル(stats)を削除し、すっきりしたUIを維持しています。
- 
        <script>
- 
        // Remove any A-Frame stats panel
- 
        function hideAFrameStats() {
- 
        document.querySelectorAll('#stats, .stats, .a-stats').forEach(el => el.remove());
- 
        }
- 
        const sceneEl = document.getElementById('plyScene');
- 
        sceneEl.addEventListener('renderstart', hideAFrameStats);
- 
        sceneEl.addEventListener('enter-vr', hideAFrameStats);
- 
        sceneEl.addEventListener('exit-vr', hideAFrameStats);
- 
        document.addEventListener('fullscreenchange', hideAFrameStats);
- 
        </script>
結果
 
    
まとめ
本プロジェクトでは、追加のソフトウェアやインストールなしに、Webブラウザ上で3Dスキャンデータをインタラクティブに表示できる仕組みを実現しました。
.glb形式のモデルには<model-viewer>、.ply形式のGaussian Splattingモデルにはaframe-gaussian-splattingを活用することで、軽量かつ柔軟性の高いソリューションを構築し、複数のフォーマットを即時に表示することが可能となりました。
このようなビューワは、以下のような幅広い分野での活用が期待されます:
- ECサイトでの商品表示(立体的な確認による購買促進)
- 文化財・博物館における3D資料のオンライン展示
- 医療・産業用途におけるスキャンデータの確認・共有
ただし、いくつかの課題も存在します。
- 元となる3Dスキャンモデルの解像度や品質が不十分であれば、ユーザー体験を損ねてしまう可能性があります。
- 逆に、高精細なモデルはファイルサイズが非常に大きくなり、Webブラウザでの表示に時間がかかったり、モバイル環境での実用性が低くなる場合もあります。
    そのため、本技術は大きな可能性を秘めている一方で、すべての利用シーンに最適とは限らないことも念頭に置く必要があります。
    用途に応じて、表示品質とデータ容量のバランスを見極めることが重要です。
 
    
上記ブログの内容に少しでも興味がありましたら、お気軽にご連絡ください。
弊社のエンジニアがフレンドリーに対応させていただきます。

