Skip to content

Construir un mundo abierto en el navegador, parte 16: Una estructura para un mundo que no deja de crecer

Por Oleg Sidorkin, CTO y cofundador de Cinevva

¿Acabas de llegar? Consulta la guía de la serie. Explica qué es un spike y enlaza todas las partes.

Todos los spikes desde la parte 13 habían seguido la misma receta: copiar el monolito anterior y añadir una función. Al terminar el spike 32, ese monolito era un index.html de 6285 líneas dentro de un único <script type="module">. Las búsquedas de código producían demasiado ruido, encontrar dónde añadir una función llevaba más tiempo que escribirla y cualquier cambio arquitectónico afectaba a un archivo demasiado grande para abarcar mentalmente el diff. Antes de añadir otra función, pusimos la estructura en orden.

Dividir el monolito sin cambiar el comportamiento

Abrir el spike 33 en una pestaña nueva ↗ · Ver el código fuente

La restricción era estricta: cada división debía ser una refactorización pura, no un rediseño. El monolito se convirtió en 19 archivos .mjs más un shell principal de 151 líneas. Módulos de nivel superior para la escena, el agua, la hierba, el personaje, la física, el multijugador y la interfaz; un wgsl.mjs que contiene todas las cadenas de código fuente WGSL como única fuente de verdad para la GPU; y un subárbol terrain/ para el mapa de alturas, el SDF, los chunks, los wrappers de búferes de GPU, el pincel, el LOD y la persistencia. El total quedó en 6555 líneas, básicamente el monolito más el código repetitivo de las importaciones. Ningún cambio neto de volumen y un gran cambio en facilidad de navegación.

Entonces la página cargó con la pantalla en negro. Dos mensajes de error, dos causas raíz sin relación entre sí. El primero era una queja de WebGPU sobre un binding de búfer de cero bytes. En el monolito, el búfer del pincel SDF se asignaba de forma diferida cuando aparecía el primer chunk de marching cubes, y la fábrica de grupos de bindings se ejecutaba después, una vez que el búfer ya existía. Separar terrain/gpu.mjs de terrain/brush.mjs cambió el orden de evaluación de los módulos, así que la fábrica pasó a ejecutarse primero e intentó enlazar un marcador null. La solución fue posponer la creación del grupo de bindings hasta el primer dispatch mediante un helper getOrCreateBindGroup(chunk). El patrón de «crearlo todo por adelantado» era un artefacto de la ruta de inicialización única del monolito.

El segundo mensaje era una alarmante advertencia sobre un esqueleto FBX que resultó ser una pista falsa. Se mostraba desde el spike 25 y era inofensiva. El personaje solo faltaba porque el primer error se propagaba: cada dispatch de cómputo lanzaba una excepción, el mapa de alturas nunca se escribía, las muestras de altura devolvían 0 y el personaje aparecía en el origen y caía a través del mundo. Al arreglar el búfer, el personaje se animaba correctamente, con advertencia incluida.

Esa es la verdadera lección de la refactorización. El monolito ocultaba todas las relaciones del tipo «X debe existir antes de construir Y» dentro del orden de arriba abajo del script. La modularización alteró ese orden y sacó a la luz otros tres errores latentes de ordenación: la dispersión de hierba antes de subir el mapa de alturas, la adición del plano de agua antes de que terminara de decodificarse el mapa de entorno y la carga de la persistencia después del primer fotograma. Los tres se arreglaron con una sola línea y ninguno se habría detectado sin la división.

Cien objetos sin hacer crecer nada

Abrir el spike 34 en una pestaña nueva ↗ · Ver el código fuente

El spike 34 fue la prueba para comprobar si la estructura había valido la pena. El objetivo era crear una paleta en primera persona para colocar árboles, rocas, arbustos, setas y caminos procedentes de un paquete de modelos CC0, con alineación adaptada al terreno, persistencia, sincronización multijugador y colliders físicos, todo ello sin soltar los controles. Cada línea de código nueva se añadió en cinco archivos nuevos dentro de src/props/, y ningún módulo existente creció más de diez líneas de conexión.

