Skip to content

3D・パノラマ・バイナリ素材 ​

カードの画面のコードは、厳しく制限された iframe の中で動きます。画像と音声はふつうに読み込めますが、.glb のモデルは読み込めず、その理由も表示されません。以下では、何が読み込めて、何が黙って失敗するのか、そして場所の間を歩くのではなく飛び移ることで 3D ワールドを安く作る方法を説明します。


サンドボックスが読み込めるもの ​

カスタムUIは、独自のコンテンツセキュリティポリシー(CSP)を持つサンドボックスの iframe の中で動きます。このポリシーがあるので、カードがサイトを攻撃したり、プレイヤーのデータをどこかへ送ったりすることはできません。そのかわり、ごくふつうの Web のコードが、公開したカードでは何もしないこともあります。

書いたコード実際の挙動
<img src="/cdn/…">、CSSの background-image、<video>、<audio>、Webフォント動く。 画像・メディア・フォントには特権的な読み込み経路がある
THREE.TextureLoader().load(url)動く —— 内部では <img> 経由で読み込まれる
fetch(...)、XMLHttpRequest、WebSocket、EventSource完全に禁止。 ポリシーは connect-src 'none'
GLTFLoader().load(url)、.bin、.ply、大きな .json禁止 —— これらは <img> ではなく fetch を使う
new Worker(URL.createObjectURL(blob))禁止。 Workerは無いものと考える
SharedArrayBuffer、マルチスレッドWASM利用不可 —— ページはクロスオリジン分離されていない
api.fetchAsset("<asset-id>")バイトを入れる唯一の入口。 プラットフォームが取得し、ArrayBuffer を渡す

よくある罠

モデルを loader.load(url) で読むと、はっきりしたエラーは出ません。読み込みが永遠に完了しないだけなので、カードは空のシーンか、自前の「読み込み中」表示のまま止まります。ローカルの実験では動いた3Dカードが公開すると何も表示されないなら、ほぼ必ずこれが原因です。

自分の素材を参照する2つの方法 ​

マイライブラリ → アセット(エディターなら パネル → アセット)で素材をアップロードし、その id を使います:

tsx
var api = useYumina()

// 画像/音声/フォント —— URLに解決し、ブラウザに読ませる。
var url = api.resolveAssetUrl("@asset:2ec02221-2664-4f31-9d31-03b2db7570ab")
// → "/cdn/2ec02221-…"。<img src>、CSS、THREEのテクスチャにそのまま使える普通のURL

// バイナリ全般 —— プラットフォームにバイト列をもらう。
var res = await api.fetchAsset("2ec02221-2664-4f31-9d31-03b2db7570ab")
if (res.ok) {
  var bytes = res.bytes // ArrayBuffer
}

fetchAsset が受け取るのは 素材id だけで、URLは受け取りません。だから公開しても安全です。1素材あたり上限 32 MB。失敗時は { ok: false, error } を返し、error は "bad-ref"、"http-404"、"too-large"、またはネットワークエラーです。bytes を触る前に必ず ok を確認してください。

知っておくべきサイズ ​

  • 32 MB —— fetchAsset 1回あたりの上限。
  • 10 MB —— ワールド保存1回あたりの上限。カードのソースファイルはワールドの中に入っているので、モデルをbase64で埋め込んだり音声を大量に内蔵したりすると、いずれワールドが保存できなくなります。素材は素材ライブラリへ、コードはカードへ。
  • /cdn は意図的に max-age=0 を返します。5分前に読み込んだ画像でも、参照を保持していない限りブラウザは再検証します。すぐ出てほしいもの(テレポート先、キャラクターの立ち絵)は、読み込んだ Image や Texture を Map に入れてセッション中ずっと持っておきましょう。

パターン:場所から場所へ飛ぶ ​

Yuminaで最も安く、しかも説得力のある3Dワールドは、カメラが決して歩かないものです。プレイヤーはある場所に立ち、見回し、リストから次の場所を選ぶ。当たり判定もナビメッシュもストリーミングも要らず、場所を1つ増やすコストは「レベル1つ」ではなく「画像1枚」です。

各場所は360°の正距円筒パノラマで、球の内側に貼り、カメラを中心に置きます。テレポートはその球のテクスチャを差し替えるだけです。レンダラー1つ、球1つ、ドローコール1回で、どれだけ広いワールドでも成立します。

ステップ1:現在地はReactのstateではなく変数 ​

ゲーム変数は毎ターンモデルに送られます。なので現在地を変数に置けば、AI はプレイヤーがどこに立っているかをいつも知っていて、AI 自身がプレイヤーを移動させることもできます。

