Construir un mundo abierto en el navegador, parte 18: Un pincel de dispersión que parece usar IA
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 17 proporcionó al jugador un conjunto de animaciones apto para el combate y una forma de incorporar al mundo cualquier modelo CC0. Esta parte vuelve al lado del creador. La paleta del Spike 34 coloca un objeto por clic, lo cual sirve para situar un objeto protagonista, pero no para crear un bosque. El Spike 37 es el pincel: arrastra sobre el terreno y los árboles llenarán los lugares donde deberían estar.
«Colocado por IA» sin una IA
Abrir el Spike 37 en una pestaña nueva ↗ · Ver el código fuente
La pregunta que responde este spike es si un pincel puramente heurístico resulta lo bastante inteligente como para prescindir del LLM. La prueba de que algo parece «colocado por IA» es concreta: los árboles se mantienen alejados de los acantilados, las rocas se inclinan siguiendo la pendiente y los guijarros de playa se detienen en la línea de agua, todo desde la primera pincelada. Lo conseguimos con predicados de pendiente y altitud, selecciones ponderadas y espaciado por familia, sin una sola llamada a un modelo.
El pincel funciona sobre un mapa de alturas de CPU de 257×257 con elementos ajustados a mano para que cada preajuste tenga algún lugar donde aplicarse: montañas al norte para las selecciones de pendientes mixtas, una franja de acantilados al este para los canchales, una llanura costera al sur para la playa y la pradera, y una cuenca lacustre al sudoeste. El terreno precalcula los colores de los vértices mediante un clasificador de biomas basado en (altitude, slope), de modo que antes de pintar un solo árbol ya puedes ver dónde se aplicará un preajuste. Se incluyen cinco preajustes como datos planos, cada uno con una lista de selecciones como { category, weight, slopeMin, slopeMax, altMin, altMax, minSpacing, alignToSlope }. Cliff and Scree establece slopeMin: 0.3 para que las rocas solo aparezcan en pendientes reales y alignToSlope: true para que el vector vertical de cada roca siga la normal de la superficie.
En cada pincelada, el motor de dispersión muestrea densityPerM2 × area puntos candidatos dentro del disco del pincel, lee la altura y la pendiente de cada candidato, filtra las selecciones del preajuste para conservar aquellas cuyos predicados se cumplen, elige una mediante selección ponderada y después comprueba el espaciado mediante un hash espacial dentro del radio. Todo es determinista: un generador aleatorio Mulberry32 con semilla controla cada selección, por lo que (seed, brush events) reproduce cualquier sesión con exactitud. En el terreno inicial, una pincelada de Mixed Forest sobre una pradera llana colocó 139 de 158 candidatos en 5 ms, mientras que el mismo preajuste sobre un acantilado solo colocó 106 de 226 y el HUD indicó que 81 habían sido rechazados por la pendiente. Ese desglose de rechazos constituye toda la experiencia de usuario: puedes ver por qué el acantilado solo aceptó unos pocos árboles en lugar de tener que adivinarlo.
La ventaja de mantener los preajustes como datos planos es que, cuando llegue la versión con LLM, bastará con sustituir un JSON en lugar de reescribir el sistema. A paint({ preset }) no le importa si preset.picks procede de una receta ajustada a mano o de un worker que convirtió «bosque caducifolio con rocas cubiertas de musgo» en ponderaciones. El motor tampoco codifica de forma fija ningún identificador de objeto, así que incorporar un catálogo distinto no requiere cambios en el motor.
De 300 llamadas de dibujo a 49
La primera versión renderizaba cada elemento colocado como un clone(true) de un grupo con varias mallas. Esto funciona bien con unos pocos cientos de objetos, pero se convierte en un límite al alcanzar el máximo de 2.500, cuando las llamadas de dibujo suben a varios miles. Antes de llegar a ese punto cambiamos a InstancedMesh, con un bucket por cada (propId, partIndex). Cada bucket crece duplicando su tamaño: se asigna un InstancedMesh mayor, se copian las matrices activas, se sustituye el padre de la escena y se libera el atributo anterior. El borrado usa intercambio con el último elemento, por lo que eliminar una instancia es O(1) independientemente del tamaño del bucket. El determinismo, el espaciado y el HUD de rechazos se mantienen sin cambios porque la sustitución ocurre por completo por debajo del registro de colocación.
Un diagnóstico del paquete MegaKit resolvió una cuestión arquitectónica real. Una malla glTF con varias primitivas —tronco y hojas— puede llegar a three.js como una sola malla con un array de materiales y geometry.groups, o como varias mallas hermanas independientes con un material cada una. Para este paquete, el cargador sigue la segunda ruta: cada parte es una malla de un solo material con los grupos vacíos. Esa estructura es mejor para la dispersión, porque tener buckets separados por primitiva permite que el bucket del tronco crezca de forma independiente al de las hojas si sus cantidades divergen. El número de llamadas de dibujo es el mismo en ambos casos, pero la separación ofrece una mejor distribución de memoria. La mejora medida se mantuvo: una pincelada de bosque que generaba unas 300 llamadas de dibujo pasó a 49, y una sesión completa con varias pinceladas alcanzó 3.221 instancias a 75 FPS con 51 llamadas de dibujo, un límite que la ruta de clonación nunca podría alcanzar antes de agotar el presupuesto por fotograma.
LOD por distancia y cuatro errores ocultos en él
El instanciado redujo las llamadas de dibujo, pero cada instancia seguía renderizando todos sus triángulos, incluso los árboles situados a 90 m que apenas aportaban dos píxeles de detalle en las hojas. Por eso precalculamos tres niveles de LOD por cada parte del objeto con meshoptimizer —completo, 50 % y 15 %—, ampliamos la clave del bucket a (propId, partIndex, lod) y añadimos un move() que traslada un elemento colocado entre buckets hermanos sin asignaciones. Las franjas de distancia son de 0 a 30 m, de 30 a 90 m y más allá, con ±4 m de histéresis alrededor de cada límite para que una cámara situada cerca del borde de una franja no mueva repetidamente un elemento de un lado a otro ni vuelva a subir su matriz en cada fotograma. La reevaluación está limitada a 4 Hz y solo se ejecuta si la cámara se ha movido realmente, por lo que una cámara inmóvil cuesta una sola comparación de distancia al cuadrado por fotograma.
En esa ruta de LOD se escondían los errores más instructivos. El primero se manifestó como elementos que desaparecían o se duplicaban al orbitar la cámara, y empeoraba a medida que se llenaba la escena. La causa era una matriz temporal compartida: move() leía la transformación de un elemento colocado en _tmpMat, definida en el ámbito del módulo, pero la eliminación mediante intercambio del bucket de origen usaba esa misma _tmpMat para su reorganización interna, sobrescribiendo la matriz transportada antes de que el destino pudiera escribirla. El error solo evitaba el caso en el que la posición trasladada ya era la última de su bucket, con una probabilidad aproximada de 1/count, lo que coincide exactamente con el «parpadeo poco frecuente que empeora a medida que crece la escena» observado durante las pruebas. La solución fue reservar una _carryMat exclusivamente para move(). Tras una prueba de estrés con 1.274 movimientos acumulados, el grupo se mantuvo idéntico píxel a píxel.
El segundo error era más sutil: todas las transiciones de LOD parecían fluidas excepto la primera. Los árboles que pasaban a LOD1 mostraban un cambio visible de sombreado aunque su silueta apenas variaba, mientras que reducciones mayores de triángulos en niveles posteriores se veían bien. El simplificador con LockBorder nunca mueve ni crea vértices, por lo que los vértices supervivientes conservan exactamente sus normales, pero aun así llamábamos a computeVertexNormals() después de cada simplificación. LOD0 devuelve intactas las normales originales creadas por el artista; LOD1 y los niveles posteriores recibían el recálculo genérico por promedio de caras de three.js. El límite entre 0 y 1 era el único punto de la escala donde cambiaba el régimen de normales, y por eso el salto aparecía allí. Eliminar esa única línea defensiva corrigió el sombreado y, como ventaja adicional, redujo aproximadamente a la mitad el tiempo de precálculo por objeto, porque dejamos de recalcular normales en cuatro LOD por cada parte.
La auditoría del resultado del simplificador reveló una tercera mejora. Cada LOD era un original.clone() con un índice nuevo, y BufferGeometry.clone() hace una copia profunda de todos los atributos, por lo que cinco LOD almacenaban cinco copias independientes de los búferes de posición, normales, UV y color cuyos valores eran idénticos bit a bit. Refactorizamos el sistema para compartir referencias a los atributos y mantener únicamente un búfer de índices privado por cada LOD, lo que redujo una parte de árbol típica de 20 identidades de atributos distintas a 9 y permitió subir cada búfer de vértices a la GPU una sola vez. El almacenamiento compartido impone dos condiciones: no modificar los datos de atributos desde ningún LOD individual y no ejecutar dispose() sobre la geometría de un solo LOD, ya que ambas acciones afectarían a todos los niveles hermanos que comparten el búfer.
El cuarto error no tenía nada que ver con pintar. El simple hecho de mover el cursor sobre el terreno reducía la velocidad de fotogramas, sin mantener pulsado ningún botón. El controlador pointermove lanzaba rayos contra la malla del terreno, un plano de 131.072 triángulos sin ninguna estructura espacial, por lo que three.js recorría todo el búfer de índices en cada evento, hasta 1.000 eventos por segundo. No necesitábamos la malla para esa consulta, porque el terreno es un mapa de alturas paramétrico. Una marcha de rayos adaptativa contra sampleHeight —pasos grandes muy por encima de la superficie, un mínimo de 0,4 m cerca de ella y después 12 bisecciones cuando cambia el signo— cuesta aproximadamente entre 8 y 30 muestras por rayo en lugar de 131.072 pruebas de triángulos, unas tres órdenes de magnitud menos, y al pasar el cursor vuelve a mantenerse el límite de fotogramas.
El coste solo se desplaza; asegúrate de sacarlo del clic
Después de cambiar el spike a WebGPURenderer en three r184 —el objetivo de producción—, un perfil de DevTools mostró que la primera pincelada bloqueaba durante 265 ms, con un 79 % del tiempo dentro del WASM de meshoptimizer. El precálculo realizaba trabajo real, unas 180 llamadas de simplificación para un preajuste en frío, pero se ejecutaba dentro del controlador del clic porque preloadProps solo descargaba y analizaba escenas, sin activar nunca el precálculo de LOD. La solución fue hacer que la selección del preajuste ejecutara el precálculo completo en segundo plano: ahora preloadProps llama a la ruta de resolución de partes, guarda en caché la promesa en curso para que un clic rápido se una a ella en lugar de iniciar un duplicado y memoriza el preprocesamiento por geometría que el simplificador repetía cuatro veces por cada parte. La primera pincelada bajó de 209 ms a 4 ms en el HUD. El tiempo del WASM no desapareció; simplemente salió de la ruta crítica del usuario y ahora se ejecuta mientras este observa el terreno y decide dónde pintar.
Esa es la lección recurrente de este spike. Casi ninguna de estas correcciones cambió lo que el pincel hace. Cambiaron cuándo se paga el coste: fuera del clic, fuera del movimiento del cursor y fuera del límite cerca del que se encuentra la cámara. Una herramienta de dispersión que parece instantánea no hace menos trabajo; hace el trabajo cuando el usuario no está esperándolo.
Tecnología mencionada en este capítulo
Dispersión heurística por idoneidad. Un pincel muestrea puntos candidatos dentro de un disco, lee (height, slope) por cada punto desde un mapa de alturas en la CPU, filtra las selecciones de un preajuste mediante predicados de pendiente y altitud, elige una de forma ponderada y la rechaza si infringe el espaciado mínimo por familia registrado en un hash espacial. Las selecciones alineadas con la pendiente giran su vector vertical para adaptarlo a la normal de la superficie. Esto genera una colocación que parece intencionada —árboles alejados de los acantilados, rocas inclinadas sobre las pendientes y guijarros que se detienen en la línea de agua— sin ponderaciones aprendidas, y mantiene el preajuste como datos planos para que una lista de selecciones generada por un LLM pueda sustituirla directamente.
Colocación determinista con cargas asíncronas. Un generador aleatorio Mulberry32 con semilla controla cada selección, por lo que (seed, brush events) reproduce una sesión con exactitud. Las extracciones aleatorias se producen antes de cualquier await, y las reservas de espaciado se insertan en el índice espacial antes de que se resuelva el clon de glTF, de modo que los candidatos concurrentes se respetan entre sí y la carga asíncrona de recursos no puede alterar la secuencia.
InstancedMesh organizados en buckets con ediciones O(1). Un InstancedMesh por cada (propId, partIndex, lod), cuya capacidad se duplica bajo demanda copiando las matrices activas en un búfer mayor. El borrado y la expulsión FIFO usan intercambio con el último elemento y corrigen en un array de referencias inversas el índice de la instancia trasladada, por lo que una eliminación es O(1) independientemente del tamaño del bucket. Un diagnóstico confirmó que las partes glTF llegan como mallas de un solo material, lo que convierte un bucket por primitiva en la ruta activa y proporciona a cada primitiva un bucket capaz de crecer de forma independiente.
LOD por distancia con histéresis y búferes de atributos compartidos. Tres niveles simplificados con meshopt por cada parte, seleccionados mediante franjas de distancia con ±4 m de histéresis para que una cámara cerca de un límite no alterne continuamente entre niveles, reevaluados a una frecuencia limitada y solo cuando existe movimiento real de la cámara. Como la simplificación con LockBorder nunca mueve los vértices, todos los LOD comparten un solo conjunto de búferes de posición, normales, UV y color, y solo se diferencian por su búfer de índices privado, lo que reduce aproximadamente a la mitad los búferes de vértices distintos en la GPU. Omitir un computeVertexNormals defensivo mantiene idénticas las normales creadas por el artista en todos los LOD y elimina la única discontinuidad de sombreado de la escala. Consulta LOD y meshoptimizer.
Lanzamiento de rayos analítico sobre el mapa de alturas para consultas de alta frecuencia. Una consulta del cursor a la frecuencia de pointermove contra la malla de un plano de 131.000 triángulos recorre todo el búfer de índices en cada evento. Sustituirla por una marcha de rayos adaptativa contra la función de altura analítica —pasos grandes lejos de la superficie, un mínimo pequeño cerca de ella y bisección cuando cambia el signo de
Sacar el trabajo de la ruta crítica de la interacción. El trabajo costoso que solo se realiza una vez —precálculos de LOD con meshopt, compilaciones de pipelines WGSL— debe ejecutarse durante los intervalos de inactividad, no dentro del controlador del clic. Precargar el cálculo completo del preajuste activo al seleccionarlo, almacenar en caché la promesa en curso para que un clic rápido se una a ella en lugar de iniciar otra y memorizar el preprocesamiento por geometría redujo la latencia de la primera pincelada de 209 ms a 4 ms sin disminuir el trabajo total realizado.
Parte 18 de 29. Anterior: Parte 17 - Animaciones que no necesitaron retargeting y una búsqueda de recursos en tiempo real Siguiente: Parte 19 - El impostor que tiene que sobrevivir a un bosque Guía de la serie: /es/blog/2026-02-25-open-world-browser-series-guide