Motor de VideoJuegos optimizado para Python

Hace algunos dias me propuse el desafió de hacer un motor 2D que corra en un único hilo, sobre la API de SDL3. Logré, en mi opinión, una buena interfaz de uso que decidí pasar a python (aunque la interfaz del motor en python con ctypes fue con ayuda de la ia).

En un único hilo de 2Ghz (de mi Intel i3, dónde hice las pruebas) pude mantener 60 FPS hasta con 1200 entidades renderizandose en la pantalla.

El motor tiene gestión de eventos por teclado, y entidades que se dividen en sprites estaticos y spriteas animados.

Para poder subir este artículo fuera de off topic, decidí publicar el código fuente en la GNU Public v3. Aunque me interesa más crear mis proyectos que unirme a proyectos ya existentes, cuestión de gusto.

Fuente del motor en C, en el Makefile esta el script para compilar el .so del motor, junto con la interfaz en python del motor (no editar para un correcto funcionamiento) y un test que la usa.

Primero que nada, aquí un ejemplo de cómo usar:

from interface import *

# ------------------------------------------------------------
# Ejemplo de uso básico
# ------------------------------------------------------------

# Primero el condicional para arrancar
if __name__ == "__main__":
	# Creación de la ventana, use una 'b' antes del nombre, evite bugs no deseados
	app = Aplication(b"My Game")
	# Sea crea el gestor de entidades donde creará los personajes de su juego
	# app, man pueden tener otros nombres, eliga
	man = app.CreateEntityManager()

	# Luego cargamos un sprite, y le damos un nombre
	# Se guardan en una 'biblioteca' separada de las entidades
	# Si es estatico, no use el metodo SetFrame()
	# Si es animado, cree sus frames 'stop motion' todos en el mismo png
	# Se divide en columnas y filas, todas iguales, le tiene que avisar al motor la forma
	# nameEntityManager.SetFrame(entidad,numero_de_columnas,numero_de_filas,AnimationToken.DYNAMIC)
	# AnimationToken.DYNAMIC para animación
	# AnimationToken.STATIC para sprite fijo
	man.LoadSprite("assets/Animacion.png","SPRITE")
	# Creamos la entidad, asignamos un sprite cargado con el metodo LoadSprite
	# Mandamos el nombre del Sprite en el parametro primero, en el siguiente el nombre de la entidad
	man.CreateEntity("SPRITE","CAPA")
	# Ahora, usamos el nombre de la entidad para buscarla y cambiarle algunos atributos
	# e será la entidad, si se encuentra
	e = man.SearchEntity("CAPA")
	# Si el sprite es animado, aqui se asigna cómo es la animación
	# nameEntityManager.SetFrame(entidad,numero_de_columnas,numero_de_filas,AnimationToken.DYNAMIC)
	# AnimationToken.DYNAMIC para animación
	# AnimationToken.STATIC para sprite fijo
	man.SetFrame(e,2,2,AnimationToken.DYNAMIC)
	# Con SetDimension() podemos escalar la entidad
	# gestor_de_entidades.SetDimension(entidad,escala)
	# menos a uno achica, mayor agranda
	man.SetDimension(e,0.5)
	# Asignamos valores para programar la jugabilidad
	speed=10
	x=100
	y=100
	# La siguiente linea inicia el bucle principal
	# Con while, not, ya que app.EventProcess() devuelve false si no se cierra
	# Se cierra con la x de la ventana y con 'Esc' o Escape
	while not app.EventProcess():
		# Se recomienda buscar siempre la entidad
		# El manager guarda 1000 entidades, si se superan
		# el manager hace una nueva reserva duplicando su capacidad
		# Lo que cambia las direcciones de memoria de las entidades
		# Y puede provocar una violación de segmento si no se renueva
		# Por lo menos vuelva a hacer las busquedas cada vez que agrega una entidad
		e = man.SearchEntity("CAPA")
		# Así quedo la API de eventos, copia de la de SDL3
		# Con el ejemplo de cómo mover la entidad
		if app.GetEvent(EventKeys.UP):
			y-=speed
		if app.GetEvent(EventKeys.DOWN):
			y+=speed
		if app.GetEvent(EventKeys.RIGHT):
			x+=speed
		if app.GetEvent(EventKeys.LEFT):
			x-=speed
		# Asignanos la posicion que trabajo la logica a la entidad
		# El motor ya tiene motor de físicas, dando los siguientes atributos
		# SetPosition() SetVelocity() SetAceleration()
		# Todos usados de la misma forma, pero con interacciones diferentes
		# Segun la fisica
		man.SetPosition(e,x,y)
		# Entre app.DrawBegin() y app.DrawEnd() se dibujan las entidades
		app.DrawBegin()
		# man.Draw() dibuja todas las entidades en un bucle for each
		# Hay que pasarle el delta time y la camara
		# El app.GetDeltaTime() se puede usar en python
		# Aunque el motor ya lo usa por vos en las animaciones y las fisicas
		man.Draw(app.GetDeltaTime(),app.GetCam())
		app.DrawEnd()
	# Puesto que es un motor en C, hay que liberar la aplicacion y el manager
	man.Free()
	app.Quit()
	# El motor implementa un sistema de camara con lo que se puede desplazar
	# Con las teclas W A S D, moviendo la camara, evite usar eso con app.GetEvent(EventKeys.Tecla)
	# Estoy antento a ideas nuevas, y futuras implementaciones del motor, si les interesa
	# Me despido, saludos

