はじめに
IIIF(International Image Interoperability Framework)で公開されている文化資源の画像を、原寸大で体験できたら面白いのではないか。そんな発想から、江戸時代の町家を再現した VR 空間に IIIF 画像を配置し、ブラウザや VR ヘッドセットで閲覧できるビューアを開発しました。
技術スタックは A-Frame 1.5.0 + THREE.js 0.158.0 + WebXR。3D モデルは Sketchfab で公開されている Japanese Machiya Set Kit をベースに、部位ごとに分割して組み合わせています。
この記事では「うまくいったこと」だけでなく、ハマった失敗とその原因・修正も正直に書きます。
プロジェクト概要
| 項目 | 内容 |
|---|---|
| レンダラー | A-Frame 1.5.0 / THREE.js 0.158.0 |
| XR | WebXR (VR ヘッドセット対応) |
| 画像規格 | IIIF Presentation API v3 / Image API v2 |
| 3D モデル | Sketchfab: Japanese Machiya Set Kit (GLB) |
| アバター | VRM + Mixamo リターゲット |
| 補助ライブラリ | three-vrm v2, aframe-extras |
| ツール | gltf-transform, Blender CLI |
部屋の設計
部屋のサイズは TAT_SZ = 1.76 m(江戸間の畳 1 枚の短辺)を基準単位にしています。畳・壁・障子・天井・提灯をパーツとしてタイル状に並べることで、IIIF 画像のサイズに応じて柔軟に部屋サイズを変更できる設計です。
IIIF Collection を指定すると、コレクション内の全画像を並べて表示し、部屋サイズが自動拡張されます。
GLB モデルの分割
配布されている GLB は町家全体が 1 ファイルにまとまっていました。壁・畳・窓壁を独立して配置したいので、gltf-transform で部位ごとに分割しました。
# 特定メッシュだけ抜き出す
npx gltf-transform filter input.glb SM_tatami.glb \
--node "SM_tatami"
分割後は各パーツのバウンディングボックスを測定し、ピボット位置を揃える必要があります。
const box = new THREE.Box3().setFromObject(mesh);
const center = box.getCenter(new THREE.Vector3());
console.log('offset:', center);
「なんかズレてる」をカンに頼らず数値で解決できたのは、この計測ステップのおかげでした。
アバター導入の苦労
Mixamo → Blender → GLB 変換
Mixamo は FBX 形式でしかダウンロードできないため、Blender を CLI で呼び出して GLB に変換しました。
blender --background --python convert_fbx_to_glb.py -- \
--input avatar_walk.fbx \
--output avatar.glb
失敗:scale=100 で 181m の巨人が出現
A-Frame のエンティティに scale="100 100 100" を指定したら、真っ赤な巨大キャラクターが画面を埋め尽くしました。
原因はスキンメッシュのバインド行列にあります。スキンアニメーションでは逆バインド行列(InverseBindMatrix)が使われますが、ここに scale=100 が掛かると、
SkinMatrix = GlobalTransform × InverseBindMatrix
= Scale(100) × ... = 約 181m
という計算になり、アバターが巨人化します。
修正: scale="1 1 1"(デフォルト)に戻し、モデル側の単位を Blender でメートルに合わせるのが正解でした。
Hips ボーンのオフセット補正
Mixamo のキャラクターは Hips ボーンが Z = -1.04 m の位置にあります(キャラクターの重心がモデル原点から前方にオフセットしている)。そのままだと「空中に浮いた場所」が原点になってしまうため、
<a-entity id="avatar" position="0 0 1.04" ...></a-entity>
と Z 方向に +1.04 m 補正して対応しました。
IIIF タイル動的読み込みシステム
原寸大表示の仕組み
IIIF Image API の info.json に含まれる物理寸法サービス(physDim)の情報に基づいて、画像を原寸大で VR 空間に配置します。未定義の場合は幅 5m にフォールバックします。
LOD(Level of Detail)
カメラに近い部分から段階的に高解像度タイルを読み込みます。VR ヘッドセットでしゃがんで床の絵図に顔を近づけると、その箇所が最優先で高解像度化されます。
[低解像度ベース (Y=0.02)] ← 常時表示
↑ 上にオーバーレイ
[タイルグリッド (Y=0.025)] ← ダウンロード完了後に DOM 追加
- ベースプレーン: 低解像度の概観画像を即座に配置。ユーザーは最初からぼやけた全体像を見られる
- タイルグリッド計算:
info.jsonのtiles定義からグリッドを算出。MAX_GRID_TILES = 150を超えないよう scaleFactor を自動選択 - 距離ベースのダウンロード: 500ms ごとにカメラ位置を取得し、未読み込みタイルを距離順にソートして最大 6 枚を並列ダウンロード
タイル URL 形式
{baseId}/{x},{y},{w},{h}/{outW},/0/default.jpg
サイズ指定は幅のみ ({outW},) にしています。高さを含めると 404 になるサーバーがあるためです(level0 静的配信)。
scaleFactors の選択ロジック
MAX_GRID_TILES = 150 を超えないよう、小さい scaleFactor から順に試します。
例: 49797×28435px の画像、tileWidth=1024
- sf=1 → 49×28 = 1,372 枚(多すぎる)
- sf=2 → 25×14 = 350 枚(多すぎる)
- sf=4 → 13×7 = 91 枚 ← これを採用
設定定数
| 定数 | 値 | 説明 |
|---|---|---|
LOD_CHECK_MS | 500 | カメラ距離チェック間隔 (ms) |
MAX_CONCURRENT | 6 | 同時ダウンロード数 |
MAX_GRID_TILES | 150 | タイル総数の上限 |
IIIF_MAX_PX | 2048 | ベース画像の最大幅 (px) |
IMAGE_GAP_M | 0.5 | コレクション内の画像間マージン (m) |
ROOM_PADDING_M | 2.0 | 壁から画像までの余白 (m) |
ハマりポイント:タイルダウンロード中にベースが消える
タイル用の <a-plane> を visible: false で事前に DOM に追加していたところ、不可視でもベースプレーンの描画に干渉し、ダウンロード中にベース画像が消えてしまいました。
修正: タイル要素は生成のみ行い、DOM への追加は画像ダウンロード完了後にする。
// ダウンロード完了後に初めて DOM に追加
img.onload = () => {
t.el.setAttribute('material', `src: ${t.url}; side: double`);
t.container.appendChild(t.el);
t.state = 'loaded';
};
三人称カメラの実装
ここが最もハマったポイントです。「アバターを後ろから追いかけるカメラ」を A-Frame で実現するまでに 3 回失敗しました。
失敗 1:カメラとアバターを同じ親に入れる
<a-entity id="rig">
<a-camera .../>
<a-entity id="avatar" .../>
</a-entity>
一見シンプルですが、カメラとアバターの相対位置が常に固定されるため、カメラが動いてもアバターは画面内で「動いているように見えない」状態になります。
失敗 2:毎フレーム位置を同期させる
#player と #camera-rig を別エンティティにして、tick() で毎フレーム位置をコピーする方法を試しました。しかし A-Frame の内部状態(look-controls のクォータニオン管理など)と干渉して動作が不安定になりました。
失敗 3:look-controls と独自移動が干渉する
a-camera にはデフォルトで wasd-controls コンポーネントも有効になっています。独自の player-move コンポーネントと同時に動くため、アバターとカメラが少しずつズレて離れていきました。
成功した設計
シーン(ワールド)
├── #avatar ← WASD でワールド座標を直接移動(独立)
└── #cam-rig ← #avatar の位置に毎フレーム追従(独立)
└── #cam ← カメラ本体(look-controls, wasd-controls 共に無効)
ポイントはアバターとカメラリグを完全に独立させ、毎フレーム追従させること。そして a-camera のデフォルトコンポーネントを明示的に無効化すること。
<a-camera wasd-controls="enabled: false" look-controls="enabled: false">
追従処理はシンプルです。
// tick() での追従処理(簡略)
const avatarPos = avatar.object3D.position;
camRig.object3D.position.set(avatarPos.x, avatarPos.y, avatarPos.z);
三人称カメラのオフセットは (0, 1.6, 2.5)(後方 2.5 m、上方 1.6 m)にしました。
アバターの向きを進行方向に合わせる
rotation.y = θ のとき、ローカル -Z 軸はワールド座標で (-sin θ, 0, -cos θ) 方向を指します。WASD 移動ベクトルも同じ式で計算しているため、アバターは常に自分の進行方向を向きます。
マイクラ式操作
PC ではマインクラフト風のストレイフ移動(WASD)、スマートフォンでは左側に仮想ジョイスティック、右半分でカメラ回転というタッチ分割を実装しています。3段階の姿勢変更(立つ → しゃがむ → うつ伏せ)により、床に配置された画像に顔を近づけて高解像度で閲覧できます。
VRM アバター対応
Mixamo アニメーションのリターゲット
VRM モデル自体にはアニメーションが含まれていないため、Mixamo の Walk アニメーションを VRM のボーン構造にリターゲットして適用しています。
avatar.glb (Mixamo) ──── アニメーションデータ提供(非表示)
│
│ リターゲット(ボーン名変換 + レスト姿勢補正)
▼
avatar1.glb (VRM) ──── 画面に表示
Mixamo と VRM ではボーン名とレスト姿勢が異なるため、単純な名前差し替えでは動きません。数学的には以下の式で変換します。
retargeted = W_parent × animation × inv(W_bone)
ルートモーション除去
Mixamo の Walk アニメーションは Hips ボーンの位置が毎フレーム変動するルートモーション付きです。測定すると Y 軸方向に約 1.68 m の上下変動があり、アバターが「ぴょんぴょん跳ねる」問題が発生しました。
ゲームエンジン(Unity / Unreal)ならルートモーション制御が標準機能ですが、A-Frame には存在しません。gltf-transform の API で GLB ファイル内のアニメーショントラックを直接書き換えて解決しました。
import { Document, NodeIO } from '@gltf-transform/core';
const io = new NodeIO();
const document = await io.read('avatar.glb');
for (const anim of document.getRoot().listAnimations()) {
for (const sampler of anim.listSamplers()) {
const output = sampler.getOutput();
const arr = output.getArray().slice();
// Y の中央値(自然な立ち高さ)を計算
const yValues = [];
for (let i = 1; i < arr.length; i += 3) yValues.push(arr[i]);
yValues.sort((a, b) => a - b);
const medianY = yValues[Math.floor(yValues.length / 2)];
const firstX = arr[0], firstZ = arr[2];
// 全フレームの Hips 位置を固定
for (let i = 0; i < arr.length; i += 3) {
arr[i] = firstX; // X: 初期値に固定
arr[i + 1] = medianY; // Y: 中央値に固定
arr[i + 2] = firstZ; // Z: 初期値に固定
}
output.setArray(new Float32Array(arr));
}
}
await io.write('avatar_fixed.glb', document);
Y を 0 ではなく中央値に固定するのがポイントです。0 に固定すると床にめり込んだり足が浮いたりするので、Walk アニメーション中の自然な立ち高さを保ちます。
VRM0 の向き補正
VRM0 形式は Z+ 方向が正面ですが、A-Frame のカメラは Z- 方向を向いています。シーンを 180° Y 回転させると、スキニングの X・Z 成分も反転してしまいます。
修正: リターゲット結果にクォータニオンの 180° Y 共役を適用。
if (isVrm0) {
// 180° Y 共役: scene.rotation.y = PI によるデフォメーション反転を補正
values[i] = -q.x; // X 反転
values[i+1] = q.y; // Y そのまま
values[i+2] = -q.z; // Z 反転
values[i+3] = q.w; // W そのまま
}
VRMLoaderPlugin の罠
当初 @pixiv/three-vrm の VRMLoaderPlugin を使っていましたが、VRM0 ファイルで gltf.userData.vrm が null(プラグイン処理失敗)になる場合でも、プラグインがシーングラフを部分的に書き換えてしまう問題に遭遇しました。
具体的には、ボーン正規化用のラッパーノードが挿入され、AnimationMixer が名前でボーンを見つけても、SkinnedMesh の skeleton は別のノードを参照している状態になります。結果、アニメーションは再生されているのにメッシュが動かない「ゴースト再生」になりました。
修正: VRMLoaderPlugin を使わず、VRM ファイルを通常の GLTF として読み込み、VRM 固有の処理(向き補正、リターゲット)は自前で行う。
将来の拡張
アニメーションを増やしたい場合(走る、ジャンプ、お辞儀など)も、Mixamo から追加の GLB を取得してリターゲットすれば対応可能です。アニメーションソースとキャラクターモデルが分離しているため、VRM モデルを差し替えるだけで別のキャラクターにも同じアニメーションが適用されます。
3D モデル圧縮
未使用テクスチャの問題
各パーツ GLB に 11 枚のテクスチャが埋め込まれていましたが、実際に使われているのは 3 枚のみ。Sketchfab のエクスポート時に全マテリアルのテクスチャが各ファイルに含まれてしまっていました。
最適化
npx @gltf-transform/cli dedup input.glb output.glb
npx @gltf-transform/cli prune output.glb output.glb
| ファイル | 最適化前 | 最適化後 | 削減率 |
|---|---|---|---|
| SM_tatami.glb | 2.09 MB | 144 KB | 93% |
| SM_wall.glb | 2.13 MB | 305 KB | 86% |
| SM_floorBeam.glb | 2.08 MB | 111 KB | 95% |
| SM_windowWallHigh.glb | 2.19 MB | 475 KB | 78% |
| parts 合計 (20ファイル) | ~43 MB | ~5 MB | 88% |
注意点
- VRM ファイルには適用不可:
gltf-transformが VRM 拡張メタデータを除去する(extensionsUsed: ["VRM"]→ 消失)ため、VRM0 検出(isVrm0 = extensionsUsed?.includes('VRM'))が壊れ、向き補正と符号反転が適用されずアバターの向きが反転する - WebP テクスチャ圧縮は断念: A-Frame 同梱の GLTFLoader が WebP 拡張に未対応で、モデルが表示されなくなった
環境演出:空と霧
プロシージャルな空
HDRI 画像(EXR → JPEG 変換)を a-sky で表示する方法を試みましたが、fog との干渉や色調の不一致で断念。最終的に、THREE.js の scene.background にキャンバスで描いたグラデーションを設定する方式に落ち着きました。
const canvas = document.createElement('canvas');
canvas.width = 1; canvas.height = 512;
const ctx = canvas.getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 0, 512);
grad.addColorStop(0.0, '#5a88b8'); // 天頂
grad.addColorStop(0.75, '#c8d4b8'); // 地平線 = fog 色と一致
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 1, 512);
const tex = new THREE.CanvasTexture(canvas);
tex.mapping = THREE.EquirectangularReflectionMapping;
scene.background = tex;
霧で統一感を出す
庭園はプリミティブ(球・円柱)で構成しているため、精細な室内モデルとのギャップが目立ちます。exponential fog で遠景をぼかし、空のグラデーションの地平線色と fog 色を一致させることで、自然な奥行き感を演出しています。
<a-scene fog="type: exponential; color: #c8d4b8; density: 0.04">
使い方
URL パラメータ
| パラメータ | 説明 |
|---|---|
collection | IIIF Collection URL。コレクション内の全画像を並べて表示 |
manifest | IIIF Manifest URL。1 枚の画像のみ表示 |
avatar | アバター番号。三人称モードで起動 |
outside | 建物の外から開始(デバッグ用) |
debug | オーバーレイをスキップ |
操作
| PC | スマートフォン | VR ヘッドセット |
|---|---|---|
| WASD:移動 | 仮想ジョイスティック:移動 | 左スティック:移動 |
| マウスドラッグ:視点 | 右半面ドラッグ:視点 | 右スティック:水平回転 |
| V:一人称/三人称切替 | - | - |
| C:しゃがむ/うつ伏せ/立つ | ボタン | 物理的にしゃがむ |
まとめ
A-Frame は「HTML タグで VR が書ける」手軽さが魅力ですが、カメラ制御・スキンメッシュ・アニメーションの領域に踏み込むと THREE.js の低レイヤーを直接触る場面が増えます。
IIIF の物理寸法情報と VR の組み合わせにより、「博物館で実物を見ている感覚」をブラウザ上で再現できる可能性を感じています。文化資源のデジタル公開が進む中、原寸大での体験は新しい鑑賞の形になり得るのではないでしょうか。
技術スタック
| カテゴリ | ツール / ライブラリ |
|---|---|
| フロントエンド | A-Frame 1.5.0, THREE.js 0.158.0 |
| VR | WebXR API |
| 画像規格 | IIIF Presentation API v3, Image API v2 |
| アバター | three-vrm v2, Mixamo, aframe-extras |
| モデル編集 | gltf-transform |
| モデル変換 | Blender CLI (FBX → GLB) |