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(...)XMLHttpRequestWebSocketEventSource完全に禁止。 ポリシーは 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つの方法

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

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

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

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

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

ここが、これを「Webページ」ではなく「Yuminaのワールド」にする部分です。ゲーム変数は毎ターン、モデルのコンテキストに注入されます。つまり現在地を変数に置けば、AIは常にプレイヤーがどこに立っているかを知っている——そしてAI自身がプレイヤーを移動させることもできます。

string 型の変数 location(初期値 atrium)を作り、こう書きます:

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 がその場所であることを条件に発火するエントリを用意します。こうすればプレイヤーが今いる部屋だけがコンテキストを消費し、AIは温室のひび割れたガラスを——推測ではなく、目の前に温室のエントリがあるから——描写できます。

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

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

[location: set greenhouse]

UIはすでに api.variables.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.tsxthree-lib.tsthree-lib.jsxthree-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しない。 ReactuseYuminaIconsChatMessageListMessageInput はすでにスコープにあります。

モデルを読み込む

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回目のテレポートでカクつくテクスチャが回収された——参照を保持する
ワールドが保存できないカード自身のファイルが5 MBの上限を超えている——データを素材へ

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