miércoles, 15 de abril de 2026

FFMPEG: Creando un motor 3D???

Si os gustó mi último post sobre FFMPEG, este os hará replantearos mi nivel de salud mental. En esta ocasión trataré de implementar el motor Voxel Space que ya usé en mi demo de GameBoy Advance y en lenguaje LOGO, mediante filtros de video de FFMPEG, esto es sin ninguna línea de código.

Pues bien, la forma en la que funciona este motor voxel esta perfectamente documentada en este repositorio de GitHub: https://github.com/s-macke/voxelspace
Básicamente, y dicho de una forma que se entienda sin formulas ni código, debemos:
  1. Aplicar perspectiva en el eje X de forma que cuanto más cerca este un elemento del mapa de la cámara, más "zoom" haremos
  2. De la misma forma en el eje Y, cuanto más cerca este un elemento del mapa de la cámara, más se exagerará la elevación
  3. Trazar lineas verticales que alcancen esa altura calculada, empezando o dando prioridad a las más cercanas a la cámara
Bueno. ¡Empecemos! Usaremos el mismo mapa de siempre, dos archivos c.png y p.png
Lo primero que haremos en FFMPEG es unir el mapa de color y alturas (el cual pasará a ser el canal alpha) para poder trabajar con ellos de forma conjunta. Esto lo haremos con el filtro: alphamerge
A continuación recortaremos el mapa a un tamaño que sea el que tendrá nuestra salida de video. Esto lo haremos con el filtro: crop=340:256:9
Muy bien, ahora si queremos animarlo para que por ejemplo de vueltas, tendremos que añadir rotación antes de hacer el crop, y de paso añadiremos un poco de desplazamiento hacia los lados con: rotate=n/50:bilinear=0,crop=340:256:64+32*cos(n/25)
Aquí "n" es el número de frame, y usamos la opción bilinear=0 para que busque siempre el pixel más cercano en vez de calcular promedios, que afectarían al mapa de alturas.
Muy bien, ahora ¡empecemos a implementar nuestro motor! para aplicar perspectiva al eje X usaremos el filtro qeg que permite aplicar una formula a cada valor de pixel, en este caso para los colores la formula será p(floor(170+(X-170)*Y/256),Y)
Sobra decir que aquí 170 es la mitad del ancho y 256 es el alto. ¡De momento pinta bien! Para el canal alpha donde también hay que "exagerar" las alturas la formula será clip((height-alpha(floor(170+(X-170)*Y/256),Y))/Y*scale_h+horizon,0,255)
usamos clip para mantener los valores dentro de nuestro rango de altura 0-255.
Ahora, para trazar las lineas verticales en principio es necesario un bucle FOR iterando las distintas profundidades como se puede ver en el primer GIF. Esto en FFMPEG no es tan sencillo, aunque en principio existe una función "while" en la expresiones de filtros, me decantaré por usar varios IF-ELSEs encadenados para reducir la complejidad al máximo, aunque como resultado me quede un comando demasiado largo. Empecemos a partir de la profundidad 5 hasta el final en 255. Sería algo así:
if(gt(Y,alpha(X,5)),p(X,5), if(gt(Y,alpha(X,6)),p(X,6), if(gt(Y,alpha(X,7)),p(X,7), if(gt(Y,alpha(X,8)),p(X,8), if(gt(Y,alpha(X,9)),p(X,9), if(gt(Y,alpha(X,10)),p(X,10),
...
Y este sería el resultado final: ¡Funcionó!
El resultado animado que nos devuelve FFMPEG sería el siguiente:
ffmpeg -loop 1 -i c.png -loop 1 -i p.png -filter_complex "[0][1]alphamerge,rotate=n/50:bilinear=0,crop=340:256:64+32*cos(n/25),geq='p(floor(170+(X-170)*Y/256),Y)':a='clip((120-alpha(floor(170+(X-170)*Y/256),Y))/Y*280+0,0,255)',geq=' if(gt(Y,alpha(X,5)),p(X,5), if(gt(Y,alpha(X,6)),p(X,6), if(gt(Y,alpha(X,7)),p(X,7), if(gt(Y,alpha(X,8)),p(X,8), if(gt(Y,alpha(X,9)),p(X,9), if(gt(Y,alpha(X,10)),p(X,10), if(gt(Y,alpha(X,11)),p(X,11), if(gt(Y,alpha(X,12)),p(X,12), if(gt(Y,alpha(X,13)),p(X,13), if(gt(Y,alpha(X,15)),p(X,15), if(gt(Y,alpha(X,16)),p(X,16), if(gt(Y,alpha(X,17)),p(X,17), if(gt(Y,alpha(X,19)),p(X,19), if(gt(Y,alpha(X,20)),p(X,20), if(gt(Y,alpha(X,22)),p(X,22), if(gt(Y,alpha(X,23)),p(X,23), if(gt(Y,alpha(X,25)),p(X,25), if(gt(Y,alpha(X,26)),p(X,26), if(gt(Y,alpha(X,28)),p(X,28), if(gt(Y,alpha(X,29)),p(X,29), if(gt(Y,alpha(X,31)),p(X,31), if(gt(Y,alpha(X,33)),p(X,33), if(gt(Y,alpha(X,34)),p(X,34), if(gt(Y,alpha(X,36)),p(X,36), if(gt(Y,alpha(X,38)),p(X,38), if(gt(Y,alpha(X,40)),p(X,40), if(gt(Y,alpha(X,41)),p(X,41), if(gt(Y,alpha(X,43)),p(X,43), if(gt(Y,alpha(X,45)),p(X,45), if(gt(Y,alpha(X,47)),p(X,47), if(gt(Y,alpha(X,49)),p(X,49), if(gt(Y,alpha(X,51)),p(X,51), if(gt(Y,alpha(X,53)),p(X,53), if(gt(Y,alpha(X,55)),p(X,55), if(gt(Y,alpha(X,57)),p(X,57), if(gt(Y,alpha(X,59)),p(X,59), if(gt(Y,alpha(X,62)),p(X,62), if(gt(Y,alpha(X,64)),p(X,64), if(gt(Y,alpha(X,66)),p(X,66), if(gt(Y,alpha(X,68)),p(X,68), if(gt(Y,alpha(X,71)),p(X,71), if(gt(Y,alpha(X,73)),p(X,73), if(gt(Y,alpha(X,75)),p(X,75), if(gt(Y,alpha(X,78)),p(X,78), if(gt(Y,alpha(X,80)),p(X,80), if(gt(Y,alpha(X,83)),p(X,83), if(gt(Y,alpha(X,85)),p(X,85), if(gt(Y,alpha(X,88)),p(X,88), if(gt(Y,alpha(X,90)),p(X,90), if(gt(Y,alpha(X,93)),p(X,93), if(gt(Y,alpha(X,96)),p(X,96), if(gt(Y,alpha(X,98)),p(X,98), if(gt(Y,alpha(X,101)),p(X,101), if(gt(Y,alpha(X,104)),p(X,104), if(gt(Y,alpha(X,106)),p(X,106), if(gt(Y,alpha(X,109)),p(X,109), if(gt(Y,alpha(X,112)),p(X,112), if(gt(Y,alpha(X,115)),p(X,115), if(gt(Y,alpha(X,118)),p(X,118), if(gt(Y,alpha(X,121)),p(X,121), if(gt(Y,alpha(X,124)),p(X,124), if(gt(Y,alpha(X,127)),p(X,127), if(gt(Y,alpha(X,130)),p(X,130), if(gt(Y,alpha(X,133)),p(X,133), if(gt(Y,alpha(X,136)),p(X,136), if(gt(Y,alpha(X,139)),p(X,139), if(gt(Y,alpha(X,142)),p(X,142), if(gt(Y,alpha(X,146)),p(X,146), if(gt(Y,alpha(X,149)),p(X,149), if(gt(Y,alpha(X,152)),p(X,152), if(gt(Y,alpha(X,155)),p(X,155), if(gt(Y,alpha(X,159)),p(X,159), if(gt(Y,alpha(X,162)),p(X,162), if(gt(Y,alpha(X,166)),p(X,166), if(gt(Y,alpha(X,169)),p(X,169), if(gt(Y,alpha(X,172)),p(X,172), if(gt(Y,alpha(X,176)),p(X,176), if(gt(Y,alpha(X,180)),p(X,180), if(gt(Y,alpha(X,183)),p(X,183), if(gt(Y,alpha(X,187)),p(X,187), if(gt(Y,alpha(X,190)),p(X,190), if(gt(Y,alpha(X,194)),p(X,194), if(gt(Y,alpha(X,198)),p(X,198), if(gt(Y,alpha(X,202)),p(X,202), if(gt(Y,alpha(X,205)),p(X,205), if(gt(Y,alpha(X,209)),p(X,209), if(gt(Y,alpha(X,213)),p(X,213), if(gt(Y,alpha(X,217)),p(X,217), if(gt(Y,alpha(X,221)),p(X,221), if(gt(Y,alpha(X,225)),p(X,225), if(gt(Y,alpha(X,229)),p(X,229), if(gt(Y,alpha(X,233)),p(X,233), if(gt(Y,alpha(X,237)),p(X,237), if(gt(Y,alpha(X,241)),p(X,241), if(gt(Y,alpha(X,245)),p(X,245), if(gt(Y,alpha(X,249)),p(X,249), if(gt(Y,alpha(X,253)),p(X,253),145)))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))':a=255" -r 25 -qp 0 salida.mp4

¿Preguntas? ¿Dudas?

viernes, 27 de marzo de 2026

FFMPEG: Añadiendo pulsaciones del mando a tus gameplays en emulador

Algunos emuladores permiten grabar tus gameplays/speedruns de forma que las pulsaciones del mando se graban y luego se puede volver a reproducir ese mismo gameplay, pudiendo incluso exportar el gameplay a video.

Pues hay gente a la que le gusta ver las pulsaciones del mando a la vez que mira el gameplay, y no hay muchas herramientas para ello. Aquí os enseñare como se puede hacer tal cosa tan solo usando el codificador de video FFMPEG, nada de programas externos. Así es, solamente FFMPEG y sus filtros. Para este ejemplo usaré el emulador de NES Nestopia.

Este emulador en concreto guarda las grabaciones (comprimidas con ZLIB) en bloques de 8 bytes por frame donde cada byte representa uno de los 8 botones del mando de la NES. 0x40 cuando no esta pulsado, 0x41 cuando esta pulsado.
Muy bien, ¿y que hacemos con este archivo en FFMPEG? Pues resulta que podemos abrirlo como rawvideo. Le decimos a FFMPEG que tiene una resolucion de 8x1, que esta en escala de grises (1 byte por pixel), y le adaptamos el brillo y contraste para que se note la diferencia de 0x40 y 0x41.
Ahora si ponemos el video .AVI exportado y el rawvideo del mando uno encima del otro, nos quedaría algo así:

ffmpeg -f rawvideo -pixel_format gray -video_size 8x1 -framerate 60 -i input.dat -i v.avi -filter_complex [0]eq=brightness=1:contrast=6.1:gamma=10,scale=256:-1:flags=neighbor[k];[1][k]vstack v.mp4

¡Nada mal de momento! La sincronización de las pulsaciones en los pixeles de abajo es perfecta, pero ahora nos faltaria visualizarlo como un mando de NES. Para ello lo primero es dibujarnos nuestro mando de NES:

Ahora lo que haremos mediante el filtro geq es mapear las posiciones de los pixeles para que iluminen los botones correspondientes del mando que hemos dibujado. No hace falta complicarnos mucho con la forma exacta de los botones, luego haremos que iluminen solo las zonas grises con blend.
Ahora el resultado es el siguiente:



ffmpeg -f rawvideo -pixel_format gray -video_size 8x1 -framerate 60 -i input.dat -i controller.png -i v.avi -filter_complex [0]scale=256:128:flags=neighbor,eq=brightness=1:contrast=6.1:gamma=10,geq=g='if(gt(X,193),p(0,0),if(gt(X,163),p(32,0),if(gt(X,120),p(96,0),if(gt(X,90),p(64,0),if(gt(X,62),p(224,0),if(gt(X,48),if(gt(Y,77),p(160,0),if(lt(Y,64),p(128,0))),p(192,0)))))))':r=0:b=0[k];[k][1]blend=all_mode=hardlight[p];[2][p]vstack v.mp4
También podemos hacer que el mando aparezca sobre la imagen, e incluso que tenga un componente alpha. El resultado sería el siguiente:
ffmpeg -f rawvideo -pixel_format gray -video_size 8x1 -framerate 60 -i input.dat -loop 1 -i controller.png -i v.avi -filter_complex [0]scale=256:128:flags=neighbor,eq=brightness=1:contrast=6.1:gamma=10,geq=g='if(gt(X,193),p(0,0),if(gt(X,163),p(32,0),if(gt(X,120),p(96,0),if(gt(X,90),p(64,0),if(gt(X,62),p(224,0),if(gt(X,48),if(gt(Y,77),p(160,0),if(lt(Y,64),p(128,0))),p(192,0)))))))':r=0:b=0[k];[k][1]blend=all_mode=hardlight[p];[1]alphaextract[a];[p][a]alphamerge,scale=128:-1:flags=neighbor[pa];[2][pa]overlay=main_w-overlay_w:main_h-overlay_h:shortest=1 v.mp4
¿Que os ha parecido? ¿Que otras ideas teneis para implementar en FFMPEG?

jueves, 19 de marzo de 2026

Head On / Crash: Predecir los movimientos de la CPU

 Si eres viejo, como yo, es posible que recuerdes el juego Arcade "Head On" (Gremlin) o "Crash" (Exidy) de 1979. Básicamente los dos son una copia el uno del otro. Es un juego simple y adictivo, y que sirvió de inspiración para Pac-Man unos años más adelante, es el primero de la historia con el objetivo de recoger todos los "puntitos". En él llevas un cochecito que va en sentido anti-horario en un circuito de 4 carriles, mientras que otro cochecito va en dirección contraria en sentido horario intentando chocarse contigo, y si lo consigue: GAME OVER

Aquí en este video podeis ver el gameplay de la versión GameBoy, que es la que voy a desensamblar en esta ocasión.
Y pensareis... Pues el cochecito de la CPU intentará moverse siempre al mismo carril donde estemos nosotros para chocar. ¡Que fácil! ... Pues no. El cochecito de la CPU a veces hará cosas raras como moverse 2 carriles cuando solo estamos a 1 de distancia. ¡Incluso se puede dejar el juego en bucle infinito sin que choque nunca!
Más raro aun, en algunas ocasiones incluso se puede ver al cochecito de la CPU sin cambiar de carril pasando totalmente de nosotros. WTF?

Pues bien, llegó la hora de desensamblar, y por suerte el algoritmo que dicta al cochecito si debe girar o no esta contenida en una única función en 0x3efc que se llama cada frame
Si reemplazamos la llamada por NOPs, el cochecito de la CPU no girará nunca
Os voy a ahorrar leer ensamblador, así que os pondré las direcciones de memoria con las variables del juego que nos importan, y a continuación una traducción a lenguaje C:


0xC024 -> CPU_Y (Posicion Y del cochecito de la CPU)
0xC025 -> CPU_X (Posicion X del cochecito de la CPU)
0xC132 -> dir (Dirección del cochecito de la CPU 0-3. 0=IZQUIERDA,...)
0xC127 -> frame_giro (contador de frames que llevamos sin girar el cochecito de la CPU)
0xC138 -> velocidad (velocidad del cochecito. Incrementa al recolectar puntitos)
0xC13A -> flag_mov (¡este es importante! su valor {0x10, 0x20, 0x40, 0x80} cambia cada vez que los cochecitos del jugador, o de la CPU salen de una intersección entrando en los carriles, y cuando el cochecito de la CPU cambia de dirección "dir". Indica hacia donde debe moverse el cochecito de la CPU para girar y acercarse al cochecito del jugador. Si están en el mismo carril, valdrá 0x00)

if (velocidad==1) {
  frame_giro++;
  if (frame_giro!=4) return;
  frame_giro=0;
} else {
  frame_giro++;
  if (frame_giro!=2) return;
  frame_giro=0;
}

switch (dir) {
  case IZQUIERDA:
    if (CPU_X<0x48 || CPU_X>0x60) return;
    if (flagmov & 0x40) {
      CPU_Y=max(CPU_Y-1, 0X6F);
    } else if (flagmov & 0x80) {
      CPU_Y=min(CPU_Y+1, 0X93);
    }
    return;
......................................

Como veis el código es muy muy simple. Se divide en 2 bloques: el primero limita el giro a cada 2/4 frames dependiendo de la velocidad que tenga el cochecito de la CPU, y el segundo simplemente lo mueve en la dirección indicada por flagmov cuando se encuentre en una intersección sin salirnos de los limites. Realmente nunca controla si quiere moverse 1 o 2 carriles. Entonces... ¿que pasa? Centremonos en el primer bloque:
  • En velocidad 1 se desplaza lateralmente cada 4 frames, y así le da tiempo a desplazarse 2 carriles, aunque el jugador este solo a 1 de distancia.
  • En velocidad 2 se desplaza lateralmente cada 2 frames, y así también le da tiempo a desplazarse 2 carriles, aunque el jugador este solo a 1 de distancia.
  • En velocidad 3 y en adelante, se desplaza lateralmente cada 2 frames y solo le da tiempo a desplazarse 1 carril, aunque el jugador este a 2 o más de distancia.
Resumiendo, el coche de la CPU saltará siempre o 1 o 2 carriles dependiendo únicamente de la velocidad que tenga, moviendose siempre en dirección hacia el carril del jugador (esta dirección cambia cuando los coches salen de las intersecciones y entran de nuevo en los carriles)

Pero entonces... ¿Que ocurria cuando el cochecito de la CPU no cambiaba de carril y nos ignoraba?
Si veis el GIF detenidamente, veréis que justo antes de entrar al cruce, cambiaba de velocidad (1 -> 2)
⚠ ¡Alerta, bug! ⚠ si el valor de 0xC127 -> frame_giro en ese momento de cambiar velocidad vale 2 o 3, se seguirá incrementando sin control sin que llegue a valer de nuevo 2, y por lo tanto sin ejecutar ninguna lógica de giro durante los siguientes 254 frames, que es cuando el numero de 8bit hará overflow.

¿Que os ha parecido? Ahora que sabéis la lógica sigue el cochecito de la CPU, ya nunca veréis el juego de la misma forma. ¿preguntas? ¿dudas?...

jueves, 29 de enero de 2026

Flappy Adventure 3 - Nuevo juego para PS1

 Descarga ISO Aquí

Era un secreto a voces: Flappy Adventure 3, la entrega que pondría fin a la saga "Flappy Adventure" estaba en desarrollo, no para version web como planeaba originalmente, sino para PS1 igual que su predecesor Flappy Adventure X, al ser una plataforma para la cual apenas hay homebrew en la actualidad.

De hecho fue a finales de Octubre de 2024 cuando me puse a experimentar en la creación de un motor 3D decente para PS1, puesto que no existía ninguno de software libre y esto frenaba mucho la creación de homebrew para PS1 (El de Flappy Adventure X era muy simple).

Fue poco después en Noviembre, cuando viendo que el motor funcionaba e iba bien, decidí empezar la idea de crear una pequeña demo de como podría verse mi proyecto abandonado de "Flappy Adventure 3" en la PS1 con ese motor. Sería en 3ª persona, tal y como siempre había planeado, no sería "cel shading" pero si tendría una apariencia con colores vivos y poquitas texturas dándole un aspecto caricaturesco.

¡Y así empezó todo! Aunque el proyecto fue abandonado en varias ocasiones por pura dejadez, estando parado casi un año entero, finalmente "Flappy Adventure 3" vio la luz, con muchos recortes frente a la idea original, eso sí, puesto que el desarrollo se estaba alargando mucho para las no más de 10 personas que planeo podrían llegar a probar el juego.

⚫ La pantalla original de selección de niveles iba a ser el nivel 0 del juego basado en Minecraft, con portales que tele-transportarían al resto de niveles como en Flappy Adventure X, aunque descarté la idea por ser muy repetitiva (ya se hizo en FADVX) y demasiado lento para acceder al nivel concreto que quisiéramos. Al final terminó siendo un nivel más (nivel bonus)

⚫ Se descartó totalmente la funcionalidad de la Memory-Card puesto que uno de los mayores problemas que tuvo FADVX fue que debido a su alta dificultad, la mayoría de jugadores no alcanzaban a ver más que el primer nivel. Todos los niveles están desbloqueados desde el primer momento.

⚫ El nivel de la feria de Albacete iba a tener una noria gigante girando (es una idea que tenía en mente desde hace muchos años). No obstante, no supe ver como hacerla encajar con el nivel, y lo más que podía hacer es que nos ayudase a subir a cierta altura, aunque eso forzaría a añadir plataformas en el aire que no encajaban con el resto de nivel basado en el recinto ferial real en tierra.

⚫ La batalla final con Duolingo fue descartada totalmente por todo el tiempo que llevaría implementarla. En ella Duolingo se enfrentaría a Flappy con la escusa de: -"Flappy, ¿Cuanto tiempo llevas sin repasar tu nivel de esloveno?" A lo que Flappy contestaría que quien c**o es él y que para que iba a querer él aprender esloveno, enfadando a Duolingo e iniciándose así la batalla. Algunos de los samples de Duolingo al ser atacado por Flappy serían cosas inesperadas como: -"¡Auch!... que en japones se dice: (sonido de orgasmo)"
⚫ También contaría con una animación inicial al iniciar el juego mostrando a Flappy y su chica hablando en una cueva sobre su futuro con lluvia de fondo usando el motor del juego. En su lugar hice un video de repaso de todos los Flappy Adventures, que fue una idea de última hora y que me pareció una idea muy acertada para toda la gente que me preguntaba por: "¿Y donde están el Flappy Adventure 1 y 2?". Hacer la animación con el motor me habría llevado mucho tiempo para la poca gente que realmente le prestaría atención.
⚫ El juego planeaba tener más objetos dinámicos, pero con solo 5 niveles y un objetivo tan simple para completar los niveles (recoger las 30 monedas y llevarlas con la chica) me pareció innecesario.


Aun así, con todos los recortes, estoy bastante contento con el resultado del juego, que espero que con suerte lleguen a probar unas 10-20 personas, y es que no debemos olvidar que "Flappy Adventure X" fue un autentico fracaso en su recepción. Aunque fuese una obra maestra, al final si no tienes alcance, de nada sirve! Y como ya sabéis, no tengo ni los medios, ni la popularidad en redes sociales ni para que incluso los más fans de PS1 sepan siquiera de la existencia del proyecto. En este punto en 2026 solo hay 3 grandes proyectos para PS1 terminados o en desarrollo. El primero, Flappy Adventure 3 (obvio), el segundo, un port de Super Mario 64 para PS1, y por último un juego aun sin titulo desarrollado por Elias Daler (quien también hizo un minijuego para PS1 bastante bueno llamado Yume Nikki).
A día de hoy actualmente, hay miles y miles de personas siguiendo de cerca estos dos últimos. Una lastima que Flappy Adventure 3 jamas tendrá ni la milésima parte de ese seguimiento ni de cerca, quedando olvidado para siempre en la deep-web

Por último como curiosidad deciros que en el Nivel 1 hay una esquina "buggeada" donde quise poner al limite la colisión del Flappy Engine 3, ¡a ver si la encontrais! ...Ah, y que al igual que en Flappy Adventure 2 y Flappy Adventure X, los modelos del motor Flappy Engine son diseñados mediante el mítico Google SketchUp