Optimizaciones
La siguiente es una lista de las optimizaciones más importantes
que sería interesante estudiar o tener presentes de cara a la
versión 1.0.
Subrutinas
Actualmente la creación de un proceso tiene una sobrecarga
considerable: se inicializan todos sus valores locales (potencialmente
2, 4K de datos), se marca el uso de todas sus cadenas locales, se
reserva memoria para la estructura que lo describe, etc. Lo que es
peor, estos recursos no son liberados hasta el siguiente frame. La
programación tradicional (por ejemplo, resolución de
problemas matemáticos sencillos mediante funciones recursivas)
resulta prohibitiva.
Sería preciso poder marcar un proceso como subrutina. Un proceso
de este tipo no se visualiza en pantalla, no contiene datos locales, y
no ejecuta nunca la instrucción FRAME. Se comporta como una
función en C u otro lenguaje tradicional: recibe una serie de
parámetros, tiene un espacio de variables privadas, y al salir
éstas son destruídas. Las subrutinas se
ejecutarían desde dentro del propio intérprete sin salir
de la función que ejecuta un proceso [instance_go], usando espacio de
pila para mantener sus variables privadas temporalmente hasta que
vuelvan y actuando poco más o menos como un salto JMP con algo
más de sobrecarga para inicializar variables privadas.
Debería ser responsabilidad del compilador FXC:
- Identificar un proceso como subrutina (comprobando que no use
variables locales ni contenga la instrucción FRAME) y marcarlo
como tal.
- Generar código que habilite espacio de pila (stack) para
variables privadas e inicialice sus valores (por ejemplo, aumente la
cuenta de uso de las cadenas que se almacenen allí). Al entrar
en la subrutina, un registro de "marco de pila" se inicializará
con la ubicación de este espacio.
- Generar código que libere el espacio de pila ocupado (por
ejemplo, reduzca la cuenta de uso de las cadenas que haya almacenadas
en estas variables). Será responsabilidad de la subrutina
además restaurar el registro de marco de pila al valor anterior.
- Sustituir los accesos a variables privadas del proceso por
accesos a pila referenciados sobre el registro de marco de pila
anteriormente mencionado.
Debe ser responsabilidad del compilador FXI:
- Mantener el nuevo registro de marco de pila
- Reconocer e interpretar cualquier nuevo mnemónico
necesario para acceder a valores de la pila distantes en función
del nuevo registro de marco.
- Reconocer e interpretar el nuevo mnemónico de salto a
subrutina.
Sería deseable incorporar el mantenimiento del marco de pila
dentro de los nuevos mnemónicos de llamada a subrutina y retorno
de la misma.
Máscaras o sprites RLE
Actualmente el dibujo de gráficos más común se
realiza comprobando pixel a pixel la existencia de ceros (color
transparente). La versión MMX del mismo proceso hace una
combinación de los valores leídos con los existentes en
el buffer de destino, lo cual es mucho más lento en memoria de
video pero más rápido en la RAM, ya que no ejecuta saltos
internos.
Existen técnicas que permiten acelerar considerablemente este
proceso, a cambio de hacer el gráfico inmutable: crear una
máscara de bits del mismo (con 1 para los pixels transparentes),
o una versión RLE que codifique los espacios vacíos por
separado de los pixels. Las máscaras permiten acelerar
también considerablemente la detección de colisiones.
Ninguna de las dos técnicas resulta interesante cuando los
sprites son rotados, amplíados, o reducidos de tamaño.
Además, el espejo horizontal requeriría versiones
especiales de las rutinas de dibujo por máscara o con RLE. Todo
ello sugiere que las versiones actuales del blitter deberían
seguir existiendo, y estas optimizaciones coexistir como casos
especiales.
El mayor problema es que se requiere una conversión más o
menos costosa de un sprite a máscara o a versión RLE. Una
modificación en el contenido del sprite puede requerir volver a
convertirlo. Para este problema hay dos posibles soluciones:
- Que sea el programador el que explícitamente cree la
máscara o versión RLE de un gráfico, mediante una
función al efecto, y que vuelva a llamarla para actualizarla si
modifica el gráfico original. Tiene el inconveniente de que
puede ser pesado.
- Usar un flag "dirty" en un gráfico para detectar
automáticamente cuándo ha sido modificado. Tiene el
inconveniente de que algunos cambios son indetectables (p.ej.
MAP_BUFFER) y de que erróneamente se puede estar perdiendo el
tiempo en crear máscaras de gráficos que siempre se van a
dibujar escalados, por ejemplo.
Personalmente me decanto por la primera opción, en todo caso con
una variante de la función de crear máscaras que cree
máscaras sobre todos los gráficos en memoria, o sobre
todos los de una librería, o que active un modo para crearlas
automáticamente sobre todos los gráficos cargados en el
futuro. Esto aumentaría el número de funciones en tres o
cuatro.
En cuanto a la implementación, cada estructura GRAPH
debería contener un puntero a una función de dibujo
específica, a ser llamado en lugar de gr_blit cuando las
condiciones de dibujo sean normales (sin escalado, sin rotado, y sin
flags o sólo con un conjunto reducido de flags, tal vez con una
máscara especificada en otro campo de la estructura). La
detección de la existencia de esta rutina puede venir al
principio de la propia gr_blit, haciendo la mejora transparente al
sistema.
La opción de hacer gráficos con rutinas de dibujo
personalizables tiene múltiples aplicaciones adicionales. Por
ejemplo, se podrían crear DLLs que implementaran blitters
alternativos al original (con efectos especiales, u optimizaciones como
las comentadas). También sería posible crear mapas
compuestos (una versión especial de un mapa de 8 bits donde cada
pixel en lugar de ser un color representa el índice de un
gráfico dentro de una librería FPG) que serían muy
útiles como fondo de un scroll, donde ahora hay limitaciones de
tamaño y memoria ya que todo el fondo es un único
gráfico. Bastaría con crear una rutina de dibujo
especializada al caso.
Señales
Ahora mismo las señales lanzadas con SIGNAL tienen efecto al
acabar la sentencia. Es posible para un proceso hijo lanzar una
señal para dormir al proceso padre y volver: el proceso padre no
llegará a ejecutar la sentencia siguiente.
El precio a pagar es que se comprueba el estado actual del proceso
contínuamente. Tras cada mnemónico hay una
comprobación de stack vacío para saber si estamos en un
punto final de sentencia. Aunque es sólo una comparación
de registros, sobra, y el intérprete se vería acelerado
en un cierto porcentaje sin su existencia.
Desgraciadamente eso haría errática la actuación
de señales, porque una señal tendría efecto cuando
el bucle principal llegara al proceso correspondiente. Algunas
señales tendrían efecto casi inmediato (las de un proceso
contra otro que no lo ha llamado y que aún no se ha ejecutado
este frame), mientras otras no actuarían hasta el siguiente.
Para solucionar esto, la función SIGNAL no debería
cambiar inmediatamente el estado de un proceso, sino guardar el nuevo
estado en una variable auxiliar, de manera que sólo al final del
actual frame ésta sustituyera al estado real. De esta forma se
emularía el funcionamiento del DIV, donde las señales no
tienen efecto hasta justo antes de la visualización, y que ya es
suficiente como medio de interactuación entre procesos.
Lo que es peor es que se perdería la compatibilidad con los
programas ya existentes, que son dependientes del funcionamiento de las
señales.
La mejor solución es crear dos versiones del núcleo del
intérprete (eventualmente es preciso crear más de una,
por ejemplo para habilitar una ejecución paso a paso o con
breakpoints). En una de ellas se sigue el mecanismo actual (aunque la
comprobación del stack no sigue siendo válida si se
implementan subrutinas, ya que estas pueden crear procesos), y en otra
alternativa algo más rápida las señales
funcionarían como en DIV. Una variable global podría
decidir qué versión ejecutar. No sería necesario
duplicar las versiones paso a paso del intérprete, ya que en
estas el rendimiento no es tan importante y podrían comprobar el
estado de dicha variable global para emular ambas versiones.
Bucle principal de ejecución y dibujo
Actualmente el bucle principal de Fenix crea al menos dos listas
dinámicas que son ordenadas cada frame con qsort: una lista de
procesos a ejecutar, y una lista de objetos a dibujar.
Este funcionamiento no es óptimo, ya que en muchos casos estas
listas son largas y bastante constantes (con pocas o ninguna
variación de un frame a otro).
Se obtendría algo de mejora en el rendimiento, especialmente en
programas con muchos procesos simples, si estas listas se mantuvieran
en todo momento creadas y ordenadas (insertando los nuevos elementos en
la posición correcta, por ejemplo). La mayor complejidad
consiste en detectar cambios en los procesos (por ejemplo, las
variables PRIORITY y Z pueden ser cambiadas por procesos remotos que
accedan usando punteros o el operador punto [.]).
Tampoco es óptimo el método de dibujo que se emplea
actualmente, que redibuja el frame entero. Sería más
óptimo en muchos casos (especialmente en juegos en alta
resolución con fondo estático) si se creara una lista de
zonas "calientes" rectangulares y se limitara el dibujo del frame a
esas zonas (repasando los procesos que han sido creados, movidos o
alterados en forma o gráfico). Este tipo de
implementación tiene el problema de que es necesario mezclar los
rectángulos obtenidos. Por ejemplo, una gran cantidad de
procesos "partícula" pueden crear 15 ó 20
rectángulos en pantalla que se superpongan en una
configuración no trivial. En un caso extremo puede ser
más lento realizar este proceso que dibujar la pantalla entera.
Sería mejor dejar de manos del usuario la elección,
soportando las variables DUMP_TYPE y RESTORE_TYPE del DIV original. Un
juego con una gran cantidad de procesos estáticos (por ejemplo
un juego isométrico tipo "Alien 8" donde cada cubo en pantalla
formando el decorado sea un proceso) no es práctico ahora mismo,
pero podría serlo si no se redibujaran todos cada frame.
[El fichero que esquematiza el funcionamiento actual del
intérprete contiene una posible implementación de este
sistema]
Otra alternativa es dividir la pantalla en una rejilla y marcar cada
cuadro de la rejilla que sea "pisado" por un proceso para ser
redibujado. Con un número ajustado de celdas esto puede ser
más adecuado para ciertos casos que la solución anterior
de los rectángulos, a costa de dibujar potencialmente una zona
de pantalla bastante más amplia.
Scroll "parallax"
Ahora mismo no hay ninguna rutina que dibuje scrolls, sino que
simplemente se dibujan las capas del mismo la una sobre la otra. Esto
resulta lento especialmente en alta resolución.
Un primer paso es dibujar una rutina de dibujo "parallax" que mezcle
dos o más capas, dibujando las capas inferiores a través
de los agujeros de las capas superiores. Esta rutina puede beneficiarse
de la compresión RLE del gráfico que forme la capa
superior. Sería preciso alterar las estructuras actuales,
pensadas para la existencia de sólo dos capas por scroll. El
resultado podría no ser compatible con los juegos actuales.
Un inconveniente es que puede ser interesante usar rutinas particulares
para dibujar planos de scroll, como por ejemplo la rutina comentada
más arriba de dibujo de gráficos por bloques a partir de
otros gráficos. Para implementar este caso, la rutina de scroll
podría mantener una caché con la zona visible de cada
plano (de tamaño un poco superior a la superficie visible de
pantalla) e irla actualizando llamando a la rutina de dibujo a medida
que se desplace la pantalla. Esto permitiría también
habilitar planos con flags simples, como el espejo horizontal, sin
implementar un caso especial de la rutina de parallax para ello, a
costa de un incremento en el uso de memoria (más o menos
elevado; a 640x480 un plano a pantalla completa de 16 bits son unos
600K de RAM).
Un problema clásico de los juegos DIV con scroll es la no
existencia de planos superiores de scroll (o mejor dicho, no poder
intercalar procesos entre planos de scroll). Sólo habiendo dos
planos no es especialmente importante y el usuario recurre a procesos
para simular un plano de scroll superior, pero permitiendo la
existencia de más planos sería mejor soportar esta
característica. Fenix permite ahora el apilado de capas de
scroll, pero salvo a baja resolución, no es una solución
óptima. Si se implementa un parallax de más de dos planos
sería interesante poder dibujar procesos entre plano y plano.
Este problema no tiene una solución sencilla. La rutina de
parallax podría dibujar los procesos encima del scroll, una vez
dibujado éste al completo, y luego redibujar las capas que
estén encima de ellos (limitandose a la zona que ocupan en
pantalla, una técnica parecida a un RESTORE_TYPE por
rectángulos). Esta solución sería más
rápida que tener que dibujar los planos en múltiples
pasos, pero de todas formas son bastantes pixels los que se dibujan
varias veces. Una solución mejor pero más compleja:
estudiar la disposición en pantalla de todos los procesos y
hacer una clasificación de la pantalla en zonas rectangulares,
de forma similar a como funciona la GUI.DLL para dibujar ventanas sin
pintar dos veces el mismo pixel. En los rectángulos donde no
haya procesos, actuar normalmente; en donde haya algún proceso,
dibujar los planos inferiores, seguidos de los procesos, seguidos de
los planos superiores.
Para complicar el asunto sigue siendo interesante poder hacer
transparencias o efectos entre planos de scroll. Sin embargo este tipo
de recursos ya no hay más remedio que dibujarlos después
de haber dibujado el scroll inferior. La solución más
simple es permitir el apilado de planos de scroll como hasta ahora, de
manera que si el usuario desea hacer una franja de nubes que se
desplacen, por ejemplo, puede hacer un scroll apilado sobre una
región reducida de pantalla.