En el nivel puede haber miles de enemigos.Defender's Quest: Valley of the Forgotten DX siempre ha tenido problemas con la velocidad desde hace mucho tiempo, y finalmente logré resolverlos. El principal incentivo para un aumento masivo de la velocidad fue nuestro
puerto en PlayStation Vita . El juego ya se lanzó en la PC y funcionó bien, si no perfectamente, en la
Xbox One con la
PS4 . Pero sin una mejora importante en el juego, nunca podríamos lanzarlo en Vita.
Cuando un juego se ralentiza, los comentaristas en Internet suelen culpar a un lenguaje de programación o motor. Es cierto que lenguajes como C # y Java son más caros que C y C ++, y herramientas como Unity tienen problemas irresolubles, como la recolección de basura. De hecho, a la gente se le ocurren tales explicaciones porque el lenguaje y el motor son las propiedades más obvias del software. Pero los verdaderos asesinos del rendimiento pueden ser pequeños detalles estúpidos que no tienen nada que ver con la arquitectura.
0. Herramientas de perfilado
Solo hay una forma real de hacer que el juego sea más rápido: realizar perfiles. Averigüe en qué pasa demasiado tiempo la computadora y haga que pase menos tiempo en ella, o incluso mejor, que no pierda tiempo
en absoluto .
La herramienta de creación de perfiles más simple es el monitor de sistema estándar de Windows (monitor de rendimiento):
De hecho, esta es una herramienta bastante flexible y es muy fácil trabajar con ella. Simplemente presione Ctrl + Alt + Supr, abra el "Administrador de tareas" y haga clic en la pestaña "Rendimiento". Sin embargo, no ejecute muchos otros programas. Si observa de cerca, puede detectar fácilmente picos en el uso de la CPU e incluso pérdidas de memoria. Esta es una forma poco informativa, pero puede ser el primer paso para encontrar lugares lentos.
Defender's Quest está escrito en el lenguaje
Haxe de alto nivel compilado en otros idiomas (mi objetivo principal era C ++). Esto significa que cualquier herramienta capaz de perfilar C ++ también puede perfilar mi código C ++ generado por Haxe. Entonces, cuando quería entender las causas de los problemas, lancé Performance Explorer desde Visual Studio:
Además, las diferentes consolas tienen sus propias herramientas de creación de perfiles, lo cual es muy conveniente, pero debido a la NDA no puedo decirle nada sobre ellas. Pero si tienes acceso a ellos, ¡pero asegúrate de usarlos!
En lugar de escribir un terrible tutorial sobre cómo usar herramientas de creación de perfiles como Performance Explorer, solo dejo un enlace a la
documentación oficial y paso al tema principal: cosas sorprendentes que llevaron a un gran aumento de la productividad y cómo logré encontrarlas !
1. Detección de problemas
El rendimiento del juego no es solo la velocidad en sí misma, sino también su percepción. Defender's Quest es un juego de género de defensa de torre que se renderiza a 60 FPS, pero con una velocidad de juego variable en el rango de 1 / 4x a 16x. Independientemente de la velocidad del juego, la simulación utiliza una
marca de tiempo fija con 60 actualizaciones por segundo de 1x tiempo de simulación. Es decir, si ejecuta el juego a una velocidad de 16x, entonces la lógica de actualización realmente funcionará con una frecuencia de
960 FPS . Honestamente, ¡estas son solicitudes demasiado altas para el juego! Pero fui yo quien creó este modo, y si resulta ser lento, los jugadores definitivamente lo notarán.
Y en el juego hay
tal nivel:
Esta es la batalla de bonificación final "Endless 2", también es "mi pesadilla personal". La captura de pantalla se tomó en el modo New Game +, en el que los enemigos no solo son mucho más fuertes, sino que también tienen características como restaurar la salud. La estrategia favorita del jugador aquí es bombear a los dragones al máximo nivel de Rugido (ataque AOE que aturde a los enemigos), y detrás de ellos poner una cantidad de caballeros con Knockback bombeado al máximo para empujar a todos los que pasen los dragones de regreso a su área de acción. El efecto acumulativo es que un gran grupo de monstruos permanece sin parar en un lugar, mucho más tiempo del que tendrían que sobrevivir los jugadores si realmente los mataran. Dado que los jugadores deben
esperar olas y no
matarlas para recibir recompensas y logros, esta estrategia es absolutamente efectiva y brillante: este es exactamente el comportamiento de los jugadores que estimulé.
Desafortunadamente, esto también resulta ser un caso
patológico para el rendimiento,
especialmente cuando los jugadores quieren jugar a velocidades de 16x u 8x. Por supuesto, solo los jugadores más hardcore tratarán de obtener el logro "Hundredth Wave" en New Game + en el nivel Endless 2, pero son solo los que hablan el juego más fuerte, así que quería que fueran felices.
Es solo un juego en 2D con un montón de sprites, ¿qué podría estar mal?
Y de hecho. Vamos a hacerlo bien.
2. Resolución de colisión
Echa un vistazo a esta captura de pantalla:
¿Ves este bagel alrededor del guardabosques? Esta es su área de impacto: tenga en cuenta que también hay una zona muerta en la que no
puede alcanzar objetivos. Cada clase tiene su propia área de ataque, y cada defensor tiene un área de diferente tamaño, dependiendo del nivel de impulso y los parámetros personales. Y cada defensor en teoría puede apuntar a cualquier enemigo en el campo de su alcance. Lo mismo es cierto para ciertos tipos de enemigos. Puede haber hasta 36 defensores en el mapa (sin incluir el personaje principal Azru), pero no hay un límite superior en el número de enemigos. Cada defensor y enemigo tiene una lista de posibles objetivos, creada sobre la base de llamadas para verificar el área en cada paso de actualización (menos el corte lógico de aquellos que no pueden atacar en este momento, y así sucesivamente).
Hoy en día, los procesadores de video son muy rápidos: si no los aplica demasiado, pueden procesar casi cualquier número de polígonos. Pero incluso las CPU más rápidas tienen fácilmente "cuellos de botella" en procedimientos simples, especialmente aquellos que crecen exponencialmente. Es por eso que un juego 2D puede resultar más lento que un juego 3D mucho más hermoso, no porque el programador no pueda hacer frente (tal vez esto también sea, al menos en mi caso), sino en principio porque la lógica a veces puede ser más costosa, que dibujar! La pregunta no es cuántos objetos hay en la pantalla, sino qué
hacen .
Exploremos y agilicemos el reconocimiento de colisiones. A modo de comparación, diré que antes de la optimización, el reconocimiento de colisión tomó hasta ~ 50% del tiempo de CPU en el ciclo de batalla principal. Después de la optimización, menos del 5%.
Se trata de árboles cuadrantes
La principal solución al problema del reconocimiento de colisión lenta es
dividir el espacio , y desde el principio utilizamos una implementación
de alta calidad
del árbol de cuadrante . Esencialmente, separa efectivamente el espacio para que se puedan omitir muchas comprobaciones de colisión opcionales.
En cada cuadro, actualizamos todo el árbol de cuadrantes (QuadTree) para rastrear la posición de cada objeto, y cuando el enemigo o el defensor quiere apuntar a alguien, le pide a QuadTree una lista de objetos cercanos. Pero el generador de perfiles nos dijo que ambas operaciones son mucho más lentas de lo que deberían ser.
¿Qué está mal aquí?
Al final resultó que, mucho.
Mecanografía
Como mantuve a los enemigos y defensores en un árbol de cuadrante, tuve que indicar lo que estaba buscando, y esto se hizo así:
var things:Array<XY> = _qtree.queryRange(zone.bounds, "e"); //"e" - "enemy"
En la jerga de los programadores, esto se llama código de
escritura de cadenas y, entre otras razones, es malo porque las comparaciones de cadenas siempre son más lentas que las enteras.
Rápidamente recogí constantes enteras y reemplacé el código con esto:
var things:Array<XY> = _qtree.queryRange(zone.bounds, QuadTree.ENEMY);
(Sí, probablemente valió la pena usar
Enum Abstract para la máxima seguridad de tipografía, pero tenía prisa y primero necesitaba hacer el trabajo).
Este cambio solo hizo una
gran contribución, porque esta función se llama de forma
constante y recursiva, cada vez que alguien necesita una nueva lista de objetivos.
Matriz vs Vector
Mira esto:
var things:Array<XY>
Las matrices Haxe son muy similares a las matrices ActionScript y JS en que son colecciones de objetos redimensionables, pero en Haxe están fuertemente tipadas.
Sin embargo, existe otra estructura de datos que es más eficiente con lenguajes estáticos de destino como cpp, es decir,
haxe.ds.Vector . Los vectores haxe son esencialmente lo mismo que las matrices, excepto que cuando se crean obtienen un tamaño fijo.
Como mis árboles de cuadrante ya tenían un volumen fijo, reemplacé las matrices con vectores para lograr un aumento notable de la velocidad.
Solicita solo lo que necesitas
Anteriormente, mi función
queryRange
devolvía una lista de objetos, instancias
XY
. Contenían las coordenadas x / y del objeto de juego referenciado y su identificador entero único (índice de búsqueda en la matriz principal). El objeto del juego que ejecutaba la solicitud recibió estos XY, extrajo un identificador entero para obtener su objetivo y luego se olvidó del resto.
Entonces, ¿por qué debería pasar todas estas referencias a objetos XY para cada nodo QuadTree de forma
recursiva , e incluso
960 veces por cuadro? Es suficiente para mí devolver una lista de identificadores enteros.
CONSEJO PROFESIONAL: ¡los enteros son mucho más rápidos de transmitir que casi todos los demás tipos de datos!En comparación con otras correcciones, esto era bastante simple, pero el crecimiento del rendimiento aún era notable, porque este bucle interno se usaba de manera muy activa.
Optimización de recursión de cola
Hay una cosa elegante
llamada optimización de llamadas de
cola . Es difícil de explicar, así que mejor te mostraré un ejemplo.
Fue:
nw.queryRange(Range, -1, result);
ne.queryRange(Range, -1, result);
sw.queryRange(Range, -1, result);
se.queryRange(Range, -1, result);
return result;
Se convirtió en:
return se.queryRange(Range, filter, sw.queryRange(Range, filter, ne.queryRange(Range, filter, nw.queryRange(Range, filter, result))));
El código devuelve los mismos resultados lógicos, pero según el generador de perfiles, la segunda opción es más rápida, al menos cuando se traduce a cpp. Ambos ejemplos realizan exactamente la misma lógica: realizan cambios en la estructura de datos de "resultado" y la pasan a la siguiente función antes de regresar. Cuando hacemos esto de forma recursiva, podemos evitar que el compilador genere referencias temporales, ya que simplemente puede devolver el resultado de la función anterior de inmediato, en lugar de seguirlo en un paso adicional. O algo por el estilo. No entiendo completamente cómo funciona esto, así que lea la publicación en el enlace de arriba.
(A juzgar por lo que sé, la versión actual del compilador Haxe no tiene una función de optimización de recursión de cola, es decir, probablemente sea el trabajo del compilador C ++, así que no se sorprenda si este truco no funciona al traducir el código Haxe no en cpp).
Agrupación de objetos
Si necesito resultados precisos, debo destruir y reconstruir QuadTree nuevamente con cada llamada de actualización. Crear nuevas instancias de QuadTree es una tarea bastante común, pero con un gran número de nuevos objetos AABB y XY, los QuadTrees que dependen de ellos provocaron una sobrecarga de memoria severa. Como se trata de objetos muy simples, sería lógico asignar muchos de estos objetos por adelantado y reutilizarlos constantemente. Esto se llama un
grupo de objetos .
Solía hacer algo como esto:
nw = new QuadTree( new AABB( cx - hs2x, cy - hs2y, hs2x, hs2y) );
ne = new QuadTree( new AABB( cx + hs2x, cy - hs2y, hs2x, hs2y) );
sw = new QuadTree( new AABB( cx - hs2x, cy + hs2y, hs2x, hs2y) );
se = new QuadTree( new AABB( cx + hs2x, cy + hs2y, hs2x, hs2y) );
Pero luego reemplacé el código con esto:
nw = new QuadTree( AABB.get( cx - hs2x, cy - hs2y, hs2x, hs2y) );
ne = new QuadTree( AABB.get( cx + hs2x, cy - hs2y, hs2x, hs2y) );
sw = new QuadTree( AABB.get( cx - hs2x, cy + hs2y, hs2x, hs2y) );
se = new QuadTree( AABB.get( cx + hs2x, cy + hs2y, hs2x, hs2y) );
Utilizamos el marco de código abierto
HaxeFlixel , por lo que lo
implementamos usando la clase
FlxPool HaxeFlixel. En el caso de optimizaciones tan altamente especializadas, a menudo reemplazo algunos de los elementos básicos de Flixel (por ejemplo, reconocimiento de colisión) con mi propia implementación (como lo hice con QuadTrees), pero FlxPool es mejor que todo lo que escribí yo mismo y hace exactamente lo que necesita.
Especialización si es necesario
Un objeto
XY
es una clase simple que tiene las propiedades
x
,
y
e
int_id
. Como se usó en un bucle interno particularmente activo, pude guardar muchos comandos y operaciones de asignación de memoria moviendo todos estos datos a una estructura de datos especial que proporciona la misma funcionalidad que
Vector<XY>
. Llamé a esta nueva clase
XYVector
y el resultado se puede ver
aquí . Esta es una aplicación muy especializada y no es flexible al mismo tiempo, pero nos ha proporcionado algunas mejoras de velocidad.
Funciones incorporadas
Ahora, después de haber completado la fase amplia del reconocimiento de colisión, necesitamos hacer muchas comprobaciones para descubrir qué objetos realmente colisionan. Siempre que sea posible, trato de comparar puntos y figuras, no figuras y figuras, pero a veces tengo que hacer lo último. En cualquier caso, todo esto requiere sus propios controles especiales:
private static function _collide_circleCircle(a:Zone, b:Zone):Bool { var dx:Float = a.centerX - b.centerX; var dy:Float = a.centerY - b.centerY; var d2:Float = (dx * dx) + (dy * dy); var r2:Float = (a.radius2) + (b.radius2); return d2 < r2; }
Todo esto se puede mejorar con una sola
inline
:
private static inline function _collide_circleCircle(a:Zone, b:Zone):Bool { var dx:Float = a.centerX - b.centerX; var dy:Float = a.centerY - b.centerY; var d2:Float = (dx * dx) + (dy * dy); var r2:Float = (a.radius2) + (b.radius2); return d2 < r2; }
Cuando agregamos en línea a una función, le decimos al compilador que copie y pegue este código y pegue las variables cuando se usa, y que no realice una llamada externa a una función separada, lo que genera costos innecesarios. La incrustación no siempre es aplicable (por ejemplo, infla la cantidad de código), pero es ideal para situaciones en las que se invocan funciones pequeñas una y otra vez.
Traemos a la mente conflictos
La verdadera lección aquí es que en el mundo real, las optimizaciones no siempre son del mismo tipo. Tales soluciones son una mezcla de técnicas avanzadas, trucos baratos, aplicación de recomendaciones lógicas y eliminación de errores estúpidos. Todo esto en general nos da un impulso de rendimiento.
Pero aún así, ¡
mide siete veces, corta una!No vale la pena el esfuerzo de dos horas de optimización pedante de la función, llamada una vez cada seis cuadros y que toma 0.001 ms, a pesar de la fealdad y la estupidez del código.
3. Ordenar todo
De hecho, fue una de mis últimas mejoras, pero resultó ser tan ventajoso que merece su propio título. Además, fue el más simple y se demostró repetidamente. El generador de perfiles me mostró un procedimiento que no pude mejorar en absoluto: el bucle principal draw (), que tomó demasiado tiempo. La razón fue la función que ordenó todos los elementos de la pantalla antes de renderizar, es decir, ¡
ordenar todos los sprites tomó mucho más tiempo que dibujarlos!
Si miras las capturas de pantalla del juego, verás que todos los enemigos y defensores se ordenan primero por
y
, luego, por
x
, de modo que los elementos se superpongan entre sí de atrás hacia adelante, de izquierda a derecha, cuando nos movemos desde la esquina superior izquierda a la esquina inferior derecha de la pantalla.
Una forma de burlar la clasificación es simplemente pasar la clasificación de representación a través del marco. Este es un truco útil para algunas funciones costosas, pero de inmediato condujo a errores visuales muy notables, por lo que no nos convenía.
Finalmente, la decisión vino de uno de los mantenedores de HaxeFlixel,
Jens Fisher . Él preguntó: "¿Te has asegurado de usar un algoritmo de clasificación que sea rápido para las matrices casi ordenadas?"
No! Resultó que no. Utilicé la ordenación de matrices de la biblioteca estándar de Haxe (creo que fue una
combinación de fusión , una buena opción para casos generales. Pero tuve un caso muy
especial . Al ordenar en cada cuadro, la posición de clasificación cambia solo un número muy pequeño de sprites, incluso si hay muchos de ellos. Por lo tanto, Reemplacé la antigua llamada de
clasificación con
clasificación por insertos , y
¡boom! - la velocidad aumentó instantáneamente.
4. Otros problemas técnicos
El reconocimiento y la clasificación de colisiones fueron grandes victorias en la lógica de
update()
y
draw()
, pero se ocultaron muchas más trampas en los bucles internos utilizados activamente.
Std.is () y elenco
En diferentes bucles internos "calientes", tenía un código similar:
if(Std.is(something,Type)) { var typed:Type = cast(something,Type); }
En el lenguaje Haxe,
Std.is()
nos dice si un objeto pertenece a un tipo específico (Tipo) o una clase (Clase), y
cast
intenta lanzarlo a un tipo específico durante la ejecución del programa.
Hay versiones seguras y sin protección de
cast
: los yesos seguros conducen a un rendimiento reducido, pero los yesos sin protección no lo hacen.
Seguro:
cast(something, Type);
Desprotegido:
var typed:Type = cast something;
Cuando falla un intento de lanzamiento inseguro, quedamos nulos, mientras que un lanzamiento seguro arroja una excepción. Pero si no vamos a atrapar una excepción, ¿cuál es el punto de hacer un reparto seguro? Sin captura, la operación aún falla, pero funciona más lentamente.
Además, no tiene sentido preceder un lanzamiento seguro con la comprobación
Std.is()
. La única razón para usar un yeso seguro es una excepción garantizada, pero si verificamos el tipo antes del yeso, ¡ya garantizamos que el yeso no fallará!
Puedo acelerar un poco las
Std.is()
con un reparto
Std.is()
después de verificar
Std.is()
. Pero, ¿por qué necesitamos reescribir lo mismo si no necesito verificar el tipo de clase en absoluto?
Supongamos que tengo un
CreatureSprite
, que puede ser una instancia de una subclase de
DefenderSprite
o
EnemySprite
. En lugar de llamar a
Std.is(this,DefenderSprite)
podemos crear un campo entero en
CreatureSprite
con valores como
CreatureType.DEFENDER
o
CreatureType.ENEMY
, que se verifican aún más rápido.
Repito, vale la pena arreglarlo solo en aquellos lugares donde se registra claramente una desaceleración significativa.
Por cierto, puedes leer más sobre el yeso
seguro y
sin protección en el
manual de Haxe .
Serialización / Deserialización del Universo
Fue molesto encontrar esos lugares en el código:
function copy():SomeClass { return SomeClass.fromXML(this.toXML()); }
Si Para copiar un objeto, lo
serializamos en XML y luego
analizamos todo este XML , después de lo cual descartamos instantáneamente el XML y devolvemos un nuevo objeto. Esta es probablemente la forma más lenta de copiar un objeto, además, sobrecarga la memoria. Inicialmente, escribí llamadas XML para guardar y cargar desde el disco, y creo que era demasiado vago para escribir los procedimientos de copia correctos.
Probablemente, todo estaría en orden si esta función rara vez se usara, pero estas llamadas surgieron en lugares inapropiados en el medio del juego. Así que me senté y comencé a escribir y probar la función de copia correcta.
Di no a nulo
La comprobación de igualdad para nulo se usa con bastante frecuencia, pero cuando se traduce Haxe a cpp, un objeto que permite un valor indefinido genera costos innecesarios que no surgen si el compilador puede suponer que el objeto nunca será nulo. Esto es especialmente cierto para los tipos base como
Int
- Haxe implementa la validez de un valor indefinido para ellos en el sistema de destino estático por su "empaque", lo que ocurre no solo para las variables que se declaran explícitamente como nulas (
var myVar:Null<Int>
), sino también para cosas como las opciones de ayuda (
?myParam:Int
). Además, los cheques nulos en sí mismos causan desperdicio innecesario.
Pude solucionar algunos de estos problemas simplemente mirando el código y pensando en alternativas: ¿puedo hacer una verificación más simple, que siempre será cierta cuando el objeto sea nulo? ¿Puedo detectar nulos mucho antes en la cadena de llamadas a funciones y pasar un entero simple o un indicador booleano a las llamadas secundarias? ¿Puedo estructurar todo para que
nunca se garantice que el valor sea nulo? Y así sucesivamente. No podemos eliminar por completo las verificaciones nulas y los valores anulables, pero sacarlos de las funciones me ayudó mucho.
5. Tiempo de descarga
En PSVita, tuvimos problemas serios especiales con el tiempo de carga de algunas escenas. Al generar perfiles, resultó que las razones se reducen principalmente a la rasterización de texto, la representación innecesaria de software, la representación costosa de botones y otras cosas.
Texto
HaxeFlixel se basa en
OpenFL , que tiene TextField impresionante y confiable. Pero utilicé objetos FlxText de manera imperfecta: los objetos FlxText tienen un campo de texto interno OpenFL que está rasterizado. Sin embargo, resultó que no necesitaba la mayoría de estas funciones de texto complejas, pero debido a la forma estúpida de configurar mi sistema de interfaz de usuario, los campos de texto tenían que representarse antes de que se ubicaran todos los demás objetos. Esto condujo a saltos pequeños pero notables, por ejemplo, al cargar una ventana emergente.
Aquí hice tres correcciones: en primer lugar, reemplacé la mayor cantidad de texto posible con fuentes ráster. Flixel tiene soporte incorporado para varios formatos de fuente ráster, incluido
BMFont de AngelCode , que facilita el trabajo con Unicode, estilo y kerning, pero la API de texto ráster es ligeramente diferente de la API de texto plano, por lo que tuve que escribir una pequeña clase de contenedor para simplifica la transición. (Le di el nombre adecuado
FlxUITextHack
).
Esto mejoró ligeramente el trabajo: las fuentes de mapa de bits se procesan muy rápidamente, pero aumentó ligeramente la complejidad: tuve que preparar especialmente conjuntos de caracteres separados y agregar lógica de cambio dependiendo de la configuración regional, en lugar de simplemente configurar un cuadro de texto que hizo todo el trabajo.
La segunda solución fue crear un nuevo objeto de interfaz de usuario que fuera un simple
marcador de
posición para el texto pero que tuviera las mismas propiedades públicas que el texto. Lo llamé "área de texto" y creé una nueva clase para él en mi biblioteca de interfaz de usuario para que mi sistema de interfaz de usuario pueda usar estas áreas de texto de la misma manera que los campos de texto reales, pero no muestra nada hasta que calcula el tamaño y la posición para todo lo demás. Luego, cuando mi escena estaba preparada, comencé el procedimiento de reemplazar estas áreas de texto con campos de texto reales (o campos de texto de fuentes de mapa de bits).
La tercera corrección se refería a la percepción. Si hay pausas entre la entrada y la reacción, incluso en medio segundo, el jugador percibe esto como frenado. Por lo tanto, traté de encontrar todas las escenas en las que hay un retraso en la entrada hasta la próxima transición, y agregué una capa translúcida con la palabra "Cargando ..." o simplemente una capa sin texto. Una corrección tan simple mejoró en gran medida la
percepción de la capacidad
de respuesta del juego, ya que algo sucede inmediatamente después de que el jugador toca el control, incluso si lleva un tiempo mostrar el menú.
Renderizado de software
La mayoría de los menús utilizan una combinación de escala de software y composición de 9 divisiones. Esto sucedió porque en la versión para PC había una interfaz de usuario independiente de la resolución que podía funcionar con una relación de aspecto de 4: 3 y 16: 9, escalada en consecuencia. Pero en PSVita ya
conocemos la resolución, es decir, no necesitamos todos estos recursos extra de alta resolución y algoritmos de escala en tiempo real. Simplemente podemos pre-renderizar recursos a la resolución exacta y colocarlos en la pantalla.
Primero, ingresé en el marcado de UI para las condiciones de Vita que cambiaron el juego al uso de un conjunto paralelo de recursos. Entonces necesitaba crear estos recursos preparados para un permiso. El
depurador HaxeFlixel resultó ser muy útil
aquí : agregué mi script para que simplemente vacíe el caché de trama al disco. Luego, creé una configuración de compilación especial para Windows que simula el permiso para Vita, abrí todos los menús del juego, cambié al depurador y lancé el comando de exportación para las versiones escaladas de los recursos como PNG listos para usar. Luego los renombré y los usé como recursos para Vita.
Representación de botones
Mi sistema de interfaz de usuario tenía un problema real con los botones: cuando se crearon, los botones representaron el conjunto de recursos predeterminado y, un momento después, cambiaron el tamaño (y volvieron a mostrar) el código de inicio de la interfaz de usuario, y a veces incluso la
tercera vez, antes de cargar toda la interfaz de usuario . Resolví este problema agregando opciones que retrasaron la representación de los botones a la última etapa.
Escaneo de texto opcional
La revista se estaba cargando especialmente despacio. Al principio pensé que el problema estaba en los campos de texto, pero no. El texto de la revista puede contener enlaces a otras páginas, lo que se indica mediante caracteres especiales incrustados en el texto sin formato. Estos caracteres se cortaron más tarde y se usaron para calcular la ubicación del enlace.
Resultó que escaneé
todos los campos de texto para encontrar y reemplazar estos caracteres con enlaces formateados correctamente, ¡sin siquiera comprobar primero si hay algún carácter especial en este campo de texto! Peor aún, según el diseño, los enlaces
solo se usaron en la página de contenido, pero los verifiqué en cada cuadro de texto en cada página.
Me las arreglé para sortear todas estas comprobaciones utilizando la construcción if del formulario "¿Este cuadro de texto utiliza enlaces en absoluto?". La respuesta a esta pregunta generalmente era no. Finalmente, la página que tardó más en cargar resultó ser la página de índice. Como nunca cambia en el menú del diario, ¿por qué no lo almacenamos en caché?
6. Perfiles de memoria
La velocidad no es solo la CPU. La memoria también puede ser un problema, especialmente en plataformas débiles como Vita. Incluso cuando logró deshacerse de la última pérdida de memoria, aún podría tener problemas con el uso de la memoria de diente de sierra en un entorno de recolección de basura.¿Cuál es el uso de la memoria de diente de sierra? El recolector de basura funciona de la siguiente manera: los datos y los objetos que no utiliza se acumulan con el tiempo y se borran periódicamente. Pero no tiene un control claro sobre cuándo sucede esto, por lo que el gráfico de uso de memoria parece una sierra:Sacar la basura
Dado que la limpieza no es instantánea, la cantidad total de RAM que usa generalmente es mayor de lo que realmente necesita. Pero si excede la cantidad total de RAM del sistema, puede suceder una de dos cosas: en una PC, probablemente solo use un archivo de página , es decir, convierta temporalmente parte del espacio del disco duro en RAM virtual. Una alternativa en entornos de memoria limitada (como las consolas) es bloquear la aplicación, incluso si no hubiera suficientes pares de bytes miserables. ¡Y esto sucederá incluso si no usa estos bytes y la recolección de basura se realizará en ellos pronto!Lo bueno de Haxe es que es completamente de código abierto, es decir, no está encerrado en un cuadro negro que no puede arreglar, como es el caso de Unity. ¡Y el backend hxcpp proporciona una amplia gestión de recolección de basura directamente desde la API!Los usamos para borrar instantáneamente la memoria después de un gran nivel para permanecer dentro de los límites dados:cpp.vm.Gc.run(false); // (true/false - / )
no debe usarlo involuntariamente si no sabe lo que está haciendo, pero es conveniente que existan tales herramientas cuando sean necesarias.7. Solución a través del diseño
Todas estas mejoras de rendimiento fueron más que suficientes para optimizar el juego para PC, pero también tratamos de lanzar una versión para PSVita, y teníamos planes a largo plazo para Nintendo Switch, por lo que tuvimos que exprimir todo, desde el código hasta la caída.Pero a menudo hay una "visión de túnel" cuando te enfocas solo en ataques técnicos y olvidas que un simple cambio de diseño puede mejorar enormemente la situación .Acelerar los efectos a alta velocidad
Con 16x, muchos efectos ocurren tan rápido que el jugador ni siquiera los ve. Ya hemos usado un truco: el rayo de Azra se hizo más fácil con la velocidad del juego, y la cantidad de partículas para los ataques AOE es menor. Complementamos esta técnica al deshabilitar los números de daño de alta velocidad y otros trucos similares.También nos dimos cuenta de que en algún momento, la velocidad de 16x puede ser más lenta que la de 8x cuando hay demasiados objetos en la pantalla, por lo que cuando el número de enemigos aumenta hasta cierto límite, reducimos automáticamente la velocidad del juego a 8x o 4x. En la práctica, es probable que el jugador vea esto solo en Endless Battle 2. Esto permite un rendimiento y renderización fluidos sin sobrecargar la CPU.También utilizamos restricciones específicamente para la plataforma. En Vita, omitimos el efecto del rayo cuando Azra activa o acelera al personaje, y usamos otros trucos similares.Cuerpo oculto
¿Y qué hay del enorme montón de enemigos en la esquina inferior derecha de Endless Battle 2? Hay literalmente cientos o incluso miles de enemigos dibujando uno encima del otro. ¿Por qué no nos saltamos la representación de aquellos que ni siquiera podemos ver?Este es un truco de diseño astuto que requiere una programación astuta, porque necesitamos un algoritmo inteligente que defina los objetos ocultos.La mayoría de estos juegos se dibujan utilizando el algoritmo del artista : los objetos anteriores en la lista de dibujo están bloqueados por todo lo que viene después de ellos.Al invertir el orden de representación del algoritmo del artista, puede generar un "mapa de portada" y descubrir qué debe ocultarse. Creé un falso "lienzo" con 8 niveles de "oscuridad" (solo una matriz bidimensional de bytes) con una resolución mucho menor que un campo de batalla real. Comenzando desde el final de la lista de renderizado, tomamos el cuadro delimitador de cada objeto y lo "dibujamos" en el lienzo, aumentando la "oscuridad" del punto en 1 por cada "píxel" cubierto por el cuadro delimitador de baja resolución. Al mismo tiempo, leemos la "oscuridad" promedio del área en la que vamos a dibujar. De hecho, predecimos cuántos redibujos experimentará cada objeto con una llamada de dibujo real.Si el número previsto de redibujos es lo suficientemente alto, entonces marco al enemigo como "enterrado", con dos umbrales: completamente enterrado, es decir, completamente invisible o parcialmente enterrado, es decir, será dibujado, pero sin mostrar una barra de salud.(Por cierto, esta es la función de verificar los redibujos).Para que esto funcione correctamente, debe configurar correctamente la resolución del mapa oculto. Si es demasiado grande, tendremos que realizar un montón adicional de llamadas simplificadas, si es demasiado pequeño, ocultaremos los objetos de manera demasiado agresiva y obtendremos errores visuales. Si selecciona la tarjeta correctamente, el efecto apenas se nota, pero el aumento de la velocidad es muy notable: no hay forma de dibujar algo más rápido que no hacerlo. !Mejor precarga que frenos
En medio de las peleas, noté frenadas frecuentes, que, estaba seguro, fueron causadas por una pausa en la recolección de basura. Sin embargo, la elaboración de perfiles ha demostrado que esto no es así. Pruebas posteriores revelaron que esto ocurre al comienzo de la ola de generación de enemigos, y más tarde descubrí que esto solo ocurre cuando se trata de una ola de enemigos que no existía antes. Obviamente, algún código de configuración enemigo causó el problema y, por supuesto, al perfilar, se encontró una función "activa" en la configuración de gráficos. Comencé a trabajar en una compleja configuración de descarga de subprocesos múltiples, pero luego me di cuenta de que podía poner todos los procedimientos de carga de gráficos enemigos en la precarga de la batalla. Por separado, estas fueron descargas muy pequeñas, incluso en las plataformas más lentas que agregaron menos de un segundo al tiempo total de carga de la batalla, pero evitaron un frenado muy notable durante el juego.Reservamos stock para más tarde
Si trabaja en un entorno con memoria limitada, puede usar el antiguo truco de nuestra industria: asignar una gran cantidad de memoria así, y luego olvidarlo hasta el final del proyecto. Al final del proyecto, después de haber desperdiciado todo el presupuesto de memoria disponible, puede salvarse gracias a este "huevo de ahorros".Nos encontramos en una situación así: solo necesitábamos una docena de bytes para guardar el ensamblado para PSVita, pero demonios, ¡nos olvidamos de este truco y, por lo tanto, nos atascamos! ¡Las únicas opciones restantes fueron semanas de cirugía de código desesperada y dolorosa!Pero espera un momento! Una de mis optimizaciones (sin éxito) fue cargar tantos recursos como sea posible y perpetuoalmacenarlos en la memoria, porque supuse por error que la lectura de recursos durante la ejecución del programa causó un gran tiempo de carga. Resultó que esto no era así, por lo que casi todas estas llamadas adicionales para precarga y almacenamiento eterno podían eliminarse por completo, ¡y todavía tenía memoria libre!Deshacerse de cosas que no usamos
Mientras trabajábamos en la compilación para PSVita, fuimos particularmente claros en que hay un montón de cosas que simplemente no necesitamos. Debido a la baja resolución, el modo de gráficos fuente y el modo de gráficos HD eran indistinguibles, por lo que para todos los sprites utilizamos los gráficos originales. También logramos mejorar la función de reemplazar la paleta con la ayuda de un sombreador de píxeles especial (anteriormente utilizamos la función de representación del programa).Otro ejemplo fue el mapa de batalla en sí mismo: en la PC y las consolas domésticas, apilamos un montón de tarjetas de fichas una encima de la otra para crear un mapa de varias capas. Pero como el mapa nunca cambia, en Vita podríamos hornear todo en una imagen terminada para que se llame en una llamada de sorteo.Además de los recursos adicionales, el juego tenía muchas llamadas adicionales, por ejemplo, defensores y enemigos que enviaban una señal de regeneración en cada cuadro, incluso cuando no tenían la capacidad de regenerarse . Si la IU estaba abierta para tal criatura, entonces se redibujó en cada cuadro .Hay media docena de otros ejemplos de pequeños algoritmos que calculan algo dentro de una función "activa", pero nunca devuelven resultados en ningún lado. Por lo general, estos fueron los resultados de crear la estructura en las primeras etapas de desarrollo, por lo que simplemente los eliminamos.NaNopocalypse
Este caso fue gracioso. El generador de perfiles informó que lleva mucho tiempo calcular los ángulos. Aquí está el código Haxe C ++ generado en el generador de perfiles:Esta es una de esas funciones que toman valores como -90
y los convierten 270
. A veces obtienes valores como -724
, que en unos pocos ciclos se reducen a 4
.Por alguna razón, se pasó un valor a esta función -2147483648
.Hagamos los cálculos. Si en cada ciclo agregamos 360 a -2147483648, entonces tomará aproximadamente 5,965,233 iteraciones hasta que sea mayor que 0 y complete el ciclo. Por cierto, este ciclo se realizó con cada actualización (no en cada cuadro , ¡en cada actualización !), Cada vez que el proyectil (u otra cosa) cambió su ángulo.Por supuesto, fue mi culpa, porque pasé un valor NaN
, un valor especial que significa "No es un número" (no es un número), que generalmente indica un error que ocurrió anteriormente en el código. Si lo lleva a un número entero sin verificar primero, entonces suceden cosas tan extrañas.Como solución temporal, agregué un chequeMath.isNan()
, que restablecen el ángulo cuando se produce un evento de este tipo (bastante raro, pero inevitable). Al mismo tiempo, continué buscando la causa raíz del error, lo encontré y el retraso desapareció de inmediato. Resulta que si no realiza 6 millones de iteraciones sin sentido, ¡entonces puede obtener un gran aumento de velocidad!(Se insertó una solución para este error en el propio HaxeFlixel).No te burles de ti mismo
Tanto OpenFL como HaxeFlixel se basan en el almacenamiento en caché de recursos. Esto significa que cuando cargamos un recurso, la próxima vez que se reciba este recurso, se tomará del caché y no se volverá a cargar desde el disco. Este comportamiento puede ser anulado y, a veces, tiene sentido.Sin embargo, me metí en algunas cosas extrañas extrañas: descargué el recurso, le dije explícitamente al sistema que no almacenara en caché los resultados, porque estaba completamente seguro de lo que estaba haciendo y no quería "desperdiciar memoria" en la memoria caché. Años más tarde, estas llamadas "inteligentes" me hicieron cargar el mismo recurso una y otra vez, ralentizando el juego y desperdiciando una memoria preciosa, que "guardé" al abandonar el caché.8. Además, puede que no valga la pena hacer niveles como Endless Battle 2
Sí, es genial que hayamos implementado todos estos pequeños trucos para aumentar la velocidad. Honestamente, no nos dimos cuenta de la mayoría de ellos hasta que comenzamos a portar el juego a sistemas menos potentes, cuando en algunos niveles los problemas se volvieron completamente intolerables. Me alegro de que al final logramos aumentar la velocidad, pero creo que también debe evitarse el diseño del nivel patológico. Endless Battle 2 puso demasiado estrés en el sistema, especialmente en comparación con todos los demás niveles del juego .Incluso después de todos estos cambios, la versión de PSVita todavía no puede hacer frente al diseño original de Endless 2, y no quería arriesgar la velocidad en los modelos base XB1 y PS4, por lo que cambié el equilibrio para las versiones de consola de Endless 2. Reduje el número de enemigos, pero aumenté sus características para que el nivel tenga aproximadamente la misma dificultad. Además, en PSVita limitamos el número de ondas a cien para evitar el riesgo de falla de memoria, pero no agregamos restricciones en la PS4 y XB1. Gracias a esto, lograr el logro de resistencia sigue siendo igualmente difícil en todas las consolas. En la versión para PC, el diseño del nivel Endless Batlte 2 se mantuvo sin cambios.Todo esto fue una lección para nosotros, que tendremos en cuenta al crear Defender's Quest II: ¡estaremos muy atentos a los niveles sin un límite superior en la cantidad de enemigos en la pantalla! Por supuesto, las misiones "interminables" son muy atractivas para los fanáticos de Tower Defense, así que no las eliminaré por completo, pero ¿qué pasa con los niveles con puntos de control en los que el jugador DEBE destruir todo en la pantalla antes de pasar a las siguientes oleadas? Esto no solo nos permitirá limitar el número de enemigos en la pantalla, sino que también nos daremos cuenta de guardar en el medio del nivel sin preocuparnos por serializar el estado de la sopa loca de objetos en una batalla intensa: será suficiente para nosotros simplemente guardar las coordenadas de los defensores, aumentar los niveles, etc.9. Pensamientos en conclusión
El rendimiento del juego es un tema complejo porque los jugadores a menudo no entienden lo que es, y no debemos esperar que lo comprendan. Pero espero que este artículo te haya aclarado un poco cómo se ve todo dentro, y hayas aprendido más sobre cómo el diseño, las compensaciones técnicas y las decisiones simplemente estúpidas ralentizan los juegos.La conclusión es que incluso en un juego con un buen diseño desarrollado por un equipo talentoso, estos pequeños fragmentos de código "oxidados" se pueden encontrar absolutamente en todas partes . Pero en la práctica, solo una pequeña fracción de ellos realmente afecta el rendimiento. La capacidad de detectarlos y eliminarlos es igualmente arte y ciencia.Me alegro de que aprovecharemos todas estas ventajas en el desarrollo de Defender's Quest II. Honestamente, si no hubiéramos hecho un puerto para PSVita, entonces probablemente ni siquiera habría probado la mitad de estas optimizaciones. E incluso si no compras el juego para PSVita, puedes agradecer a esta pequeña consola, que mejoró significativamente la velocidad de Defender's Quest.