El recorrido de los recursos dio un rodeo que merece la pena documentar, porque es el tipo de cosa que consume un día entero. Empezamos con el paquete Ultimate Nature de Quaternius, una biblioteca FBX sin texturas incrustadas. Los materiales FBX venían como MeshPhong sin mapa, así que conectamos una tabla manual de nombres de materiales a archivos PNG, convertimos Phong a Standard y configuramos los espacios de color a mano. Alrededor del 30 % de los materiales no tenía un PNG correspondiente y varios nombres eran ambiguos entre árboles similares. Un segundo paquete FBX presentaba el mismo problema. La solución no era crear más tablas de correspondencias, sino usar un paquete mejor preparado: Stylized Nature MegaKit de Quaternius incluye 116 glTF completos con materiales PBR incrustados y normales precalculadas. Cambiar FBXLoader por GLTFLoader eliminó el escalado de centímetros a metros, la tabla de texturas y la conversión de Phong, y redujo library.mjs en unas 80 líneas. La conclusión: glTF con PBR es el pipeline adecuado para los paquetes CC0 que lo ofrecen, mientras que FBX con asignación manual de texturas requería el doble de código para obtener la mitad de calidad.

La ruta de glTF también trajo algunos problemas delicados. La paleta renderiza 116 miniaturas, y canvas.toDataURL() de WebGPU devuelve una imagen vacía para una superficie GPUCanvasContext, por lo que las miniaturas se renderizan en un RenderTarget, se leen con readRenderTargetPixelsAsync y se copian en un canvas 2D, teniendo en cuenta la alineación de filas de 256 bytes de WebGPU. Las previsualizaciones fantasma clonan cada material para teñirlo de verde, lo que fallaba en las mallas cuyo material era un array; se corrigió con una rama Array.isArray. Además, los mapas de normales de 16 bits, que ocupaban unos 200 MB, se redujeron una sola vez con mogrify -depth 8 hasta unos 32 MB, sin diferencias visuales, ya que los navegadores reducen su resolución durante la carga de todos modos.

Cuando la geometría renderizada solo existe en la GPU

El error más instructivo hacía que la previsualización fantasma saltara en incrementos de entre 1 y 2 metros al mover el cursor. Las mallas del terreno almacenan las posiciones de los vértices en un StorageBufferAttribute porque el pipeline de cómputo las escribe directamente en la GPU, de modo que el Raycaster de CPU de three.js no puede verlas y no devuelve nada. La alternativa era un ray marching aproximado con pasos de 1,5 m contra el mapa de alturas analítico, y ese intervalo fijo era la cuadrícula que veía el usuario. Lo sustituimos por un recorrido adaptativo: pasos de 2,5 m cuando el rayo está muy por encima de la superficie, reducción a 0,4 m al encontrarse a menos de 5 m y, después, 14 bisecciones cuando cambia el signo de (rayyterrainy). Así se obtiene una precisión inferior al milímetro con unos 30 pasos amplios más 14 bisecciones por lanzamiento. Cuando la geometría renderizada solo existe en la GPU, no luches contra el raycaster: recorre la fuente analítica.

Colliders que siguen siendo fieles tras las ediciones

Elegimos proxies primitivos en lugar de envolventes convexas o colliders de malla. Los objetos de Quaternius son redondeados y de bajo número de polígonos, sin concavidades relevantes, por lo que las envolventes requerirían aproximadamente 50 veces más código y multiplicarían por 10 el coste en tiempo de ejecución para ofrecer la misma experiencia de juego. Cada objeto se reduce a una forma derivada de su caja delimitadora: árboles y cactus se convierten en una cápsula vertical, rocas en una esfera, troncos en una cápsula horizontal a lo largo de su eje mayor, y los arbustos y flores decorativos no tienen collider. Se puede caminar sobre las rocas y los troncos —empuje solo vertical para poder ponerse encima—, mientras que los árboles y cactus bloquean el paso —empuje 3D completo para impedir trepar por un tronco—. Un hash espacial de 8 m limita la comprobación por fotograma al vecindario de 3×3 del jugador, normalmente entre cero y seis objetos.

