Skip to content

3D, panoramas y recursos binarios

Tu carta se ejecuta dentro de un iframe cerrado con llave. Las imágenes y el audio entran sin problemas; un .glb no. Esta página es el mapa: qué carga, qué falla en silencio y el patrón que abarata los mundos 3D — teletransportarse entre lugares en vez de caminar por ellos.


Qué puede cargar el sandbox

La UI personalizada corre en un iframe con su propia Content Security Policy. Esa política es la razón de que una carta no pueda atacar el sitio ni enviar fuera los datos de un jugador — y también la razón de que código web perfectamente normal no haga absolutamente nada en una carta publicada.

Lo que escribesLo que ocurre
<img src="/cdn/…">, background-image en CSS, <video>, <audio>, fuentes webFunciona. Imágenes, medios y fuentes tienen una vía de carga privilegiada
THREE.TextureLoader().load(url)Funciona — por dentro carga a través de un <img>
fetch(...), XMLHttpRequest, WebSocket, EventSourceBloqueado del todo. La política es connect-src 'none'
GLTFLoader().load(url), .bin, .ply, un .json grandeBloqueado — usan fetch, no <img>
new Worker(URL.createObjectURL(blob))Bloqueado. Da por hecho que no hay workers
SharedArrayBuffer, WASM multihiloNo disponible — la página no está aislada entre orígenes
api.fetchAsset("<asset-id>")La entrada para los bytes. La plataforma descarga el recurso y te entrega un ArrayBuffer

La trampa

loader.load(url) para un modelo no lanza un error claro: la carga simplemente no termina nunca, así que la carta muestra una escena vacía o tu propio "cargando…" para siempre. Si una carta 3D funciona en una prueba local y no muestra nada al publicarse, casi siempre es esto.

Dos formas de referenciar tus propios recursos

Sube el arte en Biblioteca → Assets y usa el id del recurso:

tsx
var api = useYumina()

// Imágenes / audio / fuentes: resuelve una URL y deja que la cargue el navegador.
var url = api.resolveAssetUrl("@asset:2ec02221-2664-4f31-9d31-03b2db7570ab")
// → "/cdn/2ec02221-…", una URL normal para <img src>, CSS o una textura de THREE

// Cualquier binario: pídele los bytes a la plataforma.
var res = await api.fetchAsset("2ec02221-2664-4f31-9d31-03b2db7570ab")
if (res.ok) {
  var bytes = res.bytes // ArrayBuffer
}

fetchAsset acepta un id de recurso, nunca una URL — eso es lo que hace seguro exponerlo. El límite es de 32 MB por recurso y devuelve { ok: false, error } con "bad-ref", "http-404", "too-large" o un fallo de red. Comprueba ok antes de tocar bytes.

Tamaños que conviene saber

  • 32 MB — techo de una llamada a fetchAsset.
  • 5 MB — techo al guardar un mundo. Los archivos fuente de tu carta viven dentro del mundo, así que un modelo incrustado en base64 o un muro de audio embebido acabará impidiendo que el mundo se guarde. El arte va a la biblioteca de recursos; el código va en la carta.
  • /cdn sirve los recursos con max-age=0 a propósito. El navegador revalidará una imagen que cargaste hace cinco minutos salvo que sigas manteniendo una referencia a ella. Para todo lo que quieras que aparezca al instante (un destino de teletransporte, un retrato), guarda la Image o la Texture ya cargada en un Map durante toda la sesión.

El patrón: teletransportarse entre lugares

El mundo 3D más convincente y barato en Yumina es aquel en el que la cámara nunca camina. El jugador está en un lugar, mira alrededor y elige el siguiente de una lista. Sin colisiones, sin malla de navegación, sin streaming — y cada nueva localización cuesta una imagen, no un nivel.

Cada lugar es una panorámica equirrectangular de 360° mapeada sobre el interior de una esfera con la cámara en el centro. Teletransportarse es cambiar la textura de esa misma esfera: un renderer, una esfera, una draw call, para un mundo de cualquier tamaño.

Paso 1 — el lugar actual es una variable, no estado de React

Esta es la parte que lo convierte en un mundo de Yumina y no en una página web. Las variables de juego se inyectan en el contexto del modelo cada turno, así que si el lugar actual vive en una variable, la IA siempre sabe dónde está el jugador — y puede moverlo ella misma.

Crea una variable string llamada location con valor por defecto atrium y luego:

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

  function goTo(id) {
    api.setVariable("location", id)
    api.sendMessage("Cruzo el umbral y entro en " + 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>
  )
}

Usa api.sendMessage cuando quieras que la IA reaccione al movimiento de inmediato, o api.setComposerDraft si prefieres que el jugador escriba primero su propia frase de entrada.

Paso 2 — una entrada del lorebook por lugar

Dale a cada lugar una entrada cuya activación dependa de que location sea ese lugar. Así solo la sala en la que está el jugador gasta contexto, y la IA describe el cristal agrietado del invernadero porque tiene delante la entrada del invernadero, no porque lo esté adivinando.

Paso 3 — deja que la IA teletransporte al jugador

Como location es una variable corriente, el modelo puede escribirla:

[location: set greenhouse]

Tu UI ya está mirando api.variables.location, así que la escena cambia cuando la historia dice que cambia. Una puerta por la que arrastra la narrativa y una puerta que pulsó el jugador son el mismo camino de código.

Paso 4 — la esfera

js
// stage.js — una escena, una esfera; cambiar la textura es teletransportarse.
import * as THREE from "./three-lib"

