Construyendo un mundo abierto en el navegador, parte 24: Guardar un mundo y hacer visible el viento
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.
La parte 23 puso personas en el mundo y les dio voz. Esta parte trata de conseguir que el mundo recuerde lo que hicieron en él y que parezca vivo cuando nadie lo toca. El Spike 47 se centra en la persistencia: un creador esculpe el terreno y coloca elementos, y esas ediciones sobreviven a una recarga, se sincronizan con todos los demás pares y se arbitran de forma limpia cuando dos personas editan a la vez. El Spike 49 se centra en el viento: trasladar los shaders de vegetación de un paquete de naturaleza estilizada para que los árboles, arbustos y la hierba se muevan como pretendía el artista, lo que terminó convirtiéndose más en una lucha con el compilador de shaders que con las matemáticas.
Un mundo que recuerda
Abrir el Spike 47 en una pestaña nueva ↗ · Ver código fuente
El escenario es una sesión de creación compartida. Los jugadores recorren un mundo, colocan elementos con un clic, los eliminan con un clic derecho y esculpen el suelo arrastrando un pincel, elevando y reduciendo el mapa de alturas o excavando cuevas volumétricas en el terreno SDF, lo que hace que un chunk pase a usar marching cubes. Todo lo que hacen persiste en un WorldChunkDO de Cloudflare, un Durable Object con autoridad del servidor respaldado por su propio almacenamiento SQLite. Los clientes no escriben el estado directamente. Envían su intención, el DO arbitra y difunde el resultado, y el DO es la única fuente de verdad, por lo que quien acaba de unirse recibe una instantánea y aparece exactamente en el mismo mundo que ven los demás.
Dos detalles de la persistencia demostraron su valor. El terreno no se almacena como un registro de eventos que haya que reproducir al unirse; se guarda como blobs binarios por chunk que constituyen la fuente de verdad y se suben al finalizar cada trazo, de modo que quien se une carga directamente los bytes confirmados en lugar de volver a ejecutar miles de muestras del pincel. Además, esos blobs usan una clave de almacenamiento por chunk en lugar de una única fila grande, porque un solo chunk SDF ocupa 168 KB y el DO tiene un límite de 2 MB por fila. Un índice de chunks registra qué claves existen para que el DO pueda rehidratar el mapa completo al reactivarse. La identidad se gestiona mediante dos almacenamientos en el cliente: el id del jugador se guarda en sessionStorage, por lo que dos pestañas son dos pares distintos en lugar de un mismo par que se sobrescribe a sí mismo en el mapa de jugadores del DO, mientras que el nombre visible se guarda en localStorage, de modo que un cambio de nombre en una pestaña se propaga a todas las demás.
Hacer converger las ediciones simultáneas
La parte realmente difícil de la creación multijugador es qué ocurre cuando dos personas editan una zona de terreno superpuesta en el mismo instante. El spike divide las ediciones según su álgebra. Las operaciones aditivas —elevar y bajar en el mapa de alturas, y sumar y restar en el SDF— son conmutativas: aplicarlas en cualquier orden produce el mismo resultado, por lo que siguen una ruta optimista basada en sellos donde cada cliente aplica localmente el cambio y envía el sello, y el DO lo difunde a todos sin coordinación. El orden realmente no importa, así que no hay nada que coordinar.
Las operaciones dependientes del orden, suavizar y aplanar, fueron el caso interesante. El primer diseño les asignaba un bloqueo de región: un cliente solicita un bloqueo al pulsar el puntero, el DO lo concede o lo deniega, el cliente almacena temporalmente las muestras mientras se mantiene la pulsación y, al soltar el puntero, el DO aplica todo el trazo de forma atómica. Funciona, pero es un protocolo independiente, con su propio TTL de bloqueo y su viaje de ida y vuelta para concederlo o denegarlo. La solución más limpia que lo sustituyó es un delta precalculado: el cliente de origen ejecuta localmente el pincel de suavizado o aplanado, después envía la lista resultante de deltas por celda y cada par se limita a sumar esos deltas a sus propias celdas sin volver a derivar nada. Esto convierte una operación dependiente del orden en una operación conmutativa al fijar su resultado en el origen, por lo que todo el sistema de edición funciona con un único protocolo conmutativo uniforme, con una convergencia idéntica y sin ningún bloqueo. Los bloqueos de elementos se mantienen por un motivo distinto: el bloqueo por registro sustituyó a la eliminación exclusiva del propietario, de modo que cualquier par puede eliminar cualquier elemento salvo que alguien lo haya bloqueado, y solo quien lo bloqueó puede desbloquearlo. Deshacer y rehacer funcionan permitiendo que el cliente asigne un id al elemento antes de colocarlo, de modo que conoce el id antes de recibir el eco del servidor y puede revertir sus propias acciones de forma determinista. Los WebSockets en hibernación mantienen gratuita una sala inactiva en todo momento, la misma propiedad que abarató el relé de avatares de la parte anterior.
Un viento trasladado fielmente y luego combatido
Abrir el Spike 49 en una pestaña nueva ↗ · Ver código fuente
El Spike 49 toma el Stylized Nature MegaKit de Quaternius y traslada su viento a nuestra pila tecnológica. El paquete incluye sus shaders originales de Godot, cuatro en total, y lo acertado era traducirlos fielmente en lugar de reinventarlos. La corteza tiene una función de vértices vacía, así que los troncos son rígidos; un intento anterior basado en una máscara procedural hacía que los troncos se balancearan, y la solución fue simplemente dejar de aplicar viento a la corteza. Las hojas reciben un balanceo caótico por vértice a partir de un hash de pulsos triangulares, enmascarado según la altura para que las copas se muevan mientras la base permanece plantada. El follaje básico recibe en el espacio del mundo un balanceo seno/coseno modulado por ruido. La hierba es follaje básico más una oscilación de líneas de viento que muestrea una textura de ruido en desplazamiento mediante una curva de potencia, de modo que solo contribuyen las bandas claras de la textura, lo que produce las ondas visibles que recorren una pradera. La selección sigue la propia convención de nombres de materiales del paquete, por lo que un material llamado Leaves_Birch se dirige a la ruta de hojas y Grass_Common a la ruta de hierba, sin conjeturas.
Una sorpresa del traslado es que el color de las hojas no está en la textura. Quaternius define por completo el aspecto de las hojas mediante un degradado vertical y un borde Fresnel: el albedo es una mezcla desde un color adicional en la parte inferior de la copa hasta el color de la hoja en la parte superior, determinada por la altura, con un tinte de dispersión subsuperficial añadido como emisión escalada mediante un término Fresnel heightFactor precalculado en lugar de la Y local sin procesar, normalizado por grupo de hojas durante la carga a partir de la Y en el espacio del mundo, para que tanto el degradado como la máscara de viento se comporten correctamente sin importar cómo haya rotado la importación FBX los ejes locales de cada malla. Las constantes de color de Godot están etiquetadas como sRGB y se convierten a lineal antes de llegar al shader, así que el port realiza la misma conversión en lugar de introducir los valores sRGB brillantes como si fueran lineales y desteñir el follaje.
La recompilación que devoró la tasa de fotogramas
El motivo por el que esto se publicó primero con una base FBX, dejando a un lado el conjunto completo de materiales de viento en un archivo .bak, es un error de recompilación por fotograma que reducía la escena a aproximadamente 1 fps. Superponer TSL personalizado sobre materiales cargados desde FBX hacía que Three.js reconstruyera los programas de shaders en cada fotograma, con needsUpdate prácticamente atascado en el estado activo. El método de diagnóstico consistió en reducir cada material de follaje a una pasada sencilla con textura, sin nodos personalizados, y observar si el bucle de recompilación persistía. Si se detenía, el culpable era el grafo personalizado; si continuaba, la causa estaba en una fase anterior, en la configuración de materiales FBX o en el propio Three.js. La solución que permitió recuperar los shaders reales fue vincular como uniforms todas las diferencias entre materiales —color de las hojas, color SSS, intensidad y mezcla— para que todos los recursos de hojas compartieran un único programa compilado, en lugar de que el compilador emitiera un shader nuevo para cada combinación de colores única y saturara la cola de compilación.
Merece la pena conservar otros dos elementos. La hierba se renderiza como un InstancedMesh por archivo de origen, y su viento se calcula en el espacio del mundo porque las fases sinusoidales dependen de la posición global. Sin embargo, el desplazamiento debe aplicarse en el espacio local antes de que se ejecute la matriz de instancia, y WGSL no dispone de inverse(). Para una matriz de instancia compuesta por una traslación, una rotación Y y una escala uniforme, la inversa del bloque superior de 3×3 es simplemente su transpuesta dividida por la escala al cuadrado, por lo que el shader multiplica el desplazamiento global por la matriz de modelo transpuesta y lo divide por el cuadrado de la longitud de la primera columna de la matriz, recuperando la escala sin una raíz cuadrada. Cuando la transformación de vértices vuelve a aplicar la matriz, el movimiento termina en el espacio del mundo exactamente como se diseñó, con independencia de la rotación o escala de cada mata. Además, cada mata realiza un descarte de frustum por vértice en la GPU: proyecta el centro de la instancia al espacio de recorte y, si queda fuera del frustum con un margen, colapsa todos los vértices en el origen local para que los tres vértices de cada triángulo coincidan; el rasterizador descarta el triángulo degenerado y no se realiza ningún trabajo de fragmentos, prueba alfa ni sombras para la hierba fuera de pantalla. Esto se suma al descarte aproximado por esfera envolvente de chunk que Three.js ya realiza, construido con step de tipo float en lugar de booleanos para que pueda multiplicarse directamente en la mezcla de posiciones.
Tecnología mencionada en este capítulo
Persistencia del mundo con autoridad del servidor. Un Durable Object WorldChunkDO arbitra la colocación de elementos y las ediciones del terreno, conserva blobs binarios por chunk como fuente de verdad —para que quienes se unen carguen los bytes confirmados en lugar de reproducir un registro de eventos— y almacena una clave por chunk para mantenerse por debajo del límite de 2 MB por fila del DO. El id del jugador se guarda en sessionStorage para que las pestañas sean pares distintos; el nombre visible se guarda en localStorage para que los cambios de nombre se propaguen entre pestañas.
Convergencia conmutativa de las ediciones. Las operaciones aditivas sobre el terreno —elevar/bajar y sumar/restar en el SDF— son conmutativas y siguen una ruta optimista basada en sellos sin coordinación. Las operaciones dependientes del orden —suavizar y aplanar— se vuelven conmutativas enviando deltas precalculados por celda en lugar de adquirir un bloqueo de región, por lo que todo el sistema converge mediante un único protocolo uniforme sin bloqueos. Los bloqueos de elementos por registro sustituyen a la eliminación exclusiva del propietario, y los ids de objetos asignados por el cliente permiten deshacer y rehacer de forma determinista antes de que llegue el eco del servidor. Consulta LOD controlado por GPU.
Traslado fiel de shaders de Godot a TSL. Los cuatro shaders de viento originales de Quaternius se traducen línea por línea: corteza rígida, balanceo de hojas enmascarado por altura, balanceo del follaje en el espacio del mundo y hierba con una oscilación de líneas de viento en desplazamiento, seleccionados mediante la convención de nombres de materiales del paquete. El color de las hojas procede de un degradado de altura más un borde SSS controlado por Fresnel, en lugar de la textura, y las constantes creadas en sRGB se convierten a lineal para que el aspecto coincida con los renders de referencia.
Evitar recompilaciones de shaders por fotograma. Superponer TSL personalizado sobre materiales FBX puede dejar needsUpdate activado y reconstruir los programas en cada fotograma, reduciendo el rendimiento a unos 1 fps. Vincular como uniform todas las diferencias entre materiales permite que todas las variantes compartan un único programa compilado, en lugar de emitir un shader nuevo por cada conjunto único de parámetros. Una pasada de diagnóstico sencilla con textura permite aislar si la causa es el grafo personalizado o la configuración anterior.
Viento en el espacio del mundo para follaje instanciado. El viento de la hierba se calcula en el espacio del mundo y se transforma de vuelta al espacio local mediante una inversa derivada manualmente —transpuesta dividida por la escala al cuadrado— porque WGSL no dispone de inverse(). Un descarte de frustum por vértice en la GPU colapsa las matas fuera de pantalla en un triángulo degenerado para evitar cualquier trabajo de fragmentos o sombras, sumándose al descarte por esfera envolvente de chunk de Three.js.
Parte 24 de 29. Anterior: Parte 23 - Cincuenta avatares y una voz en la sala Siguiente: Parte 25 - Un esqueleto, cualquier atuendo Guía de la serie: /es/blog/2026-02-25-open-world-browser-series-guide