Skip to content

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 卡在本地试的时候好好的,一发布就什么都不显示,几乎都是这个原因。

引用自己素材的两种方式 ​

在 我的库 → 资源 上传素材(在编辑器里是 面板 → 资源),然后用它的 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 的上限。
  • 10 MB:一次世界保存的上限。卡的源码文件是存在世界里的,所以把模型 base64 内联、或塞一堆内嵌音频,迟早会让世界存不上。素材放素材库,代码放卡里。
  • /cdn 是故意发 max-age=0 的。五分钟前加载过的图,浏览器还是会回源校验,除非你还持有它的引用。任何你希望瞬间出现的东西(传送目的地、角色立绘),把加载好的 Image 或 Texture 存进一个 Map,整局都别丢。

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

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

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

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

游戏变量每回合都会发给模型,所以当前地点只要存在变量里,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。

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

给每个地点写一条设定,只在 location 等于这个地点时才发:在画布上把 location 拖到这条设定上,再设好条件(条件设定)。这样只有玩家当下所在的房间在占上下文,AI 描述温室裂开的玻璃,是因为温室那条设定就摆在它面前,不用去猜。

第三步:让 AI 传送玩家 ​

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

[location: set greenhouse]

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

如果给 location 填上 可选值(一个地点一个),也可以交给精准追踪来挪玩家:每次回复后,它会挑出剧情走到的那个地点,AI 写给 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.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 本来就在作用域里。

加载模型 ​

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 兜底分支吞掉了
第二次传送开始卡顿贴图被回收了,持住引用
世界存不上卡自己的文件超了 10 MB 的世界体积,把数据挪进素材

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