[JavaScript] フォルダ置くだけで動く3D ROOM機能を作る(Three.js)
コンセプト
個人サイトに3Dの「部屋」を持つ。
ZIPを解凍して/public/room/に置くだけで、誰のサーバーでも動く。ビルドツールなし、npmなし、フレームワークなし。index.html、app.js、style.css、room.jsonの4ファイルで完結する3Dウォークスルー空間。
部屋のモデルはSketchfabからCC-BYのGLBをダウンロードして差し替えるだけ。VRMアバターを置いて歩かせる。将来的にはポータル(ドア)を通じて他のサイトのROOMと繋げる。空間的なハイパーリンク。
スクリーンショット:
動画:
構成
/public/room/
index.html ← importmapでCDNからThree.js読み込み
app.js ← ルームエンジン全部入り
style.css ← UI
room.json ← 部屋の定義(データ駆動)
assets/
room/
models.json ← 部屋テンプレートカタログ
room_blank.glb
avatar/
default.vrm
idle.vrma
walk.vrma
importmapでCDNから全ライブラリを引っ張る。ローカルにインストールするものはゼロ。
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/[email protected]/build/three.module.js",
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/",
"three-mesh-bvh": "https://unpkg.com/[email protected]/build/index.module.js",
"@pixiv/three-vrm": "https://unpkg.com/@pixiv/[email protected]/lib/three-vrm.module.min.js",
"@pixiv/three-vrm-animation": "https://unpkg.com/@pixiv/[email protected]/lib/three-vrm-animation.module.min.js"
}
}
</script>
room.json:データ駆動の部屋定義
部屋の全情報をJSONに外出し。GLBパス、スポーン位置、アバター設定、ポータル先URL、スカイボックスまで全部ここに書く。
{
"name": "Lain's Room",
"spawn": [0, 0, 3],
"room": {
"glb": "./assets/room/room_blank.glb",
"scale": 1,
"pos": [0, 0, 2],
"collision": true
},
"avatar": {
"vrm": "./assets/avatar/default.vrm",
"scale": 1,
"animations": {
"idle": "./assets/avatar/idle.vrma",
"walk": "./assets/avatar/walk.vrma"
}
},
"portals": [
{ "pos": [4.9, 1, 0], "url": "https://someone.com/room/", "label": "Someone's Room" }
],
"sky": {
"type": "gradient",
"topColor": "#0a0a2e",
"midColor": "#1a1a4e",
"stars": true,
"moon": true
}
}
room.jsonがなければプリミティブ(box/sphere)で部屋を自動生成するフォールバック付き。
GLBの部屋モデル読み込み
SketchfabのCC-BYモデルをダウンロードしてassetsに置くだけ。GLTFLoaderにDRACOLoaderを登録して読み込む。
ポイントは、GLBを読んだ後にBVHを構築すること。
import { MeshBVH, acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh';
// Three.jsにBVHパッチを当てる
THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree;
THREE.Mesh.prototype.raycast = acceleratedRaycast;
// GLBロード時
model.traverse((child) => {
if (child.isMesh && child.geometry) {
child.geometry.computeBoundsTree(); // BVH構築
colliderMeshes.push(child);
}
});
これでレイキャストが高速化される。家具が多いGLBでも移動が重くならない。
衝突判定:レイキャスター方式
壁の衝突判定は8方向レイキャスト。腰と膝の2段で計16本のレイを飛ばして、PLAYER_RADIUS以内にメッシュがあれば移動をブロック。
加えて、床なし落下防止。移動先の真下にレイを飛ばして、床メッシュに当たらなければ進めない。窓から落ちたりしない。
// 床チェック
_floorRaycaster.set(
new THREE.Vector3(newPos.x, newPos.y, newPos.z),
new THREE.Vector3(0, -1, 0) // 真下
);
const floorHits = _floorRaycaster.intersectObjects(colliderMeshes, false);
if (floorHits.length === 0) {
return true; // 床がない = 進めない
}
VRMアバターの表示と歩行
@pixiv/three-vrmでVRMを読み込み、@pixiv/three-vrm-animationで.vrmaファイルからアニメーションクリップを生成。WASD入力の有無でidle↔walkをクロスフェード切り替え。
ADV(PROTOCOL.LAIN)のR3F実装と同じロジックをvanilla Three.jsに移植した。
function playAvatarAnim(animId, fadeDuration = 0.3) {
const clip = vrmAnims[animId];
const newAction = playerMixer.clipAction(clip);
if (currentAction && currentAction !== newAction) {
currentAction.fadeOut(fadeDuration);
newAction.fadeIn(fadeDuration).play();
} else {
playerMixer.stopAllAction();
newAction.fadeIn(fadeDuration).play();
}
currentAction = newAction;
}
3人称カメラ
カメラはアバターの後方上空を周回する。マウスでカメラを回してもアバターは動かない。WASDで移動した時だけ、移動方向にアバターが向きを変える。
// アバターの向きはカメラから独立
const targetYaw = Math.atan2(moveDir.x, moveDir.z) + Math.PI;
avatarYaw += shortestAngleDiff * 0.15; // 滑らかに回転
// カメラはyaw角でアバターの周囲を回る
const camX = playerPos.x + Math.sin(yaw) * CAM_DISTANCE;
const camZ = playerPos.z + Math.cos(yaw) * CAM_DISTANCE;
camera.position.lerp(desiredPos, CAM_LERP);
camera.lookAt(avatarTarget);
PBRマテリアルの黒潰れ対策
Sketchfabで綺麗に見えるモデルが、ローカルだと鏡やフレームが真っ黒になる。原因はHDRI環境マップがないこと。metalness が高い面は環境マップがないと何も反射しない。
対策はシンプルで、ロード時にmetalnessに上限をかける。
if (child.material.metalness > 0.5) {
child.material.metalness = 0.3;
child.material.roughness = Math.max(child.material.roughness, 0.4);
}
ポータル:空間的なハイパーリンク
部屋の中にポータル(緑のゲート)を置いて、他のURLに遷移できる。room.jsonにURLを書くだけ。近づくと[E] Someone's Roomと表示されて、Eキーで遷移。
HTMLのaタグと同じ。サーバー不要。各自が自分のサーバーに部屋を置いて、ポータルURLを手書きで相互リンク。90年代のウェブリングと同じ仕組み。
サーバーは作らない
荒らし対策の最適解は「荒らす場所を作らないこと」。
各ユーザーが自分のサーバーに自分の部屋を置く。中央サーバーは存在しない。カスタム結果はroom.json + GLBとしてエクスポートして再アップロード。localStorageは編集中の一時保存だけ。訪問者が見る部屋は全部静的ファイル。
「ZIPを解凍してアップするだけ」の敷居は、荒らしにとっては面倒すぎて、本当に部屋を作りたい人にとってはちょうどいい。
今後
- 部屋テンプレート選択UI(models.jsonからサムネイル一覧)
- 初回セットアップ(アバター選択、名前入力)
- ポータル遷移の実装テスト
- WebXR対応(Quest 3でMR表示)