Sistema de hielo - JRuizGil/MeltTheDeep GitHub Wiki
TDD · Documento técnico
Cada módulo guarda su hielo como un campo de densidad en la CPU: un NativeArray<byte> donde 0 es vacío y 255 es sólido. La superficie del hielo está en 128, el punto medio. Todo lo que está por encima cuenta como hielo y todo lo que está por debajo como aire o agua.
| Dato | Valor |
|---|---|
| Tamaño de voxel | 0,25 m |
| Tamaño de chunk | 16×16×16 voxels (4 m) |
| Tamaño de módulo | unos 16×6×16 m, es decir, 64×24×64 voxels |
| Puntos de densidad | 65×25×65 |
| Memoria del campo | unos 105 KB |
| Chunks por módulo | 4×2×4 (la segunda capa en vertical solo cubre media altura) |
El tamaño del vóxel está decidido en 0,25 m. Lo comparé con valores entre 0,125 y 0,5 m: por debajo de 0,2 m el número de puntos se dispara (cada vez que divido el vóxel a la mitad hay 8 veces más), y por encima de 0,33 m un túnel de 2 m queda con muy pocos vóxeles de ancho y el chorro funde a escalones. Con 0,25 m un chunk de 16 vóxeles mide 4 m exactos. El resto de cifras de la tabla siguen siendo valores iniciales.
A la densidad se suma un ushort por punto con el id de la región de cueva a la que pertenece. Vale 0 en el hielo macizo y no cuesta casi nada: unos 211 KB más por módulo.
Lo hago con Jobs y Burst en la CPU por tres razones. La primera es que puedo controlarlo y depurarlo: con un campo en un NativeArray puedo pintarlo en el editor, compararlo entre ejecuciones y probarlo con tests. La segunda es que las ediciones son locales, porque un chorro toca de decenas a unos pocos cientos de puntos y no hace falta lanzar un compute shader para eso. La tercera es que muestrear la densidad desde el código de juego, para saber si un objeto sigue atrapado o en qué región de cueva estoy, es una lectura directa de memoria y no pasa por una lectura de vuelta desde la GPU. Si el Profiler me demuestra que el mallado no cabe en su presupuesto, la GPU queda como optimización posterior.
Una cueva recién generada es aire dentro de un bloque de hielo y no debe verse ni colisionar. Para eso el mallado no usa la densidad cruda, sino una densidad efectiva: un punto que pertenece a una región de cueva todavía cerrada cuenta como sólido. Mientras la cueva está cerrada no produce malla ni collider. Cuando se abre, marco como sucios los chunks que toca y el siguiente remallado la deja a la vista. La apertura se explica en Generación de cuevas.
- En
FixedUpdate, lanzo un raycast desde la boquilla contra la capaIce. Si no golpea nada, no hay fundido. - Calculo la intensidad del golpe:
intensidad = potencia × temperatura × Δt / (dureza(profundidad) × freno(agua)). La dureza sale del perfil de la profundidad del punto y el freno sale de las bolitas acumuladas debajo (Bolitas y pistola). - Un job con Burst resta densidad en una esfera alrededor del punto de impacto, con caída suave (
smoothstep) del centro al borde, para que el agujero no tenga aristas. - El mismo job suma la densidad retirada. Un punto totalmente sólido equivale a un voxel de 0,25 m de lado, unos 15,6 L, y como 1 m³ son 1000 L, la suma se convierte en litros. Esos litros se los doy a
WaterBallPool, que los reparte en bolitas. - Marco como sucios los chunks que han cambiado.
- Si un punto tallado toca una región de cueva cerrada, el job lo anota y esa región se abre al terminar.
El mallado es mi propio marching cubes, ejecutado en un IJob con Burst. Como mucho remallo 2 chunks por fotograma, ordenados por distancia a la cámara, de modo que lo que miro se actualiza antes que lo que queda detrás. Escribo la malla con Mesh.AllocateWritableMeshData y la entrego con Mesh.ApplyAndDisposeWritableMeshData, que evita copiar los vértices por el camino.
Las normales no salen de las caras, sino del gradiente de la densidad, calculado con diferencias centrales. Así el sombreado es suave y los bordes de las celdas no se notan. Un chunk completamente vacío o completamente sólido no genera malla.
La tabla de triangulación es la de Paul Bourke. Es el único dato que copio tal cual y está citado abajo; el resto del algoritmo (recorrer celdas, interpolar vértices sobre la arista, construir la malla) es mío.
Cada chunk tiene un MeshCollider. Para no frenar el juego, el cocinado se hace fuera del hilo principal con Physics.BakeMesh dentro de un job. Cuando termina, asigno la malla cocinada al collider. Mientras tanto se queda el collider anterior, así que durante unos fotogramas la colisión va un poco por detrás de lo que se ve, pero nunca desaparece.
HardnessProfile es un AnimationCurve que ajusto en el editor. Los jobs no pueden usar un AnimationCurve, así que al arrancar el módulo lo muestreo en un array de valores y el job interpola sobre ese array.
No recuento el campo entero cada vez. Llevo un contador de puntos sólidos que actualiza el propio job de fundido con lo que cambia en cada golpe. Cuando queda un 10 % o menos, subo el parámetro de material _RemainingHighlight para que lo que falta se vea más opaco. Al llegar al 100 % se lanza el evento ModuleCompleted, que escuchan el ascensor, el audio y el guardado.
Los peces y los huesos que están dentro del hielo se quedan quietos como cuerpos cinemáticos. Cada cierto tiempo leo la densidad directamente en la posición del objeto, sin raycasts. Cuando la densidad a su alrededor baja de 128, lo suelto: pasa de cinemático a dinámico y la física se encarga de que caiga.
El hielo usa un Shader Graph con proyección triplanar, porque la malla no tiene UVs. Sumo un fresnel para el brillo del borde y coloreo por profundidad, de modo que cuanto más hondo, más oscuro y más azul. Las zonas recién fundidas se ven más oscuras y mojadas durante unos segundos. La dirección de arte general está en Interfaz, arte y sonido.
Los números de esta página (tamaño de voxel, chunks por fotograma, umbral del 90 %) son valores iniciales y los ajusto con las capturas del Profiler.
- Paul Bourke, "Polygonising a scalar field": paulbourke.net/geometry/polygonise. Fuente de la tabla de triangulación.
- Sebastian Lague, "Coding Adventure: Terraforming": youtu.be/vTMEdHcKgM4. Referencia de concepto, no de código.