3D、全景与二进制素材
你的卡跑在一个上了锁的 iframe 里。图片和音频可以直接进来,
.glb不行。这一页讲清楚:什么能加载、什么会悄无声息地失败,以及那个让 3D 世界变便宜的做法——在地点之间传送,而不是走过去。
沙箱能加载什么
自定义 UI 跑在一个带独立内容安全策略(CSP)的沙箱 iframe 里。这套策略是卡片无法攻击站点、也无法把玩家数据发出去的原因——同时也是某些完全正常的网页代码在已发布的卡里毫无反应的原因。
| 你写的 | 实际结果 |
|---|---|
<img src="/cdn/…">、CSS background-image、<video>、<audio>、网页字体 | 可以。 图片、音视频和字体有特权加载通道 |
THREE.TextureLoader().load(url) | 可以 —— 它底层走的是 <img> |
fetch(...)、XMLHttpRequest、WebSocket、EventSource | 完全禁止。 策略是 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:
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的。五分钟前加载过的图,浏览器还是会回源校验——除非你还持有它的引用。任何你希望瞬间出现的东西(传送目的地、角色立绘),把加载好的Image或Texture存进一个Map,整局都别丢。
核心做法:在地点之间传送
在 Yumina 上,最划算又最有说服力的 3D 世界,是相机永远不走路的那种。玩家站在一个地点,四下环视,然后从一个列表里挑下一个地点。没有碰撞、没有寻路网格、没有流式加载——而且每加一个地点的成本是一张图,不是一个关卡。
每个地点是一张 360° 等距柱状全景,贴在一个球的内壁上,相机在球心。传送就是换掉这个球的贴图:一个渲染器、一个球、一个 draw call,撑起任意大的世界。
第一步:当前地点是变量,不是 React state
这一步才让它成为 Yumina 的世界,而不是一个网页。游戏变量每回合都会注入模型的上下文,所以当前地点只要存在变量里,AI 永远知道玩家站在哪——而且它自己也能把玩家挪走。
建一个 string 变量 location,默认值 atrium,然后:
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,所以剧情说场景变了,场景就变了。被剧情拽进去的门,和玩家自己点的门,走的是同一条代码路径。
第四步:那个球
// 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 标签。自己打包一次,把产物当成卡的一个文件贴进去:
# three-entry.js —— 只重导出你真正用到的东西,摇树才有意义
npx esbuild three-entry.js --bundle --format=esm --minify --outfile=three-lib.js一个克制的入口(二三十个类)打出来大约 500 KB。把它作为文件 three-lib.js 加进去,并且带绑定地导入:
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.tsx、three-lib.ts、three-lib.jsx 或 three-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 平台全局。
React、useYumina、Icons、Chat、MessageList、MessageInput本来就在作用域里。
加载模型
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 内嵌贴图的解码在沙箱里可能静默失败。把贴图作为单独的图片素材上传,自己挂上去:
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 的世界体积——把数据挪进素材 |