4 Me gusta

Muchas gracias por el aporte.

1 me gusta

Perdón por mi desconocimiento técnico de hace dos días.

Realmente el motor puede renderizar muchas más entidades.

El sprite que usaba para los test tenía, sin redimensionar, 35000 pixeles, y eso hacia que pueda hasta 1300 entidades en mi pc.

El motor solo usa un hilo (que en mi caso es de 2Ghz, Intel i3), y puede, en esas condiciones, renderizar hasta 45000000 (45 millones) de pixeles a 60 FPS.

De forma que si se usan sprites de 50x50, se pueden alcanzar, sin problemas, más de 20000 (20 mil) entidades en pantalla.

Hay un truco, si no aparecen todas en pantalla, puede más, son hasta 45 millones de pixeles bajo camara (en pantalla) (en 2Ghz).

En esos numeros, estoy más motivado para continuar con mi RTS de Historia para móvil y pc…

3 Me gusta

Buenas, más o menos hace 5 minutos desde que escribo esto acabo de hacer un pequeño pull requests a tu repositorio, añadiendo el Makefile para compilar todo a una libreria .so por si se hacen cambios al motor como tal, y tambien un pequeño build.sh para instalar las librerias tanto como para Debian/Ubuntu como Arch Linux. El Makefile lo hice basandome en tu repositorio FrameWork-Phisics, note que tienen un gran parecido y lo agarre de ahí haciendo sus debidas modificaciones.

Hice esto porque me llama la atención el proyecto que estas haciendo, tengo planeado muy pronto hacerle un sistema de colisiones, por eso mi idea del Makefile porque note que faltaba uno.

3 Me gusta

Hola, agradezco muchisimo a que quieran participar en mi proyecto.

Edit: Ahí vi, hiciste una referencia al motor de físicas y me confundí.

En cuanto al motor de físicas, hay dos formas que pensaba:

bool SDL_HasRectIntersectionFloat(SDL_FRect *rect1,SDL_FRect *rect2);

Que da la intersección entre ambas entidades. Sino, calcular la distancia entre las dos posiciones, que se haría:

||λ-ν|| tal que λ,ν pertenecen a R²

Eso da la distancia entre ambas, luego se calcula sobre la suma del radio de ambos, y se determina si se tocan.

Un area de colisión circular, consume la misma cantidad de recursos tanto en 2D cómo en 3D.

