[Next.js #32] Three.js MMDアプリにモデルZIP / 壁紙 / IndexedDB管理を実装 — ローカルアセット基盤の整備

はじめに

前回の #29 では、Quest2 / WebXR / HTTPS / XRカメラまわりの実装を進めました。

今回はその続きとして、Three.js + MMD アプリに ローカルアセット管理の基盤 を入れました。

具体的には、

  • モデルZIPのドラッグ&ドロップ取り込み
  • ZIP展開と Blob 化
  • PMX検出
  • IndexedDB 保存
  • モデル一覧 / サムネ表示
  • 壁紙画像の保存
  • 壁紙サムネ一覧
  • 削除UI

まで一気に実装しました。

今回は、将来的にモデルや背景画像をアプリ内で増減できるようにするための、土台作りの回です。

過去にindexedDBの実装を何度かやってますが、毎回、一筋縄ではいかず、2日ぐらいかけて実装してましたが、今回は、AIが賢くなった影響で物凄い速度で実装出来ています。

ただ、事前に過去の実装コードを見せて再利用して良いと、助言を言ってるのもあり、すべて自前でコードを書いたわけではないです。

それでも驚くような速度で実装出来てビックリで、UIの実装も滅茶苦茶早かったです。

もうほんと、要件を投げるだけでアプリが完成していく時代ですね…。

実装内容については、過去やった内容とほぼ同じなので、詳しい内容は割愛します、興味のある方は過去記事を参照ください。

前回の記事:

動画(YouTube):

動画(PC):

今回やったこと

今回実装したのは、主に次の2系統です。

1. モデルアセット管理

  • ZIPファイルを画面へドラッグ&ドロップ
  • ZIP内のファイルを展開
  • .pmx を検出
  • thumbnail.png / jpg / webp があればサムネとして利用
  • IndexedDB の Models ストアへ保存
  • UI上でモデル一覧を表示
  • モデル削除

2. 壁紙アセット管理

  • png / jpg / webp をドラッグ&ドロップ
  • IndexedDB の Wallpaper ストアへ保存
  • 壁紙をサムネ付きタイルで一覧表示
  • タイル選択で背景へ反映
  • タイル右上の削除ボタンで削除

ここまで入れたことで、/public/… に固定で置いていたデータ以外も、アプリ内から扱える基盤ができました。

なぜ必要だったか

これまでは、モデル読み込みを

/models/モデル名/モデル名.pmx

のような固定パス前提で扱っていました。

この方式は最初の実装としてはシンプルですが、モデルを増やしたい時に毎回ファイル配置が必要になり、運用面ではかなり窮屈です。

また、背景画像についても同じで、プリセットをソースコード側に持つだけでは、後から差し替えたり増やしたりする柔軟性がありません。

将来的にやりたいことを考えると、

  • モデルをアプリ内で追加したい
  • 壁紙もアプリ内で追加したい
  • 一覧から選びたい
  • 不要になったら削除したい

という流れが自然です。

そのため、今回は先に ローカルアセット管理基盤 を整備することにしました。

実装方針

今回いちばん重要だったのは、最初から全部を完璧にやろうとしなかったことです。

特にモデルまわりは本気でやろうとすると、

  • PMX本体
  • テクスチャ相対パス
  • ZIP内ファイル参照
  • Blob URL 化
  • ローダーへの受け渡し

まで一気に考える必要があり、かなり重くなります。

そのため今回は、まず次の順で進めました。

  1. 保存層を作る
  2. 一覧 / 削除UIを作る
  3. 壁紙は先に実用化する
  4. モデルの完全な IndexedDB 直読込は後段に回す

この切り方にしたことで、保存や一覧の基盤を先に通しながら、読込の本丸は後でじっくり扱える状態になりました。

モデルZIPの取り込み

モデル側では、まず ZIP のドラッグ&ドロップを実装しました。

window.addEventListener("drop", async (e) => {
  e.preventDefault();

  const file = e.dataTransfer?.files?.[0];
  if (!file) return;

  const name = file.name.toLowerCase();

  if (/\.zip$/.test(name)) {
    const result = await importModelZip(file);
    console.log("model imported:", result.meta);
    await renderModelList();
    return;
  }
});

受け取った ZIP は JSZip で展開し、内部ファイルを blobMap に変換します。

async function convertZipToBlobMap(zip) {
  const blobMap = {};

  for (const [filename, entry] of Object.entries(zip.files)) {
    if (entry.dir) continue;
    blobMap[filename] = await entry.async("blob");
  }

  return blobMap;
}

この blobMap から .pmx を検出し、モデルメタ情報を組み立てます。

const pmxFiles = Object.keys(blobMap).filter((path) => /\.pmx$/i.test(path));

さらに thumbnail.png / jpg / jpeg / webp を拾って、一覧表示用のサムネに使うようにしました。

IndexedDB への保存

展開したモデルは、meta と files をまとめて IndexedDB に保存しています。

await saveToStore("Models", meta.id, {
  meta,
  files: blobMap,
});

今回の時点では、保存対象は主に次の構造です。

{
  meta: {
    id,
    title,
    pmxPath,
    thumbnailPath,
    version,
    createdAt
  },
  files: {
    "model/model.pmx": Blob,
    "tex/thumbnail.png": Blob,
    ...
  }
}

この形にしておくと、後で

  • PMX を読む
  • サムネを出す
  • テクスチャを引く
  • メタ情報を一覧表示する

といった処理へ拡張しやすくなります。

モデル一覧UI

保存したモデルは、サイドパネルに一覧表示するようにしました。

  • サムネ表示
  • モデル名表示
  • Delete ボタン

というシンプルな構成です。

const thumbBlob = meta.thumbnailPath ? files[meta.thumbnailPath] : null;
const thumbUrl = thumbBlob ? URL.createObjectURL(thumbBlob) : "";

サムネがある場合はその Blob から object URL を作って表示し、無い場合は No Image を出しています。

これで、保存されたモデルが単なるデータではなく、視覚的に管理できるようになりました。

壁紙管理はタイルUIにした

最初は壁紙も select で管理しようと考えていましたが、実際に組んでみると、壁紙は名前で選ぶより 見た目で選ぶもの なので、UIとして不自然でした。

そのため、途中で方針を変えて サムネタイル型の一覧 にしました。

これは結果的に正解でした。

const tile = document.createElement("button");
tile.className = "wallpaper-tile";
tile.dataset.wallpaperDb = record.meta.id;

tile.innerHTML = `
  <img class="wallpaper-tile__thumb" src="${url}" alt="${escapeHtml(record.meta.title)}" />
  <div class="wallpaper-tile__label">${escapeHtml(record.meta.title)}</div>
  <span class="wallpaper-tile__delete" data-wallpaper-delete="${escapeHtml(record.meta.id)}">✕</span>
`;

壁紙はタイルで並べた方が直感的で、削除ボタンとの相性も良く、結果としてかなり分かりやすいUIになりました。

壁紙の保存と削除

壁紙画像は Wallpaper ストアへ保存しています。

store.put({ meta, file }, meta.id);

一覧は getAllWallpapers() で取得し、タイルへ展開します。 クリック時は

  • None タイルならプリセット解除
  • 壁紙タイルなら背景へ反映
  • 削除ボタンなら削除

という分岐です。

if (deleteBtn) {
  await deleteWallpaperById(id);
  renderWallpaperPanel();
  return;
}

if (dbTile) {
  const id = dbTile.dataset.wallpaperDb;
  const record = await getWallpaperById(id);
  if (!record?.file) return;

  const url = URL.createObjectURL(record.file);
  setBackWallWallpaperFromUrl(url);
  return;
}

ここまで入れたことで、壁紙まわりはかなり実用レベルになりました。

DB管理を共通化した

今回途中で詰まったポイントの1つが、IndexedDB の version 管理でした。

Models だけを持つ古い MyAppDB と、Wallpaper を追加した新しい構成が混ざって、object store がない エラーが出ました。

その対策として、DB定義を共通ファイルに寄せました。

export const DB_NAME = "MyAppDB";
export const DB_VERSION = 2;

export function openDB() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, DB_VERSION);

    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains("Models")) db.createObjectStore("Models");
      if (!db.objectStoreNames.contains("Wallpaper")) db.createObjectStore("Wallpaper");
    };

    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