キャンバスで 文字 の変数を追加して初期値を atrium にし、ID を location にします(その変数の設定の 詳細設定 にあります。新しい変数にはランダムな ID が付きますが、下のコードは api.variables.location を読みます)。そのうえで、こう書きます:

tsx
export default function World() {
  var api = useYumina()
  var here = String(api.variables.location || "atrium")

  function goTo(id) {
    api.setVariable("location", id)
    api.sendMessage(PLACES[id].name + "へ足を踏み入れる。")
  }

  return (
    <div className="relative h-full w-full">
      <Stage place={here} />
      <div className="absolute bottom-4 left-4 flex gap-2">
        {PLACES[here].exits.map(function (id) {
          return (
            <button key={id} onClick={() => goTo(id)}
              className="rounded bg-black/60 px-3 py-1.5 text-sm text-white">
              {PLACES[id].name}
            </button>
          )
        })}
      </div>
      <Chat />
    </div>
  )
}

移動にAIをすぐ反応させたいなら api.sendMessage、プレイヤー自身に入場のひと言を書かせたいなら api.setComposerDraft を使います。

ステップ2:場所ごとに1つのエントリ ​

各場所に、location がその場所のあいだだけ送られるエントリを用意します。キャンバスでは、location をエントリの上にドラッグして条件を設定します(条件ロア)。こうすればコンテキストを使うのはプレイヤーがいまいる部屋だけになり、AI が温室のひび割れたガラスを描写するのは、推測したからではなく、目の前に温室のエントリがあるからになります。

ステップ3:AIにテレポートさせる ​

location はただの変数なので、モデルが書き換えられます:

[location: set greenhouse]

UIはすでに api.variables.location を見ているので、物語が「場面が変わった」と言えば場面が変わります。物語に引きずり込まれた扉も、プレイヤーがクリックした扉も、通るコードは同じです。

location に 選択肢(場所ごとにひとつ)を設定すれば、精密トラッキングがプレイヤーを移動させるようにもできます。返信のたびに物語がたどり着いた場所を選び、location へのディレクティブは無視されます。

ステップ4:球 ​

js
// stage.js —— シーン1つ、球1つ。テクスチャの差し替えがテレポート。
import * as THREE from "./three-lib"

var renderer, scene, camera, sphere
var textures = new Map()   // パノラマは自分で保持する。/cdn はキャッシュしてくれない

export function mount(canvas) {
  renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: false })
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5))
  scene = new THREE.Scene()
  camera = new THREE.PerspectiveCamera(70, 1, 0.1, 100)
  var geo = new THREE.SphereGeometry(10, 48, 32)
  geo.scale(-1, 1, 1)                       // 法線を反転して内側から見る
  sphere = new THREE.Mesh(geo, new THREE.MeshBasicMaterial())
  scene.add(sphere)
}

export function show(url) {
  var tex = textures.get(url)
  if (!tex) {
    tex = new THREE.TextureLoader().load(url)   // 内部は <img>:許可されている
    tex.colorSpace = THREE.SRGBColorSpace
    textures.set(url, tex)
  }
  sphere.material.map = tex
  sphere.material.needsUpdate = true
}

プレイヤーがテキストを読んでいる間に、今いる場所の出口のパノラマを先読みしておけば、テレポートは一瞬になります。

パノラマはどこから ​

2:1の正距円筒画像なら何でも使えます。Blenderのレンダリング、360カメラの写真、AI生成のパノラマなど。見回すための背景なら2048×1024のJPEGで十分で、約300 KB。12部屋そろえてもキャラクターモデル1体より軽いくらいです。


three.js(や任意のライブラリ)をカードに入れる ​

カードの中にnpmはなく、CDNのscriptタグも使えません。自分で一度バンドルし、その成果物を パネル → フロントエンドコード でカードのファイルとして追加します:

bash
# three-entry.js —— 実際に使うものだけを再エクスポートすればツリーシェイクが効く
npx esbuild three-entry.js --bundle --format=esm --minify --outfile=three-lib.js

絞ったエントリポイント(クラス20〜30個程度)で約500 KBになります。それを three-lib.js という名前のファイルとして追加し、バインディング付きでimportします:

tsx
import * as THREE from "./three-lib"

必ずバインディング付きで。 副作用だけの import "./three-lib" は依存として登録されないため、バンドラーは黙ってそのファイルを外し、カードは THREE is not defined で落ちます。

ツリーシェイク済みビルドと欠けたシンボル

