Skip to content

3D、全景与二进制素材

你的卡跑在一个上了锁的 iframe 里。图片和音频可以直接进来,.glb 不行。这一页讲清楚:什么能加载、什么会悄无声息地失败,以及那个让 3D 世界变便宜的做法——在地点之间传送,而不是走过去。


沙箱能加载什么

自定义 UI 跑在一个带独立内容安全策略(CSP)的沙箱 iframe 里。这套策略是卡片无法攻击站点、也无法把玩家数据发出去的原因——同时也是某些完全正常的网页代码在已发布的卡里毫无反应的原因。

你写的实际结果
<img src="/cdn/…">、CSS background-image<video><audio>、网页字体可以。 图片、音视频和字体有特权加载通道
THREE.TextureLoader().load(url)可以 —— 它底层走的是 <img>
fetch(...)XMLHttpRequestWebSocketEventSource完全禁止。 策略是 connect-src 'none'
GLTFLoader().load(url).bin.ply、大 .json禁止 —— 它们走 fetch,不走 <img>
new Worker(URL.createObjectURL(blob))禁止。 就当没有 Worker
SharedArrayBuffer、多线程 WASM不可用 —— 页面没有跨源隔离
api.fetchAsset("<asset-id>")字节唯一的入口。 由平台去取,交给你的代码一个 ArrayBuffer

这个坑

模型用 loader.load(url) 不会抛出清晰的错误——加载就是永远不完成,于是卡片要么是空场景,要么永远停在你自己写的「加载中」。如果一张 3D 卡在本地试的时候好好的,一发布就什么都不显示,几乎都是这个原因。

引用自己素材的两种方式

素材库 → Assets 上传素材,然后用它的 id:

tsx
var api = useYumina()

// 图片 / 音频 / 字体 —— 解析成 URL,交给浏览器加载。
var url = api.resolveAssetUrl("@asset:2ec02221-2664-4f31-9d31-03b2db7570ab")
// → "/cdn/2ec02221-…",一个普通 URL,可以放进 <img src>、CSS 或 THREE 贴图

// 任何二进制 —— 向平台要字节。
var res = await api.fetchAsset("2ec02221-2664-4f31-9d31-03b2db7570ab")
if (res.ok) {
  var bytes = res.bytes // ArrayBuffer
}

fetchAsset 只接受 素材 id,绝不接受 URL——这正是它敢开放的原因。单个素材上限 32 MB,失败时返回 { ok: false, error },取值为 "bad-ref""http-404""too-large" 或网络错误。碰 bytes 之前先看 ok

几个必须知道的体积

  • 32 MB —— 单次 fetchAsset 的上限。
  • 5 MB —— 一次世界保存的上限。卡的源码文件是存在世界里的,所以把模型 base64 内联、或塞一堆内嵌音频,迟早会让世界存不上。素材放素材库,代码放卡里。
  • /cdn故意max-age=0 的。五分钟前加载过的图,浏览器还是会回源校验——除非你还持有它的引用。任何你希望瞬间出现的东西(传送目的地、角色立绘),把加载好的 ImageTexture 存进一个 Map,整局都别丢。

核心做法:在地点之间传送

在 Yumina 上,最划算又最有说服力的 3D 世界,是相机永远不走路的那种。玩家站在一个地点,四下环视,然后从一个列表里挑下一个地点。没有碰撞、没有寻路网格、没有流式加载——而且每加一个地点的成本是一张图,不是一个关卡。

每个地点是一张 360° 等距柱状全景,贴在一个球的内壁上,相机在球心。传送就是换掉这个球的贴图:一个渲染器、一个球、一个 draw call,撑起任意大的世界。

第一步:当前地点是变量,不是 React state

这一步才让它成为 Yumina 的世界,而不是一个网页。游戏变量每回合都会注入模型的上下文,所以当前地点只要存在变量里,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

第二步:一个地点一条词条

给每个地点写一条词条,激活条件是 location 等于该地点。这样只有玩家当下所在的房间在花上下文,AI 描述温室裂开的玻璃,是因为温室那条词条就摆在它面前——不是它在猜。

第三步:让 AI 传送玩家

既然 location 就是个普通变量,模型可以直接写它:

[location: set greenhouse]

你的界面本来就在看 api.variables.location,所以剧情说场景变了,场景就变了。被剧情拽进去的门,和玩家自己点的门,走的是同一条代码路径。

第四步:那个球

js
// stage.js —— 一个场景、一个球,换贴图就是传送。
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——十二个房间加起来还没一个角色模型大。


把 three.js(或任意库)放进卡里

卡里没有 npm,也不能挂 CDN 的 script 标签。自己打包一次,把产物当成卡的一个文件贴进去:

bash
# three-entry.js —— 只重导出你真正用到的东西,摇树才有意义
npx esbuild three-entry.js --bundle --format=esm --minify --outfile=three-lib.js

一个克制的入口(二三十个类)打出来大约 500 KB。把它作为文件 three-lib.js 加进去,并且带绑定地导入:

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

永远带绑定导入。 只有副作用的 import "./three-lib" 不会登记依赖,打包器会安静地把这个文件丢掉,卡片报 THREE is not defined

摇树包与缺失的符号

精简包里只有你入口文件点过名的类。用到它没收进去的那个——比如 THREE.RingGeometry——就会在建场景时抛 THREE.RingGeometry is not a constructor。而卡里通常用同一个 try/catch 兜「这台设备没有 WebGL」,于是屏幕上显示的是*「你的设备不支持 3D」*,你会去查一个根本不存在的硬件问题。用一个临时 canvas 单独探 WebGL;探测通过了,就把真实的 error.message 显示出来,别复用那句兜底文案。

文件名与 import 语法

卡里每个文件都用同一套流程编译,跟叫什么名字无关;导入时也不需要写后缀:import * as THREE from "./three-lib" 能找到 three-lib.tsxthree-lib.tsthree-lib.jsxthree-lib.js。第三方包就叫 .js,因为它本来就是——这条流水线两边都不在乎。

有三条语法规则没得商量,因为 import/export 是在打包前按模式剥掉的:

  • 每个 import 写成一行。 跨行的 import { a, b } from "./x" 会原样留在产物里,卡片死于 Cannot use import statement outside a module
  • 导出只用 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)
})

parse 而不是 load——字节已经在你手上了,load 只会去 fetch。

模型渲成纯白,说明贴图没跟过来: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 倍像素比,等于每帧多画几百万像素,肉眼看不出任何好处。
  • 手机上别开实时阴影。 在一张已上线的卡上实测,它占了每帧 GPU 的约 85%,换来的是没人会看的一圈软边。
  • 量真实帧间隔,扛不住就降档。 每两秒评估一次;如果慢帧一直来,先降一档像素比,再关特效。也给玩家一个明确的画质开关。
  • 绝不要用每帧随机数抖相机。 Math.random() 的抖动一秒要反转几十次方向,晕 3D 就是这么来的。改用两条慢正弦相加;软键盘弹出时保持画布高度不变,而不是重算视场角。
  • 缓存和释放都要有意为之。 会回去的贴图留着,不会回去的 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 兜底分支吞掉了
第二次传送开始卡顿贴图被回收了——持住引用
世界存不上卡自己的文件超了 5 MB 的世界体积——把数据挪进素材

相关:自定义 UI 指南 · API 参考 · 场景跳转