これで、モデル側・壁紙側ともに同じ DB / version / store 構成を前提にできるようになり、だいぶ整理されました。

object URL の後片付け

一覧表示では URL.createObjectURL() を使うので、プレビューの描画を繰り返すと URL が積み上がります。

壁紙一覧の方は、描画時に生成した preview URL を配列で管理し、再描画前に解放するようにしました。

let wallpaperPreviewUrls = [];

function cleanupWallpaperPreviewUrls() {
  for (const url of wallpaperPreviewUrls) {
    URL.revokeObjectURL(url);
  }
  wallpaperPreviewUrls = [];
}

このあたりも、保存や表示が一通り動いた後に見えてくる、地味だけど大事な整理ポイントでした。

今回の実装で得られたもの

今回の作業は、見た目だけなら派手ではありません。 ただ、Three.js / MMD / WebXR 系アプリにおいては、こういう 地味な基盤 が後から効いてきます。

今回得られたのは、単なる保存機能ではなく、

  • モデルを取り込める
  • 壁紙を取り込める
  • アプリ内で一覧できる
  • アプリ内で削除できる
  • UIから視覚的に管理できる

という、ローカルアセット管理の骨組みです。

ここができたことで、今後の拡張はかなりやりやすくなります。

今後の課題

今回で保存と一覧の基盤はかなり通りました。 一方で、まだ本丸は残っています。

  • モデルの IndexedDB 直読込
  • PMX と ZIP内テクスチャの相対参照解決
  • モデル側サムネ URL の cleanup
  • 壁紙選択中タイルのハイライト
  • アセットUIの整理

特にモデル側は、まだ /models/... の固定パスに依存しているので、ここを完全に IndexedDB ベースへ寄せていくのが次の大きな課題です。

まとめ

今回は、Three.js + MMD アプリに対して、

  • モデルZIPの取り込み
  • ZIP展開 / Blob処理
  • IndexedDB保存
  • モデル一覧 / 削除
  • 壁紙サムネ一覧 / 削除
  • DB共通化

まで一気に進めました。

地味で気の重い処理が多い回でしたが、ここを通したことで、アプリ内でローカルアセットを扱う基盤がかなり整いました。

次はこの土台の上で、モデル読込の本丸を進めていきます。