絞ったビルドには、エントリファイルが名指ししたクラスしか入っていません。入れ忘れたもの(たとえば THREE.RingGeometry)を使うと、シーン構築時に THREE.RingGeometry is not a constructor が出ます。カード側はたいてい「この端末はWebGL非対応」と同じ try/catch で受けるので、画面には*「お使いの端末は3Dに対応していません」*と出て、存在しないハードウェアの問題を探すはめになります。WebGLの可否は使い捨てcanvasで別に判定し、判定が通ったなら実際の error.message を表示してください。フォールバック文言を使い回さないこと。

ファイル名とimportの書き方 ​

カード内のファイルは名前に関係なくすべて同じ方法でコンパイルされ、importに拡張子は要りません。import * as THREE from "./three-lib" は three-lib.tsx、three-lib.ts、three-lib.jsx、three-lib.js のどれでも見つけます。ベンダーバンドルは .js と名付けてかまいません——実際そうなのですから。パイプラインはどちらでも気にしません。

ただし次の3つは守ってください。importとexportはバンドル前にパターンで除去されるためです:

  • import は必ず1行で。 複数行の import { a, b } from "./x" は出力に残り、Cannot use import statement outside a module でカードが死にます。
  • exportは export function / export const / export class / export default / export { a as b } で。 export * from "./x" は認識されず、出力に残って Unexpected token 'export' を投げます。
  • プラットフォームのグローバルをimportしない。 React、useYumina、Icons、Chat、MessageList、MessageInput はすでにスコープにあります。

モデルを読み込む ​

tsx
import { GLTFLoader } from "./three-lib"   // バンドルのエントリに含めておく

var res = await api.fetchAsset(ASSETS.dealerGlb)
if (!res.ok) { /* 正直に表示する。回し続けない */ return }

var loader = new GLTFLoader()
loader.parse(res.bytes, "", function (gltf) {
  scene.add(gltf.scene)
}, function (err) {
  console.error(err)
})

load ではなく parse を使います。バイト列はもう手元にあり、load は取得しに行ってしまいます。

モデルが真っ白なら、テクスチャが付いてきていません。GLBに埋め込まれたテクスチャのデコードはサンドボックス内で静かに失敗することがあります。テクスチャを別の画像素材としてアップロードし、自分で割り当ててください:

tsx
var tex = new THREE.TextureLoader().load(api.resolveAssetUrl("@asset:" + ASSETS.dealerTex))
tex.colorSpace = THREE.SRGBColorSpace
mesh.material.map = tex

スマホで生き延びさせる ​

スマホで遊ぶプレイヤーはたくさんいます。スマホを熱くする 3D カードは黙って閉じられ、あなたのところには何も届きません。

  • ピクセル比に上限を。 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5))。低スペック端末は1にします。縦長画面で3倍のまま描くのは、見た目の利点なしに毎フレーム数百万ピクセルを増やす行為です。
  • モバイルではリアルタイム影を切る。 実際に公開されたカードでの計測では、1フレームのGPU時間の約85%を占めていました。誰も見ない柔らかい輪郭のために。
  • 実フレーム間隔を測って落とす。 数秒ごとに評価し、遅いフレームが続くならピクセル比を一段下げ、それでも駄目なら効果を切る。プレイヤー向けの画質切り替えも用意しましょう。
  • 毎フレームの乱数でカメラを揺らさない。 Math.random() の揺れは 1 秒に数十回向きが反転し、3D 酔いの原因になります。遅い正弦波を2つ足す方式にし、ソフトキーボードが開いたら画角を計算し直すのではなくキャンバスの高さを保ってください。
  • キャッシュと破棄を意識的に。 また戻る場所のテクスチャは持ち、戻らないものは dispose()。

うまくいかないとき ​

症状原因
モデルが出ず、エラーも出ないloader.load(url) を使っている——fetchAsset + parse へ
モデルが真っ白埋め込みテクスチャが復号できていない——画像素材として別に読む
THREE is not defined副作用だけのimport。バインディングを付ける
THREE.X is not a constructorツリーシェイク済みビルドにそのシンボルが無い
白画面+Unexpected token 'export'どこかのファイルに export * from "./x"
白画面+Cannot use import statement outside a module複数行の import
高性能端末で「3D非対応」と出る実際のエラーがWebGLフォールバックに飲み込まれている
2回目のテレポートでカクつくテクスチャが回収された——参照を保持する
ワールドが保存できないカード自身のファイルが10 MBの上限を超えている——データを素材へ

関連:カスタムUIガイド · APIリファレンス · シーンジャンプ