Construir un mundo abierto en el navegador, parte 15: Sustituir la base y después sincronizarla
Por Oleg Sidorkin, CTO y cofundador de Cinevva
¿Acabas de llegar? Consulta la guía de la serie. Explica qué es un experimento y enlaza todas las partes.
Las primeras catorce partes cubrieron los experimentos del 1 al 30. Esa etapa terminó con un sistema de terreno que podía esculpirse en tiempo real, además de un personaje capaz de caminar, planear y caer sobre él. Esta parte retoma la serie en el experimento 31, y lo primero que tuvimos que decidir no fue una cuestión técnica. Fue qué hacer con todo aquel código experimental.
La decisión de «sustituir, no portar»
Teníamos 30 archivos HTML independientes, cada uno dedicado a demostrar un concepto aislado, y ninguna integración. El world/client/ de producción seguía usando la pila antigua: WebGL, un mapa de alturas sencillo, un controlador de personaje de 75 líneas y un protocolo MessagePack con nueve tipos de mensajes. Sin edición, sin WebGPU, sin materiales y sin vegetación.
El plan obvio era portar los resultados de los experimentos a ese código de producción uno por uno. Lo descartamos. El experimento 30 ya tenía mejores terrenos, físicas, materiales, vegetación y cámara que los que jamás tuvo world/client/. Portarlo todo al antiguo código WebGL habría significado luchar contra él en cada paso. Así que decidimos sustituir la implementación del mundo por el experimento más logrado y seguir construyendo a partir de ahí. El experimento 30 se convirtió en la nueva base y world/client/ pasó a ser código muerto.
Eso replanteó el trabajo restante. Para pasar de una «gran demostración técnica para un jugador» a un «producto» necesitábamos multijugador, persistencia, streaming de un mundo infinito y colocación de objetos. La sincronización multijugador del terreno fue lo primero, porque es lo que obliga a definir la arquitectura. La pregunta que resuelve es fácil de formular y costosa de responder mal: cuando el jugador A esculpe, ¿qué se envía realmente por la red?
Repetición de parámetros del pincel, no sincronización de píxeles
Abrir el experimento 31 en una pestaña nueva ↗ · Ver código fuente
Antes de escribir una sola línea de código de red, rastreamos con exactitud qué hace una pincelada. El pincel del mapa de alturas recorre un radio alrededor del cursor dentro de un Float32Array en la CPU, aplica una atenuación smoothstep y suma, resta, suaviza o aplana. El pincel SDF hace lo mismo en 3D sobre una esfera de vóxeles. Ambas rutas son operaciones matemáticas puras con arrays en la CPU. No hay cómputo en la GPU dentro del bucle, ni aleatoriedad, ni operaciones de coma flotante no deterministas. El mismo array de entrada más los mismos parámetros produce la misma salida en todas las máquinas.
Ese es todo el truco. No enviamos el terreno editado. Enviamos los parámetros del pincel, 56 bytes por ciclo de pincelada, y cada cliente repite la misma función determinista. El protocolo de sincronización tiene cuatro tipos de mensajes: detección de pares, el mensaje del pincel {op, wx, wy, wz, radius, strength, flattenTarget} y un mensaje de posición del jugador a 20 Hz.
Para el experimento prescindimos por completo del servidor y usamos BroadcastChannel, la API del navegador para enviar mensajes entre pestañas del mismo origen. Abres dos pestañas y se comunican sin ninguna infraestructura. Eso permite aislar la cuestión de la sincronización de la latencia, la autenticación y la conexión con Durable Objects. Si la repetición de parámetros converge entre pestañas, también convergerá mediante WebSocket.
El único punto en el que la repetición puede divergir son las operaciones que dependen del orden. Elevar y rebajar son conmutativas, por lo que val + strength * falloff produce el mismo resultado sin importar quién lo aplicó primero. Suavizar y aplanar leen valores vecinos, de modo que dos clientes que suavicen exactamente el mismo lugar en el mismo instante pueden desviarse fracciones de milímetro por ciclo. En la práctica eso nunca ocurre, y la solución para producción ya es evidente: dirigir las ediciones a través del DO, dejar que asigne un número de secuencia monotónico, aplicarlas de forma optimista en el cliente y corregir el orden si la secuencia autoritativa no coincide. Es concurrencia optimista clásica, y de todos modos el DO constituye un punto de serialización natural.
La cápsula del otro jugador que no dejaba de desaparecer
Las ediciones se sincronizaron al primer intento. La cápsula del jugador remoto, no. Parpadeaba y desaparecía en la otra pestaña, y tuvimos que corregir tres errores distintos para conseguir que permaneciera visible.
La cápsula aparecía en el origen del mundo, enterrado bajo el terreno, porque el mensaje join llega antes que cualquier dato de posición. Solución: comenzar con ella oculta y mostrarla al recibir la primera actualización de posición. La emisión de la posición estaba dentro del bucle de renderizado, y Chrome limita requestAnimationFrame en las pestañas sin foco, por lo que la comprobación de inactividad de la otra pestaña eliminaba al jugador y el siguiente mensaje volvía a crearlo. Solución: trasladar la emisión a un setInterval, que no se limita en las pestañas visibles. Además, el tiempo de espera de 5 segundos era demasiado agresivo y cualquier pausa del recolector de basura lo activaba. Solución: aumentarlo a 30 segundos y confiar en el mensaje leave para los cierres normales.
Persistencia e incorporación tardía con el mismo formato
Integramos la persistencia en el mismo experimento en lugar de crear uno nuevo, porque el formato de serialización es idéntico tanto si el destino es IndexedDB como si es otra pestaña. Una instantánea contiene el mapa de alturas completo (un Float32Array de 129×129, unos 66 KB), únicamente los fragmentos SDF editados (cada uno de
La primera prueba de persistencia reveló un interesante error de orden. La hierba se distribuye de forma síncrona durante la inicialización usando las alturas procedimentales, pero la restauración desde IndexedDB es asíncrona y después sobrescribe el mapa de alturas, lo que deja todas las briznas flotando o hundidas. La solución es una pasada de refreshAllGrass() que vuelve a muestrear la altura bajo cada instancia y oculta cualquier brizna que ahora esté en una pendiente o altitud inadecuada. La misma función sirve tanto para la carga como para la incorporación tardía.
La odisea de las pendientes
El terreno esculpido es más accidentado que la suave base procedimental, y dejó al descubierto tres errores de físicas que el terreno antiguo nunca pudo revelar. Al caminar cuesta arriba en línea recta, la cápsula se deslizaba lateralmente. La causa era una proyección de la velocidad destinada a mantener el movimiento tangente al suelo, pero escrita utilizando únicamente los componentes horizontales de la normal. En una pendiente diagonal con normal
La deriva persistía por una segunda causa. Las sondas de colisión SDF empujan el cuerpo hacia fuera a lo largo del gradiente según la profundidad de penetración. En cualquier pendiente, el gradiente tiene componentes horizontales, por lo que una penetración de 0,1 m en una pendiente de 15° empuja unos 0,026 m hacia un lado en cada paso, y a 120 Hz eso supone aproximadamente 3 m/s de deriva invisible. Solución: dividir la respuesta según la pendiente. En suelo transitable (
El tercer error inmovilizaba la cápsula en los límites entre fragmentos, porque las sondas de colisión muestreaban el SDF de un único fragmento y obtenían el valor centinela de «muy adentro del aire» cuando una sonda pasaba al fragmento vecino. La solución fue sdfSampleWorld(wx, wy, wz) y sdfGradientWorld(...), que encuentran el fragmento correcto para cualquier posición del mundo y recurren a una estimación de distancia basada en el mapa de alturas cuando no existe ningún SDF. La transición entre las colisiones SDF y las del mapa de alturas ahora es continua.
El agua completa el mundo
Abrir el experimento 32 en una pestaña nueva ↗ · Ver código fuente
Hasta este punto, todos los experimentos trataban de «tierra por encima del agua». El experimento 32 añadió un océano y, con él, una nueva acción de movimiento. Fijamos el nivel del agua en 22 dentro de un terreno que abarca aproximadamente de 8 a 58. Esto inunda los valles bajos, deja playas en la costa y conserva mucha tierra firme para jugar.
La superficie es un MeshStandardNodeMaterial construido con TSL, el mismo enfoque basado en nodos que utiliza el terreno. Tres ondas sinusoidales superpuestas de distintas frecuencias desplazan los vértices, y la normal de la superficie se obtiene a partir de las derivadas analíticas del coseno de esas ondas, en lugar de las normales de la malla. El color pasa de un turquesa de aguas poco profundas a un verde azulado oscuro en las zonas profundas mediante una estimación de profundidad
La natación utiliza un resorte de flotabilidad. El jugador entra en modo natación cuando los pies descienden por debajo del nivel del agua y el centro del cuerpo está a menos de la mitad de la altura de la cápsula con respecto a la superficie. Un resorte tira del cuerpo hacia un objetivo situado justo debajo de la superficie y, con una constante de flotabilidad de 12 frente a una amortiguación del agua de 4, el jugador flota de manera estable con la cabeza fuera, sin oscilaciones. La velocidad de nado es inferior a la de caminar, con una aceleración y una resistencia que transmiten sensación de flotación; saltar cerca de la superficie te impulsa hacia fuera al 60 % de la velocidad normal de salto, y la entrada limita la velocidad descendente a -5 m/s para evitar que te hundas de golpe. Las colisiones con el terreno siguen funcionando bajo el agua, de modo que puedes caminar por el lecho del lago donde este se eleva por encima del objetivo de natación. El indicador de natación se incluye en la emisión de posición para que los demás jugadores te vean nadar, y una superposición HTML con degradado tiñe la vista cuando la cámara se sumerge bajo la superficie.
Tecnología mencionada en este capítulo
Repetición determinista de parámetros del pincel. En lugar de transmitir el terreno editado, cada cliente envía únicamente los parámetros del pincel y repite la misma función en la CPU. Esto funciona porque tanto los pinceles del mapa de alturas como los SDF realizan operaciones matemáticas puras con Float32Array, sin aleatoriedad ni no determinismo de la GPU, por lo que entradas idénticas producen resultados idénticos bit a bit en todas partes. La carga útil es de 56 bytes por ciclo de pincelada. Las operaciones conmutativas (elevar y rebajar) convergen sin importar el orden, mientras que las operaciones que leen valores vecinos (suavizar y aplanar) necesitan un punto de serialización para garantizar la convergencia, algo que el Durable Object de producción proporciona mediante números de secuencia monotónicos.
BroadcastChannel como sustituto de WebSocket. Una API del navegador para enviar mensajes entre pestañas del mismo origen sin ningún servidor. Aquí se usa para probar el protocolo de sincronización de forma aislada de la latencia de red y la autenticación. El formato de serialización (un mapa de alturas Float32Array sin procesar, más los fragmentos SDF editados y los identificadores de los fragmentos fijados a MC) utiliza los mismos bytes para la persistencia en IndexedDB y la transferencia de estado al incorporarse tarde, de modo que un único formato cubre tres tareas.
Respuesta de colisión SDF dividida según la pendiente. Cuando una sonda de la cápsula penetra en terreno volumétrico, la solución ingenua empuja el cuerpo hacia fuera siguiendo el gradiente SDF y según la profundidad de penetración. En las pendientes, ese gradiente tiene componentes horizontales que introducen deriva lateral. Dividir la respuesta para que las superficies transitables (
Agua TSL con normales de onda analíticas. El océano utiliza un material de nodos cuyos vértices son desplazados por la suma de tres ondas sinusoidales. En lugar de volver a calcular las normales de la malla tras el desplazamiento, la normal de la superficie se obtiene analíticamente a partir de las derivadas del coseno de las funciones de onda, lo que resulta más barato y evita los artefactos de las normales por diferencias finitas en una cuadrícula de baja resolución. El color según la profundidad, la espuma de la costa y la transparencia según la profundidad parten de una única estimación de profundidad.
Natación mediante resorte de flotabilidad. Las físicas de natación modelan el cuerpo como un resorte amortiguado atraído hacia un objetivo situado justo debajo de la superficie. Con una constante de flotabilidad de 12 y una amortiguación de 4, el jugador se estabiliza en la superficie sin oscilar. Unas constantes de movimiento diferenciadas —menor velocidad, aceleración flotante y gran resistencia— hacen que nadar se sienta distinto de caminar, mientras que la colisión existente entre la cápsula y el terreno sigue funcionando bajo el agua.
Parte 15 de 29. Anterior: Parte 14 - El mundo cobra vida Siguiente: Parte 16 - Estructura para un mundo que no deja de crecer Guía de la serie: /es/blog/2026-02-25-open-world-browser-series-guide