Construir un mundo abierto en el navegador, parte 13: Esculpido del terreno y la muerte de la función matemática
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.
Durante doce partes construimos un motor de terreno que se podía contemplar. Sobrevolarlo. Admirar cómo no se abrían las uniones. Esta vez queríamos tocarlo.
El objetivo parecía sencillo: permitir que un jugador esculpiera el terreno con un pincel, en tiempo real, sin romper ninguno de los sistemas que habíamos tardado 24 spikes en construir. Hicieron falta tres intentos, dos errores que parecían fallos de renderizado pero que en realidad eran fallos del modelo de datos, y un replanteamiento fundamental de cómo debían funcionar los datos del terreno.
Tres intentos fallidos
Se suponía que el Spike 25 iba a ser el fácil. La base de código de producción ya tenía un trazador de rayos que detectaba las mallas del terreno. La herramienta de colocación lo usa para soltar objetos. Una herramienta de pincel sigue el mismo patrón, solo que modifica valores del mapa de alturas en lugar de instanciar un prefab. Sencillo.
Primer intento: lo construí directamente en el código TypeScript de producción de world/client/. Un nuevo terrain-brush.ts, modificaciones en chunk.ts, cambios en el protocolo y actualizaciones de componentes Vue. Al cabo de una hora tenía un pincel que más o menos funcionaba, pero los límites entre chunks mostraban discontinuidades visibles en las normales. No podía saber si el error estaba en el código del pincel, en el cosido de chunks existente o en alguna interacción con el bucle de renderizado completo. Esa es exactamente la situación que la metodología de spikes pretende evitar. Me había saltado la regla y pagué por ello de inmediato. Revertí todos los cambios.
Segundo intento: un spike independiente, pero recurrí a Three.js 0.170.0 y WebGL. El código de producción usa WebGL, así que parecía lo natural. Pero los Spikes 13-24 ya habían migrado todos a WebGPU. Crear un spike de pincel con WebGL demostraría que funcionaba con el renderizador antiguo, no con aquel hacia el que estamos migrando. Dirección equivocada. Empecé de nuevo.
Tercer intento: WebGPU, WebGPURenderer, shader de cómputo para generar vértices y la misma pila tecnológica que el Spike 22. Esta vez la arquitectura era correcta. Cinco operaciones de pincel funcionando: elevar, bajar, suavizar, aplanar y añadir ruido. P95 del ciclo del pincel por debajo de 4 ms en un M1.
Y el error de las uniones seguía ahí.
Abrir el Spike 25 en una pestaña nueva ↗ · Ver el código fuente
El error de las uniones que se negaba a morir
La solución habitual para calcular normales entre chunks es solapar los bordes: cada chunk almacena un anillo adicional de datos de sus vecinos para que el cálculo de la normal en el límite pueda muestrear ambos lados. Lo implementé. Copié los datos de los bordes vecinos en un búfer ampliado. Las uniones seguían abriéndose.
Me sumergí en las matemáticas. En el límite entre el chunk A (cx=-1) y el chunk B (cx=0), ambos chunks deben calcular la misma normal en el vértice compartido. El shader del chunk A muestreaba mix(own_col31, own_col32, 0.85). El del chunk B muestreaba mix(neighbor_edge, own_col0, 0.85). Son rutas de interpolación bilineal distintas a través de datos diferentes. Incluso con datos de borde correctos, los dos chunks calculan normales distintas para el mismo punto.
Fue entonces cuando comprendí que copiar el borde no era el verdadero error. El verdadero error estaba en el modelo de datos.
Todos los spikes del 1 al 24 usaban una función matemática procedimental llamada height_at(). Le pasabas coordenadas del mundo y devolvía una altura. Limpia, global y sin estado. El pincel no podía modificar una función matemática, así que había añadido encima un búfer de displacement. El terreno era ahora height_at(x,z) + displacement[i]. El shader de GPU contenía 30 líneas de funciones de ruido para el terreno base, además del código de interpolación bilineal para la capa de desplazamiento. El pincel de aplanado tenía que restar height_at() para averiguar qué valor de desplazamiento produciría la altura objetivo. Dos sistemas superpuestos, calculando cosas distintas con estrategias de muestreo diferentes.
Nada de esto es propio de un juego real. En producción, el terreno creado se representa mediante datos muestreados almacenados en búferes. La función procedimental era un sustituto práctico de los primeros spikes. Había cumplido su propósito. Ahora estaba provocando errores activamente.
La eliminé.
Ahora cada chunk posee un heightmap Float32Array con valores de altura reales. Al crearlo, se rellena con ruido procedimental. Después, la función de ruido no vuelve a llamarse. El pincel modifica directamente las alturas almacenadas. El shader de GPU lee de un único búfer mediante una única función: hm_at(i,j). Las normales usan diferencias centrales alineadas con la cuadrícula sobre los mismos datos. Sin ambigüedad en la interpolación bilineal. Sin incompatibilidad entre dos sistemas. El shader pasó de 90 líneas a 40.
Las uniones se arreglaron solas. Ahora, en un borde compartido, ambos chunks leen los mismos valores discretos de altura de sus respectivos búferes, con los puntos interiores correctos del vecino en el solapamiento del borde. Los mismos datos de entrada producen las mismas normales.
Esta no fue una lección sobre pinceles. Fue una lección sobre arquitectura de datos que el pincel sacó a la luz.
La malla que explotó
El Spike 26 era el equivalente volumétrico. Modificar con un pincel un volumen SDF de 64 al cubo y volver a generar la malla con marching cubes. La misma pregunta que en el Spike 25, pero en 3D.
La primera vez que lo ejecuté, la malla explotó. Largas púas salían disparadas en todas direcciones, como un erizo de mar teniendo un mal día.
Abrir el Spike 26 en una pestaña nueva ↗ · Ver el código fuente
La tabla de casos de MC que había generado tenía 3840 entradas en vez de 4096. La tabla completa es de
queda dentro de
La solución fue absurdamente sencilla: copiar byte por byte la tabla probada del Spike 12. Lección aprendida. Nunca regeneres una tabla de consulta cuando ya existe una copia probada.
El segundo error era más sutil. Se suponía que el pincel de suavizado debía suavizar las formas del terreno. En cambio, creaba pliegues pronunciados. El problema era que yo atraía todos los valores SDF hacia cero, es decir, hacia la isosuperficie. Parece que eso debería suavizar las cosas, pero en realidad colapsa el campo de distancias. Los vóxeles por encima y por debajo de la superficie se precipitan hacia cero, aplanándolo todo dentro del radio del pincel. En el límite, los vóxeles suavizados se encuentran con los no suavizados formando un escalón abrupto. El pincel de «suavizado» era un generador de pliegues.
La solución fue aplicar un suavizado laplaciano adecuado. En lugar de atraer cada valor hacia cero, hay que llevarlo hacia el promedio de sus 6 vecinos directos:
El término entre paréntesis es un laplaciano discreto y
Todo a la vez
El Spike 27 era la prueba de integración. Había que tomar la canalización completa del Spike 24 —parches de mapa de alturas, chunks MC, uniones Transvoxel y LOD con geomorfismo— y combinarla con el modelo de datos muestreados del Spike 25 y ambos tipos de pincel.
Abrir el Spike 27 en una pestaña nueva ↗ · Ver el código fuente
Lo primero que hice fue arrancar height_at() de todos los shaders. Los tres shaders de cómputo —relleno de SDF, parche de mapa de alturas y unión Transvoxel— ahora enlazan el mismo búfer de GPU de mapa de alturas de 129x129 y usan la misma función de interpolación bilineal hm_sample() mediante un preámbulo WGSL compartido. Una fuente de datos, múltiples consumidores. Las funciones de ruido procedimental que habían vivido en todos los shaders desde el Spike 1 desaparecieron.
Entonces empezaron los problemas interesantes.
Cuando un pincel SDF bloquea un chunk en modo MC, la unión Transvoxel entre ese chunk y su vecino de mapa de alturas debe muestrear el volumen SDF, no el mapa de alturas. Amplié el shader de uniones con enlaces adicionales a búferes de almacenamiento e indicadores MC por chunk. Había que gestionar cuatro combinaciones de límites: HM-HM, HM-MC, MC-HM y MC-MC.
El LOD era otro rompecabezas. En spikes anteriores, cambiar un chunk MC a un LOD inferior implicaba volver a rellenar el SDF con una resolución más baja. Lo sustituí por un muestreo basado en el paso: los datos SDF permanecen a resolución completa, con 65 puntos de cuadrícula. El shader MC calcula un paso a partir de la relación entre el tamaño de la cuadrícula y el número de celdas. En LOD0, el paso es 1. En LOD1, el paso es 2 y se muestrea un vóxel de cada dos. Los chunks pueden cambiar libremente de LOD sin tocar sus datos SDF.
La solución más satisfactoria fue la creación dinámica de chunks verticales. Si esculpes hacia arriba más allá de la parte superior de un chunk, aparece encima un nuevo chunk exclusivo de MC cuyo SDF se inicializa a partir de la cara limítrofe del chunk inferior. Si esculpes hacia abajo, ocurre lo mismo. El mundo crece para adaptarse a las modificaciones.
El último escollo fue que el pincel de mapa de alturas no hacía absolutamente nada en los chunks bloqueados en MC. El pincel HM modifica heightmapCPU y vuelve a subirlo. Los chunks MC ya no leen del mapa de alturas porque su SDF se rellenó a partir de él y después divergió. Añadí syncHeightmapToSdf(): después de cambiar el mapa de alturas, se vuelven a derivar las columnas SDF de todos los chunks MC dentro del radio del pincel y se suben los nuevos valores. Ahora ambos tipos de pincel funcionan en ambos tipos de chunk.
Lo que aprendimos realmente
Se suponía que los spikes de pinceles debían responder a una pregunta de rendimiento: ¿puede ejecutarse el esculpido sin superar el presupuesto por fotograma? Sí puede. Esa fue la parte fácil.
La parte difícil fue descubrir que 24 spikes usando height_at() como verdad del terreno habían creado una dependencia invisible que se rompió en cuanto intentamos editar algo. La función procedimental era limpia, global y sin estado, justo hasta que dejó de representar el terreno.
Estas son las reglas que anotamos y que no olvidaremos:
- La altura del terreno procede de datos muestreados. Los chunks son propietarios de sus búferes.
- La generación procedimental rellena los datos iniciales. No es la fuente de verdad en tiempo de ejecución.
- El pincel modifica directamente los datos del chunk. Sin capas de desplazamiento.
- Las normales proceden de los mismos datos mediante diferencias centrales alineadas con la cuadrícula.
- El solapamiento de bordes —1 celda del interior del vecino— permite calcular las normales entre chunks.
- Nunca regeneres una tabla de consulta cuando ya existe una copia probada.
En la parte 14 dejamos de esculpir geometría de depuración y empezamos a hacer que parezca y se sienta como un lugar real.
Tecnología mencionada en este capítulo
Arquitectura de mapas de alturas muestreados. El terreno se almacena como datos propios de cada chunk, en lugar de evaluarse mediante una función procedimental en tiempo de ejecución. Cada chunk contiene un Float32Array con valores de altura reales. El ruido procedimental rellena los datos iniciales al crearlo y después la función no vuelve a llamarse. Esto elimina la incompatibilidad entre el terreno matemático y las capas de edición, simplifica las operaciones del pincel —se editan directamente los valores almacenados— y hace que el shader de GPU sea trivialmente sencillo: leer del búfer y calcular las normales mediante diferencias centrales alineadas con la cuadrícula. Para un mundo abierto con streaming, el enfoque estándar es que cada chunk sea propietario de sus datos y tenga un solapamiento de 1 celda procedente de sus vecinos. Consulta nuestra guía de generación de paisajes.
Operaciones de pincel SDF. Modificación de un campo de distancias con signo para esculpir el terreno. Sumar —inflar— utiliza una atenuación smooth-step alrededor de una esfera. Restar —excavar— usa la misma forma negada. Suavizar usa un promedio laplaciano: se leen los 6 vecinos directos, se calcula su media y se lleva el valor hacia ella. El enfoque ingenuo de atraer los valores hacia cero colapsa el campo de distancias y crea bordes pronunciados. El suavizado laplaciano preserva el gradiente del campo a la vez que suaviza las formas. Consulta la representación de terreno mediante SDF.
Transvoxel con fuentes de datos mixtas. Las celdas de transición situadas en el límite entre un chunk MC y uno de mapa de alturas necesitan muestrear datos diferentes en cada lado. El shader de uniones incluye indicadores por chunk y enlaces a búferes para gestionar las cuatro combinaciones: HM-HM, HM-MC, MC-HM y MC-MC. Cuando un lado está bloqueado en MC, el shader interpola trilinealmente el búfer SDF en lugar de muestrear el mapa de alturas. LOD basado en pasos para marching cubes. Los datos SDF se almacenan a resolución completa independientemente del nivel de LOD actual del chunk. El shader de MC calcula un paso de muestreo a partir de la proporción entre los puntos de la cuadrícula SDF y las celdas de MC. A resolución completa, el paso es 1; a media resolución, es 2. Esto desacopla los datos SDF de los cambios de LOD, por lo que los chunks pueden cambiar libremente de LOD sin tener que reconstruir el SDF.
Parte 13 de 14. Anterior: Parte 12 - Anillos, niebla del cielo y lo que volveríamos a hacer Siguiente: Parte 14 - El mundo cobra vida Guía de la serie: /es/blog/2026-02-25-open-world-browser-series-guide