var renderer, scene, camera, sphere
var textures = new Map()   // mantén vivas las panorámicas: /cdn no las cachea por nosotros

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)                       // invierte las normales para verla por dentro
  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)   // por dentro es un <img>: permitido
    tex.colorSpace = THREE.SRGBColorSpace
    textures.set(url, tex)
  }
  sphere.material.map = tex
  sphere.material.needsUpdate = true
}

Precarga las panorámicas de todas las salidas del lugar actual mientras el jugador lee, y el teletransporte será instantáneo.

De dónde salen las panorámicas

Sirve cualquier imagen equirrectangular 2:1 — un render de Blender, una foto de una cámara 360 o una panorámica generada por IA. Un JPEG de 2048×1024 basta de sobra para un fondo por el que mirar alrededor: unos 300 KB, así que un mundo de doce salas pesa menos que un solo modelo de personaje.


Meter three.js (o cualquier librería) en una carta

Dentro de una carta no hay npm ni etiquetas <script> a un CDN. Empaqueta la librería tú mismo, una vez, y pega el resultado como un archivo más de la carta:

bash
# three-entry.js — reexporta solo lo que de verdad usas para que el tree-shaking sirva
npx esbuild three-entry.js --bundle --format=esm --minify --outfile=three-lib.js

Un punto de entrada contenido (un par de docenas de clases) sale en torno a 500 KB. Añádelo como archivo three-lib.js e impórtalo con un binding:

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

Siempre con binding. Un import "./three-lib" de solo efecto secundario no registra dependencia, así que el bundler deja el archivo fuera sin avisar y la carta falla con THREE is not defined.

Builds con tree-shaking y símbolos que faltan

Un build recortado solo contiene las clases que nombra tu punto de entrada. Si usas una que se quedó fuera — THREE.RingGeometry, por ejemplo — obtienes THREE.RingGeometry is not a constructor al construir la escena. El código de la carta suele atraparlo en el mismo try/catch que gestiona el "aquí no hay WebGL" y muestra al jugador "tu dispositivo no soporta 3D", lo que te manda a buscar un problema de hardware que no existe. Comprueba WebGL aparte con un canvas desechable y, cuando la comprobación pase, muestra el error.message real en vez del texto de respaldo.

Nombres de archivo y sintaxis de import

Todos los archivos de una carta se compilan igual, se llamen como se llamen, y los imports se resuelven sin extensión: import * as THREE from "./three-lib" encuentra three-lib.tsx, three-lib.ts, three-lib.jsx o three-lib.js. Llama .js al bundle de la librería, porque eso es lo que es — a la tubería le da igual.

Tres reglas de sintaxis no son opcionales, porque los imports y exports se eliminan por patrón antes de ejecutar el bundle:

  • Cada import, en una sola línea. Un import { a, b } from "./x" repartido en varias líneas se queda en la salida y la carta muere con Cannot use import statement outside a module.
  • Exporta con export function / export const / export class / export default / export { a as b }. export * from "./x" no se reconoce, sobrevive en el bundle y lanza Unexpected token 'export'.
  • No importes los globales de la plataforma. React, useYumina, Icons, Chat, MessageList, MessageInput ya están en el ámbito.

Cargar un modelo

tsx
import { GLTFLoader } from "./three-lib"   // inclúyelo en el punto de entrada del bundle

var res = await api.fetchAsset(ASSETS.dealerGlb)
if (!res.ok) { /* di algo honesto, no gires para siempre */ return }

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

parse, no load: ya tienes los bytes, y load intentaría descargarlos.

Si el modelo sale completamente blanco, sus texturas no llegaron: la decodificación de texturas incrustadas en el GLB puede fallar en silencio dentro del sandbox. Sube la textura como recurso de imagen aparte y asígnala tú:

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

Que sobreviva en un móvil

Los jugadores están en el móvil, y una carta 3D que cuece el teléfono se cierra, no se reporta.

  • Limita el pixel ratio. renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)) — 1 en gama baja. Un ratio de 3× sin límite en una pantalla alargada son millones de píxeles extra por fotograma sin ninguna ganancia visible.
  • Sin sombras en tiempo real en móvil. En una carta publicada se midieron como ~85% del tiempo de GPU del fotograma, a cambio de un borde suave que nadie mira.
  • Mide los intervalos reales entre fotogramas y baja escalones. Evalúa cada par de segundos; si los fotogramas siguen llegando lentos, baja un punto el pixel ratio y después recorta efectos. Dale además al jugador un selector de calidad explícito.
  • Nunca agites la cámara con aleatoriedad por fotograma. Una sacudida con Math.random() invierte de dirección decenas de veces por segundo, y de ahí sale el mareo. Usa la suma de dos senos lentos y mantén estable la altura del canvas cuando se abre el teclado en pantalla, en lugar de recalcular el campo de visión.
  • Cachea y libera con intención. Guarda las texturas a las que volverás; haz dispose() de las que no.

Cuando algo no funciona

SíntomaCausa
El modelo no aparece y no hay errorloader.load(url) — usa fetchAsset + parse
El modelo sale blancoLas texturas incrustadas no se decodificaron: cárgalas como recurso de imagen aparte
THREE is not definedImport de solo efecto secundario; dale un binding
THREE.X is not a constructorEse símbolo falta en tu build con tree-shaking
Pantalla en blanco: Unexpected token 'export'Un export * from "./x" en algún archivo
Pantalla en blanco: Cannot use import statement outside a moduleUn import de varias líneas
"Este dispositivo no soporta 3D" en un buen dispositivoUn error real tragado por la rama de respaldo de WebGL
Los teletransportes tiran la segunda vezLas texturas se recolectaron: mantén las referencias
El mundo no se guardaLos archivos de la carta superan los 5 MB del cuerpo del mundo: mueve datos a recursos

Relacionado: Guía de UI personalizada · Referencia de API · Saltos de escena