Aprender sobre C++, Motores ECS e IA
| Sitio: | Moodle UA 2024-25 |
| Curso: | VIDEOJUEGOS I (21038) |
| Libro: | Aprender sobre C++, Motores ECS e IA |
| Imprimido por: | , Invitado |
| Día: | lunes, 11 de mayo de 2026, 00:30 |
Cómo aprender y qué recursos usar
¿Qué haremos en V1 y V2?
En V1 y V2 nos vamos a centrar en programar el motor del juego y la IA. Para el motor del juego utilizaremos arquitectura Entity-Component-System. Dentro de la IA veremos los algoritmos esenciales como los Steering Behaviours y los Behaviour Trees.
En todas las clases aprendemos a programar en C++ moderno (17, 20 y 23). Trabajaremos mucho C++ en los Challenges. Es muy importante que lo pongáis en práctica en vuestro ABP. Evitad programar como en cursos anteriores: cuánto más os esforcéis por adquirir nuevas y buenas prácticas de programación, más rápido irá vuestro proyecto y más aprenderéis.
Vale, ¿Cómo lo hago?
Primero de todo, conoce lo más básico de un ECS en C++ y aprende a hacerlo por ti mismx. Empieza por esta serie de 3 vídeos:
Una vez tengas las bases, organízate según tu rol: ¿Programarás parte del motor o te centrarás en la IA?
Para programadores del Motor
Hay 2 series de vídeos y un libro sobre programación del motor ECS. Te explico los detalles:
- Serie de 2020 (2020. GameEngine ECS desde 0 en C++17)
- Descripción: empieza desde 0, enseñando makefiles, C++, cómo abrir una ventana básica y dibujar tus propios sprites y cómo empezaríamos con un motor usando herencia, que es lo que conocéis de cursos anteriores. Llegado un punto, vemos que eso se complica y no es la forma de hacerlo. Eso nos enseña para qué sirven las templates. Vemos templates, cambiamos el motor y lo terminamos. Todo mientras aprendemos C++17.
- Ventajas: es más sencillo y os ayuda a ir aprendiendo más paso a paso, desde lo que sabéis. El motor es más sencillo de implementar. Puedes apoyarte en el Libro de Laureano Canto Berná (ECS).
- Inconvenientes: El motor es mucho menos flexible y menos óptimo. No aprendéis técnicas avanzadas.
- Libro de Laureano Canto Berná (ECS)
- Escrito por Laureano en su TFG en 2022, es un libro que explica cómo crear un ECS básico en base a 3 mini-proyectos. Cubre principalmente las ideas básicas y las ideas de la Serie de 2020, con algunos detalles de la Serie de 2022. Es un apoyo muy interesante porque da más ideas y ejemplos que no están en los vídeos de teoría y os puede ayudar a aprender más.
- Serie de 2022 (2022. GameEngine ECS C++20 Avanzado)
- Un ECS completo, desarrollado usando templates y técnicas de programación modernas. Es muchísimo más óptimo y flexible. Permite crear muchos más tipos de componentes, tags y sistemas. Los sistemas son más fáciles de manejar por el equipo de desarrollo y hace que el trabajo en equipo sea más rápido y avanzado. Se pueden realizar muchas más funcionalidades avanzadas con más facilidad y menos esfuerzo. Eso sí, es tecnológicamente más avanzado, por lo que requiere entender muchos más conceptos y alcanzar mayor nivel de programación. Este es el motor que se usa en la serie sobre IA.
- Ventajas: Más flexible y óptimo. Se aprenden muchas cosas de C++ moderno y orientación a datos que son importantes para la industria. Da muchas más funcionalidades al equipo, permitiendo hacer sistemas y componentes más versátiles y fáciles de crear.
- Inconvenientes: Más avanzado. Es recomendable haber empezado primero por el ECS más sencillo antes de abordar este (aunque no necesario). Muchos conceptos que entender. Hay que llegar a un nivel más alto en programación, por lo que requiere más esfuerzo. No está explicado en el libro, sólo en los vídeos.
Puedes realizar cualquiera de los dos. Con los dos podréis hacer vuestros juegos y tener buenos resultados. Debes decidir, sobre todo, en función de cuáles sean vuestros objetivos de aprendizaje, producto y portfolio. También, en función de vuestro nivel de programación, el tiempo que esperéis dedicar y el nivel que queráis alcanzar.
Otra posibilidad es empezar por el más sencillo y progresar al más avanzado si os véis avanzando bien y entendiendo el material. En el motor sencillo tenéis vídeos y libro para apoyaros.
Para programadores de gameplay e IA
Este es tu caso si vas a usar el motor, pero no vas a involucrarte en su programación de forma muy directa. Aunque no lo vayas a programar, necesitas tener conocimientos de cómo funciona y cómo está programado. Por eso, es conveniente que trabajes cómo mínimo el libro de laureano y algunos vídeos de la serie con la que está implementado tu motor.
Los vídeos de IA de 2021 utilizan un motor como el de la serie de 2022. Si cuando veas la serie no entiendes bien cómo creo y utilizo los sistemas, apoyate en los vídeos de 2022 para entenderlo y haz algunas prácticas de crear partes de tu propio motor. Necesitas entenderlo bien y saber usarlo.
Serie de IA (2021. IA en C++ desde 0)
- Steering Behaviours
- Decision Trees
- Behaviour Trees
- ImGui
- Ensamblador x86-32
Quiero saber más
Si quieres saber más sobre motores y orientación a datos, te recomiendo el libro de Richard Fabian, Data Oriented Design. Es un libro más teórico y avanzando, pero muy bueno. Si de verdad quieres profundizar en estos temas y entender muchas más cosas, estando preparado para hacer mejores diseños y para trabajar en esto en la industria, este es tu libro.
Entendiendo los objetos en C++
En C++, todo lo que los programas hacen es manipular objetos: los crean y destruyen, los leen, escriben y operan. Necesitamos comprender y dominar cómo los objetos se representan en memoria y cómo se manejan sus propiedades. Veamos qué es un objeto en C++, qué características tiene, cómo se gestiona su memoria y cómo funciona.
Objetos en C++
- Definición: un objeto es una región de memoria con una semántica asociada.
- Región de memoria: es un término genérico, pero se refiere a un espacio de memoria accesible por la CPU, es decur, RAM, Registros o incluso ROM. Como la unidad mínima es el byte, este espacio tendrá un número entero de bytes.
- Semántica asociada: define qué significan los posibles valores almacenados en el objeto, y qué operaciones están lógicamente permitidas sobre ese objeto (y, por tanto, qué otras cosas no lo están). Es la forma de explicar qué significa cualquier valor concreto de ese objeto y, también, de definir el conjunto de valores permitidos (llamados invariantes de un objeto).
Los programas crean, destruyen, se refieren a, y manipulan objetos.
Nota: para referirse a un objeto en memoria, sin importar cuanto ocupa, se indica siempre la dirección de su primer byte. Como sabemos su tamaño, sabiendo su primer byte, podemos leerlo o modificarlo sin equivocarnos.
Un objeto en C++ tiene:
- Tamaño (size): número de bytes que ocupa. Se puede saber usando el operador
sizeof.- Ejemplo:
int vidas = 25;
std::print("Tamaño de 'vidas' = {}\n", sizeof(vidas));
- Ejemplo:
- Alineamiento requerido (alignment): múltiplo de byte en memoria donde puede estar el inicio del objeto. Podemos saberlo con el operador
alignof. - Duración de almacenamiento (storage duration): es una condición que determina cuánto tiempo será válido el objeto.
- Automático (automatic): se crea en un ámbito (scope) y se destruye al terminar ese ámbito. Solemos llamarlo "local",
- Estático (static): se crea antes de empezar
main()y se destruye tras terminarmain(). Tiene la misma duración que el programa. - Dinámico (dynamic): lo creamos y destruimos manualmente a conveniencia (con
new/mallocydelete/free). - Local a hilo (thread-local): que pertence exclusivamente a un hilo de ejecución, y su creación y destrucción dependerá de la vida del hilo.
- Tiempo de vida (lifetime): intervalo de tiempo desde que un objeto se crea hasta que se destruye. Tiene dos posibles condiciones:
- Está limitado por el tipo de duración de almacenamiento (storage duration) del objeto.
- Puede ser temporal: en objetos que sólo existen durante la evaluación de la expresión en que están involucrados.
- Ejemplo:
// 25 es un objeto temporal. Deja de existir tras ejecutar esta línea int vidas = 25; // Se llama a random(1,10) y eso devuelve un int temporal. // Se suma 100 + el int temporal y eso produce otro int temporal. // El objeto health se inicializa con el último int temporal. // Todos los temporales dejan de existir tras esta línea. float health = 100 + random(1, 10);
- Ejemplo:
- Tipo (type): clase dentro de los posibles tipos de objeto. Determina los invariantes y el conjunto de operaciones válidas.
- Valor (value): el valor que tiene el objeto en un instante, que puede ser indeterminado, por ejemplo, en la inicialización por defecto (default-initialized).
- Nota: indeterminado quiere decir que tiene un valor, pero que no podemos saber cuál es. Si se usa una posición de memoria para el objeto, pero no se escribe en ella un valor inicial (inicializar), tendrá un valor previo que no sabemos cuál es. Es indeterminado.
- Nombre (name) --Opcional--: los objetos pueden tener un nombre y, entonces, pueden ser variables.
Variable: un objeto o referencia que se introduce con una (declaration) y que no es ni un dato miembro, ni un miembro no-estatico.
Nota: un dato miembro es un campo de una estructura o clase, que no es estático. Los datos estáticos, aunque se declaren dentro de una clase, son únicos y globales como cualquier estático. Se crean antes de que empiece el programa y existen hasta su terminación. Por eso son únicos y no dependen de la creación de objetos de estructura o clase. Son, a todos los efectos, datos estáticos globales a los que limitamos su visibilidad.
Ejemplos
Considera el siguiente código:
10 | int main () {
11 | int vidas = 25; // Un objeto tipo int que ocupa 4 bytes
...| //...
56 | }
En este ejemplo, vidas es un objeto en C++ con:
- Nombre:
vidas(al tener nombre, es una variable) - Tamaño: 4 bytes
- Valor: 25
- Tipo:
int(indica sus semánticas asociadas: se representará en binario como un número aritmético estándar, y admitirá operaciones aritméticas como suma, resta, multiplicación, etc.) - Alineamiento: 4 bytes (sólo puede estar en memoria en celdillas múltiplo de 4 bytes)
- Storage duration: Automático (se crea en el stack y se libera al terminar su scope/ámbito)
- Tiempo de vida: Líneas 11 a 56 (se crea al ejecutar la 11, se libera al ejecutar la 56)
Considera ahora este otro código:
04 | // Definición de estructura Entity que ocupará 32 bytes
05 | struct Entity {
06 | int x, y, vx, vy; // 4 ints = 16 bytes
07 | double attrs[2]; // 2 doubles = 16 bytes
08 | };
.. |
40 | int main () {
41 | static Entity* ep = new Entity();
.. | //...
56 | }
En este ejemplo se crean 2 objetos en la línea 41:
- La variable
ep - Un objeto de tipo
Entitysin nombre
El objeto sin nombre se crea en el free-store (Heap) y sólo podemos acceder a él a través de su dirección de memoria. Para eso usamos la variable ep, para almacenar el valor del puntero a Entity que nos devuelve la expresión new y que nos dice dónde está el nuevo objeto sin nombre.
Así pues, hemos creado 2 objetos:
| Propiedad | ep |
Objeto Sin-Nombre (OSN) |
|---|---|---|
| Tamaño | 8 bytes (en CPUs de 64 bits) | 32 bytes |
| Alineamiento | 8 bytes (en CPUs de 64 bits) | 8 bytes (campo más grande, double) |
| Storage duration | Estático (global) | Dinámico (gestión manual) |
| Tiempo de Vida | Desde la línea 41 hasta el final del programa | Desde la línea 41 hasta que se llame a delete |
| Tipo | Entity* |
Entity |
| Nombre | ep |
No tiene nombre |
| Valor | Dirección de memoria del OSN | Indeterminado (Entity no tiene constructor ni valores por defecto) |
Importante: cosas que NO son objetos
Estas "entidades" no son objetos:
- Un valor (value)
- Una referencia (reference)
- Una función (function)
- Un enumerador (enumerator)
- Un tipo (type)
- Un miembro de clase no-estático (non-static class member)
- Una plantilla (template)
- Una clase (class)
- Una especialización de plantilla de función (function template specialization)
- Un espacio de nombres (namespace)
- Un pack de parámetros (parameter pack)
- La expresión
this(this)
Creación y Destrucción de Objetos en C++
Las palabras crear y destruir en C++ suelen dar problemas. Es importante que las conozcamos con precisión.
- Crear: decidir un lugar exacto de memoria donde estará el objeto e inicializarlo (escribir su valor inicial). A partir de ese momento, se dice que el objeto ha sido creado y existe.
- Destruir: dejar de usar la memoria del objeto, quedando libre para otros usos.
Esto es lo básico, pero hay matices y detalles. Vamos a explorarlos con precisión.
Creación de Objetos
La creación de un objeto implica reservar una región de memoria (elegir un sitio, decidir dónde va) e inicializar esa región con un valor específico. A la inicialización la llamamos construcción, y es la labor del constructor. Los objetos que no tienen un constructor por defecto, ni se les asigna un valor inicial, quedan sin inicializar, por lo que su valor es indeterminado.
Existen diversas formas de crear objetos dependiendo de la duración de almacenamiento:
-
Automático: Los objetos se crean automáticamente cuando se entra en su ámbito (scope). Se elige un espacio en la pila (stack, que está en la RAM).
-
Estático: Los objetos con almacenamiento estático se crean antes de la ejecución del
main()y se destruyen después de terminarlo. La región de memoria que usan suele elegirse (reservarse) al cargar el programa en memoria. -
Dinámico: Los objetos se crean cuando se ejecuta
newomalloc. Se elige un sitio para ellos en el free-store (reserva en el Heap). Este espacio permanece disponible hasta que ejecutadeleteofree, indicando que queda libre para otros usos. -
Local a hilo: Se crean cuando un hilo comienza y existen solo mientras el hilo esté activo.
Destrucción de Objetos
La destrucción de un objeto implica dejar de usar su región de memoria, por lo que quedará libre para otros usos. Algunos objetos complejos son responsables de la gestión de otros objetos o recursos, por lo que necesitan ejecutar código antes de destruirse, para liberar los recursos que gestionan. Esta lógica es la que va en su destructor. A estos objetos se les llama no-trivialmente-destruibles.
Según la duración de sus almacenamientos:
-
Automático: se destruyen automáticamente cuando se sale del ámbito (scope) donde fueron creados. Es al final del bloque donde el objeto está definido.
-
Estático: se destruyen después de finalizar
main(). -
Dinámico: deben ser liberados explícitamente usando
deleteofree. De lo contrario, se producirá una fuga de memoria (memory leak), pues sus regiones de memoria permanecen en uso. -
Local a hilo: se destruyen cuando el hilo termina.