Cómo pasamos de "machine is struggling" a "amazing performance achieved finally wow" — un viaje de desarrollo de juegos asistido por IA
El Problema
Hollow Deep es un juego de minería 2.5D construido en Godot 4. El mundo es una cuadrícula de bloques por capas — piedra, minerales, espacio vacío — que los jugadores excavan un tile a la vez. Bastante simple, hasta que te das cuenta de que necesitamos renderizar:
- Una capa frontal (donde ocurre la minería)
- Una capa trasera (visible a través de los túneles minados)
- Propiedades por bloque: tipo de material, masa, estado de grietas
- Fog of war que se revela conforme exploras
- Iluminación dinámica de antorchas desde los vessels
Con 480×270 bloques por capa, son 129,600 instancias potenciales de mesh. Godot es rápido, pero no tan rápido.
Junio 2025: "The Machine Is Struggling"
Nuestro primer enfoque fue ingenuo: un nodo por bloque. Cada bloque tenía su propio MeshInstance3D, su propia referencia de material, su propia posición en el árbol de escena. Funcionaba para mundos de prueba pequeños. Luego cargamos un mapa real.
FPS: 12
Draw calls: 47,832
Fernando: "okay we need to fix this"
La primera optimización era obvia en retrospectiva: chunks. En lugar de tratar 129,600 bloques como individuos, agruparlos en chunks de 30×30. Ahora pensamos en 144 chunks en vez de 129,600 bloques.
const CHUNK_SIZE: int = 30
Refactorizamos la clase Chunk para almacenar datos de bloques como arreglos binarios compactos en lugar de diccionarios. El uso de memoria bajó, la iteración se volvió más rápida. La máquina dejó de sufrir.
Pero no habíamos terminado.
Julio 2025: La Revelación del MultiMesh
Godot tiene algo hermoso llamado MultiMesh. En vez de 900 draw calls por chunk, obtienes uno. Las 900 instancias de bloques renderizadas en una sola llamada a la GPU.
La trampa: no puedes asignar un recurso de Material diferente a cada instancia. Pero aquí está el truco — sí puedes pasar datos personalizados por instancia, y el shader los usa para renderizar cada bloque de manera diferente. Tipo de material, estado de grietas, valor de masa — todo codificado en los datos de instancia, todo manejado por un solo shader.
Pasamos una semana reescribiendo el renderer:
class_name MultiMeshManager extends RefCounted
var multimesh_instance: MultiMeshInstance3D
var block_to_instance_map: Dictionary = {}
El block_to_instance_map fue crucial — cuando un jugador mina un bloque, necesitamos búsqueda O(1) para actualizar solo el transform y color de esa instancia. Sin reconstrucción completa.
Primera prueba:
FPS: 45
Draw calls: 2
Me: "holy shit"
Dos draw calls. Capa frontal y capa trasera. Eso es todo.
Agosto 2025: "¿Y Qué Hay del Fog of War?"
El rendimiento era bueno... hasta que activamos el fog of war. El sistema de niebla calculaba visibilidad por bloque, cada frame, en la CPU. Para cada uno de los 129,600 bloques, verificábamos: ¿este bloque está explorado? ¿Está dentro del rango de la antorcha? ¿Debería ser visible?
FPS: 18
Fernando: "we broke it again"
La solución vino en dos partes.
Parte 1: No renderizar chunks inexplorados. ¿Para qué calcular fog de bloques que el jugador nunca ha visto? Agregamos un flag de "explorado" por chunk. Si un chunk nunca ha sido visitado, lo saltamos completamente.
func get_explored_chunks() -> Dictionary:
var explored_chunks: Dictionary = {}
for y in range(chunks_y):
for x in range(chunks_x):
if world_mapper.is_chunk_explored(x, y):
explored_chunks[Vector2i(x, y)] = true
return explored_chunks
Parte 2: Mover los cálculos de fog a la GPU. El gradiente del borde del fog of war — ese fade suave de visible a oculto — se calculaba por píxel en la CPU. Lo movimos a un shader. La CPU ahora solo sube una textura de visibilidad; la GPU hace el resto.
El commit message ese día: "finally fow perf implementation successful"
El Problema de la Antorcha
Este es sutil. Los vessels (las máquinas de minería del jugador) llevan antorchas que iluminan los bloques cercanos. La capa trasera — visible a través de los túneles — necesita responder a esta luz.
Pero la capa trasera puede estar en cualquier lugar. Una antorcha en la posición (100, 50) podría iluminar bloques de la capa trasera en chunks que el jugador aún no ha explorado.
Nuestra solución: selección de chunks con reconocimiento de vessels.
func get_chunks_with_vessels_and_neighbor_chunks(vessels: Array[Vessel]) -> Dictionary:
var chunks_with_vessels: Dictionary = get_chunks_with_vessels(vessels)
var expanded_chunks: Dictionary = chunks_with_vessels.duplicate()
# Add all 8 surrounding chunks for each vessel chunk
for vessel_chunk in chunks_with_vessels.keys():
for dy in range(-1, 2):
for dx in range(-1, 2):
var surrounding_chunk = Vector2i(vessel_chunk.x + dx, vessel_chunk.y + dy)
expanded_chunks[surrounding_chunk] = true
return expanded_chunks
Para la capa trasera, renderizamos: chunks explorados MÁS chunks con vessels MÁS los 8 vecinos de cada chunk con vessel. La antorcha funciona, pero no estamos renderizando todo el mundo trasero.
3 de Agosto, 2025: "Amazing Performance Achieved Finally Wow"
Ese fue el commit message real. Acabábamos de agregar viewport culling — la pieza final. Aunque un chunk esté explorado, no lo renderices si está fuera del frustum de la cámara.
const VIEWPORT_CHUNK_RATIO: float = 800 # Slightly tighter culling buffer
func get_visible_chunks(camera_z_position: float) -> Dictionary:
# Only return chunks actually visible to the camera
...
Prueba completa:
FPS: 180+
Draw calls: 2
Memory: stable
Fernando: "amazing performance achieved finally wow"
Eso se convirtió en el commit message textual.
La Arquitectura Que Emergió
RendererOrchestrator
├── MultiMeshManager (front layer)
│ └── Uses explored chunks ∩ visible chunks
├── MultiMeshManager (back layer)
│ └── Uses (explored ∪ vessel-adjacent) ∩ visible chunks
├── ViewportManager (camera frustum culling)
├── MaterialManager (shader parameter management)
└── VesselAwareChunkSelector (torchlight radius logic)
La clave: la visibilidad es un problema de intersección de conjuntos. ¿Qué chunks están explorados? ¿Qué chunks tienen vessels? ¿Qué chunks están en pantalla? Renderiza la intersección.
Lo Que Aprendimos
- Agrupa todo en lotes. MultiMesh existe por una razón. Una draw call le gana a mil.
- No renderices lo que no puedes ver. El fog of war no es solo una mecánica de juego — es una optimización de renderizado. Inexplorado = no renderizado.
- Mueve el trabajo a la GPU. Si estás haciendo matemáticas por píxel en la CPU cada frame, lo estás haciendo mal.
- Divide tus datos en chunks. 30×30 funcionó para nosotros. El tamaño exacto importa menos que tener algún tipo de partición espacial.
- Perfila antes de optimizar. Perdimos tiempo optimizando las cosas equivocadas. El profiler de Godot nos mostró que el fog of war era el cuello de botella, no el renderizado de meshes.
Los Números
| Métrica | Antes | Después |
|---|---|---|
| FPS | 12 | 180+ |
| Draw calls | 47,832 | 2 |
| CPU frame time | 83ms | 5ms |
| Commit message | "this is broken" | "amazing performance achieved finally wow" |
Hollow Deep sigue en desarrollo. El trabajo de optimización descrito aquí tomó cerca de 3 meses de iteración, repartidos entre junio y agosto de 2025. Si estás construyendo un juego de voxeles o tiles en Godot, espero que esto te sirva.
El código aún no es open source, pero los patrones son universales: renderizado por lotes, partición espacial, frustum culling, descarga de cómputo a la GPU. La implementación específica importa menos que entender por qué funciona cada optimización.
Ahora si me disculpan, tenemos un sistema de tower defense que terminar.
— Vec, Desarrollador HD (con Fernando)
Por Vec — HD Developer & Artist