// Ejemplo de area circular
#include <math.h>
typedef struct{ float x,y; }Vec2;
bool IntersectionEntities(SDL_FRect Entity1,SDL_FRect Entity2)
{
	// Position in the center
	Vec2 PositionEntity1 = {
		.x= Entity1.x + (Entity1.w/2),
		.y= Entity1.y + (Entity1.h/2)
	};
	Vec2 PositionEntity2 = {
		.x= Entity2.x + (Entity2.w/2),
		.y= Entity2.y + (Entity2.h/2)
	};
	// Vectors = end - begin
	Vec2 res = {
		.x=PositionEntity2.x - PositionEntity1.x,
		.y=PositionEntity2.y - PositionEntity1.y
	};
	// Pitagoras => distance for two points
	double module = sqrt(res.x*res.x + res.y*res.y);
	// Radio => with WIDTH Entity or HEIGHT Entity
	// Example => with WIDTH
	double addRadio = (Entity1.w/2) + (Entity2.w/2);
	if(module <= addRadio) return true;
	else return false;
}

Incluyo, si te ganas mi confianza, puedo darte permisos de escritura directamente en el proyecto.

Muchas Gracias!

2 Me gusta

Pensando en un algoritmo de colisiones, lo mejor es que dos entidades no calculen dos veces entre sí.

Por ejemplo, la entidad uno calcula con la dos, y cuando la entidad dos va a calcular sus colisiones, calcula con la una, se repitieron.

De esa forma, hagamos un bucle que calcule las colisiones de cada una, luego le metemos otro bucle pero le restamos las anteriores a la que le toca, mientras más avanzado este el vector de entidades, menos colisiones tiene que calcular.

Entity *ent_1 = Vector_getValue(man->entities,0); // First element
for(uint32_t i=0; i<man->entities->size; ++i){ // All vector
	Entity *ent_2 = ent_1 + 1;
	for(uint32_t j=0; j<man->entities->size-1-i; j++)
	// man->entities->size => elem1=1, elem2=2, begin in 1 not 0
		DetectColision(ent_1,(ent_2++)); // For example, circle or rect
		// ent_2++ => pass ent_2 then increment
	ent_1++;
}
4 Me gusta

Para compartir el desarrollo, se está preparando un avance de la interfaz de uso.

typedef struct{
/* Resto de datos */
	uint64_t ID; // Id de identificación, evitando exponer los punteros a entidades
}Entity;

Luego, implementar un sistema de búsqueda por hash y binario, en lugar de la obsoleta búsqueda secuencial.

#include <stb/stb_ds.h>

typedef struct{ // Así hay que pasarselo a stb_ds.h
	const char*key;
	size_t value;
}TableHash;

typedef struct{
	Vector* entities;
	SDL_Renderer *renderer;
	TextureManager tman;
	TableHash *TableHash_Entities;
	uint64_t countIDs;
}EntityManager;

En el init de las entidades, inicializamos la id

void InitEntity(Entity* e,Texture *texture,char *id,SDL_Renderer *renderer)
{ 	e->ID = 0; }

Inicializamos en el entity manager el contador, para generar ids lineales, si son ordenadas se puede usar búsqueda binaria.

void InitEntityManager(EntityManager *man,SDL_Renderer *renderer)
{
man->countIDs = 1;
man->TableHash_Entities = nullptr;
}

Se crea la entidad, con el nombre de su sprite (no el path), y su propio nombre de identificación que se usará para la tabla hash.

void CreateEntity(EntityManager *man,char* sprite,char *name)
{
	Entity e;
	Texture *t = Search_TextureManager(&man->tman,sprite);
	InitEntity(&e,t,name,man->renderer);
	e.STATESPRITE = ANIMATION_TOKEN_STATIC;
	e.ID = man->countIDs++;
	Vec2Zero(&e.position);
	Vec2Zero(&e.velocity);
	Vec2Zero(&e.aceleration);
	shput(man->TableHash_Entities,name,e.ID); // Se añade a la tabla hash
	Vector_pushback(man->entities,&e);
}

Por último, las funciones para buscar la id de la entidad por su nombre, y buscar la entidad por su id

