Blueprint o C++ en Unreal: el coste real que medí en ms
La VM de Blueprint es invisible en casi cualquier escena, pero con decenas de miles de nodos por tick nos costó 7 ms. Este es el reparto híbrido, con números.
Cuando 41.000 nodos se ejecutan en un solo tick
En un proyecto de tower defence de finales de 2023, el tiempo de fotograma subía de 11,2 ms a 19,4 ms en cuanto arrancaba la oleada 14. En el editor todo parecía aceptable; en una build de Development empaquetada la diferencia era evidente. La línea superior en Unreal Insights era BlueprintTime, y ella sola se comía 7,1 ms.
El culpable era el Event Tick dentro de BP_TowerBase. Cada torre recorría los 220 enemigos con un ForEachLoop, calculaba la distancia al cuadrado y elegía al más cercano. Veinticuatro torres por 220 enemigos, con un puñado de operaciones por nodo, daban unas 41.000 ejecuciones de nodo por fotograma.
El problema no era que «Blueprint sea lento». El problema era ejecutar una búsqueda O(n·m) dentro de una máquina virtual en cada fotograma. Mover ese código a C++ sí reduce el coste, pero el error real era algorítmico, y en el debate Blueprint o C++ en Unreal esas dos cosas se mezclan constantemente.
Entonces éramos tres en el equipo y ninguno estaba perfilando el lado de Blueprint. A medida que el tiempo de fotograma empeoraba, miramos primero los draw calls, luego los ajustes de sombras y después las resoluciones de textura. Tras perder dos días, bastó con abrir Insights y leer la línea correcta. El resto de este artículo son las reglas que anoté para no repetir esos dos días.
Dónde el rendimiento de Blueprint se vuelve medible
Para tener un número hice una prueba simple: llamar 100.000 veces a una función vacía. En C++ el total fue de 0,4 ms; la misma función en Blueprint tardó 6,8 ms. Eso son unos 68 ns de sobrecoste por llamada.
68 ns parecen nada, y la mayor parte del tiempo lo son. Para un flujo de apertura de puerta de 200 nodos o la lógica de una pantalla de inventario, el coste se queda por debajo del ruido; por debajo de unos 2.000 nodos por fotograma el impacto total nunca superó los 0,15 ms en ninguna de mis mediciones.
El punto en el que deja de ser ruido está claro: decenas de miles de nodos por fotograma. El umbral aproximado que uso es este: si un Blueprint se ejecuta cada fotograma y contiene un bucle, esa lógica ya es candidata a C++. En flujos disparados por eventos puntuales, el sobrecoste de la VM ni siquiera merece discusión.
También hay una trampa de medición. En PIE las llamadas de Blueprint parecen más caras de lo que son por culpa de la instrumentación de depuración, así que decidir con un número del editor es engañoso. No muevo nada a C++ sin revisar antes una build de Development empaquetada con stat game e Insights.
Flujo híbrido: núcleo en C++, ajustes en Blueprint
La selección de objetivo, el cálculo de daño, el seguimiento de cooldown y la máquina de estados se movieron a AOFKTowerBase como C++. La búsqueda de objetivo ya no corre por fotograma: se ejecuta con un temporizador de 0,2 segundos sobre una rejilla espacial. Lo que se quedó en Blueprint: el timing de los efectos, los disparadores de audio, el feedback de UI y los números que el diseñador no deja de retocar.
El tiempo de fotograma pasó de 19,4 ms a 11,8 ms y BlueprintTime bajó de 7,1 ms a 0,9 ms. No todo vino de la VM: unos 5 ms corresponden al cambio de algoritmo y 2,6 ms al paso a código nativo. Sin ese desglose, decir «C++ lo hizo un 40 % más rápido» sería deshonesto.
Descartamos dos alternativas. Ir a C++ del todo era técnicamente lo más rápido, pero un diseñador que probaba un único valor de daño tenía que aguantar una compilación de 90 segundos y un reinicio del editor; para alguien que hace 40 pruebas al día eso es inasumible. Dejarlo todo en Blueprint y limitarse a desactivar el tick no servía de nada, porque no eliminaba el bucle que causaba el coste.
La pregunta que me hago al trazar la línea es simple: ¿cambiar este valor exige compilar? Si es así, y un diseñador lo toca varias veces por semana, ese valor pertenece a Blueprint o a un data asset. La tabla de balanceo de torres la guardamos como UDataAsset: C++ la lee, el diseñador la edita en el editor y nadie espera a una build.
Exponer la superficie correcta con UPROPERTY
La calidad de un montaje híbrido depende de qué expone exactamente C++ a Blueprint. Los números ajustables salen como UPROPERTY(EditAnywhere, BlueprintReadOnly): el diseñador puede cambiar el valor en el editor, pero no escribirlo en tiempo de ejecución. Añadir meta = (ClampMin, UIMax) elimina la mitad de los errores del tipo «alguien escribió 0 sin querer» antes de que lleguen a existir.
En el lado de las funciones, la diferencia entre tres especificadores importa. BlueprintCallable es Blueprint haciéndole una pregunta a C++; BlueprintImplementableEvent es C++ diciéndole a Blueprint «esto acaba de pasar, encárgate tú de cómo se ve»; BlueprintNativeEvent da una implementación por defecto en C++ que Blueprint puede sobrescribir cuando lo necesite. La regla es corta: C++ decide cuándo, Blueprint decide cómo se ve.
El error más común que veo es estampar BlueprintReadWrite en cada campo. Una vez abierto, los diseñadores empiezan a escribir en variables de estado y los invariantes que proteges en C++ se rompen en silencio; en nuestro caso salió a la luz como un contador de munición en negativo. La segunda trampa es renombrar: cambiar el nombre de una UPROPERTY rompe las referencias de Blueprint sin avisar, así que nunca renombramos un campo sin añadir una entrada de Core Redirects.
También hay un lado de tiempo de compilación. Tocar una UPROPERTY en una cabecera obliga a recompilar todo lo que incluye esa cabecera, lo que en nuestro caso llegaba a 4 minutos en una máquina decente. Agrupar los ajustes que cambian a menudo en una struct pequeña y aparte aceleró el ciclo del diseñador y redujo de forma visible cuántas recompilaciones completas hacíamos al día.
Qué cambió tras la retirada de la nativization
Por la época de 4.26 algunos equipos se apoyaban en la Blueprint nativization, y nosotros la probamos un tiempo. La ganancia medida en escenas pesadas rondaba 1,6 ms; a cambio, el empaquetado tardaba 18 minutos más y nos topamos con tres bugs que solo aparecían en builds nativizadas. UE5 eliminó la opción por completo.
La consecuencia práctica es que ya no existe una salida de emergencia tardía en la que el compilador te salve. Tener el camino caliente en C++ ya no es un paso de optimización, es una decisión de arquitectura que se toma al principio del proyecto. Tratar UE5 C++ como un parche que se añade después es justo lo que pilla a los equipos a mitad de producción.
También tiene su lado bueno. Mientras existió la nativization, la gente escribía lógica pesada en Blueprint y planeaba «ya lo nativizaremos», y ese momento no llegaba nunca. Al desaparecer la opción, trazar la frontera desde el principio se volvió obligatorio, y con el tiempo eso nos dejó un código más limpio.
Lo que pusimos en el lugar de la nativization fue medir. Antes de cada release capturamos una traza de Insights desde una build empaquetada, y cuando BlueprintTime pasa de 1 ms buscamos qué Blueprint es el responsable. La comprobación lleva quince minutos. En el último año ha pillado tres veces una regresión seria antes de salir a producción.
Conflictos de merge cuando el equipo crece
Con cuatro personas, que Blueprint fuera un asset binario no era un problema. Con once, dos o tres veces por semana dos personas tocaban el mismo .uasset, y un .uasset no se puede fusionar; quien perdía rehacía su trabajo. Calculamos el tiempo perdido así en un sprint en unos dos días.
Montamos checkout obligatorio y bloqueos exclusivos en Perforce. Los conflictos se acabaron y en su lugar llegó la espera: mientras un Blueprint está bloqueado, la segunda persona espera o cambia de tarea. C++ no tiene ese problema: el texto se fusiona línea a línea y de verdad se puede leer en un pull request. Revisar un Blueprint, en la práctica, consiste en mandar capturas de pantalla.
Hoy aplico cuatro reglas al desarrollo con Unreal Engine. Ninguna lógica que corra en cada fotograma se queda en Blueprint, y el tick está desactivado por defecto en cada Blueprint que creamos. Si un Blueprint pasa de 150 nodos, una parte de él sale a C++. Y si dos personas necesitan editar el mismo Blueprint dentro de un mismo sprint, esa lógica ya pertenece a C++.
Las reglas tienen tanto que ver con el ritmo del equipo como con el rendimiento. Usa Blueprint para la iteración rápida del diseñador y C++ para la columna vertebral del sistema, y traza esa línea antes de la primera línea de código. Mover después siempre sale más caro: nuestra factura fue una refactorización de tres semanas.