Dos decisiones de diseño mantuvieron la coherencia del sistema. La alineación con el terreno es un indicador del manifiesto, no un enum de categorías fijado en el código, por lo que tanto la previsualización fantasma como la colocación confirmada leen el mismo valor placement.alignToTerrain y no pueden discrepar. Además, los objetos colocados reaccionan a las ediciones del terreno mediante un único helper: después de una pincelada —local o reproducida desde otro par—, refreshPlacementsInRadius vuelve a muestrear el suelo bajo cada objeto dentro del disco afectado, reaplica la alineación y vuelve a derivar los extremos del collider. Esculpe una colina debajo de un árbol y el árbol ascenderá con ella. La persistencia y el multijugador reutilizan exactamente el patrón del spike 31: almacenan una lista plana de {uid, propId, x, y, z, rotY, scale} y replican los eventos de colocación, eliminación y ajuste mediante BroadcastChannel.

Tecnología mencionada en este capítulo

Descomposición en módulos ES con WebGPU. Dividir un <script type="module"> monolítico en importaciones .mjs con rutas directas no requiere un empaquetador cuando los módulos se sirven como recursos estáticos, y TSL de three.js funciona sin problemas entre módulos. El coste oculto es el orden de inicialización: un monolito codifica «construir X antes que Y» mediante el orden de arriba abajo del script, mientras que los módulos se evalúan según el orden de importación, lo que puede ejecutar una fábrica de grupos de bindings de GPU antes de que exista su búfer. El patrón de solución es la inicialización diferida (getOrCreate... en el primer uso) y esperar la promesa adecuada en lugar de depender del orden de declaración.

glTF con PBR incrustado frente a FBX con asignación manual. glTF utiliza metros, referencia sus propias texturas y proporciona directamente MeshStandardMaterial, por lo que un paquete CC0 creado como glTF se integra directamente en un pipeline PBR. Los paquetes FBX sin metadatos de vinculación de texturas requieren una tabla manual de nombres de materiales a archivos PNG que queda desactualizada con cada actualización del paquete, además de una conversión de Phong a Standard y el etiquetado manual del espacio de color. Como red de seguridad para el follaje, los materiales transparent sin alphaTest se convierten en tarjetas recortadas con alphaTest: 0.5, para que se ordenen correctamente detrás de la geometría opaca.

Miniaturas fuera de pantalla con WebGPU. canvas.toDataURL() devuelve una imagen vacía para un canvas respaldado por GPUCanvasContext porque no existe una ruta desde una superficie de presentación hasta un contexto 2D. Renderizar en un RenderTarget, leer los píxeles con readRenderTargetPixelsAsync y copiarlos en un canvas 2D funciona, siempre que la copia avance según el stride de lectura alineado a 256 bytes de WebGPU. Los resultados se almacenan en caché en localStorage bajo una clave cuya versión se incrementa para que los cambios en el paquete invaliden los renderizados obsoletos.

Ray marching adaptativo contra un mapa de alturas analítico. Cuando los vértices del terreno viven en un StorageBufferAttribute de la GPU, el raycaster de CPU no puede verlos. Recorrer la función de altura analítica con pasos grandes lejos de la superficie, pasos pequeños cerca de ella y un refinamiento mediante búsqueda binaria cuando cambia el signo de (rayyterrainy) proporciona al cursor una precisión inferior al milímetro con un número acotado de muestras. La misma primitiva se utiliza para el cursor del pincel y el objeto fantasma.

Colliders de cápsula primitivos con un hash espacial. Cada objeto se reduce a una cápsula o esfera derivada de su categoría a partir de su caja delimitadora, se registra como {kind, walkable, radius, p1, p2} y se añade a cada bucket de 8 m del hash con el que se solapa. En cada fotograma, el jugador solo comprueba los objetos del vecindario de 3×3 buckets, con una resolución cápsula contra cápsula para cada uno. Los proxies transitables —rocas y troncos— reciben un empuje solo vertical; los proxies que bloquean —árboles— reciben el empuje 3D completo. Consulta Colisiones con terreno SDF para ver las matemáticas de cápsulas en las que se basa este sistema.


Parte 16 de 29. Anterior: Parte 15 - Sustituir la base y después sincronizarla Siguiente: Parte 17 - Animaciones que no necesitaron retargeting y una búsqueda de recursos en vivo Guía de la serie: /es/blog/2026-02-25-open-world-browser-series-guide