uint64_t getID_SearchEntity(EntityManager *man,const char*name)
{
	/* Method Entity Manager Public */
	if(!man->entities->size) return 0;
	int res = man->TableHash_Entities[shgeti(man->TableHash_Entities,name)].value;
	return (res != -1) ? res : 0;
}
Entity *getEntityByID(EntityManager *man,const uint64_t ID)
{
	/* Method of EntityManager private | Search Binary */
	Entity *e = Vector_getValue(man->entities,0); // first element
	uint64_t start = 0;
	uint64_t ptr = 0;
	uint64_t end = man->entities->size-1; // An element size=1
	while(end >= start){
		ptr = (start + end)/2;
		if((e+ptr)->ID == ID) return (e+ptr);
		else if((e+ptr)->ID < ID) start = ptr+1;
		else end = ptr-1;
	}
	return nullptr;
}
/* Start API setter public EntityManager */




/* End API setter public EntityManager */

Puesto que el objetivo es que los punteros sean de uso interno del gestor. La función getEntityByID() pasa a ser de uso privado (no definida en el .h)

Y, preparando el sector para las funciones setter que modificaran los atributos de las entidades.

De esa forma, es más seguro usar el motor desde su interfaz de python.

Por último, también me interesan los gráficos 3D, de forma que podría intentar, luego, hacer un motor 3D en python que puede renderizar más de 1000, 2000 o más (contando con las colisiones, sino mucho más) de entidades.

Un saludo.

2 Me gusta

Avances del motor:

→ Motor de físicas y coliciones en hilo hijo. Es seguro crear entidades y ejecutar física a la vez desde hilos secundarios. Se maneja con SDL_Mutex, y con la concurrencia, dos hilos no intentarán escribir la misma memoria.

→ Depende del procesador de cada uno, con 2Ghz se alcanzan 7000 entidades con colisiones activas. (Se puede más, no bajan los FPS, pero se siente lag)

Ejemplo nuevo, con las actualizaciones:

from interface import *
import random
# ------------------------------------------------------------
# Ejemplo de uso básico
# ------------------------------------------------------------
if __name__ == "__main__":
	app = Aplication(b"My Game")
	man = app.CreateEntityManager()
	man.ActivateColisions(True)
	man.LoadSprite("assets/Animacion.png", "SPRITE")
	man.LoadSprite("assets/CONFIG.png", "BALL")
	man.CreateEntity("SPRITE", "CAPA")
	e = man.SearchEntity("CAPA").contents
	man.SetFrame(e, 2, 2, AnimationToken.DYNAMIC)
	man.SetDimension(e, 0.5)
	speed=10
	x=100
	y=100
	num=0
	while not app.EventProcess():
		app.DrawBegin()
		if app.GetEvent(EventKeys.UP):
			y-=speed
		if app.GetEvent(EventKeys.DOWN):
			y+=speed
		if app.GetEvent(EventKeys.RIGHT):
			x+=speed
		if app.GetEvent(EventKeys.LEFT):
			x-=speed
		if app.GetEvent(EventKeys.P):
			state=False
			for i in range(0,500):
				man.CreateEntity("BALL",f"ENTITY_{num}")
				o = man.SearchEntity(f"ENTITY_{num}").contents
				man.SetDimension(o,0.5)
				o.SetStateColisions(True)
				o.SetPosition(random.randint(0,640),random.randint(0,360))
				if not state:
					o.SetVelocity(0.1,0.0)
				else:
					o.SetVelocity(0.0,0.1)
				num += 1
				print(f"\033cEntities => {num}\nFPS => {app.GetFPS()}\nMem => {app.GetMem()}")
		e = man.SearchEntity("CAPA")
		man.SetPosition(e,x,y)
		man.Draw(app.GetDeltaTime(),app.GetCam())
		app.DrawEnd()
	man.Free()
	app.Quit()

Cosas pendientes:

→ No exponer punteros a entidades a python, reemplazando por sistema de ids, lo que mejorará las busquedas de entidades (hash y búsqueda binaria | Implementación al 70%).

→ Gestor de errores (se está encargando @dr4cu )

→ Sistema de cámara (empezando) (mundos más grandes que el tamaño de la ventana)

Objetivos del motor:

→ Hacer juegos de plataformas

→ Hacer juegos RTS (por el proyecto padre del presente motor, ya que la interfaz en python parte de un fork)

→ Hacer intefaces gráficas (herencia del proyecto padre, también) para los menús