Documentos de Académico
Documentos de Profesional
Documentos de Cultura
Curso Desarrollo Videojuegos 3ed
Curso Desarrollo Videojuegos 3ed
Creative Commons License: Usted es libre de copiar, distribuir y comunicar públicamente la obra, bajo
las condiciones siguientes: 1. Reconocimiento. Debe reconocer los créditos de la obra de la manera
especificada por el autor o el licenciador. 2. No comercial. No puede utilizar esta obra para fines
comerciales. 3. Sin obras derivadas. No se puede alterar, transformar o generar una obra derivada a
partir de esta obra. Más información en: http://creativecommons.org/licenses/by-nc-nd/3.0/
Prefacio
1. Arquitectura del Motor, donde se estudian los aspectos esenciales del diseño
de un motor de videojuegos, así como las técnicas básicas de programación
y patrones de diseño. En este bloque también se estudian los conceptos más
relevantes del lenguaje de programación C++.
2. Programación Gráfica, donde se presta especial atención a los algoritmos y
técnicas de representación gráfica, junto con las optimizaciones en sistemas de
despliegue interactivo.
3. Técnicas Avanzadas, donde se recogen ciertos aspectos avanzados, como es-
tructuras de datos específicas, técnicas de validación y pruebas o simulación
física. Así mismo, en este bloque se profundiza en el lenguaje C++.
4. Desarrollo de Componentes, donde, finalmente, se detallan ciertos componen-
tes específicos del motor, como la Inteligencia Artificial, Networking, Sonido y
Multimedia o técnicas avanzadas de Interacción.
Sobre este libro
Este libro que tienes en tus manos es una ampliación y revisión de los apuntes del
Curso de Experto en Desarrollo de Videojuegos, impartido en la Escuela Superior de
Informática de Ciudad Real de la Universidad de Castilla-La Mancha. Puedes obtener
más información sobre el curso, así como los resultados de los trabajos creados por
los alumnos, en la web del mismo: http://www.cedv.es. La versión electrónica de este
libro puede descargarse desde la web anterior. El libro «físico» puede adquirirse desde
la página web de la editorial online Edlibrix en http://www.shoplibrix.com.
Requisitos previos
Este libro tiene un público objetivo con un perfil principalmente técnico. Al igual
que el curso del que surgió, está orientado a la capacitación de profesionales de la
programación de videojuegos. De esta forma, este libro no está orientado para un
público de perfil artístico (modeladores, animadores, músicos, etc.) en el ámbito de
los videojuegos.
Se asume que el lector es capaz de desarrollar programas de nivel medio en C
y C++. Aunque se describen algunos aspectos clave de C++ a modo de resumen, es
recomendable refrescar los conceptos básicos con alguno de los libros recogidos en
la bibliografía. De igual modo, se asume que el lector tiene conocimientos de estruc-
turas de datos y algoritmia. El libro está orientado principalmente para titulados o
estudiantes de últimos cursos de Ingeniería en Informática.
Agradecimientos
Los autores del libro quieren agradecer, en primer lugar, a los alumnos de las tres
primeras ediciones del Curso de Experto en Desarrollo de Videojuegos por su partici-
pación en el mismo y el excelente ambiente en las clases, las cuestiones planteadas y
la pasión demostrada en el desarrollo de todos los trabajos.
De igual modo, se quiere reflejar el agradecimiento especial al personal de admi-
nistración y servicios de la Escuela Superior de Informática, por su soporte, predis-
posición y ayuda en todos los caprichosos requisitos que planteábamos a lo largo del
curso.
Por otra parte, este agradecimiento también se hace extensivo a la Escuela de In-
formatica de Ciudad Real y al Departamento de Tecnologías y Sistema de Información
de la Universidad de Castilla-La Mancha.
Finalmente, los autores desean agradecer su participación a los colaboradores de
las tres primeras ediciones: Indra Software Labs, la asociación de desarrolladores de
videojuegos Stratos, Libro Virtual, Devilish Games, Dolores Entertainment, From the
Bench, Iberlynx, KitMaker, Playspace, Totemcat/Materia Works y ZuinqStudio.
Autores
1. Introducción 3
1.1. El desarrollo de videojuegos . . . . . . . . . . . . . . . . . . . . . . 3
1.1.1. La industria del videojuego. Presente y futuro . . . . . . . . . 3
1.1.2. Estructura típica de un equipo de desarrollo . . . . . . . . . . 5
1.1.3. El concepto de juego . . . . . . . . . . . . . . . . . . . . . . 7
1.1.4. Motor de juego . . . . . . . . . . . . . . . . . . . . . . . . . 9
1.1.5. Géneros de juegos . . . . . . . . . . . . . . . . . . . . . . . 10
1.2. Arquitectura del motor. Visión general . . . . . . . . . . . . . . . . . 15
1.2.1. Hardware, drivers y sistema operativo . . . . . . . . . . . . . 15
1.2.2. SDKs y middlewares . . . . . . . . . . . . . . . . . . . . . . 16
1.2.3. Capa independiente de la plataforma . . . . . . . . . . . . . . 17
1.2.4. Subsistemas principales . . . . . . . . . . . . . . . . . . . . 18
1.2.5. Gestor de recursos . . . . . . . . . . . . . . . . . . . . . . . 18
1.2.6. Motor de rendering . . . . . . . . . . . . . . . . . . . . . . . 19
1.2.7. Herramientas de depuración . . . . . . . . . . . . . . . . . . 22
1.2.8. Motor de física . . . . . . . . . . . . . . . . . . . . . . . . . 22
1.2.9. Interfaces de usuario . . . . . . . . . . . . . . . . . . . . . . 23
1.2.10. Networking y multijugador . . . . . . . . . . . . . . . . . . . 23
1.2.11. Subsistema de juego . . . . . . . . . . . . . . . . . . . . . . 24
1.2.12. Audio . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
1.2.13. Subsistemas específicos de juego . . . . . . . . . . . . . . . . 26
2. Herramientas de Desarrollo 27
I
[II] ÍNDICE GENERAL
2.1. Introducción . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
2.2. Compilación, enlazado y depuración . . . . . . . . . . . . . . . . . . 28
2.2.1. Conceptos básicos . . . . . . . . . . . . . . . . . . . . . . . 28
2.2.2. Compilando con GCC . . . . . . . . . . . . . . . . . . . . . 31
2.2.3. ¿Cómo funciona GCC? . . . . . . . . . . . . . . . . . . . . . 31
2.2.4. Ejemplos . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
2.2.5. Otras herramientas . . . . . . . . . . . . . . . . . . . . . . . 39
2.2.6. Depurando con GDB . . . . . . . . . . . . . . . . . . . . . . 39
2.2.7. Construcción automática con GNU Make . . . . . . . . . . . 45
2.3. Gestión de proyectos y documentación . . . . . . . . . . . . . . . . . 50
2.3.1. Sistemas de control de versiones . . . . . . . . . . . . . . . . 50
2.3.2. Documentación . . . . . . . . . . . . . . . . . . . . . . . . . 59
2.3.3. Forjas de desarrollo . . . . . . . . . . . . . . . . . . . . . . . 62
Anexos 1079
XXI
[XXII] ACRÓNIMOS
DD Debian Developer
DEB Deep Estimation Buffer
DEHS Debian External Health Status
DFSG Debian Free Software Guidelines
DIP Dependency Inversion Principle
DLL Dynamic Link Library
DOD Diamond Of Death
DOM Document Object Model
DTD Documento Técnico de Diseño
DVB Digital Video Broadcasting
DVD Digital Video Disc
E/S Entrada/Salida
EASTL Electronic Arts Standard Template Library
EA Electronic Arts
ELF Executable and Linkable Format
FIFO First In, First Out
FLAC Free Lossless Audio Codec
FPS First Person Shooter
FSM Finite State Machine
GCC GNU Compiler Collection
GDB GNU Debugger
GDD Game Design Document
GIMP GNU Image Manipulation Program
GLUT OpenGL Utility Toolkit
GLU OpenGL Utility
GNOME GNU Object Model Environment
GNU GNU is Not Unix
GPL General Public License
GPS Global Positioning System
GPU Graphic Processing Unit
GPV grafos de puntos visibles
GTK GIMP ToolKit
GUI Graphical User Interface
GUP Game Unified Process
HDTV High Definition Television
HFSM Hierarchical Finite State Machine
HOM Hierarchical Occlusion Maps
HSV Hue, Saturation, Value
HTML HyperText Markup Language
I/O Input/Output
IANA Internet Assigned Numbers Authority
IA Inteligencia Artificial
IBM International Business Machines
IDL Interface Definition Language
[XXIII]
Introducción
1
David Vallejo Fernández
A
ctualmente, la industria del videojuego goza de una muy buena salud a nivel
mundial, rivalizando en presupuesto con las industrias cinematográfica y mu-
sical. En este capítulo se discute, desde una perspectiva general, el desarrollo
de videojuegos, haciendo especial hincapié en su evolución y en los distintos elemen-
tos involucrados en este complejo proceso de desarrollo. En la segunda parte del capí-
tulo se introduce el concepto de arquitectura del motor, como eje fundamental para
el diseño y desarrollo de videojuegos comerciales.
El primer videojuego Lejos han quedado los días desde el desarrollo de los primeros videojuegos, ca-
racterizados principalmente por su simplicidad y por el hecho de estar desarrollados
El videojuego Pong se considera completamente sobre hardware. Debido a los distintos avances en el campo de la in-
como unos de los primeros video- formática, no sólo a nivel de desarrollo software y capacidad hardware sino también
juegos de la historia. Desarrollado
por Atari en 1975, el juego iba in- en la aplicación de métodos, técnicas y algoritmos, la industria del videojuego ha evo-
cluido en la consola Atari Pong. lucionado hasta llegar a cotas inimaginables, tanto a nivel de jugabilidad como de
Se calcula que se vendieron unas calidad gráfica, tan sólo hace unos años.
50.000 unidades.
3
[4] CAPÍTULO 1. INTRODUCCIÓN
Tiempo real El desarrollo de videojuegos comerciales es un proceso complejo debido a los dis-
tintos requisitos que ha de satisfacer y a la integración de distintas disciplinas que
En el ámbito del desarrollo de vi- intervienen en dicho proceso. Desde un punto de vista general, un videojuego es una
deojuegos, el concepto de tiempo aplicación gráfica en tiempo real en la que existe una interacción explícita mediante
real es muy importante para dotar
de realismo a los juegos, pero no el usuario y el propio videojuego. En este contexto, el concepto de tiempo real se refie-
es tan estricto como el concepto de re a la necesidad de generar una determinada tasa de frames o imágenes por segundo,
tiempo real manejado en los siste- típicamente 30 ó 60, para que el usuario tenga una sensación continua de realidad.
mas críticos. Por otra parte, la interacción se refiere a la forma de comunicación existente entre el
usuario y el videojuego. Normalmente, esta interacción se realiza mediante joysticks o
mandos, pero también es posible llevarla a cabo con otros dispositivos como por ejem-
plo teclados, ratones, cascos o incluso mediante el propio cuerpo a través de técnicas
de visión por computador o de interacción táctil.
A continuación se describe la estructura típica de un equipo de desarrollo aten-
diendo a los distintos roles que juegan los componentes de dicho equipo [42]. En
muchos casos, y en función del número de componentes del equipo, hay personas
especializadas en diversas disciplinas de manera simultánea.
Los ingenieros son los responsables de diseñar e implementar el software que
permite la ejecución del juego, así como las herramientas que dan soporte a dicha
ejecución. Normalmente, los ingenieros se suelen clasificar en dos grandes grupos:
Los programadores del núcleo del juego, es decir, las personas responsables
de desarrollar tanto el motor de juego como el juego propiamente dicho.
Los programadores de herramientas, es decir, las personas responsables de
desarrollar las herramientas que permiten que el resto del equipo de desarrollo
pueda trabajar de manera eficiente.
Productor ejecutivo
Programador Networking
Equipo artístico
Herramientas Física
Conceptual Modelado Artista
técnico Inteligencia Art. Motor
Animación Texturas
Interfaces Audio
Al igual que suele ocurrir con los ingenieros, existe el rol de artista senior cuyas
responsabilidades también incluyen la supervisión de los numerosos aspectos vincu-
lados al componente artístico.
Los diseñadores de juego son los responsables de diseñar el contenido del juego,
Scripting e IA destacando la evolución del mismo desde el principio hasta el final, la secuencia de
El uso de lenguajes de alto nivel capítulos, las reglas del juego, los objetivos principales y secundarios, etc. Evidente-
es bastante común en el desarrollo mente, todos los aspectos de diseño están estrechamente ligados al propio género del
de videojuegos y permite diferen- mismo. Por ejemplo, en un juego de conducción es tarea de los diseñadores definir el
ciar claramente la lógica de la apli- comportamiento de los coches adversarios ante, por ejemplo, el adelantamiento de un
cación y la propia implementación.
Una parte significativa de las desa- rival.
rrolladoras utiliza su propio lengua- Los diseñadores suelen trabajar directamente con los ingenieros para afrontar di-
je de scripting, aunque existen len-
guajes ampliamente utilizados, co- versos retos, como por ejemplo el comportamiento de los enemigos en una aventura.
mo son Lua o Python. De hecho, es bastante común que los propios diseñadores programen, junto con los in-
genieros, dichos aspectos haciendo uso de lenguajes de scripting de alto nivel, como
por ejemplo Lua4 o Python5 .
Como ocurre con las otras disciplinas previamente comentadas, en algunos estu-
dios los diseñadores de juego también juegan roles de gestión y supervisión técnica.
Finalmente, en el desarrollo de videojuegos también están presentes roles vincu-
lados a la producción, especialmente en estudios de mayor capacidad, asociados a la
planificación del proyecto y a la gestión de recursos humanos. En algunas ocasiones,
los productores también asumen roles relacionados con el diseño del juego. Así mis-
mo, los responsables de marketing, de administración y de soporte juegan un papel
relevante. También resulta importante resaltar la figura de publicador como entidad
responsable del marketing y distribución del videojuego desarrollado por un determi-
nado estudio. Mientras algunos estudios tienen contratos permanentes con un deter-
minado publicador, otros prefieren mantener una relación temporal y asociarse con el
publicador que le ofrezca mejores condiciones para gestionar el lanzamiento de un
título.
En otras palabras, los juegos son aplicaciones interactivas que están marcadas por
el tiempo, es decir, cada uno de los ciclos de ejecución tiene un deadline que ha de
cumplirse para no perder realismo.
Aunque el componente gráfico representa gran parte de la complejidad compu-
tacional de los videojuegos, no es el único. En cada ciclo de ejecución, el videojuego
ha de tener en cuenta la evolución del mundo en el que se desarrolla el mismo. Dicha
evolución dependerá del estado de dicho mundo en un momento determinado y de có-
mo las distintas entidades dinámicas interactúan con él. Obviamente, recrear el mundo
C1
1.1. El desarrollo de videojuegos [9]
real con un nivel de exactitud elevado no resulta manejable ni práctico, por lo que nor-
malmente dicho mundo se aproxima y se simplifica, utilizando modelos matemáticos
para tratar con su complejidad. En este contexto, destaca por ejemplo la simulación
física de los propios elementos que forman parte del mundo.
Por otra parte, un juego también está ligado al comportamiento del personaje prin-
cipal y del resto de entidades que existen dentro del mundo virtual. En el ámbito
académico, estas entidades se suelen definir como agentes (agents) y se encuadran
dentro de la denominada simulación basada en agentes [64]. Básicamente, este tipo
de aproximaciones tiene como objetivo dotar a los NPC con cierta inteligencia pa-
ra incrementar el grado de realismo de un juego estableciendo, incluso, mecanismos
de cooperación y coordinación entre los mismos. Respecto al personaje principal, un
videojuego ha de contemplar las distintas acciones realizadas por el mismo, consi-
derando la posibilidad de decisiones impredecibles a priori y las consecuencias que
podrían desencadenar.
Figura 1.3: El motor de juego re- En resumen, y desde un punto de vista general, el desarrollo de un juego implica
presenta el núcleo de un videojuego considerar un gran número de factores que, inevitablemente, incrementan la comple-
y determina el comportamiento de jidad del mismo y, al mismo tiempo, garantizar una tasa de fps adecuada para que la
los distintos módulos que lo com- inmersión del usuario no se vea afectada.
ponen.
Obviamente, la separación entre motor de juego y juego nunca es total y, por una
circunstancia u otra, siempre existen dependencias directas que no permiten la reusa-
bilidad completa del motor para crear otro juego. La dependencia más evidente es el
genero al que está vinculado el motor de juego. Por ejemplo, un motor de juegos dise-
ñado para construir juegos de acción en primera persona, conocidos tradicionalmente
como shooters o shoot’em all, será difícilmente reutilizable para desarrollar un juego
de conducción.
Una forma posible para diferenciar un motor de juego y el software que representa
a un juego está asociada al concepto de arquitectura dirigida por datos (data-driven
architecture). Básicamente, cuando un juego contiene parte de su lógica o funciona-
miento en el propio código (hard-coded logic), entonces no resulta práctico reutilizar-
la para otro juego, ya que implicaría modificar el código fuente sustancialmente. Sin
embargo, si dicha lógica o comportamiento no está definido a nivel de código, sino
por ejemplo mediante una serie de reglas definidas a través de un lenguaje de script,
entonces la reutilización sí es posible y, por lo tanto, beneficiosa, ya que optimiza el
tiempo de desarrollo.
Como conclusión final, resulta relevante destacar la evolución relativa a la genera- Game engine tuning
lidad de los motores de juego, ya que poco a poco están haciendo posible su utilización
para diversos tipos de juegos. Sin embargo, el compromiso entre generalidad y optima- Los motores de juegos se suelen
adaptar para cubrir las necesidades
lidad aún está presente. En otras palabras, a la hora de desarrollar un juego utilizando específicas de un título y para obte-
un determinado motor es bastante común personalizar dicho motor para adaptarlo a ner un mejor rendimiento.
las necesidades concretas del juego a desarrollar.
Figura 1.5: Captura de pantalla del juego Tremulous R , licenciado bajo GPL y desarrollado sobre el motor
de Quake III.
Otro género importante está representado por los juegos de lucha, en los que,
normalmente, dos jugadores compiten para ganar un determinado número de com-
bates minando la vida o stamina del jugador contrario. Ejemplos representativos de
juegos de lucha son Virtua Fighter, Street Fighter, Tekken, o Soul Calibur, entre otros.
Actualmente, los juegos de lucha se desarrollan normalmente en escenarios tridimen-
sionales donde los luchadores tienen una gran libertad de movimiento. Sin embargo,
últimamente se han desarrollado diversos juegos en los que tanto el escenario como
los personajes son en 3D, pero donde el movimiento de los mismos está limitado a dos
dimensiones, enfoque comúnmente conocido como juegos de lucha de scroll lateral.
Debido a que en los juegos de lucha la acción se centra generalmente en dos per-
sonajes, éstos han de tener una gran calidad gráfica y han de contar con una gran
variedad de movimientos y animaciones para dotar al juego del mayor realismo posi-
ble. Así mismo, el escenario de lucha suele estar bastante acotado y, por lo tanto, es
posible simplificar su tratamiento y, en general, no es necesario utilizar técnicas de op-
timización como las comentadas en el género de los FPS. Por otra parte, el tratamiento
de sonido no resulta tan complejo como lo puede ser en otros géneros de acción.
Los juegos del género de la lucha han de prestar atención a la detección y gestión
de colisiones entre los propios luchadores, o entre las armas que utilicen, para dar una
sensación de mayor realismo. Además, el módulo responsable del tratamiento de la
entrada al usuario ha de ser lo suficientemente sofisticado para gestionar de manera
adecuada las distintas combinaciones de botones necesarias para realizar complejos
movimientos. Por ejemplo, juegos como Street Fighter IV incorporan un sistema de
timing entre los distintos movimientos de un combo. El objetivo perseguido consiste
Simuladores F1 en que dominar completamente a un personaje no sea una tarea sencilla y requiera que
Los simuladores de juegos de con- el usuario de videojuegos dedique tiempo al entrenaiento del mismo.
ducción no sólo se utilizan para Los juegos de lucha, en general, han estado ligados a la evolución de técnicas
el entretenimiento doméstico sino
también para que, por ejemplo, los complejas de síntesis de imagen aplicadas sobre los propios personajes con el objetivo
pilotos de Fórmula-1 conozcan to- de mejorar al máximo su calidad y, de este modo, incrementar su realismo. Un ejemplo
dos los entresijos de los circuitos y representativo es el uso de shaders [76] sobre la armadura o la propia piel de los
puedan conocerlos al detalle antes
de embarcarse en los entrenamien-
personajes que permitan implementar técnicas como el bump mapping [6], planteada
tos reales. para dotar a estos elementos de un aspecto más rugoso.
[14] CAPÍTULO 1. INTRODUCCIÓN
Al igual que ocurre en los juegos de estrategia, los MMOG suelen utilizar persona-
jes virtuales en baja resolución para permitir la aparición de un gran número de ellos
en pantalla de manera simultánea.
Además de los distintos géneros mencionados en esta sección, existen algunos
más como por ejemplo los juegos deportivos, los juegos de rol o RPG (Role-Playing
Games) o los juegos de puzzles.
Antes de pasar a la siguiente sección en la que se discutirá la arquitectura general
de un motor de juego, resulta interesante destacar la existencia de algunas herramien-
tas libres que se pueden utilizar para la construcción de un motor de juegos. Una de
las más populares, y que se utilizará en el presente curso, es OGRE 3D9 . Básicamente,
OGRE es un motor de renderizado 3D bien estructurado y con una curva de aprendi-
zaje adecuada. Aunque OGRE no se puede definir como un motor de juegos completo,
sí que proporciona un gran número de módulos que permiten integrar funcionalidades
no triviales, como iluminación avanzada o sistemas de animación de caracteres.
La arquitectura Cell La capa relativa al hardware está vinculada a la plataforma en la que se ejecutará
el motor de juego. Por ejemplo, un tipo de plataforma específica podría ser una consola
En arquitecturas más novedosas, de juegos de sobremesa. Muchos de los principios de diseño y desarrollo son comu-
como por ejemplo la arquitectura nes a cualquier videojuego, de manera independiente a la plataforma de despliegue
Cell usada en Playstation 3 y desa-
rrollada por Sony, Toshiba e IBM, final. Sin embargo, en la práctica los desarrolladores de videojuegos siempre llevan
las optimizaciones aplicadas suelen a cabo optimizaciones en el motor de juegos para mejorar la eficiencia del mismo,
ser más dependientes de la platafor- considerando aquellas cuestiones que son específicas de una determinada plataforma.
ma final.
La capa de drivers soporta aquellos componentes software de bajo nivel que per-
miten la correcta gestión de determinados dispositivos, como por ejemplo las tarjetas
de aceleración gráfica o las tarjetas de sonido.
La capa del sistema operativo representa la capa de comunicación entre los pro-
cesos que se ejecutan en el mismo y los recursos hardware asociados a la plataforma
en cuestión. Tradicionalmente, en el mundo de los videojuegos los sistemas opera-
tivos se compilan con el propio juego para producir un ejecutable. Sin embargo, las
9 http://www.ogre3d.org/
[16] CAPÍTULO 1. INTRODUCCIÓN
Subsistema
Networking Audio
de juego
Gestor de recursos
Subsistemas principales
Sistema operativo
Drivers
Hardware
Figura 1.9: Visión conceptual de la arquitectura general de un motor de juegos. Esquema adaptado de la
arquitectura propuesta en [42].
Abstracción funcional Gran parte de los juegos se desarrollan teniendo en cuenta su potencial lanzamien-
to en diversas plataformas. Por ejemplo, un título se puede desarrollar para diversas
Aunque en teoría las herramien- consolas de sobremesa y para PC al mismo tiempo. En este contexto, es bastante co-
tas multiplataforma deberían abs-
traer de los aspectos subyacentes
mún encontrar una capa software que aisle al resto de capas superiores de cualquier
a las mismas, como por ejemplo aspecto que sea dependiente de la plataforma. Dicha capa se suele denominar capa
el sistema operativo, en la práctica independiente de la plataforma.
suele ser necesario realizar algunos
ajustos en función de la plataforma Aunque sería bastante lógico suponer que la capa inmediatamente inferior, es de-
existente en capas de nivel inferior. cir, la capa de SDKs y middleware, ya posibilita la independencia respecto a las pla-
taformas subyacentes debido al uso de módulos estandarizados, como por ejemplo
bibliotecas asociadas a C/C++, la realidad es que existen diferencias incluso en bi-
bliotecas estandarizadas para distintas plataformas.
Algunos ejemplos representativos de módulos incluidos en esta capa son las bi-
bliotecas de manejo de hijos o los wrappers o envolturas sobre alguno de los módulos
de la capa superior, como el módulo de detección de colisiones o el responsable de la
parte gráfica.
10 http://www.sgi.com/tech/stl/
11 http://http://www.opengl.org/
12 http://www.havok.com
13 http://www.ode.org
[18] CAPÍTULO 1. INTRODUCCIÓN
Recurso Recurso
Mundo Etc
esqueleto colisión
Gestor de recursos
Figura 1.10: Visión conceptual del gestor de recursos y sus entidades asociadas. Esquema adaptado de la
arquitectura propuesta en [42].
Ogre::ScriptLoader
ResourceAlloc Ogre::TextureManager
Ogre::SkeletonManager
Ogre::MeshManager
Ogre::ResourceManager Ogre::MaterialManager
Ogre::GPUProgramManager
Ogre::FontManager
Ogre::CompositeManager
Figura 1.11: Diagrama de clases asociado al gestor de recursos de Ogre 3D, representado por la clase
Ogre::ResourceManager.
Front end
Efectos visuales
Interfaz con el
dispositivo gráfico
Motor de rendering
Figura 1.12: Visión conceptual de la arquitectura general de un motor de rendering. Esquema simplificado
de la arquitectura discutida en [42].
Por otra parte, dicha capa también gestiona el estado del hardware gráfico y los
shaders asociados. Básicamente, cada primitiva recibida por esta capa tiene asociado
un material y se ve afectada por diversas fuentes de luz. Así mismo, el material descri-
be la textura o texturas utilizadas por la primitiva y otras cuestiones como por ejemplo
qué pixel y vertex shaders se utilizarán para renderizarla.
La capa superior a la de renderizado de bajo nivel se denomina scene graph/-
culling y optimizaciones y, desde un punto de vista general, es la responsable de
seleccionar qué parte o partes de la escena se enviarán a la capa de rendering. Esta se-
lección, u optimización, permite incrementar el rendimiento del motor de rendering,
debido a que se limita el número de primitivas geométricas enviadas a la capa de nivel
inferior.
Aunque en la capa de rendering sólo se dibujan las primitivas que están dentro del
campo de visión de la cámara, es decir, dentro del viewport, es posible aplicar más
optimizaciones que simplifiquen la complejidad de la escena a renderizar, obviando
aquellas partes de la misma que no son visibles desde la cámara. Este tipo de optimi-
zaciones son críticas en juegos que tenga una complejidad significativa con el objetivo
de obtener tasas de frames por segundo aceptables.
Una de las optimizaciones típicas consiste en hacer uso de estructuras de datos de
subdivisión espacial para hacer más eficiente el renderizado, gracias a que es posible
determinar de una manera rápida el conjunto de objetos potencialmente visibles. Di-
chas estructuras de datos suelen ser árboles, aunque también es posible utilizar otras
alternativas. Tradicionalmente, las subdivisiones espaciales se conocen como scene
graph (grafo de escena), aunque en realidad representan un caso particular de estruc-
tura de datos.
Por otra parte, en esta capa también es común integrar métodos de culling, como
por ejemplo aquellos basados en utilizar información relevante de las oclusiones para
determinar qué objetos están siendo solapados por otros, evitando que los primeros se
tengan que enviar a la capa de rendering y optimizando así este proceso.
Idealmente, esta capa debería ser independiente de la capa de renderizado, permi-
tiendo así aplicar distintas optimizaciones y abstrayéndose de la funcionalidad rela-
tiva al dibujado de primitivas. Un ejemplo representativo de esta independencia está
representado por OGRE (Object-Oriented Graphics Rendering Engine) y el uso de la
filosofía plug & play, de manera que el desarrollador puede elegir distintos diseños de
grafos de escenas ya implementados y utilizarlos en su desarrollo.
Filosofía Plug & Play Sobre la capa relativa a las optimizaciones se sitúa la capa de efectos visuales, la
cual proporciona soporte a distintos efectos que, posteriormente, se puedan integrar en
Esta filosofía se basa en hacer uso los juegos desarrollados haciendo uso del motor. Ejemplos representativos de módulos
de un componente funcional, hard-
ware o software, sin necesidad de
que se incluyen en esta capa son aquéllos responsables de gestionar los sistemas de
configurar ni de modificar el fun- partículos (humo, agua, etc), los mapeados de entorno o las sombras dinámicas.
cionamiento de otros componentes
asociados al primero. Finalmente, la capa de front-end suele estar vinculada a funcionalidad relativa
a la superposición de contenido 2D sobre el escenario 3D. Por ejemplo, es bastante
común utilizar algún tipo de módulo que permita visualizar el menú de un juego o la
interfaz gráfica que permite conocer el estado del personaje principal del videojuego
(inventario, armas, herramientas, etc). En esta capa también se incluyen componentes
para reproducir vídeos previamente grabados y para integrar secuencias cinemáticas,
a veces interactivas, en el propio videojuego. Este último componente se conoce como
IGC (In-Game Cinematics) system.
[22] CAPÍTULO 1. INTRODUCCIÓN
Por otra parte, algunos juegos incluyen sistemas realistas o semi-realistas de simu-
lación dinámica. En el ámbito de la industria del videojuego, estos sistemas se suelen
denominar sistema de física y están directamente ligados al sistema de gestión de
colisiones.
Actualmente, la mayoría de compañías utilizan motores de colisión/física desarro-
llados por terceras partes, integrando estos kits de desarrollo en el propio motor. Los
más conocidos en el ámbito comercial son Havok, el cual representa el estándar de
facto en la industria debido a su potencia y rendimiento, y PhysX, desarrollado por
NVIDIA e integrado en motores como por ejemplo el Unreal Engine 3.
En el ámbito del open source, uno de los más utilizados es ODE. Sin embargo,
en este curso se hará uso del motor de simulación física Bullet14 , el cual se utiliza
actualmente en proyectos tan ambiciosos como la suite 3D Blender.
Sistema de scripting
Subsistema de juego
Figura 1.13: Visión conceptual de la arquitectura general del subsistema de juego. Esquema simplificado
de la arquitectura discutida en [42].
Este subsistema sirve también como capa de aislamiento entre las capas de más
bajo nivel, como por ejemplo la de rendering, y el propio funcionamiento del juego. Diseñando juegos
Es decir, uno de los principales objetivos de diseño que se persiguen consiste en inde-
pendizar la lógica del juego de la implementación subyacente. Por ello, en esta capa es Los diseñadores de los niveles de un
juego, e incluso del comportamien-
bastante común encontrar algún tipo de sistema de scripting o lenguaje de alto nivel to de los personajes y los NPCs,
para definir, por ejemplo, el comportamiento de los personajes que participan en el suelen dominar perfectamente los
juego. lenguajes de script, ya que son su
principal herramienta para llevar a
cabo su tarea.
C1
1.2. Arquitectura del motor. Visión general [25]
El modelo de objetos del juego está intimamente ligado al modelo de objetos soft-
ware y se puede entender como el conjunto de propiedades del lenguaje, políticas y
convenciones utilizadas para implementar código utilizando una filosofía de orienta-
ción a objetos. Así mismo, este modelo está vinculado a cuestiones como el lenguaje
de programación empleado o a la adopción de una política basada en el uso de patrones
de diseño, entre otras.
En la capa de subsistema de juego se integra el sistema de eventos, cuya principal
responsabilidad es la de dar soporte a la comunicación entre objetos, independiente-
mente de su naturaleza y tipo. Un enfoque típico en el mundo de los videojuegos con-
siste en utilizar una arquitectura dirigida por eventos, en la que la principal entidad es
el evento. Dicho evento consiste en una estructura de datos que contiene información
relevante de manera que la comunicación está precisamente guiada por el contenido
del evento, y no por el emisor o el receptor del mismo. Los objetos suelen implementar
manejadores de eventos (event handlers) para tratarlos y actuar en consecuencia.
Por otra parte, el sistema de scripting permite modelar fácilmente la lógica del
juego, como por ejemplo el comportamiento de los enemigos o NPCs, sin necesidad
de volver a compilar para comprobar si dicho comportamiento es correcto o no. En
algunos casos, los motores de juego pueden seguir en funcionamiento al mismo tiempo
que se carga un nuevo script.
Finalmente, en la capa del subsistema de juego es posible encontrar algún módulo
que proporcione funcionalidad añadida respecto al tratamiento de la IA, normalmente
de los NPCs. Este tipo de módulos, cuya funcionalidad se suele incluir en la propia
capa de software específica del juego en lugar de integrarla en el propio motor, son
cada vez más populares y permiten asignar comportamientos preestablecidos sin nece-
sidad de programarlos. En este contexto, la simulación basada en agentes [102] cobra
especial relevancia.
Este tipo de módulos pueden incluir aspectos relativos a problemas clásicos de la
IA, como por ejemplo la búsqueda de caminos óptimos entre dos puntos, conocida
como pathfinding, y típicamente vinculada al uso de algoritmos A* [79]. Así mismo,
también es posible hacer uso de información privilegiada para optimizar ciertas tareas,
I’m all ears!
como por ejemplo la localización de entidades de interés para agilizar el cálculo de
El apartado sonoro de un juego es aspectos como la detección de colisiones.
especialmente importante para que
el usuario se sienta inmerso en el
mismo y es crítico para acompañar
de manera adecuada el desarrollo de
dicho juego.
[26] CAPÍTULO 1. INTRODUCCIÓN
1.2.12. Audio
Tradicionalmente, el mundo del desarrollo de videojuegos siempre ha prestado
más atención al componente gráfico. Sin embargo, el apartado sonoro también tiene
una gran importancia para conseguir una inmersión total del usuario en el juego. Por
ello, el motor de audio ha ido cobrando más y más relevancia.
Asimismo, la aparición de nuevos formatos de audio de alta definición y la popu-
laridad de los sistemas de cine en casa han contribuido a esta evolución en el cada vez
más relevante apartado sonoro.
Actualmente, al igual que ocurre con otros componentes de la arquitectura del mo-
tor de juego, es bastante común encontrar desarrollos listos para utilizarse e integrarse
en el motor de juego, los cuales han sido realizados por compañías externas a la del
propio motor. No obstante, el apartado sonoro también requiere modificaciones que
son específicas para el juego en cuestión, con el objetivo de obtener un alto de grado
de fidelidad y garantizar una buena experiencia desde el punto de visto auditivo.
A
ctualmente, existen un gran número de aplicaciones y herramientas que permi-
ten a los desarrolladores de videojuegos, y de aplicaciones en general, aumen-
tar su productividad a la hora de construir software, gestionar los proyectos y
recursos, así como automatizar procesos de construcción.
En este capítulo, se pone de manifiesto la importancia de la gestión en un proyec-
to software y se muestran algunas de las herramientas de desarrollo más conocidas
en sistemas GNU/Linux. La elección de este tipo de sistema no es casual. Por un la-
do, se trata de Software Libre, lo que permite a desarrolladores estudiar, aprender y
entender lo que hace el código que se ejecuta. Por otro lado, probablemente sea el
mejor sistema operativo para construir y desarrollar aplicaciones debido al gran nú-
mero de herramientas que proporciona. Es un sistema hecho por programadores para
programadores.
2.1. Introducción
En la construcción de software no trivial, las herramientas de gestión de proyec-
tos y de desarrollo facilitan la labor de las personas que lo construyen. Conforme el
software se va haciendo más complejo y se espera más funcionalidad de él, se hace
necesario el uso de herramientas que permitan automatizar los procesos del desarrollo,
así como la gestión del proyecto y su documentación.
Además, dependiendo del contexto, es posible que existan otros integrantes del
proyecto que no tengan formación técnica y que necesiten realizar labores sobre el
producto como traducciones, pruebas, diseño gráfico, etc.
27
[28] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
C2
Código fuente, código objeto y código ejecutable
Compilador
Enlazador
Un programa puede estar compuesto por varios módulos, lo cual permite que un
proyecto pueda ser más mantenible y manejable. Los módulos pueden tener indepen-
dencias entre sí y la comprobación y resolución de estas dependencias corren a cargo
del enlazador. El enlazador toma como entrada el código objeto.
Bibliotecas
Una de las principales ventajas del software es la reutilización del código. Normal-
mente, los problemas pueden resolverse utilizando código ya escrito anteriormente y
la reutilización del mismo se vuelve un aspecto clave para el tiempo de desarrollo del
producto. Las bibliotecas ofrecen una determinada funcionalidad ya implementada
para que sea utilizada por programas. Las bibliotecas se incorporan a los programas
durante el proceso de enlazado.
2.2. Compilación, enlazado y depuración [31]
C2
Las bibliotecas pueden enlazarse contra el programa de dos formas:
Compilación
C2
Comprobación y resolución de símbolos y dependencias a nivel de declaración.
Realizar optimizaciones.
Ensamblador
Enlazador
GNU Linker Con todos los archivos objetos el enlazador (linker) es capaz de generar el eje-
cutable o código binario final. Algunas de las tareas que se realizan en el proceso de
GNU Linker también forma parte de enlazado son las siguientes:
la distribución GNU Binutils y se
corresponde con el programa ld.
Selección y filtrado de los objetos necesarios para la generación del binario.
Comprobación y resolución de símbolos y dependencias a nivel de definición.
Realización del enlazado (estático y dinámico) de las bibliotecas.
2.2.4. Ejemplos
Como se ha mostrado, el proceso de compilación está compuesto por varias fases
bien diferenciadas. Sin embargo, con GCC se integra todo este proceso de forma que,
a partir del código fuente se genere el binario final.
En esta sección se mostrarán ejemplos en los que se crea un ejecutable al que,
posteriormente, se enlaza con una biblioteca estática y otra dinámica.
[34] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
Compilación de un ejecutable
1 #include <iostream>
2
3 using namespace std;
4
5 class Square {
6 private:
7 int side_;
8
9 public:
10 Square(int side_length) : side_(side_length) { };
11 int getArea() const { return side_*side_; };
12 };
13
14 int main () {
15 Square square(5);
16 cout << "Area: " << square.getArea() << endl;
17 return 0;
18 }
En C++, los programas que generan un ejecutable deben tener definida la fun-
ción main, que será el punto de entrada de la ejecución. El programa es trivial: se
define una clase Square que representa a un cuadrado. Ésta implementa un método
getArea() que devuelve el área del cuadrado.
Suponiendo que el archivo que contiene el código fuente se llama main.cpp,
para construir el binario utilizaremos g++, el compilador de C++ que se incluye en
GCC. Se podría utilizar gcc y que se seleccionara automáticamente el compilador.
Sin embargo, es una buena práctica utilizar el compilador correcto:
C2
Compilación de un ejecutable (modular)
Para construir el programa, se debe primero construir el código objeto del módulo
y añadirlo a la compilación de la función principal main. Suponiendo que el archivo
de cabecera se encuentra en un directorio llamado headers, la compilación puede
realizarse de la siguiente manera:
Con la opción -I, que puede aparecer tantas veces como sea necesario, se puede
añadir rutas donde se buscarán las cabeceras. Nótese que, por ejemplo, en main.cpp
se incluyen las cabeceras usando los símbolos <> y . Se recomienda utilizar los pri-
meros para el caso en que las cabeceras forman parte de una API pública (si existe) y
deban ser utilizadas por otros programas. Por su parte, las comillas se suelen utilizar
para cabeceras internas al proyecto. Las rutas por defecto son el directorio actual . pa-
ra las cabeceras incluidas con y para el resto el directorio del sistema (normalmente,
/usr/include).
Como norma general, una buena costumbre es generar todos los archivos de
código objeto de un módulo y añadirlos a la compilación con el programa
principal.
Para este ejemplo se supone que se pretende construir una biblioteca con la que se
pueda enlazar estáticamente y que contiene una jerarquía de clases correspondientes
a 3 tipos de figuras (Figure): Square, Triangle y Circle. Cada figura está
implementada como un módulo (cabecera + implementación):
C2
Listado 2.9: Triangle.h
1 #include <Figure.h>
2
3 class Triangle : public Figure {
4 private:
5 float base_;
6 float height_;
7
8 public:
9 Triangle(float base_, float height_);
10 float getArea() const;
11 };
Como se puede ver, se utiliza GCC directamente para generar la biblioteca dinámi-
ca. La compilación y enlazado con el programa principal se realiza de la misma forma
que en el caso del enlazado estático. Sin embargo, la ejecución del programa principal
es diferente. Al tratarse de código objeto que se cargará en tiempo de ejecución, exis-
ten una serie de rutas predefinadas donde se buscarán las bibliotecas. Por defecto, son
las mismas que para el proceso de enlazado.
También es posible añadir rutas modificando la variable LD_LIBRARY_PATH:
$ LD_LIBRARY_PATH=. ./main
2.2. Compilación, enlazado y depuración [39]
C2
2.2.5. Otras herramientas
La gran mayoría de las herramientas utilizadas hasta el momento forman parte
de la distribución GNU Binutils1 que se proporcionan en la mayoría de los sistemas
GNU/Linux. Existen otras herramientas que se ofrecen en este misma distribución y
que pueden ser de utilidad a lo largo del proceso de desarrollo:
GDB necesita información extra que, por defecto, GCC no proporciona para po-
der realizar las tareas de depuración. Para ello, el código fuente debe ser compilado
con la opción -ggdb. Todo el código objeto debe ser compilado con esta opción de
compilación, por ejemplo:
Para depurar no se debe hacer uso de las optimizaciones. Éstas pueden gene-
rar código que nada tenga que ver con el original.
C2
$ ./main
Main start
Function A: 24
Segmentation fault
Una violación de segmento (segmentation fault) es uno de los errores lógicos típi-
cos de los lenguajes como C++. El problema es que se está accediendo a una zona de
la memoria que no ha sido reservada para el programa, por lo que el sistema operativo
interviene denegando ese acceso indebido.
A continuación, se muestra cómo iniciar una sesión de depuración con GDB para
encontrar el origen del problema:
$ gdb main
...
Reading symbols from ./main done.
(gdb)
Como se puede ver, GDB ha cargado el programa, junto con los símbolos de depu-
ración necesarios, y ahora se ha abierto una línea de órdenes donde el usuario puede
especificar sus acciones.
Examinando el contenido
Abreviatura Para comenzar la ejecución del programa se puede utilizar la orden start:
Todas las órdenes de GDB pueden (gdb) start
escribirse utilizando su abreviatura. Temporary breakpoint 1 at 0x400d31: file main.cpp, line 26.
Ej: run = r. Starting program: main
(gdb) list
21 cout << "Return B: " << functionB("Hi", test) << endl;
22 return 5;
23 }
24
25 int main() {
26 cout << "Main start" << endl;
27 cout << "Return A: " << functionA(24) << endl;
28 return 0;
29 }
(gdb)
[42] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
Como el resto de órdenes, list acepta parámetros que permiten ajustar su com-
portamiento.
Las órdenes que permiten realizar una ejecución controlada son las siguientes:
(gdb) s
Main start
27 cout << "Return A: " << functionA(24) << endl;
(gdb)
functionA (a=24) at main.cpp:18
18 cout << "Function A: " << a << endl;
(gdb)
En este punto se puede hacer uso de las órdenes para mostrar el contenido del
parámetro a de la función functionA():
(gdb) print a
$1 = 24
(gdb) print &a
$2 = (int *) 0x7fffffffe1bc
La ejecución está detenida en la línea 19 donde un comentario nos avisa del error.
Se está creando un puntero con el valor NULL. Posteriormente, se invoca un método
sobre un objeto que no está convenientemente inicializado, lo que provoca la violación
de segmento:
(gdb) next
20 test->setValue(15);
(gdb)
2.2. Compilación, enlazado y depuración [43]
C2
Program received signal SIGSEGV, Segmentation fault.
0x0000000000400df2 in Test::setValue (this=0x0, a=15) at main.cpp:8
8 void setValue(int a) { _value = a; }
Breakpoints
La ejecución paso a paso es una herramienta útil para una depuración de grano
fino. Sin embargo, si el programa realiza grandes iteraciones en bucles o es demasiado
grande, puede ser un poco incómodo (o inviable). Si se tiene la sospecha sobre el lugar
donde está el problema se pueden utilizar puntos de ruptura o breakpoints que permite
detener el flujo del programa en un punto determinado por el usuario.
Con el ejemplo ya arreglado, se configura un breakpoint en la función functionB()
y otro en la línea 28 con la orden break. A continuación, se ejecuta el programa hasta
que se alcance el breakpoint con la orden run (r):
¡No hace falta escribir todo!. Utiliza TAB para completar los argumentos de
una orden.
Con la orden continue (c) la ejecución avanza hasta el siguiente punto de rup-
tura (o fin del programa):
(gdb) continue
Continuing.
Function B: Hi, 15
Return B: 3.14
Return A: 5
(gdb)
Stack y frames
En muchas ocasiones, los errores vienen debidos a que las llamadas a funciones no
se realizan con los parámetros adecuados. Es común pasar punteros no inicializados o
valores incorrectos a una función/método y, por tanto, obtener un error lógico.
Para gestionar las llamadas a funciones y procedimientos, en C/C++ se utiliza la
pila (stack ). En la pila se almacenan frames, estructuras de datos que registran las
variables creadas dentro de una función así como otra información de contexto. GDB
permite manipular la pila y los frames de forma que sea posible identificar un uso
indebido de las funciones.
Con la ejecución parada en functionB(), se puede mostrar el contenido de la
pila con la orden backtrace (bt):
(gdb) backtrace
#0 functionB (str1=..., t=0x602010) at main.cpp:13
#1 0x0000000000400d07 in functionA (a=24) at main.cpp:21
#2 0x0000000000400db3 in main () at main.cpp:27
(gdb)
Con up y down se puede navegar por los frames de la pila, y con frame se puede
seleccionar uno en concreto:
(gdb) up
#1 0x0000000000400d07 in functionA (a=24) at gdb-fix.cpp:21
21 cout << "Return B: " << functionB("Hi", test) << endl;
(gdb)
#2 0x0000000000400db3 in main () at gdb-fix.cpp:27
27 cout << "Return A: " << functionA(24) << endl;
(gdb) frame 0
#0 functionB (str1=..., t=0x602010) at gdb-fix.cpp:13
13 cout << "Function B: " << str1 << ", " << t->getValue() << endl;
(gdb)
Una vez seleccionado un frame, se puede obtener toda la información del mismo, Invocar funciones
además de modificar las variables y argumentos:
La orden call se puede utilizar pa-
ra invocar funciones y métodos .
(gdb) print *t
$1 = {_value = 15}
(gdb) call t->setValue(1000)
(gdb) print *t
$2 = {_value = 1000}
(gdb)
2.2. Compilación, enlazado y depuración [45]
C2
Entornos gráficos para GDB
✄
GDB TUI: normalmente, la distribución ✄GDB
de
✄
incorpora una interfaz basada
en modo texto accesible pulsando ✂Ctrl ✁+ ✂x ✁y, a continuación, ✂a ✁.
ddd y xxgdb: las librerías gráficas utilizadas son algo anticuadas, pero facilitan
el uso de GDB.
gdb-mode: modo de Emacs para GDB. Dentro del modo se puede activar la
opción M-x many-windows para obtener buffers con toda la información
disponible.
kdbg: más atractivo gráficamente (para escritorios KDE).
Estructura
Existen algunos objetivos especiales como all, install y clean que sirven
como regla de partida inicial, para instalar el software construido y para limpiar del
proyecto los archivos generados, respectivamente.
Tomando como ejemplo la aplicación que hace uso de la biblioteca dinámica, el
siguiente listado muestra el Makefile que generaría tanto el programa ejecutable como
la biblioteca estática:
C2
$ make
$ make clean
Como ejercicio, se plantean las siguientes preguntas: ¿qué opción permite ejecutar
make sobre otro archivo que no se llame Makefile? ¿Se puede ejecutar make sobre
un directorio que no sea el directorio actual? ¿Cómo?.
GNU Coding Standars
Variables automáticas y reglas con patrones
En el proyecto GNU se definen los
objetivos que se esperan en un soft-
ware que siga estas directrices. Make se caracteriza por ofrecer gran versatilidad en su lenguaje. Las variables
automáticas contienen valores que dependen del contexto de la regla donde se aplican
y permiten definir reglas genéricas. Por su parte, los patrones permiten generalizar
las reglas utilizando el nombre los archivos generados y los fuentes.
A continuación se presenta una versión mejorada del anterior Makefile haciendo
uso de variables, variables automáticas y patrones:
Como ejercicio se plantean las siguientes cuestiones: ¿qué ocurre si una vez cons-
truido el proyecto se modifica algún fichero .cpp? ¿Y si se modifica una cabecera
.h? ¿Se podría construir una regla con patrón genérica para construir la biblioteca
estática? ¿Cómo lo harías?.
[48] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
Reglas implícitas
Como se puede ver, Make puede generar automáticamente los archivos objeto .o
a partir de la coincidencia con el nombre del fichero fuente (que es lo habitual). Por
ello, no es necesario especificar cómo construir los archivos .o de la biblioteca, ni
siquiera la regla para generar main ya que asume de que se trata del ejecutable (al
existir un fichero llamado main.cpp).
Las variables de usuario que se han definido permiten configurar los flags de com-
pilación que se van a utilizar en las reglas explícitas. Así:
C2
Funciones
GNU Make proporciona un conjunto de funciones que pueden ser de gran ayuda
a la hora de construir los Makefiles. Muchas de las funciones están diseñadas para
el tratamiento de cadenas, ya que se suelen utilizar para transformar los nombres de
archivos. Sin embargo, existen muchas otras como para realizar ejecución condicional,
bucles y ejecutar órdenes de consola. En general, las funciones tienen el siguiente
formato:
$(nombre arg1,arg2,arg3,...)
Las funciones se pueden utilizar en cualquier punto del Makefile, desde las ac-
ciones de una regla hasta en la definición de un variable. En el siguiente listado se
muestra el uso de algunas de estas funciones:
$ DEBUG=yes make
Más información
GNU Make es una herramienta que está en continuo crecimiento y esta sección só-
lo ha sido una pequeña presentación de sus posibilidades. Para obtener más informa-
ción sobre las funciones disponibles, otras variables automáticas y objetivos predefini-
dos se recomiendo utilizar el manual en línea de Make2 , el cual siempre se encuentra
actualizado.
C2
Por otro lado, sería interesante tener la posibilidad de realizar desarrollos en para-
lelo de forma que exista una versión «estable» de todo el proyecto y otra más «experi-
mental» del mismo donde se probaran diferentes algoritmos y diseños. De esta forma,
probar el impacto que tendría nuevas implementaciones sobre el proyecto no afec-
taría a una versión más «oficial». También es común que se desee añadir una nueva
funcionalidad y ésta se realiza en paralelo junto con otros desarrollos.
Los sistemas de control de versiones o Version Control System (VCS) permiten
gestionar los archivos de un proyecto (y sus versiones) y que sus integrantes puedan
acceder remotamente a ellos para descargarlos, modificarlos y publicar los cambios.
También se encargan de detectar posibles conflictos cuando varios usuarios modifican
los mismos archivos y de proporcionar un sistema básico de registro de cambios.
Como norma general, al VCS debe subirse el archivo fuente y nunca el archivo
generado. No se deben subir binarios ya que no es fácil seguir la pista a sus
modificaciones.
Existen diferentes criterios para clasificar los diferentes VCS existentes. Uno de
los que más influye tanto en la organización y uso del repositorio es si se trata de VCS
centralizado o distribuido. En la figura 2.6 se muestra un esquema de ambas filosofías.
Los VCS centralizados como CVS o Subversion se basan en que existe un nodo
servidor con el que todos los clientes conectan para obtener los archivos, subir modifi-
caciones, etc. La principal ventaja de este esquema reside en su sencillez: las diferentes
versiones del proyecto están únicamente en el servidor central, por lo que los posibles
conflictos entre las modificaciones de los clientes pueden detectarse y gestionarse más
fácilmente. Sin embargo, el servidor es un único punto de fallo y en caso de caída, los
clientes quedan aislados.
Por su parte, en los VCS distribuidos como Mercurial o Git, cada cliente tiene un
repositorio local al nodo en el que se suben los diferentes cambios. Los cambios pue-
den agruparse en changesets, lo que permite una gestión más ordenada. Los clientes
actúan de servidores para el resto de los componentes del sistema, es decir, un cliente
puede descargarse una versión concreta de otro cliente.
Esta arquitectura es tolerante a fallos y permite a los clientes realizar cambios sin
necesidad de conexión. Posteriormente, pueden sincronizarse con el resto. Aún así,
un VCS distribuido puede utilizarse como uno centralizado si se fija un nodo como
servidor, pero se perderían algunas posibilidades que este esquema ofrece.
[52] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
Subversion
Inicialmente, los clientes pueden descargarse el repositorio por primera vez utili-
zando la orden checkout:
$ mkdir doc
$ echo "This is a new file" > doc/new_file
$ echo "Other file" > other_file
$ svn add doc other_file
A doc
A doc/new_file
A other_file
La operación add indica qué archivos y directorios han sido seleccionados para
ser añadidos al repositorio (marcados con A). Esta operación no sube efectivamente
los archivos al servidor. Para subir cualquier cambio se debe hacer un commit:
$ svn commit
C2
$ svn update -r REVISION
$ svn update
C other_file
At revision X+1.
Nótese que este commit sólo añade los cambios hechos en new_file, aceptando
los cambios en other_file que hizo user2.
Mercurial
$ hg init /home/user1/myproyect
Al igual que ocurre con Subversion, este directorio debe ser accesible median-
te algún mecanismo (preferiblemente, que sea seguro) para que el resto de usuarios
Figura 2.8: Logotipo del proyecto pueda acceder. Sin embargo, el usuario user1 puede trabajar directamente sobre ese
Mercurial. directorio.
[54] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
Para obtener una versión inicial, otro usuario (user2) debe clonar el repositorio.
Basta con ejecutar lo siguiente:
$ hg clone ssh://user2@host//home/user1/myproyect
A partir de este instante, user2 tiene una versión inicial del proyecto extraída a
partir de la del usuario user1. De forma muy similar a Subversion, con la orden add
se pueden añadir archivos y directorios.
Mientras que en el modelo de Subversion, los clientes hacen commit y update
para subir cambios y obtener la última versión, respectivamente; en Mercurial es algo
más complejo, ya que existe un repositorio local. Como se muestra en la figura 2.9, la
operación commit (3) sube los cambios a un repositorio local que cada cliente tiene.
Cada commit se considera un changeset, es decir, un conjunto de cambios agrupa-
dos por un mismo ID de revisión. Como en el caso de Subversion, en cada commit se
pedirá una breve descripción de lo que se ha modificado.
Una vez hecho todos commits, para llevar estos cambios a un servidor remoto se
debe ejecutar la orden de push (4). Siguiendo con el ejemplo, el cliente user2 lo
enviará por defecto al repositorio del que hizo la operación clone.
El sentido inverso, es decir, traerse los cambios del servidor remoto a la copia
local, se realiza también en 2 pasos: pull (1) que trae los cambios del repositorio
remoto al repositorio local; y update (2), que aplica dichos cambios del repositorio
local al directorio de trabajo. Para hacer los dos pasos al mismo tiempo, se puede hacer
lo siguiente:
$ hg pull -u
Para evitar conflictos con otros usuarios, una buena costumbre antes de reali-
zar un push es conveniente obtener los posibles cambios en el servidor con
pull y update.
2.3. Gestión de proyectos y documentación [55]
C2
Para ver cómo se gestionan los conflictos en Mercurial, supóngase que user1
realiza lo siguiente:
Git
Diseñado y desarrollado por Linus Torvalds para el proyecto del kernel Linux, Git
es un VCS distribuido que cada vez es más utilizado por la comunidad de desarrolla-
dores. En términos generales, tiene una estructura similar a Mercurial: independencia
entre repositorio remotos y locales, gestión local de cambios, etc.
Sin embargo, Git es en ocasiones preferido sobre Mercurial por algunas de sus
características propias:
[56] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
Cada head apunta a un commit y tiene un nombre simbólico para poder ser re-
ferenciado. Por defecto, todos los respositorios Git tienen un head llamado master.
HEAD (nótese todas las letras en mayúscula) es una referencia al head usado en cada
instante. Por lo tanto, en un repositorio Git, en un estado sin cambios, HEAD apuntará
al master del respositorio.
Para crear un repositorio donde sea posible que otros usuarios puedan subir cam-
bios se utiliza la orden init:
C2
Figura 2.11: Esquema del flujo de trabajo básico en Git
$ git status
# On branch master
nothing to commit, working directory clean
Nótese como la última llamada a status no muestra ningún cambio por subir al
repositorio local. Esto significa que la copia de trabajo del usuario está sincronizada
con el repositorio local (todavía no se han realizado operaciones con el remoto). Se
puede utilizar reflog para ver la historia:
[58] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
$ git reflog
2f81676 HEAD@{0}: commit: Test example: initial version
...
diff se utiliza para ver cambios entre commits, ramas, etc. Por ejemplo, la si-
guiente orden muestra las diferencias entre master del repositorio local y master del
remoto:
Para modificar código y subirlo al repositorio local se sigue el mismo procedi- gitk
miento: (1) realizar la modificación, (2) usar add para añadir el archivo cambiado,
(3) hacer commit para subir el cambio al repositorio local. Sin embargo, como se ha Para entender mejor estos concep-
dicho anteriormente, una buena característica de Git es la creación y gestión de ramas tos y visualizarlos durante el proce-
(branches) locales que permiten hacer un desarrollo en paralelo. Esto es muy útil ya so de desarrollo, existen herramien-
tas gráficas como gitk que permi-
que, normalmente, en el ciclo de vida de desarrollo de un programa se debe simulta- ten ver todos los commits y heads
near tanto la creación de nuevas características como arreglos de errores cometidos. en cada instante.
En Git, estas ramas no son más que referencias a commits, por lo que son muy ligeras
y pueden llevarse de un sitio a otro de forma sencilla.
Como ejemplo, la siguiente secuencia de órdenes crea una rama local a partir del
HEAD, modifica un archivo en esa rama y finalmente realiza un merge con master:
$ git branch
* NEW-BRANCH
master
Nótese cómo la orden branch muestra la rama actual marcada con el símbolo
*. Utilizando las órdenes log y show se pueden listar los commits recientes. Estas
órdenes aceptan, además de identificadores de commits, ramas y rangos temporales
de forma que pueden obtenerse gran cantidad de información de ellos.
Finalmente, para desplegar los cambios en el repositorio remoto sólo hay que uti-
lizar:
$ git push
2.3. Gestión de proyectos y documentación [59]
C2
Git utiliza ficheros como .gitconfig y .gitignore para cargar confi-
guraciones personalizadas e ignorar ficheros a la hora de hacer los commits,
respectivamente. Son muy útiles. Revisa la documentación de git-config
y gitignore para más información.
2.3.2. Documentación
Uno de los elementos más importantes que se generan en un proyecto es la do-
cumentación: cualquier elemento que permita entender mejor tanto el proyecto en su
totalidad como sus partes, de forma que facilite el proceso de mantenimiento en el
futuro. Además, una buena documentación hará más sencilla la reutilización de com-
ponentes.
Existen muchos formatos de documentación que pueden servir para un proyecto
software. Sin embargo, muchos de ellos, tales como PDF, ODT, DOC, etc., son for-
matos «binarios» por lo que no son aconsejables para utilizarlos en un VCS. Además,
utilizando texto plano es más sencillo crear programas que automaticen la generación
de documentación, de forma que se ahorre tiempo en este proceso.
Por ello, aquí se describen algunas formas de crear documentación basadas en
texto plano. Obviamente, existen muchas otras y, seguramente, sean tan válidas como
las que se proponen aquí.
Doxygen
8 /**
9 \param s the name of the Test.
10 */
11 Test(string s);
12
13 /// Start running the test.
14 /**
15 \param max maximum time of test delay.
16 \param silent if true, do not provide output.
17 \sa Test()
18 */
19 int run(int max, bool silent);
20 };
$ doxygen .
reStructuredText
C2
30 | row 2 | | | |
31 +--------------+----------+-----------+-----------+
32
33 Images
34 ------
35
36 .. image:: gnu.png
37 :scale: 80
38 :alt: A title text
Como se puede ver, aunque RST añade una sintaxis especial, el texto es completa-
mente legible. Ésta es una de las ventajas de RST, el uso de etiquetas de formato que
no «ensucian» demasiado el texto.
YAML
Planificación y gestión de tareas: permite anotar qué tareas quedan por hacer
y los plazos de entrega. También suelen permitir asignar prioridades.
Planificación y gestión de recursos: ayuda a controlar el grado de ocupación
del personal de desarrollo (y otros recursos).
Seguimiento de fallos: también conocido como bug tracker, es esencial para
llevar un control sobre los errores encontrados en el programa. Normalmente,
permiten gestionar el ciclo de vida de un fallo, desde que se descubre hasta que
se da por solucionado.
Foros: normalmente, las forjas de desarrollo permiten administrar varios foros
de comunicación donde con la comunidad de usuarios del programa pueden
escribir propuestas y notificar errores.
Las forjas de desarrollo suelen ser accesibles via web, de forma que sólo sea ne-
cesario un navegador para poder utilizar los diferentes servicios que ofrece. Depen-
diendo de la forja de desarrollo, se ofrecerán más o menos servicios. Sin embargo, los
expuestos hasta ahora son los que se proporcionan habitualmente. Existen forjas gra-
tuitas en Internet que pueden ser utilizadas para la creación de un proyecto. Algunas
de ellas:
C2
SourceForge9 : probablemente, una de las forjas gratuitas más conocidas. Pro-
piedad de la empresa GeekNet Inc., soporta Subversion, Git, Mercurial, Bazaar
y CVS.
Google Code10 : la forja de desarrollo de Google que soporta Git, Mercurial y
Subversion.
Redmine
9 http://sourceforge.net
10 http://code.google.com
C++. Aspectos Esenciales
Capítulo 3
David Vallejo Fernández
E
l lenguaje más utilizado para el desarrollo de videojuegos comerciales es C++,
debido especialmente a su potencia, eficiencia y portabilidad. En este capítulo
se hace un recorrido por C++ desde los aspectos más básicos hasta las he-
rramientas que soportan la POO (Programación Orientada a Objetos) y que permiten
diseñar y desarrollar código que sea reutilizable y mantenible, como por ejemplo las
plantillas y las excepciones.
POO En esta sección se realiza un recorrido por los aspectos básicos de C++, hacien-
do especial hincapié en aquellos elementos que lo diferencian de otros lenguajes de
La programación orientada a obje- programación y que, en ocasiones, pueden resultar más complicados de dominar por
tos tiene como objetivo la organi-
zación eficaz de programas. Bási- aquellos programadores inexpertos en C++.
camente, cada componente es un
objeto autocontenido que tiene una
serie de operaciones y de datos o
estado. Este planteamiento permi-
3.1.1. Introducción a C++
te reducir la complejidad y gestio-
nar grandes proyectos de programa- C++ se puede considerar como el lenguaje de programación más importante en la
ción. actualidad. De hecho, algunas autores relevantes [81] consideran que si un programa-
dor tuviera que aprender un único lenguaje, éste debería ser C++. Aspectos como su
sintaxis y su filosofía de diseño definen elementos clave de programación, como por
ejemplo la orientación a objetos. C++ no sólo es importante por sus propias caracterís-
ticas, sino también porque ha sentado las bases para el desarrollo de futuros lenguajes
de programación. Por ejemplo, Java o C# son descendientes directos de C++. Desde el
punto de vista profesional, C++ es sumamente importante para cualquier programador.
65
[66] CAPÍTULO 3. C++. ASPECTOS ESENCIALES
El origen de C++ está ligado al origen de C, ya que C++ está construido sobre
C. De hecho, C++ es un superconjunto de C y se puede entender como una versión
extendida y mejorada del mismo que integra la filosofía de la POO y otras mejoras,
como por ejemplo la inclusión de un conjunto amplio de bibliotecas. Algunos autores
consideran que C++ surgió debido a la necesidad de tratar con programas de mayor
complejidad, siendo impulsado en gran parte por la POO.
C++ fue diseñado por Bjarne Stroustrup1 en 1979. La idea de Stroustrup fue
añadir nuevos aspectos y mejoras a C, especialmente en relación a la POO, de manera
que un programador de C sólo que tuviera que aprender aquellos aspectos relativos a
la OO.
En el caso particular de la industria del videojuego, C++ se puede considerar co-
mo el estándar de facto debido principalmente a su eficiencia y portabilidad. C++ es
una de los pocos lenguajes que posibilitan la programación de alto nivel y, de manera
simultánea, el acceso a los recursos de bajo nivel de la plataforma subyacente. Por lo
tanto, C++ es una mezcla perfecta para la programación de sistemas y para el desarro-
llo de videojuegos. Una de las principales claves a la hora de manejarlo eficientemente
en la industria del videojuego consiste en encontrar el equilibrio adecuado entre efi-
ciencia, fiabilidad y mantenibilidad [27].
Figura 3.1: Bjarne Stroustrup,
creador del lenguaje de programa-
3.1.2. ¡Hola Mundo! en C++ ción C++ y personaje relevante en
el ámbito de la programación.
A continuación se muestra el clásico ¡Hola Mundo! implementado en C++. En
este primer ejemplo, se pueden apreciar ciertas diferencias con respecto a un programa Dominar C++
escrito en C. Una de las mayores ventajas de
C++ es que es extremadamente po-
tente. Sin embargo, utilizarlo efi-
Listado 3.1: Hola Mundo en C++ cientemente es díficil y su curva de
aprendizaje no es gradual.
1 /* Mi primer programa con C++. */
2
3 #include <iostream>
4 using namespace std;
5
6 int main () {
7
8 string nombre;
9
10 cout << "Por favor, introduzca su nombre... ";
11 cin >> nombre;
12 cout << "Hola " << nombre << "!"<< endl;
13
14 return 0;
15
16 }
✄
La directiva include de la línea ✂3 ✁incluye la biblioteca
✄ <iostream>, la cual soporta
el sistema de E/S de C++. A continuación, en la ✂4 ✁el programa le indica al compila-
dor que utilice el espacio de nombres std, en el que se declara la biblioteca estándar de
C++. Un espacio de nombres delimita una zona de declaración en la que incluir dife-
rentes elementos de un programa. Los espacios de nombres siguen la misma filosofía
que los paquetes en Java y tienen como objetivo organizar los programas. El hecho de
utilizar un espacio de nombres permite acceder a sus elementos y funcionalidades sin
tener que especificar a qué espacio pertenecen.
1 http://www2.research.att.com/~bs/
3.1. Utilidades básicas [67]
✄
En la línea ✂10 ✁de hace uso de cout (console output), la sentencia de salida por
C3
consola junto con el operador <<, redireccionando lo que queda a su derecha, es
decir, Por favor, introduzca su nombre..., hacia la salida por consola. A continuación,
en la siguiente línea se hace uso de cin (console input) junto con el operador >>
para redirigir la entrada proporcionado por teclado a la variable nombre. Note que
dicha variable es de tipo cadena, un tipo de datos de C++ que se define✄ como
un array
de caracteres finalizado con el carácter null. Finalmente, en la línea ✂12 ✁se saluda al
lector, utilizando además la sentencia endl para añadir un retorno de carro a la salida
y limpiar el buffer.
int *ip;
edadptr = &edad;
miEdad = *edadptr
En C++ existe una relación muy estrecha entre los punteros y los arrays, siendo
posible intercambiarlos en la mayoría de casos. El siguiente listado de código muestra
un sencillo ejemplo de indexación de un array mediante aritmética de punteros.
✄
En la inicialización del bucle for de la línea ✂10 ✁se aprecia cómo se asigna la direc-
ción de inicio del array s al puntero p para, posteriormente, indexar el array mediante
p para resaltar en mayúsculas el contenido del array una vez finalizada la ejecución
del bucle. Note que C++ permite la inicialización múltiple.
3.1. Utilidades básicas [69]
C3
Los punteros son enormemente útiles y potentes. Sin embargo, cuando un puntero
almacena, de manera accidental, valores incorrectos, el proceso de depuración puede
resultar un auténtico quebradero de cabeza. Esto se debe a la propia naturaleza del
puntero y al hecho de que indirectamente afecte a otros elementos de un programa, lo
cual puede complicar la localización de errores.
El caso típico tiene lugar cuando un puntero apunta a algún lugar de memoria que
no debe, modificando datos a los que no debería apuntar, de manera que el programa
muestra resultados indeseables posteriormente a su ejecución inicial. En estos casos,
cuando se detecta el problema, encontrar la evidencia del fallo no es una tarea trivial,
ya que inicialmente puede que no exista evidencia del puntero que provocó dicho
error. A continuación se muestran algunos de los errores típicos a la hora de manejar
punteros [81].
1. No inicializar punteros. En el listado que se muestra a continuación, p contiene
una dirección desconocida debido a que nunca fue definida. En otras palabras, no es
posible conocer dónde se ha escrito el valor contenido en edad.
No olvide que una de las claves para garantizar un uso seguro de los punteros
consiste en conocer en todo momento hacia dónde están apuntando.
C3
2 using namespace std;
3
4 struct persona {
5 string nombre;
6 int edad;
7 };
8
9 void modificar_nombre (persona *p, const string& nuevo_nombre);
10
11 int main () {
12 persona p;
13 persona *q;
14
15 p.nombre = "Luis";
16 p.edad = 23;
17 q = &p;
18
19 cout << q->nombre << endl;
20 modificar_nombre(q, "Sergio");
21 cout << q->nombre << endl;
22
23 return 0;
24 }
25
26 void modificar_nombre (persona *p, const string& nuevo_nombre) {
27 p->nombre = nuevo_nombre;
28 }
C3
Las funciones también pueden devolver referencias. En C++, una de las mayores
utilidades de esta posibilidad es la sobrecarga de operadores. Sin embargo, en el lis-
tado que se muestra a continuación se refleja otro uso potencial, debido a que cuando
se devuelve una referencia, en realidad se está devolviendo un puntero implícito al
valor de retorno. Por lo tanto, es posible utilizar la función en la parte izquierda de
una asignación.
Funciones y referencias Como se puede apreciar en el siguiente listado, la función f devuelve una referen-
cia a un valor en punto flotante de doble precisión, en ✄ concreto
a la variable global
En una función, las referencias se valor. La parte importante del código está en la línea ✂15 ✁, en la que valor se actualiza
pueden utilizar como parámetros de
entrada o como valores de retorno. a 7,5, debido a que la función devuelve dicha referencia.
Aunque se discutirá más adelante, las referencias también se pueden utilizar para
devolver objetos desde una función de una manera eficiente. Sin embargo, hay que
ser cuidadoso con la referencia a devolver, ya que si se asigna a un objeto, entonces
se creará una copia. El siguiente fragmento de código muestra un ejemplo represen-
tativo vinculado al uso de matrices de 16 elementos, estructuras de datos típicamente
utilizada en el desarrollo de videojuegos.
Las ventajas de las referencias sobre los punteros se pueden resumir en que utili-
zar referencias es una forma de más alto nivel de manipular objetos, ya que permite al
desarrollador olvidarse de los detalles de gestión de memoria y centrarse en la lógica
del problema a resolver. Aunque pueden darse situaciones en las que los punteros son
más adecuados, una buena regla consiste en utilizar referencias siempre que sea posi-
ble, ya que su sintaxis es más limpia que la de los punteros y su uso es menos proclive
a errores.
3.2. Clases
C3
Listado 3.10: Clase Figura
1 class Figura
2 {
3 public:
4 Figura (double i, double j);
5 ~Figura ();
6
7 void setDim (double i, double j);
8 double getX () const;
9 double getY () const;
10
11 protected:
12 double _x, _y;
13 };
Note cómo las variables de clase se definen como protegidas, es decir, con una vi-
sibilidad privada fuera de dicha clase a excepción de las clases que hereden de Figura,
tal y como se discutirá en la sección 3.3.1. El constructor y el destructor comparten el
nombre con la clase, pero el destructor tiene delante el símbolo ~.
Paso por referencia El resto de funciones sirven para modificar y acceder al estado de los objetos ins-
tanciados a partir de dicha clase. Note el uso del modificador const en las funciones
Recuerde utilizar parámetros por de acceso getX() y getY(), con el objetivo de informar de manera explícita al compila-
referencia const para minimizar el
número de copias de los mismos. dor de que dichas funciones no modifican el estado de los objetos. A continuación, se
muestra la implementación de las funciones definidas en la clase Figura.
Uso de inline Antes de continuar discutiendo más aspectos de las clases, resulta interesante in-
El modificador inline se suele in- troducir brevemente el concepto de funciones en línea (inlining), una técnica que
cluir después de la declaración de la puede reducir la sobrecarga implícita en las llamadas a funciones. Para ello, sólo es
función para evitar líneas de código necesario incluir el modificador inline delante de la declaración de una función. Es-
demasiado largas (siempre dentro
del archivo de cabecera). Sin em-
ta técnica permite obtener exactamente el mismo rendimiento que el acceso directo
bargo, algunos compiladores obli- a una variable sin tener que desperdiciar tiempo en ejecutar la llamada a la función,
gan a incluirlo en ambos lugares. interactuar con la pila del programa y volver de dicha función.
Las funciones en línea no se pueden usar indiscriminadamente, ya que pueden
degradar el rendimiento de la aplicación fácilmente. En primer lugar, el tamaño del
ejecutable final se puede disparar debido a la duplicidad de código. Así mismo, la
caché de código también puede hacer que dicho rendimiento disminuya debido a las
continuas penalizaciones asociadas a incluir tantas funciones en línea. Finalmente, los
tiempos de compilación se pueden incrementar en grandes proyectos.
[76] CAPÍTULO 3. C++. ASPECTOS ESENCIALES
Una buena regla para usar de manera adecuada el modificador inline consiste
en evitar su uso hasta prácticamente completar el desarrollo de un proyecto. A
continuación, se puede utilizar alguna herramienta de profiling para detectar
si alguna función sencilla está entre las más utilidas. Este tipo de funciones
son candidatas potenciales para modificarlas con inline y, en consecuencia,
elementos para mejorar el rendimiento del programa.
Al igual que ocurre con otros tipos de datos, los objetos también se pueden mani-
pular mediante punteros. Simplemente se ha de utilizar la misma notación y recordar
que la aritmética de punteros también se puede usar con objetos que, por ejemplo,
formen parte de un array.
Para los objetos creados en memoria dinámica, el operador new invoca al cons-
tructor de la clase de manera que dichos objetos existen hasta que explícitamente
se eliminen con el operador delete sobre los punteros asociados. A continuación se
muestra un listado de código que hace uso de la clase Figura previamente introducida.
C3
2 #include "Figura.h"
3 using namespace std;
4
5 int main () {
6 Figura *f1;
7 f1 = new Figura (1.0, 0.5);
8
9 cout << "[" << f1->getX() << ", " << f1->getY() << "]" << endl;
10
11 delete f1;
12 return 0;
13 }
Construyendo...
7
Destruyendo...
Destruyendo...
[78] CAPÍTULO 3. C++. ASPECTOS ESENCIALES
✄
Como se puede apreciar, existe una llamada al constructor al crear a (línea ✂24 ✁) y
dos llamadas al destructor. Como se ha comentado antes, cuando un objeto se pasa a
una función, entonces se crea una copia del mismo, la cual se destruye cuando finaliza
la ejecución de la función. Ante esta situación surgen dos preguntas: i) ¿se realiza una
llamada al constructor? y ii) ¿se realiza una llamada al destructor?
En realidad, lo que ocurre cuando se pasa un objeto a una función es que se llama
al constructor de copia, cuya responsabilidad consiste en definir cómo se copia un
objeto. Si una clase no tiene un constructor de copia, entonces C++ proporciona uno
por defecto, el cual crea una copia bit a bit del objeto. En realidad, esta decisión es
bastante lógica, ya que el uso del constructor normal para copiar un objeto no generaría
el mismo resultado que el estado que mantiene el objeto actual (generaría una copia
con el estado inicial).
Sin embargo, cuando una función finaliza y se ha de eliminar la copia del objeto,
entonces se hace uso del destructor debido a que la copia se encuentra fuera de su
ámbito local. Por lo tanto, en el ejemplo anterior se llama al destructor tanto para la
copia como para el argumento inicial.
C3
Listado 3.14: Paso de objetos por valor
1 #include <iostream>
2 using namespace std;
3
4 class A {
5 int _valor;
6 public:
7 A(int valor): _valor(valor) {
8 cout << "Construyendo..." << endl;
9 }
10 ~A() {
11 cout << "Destruyendo..." << endl;
12 }
13
14 int getValor () const {
15 return _valor;
16 }
17 };
18
19 void mostrar (A a) {
20 cout << a.getValor() << endl;
21 }
22
23 int main () {
24 A a(7);
25 mostrar(a);
26 return 0;
27 }
C3
7
Destruyendo...
Destruyendo...
✄ ✄
El constructor de copia se define entre las líneas ✂18 ✁y ✂21 ✁, reservando una nueva
C3
región de memoria para el contenido
✄ de _valor y copiando el mismo en la variable
miembro. Por otra parte, las líneas ✂23-26 ✁muestran la implementación de la función
set, que modifica el contenido de dicha variable miembro.
El siguiente listado muestra la implementación de la función entrada, que pide
una cadena por teclado y devuelve un objeto que alberga la entrada proporcionada por
el usuario.
En primer lugar, el programa se comporta adecuadamente cuando se llama a entra-
da, particularmente cuando se devuelve la copia del objeto a, utilizando el constructor
de copia previamente definido. Sin embargo, el programará abortará abruptamente
cuando el objeto devuelto por entrada se asigna a obj en la función principal. Recuer-
de que en este caso se efectua una copia idéntica. El problema reside en que obj.valor
apunta a la misma dirección de memoria que el objeto temporal, y éste último se des-
truye después de volver desde entrada, por lo que obj.valor apunta a memoria que
acaba de ser liberada. Además, obj.valor se vuelve a liberar al finalizar el programa.
3.3.1. Herencia
El siguiente listado de código muestra la clase base Vehículo que, desde un punto
de vista general, define un medio de transporte por carretera. De hecho, sus variables
miembro son el número de ruedas y el número de pasajeros.
Herencia y acceso
La clase base anterior se puede extender para definir coches con una nueva carac-
terística propia de los mismos, como se puede apreciar en el siguiente listado. El modificador de acceso cuando se
✄
usa herencia es opcional. Sin em-
En este ejemplo no se han definido los constructores de manera intencionada para
discutir el acceso a los miembros de la clase. Como se puede apreciar en la línea ✂5 ✁
bargo, si éste se especifica ha de ser
public, protected o private. Por
del siguiente listado, la clase Coche hereda de la clase Vehículo, utilizando el operador defecto, su valor es private si la
clase derivada es efectivamente una
:. La palabra reservada public delante de Vehículo determina el tipo de acceso. En clase. Si la clase derivada es una es-
este caso concreto, el uso de public implica que todos los miembros públicos de la tructura, entonces su valor por de-
fecto es public.
3.3. Herencia y polimorfismo [85]
C3
Listado 3.21: Clase derivada Coche
1 #include <iostream>
2 #include "Vehiculo.cpp"
3 using namespace std;
4
5 class Coche : public Vehiculo {
6 int _PMA;
7
8 public:
9 void setPMA (int PMA) {_PMA = PMA;}
10 int getPMA () const {return _PMA;}
11
12 void mostrar () const {
13 cout << "Ruedas: " << getRuedas() << endl;
14 cout << "Pasajeros: " << getPasajeros() << endl;
15 cout << "PMA: " << _PMA << endl;
16 }
17 };
clase base serán también miembros públicos de la clase derivada. En otras palabras, el
efecto que se produce equivale a que los miembros públicos de Vehículo se hubieran
declarado dentro de Coche. Sin embargo, desde Coche no es posible acceder a los
miembros privados de Vehículo, como por ejemplo a la variable _ruedas.
El caso contrario a la herencia pública es la herencia privada. En este caso, cuando
la clase base se hereda con private, entonces todos los miembros públicos de la clase
base se convierten en privados en la clase derivada.
Además de ser público o privado, un miembro de clase se puede definir como
protegido. Del mismo modo, una clase base se puede heredar como protegida. Si un
miembro se declara como protegido, dicho miembro no es accesible por elementos que
no sean miembros de la clase salvo en una excepción. Dicha excepción consiste en he-
redar un miembro protegido, hecho que marca la diferencia entre private y protected.
En esencia, los miembros protegidos de la clase base se convierten en miembros pro-
tegidos de la clase derivada. Desde otro punto de vista, los miembros protegidos son
miembros privados de una clase base pero con la posibilidad de heredarlos y acceder
a ellos por parte de una clase derivada. El siguiente listado de código muestra el uso
de protected.
Otro caso particular que resulta relevante comentar se da cuando una clase ba-
se se hereda como privada. En este caso, los miembros protegidos se heredan como
miembros privados en la clase protegida.
Si una clase base se hereda como protegida mediante el modificador de acceso pro-
tected, entonces todos los miembros públicos y protegidos de dicha clase se heredan
como miembros protegidos en la clase derivada.
Constructores y destructores
Cuando se hace uso de herencia y se definen constructores y/o destructores de Inicialización de objetos
clase, es importante conocer el orden en el que se ejecutan en el caso de la clase
base y de la clase derivada, respectivamente. Básicamente, a la hora de construir un El constructor de una clase debería
inicializar idealmente todo el esta-
objeto de una clase derivada, primero se ejecuta el constructor de la clase base y, a do de los objetos instanciados. Uti-
continuación, el constructor de la derivada. En el caso de la destrucción de objetos, lice el constructor de la clase base
el orden se invierte, es decir, primero se ejecuta el destructor de la clase derivada y, a cuando así sea necesario.
continuación, el de la clase base.
Otro aspecto relevante está vinculado al paso de parámetros al constructor de la
clase base desde el constructor de la clase derivada. Para ello, simplemente se realiza
una llamada al constructor de la clase base, pasando los argumentos que sean nece-
sarios. Este planteamiento es similar al utilizado en Java mediante super(). Note que
aunque una clase derivada no tenga variables miembro, en su constructor han de es-
pecificarse aquellos parámetros que se deseen utilizar para llamar al constructor de la
clase base.
C3
El enfoque todo en uno
Una primera opción de diseño podría consistir en aplicar un enfoque todo en uno,
es decir, implementar los requisitos previamente comentados en la propia clase Ob-
jetoJuego, añadiendo la funcionalidad de recepción de mensajes y la posibilidad de
enlazar el objeto en cualquier parte del árbol a la propia clase.
Aunque la simplicidad de esta aproximación es su principal ventaja, en general
añadir todo lo que se necesita en una única clase no es la mejor decisión de diseño. Si
se utiliza este enfoque para añadir más funcionalidad, la clase crecerá en tamaño y en
complejidad cada vez que se integre un nuevo requisito funcional. Así, una clase base
que resulta fundamental en el diseño de un juego se convertirá en un elemento difícil
de utilizar y de mantener. En otras palabras, la simplicidad a corto plazo se transforma
en complejidad a largo plazo.
Otro problema concreto con este enfoque es la duplicidad de código, ya que la
clase ObjetoJuego puede no ser la única en recibir mensajes, por ejemplo. La clase
Jugador podría necesitar recibir mensajes sin ser un tipo particular de la primera clase.
En el caso de enlazar con una estructura de árbol se podría dar el mismo problema, ya
que otros elementos del juego, como por ejemplo los nodos de una escena se podría
organizar del mismo modo y haciendo uso del mismo tipo de estructura de árbol.
En este contexto, copiar el código allí donde sea necesario no es una solución viable
debido a que complica enormemente el mantenimiento y afecta de manera directa a la
arquitectura del diseño.
Contenedores La conclusión directa que se obtiene al reflexionar sobre el anterior enfoque es que
resulta necesario diseñar sendas clases, ReceptorMensajes y NodoArbol, para repre-
La aplicación de un esquema basa-
do en agregación, de manera que sentar la funcionalidad previamente discutida. La cuestión reside en cómo relacionar
una clase contiene elementos rele- dichas clases con la clase ObjetoJuego.
vantes vinculados a su funcionali-
dad, es en general un buen diseño. Una opción inmediata podría ser la agregación, de manera que un objeto de la cla-
se ObjetoJuego contuviera un objeto de la clase ReceptorMensajes y otro de la clase
NodoArbol, respectivamente. Así, la clase ObjetoJuego sería responsable de propor-
cionar la funcionalidad necesaria para manejarlos en su propia interfaz. En términos
generales, esta solución proporciona un gran nivel de reutilización sin incrementar de
manera significativa la complejidad de las clases que se extienden de esta forma.
El siguiente listado de código muestra una posible implementación de este diseño.
La desventaja directa de este enfoque es la generación de un gran número de fun-
ciones que simplemente llaman a la función de una variable miembro, las cuales han
de crearse y mantenerse. Si unimos este hecho a un cambio en su interfaz, el man-
tenimiento se complica aún más. Así mismo, se puede producir una sobrecarga en el
número de llamadas a función, hecho que puede reducir el rendimiento de la aplica-
ción.
Una posible solución a este problema consiste en exponer los propios objetos en
lugar de envolverlos con llamadas a funciones miembro. Este planteamiento simpli-
fica el mantenimiento pero tiene la desventaja de que proporciona más información
de la realmente necesaria en la clase ObjetoJuego. Si además, posteriormente, es ne-
cesario modificar la implementación de dicha clase con propósitos de incrementar la
Uso de la herencia eficiencia, entonces habría que modificar todo el código que haga uso de la misma.
Recuerde utilizar la herencia con
prudencia. Un buen truco consiste
en preguntarse si la clase derivada
es un tipo particular de la clase ba-
se.
[88] CAPÍTULO 3. C++. ASPECTOS ESENCIALES
Nodo
Árbol
Receptor Nodo Objeto
Mensajes Árbol Juego
Objeto
Juego
Figura 3.2: Distintas soluciones de diseño para el problema de la clase ObjetoJuego. (a) Uso de agregación.
(b) Herencia simple. (c) Herencia múltiple.
Otra posible solución de diseño consiste en usar herencia simple, es decir, Ob-
jetoJuego se podría declarar como una clase derivada de ReceptorMensajes, aunque
NodoArbol quedaría aislado. Si se utiliza herencia simple, entonces una alternativa se-
ría aplicar una cadena de herencia, de manera que, por ejemplo, ArbolNodo hereda de
ReceptorMensajes y, a su vez, ObjetoJuego hereda de ArbolNodo (ver figura 3.2.b).
3.3. Herencia y polimorfismo [89]
C3
Aunque este planteamiento es perfectamente funcional, el diseño no es adecua-
do ya que resulta bastante lógico pensar que ArbolNodo no es un tipo especial de
ReceptorMensajes. Si no es así, entonces no debería utilizarse herencia. Simple y lla-
namente. Del mismo modo, la relación inversa tampoco es lógica.
Desde un punto de vista general, la herencia múltiple puede introducir una serie
de complicaciones y desventajas, entre las que destacan las siguientes:
Ambigüedad, debido a que las clases base de las que hereda una clase derivada
pueden mantener el mismo nombre para una función. Para solucionar este pro-
blema, se puede explicitar el nombre de la clase base antes de hacer uso de la
función, es decir, ClaseBase::Funcion.
Topografía, debido a que se puede dar la situación en la que una clase derivada
herede de dos clases base, que a su vez heredan de otra clase, compartiendo
todas ellas la misma clase. Este tipo de árboles de herencia puede generar con-
secuencias inesperadas, como duplicidad de variables y ambigüedad. Este tipo
de problemas se puede solventar mediante herencia virtual, concepto distinto al
que se estudiará en el siguiente apartado relativo al uso de funciones virtuales.
Arquitectura del programa, debido a que el uso de la herencia, simple o múlti-
ple, puede contribuir a degradar el diseño del programa y crear un fuerte acopla-
miento entre las distintas clases que la componen. En general, es recomendable
utilizar alternativas como la composición y relegar el uso de la herencia múltiple
sólo cuando sea la mejor alternativa real.
C3
Listado 3.26: Manejo de punteros a clase base (cont.)
1 #include "Coche.cpp"
2
3 int main () {
4 Vehiculo *v; // Puntero a objeto de tipo vehículo.
5 Coche c; // Objeto de tipo coche.
6
7 c.setRuedas(4); // Se establece el estado de c.
8 c.setPasajeros(7);
9 c.setPMA(1885);
10
11 v = &c; // v apunta a un objeto de tipo coche.
12
13 cout << v->getPMA() << endl; // ERROR en tiempo de compilación.
14
15 cout << ((Coche*)v)->getPMA() << endl; // NO recomendable.
16
17 cout << static_cast<Coche*>(v)->getPMA() << endl; // Estilo C++.
18
19 return 0;
20 }
La palabra clave virtual Una función virtual es una función declarada como virtual en la clase base y rede-
finida en una o más clases derivadas. De este modo, cada clase derivada puede tener
Una clase que incluya una función su propia versión de dicha función. El aspecto interesante es lo que ocurre cuando se
virtual se denomina clase polimór-
fica. llama a esta función con un puntero o referencia a la clase base. En este contexto,
C++ determina en tiempo de ejecución qué versión de la función se ha de ejecutar en
función del tipo de objeto al que apunta el puntero.
Soy Base!
Soy Derivada1!
Soy Derivada2!
Las funciones virtuales han de ser miembros de la clase en la que se definen, Sobrecarga/sobreescrit.
es decir, no pueden ser funciones amigas. Sin embargo, una función virtual puede
ser amiga de otra clase. Además, los destructores se pueden definir como funciones Cuando una función virtual se rede-
fine en una clase derivada, la fun-
virtuales, mientras que en los constructores no es posible. ción se sobreescribe. Para sobrecar-
Las funciones virtuales se heredan de manera independiente del número de niveles gar una función, recuerde que el nú-
mero de parámetros y/o sus tipos
que tenga la jerarquía de clases. Suponga que en el ejemplo anterior Derivada2 hereda han de ser diferentes.
de Derivada1 en lugar de heredar de Base. En este caso, la función imprimir seguiría
siendo virtual y C++ sería capaz de seleccionar la versión adecuada al llamar a dicha
función. Si una clase derivada no sobreescribe una función virtual definida en la clase
base, entonces se utiliza la versión de la clase base.
El polimorfismo permite manejar la complejidad de los programas, garantizando
la escalabilidad de los mismos, debido a que se basa en el principio de una interfaz,
múltiples métodos. Por ejemplo, si un programa está bien diseñado, entonces se puede
suponer que todos los objetos que derivan de una clase base se acceden de la misma
forma, incluso si las acciones específicas varían de una clase derivada a la siguiente.
Esto implica que sólo es necesario recordar una interfaz. Sin embargo, la clase deri-
vada es libre de añadir uno o todos los aspectos funcionales especificados en la clase
base.
C3
Funciones virtuales puras y clases abstractas
33 }
El uso más relevante de las clases abstractas consiste en proporcionar una interfaz
sin revelar ningún aspecto de la implementación subyacente. Esta idea está fuertemen-
te relacionada con la encapsulación, otro de los conceptos fundamentales de la POO
junto con la herencia y el polimorfismo.
3.4. Plantillas
En el desarrollo de software es bastante común encontrarse con situaciones en las
que los programas implementados se parecen enormemente a otros implementados
con anterioridad, salvo por la necesidad de tratar con distintos tipos de datos o de
clases. Por ejemplo, un mismo algoritmo puede mantener el mismo comportamiento
de manera que éste no se ve afectado por el tipo de datos a manejar.
En esta sección se discute el uso de las plantillas en C++, un mecanismo que
permite escribir código genérico sin tener dependencias explícitas respecto a tipos de
datos específicos.
C3
<MiClase> <MiClase> <MiClase> <MiClase>
(a)
<MiClase> <MiClase>
MiClase
(b) (c)
Figura 3.3: Distintos enfoques para la implementación de una lista con elementos génericos. (a) Integración
en la propia clase de dominio. (b) Uso de herencia. (c) Contenedor con elementos de tipo nulo.
Otra posible solución consiste en hacer uso de la herencia para definir una clase
base que represente a cualquier elemento de una lista (ver figura 3.3.b). De este modo,
cualquier clase que desee incluir la funcionalidad asociada a la lista simplemente ha
de extenderla. Este planteamiento permite tratar a los elementos de la lista mediante
polimorfismo. Sin embargo, la mayor desventaja que presenta este enfoque está en el
diseño, ya que no es posible separar la funcionalidad de la lista de la clase propiamente
dicha, de manera que no es posible tener el objeto en múltiples listas o en alguna otra
estructura de datos. Por lo tanto, es importante separar la propia lista de los elementos
que realmente contiene.
Una alternativa para proporcionar esta separación consiste en hacer uso de una
lista que maneja punteros de tipo nulo para albergar distintos tipos de datos (ver fi-
gura 3.3.c). De este modo, y mediante los moldes correspondientes, es posible tener
una lista con elementos de distinto tipo y, al mismo tiempo, la funcionalidad de la mis-
ma está separada del contenido. La principal desventaja de esta aproximación es que
no es type-safe, es decir, depende del programador incluir la funcionalidad necesaria
para convertir tipos, ya que el compilador no los detectará.
Otra desventaja de esta propuesta es que son necesarias dos reservas de memoria
para cada uno de los nodos de la lista: una para el objeto y otra para el siguiente
nodo de la lista. Este tipo de cuestiones han de considerarse de manera especial en
el desarrollo de videojuegos, ya que la plataforma hardware final puede tener ciertas
restricciones de recursos.
[96] CAPÍTULO 3. C++. ASPECTOS ESENCIALES
La figura 3.3 muestra de manera gráfica las distintas opciones discutidas hasta
ahora en lo relativo a la implementación de una lista que permita el tratamiento de
datos genéricos.
C3
Las plantillas de funciones siguen la misma idea que las plantillas de clases apli-
cándolas a las funciones. Obviamente, la principal diferencia con respecto a las plan-
tillas a clases es que no necesitan instanciarse. El siguiente listado de código muestra
un ejemplo sencillo de la clásica función swap para intercambiar el contenido de dos
variables. Dicha función puede utilizarse con enteros, valores en punto flotante, ca-
denas o cualquier clase con un constructor de copia y un constructor de asignación.
Además, la función se instancia dependiendo del tipo de datos utilizado. Recuerde que
no es posible utilizar dos tipos de datos distintos, es decir, por ejemplo un entero y un
valor en punto flotante, ya que se producirá un error en tiempo de compilación.
Uso de plantillas El uso de plantillas en C++ solventa todas las necesidades planteadas para manejar
las listas introducidas en la sección 3.4.1, principalmente las siguientes:
Las plantillas son una herramienta
excelente para escribir código que
no dependa de un tipo de datos es- Flexibilidad, para poder utilizar las listas con distintos tipos de datos.
pecífico.
Simplicidad, para evitar la copia de código cada vez que se utilice una estruc-
tura de lista.
Uniformidad, ya que se maneja una única interfaz para la lista.
Independencia, entre el código asociado a la funcionalidad de la lista y el có-
digo asociado al tipo de datos que contendrá la lista.
7
8 private:
9 T _datos;
10 };
11
12 template<class T>
13 class Lista {
14 public:
15 NodoLista<T> getCabeza ();
16 void insertarFinal (T datos);
17 // Resto funcionalidad...
18
19 private:
20 NodoLista<T> *_cabeza;
21 };
Desde una perspectiva general, no debe olvidar que las plantillas representan una
herramienta adecuada para un determinado uso, por lo que su uso indiscriminado es un
error. Recuerde también que las plantillas introducen una dependencia de uso respecto
a otras clases y, por lo tanto, su diseño debería ser simple y mantenible.
Una de las situaciones en las que el uso de plantillas resulta adecuado está aso-
ciada al uso de contenedores, es decir, estructuras de datos que contienen objetos de
distintas clases. En este contexto, es importante destacar que la biblioteca STL de C++
ya proporciona una implementación de listas, además de otras estructuras de datos y
de algoritmos listos para utilizarse. Por lo tanto, es bastante probable que el desarro-
llador haga un uso directo de las mismas en lugar de tener que desarrollar desde cero
su propia implementación. En el capítulo 5 se estudia el uso de la biblioteca STL y se
discute su uso en el ámbito del desarrollo de videojuegos.
3.5. Manejo de excepciones [99]
C3
Freezing issues A la hora de afrontar cualquier desarrollo software, un programador siempre tiene
que tratar con los errores que dicho software puede generar. Existen diversas estra-
Aunque el desarrollo de videojue- tegias para afrontar este problema, desde simplemente ignorarlos hasta hacer uso de
gos comerciales madura año a año,
aún hoy en día es bastante común técnicas que los controlen de manera que sea posible recuperarse de los mismos. En
encontrar errores y bugs en los mis- esta sección se discute el manejo de excepciones en C++ con el objetivo de escribir
mos. Algunos de ellos obligan in- programas robustos que permitan gestionar de manera adecuada el tratamiento de
cluso a resetear la estación de jue- errores y situaciones inesperadas. Sin embargo, antes de profundizar en este aspecto
gos por completo.
se introducirán brevemente las distintas alternativas más relevantes a la hora de tratar
con errores.
Hasta ahora, los enfoques comentados tienen una serie de desventajas importantes.
En este contexto, las excepciones se posicionan como una alternativa más adecuada
y práctica. En esencia, el uso de excepciones permite que cuando un programa se
tope con una situación inesperada se arroje una excepción. Este hecho tiene como
consecuencia que el flujo de ejecución salte al bloque de captura de excepciones más
cercano. Si dicho bloque no existe en la función en la que se arrojó la excepción,
entonces el programa gestionará de manera adecuada la salida de dicha función (des-
truyendo los objetos vinculados) y saltará a la función padre con el objetivo de buscar
un bloque de captura de excepciones.
Este proceso se realiza recursivamente hasta encontrar dicho bloque o llegará a
la función principal delegando en el código de manejo de errores por defecto, que
típicamente finalizará la ejecución del programa y mostrará información por la salida
estándar o generará un fichero de log.
Los bloques de tratamiento de excepciones ofrecen al desarrollador la flexibili-
dad de hacer lo que desee con el error, ya sea ignorarlo, tratar de recuperarse del
mismo o simplemente informar sobre lo que ha ocurrido. Este planteamiento facili-
ta enormemente la distinción entre distintos tipos de errores y, consecuentemente, su
tratamiento.
C3
4
5 int main () {
6 try {
7 int *array = new int[1000000];
8 }
9 catch (bad_alloc &e) {
10 cerr << "Error al reservar memoria." << endl;
11 }
12
13 return 0;
14 }
MiExcepción
C3
desea que el programa capture cualquier tipo de excepción, entonces se puede añadir
una captura genérica (ver cuarto bloque catch). En resumen, si se lanza una excepción
no contemplada en un bloque catch, entonces el programa seguirá buscando el bloque
catch más cercano.
Es importante resaltar que el orden de las sentencias catch es relevante, ya que
dichas sentencias siempre se procesan de arriba a abajo. Además, cuando el programa
encuentra un bloque que trata con la excepción lanzada, el resto de bloques se ignoran
automáticamente.
Exception handlers Otro aspecto que permite C++ relativo al manejo de excepciones es la posibilidad
de re-lanzar una excepción, con el objetivo de delegar en una capa superior el trata-
El tratamiento de excepciones se
puede enfocar con un esquema pa- miento de la misma. El siguiente listado de código muestra un ejemplo en el que se
recido al del tratamiento de eventos, delega el tratamiento del error de entrada/salida.
es decir, mediante un planteamien-
to basado en capas y que delege las
excepciones para su posterior ges- Listado 3.36: Re-lanzando una excepción
tión.
1 void Mesh::cargar (const char *archivo) {
2
3 try {
4 Stream stream(archivo); // Puede generar un error de I/O.
5 cargar(stream);
6 }
7
8 catch (MiExcepcionIO &e) {
9 if (e.datosCorruptos()) {
10 // Tratar error I/O.
11 }
12 else {
13 throw; // Se re-lanza la excepción.
14 }
15 }
16
17 }
C3
se producirá el clásico memory leak, es decir, la situación en la que no se libera una
porción de memoria que fue previamente reservada. En estos casos, el destructor no se
ejecuta ya que, después de todo, el constructor no finalizó correctamente su ejecución.
La solución pasa por hacer uso de este tipo de punteros.
El caso de los destructores es menos problemático, ya que es lógico suponer
que nadie hará uso de un objeto que se va a destruir, incluso cuando se genere una
excepción dentro del propio destructor. Sin embargo, es importante recordar que el
destructor no puede lanzar una excepción si el mismo fue llamado como consecuencia
de otra excepción.
C
uando nos enfrentamos al diseño de un programa informático como un video-
juego, no es posible abordarlo por completo. El proceso de diseño de una
aplicación suele ser iterativo y en diferentes etapas de forma que se vaya refi-
nando con el tiempo. El diseño perfecto y a la primera es muy difícil de conseguir.
La tarea de diseñar aplicaciones es compleja y, con seguridad, una de las más
importantes y que más impacto tiene no sólo sobre el producto final, sino también
sobre su vida futura. En el diseño de la aplicación es donde se definen las estructuras
y entidades que se van a encargar de resolver el problema modelado, así como sus
relaciones y sus dependencias. Cómo de bien definamos estas entidades y relaciones
influirá, en gran medida, en el éxito o fracaso del proyecto y en la viabilidad de su
mantenimiento.
El diseño, por tanto, es una tarea capital en el ciclo de vida del software. Sin
embargo, no existe un procedimiento sistemático y claro sobre cómo crear el mejor
diseño para un problema dado. Podemos utilizar metodologías, técnicas y herramien-
tas que nos permitan refinar nuestro diseño. La experiencia también juega un papel
importante. Sin embargo, el contexto de la aplicación es crucial y los requisitos, que
pueden cambiar durante el proceso de desarrollo, también.
Un videojuego es un programa con una componente muy creativa. Además, el
mercado de videojuegos se mueve muy deprisa y la adaptación a ese medio “cam-
biante” es un factor determinante. En general, los diseños de los programas deben ser
escalables, extensibles y que permitan crear componentes reutilizables.
Esta última característica es muy importante ya que la experiencia nos hace ver que
al construir una aplicación se nos presentan situaciones recurrentes y que se asemejan
a situaciones pasadas. Es el deja vú en el diseño: «¿cómo solucioné esto?». En esencia,
muchos componentes pueden modelarse de formas similares.
107
[108] CAPÍTULO 4. PATRONES DE DISEÑO
En este capítulo, se describen algunos patrones de diseño que almacenan este co-
nocimiento experimental de diseño procedente del estudio de aplicaciones y de los
éxitos y fracasos de casos reales. Bien utilizados, permiten obtener un mejor diseño
más temprano.
4.1. Introducción
El diseño de una aplicación es un proceso iterativo y de continuo refinamiento.
Normalmente, una aplicación es lo suficientemente compleja como para que su di-
seño tenga que ser realizado por etapas, de forma que al principio se identifican los
módulos más abstractos y, progresivamente, se concreta cada módulo con un diseño
en particular.
En el camino, es común encontrar problemas y situaciones que conceptualmente
pueden parecerse entre sí, por lo menos a priori. Quizás un estudio más exhaustivo de
los requisitos permitan determinar si realmente se trata de problemas equivalentes.
Por ejemplo, supongamos que para resolver un determinado problema se llega
a la conclusión de que varios tipos de objetos deben esperar a un evento producido
por otro. Esta situación puede darse en la creación de una interfaz gráfica donde la
pulsación de un botón dispara la ejecución de otras acciones. Pero también es similar
a la implementación de un manejador del teclado, cuyas pulsaciones son recogidas por
los procesos interesados, o la de un gestor de colisiones, que notifica choques entre
elementos del juego. Incluso se parece a la forma en que muchos programas de chat
envían mensajes a un grupo de usuarios.
Ciertamente, cada uno de los ejemplos anteriores tiene su contexto y no es posible Definición
(ni a veces deseable) aplicar exactamente la misma solución a cada uno de ellos. Sin En [38], un patrón de diseño es una
embargo, sí que es cierto que existe semejanza en la esencia del problema. En nuestro descripción de la comunicación en-
ejemplo, en ambos casos existen entidades que necesitan ser notificadas cuando ocurre tre objetos y clases personalizadas
un cierto evento. para solucionar un problema gené-
rico de diseño bajo un contexto de-
Los patrones de diseño son formas bien conocidas y probadas de resolver proble- terminado.
mas de diseño que son recurrentes en el tiempo. Los patrones de diseño son amplia-
mente utilizados en las disciplinas creativas y técnicas. Así, de la misma forma que
un guionista de cine crea guiones a partir de patrones argumentales como «comedia»
o «ciencia-ficción», un ingeniero se basa en la experiencia de otros proyectos para
identificar patrones comunes que le ayuden a diseñar nuevos procesos. De esta for-
ma, reutilizando soluciones bien probadas y conocidas se ayuda a reducir el tiempo
necesario para el diseño.
Los patrones sintetizan la tradición y experiencia profesional de diseñadores de
software experimentados que han evaluado y demostrado que la solución proporciona-
da es una buena solución bajo un determinado contexto. El diseñador o desarrollador
que conozca diferentes patrones de diseño podrá reutilizar estas soluciones, pudiendo
alcanzar un mejor diseño más rápidamente.
4.1. Introducción [109]
C4
Cuando se describe un patrón de diseño se pueden citar más o menos propiedades
del mismo: el problema que resuelve, sus ventajas, si proporciona escalabilidad en el
diseño o no, etc. Nosotros vamos a seguir las directrices marcadas por los autores del
famoso libro de Design Patterns [38] 1 , por lo que para definir un patrón de diseño es
necesario describir, como mínimo, cuatro componentes fundamentales:
1 También conocido como The Gang of Four (GoF) (la «Banda de los Cuatro») en referencia a los autores
del mismo. Sin duda se trata de un famoso libro en este área, al que no le faltan detractores.
[110] CAPÍTULO 4. PATRONES DE DISEÑO
4.2.1. Singleton
El patrón singleton se suele utilizar cuando se requiere tener una única instancia
de un determinado tipo de objeto.
Problema
C4
En C++, utilizando el operador new es posible crear una instancia de un objeto.
Sin embargo, es posible que necesitemos que sólo exista una instancia de una clase
determinada por diferentes motivos (prevención de errores, seguridad, etc.).
El balón en un juego de fútbol o la entidad que representa al mundo 3D son ejem-
plos donde podría ser conveniente mantener una única instancia de este tipo de objetos.
Solución
Para garantizar que sólo existe una instancia de una clase es necesario que los
clientes no puedan acceder directamente al constructor. Por ello, en un singleton el
constructor es, por lo menos, protected. A cambio se debe proporcionar un único
punto (controlado) por el cual se pide la instancia única. El diagrama de clases de este
patrón se muestra en la figura 4.1.
Implementación
Como se puede ver, la característica más importante es que los métodos que pue-
den crear una instancia de Ball son todos privados para los clientes externos. Todos
ellos deben utilizar el método estático getTheBall() para obtener la única instan-
cia.
Consideraciones
Problema
En ella, se muestra jerarquías de clases que modelan los diferentes tipos de per-
sonajes de un juego y algunas de sus armas. Para construir cada tipo de personaje es
necesario saber cómo construirlo y con qué otro tipo de objetos tiene relación. Por
ejemplo, restricciones del tipo «la gente del pueblo no puede llevar armas» o «los ar-
queros sólo pueden puede tener un arco», es conocimiento específico de la clase que
se está construyendo.
Supongamos que en nuestro juego, queremos obtener razas de personajes: hom-
bres y orcos. Cada raza tiene una serie de características propias que hacen que pueda
moverse más rápido, trabajar más o tener más resistencia a los ataques.
4.2. Patrones de creación [113]
El patrón Abstract Factory puede ser de ayuda en este tipo de situaciones en las que
es necesario crear diferentes tipos de objetos utilizando una jerarquía de componentes.
C4
Dada la complejidad que puede llegar a tener la creación de una instancia es deseable
aislar la forma en que se construye cada clase de objeto.
Solución
En la figura 4.3 se muestra la aplicación del patrón para crear las diferentes ra-
zas de soldados. Por simplicidad, sólo se ha aplicado a esta parte de la jerarquía de
personajes.
En primer lugar se define una factoría abstracta que será la que utilice el cliente
(Game) para crear los diferentes objetos. CharFactory es una factoría que sólo
define métodos abstractos y que serán implementados por sus clases hijas. Éstas son
factorías concretas a cada tipo de raza (ManFactory y OrcFactory) y ellas son
las que crean las instancias concretas de objetos Archer y Rider para cada una de
las razas.
En definitiva, el patrón Abstract Factory recomienda crear las siguientes entidades:
Factoría abstracta que defina una interfaz para que los clientes puedan crear los
distintos tipos de objetos.
Factorías concretas que realmente crean las instancias finales.
Implementación
Nótese como las factorías concretas ocultan las particularidades de cada tipo. Una
implementación similar tendría el método makeRider().
Consideraciones
C4
cuando la creación de las instancias implican la imposición de restricciones y
otras particularidades propias de los objetos que se construyen.
los productos que se deben fabricar en las factorías no cambian excesivamente
en el tiempo. Añadir nuevos productos implica añadir métodos a todas las fac-
torías ya creadas, por lo que es un poco problemático. En nuestro ejemplo, si
quisiéramos añadir un nuevo tipo de soldado deberíamos modificar la factoría
abstracta y las concretas. Por ello, es recomendable que se aplique este patrón
sobre diseños con un cierto grado de estabilidad.
Un patrón muy similar a éste es el patrón Builder. Con una estructura similar,
el patrón Builder se centra en el proceso de cómo se crean las instancias y no en
la jerarquía de factorías que lo hacen posible. Como ejercicio se plantea estudiar el
patrón Builder y encontrar las diferencias.
Problema
Al igual que ocurre con el patrón Abstract Factory, el problema que se pretende
resolver es la creación de diferentes instancias de objetos abstrayendo la forma en que
realmente se crean.
Solución
La figura 4.4 muestra un diagrama de clases para nuestro ejemplo que emplea el
patrón Factory Method para crear ciudades en las que habitan personajes de diferentes
razas.
Como puede verse, los objetos de tipo Village tienen un método populate()
que es implementado por las subclases. Este método es el que crea las instancias de
Villager correspondientes a cada raza. Este método es el método factoría. Además
de este método, también se proporcionan otros como population() que devuelve
la población total, o location() que devuelve la posición de la cuidad en el mapa.
Todos estos métodos son comunes y heredados por las ciudades de hombres y orcos.
Finalmente, objetos Game podrían crear ciudades y, consecuentemente, crear ciu-
dadanos de distintos tipos de una forma transparente.
Consideraciones
Nótese que el patrón Factory Method se utiliza para implementar el patrón Abs-
tract Factory ya que la factoría abstracta define una interfaz con métodos de construc-
ción de objetos que son implementados por las subclases.
4.2.4. Prototype
El patrón Prototype proporciona abstracción a la hora de crear diferentes objetos
en un contexto donde se desconoce cuántos y cuáles deben ser creados a priori. La
idea principal es que los objetos deben poder clonarse en tiempo de ejecución.
Problema
Solución
C4
Para atender a las nuevas necesidades dinámicas en la creación de los distintos
tipo de armas, sin perder la abstracción sobre la creación misma, se puede utilizar el
patrón Prototype como se muestra en la figura 4.5.
Consideraciones
Puede parecer que entra en conflicto con Abstract Factory debido a que intenta
eliminar, precisamente, factorías intermedias. Sin embargo, es posible utilizar
ambas aproximaciones en una Prototype Abstract Factory de forma que la fac-
toría se configura con los prototipos concretos que puede crear y ésta sólo invoca
a clone().
También es posible utilizar un gestor de prototipos que permita cargar y descar-
gar los prototipos disponibles en tiempo de ejecución. Este gestor es interesante
para tener diseños ampliables en tiempo de ejecución (plugins).
Para que los objetos puedan devolver una copia de sí mismo es necesario que en
su implementación esté el constructor de copia (copy constructor) que en C++
viene por defecto implementado.
4.3.1. Composite
El patrón Composite se utiliza para crear una organización arbórea y homogénea
de instancias de objetos.
Problema
Solución
Por otro lado, hay objetos hoja que no contienen a más objetos, como es el caso
de Clock.
Consideraciones
Una buena estrategia para identificar la situación en la que aplicar este patrón
es cuando tengo «un X y tiene varios objetos X».
4.3. Patrones estructurales [119]
C4
prohibir la composición de un tipo de objeto con otro. Por ejemplo, un jarrón
grande dentro de una pequeña bolsa. La comprobación debe hacerse en tiempo
de ejecución y no es posible utilizar el sistema de tipos del compilador. En este
sentido, usando Composite se relajan las restricciones de composición entre
objetos.
Los usuarios de la jerarquía se hacen más sencillos, ya que sólo tratan con un
tipo abstracto de objeto, dándole homogeneidad a la forma en que se maneja la
estructura.
4.3.2. Decorator
También conocido como Wrapper, el patrón Decorator sirve para añadir y/o mo-
dificar la responsabilidad, funcionalidad o propiedades de un objeto en tiempo de
ejecución.
Problema
Supongamos que el personaje de nuestro videojuego porta un arma que utiliza para
eliminar a sus enemigos. Dicha arma, por ser de un tipo determinado, tiene una serie
de propiedades como el radio de acción, nivel de ruido, número de balas que puede
almacenar, etc. Sin embargo, es posible que el personaje incorpore elementos al arma
que puedan cambiar estas propiedades como un silenciador o un cargador extra.
El patrón Decorator permite organizar el diseño de forma que la incorporación
de nueva funcionalidad en tiempo de ejecución a un objeto sea transparente desde el
punto de vista del usuario de la clase decorada.
Solución
Implementación
8 public:
9 float noise () const { return 150.0; }
10 int bullets () const { return 5; }
11 };
12
13 /* Decorators */
14
15 class FirearmDecorator : public Firearm {
16 protected:
17 Firearm* _gun;
18 public:
19 FirearmDecorator(Firearm* gun): _gun(gun) {};
20 virtual float noise () const { return _gun->noise(); }
21 virtual int bullets () const { return _gun->bullets(); }
22 };
23
24 class Silencer : public FirearmDecorator {
25 public:
26 Silencer(Firearm* gun) : FirearmDecorator(gun) {};
27 float noise () const { return _gun->noise() - 55; }
28 int bullets () const { return _gun->bullets(); }
29 };
30
31 class Magazine : public FirearmDecorator {
32 public:
33 Magazine(Firearm* gun) : FirearmDecorator(gun) {};
34 float noise () const { return _gun->noise(); }
35 int bullets () const { return _gun->bullets() + 5; }
36 };
37
38 /* Using decorators */
39
40 ...
41 Firearm* gun = new Rifle();
42 cout << "Noise: " << gun->noise() << endl;
43 cout << "Bullets: " << gun->bullets() << endl;
44 ...
45 // char gets a silencer
46 gun = new Silencer(gun);
47 cout << "Noise: " << gun->noise() << endl;
48 cout << "Bullets: " << gun->bullets() << endl;
49 ...
50 // char gets a new magazine
51 gun = new Magazine(gun);
52 cout << "Noise: " << gun->noise() << endl;
53 cout << "Bullets: " << gun->bullets() << endl;
4.3. Patrones estructurales [121]
En cada momento, ¿qué valores se imprimen?. Supón que el personaje puede qui-
tar el silenciador. ¿Qué cambios habría que hacer en el código para «quitar» el deco-
C4
rador a la instancia?
Consideraciones
Este patrón permite tener una jerarquía de clases compuestas, formando una es-
tructura más dinámica y flexible que la herencia estática. El diseño equivalente
utilizando mecanismos de herencia debería considerar todos los posibles casos
en las clases hijas. En nuestro ejemplo, habría 4 clases: rifle, rifle con silencia-
dor, rifle con cargador extra y rifle con silenciador y cargador. Sin duda, este
esquema es muy poco flexible.
4.3.3. Facade
El patrón Facade eleva el nivel de abstracción de un determinado sistema para
ocultar ciertos detalles de implementación y hacer más sencillo su uso.
Problema
Solución
Como se puede ver, el usuario ya no tiene que conocer las relaciones que exis-
ten entre los diferentes módulos para crear este tipo de animaciones. Esto aumenta,
levemente, el nivel de abstracción y hace más sencillo su uso.
En definitiva, el uso del patrón Facade proporciona una estructura de diseño como
la mostrada en la figura 4.8.
Consideraciones
C4
Los sistemas deben ser independientes y portables.
Controlar el acceso y la forma en que se utiliza un sistema determinado.
Los clientes pueden seguir utilizando los subsistemas directamente, sin pasar
por la fachada, lo que da la flexibilidad de elegir entre una implementación de
bajo nivel o no.
Sin embargo, utilizando el patrón Facade es posible caer en los siguientes errores:
Crear clases con un tamaño desproporcionado. Las clases fachada pueden con-
tener demasiada funcionalidad si no se divide bien las responsabilidades y se
tiene claro el objetivo para el cual se creo la fachada. Para evitarlo, es necesario
ser crítico/a con el nivel de abstracción que se proporciona.
Obtener diseños poco flexibles y con mucha contención. A veces, es posible
crear fachadas que obliguen a los usuarios a un uso demasiado rígido de la fun-
cionalidad que proporciona y que puede hacer que sea más cómodo, a la larga,
utilizar los subsistemas directamente. Además, una fachada puede convertirse
en un único punto de fallo, sobre todo en sistemas distribuidos en red.
Exponer demasiados elementos y, en definitiva, no proporcionar un nivel de
abstracción adecuado.
4.3.4. MVC
El patrón MVC (Model View Controller) se utiliza para aislar el dominio de apli-
cación, es decir, la lógica, de la parte de presentación (interfaz de usuario).
Problema
Solución
En el patrón MVC, mostrado en la figura 4.9, existen tres entidades bien definidas:
Vista: se trata de la interfaz de usuario que interactúa con el usuario y recibe sus
órdenes (pulsar un botón, introducir texto, etc.). También recibe órdenes desde
el controlador, para mostrar información o realizar un cambio en la interfaz.
Figura 4.9: Estructura del patrón
MVC.
[124] CAPÍTULO 4. PATRONES DE DISEÑO
Consideraciones
4.3.5. Adapter
El patrón Adapter se utiliza para proporcionar una interfaz que, por un lado, cum-
pla con las demandas de los clientes y, por otra, haga compatible otra interfaz que, a
priori, no lo es.
Problema
Solución
C4
Usando el patrón Adapter es posible crear una nueva interfaz de acceso a un deter-
minado objeto, por lo que proporciona un mecanismo de adaptación entre las deman-
das del objeto cliente y el objeto servidor que proporciona la funcionalidad.
Consideraciones
Tener sistemas muy reutilizables puede hacer que sus interfaces no puedan ser
compatibles con una común. El patrón Adapter es una buena opción en este
caso.
Un mismo adaptador puede utilizarse con varios sistemas.
Otra versión del patrón es que la clase Adapter sea una subclase del sistema
adaptado. En este caso, la clase Adapter y la adaptada tienen una relación más
estrecha que si se realiza por composición.
Este patrón se parece mucho al Decorator. Sin embargo, difieren en que la finali-
dad de éste es proporcionar una interfaz completa del objeto adaptador, mientras
que el decorador puede centrarse sólo en una parte.
4.3.6. Proxy
El patrón Proxy proporciona mecanismos de abstracción y control para acceder a
un determinado objeto «simulando» que se trata del objeto real.
[126] CAPÍTULO 4. PATRONES DE DISEÑO
Problema
Muchos de los objetos de los que puede constar una aplicación pueden presentar
diferentes problemas a la hora de ser utilizados por clientes:
Coste computacional: es posible que un objeto, como una imagen, sea costoso
de manipular y cargar.
Acceso remoto: el acceso por red es una componente cada vez más común entre
las aplicaciones actuales. Para acceder a servidores remotos, los clientes deben
conocer las interioridades y pormenores de la red (sockets, protocolos, etc.).
Acceso seguro: es posible que muchos objetos necesiten diferentes privilegios
para poder ser utilizados. Por ejemplo, los clientes deben estar autorizados para
poder acceder a ciertos métodos.
Dobles de prueba: a la hora de diseñar y probar el código, puede ser útil uti-
lizar objetos dobles que reemplacen instancias reales que pueden hacer que las
pruebas sea pesadas y/o lentas.
Solución
Implementación
C4
8
9 ...
10 /* perform a hard file load */
11 ...
12 }
13
14 void display() {
15 ...
16 /* perform display operation */
17 ...
18 }
19 };
20
21 class ImageProxy : public Graphic {
22 private:
23 Image* _image;
24 public:
25 void display() {
26 if (not _image) {
27 _image = new Image();
28 _image.load();
29 }
30 _image->display();
31 }
32 };
33
34 /* Client */
35 ...
36 Graphic image = new ImageProxy();
37 image->display(); // loading and display
38 image->display(); // just display
39 image->display(); // just display
40 ...
Consideraciones
Existen muchos ejemplos donde se hace un uso intensivo del patrón proxy en
diferentes sistemas:
4.4.1. Observer
El patrón Observer se utiliza para definir relaciones 1 a n de forma que un objeto
pueda notificar y/o actualizar el estado de otros automáticamente.
Problema
Solución
El patrón Observer proporciona un diseño con poco acoplamiento entre los obser-
vadores y el objeto observado. Siguiendo la filosofía de publicación/suscripción, los
objetos observadores se deben registrar en el objeto observado, también conocido co-
mo subject. Así, cuando ocurra el evento oportuno, el subject recibirá una invocación
a través de notify() y será el encargado de «notificar» a todos los elementos suscri-
tos a él a través del método update(). Los observadores que reciben la invocación
pueden realizar las acciones pertinentes como consultar el estado del dominio para
obtener nuevos valores. En la figura 4.12 se muestra un esquema general del patrón
Observer.
Consideraciones
C4
Figura 4.13: Diagrama de secuencia de ejemplo utilizando un Observer
Los canales de eventos es un patrón más genérico que el Observer pero que sigue
respetando el modelo push/pull. Consiste en definir estructuras que permiten la comu-
nicación n a n a través de un medio de comunicación (canal) que se puede multiplexar
en diferentes temas (topics). Un objeto puede establecer un rol suscriptor de un tema
dentro de un canal y sólo recibir las notificaciones del mismo. Además, también pue-
de configurarse como publicador, por lo que podría enviar actualizaciones al mismo
canal.
[130] CAPÍTULO 4. PATRONES DE DISEÑO
4.4.2. State
El patrón State es útil para realizar transiciones de estado e implementar autómatas
respetando el principio de encapsulación.
Problema
Es muy común que en cualquier aplicación, incluído los videojuegos, existan es-
tructuras que pueden ser modeladas directamente como un autómata, es decir, una
colección de estados y unas transiciones dependientes de una entrada. En este caso, la
entrada pueden ser invocaciones y/o eventos recibidos.
Por ejemplo, los estados de un personaje de un videojuego podrían ser: de pie,
tumbado, andando y saltando. Dependiendo del estado en el que se encuentre y de la
invocación recibida, el siguiente estado será uno u otro. Por ejemplo, si está de pie y
recibe la orden de tumbarse, ésta se podrá realizar. Sin embargo, si ya está tumbado
no tiene sentido volver a tumbarse, por lo que debe permanecer en ese estado.
Solución
Por cada estado en el que puede encontrarse el personaje, se crea una clase que
hereda de la clase abstracta anterior, de forma que en cada una de ellas se implementen
los métodos que producen cambio de estado.
Por ejemplo, según el diagrama, en el estado «de pie» se puede recibir la orden
de caminar, tumbarse y saltar, pero no de levantarse. En caso de recibir esta última, se
ejecutará la implementación por defecto, es decir, no hacer nada.
En definitiva, la idea es que las clases que representan a los estados sean las encar-
gadas de cambiar el estado del personaje, de forma que los cambios de estados quedan
encapsulados y delegados al estado correspondiente.
4.4. Patrones de comportamiento [131]
Consideraciones
C4
Los componentes del diseño que se comporten como autómatas son buenos
candidatos a ser modelados con el patrón State. Una conexión TCP (Transport
Control Protocol) o un carrito en una tienda web son ejemplos de este tipo de
problemas.
Es posible que una entrada provoque una situación de error estando en un deter-
minado estado. Para ello, es posible utilizar las excepciones para notificar dicho
error.
Las clases que representan a los estados no deben mantener un estado intrín-
seco, es decir, no se debe hacer uso de variables que dependan de un contexto.
De esta forma, el estado puede compartirse entre varias instancias. La idea de
compartir un estado que no depende del contexto es la base fundamental del pa-
trón Flyweight, que sirve para las situaciones en las que crear muchas instancias
puede ser un problema de rendimiento.
4.4.3. Iterator
El patrón Iterator se utiliza para ofrecer una interfaz de acceso secuencial a una
determinada estructura ocultando la representación interna y la forma en que realmen-
te se accede.
Problema
Solución
Con ayuda del patrón Iterator es posible obtener acceso secuencial, desde el punto
de vista del usuario, a cualquier estructura de datos, independientemente de su imple-
mentación interna. En la figura 4.15 se muestra un diagrama de clases genérico del
patrón. Como puede verse, la estructura de datos es la encargada de crear el iterador
adecuado para ser accedida a través del método iterator(). Una vez que el cliente
ha obtenido el iterador, puede utilizar los métodos de acceso que ofrecen tales como
next() (para obtener el siguiente elemento) o isDone() para comprobar si no
existen más elementos.
[132] CAPÍTULO 4. PATRONES DE DISEÑO
Implementación
35 bool isDone() {
36 return _currentIndex > _list->length();
};
C4
37
38 };
39
40 /* client using iterator */
41
42 List list = new List();
43 ListIterator it = list.iterator();
44
45 for (Object ob = it.first(); not it.isDone(); it.next()) {
46 // do the loop using ’ob’
47 };
Consideraciones
Problema
Por ejemplo, supongamos que tenemos dos tipos de jugadores de juegos de mesa:
ajedrez y damas. En esencia, ambos juegan igual; lo que cambia son las reglas del
juego que, obviamente, condiciona su estrategia y su forma de jugar concreta. Sin
embargo, en ambos juegos, los jugadores mueven en su turno, esperan al rival y esto
se repite hasta que acaba la partida.
El patrón Template Method consiste extraer este comportamiento común en una
clase padre y definir en las clases hijas la funcionalidad concreta.
Solución
Siguiendo con el ejemplo de los jugadores de ajedrez y damas, la figura 4.16 mues-
tra una posible aplicación del patrón Template Method a modo de ejemplo. Nótese que
la clase GamePlayer es la que implementa el método play() que es el que invoca
a los otros métodos que son implementados por las clases hijas. Este método es el
método plantilla.
Cada tipo de jugador define los métodos en base a las reglas y heurísticas de su
juego. Por ejemplo, el método isOver() indica si el jugador ya no puede seguir
jugando porque se ha terminado el juego. En caso de las damas, el juego se acaba para
el jugador si se ha quedado sin fichas; mientras que en el caso ajedrez puede ocurrir
por jaque mate (además de otros motivos).
Consideraciones
4.4.5. Strategy
C4
El patrón Strategy se utiliza para encapsular el funcionamiento de una familia de
algoritmos, de forma que se pueda intercambiar su uso sin necesidad de modificar a
los clientes.
Problema
Solución
La idea es extraer los métodos que conforman el comportamiento que puede ser
intercambiado y encapsularlo en una familia de algoritmos. En este caso, el movi-
miento del jugador se extrae para formar una jerarquía de diferentes movimientos
(Movement). Todos ellos implementan el método move() que recibe un contexto
que incluye toda la información necesaria para llevar a cabo el algoritmo.
El siguiente fragmento de código indica cómo se usa este esquema por parte de
un cliente. Nótese que al configurarse cada jugador, ambos son del mismo tipo de
cara al cliente aunque ambos se comportarán de forma diferente al invocar al método
doBestMove().
Consideraciones
El patrón Strategy es una buena alternativa a realizar subclases en las entidades que
deben comportarse de forma diferente en función del algoritmo utilizado. Al extraer la
heurística a una familia de algoritmos externos, obtenemos los siguientes beneficios:
4.4.6. Reactor
El patrón Reactor es un patrón arquitectural para resolver el problema de cómo
atender peticiones concurrentes a través de señales y manejadores de señales.
Problema
Solución
En el patrón Reactor se definen una serie de actores con las siguientes responsabi-
lidades (véase figura 4.18):
Eventos: los eventos externos que puedan ocurrir sobre los recursos (Handles).
Normalmente su ocurrencia es asíncrona y siempre está relaciona a un recurso
C4
determinado.
Recursos (Handles): se refiere a los objetos sobre los que ocurren los eventos.
La pulsación de una tecla, la expiración de un temporizador o una conexión en-
trante en un socket son ejemplos de eventos que ocurren sobre ciertos recursos.
La representación de los recursos en sistemas tipo GNU/Linux es el descriptor
de fichero.
Manejadores de Eventos: Asociados a los recursos y a los eventos que se pro-
ducen en ellos, se encuentran los manejadores de eventos (EventHandler)
que reciben una invocación a través del método handle() con la información
del evento que se ha producido.
Reactor: se trata de la clase que encapsula todo el comportamiento relativo a
la desmultiplexación de los eventos en manejadores de eventos (dispatching).
Cuando ocurre un cierto evento, se busca los manejadores asociados y se les
invoca el método handle().
Nótese que aunque los eventos ocurran concurrentemente el Reactor serializa las
llamadas a los manejadores. Por lo tanto, la ejecución de los manejadores de eventos
ocurre de forma secuencial.
Consideraciones
4.4.7. Visitor
El patrón Visitor proporciona un mecanismo para realizar diferentes operaciones
sobre una jerarquía de objetos de forma que añadir nuevas operaciones no haga nece-
sario cambiar las clases de los objetos sobre los que se realizan las operaciones.
Problema
Solución
Implementación
C4
Figura 4.19: Diagrama de clases del patrón Visitor
Consideraciones
C4
Hasta el momento, se han descrito algunos de los patrones de diseño más impor-
tantes que proporcionan una buena solución a determinados problemas a nivel de dise-
ño. Todos ellos son aplicables a cualquier lenguaje de programación en mayo o menor
medida: algunos patrones de diseño son más o menos difíciles de implementar en un
lenguaje de programación determinado. Dependerá de las estructuras de abstracción
que éste proporcione.
Sin embargo, a nivel de lenguaje de programación, existen «patrones» que hacen
que un programador comprenda mejor dicho lenguaje y aplique soluciones mejores,
e incluso óptimas, a la hora de resolver un problema de codificación. Los expresiones
idiomáticas (o simplemente, idioms) son un conjunto de buenas soluciones de progra-
mación que permiten:
Al igual que los patrones, los idioms tienen un nombre asociado (además de sus
alias), la definición del problema que resuelven y bajo qué contexto, así como algunas
consideraciones (eficiencia, etc.). A continuación, se muestran algunos de los más
relevantes. En la sección de «Patrones de Diseño Avanzados» se exploran más de
ellos.
Para que no aparezcan sorpresas una clase no trivial debe tener como mínimo:
C4
1 class Vehicle
2 {
3 public:
4 virtual ~Vehicle();
5 virtual std::string name() = 0;
6 virtual void run() = 0;
7 };
8
9 class Car: public Vehicle
10 {
11 public:
12 virtual ~Car();
13 std::string name() { return "Car"; }
14 void run() { /* ... */ }
15 };
16
17 class Motorbike: public Vehicle
18 {
19 public:
20 virtual ~Motorbike();
21 std::string name() { return "Motorbike"; }
22 void run() { /* ... */ }
23 };
Este idiom muestra cómo crear una clase que se comporta como una interfaz,
utilizando métodosvirtuales puros.
El compilador fuerza que los métodos virtuales puros sean implementados por
las clases derivadas, por lo que fallará en tiempo de compilación si hay alguna que
no lo hace. Como resultado, tenemos que Vehicle actúa como una clase interfaz.
Nótese que el destructor se ha declarado como virtual. Como ya se citó en la forma
canónica ortodoxa (sección 4.5.1), esto es una buena práctica para evitar posibles leaks
de memoria en tiempo de destrucción de un objeto Vehicle usado polimórficamente
(por ejemplo, en un contenedor). Con esto, se consigue que se llame al destructor de
la clase más derivada.
Desarrollo de una librería o una API que va ser utilizada por terceros.
Clases externas de módulos internos de un programa de tamaño medio o grande.
4.5.4. pImpl
Pointer To Implementation (pImpl), también conocido como Handle Body u Opa-
que Pointer, es un famoso idiom (utilizado en otros muchos) para ocultar la implemen-
tación de una clase en C++. Este mecanismo puede ser muy útil sobre todo cuando se
tienen componentes reutilizables cuya declaración o interfaz puede cambiar, lo cual
implicaría recompilar a todos sus usuarios. El objetivo es minimizar el impacto de un
cambio en la declaración de la clase a sus usuarios.
En C++, un cambio en las variables miembro de una clase o en los métodos
inline puede suponer que los usuarios de dicha clase tengan que recompilar. Para
resolverlo, la idea de pImpl es que la clase ofrezca una interfaz pública bien definida
y que ésta contenga un puntero a su implementación, descrita de forma privada.
Por ejemplo, la clase Vehicle podría ser de la siguiente forma:
Como se puede ver, es una clase muy sencilla ya que ofrece sólo un método públi-
co. Sin embargo, si queremos modificarla añadiendo más atributos o nuevos métodos
privados, se obligará a los usuarios de la clase a recompilar por algo que realmente no
utilizan directamente. Usando pImpl, quedaría el siguiente esquema:
C4
9 class VehicleImpl;
10 VehicleImpl* _pimpl;
11 };
capitalEn este capítulo se proporciona una visión general de STL (Standard Tem-
plate Library), la biblioteca estándar proporcionada por C++, en el contexto del desa-
rrollo de videojuegos, discutiendo su utilización en dicho ámbito de programación.
Asimismo, también se realiza un recorrido exhaustivo por los principales tipos de
contenedores, estudiando aspectos relevantes de su implementación, rendimiento y
uso en memoria. El objetivo principal que se pretende alcanzar es que el lector sea ca-
paz de utilizar la estructura de datos más adecuada para solucionar un problema,
justificando el porqué y el impacto que tiene dicha decisión sobre el proyecto en su
conjunto.
Usando STL Desde un punto de vista abstracto, la biblioteca estándar de C++, STL, es un
STL es sin duda una de las bibliote-
conjunto de clases que proporciona la siguiente funcionalidad [94]:
cas más utilizadas en el desarrollo
de aplicaciones de C++. Además,
está muy optimizada para el mane-
Soporte a características del lenguaje, como por ejemplo la gestión de memoria
jo de estructuras de datos y de algo- e información relativa a los tipos de datos manejados en tiempo de ejecución.
ritmos básicos, aunque su comple-
jidad es elevada y el código fuente Soporte relativo a aspectos del lenguaje definidos por la implementación, como
es poco legible para desarrolladores por ejemplo el valor en punto flotante con mayor precisión.
poco experimentados.
Soporte para funciones que no se pueden implementar de manera óptima con
las herramientas del propio lenguaje, como por ejemplo ciertas funciones mate-
máticas o asignación de memoria dinámica.
147
[148] CAPÍTULO 5. LA BIBLIOTECA STL
La figura 5.2 muestra una perspectiva global de la organización de STL y los di-
ferentes elementos que la definen. Como se puede apreciar, STL proporciona una gran
variedad de elementos que se pueden utilizar como herramientas para la resolución de
problemas dependientes de un dominio en particular.
Las utilidades de la biblioteca estándar se definen en el espacio de nombres std y
se encuentran a su vez en una serie de bibliotecas, que identifican las partes fundamen-
tales de STL. Note que no se permite la modificación de la biblioteca estándar y no es
aceptable modificar su contenido mediante macros, ya que afectaría a la portabilidad
del código desarrollado.
En el ámbito del desarrollo de videojuegos, los contenedores juegan un papel fun- Abstracción STL
damental como herramienta para el almacenamiento de información en memoria. En El uso de los iteradores en STL re-
este contexto, la realización un estudio de los mismos, en términos de operaciones, presenta un mecanismo de abstrac-
gestión de memoria y rendimiento, es especialmente importante para utilizarlos ade- ción fundamental para realizar el re-
cuadamente. Los contenedores están representados por dos tipos principales. Por una corrido, el acceso y la modificación
sobre los distintos elementos alma-
parte, las secuencias permiten almacenar elementos en un determinado orden. Por otra cenados en un contenedor.
parte, los contenedores asociativos no tienen vinculado ningún tipo de restricción de
orden.
La herramienta para recorrer los contenedores está representada por el iterador.
Todos los contenedores mantienen dos funciones relevantes que permiten obtener dos
iteradores:
Iteradores Algoritmos
C5
<iterator> Iteradores y soporte a iteradores
<algorithm> algoritmos generales
<cstdlib> bsearch() y qsort()
Cadenas
Figura 5.2: Visión general de la organización de STL [94]. El asterisco referencia a elementos con el estilo
del lenguaje C.
[150] CAPÍTULO 5. LA BIBLIOTECA STL
✄
en concreto la función push_back para añadir un elemento al final del vector. tas estructuras de datos incluidas en
Finalmente, en la línea ✂13 ✁se declara un iterador de tipo vector<int> que se utili-
la biblioteca estándar.
zará como base✄ para el recorrido del vector. La inicialización del mismo se encuentra
en la línea ✂15 ✁, es decir, en la parte de inicialización del bucle for, de manera que
originalmente el iterador apunta al primer elemento del vector. La iteración sobre el
✄vector
se estará realizando hasta que no se llegue al iterador devuelto por end() (línea
✂16 ✁), es decir, al elemento posterior al último.
✄
(línea ✂17 ✁), al igual que el acceso
Note cómo el incremento del iterador es ✄trivial
del contenido al que apunta el iterador (línea ✂18 ✁), mediante una nomenclatura similar
a la utilizada para manejar punteros.
Por otra parte, STL proporciona un conjunto estándar de algoritmos que se pueden
aplicar a los contenedores e iteradores. Aunque dichos algoritmos son básicos, éstos se
pueden combinar para obtener el resultado deseado por el propio programador. Algu-
nos ejemplos de algoritmos son la búsqueda de elementos, la copia, la ordenación, etc.
Al igual que ocurre con todo el código de STL, los algoritmos están muy optimizados
y suelen sacrificar la simplicidad para obtener mejores resultados que proporcionen
una mayor eficiencia.
5.2. STL y el desarrollo de videojuegos [151]
C5
desarrollo, herramientas transversales y middlewares con el objetivo de dar soporte al
proceso de desarrollo de un videojuego (ver sección 1.2.2). STL estaría incluido en
dicha capa como biblioteca estándar de C++, posibilitando el uso de diversos conte-
nedores como los mencionados anteriormente.
Desde un punto de vista general, a la hora de abordar el desarrollo de un videojue-
go, el programador o ingeniero tendría que decidir si utilizar STL o, por el contrario,
utilizar alguna otra biblioteca que se adapte mejor a los requisitos impuestos por el
juego a implementar.
5.2.2. Rendimiento
Uno de los aspectos críticos en el ámbito del desarrollo de videojuegos es el ren-
dimiento, ya que es fundamental para lograr un sensación de interactividad y para
dotar de sensación de realismo el usuario de videojuegos. Recuerde que un videojue-
go es una aplicación gráfica de renderizado en tiempo real y, por lo tanto, es necesario
asegurar una tasa de frames por segundo adecuada y en todo momento. Para ello, el
rendimiento de la aplicación es crítico y éste viene determinado en gran medida por
las herramientas utilizadas.
En general, STL proporciona un muy buen rendimiento debido principalmente a
que ha sido mejorado y optimizado por cientos de desarrolladores en estos últimos
años, considerando las propiedades intrínsecas de cada uno de sus elementos y de las
plataformas sobre las que se ejecutará.
STL será normalmente más eficiente que otra implementación y, por lo tanto, es La herramienta ideal
el candidato ideal para manejar las estructuras de datos de un videojuego. Mejorar el Benjamin Franklin afirmó que si
rendimiento de STL implica tener un conocimiento muy profundo de las estructuras dispusiera de 8 horas para derribar
de datos a manejar y puede ser una alternativa en casos extremos y con plataformas un árbol, emplearía 6 horas para afi-
hardware específicas. Si éste no es el caso, entonces STL es la alternativa directa. lar su hacha. Esta reflexión se puede
aplicar perfectamente a la hora de
No obstante, algunas compañías tan relevantes en el ámbito del desarrollo de vi- decidir qué estructura de datos uti-
deojuegos, como EA (Electronic Arts), han liberado su propia adaptación de STL de- lizar para solventar un problema.
nominada EASTL (Electronic Arts Standard Template Library) 1 , justificando esta
decisión en base a la detección de ciertas debilidades, como el modelo de asignación
de memoria, o el hecho de garantizar la consistencia desde el punto de vista de la
portabilidad.
Además del rendimiento de STL, es importante considerar que su uso permite
que el desarrollador se centre principalmente en el manejo de elementos propios, es
decir, a nivel de nodos en una lista o claves en un diccionario, en lugar de prestar más
importancia a elementos de más bajo nivel, como punteros o buffers de memoria.
Uno de los aspectos claves a la hora de manejar de manera eficiente STL se basa en
utilizar la herramienta adecuada para un problema concreto. Si no es así, entonces es
fácil reducir enormemente el rendimiento de la aplicación debido a que no se utilizó,
por ejemplo, la estructura de datos más adecuada.
5.2.3. Inconvenientes
El uso de STL también puede presentar ciertos inconvenientes; algunos de ellos
directamente vinculados a su propia complejidad [27]. En este contexto, uno de los
principales inconvenientes al utilizar STL es la depuración de programas, debido a
aspectos como el uso extensivo de plantillas por parte de STL. Este planteamiento
complica bastante la inclusión de puntos de ruptura y la depuración interactiva. Sin
embargo, este problema no es tan relevante si se supone que STL ha sido extensamente
probado y depurado, por lo que en teoría no sería necesario llegar a dicho nivel.
Por otra parte, visualizar de manera directa el contenido de los contenedores o
estructuras de datos utilizadas puede resultar complejo. A veces, es muy complicado
conocer exactamente a qué elemento está apuntando un iterador o simplemente ver
todos los elementos de un vector.
La última desventaja es la asignación de memoria, debido a que se pretende que
STL se utilice en cualquier entorno de cómputo de propósito general. En este contexto,
es lógico suponer que dicho entorno tenga suficiente memoria y la penalización por
asignar memoria no es crítica. Aunque esta situación es la más común a la hora de
1 http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2007/n2271.html
5.3. Secuencias [153]
C5
En general, hacer uso de STL para desarrollar un videojuego es una de las
mejores alternativas posibles. En el ámbito comercial, un gran número de
juegos de ordenador y de consola han hecho uso de STL. Recuerde que siem-
pre es posible personalizar algunos aspectos de STL, como la asignación de
memoria, en función de las restricciones existentes.
Tamaño elemento La principal característica de los contenedores de secuencia es que los elementos
almacenados mantienen un orden determinado. La inserción y eliminación de ele-
Inicio mentos se puede realizar en cualquier posición debido a que los elementos residen
en una secuencia concreta. A continuación se lleva a cabo una discusión de tres de
los contenedores de secuencia más utilizados: el vector (vector), la cola de doble fin
(deque) y la lista (list).
Implementación
Rendimiento
C5
y libera el bloque de memoria inicial. Este planteamiento evita que el vector tenga
que reasignar memoria con frecuencia. En este contexto, es importante resaltar que un
vector no garantiza la validez de un puntero o un iterador después de una inserción, ya
que se podría dar esta situación. Por lo tanto, si es necesario acceder a un elemento en
concreto, la solución ideal pasa por utilizar el índice junto con el operador [].
Vectores y reserve() Los vectores permiten la preasignación de un número de entradas con el objetivo
de evitar la reasignación de memoria y la copia de elementos a un nuevo bloque.
La reserva de memoria explícita Para ello, se puede utilizar la operación reserve de vector que permite especificar la
puede contribuir a mejorar el rendi-
miento de los vectores y evitar un cantidad de memoria reservada para el vector y sus futuros elementos.
alto número de operaciones de re-
serva.
El caso contrario a esta situación está representado por vectores que contienen
datos que se usan durante un cálculo en concreto pero que después se descartan. Si
esta situación se repite de manera continuada, entonces se está asignando y liberando
el vector constantemente. Una posible solución consiste en mantener el vector como
estático y limpiar todos los elementos después de utilizarlos. Este planteamiento ha-
ce que el vector quede vacío y se llame al destructor de cada uno de los elementos
previamente contenidos.
Finalmente, es posible aprovecharse del hecho de que los elementos de un vector
se almacenan de manera contigua en memoria. Por ejemplo, sería posible pasar el
contenido de un vector a una función que acepte un array, siempre y cuando dicha
función no modifique su contenido [27], ya que el contenido del vector no estaría
sincronizado.
[156] CAPÍTULO 5. LA BIBLIOTECA STL
5.3.2. Deque
Deque proviene del término inglés double-ended queue y representa una cola de
doble fin. Este tipo de contenedor es muy parecido al vector, ya que proporciona
acceso directo a cualquier elemento y permite la inserción y eliminación en cualquier
posición, aunque con distinto impacto en el rendimiento. La principal diferencia reside
en que tanto la inserción como eliminación del primer y último elemento de la cola
son muy rápidas. En concreto, tienen una complejidad constante, es decir, O(1).
La colas de doble fin incluyen la funcionalidad básica de los vectores pero tam- Deque y punteros
bién considera operaciones para insertar y eliminar elementos, de manera explícita, al
principio del contenedor3 . La cola de doble fin no garanti-
za que todos los elementos almace-
nados residan en direcciones conti-
guas de memoria. Por lo tanto, no es
Implementación posible realizar un acceso seguro a
los mismos mediante aritmética de
punteros.
Este contenedor de secuencia, a diferencia de los vectores, mantiene varios blo-
ques de memoria, en lugar de uno solo, de forma que se reservan nuevos bloques
conforme el contenedor va creciendo en tamaño. Al contrario que ocurría con los vec-
tores, no es necesario hacer copias de datos cuando se reserva un nuevo bloque de
memoria, ya que los reservados anteriormente siguen siendo utilizados.
La cabecera de la cola de doble fin almacena una serie de punteros a cada uno de
los bloques de memoria, por lo que su tamaño aumentará si se añaden nuevos bloques
que contengan nuevos elementos.
3 http://www.cplusplus.com/reference/stl/deque/
5.3. Secuencias [157]
Cabecera Bloque 1
Principio
Vacío
C5
Final
Elemento 1
#elementos
Elemento 2
Tamaño elemento
Bloque 1 Bloque 2
Bloque 2 Elemento 3
Bloque n Elemento 4
Elemento 5
Vacío
Bloque n Elemento 6
Elemento j
Elemento k
Vacío
Rendimiento
5.3.3. List
La lista es otro contenedor de secuencia que difiere de los dos anteriores. En pri-
mer lugar, la lista proporciona iteradores bidireccionales, es decir, iteradores que
permiten navegar en los dos sentidos posibles: hacia adelante y hacia atrás. En se-
gundo lugar, la lista no proporciona un acceso aleatorio a los elementos que contiene,
como sí hacen tanto los vectores como las colas de doble fin. Por lo tanto, cualquier
algoritmo que haga uso de este tipo de acceso no se puede aplicar directamente sobre
listas.
La principal ventaja de la lista es el rendimiento y la conveniencia para determina-
das operaciones, suponiendo que se dispone del iterador necesario para apuntar a una
determinada posición.
La especificación de listas de STL ofrece una funcionalidad básica muy similar Algoritmos en listas
a la de cola de doble fin, pero también incluye funcionalidad relativa a aspectos más
complejos como la fusión de listas o la búsqueda de elementos4 . La propia naturaleza de la lista per-
mite que se pueda utilizar con un
buen rendimiento para implementar
algoritmos o utilizar los ya existen-
Implementación tes en la propia biblioteca.
Rendimiento
Cabecera
Principio Siguiente Siguiente Siguiente
C5
Final Anterior Anterior Anterior
#elementos
Elemento 1 Elemento 2 Elemento 3
Tamaño elemento
...
El planteamiento basado en manejar punteros hace que las listas sean más eficien-
tes a la hora de, por ejemplo, reordenar sus elementos, ya que no es necesario realizar
copias de datos. En su lugar, simplemente hay que modificar el valor de los punteros
de acuerdo al resultado esperado o planteado por un determinado algoritmo. En este
contexto, a mayor número de elementos en una lista, mayor beneficio en comparación
con otro tipo de contenedores.
El anterior listado de código mostrará el siguiente resultado por la salida estándar:
0: 23 1: 28 2: 10 3: 17 4: 20 5: 22 6: 11
2: 10 6: 11 3: 17 4: 20 5: 22 0: 23 1: 28
C5
Recorrido Rápido Rápido Menos rápido
Asignación memoria Raramente Periódicamente Con cada I/E
Acceso memoria sec. Sí Casi siempre No
Invalidación iterador Tras I/E Tras I/E Nunca
Cuadro 5.1: Resumen de las principales propiedades de los contenedores de secuencia previamente estu-
diados (I/E = inserción/eliminación).
7 9 5
Cabecera
Principio
C5
Final
Tamaño elemento
...
Hijoi Elemento Hijodi Hijodi Elemento Hijod
Implementación
Rendimiento
Implementación
Rendimiento
C5
modo, es posible obtener cierta ventaja de manejar claves simples sobre una gran
cantidad de elementos complejos, mejorando el rendimiento de la aplicación. Sin em-
bargo, nunca hay que olvidar que si se manejan claves más complejas como cadenas
o tipos de datos definidos por el usuario, el rendimiento puede verse afectado negati-
vamente.
El operador [] Por otra parte, el uso del operador [] no tiene el mismo rendimiento que en vecto-
res, ya que implica realizar la búsqueda del elemento a partir de la clave y, por lo tanto,
El operador [] se puede utilizar pa-
ra acceder, escribir o actualizar in- no sería de un orden de complejidad constante sino logarítmico. Al usar este operador,
formación sobre algunos contene- también hay que tener en cuenta que si se escribe sobre un elemento que no existe,
dores. Sin embargo, es importante entonces éste se añade al contenedor. Sin embargo, si se intenta leer un elemento que
considerar el impacto en el rendi- no existe, entonces el resultado es que el elemento por defecto se añade para la clave
miento al utilizar dicho operador, el
cual vendrá determinado por el con- en concreto y, al mismo tiempo, se devuelve dicho valor. El siguiente listado de código
tenedor usado. muestra un ejemplo.
Como se puede apreciar, el listado de código muestra características propias de
STL para manejar los elementos del contenedor:
✄
En la línea ✂7 ✁se hace uso de pair para✄declarar
una pareja de valores: i) un itera-
dor al contenedor definido en la línea ✂6 ✁y ii) un valor booleano. Esta
✄ estructura
se utilizará para recoger el valor de retorno de una inserción (línea ✂14 ✁).
✄
Las líneas ✂10-11 ✁muestran inserciones de elementos.
✄
En la línea ✂15 ✁se recoge el valor de retorno al intentar insertar un elemento
✄
con una clave ya existente. Note cómo se accede al iterador en la línea ✂17 ✁para
obtener el valor previamente almacenado en la entrada con clave 2.
✄
La línea ✂20 ✁muestra un ejemplo de inserción con el operador [], ya que la clave
3 no se había utilizado previamente.
✄
La línea ✂23 ✁ejemplifica la inserción de un elemento por defecto. Al tratarse de
cadenas de texto, el valor por defecto es la cadena vacía.
Cuadro 5.2: Resumen de las principales propiedades de los contenedores asociativos previamente estudia-
dos (I/E = inserción/eliminación).
Ante esta situación, resulta deseable hacer uso de la operación find para determi- Usando referencias
nar si un elemento pertenece o no al contenedor, accediendo al mismo con el iterador Recuerde consultar información so-
devuelto por dicha operación. Así mismo, la inserción de elementos es más eficiente bre las operaciones de cada uno
con insert en lugar de con el operador [], debido a que este último implica un ma- de los contenedores estudiados para
yor número de copias del elemento a insertar. Por el contrario, [] es ligeramente más conocer exactamente su signatura y
cómo invocarlas de manera eficien-
eficiente a la hora de actualizar los contenidos del contenedor en lugar de insert. te.
Respecto al uso de memoria, los mapas tienen un comportamiento idéntico al de
los conjuntos, por lo que se puede suponer los mismos criterios de aplicación.
C5
desarrollador tenga que especificarla. Este enfoque se basa en que, por ejemplo, los
contenederos de secuencia existentes tienen la flexibilidad necesaria para comportar-
se como otras estructuras de datos bien conocidas, como por ejemplo las pilas o las
colas.
LIFO Un pila es una estructura de datos que solamente permite añadir y eliminar ele-
La pila está diseña para operar en un mentos por un extremo, la cima. En este contexto, cualquier conteneder de secuencia
contexto LIFO (Last In, First Out) discutido anteriormente se puede utilizar para proporcionar dicha funcionalidad. En
(last-in first-out), es decir, el ele- el caso particular de STL, es posible manejar pilas e incluso especificar la imple-
mento que se apiló más reciente- mentación subyacente que se utilizará, especificando de manera explícita cuál será el
mente será el primero en salir de la
pila. contenedor de secuencia usado.
Aunque sería perfectamente posible delegar en el programador el manejo del con-
tenedor de secuencia para que se comporte como, por ejemplo, una pila, en la práctica
existen dos razones importantes para la definición de adaptadores:
1. La declaración explícita de un adaptador, como una pila o stack, hace que sea el
Cima código sea mucho más claro y legible en términos de funcionalidad. Por ejem-
plo, declarar una pila proporciona más información que declarar un vector que
se comporte como una pila. Esta aproximación facilita la interoperabilidad con
otros programadores.
2. El compilador tiene más información sobre la estructura de datos y, por lo tanto,
puede contribuir a la detección de errores con más eficacia. Si se utiliza un vec-
tor para modelar una pila, es posible utilizar alguna operación que no pertenezca
Elemento 1 a la interfaz de la pila.
5.5.1. Stack
Elemento 2
El adaptador más sencillo en STL es la pila o stack9 . Las principales operaciones
sobre una pila son la inserción y eliminación de elementos por uno de sus extremos:
Elemento 3 la cima. En la literatura, estas operaciones se conocen como push y pop, respectiva-
mente.
... La pila es más restrictiva en términos funcionales que los distintos contenedores de
secuencia previamente estudiados y, por lo tanto, se puede implementar con cualquiera
de ellos. Obviamente, el rendimiento de las distintas versiones vendrá determinado por
el contenedor elegido. Por defecto, la pila se implementa utilizando una cola de doble
Elemento i fin.
El siguiente listado de código muestra cómo hacer uso de algunas de las operacio-
... nes más relevantes de la pila para invertir el contenido de un vector.
8 vector<int>::iterator it;
9 stack<int> pila;
10
11 for (int i = 0; i < 10; i++) // Rellenar el vector
12 fichas.push_back(i);
13
14 for (it = fichas.begin(); it != fichas.end(); ++it)
15 pila.push(*it); // Apilar elementos para invertir
16
17 fichas.clear(); // Limpiar el vector
18
19 while (!pila.empty()) { // Rellenar el vector
20 fichas.push_back(pila.top());
21 pila.pop();
22 }
23
24 return 0;
25 }
5.5.2. Queue
Al igual que ocurre con la pila, la cola o queue10 es otra estructura de datos de
uso muy común que está representada en STL mediante un adaptador de secuencia.
Básicamente, la cola mantiene una interfaz que permite la inserción de elementos al Inserción
final y la extracción de elementos sólo por el principio. En este contexto, no es posible
acceder, insertar y eliminar elementos que estén en otra posición.
Por defecto, la cola utiliza la implementación de la cola de doble fin, siendo posible
utilizar la implementación de la lista. Sin embargo, no es posible utilizar el vector
debido a que este contenedor no proporciona la operación push_front, requerida por
el propio adaptador. De cualquier modo, el rendimiento de un vector para eliminar
elementos al principio no es particularmente bueno, ya que tiene una complejidad Elemento 1
lineal.
Elemento 2
5.5.3. Cola de prioridad Elemento 3
STL también proporciona el adaptador cola de prioridad o priority queue11 como ...
caso especial de cola previamente discutido. La diferencia entre los dos adaptadores
reside en que el elemento listo para ser eliminado de la cola es aquél que tiene una
mayor prioridad, no el elemento que se añadió en primer lugar.
Elemento i
Así mismo, la cola con prioridad incluye cierta funcionalidad específica, a diferen-
cia del resto de adaptadores estudiados. Básicamente, cuando un elemento se añade a ...
la cola, entonces éste se ordena de acuerdo a una función de prioridad.
Por defecto, la cola con prioridad se apoya en la implementación de vector, pero es Elemento n
posible utilizarla también con deque. Sin embargo, no se puede utilizar una lista debi-
do a que la cola con prioridad requiere un acceso aleatorio para insertar eficientemente
elementos ordenados.
La comparación de elementos sigue el mismo esquema que el estudiado en la sec-
ción 5.4.1 y que permitía definir el criterio de inclusión de elementos en un conjunto,
el cual estaba basado en el uso del operador menor que.
Extracción
10 http://www.cplusplus.com/reference/stl/queue/
11 http://www.cplusplus.com/reference/stl/priority_queue/ Figura 5.12: Visión abstracta de
una cola o queue. Los elementos so-
lamente se añaden por el final y se
eliminan por el principio.
5.5. Adaptadores de secuencia [169]
C5
Gestión de Recursos
Capítulo 6
David Vallejo Fernández
E
n este capítulo se cubren aspectos esenciales a la hora de afrontar el diseño
de un juego. En particular, se discutirán las arquitecturas típicas del bucle de
juego, haciendo especial hincapié en un esquema basado en la gestión de los
estados de un juego. Como caso de estudio concreto, en este capítulo se propone una
posible implementación del bucle principal mediante este esquema haciendo uso de
las bibliotecas que Ogre3D proporciona.
Así mismo, este capítulo discute la gestión de recursos y, en concreto, la gestión
de sonido y de efectos especiales. La gestión de recursos es especialmente importante
en el ámbito del desarrollo de videojuegos, ya que el rendimiento de un juego depende
en gran medida de la eficiencia del subsistema de gestión de recursos.
Finalmente, la gestión del sistema de archivos, junto con aspectos básicos de en-
trada/salida, también se estudia en este capítulo. Además, se plantea el diseño e imple-
mentación de un importador de datos que se puede utilizar para integrar información
multimedia a nivel de código fuente.
171
[172] CAPÍTULO 6. GESTIÓN DE RECURSOS
C6
De cualquier modo, es necesario un planteamiento que permita actualizar el es-
tado de cada uno de los subsistemas y que considere las restricciones temporales de
los mismos. Típicamente, este planteamiento se suele abordar mediante el bucle de
juego, cuya principal responsabilidad consiste en actualizar el estado de los distintos
componentes del motor tanto desde el punto de vista interno (ej. coordinación en-
tre subsistemas) como desde el punto de vista externo (ej. tratamiento de eventos de
teclado o ratón).
Antes de discutir algunas de las arquitecturas más utilizadas para modelar el bucle
de juego, resulta interesante estudiar el anterior listado de código, el cual muestra una
manera muy simple de gestionar el bucle de juego a través de una sencilla estructura de
control iterativa. Evidentemente, la complejidad actual de los videojuegos comerciales
requiere un esquema que sea más general y escalable. Sin embargo, es muy importante
mantener la simplicidad del mismo para garantizar su mantenibilidad.
En las plataformas WindowsTM , los juegos han de atender los mensajes recibidos
por el propio sistema operativo y dar soporte a los distintos componentes del propio
motor de juego. Típicamente, en estas plataformas se implementan los denominados
message pumps [42], como responsables del tratamiento de este tipo de mensajes.
Desde un punto de vista general, el planteamiento de este esquema consiste en
atender los mensajes del propio sistema operativo cuando llegan, interactuando con el
motor de juegos cuando no existan mensajes del sistema operativo por procesar. En
ese caso se ejecuta una iteración del bucle de juego y se repite el mismo proceso.
La principal consecuencia de este enfoque es que los mensajes del sistema ope-
message dispatching
rativo tienen prioridad con respecto a aspectos críticos como el bucle de renderizado.
Por ejemplo, si la propia ventana en la que se está ejecutando el juego se arrastra o su
tamaño cambia, entonces el juego se congelará a la espera de finalizar el tratamiento
de eventos recibidos por el propio sistema operativo.
17 glutInitWindowSize(640, 480);
18 glutCreateWindow("Session #04 - Solar System");
19
20 // Definición de las funciones de retrollamada.
21 glutDisplayFunc(display);
22 glutReshapeFunc(resize);
23 // Eg. update se ejecutará cuando el sistema
24 // capture un evento de teclado.
C6
25 // Signatura de glutKeyboardFunc:
26 // void glutKeyboardFunc(void (*func)
27 // (unsigned char key, int x, int y));
28 glutKeyboardFunc(update);
29
30 glutMainLoop();
31
32 return 0;
33 }
Menú Game
Intro Juego
ppal over
Pausa
Figura 6.5: Visión general de una máquina de estados finita que representa los estados más comunes en
cualquier juego.
Desde un punto de vista general, los juegos se pueden dividir en una serie de
etapas o estados que se caracterizan no sólo por su funcionamiento sino también por
la interacción con el usuario o jugador. Típicamente, en la mayor parte de los juegos
es posible diferenciar los siguientes estados:
C6
1 1 1 1..*
InputManager GameManager GameState
1 1
OIS:: OIS::
IntroState PlayState PauseState
Keyboard Mouse
Ogre::
Singleton
Figura 6.6: Diagrama de clases del esquema de gestión de estados de juego con Ogre3D. En un tono más
oscuro se reflejan las clases específicas de dominio.
www.ogre3d.org/tikiwiki/Managing+Game+States+with+OGRE
[178] CAPÍTULO 6. GESTIÓN DE RECURSOS
3. Gestión básica de eventos antes y después del renderizado (líneas ✂37-38 ✁),
operaciones típicas de la clase Ogre::FrameListener.
C6
44 void pushState (GameState* state) {
45 GameManager::getSingletonPtr()->pushState(state);
46 }
47 void popState () {
48 GameManager::getSingletonPtr()->popState();
49 }
50
51 };
52
53 #endif
Sin embargo, antes de pasar a discutir esta clase, en el diseño discutido se con-
templa la definición explícita de la clase InputManager, como punto central para la
gestión de eventos de entrada, como por ejemplo los de teclado o de ratón.
¿Ogre::Singleton? El InputManager sirve como interfaz para aquellas entidades que estén interesa-
das en procesar eventos de entrada (como se discutirá más adelante), ya que mantiene
La clase InputManager implementa operaciones para añadir y eliminar listeners de dos tipos: i) OIS::KeyListener y ii)
el patrón Singleton mediante las uti-
lidades de Ogre3D. Es posible utili- OIS::MouseListener. De hecho, esta clase hereda de ambas clases. Además, imple-
zar otros esquemas para que su im- menta el patrón Singleton con el objetivo de que sólo exista una única instancia de la
plementación no depende de Ogre misma.
y se pueda utilizar con otros frame-
works.
Listado 6.5: Clase InputManager.
1 // SE OMITE PARTE DEL CÓDIGO FUENTE.
2 // Gestor para los eventos de entrada (teclado y ratón).
3 class InputManager : public Ogre::Singleton<InputManager>,
4 public OIS::KeyListener, public OIS::MouseListener {
5 public:
6 InputManager ();
7 virtual ~InputManager ();
8
9 void initialise (Ogre::RenderWindow *renderWindow);
10 void capture ();
11
12 // Gestión de listeners.
13 void addKeyListener (OIS::KeyListener *keyListener,
14 const std::string& instanceName);
15 void addMouseListener (OIS::MouseListener *mouseListener,
16 const std::string& instanceName );
17 void removeKeyListener (const std::string& instanceName);
18 void removeMouseListener (const std::string& instanceName);
19 void removeKeyListener (OIS::KeyListener *keyListener);
20 void removeMouseListener (OIS::MouseListener *mouseListener);
21
22 OIS::Keyboard* getKeyboard ();
23 OIS::Mouse* getMouse ();
24
25 // Heredados de Ogre::Singleton.
26 static InputManager& getSingleton ();
27 static InputManager* getSingletonPtr ();
28
29 private:
30 // Tratamiento de eventos.
31 // Delegará en los listeners.
32 bool keyPressed (const OIS::KeyEvent &e);
33 bool keyReleased (const OIS::KeyEvent &e);
34
35 bool mouseMoved (const OIS::MouseEvent &e);
[180] CAPÍTULO 6. GESTIÓN DE RECURSOS
C6
39
40 bool mouseMoved (const OIS::MouseEvent &e);
41 bool mousePressed (const OIS::MouseEvent &e, OIS::MouseButtonID
id);
42 bool mouseReleased (const OIS::MouseEvent &e, OIS::MouseButtonID
id);
43
44 // Gestor de eventos de entrada.
45 InputManager *_inputMgr;
46 // Estados del juego.
47 std::stack<GameState*> _states;
48 };
✄
Note que esta clase contiene una función miembro start() (línea ✂12 ✁), definida de
manera explícita para inicializar el gestor de juego, establecer el estado inicial (pasado
como parámetro) y arrancar el bucle de renderizado.
PlayState
Listado 6.8: Clase GameManager. Función changeState().
1 void
2 GameManager::changeState
3 (GameState* state) IntroState
4 {
5 // Limpieza del estado actual.
6 if (!_states.empty()) {
7 // exit() sobre el último estado.
8 _states.top()->exit();
tecla 'p'
9 // Elimina el último estado.
10 _states.pop();
11 }
12 // Transición al nuevo estado.
13 _states.push(state);
14 // enter() sobre el nuevo estado.
15 _states.top()->enter();
16 }
PauseState
Otro aspecto relevante del diseño de esta clase es la delegación de eventos de en-
trada asociados a la interacción por parte del usuario con el teclado y el ratón. El
diseño discutido permite delegar directamente el tratamiento del evento al estado ac-
tivo, es decir, al estado que ocupe la cima de la pila. Del mismo modo, se traslada
a dicho estado la implementación de las funciones frameStarted() y frameEnded().
El siguiente listado de código muestra cómo la implementación de, por ejemplo, la
función keyPressed() es trivial.
PlayState
Listado 6.9: Clase GameManager. Función keyPressed().
1 bool
2 GameManager::keyPressed
3 (const OIS::KeyEvent &e)
IntroState
4 {
5 _states.top()->keyPressed(e);
6 return true;
7 }
Figura 6.7: Actualización de la pi-
la de estados para reanudar el juego
(evento teclado ’p’).
6.1.5. Definición de estados concretos
Este esquema de gestión de estados general, el cual contiene una clase genérica
GameState, permite la definición de estados específicos vinculados a un juego en par-
ticular. En la figura 6.6 se muestra gráficamente cómo la clase GameState se extiende
para definir tres estados:
C6
A la hora de llevar a cabo dicha implementación, se ha optado por utilizar el pa-
trón Singleton, mediante la clase Ogre::Singleton, para garantizar que sólo existe una
instacia de un objeto por estado.
El siguiente listado de código muestra una posible declaración de la clase IntroS-
tate. Como se puede apreciar, esta clase hace uso de las funciones típicas para el
tratamiento de eventos de teclado y ratón.
4 }
5
6 void PauseState::keyPressed (const OIS::KeyEvent &e) {
7 if (e.key == OIS::KC_P) // Tecla p ->Estado anterior.
8 popState();
9 }
C6
1 0..*
NuevoGestor NuevoRecursoPtr NuevoRecurso
Figura 6.11: Diagrama de clases de las principales clases utilizadas en Ogre3D para la gestión de recursos.
En un tono más oscuro se reflejan las clases específicas de dominio.
Carga de niveles Debido a las restricciones hardware que una determinada plataforma de juegos
puede imponer, resulta fundamental hacer uso de un mecanismo de gestión de recur-
Una de las técnicas bastantes comu-
nes a la hora de cargar niveles de sos que sea flexible y escalable para garantizar el diseño y gestión de cualquier tipo
juego consiste en realizarla de ma- de recurso, junto con la posibilidad de cargarlo y liberarlo en tiempo de ejecución,
nera previa al acceso a dicho nivel. respectivamente. Por ejemplo, si piensa en un juego de plataformas estructurado en
Por ejemplo, se pueden utilizar es- diversos niveles, entonces no sería lógico hacer una carga inicial de todos los nive-
tructuras de datos para representar
los puntos de acceso de un nivel al les al iniciar el juego. Por el contrario, sería más adecuado cargar un nivel cuando el
siguiente para adelantar el proceso jugador vaya a acceder al mismo.
de carga. El mismo planteamiento
se puede utilizar para la carga de En Ogre, la gestión de recursos se lleva a cabo utilizando principalmente cuatro
texturas en escenarios. clases (ver figura 6.11):
Básicamente, el desarrollador que haga uso del planteamiento que Ogre ofrece
para la gestión de recursos tendrá que implementar algunas funciones heredadas de
las clases anteriormente mencionadas, completando así la implementación específica
del dominio, es decir, específica de los recursos a gestionar. Más adelante se discute
en detalle un ejemplo relativo a los recursos de sonido.
[186] CAPÍTULO 6. GESTIÓN DE RECURSOS
C6
7 // Inicialización de SDL.
8 if (SDL_Init(SDL_INIT_VIDEO) != 0) {
9 fprintf(stderr, "Unable to initialize SDL: %s\n",
10 SDL_GetError());
11 return -1;
12 }
13
14 // Cuando termine el programa, llamada a SQLQuit().
15 atexit(SDL_Quit);
16 // Activación del double buffering.
17 SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
18
19 // Establecimiento del modo de vídeo con soporte para OpenGL.
20 screen = SDL_SetVideoMode(640, 480, 16, SDL_OPENGL);
21 if (screen == NULL) {
22 fprintf(stderr, "Unable to set video mode: %s\n",
23 SDL_GetError());
24 return -1;
25 }
26
27 SDL_WM_SetCaption("OpenGL with SDL!", "OpenGL");
28 // ¡Ya es posible utilizar comandos OpenGL!
29 glViewport(80, 0, 480, 480);
30
31 glMatrixMode(GL_PROJECTION);
32 glLoadIdentity();
33 glFrustum(-1.0, 1.0, -1.0, 1.0, 1.0, 100.0);
34 glClearColor(1, 1, 1, 0);
35
36 glMatrixMode(GL_MODELVIEW); glLoadIdentity();
37 glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
38
39 // Renderizado de un triángulo.
40 glBegin(GL_TRIANGLES);
41 glColor3f(1.0, 0.0, 0.0); glVertex3f(0.0, 1.0, -2.0);
42 glColor3f(0.0, 1.0, 0.0); glVertex3f(1.0, -1.0, -2.0);
43 glColor3f(0.0, 0.0, 1.0); glVertex3f(-1.0, -1.0, -2.0);
44 glEnd();
45
46 glFlush();
47 SDL_GL_SwapBuffers(); // Intercambio de buffers.
48 SDL_Delay(5000); // Espera de 5 seg.
49
50 return 0;
51 }
✄
Como se puede apreciar en el listado anterior, la línea ✂20 ✁establece el modo de ví-
deo con soporte para
✄ OpenGL para, posteriormente, hacer uso de comandos OpenGL
a partir de la línea ✂29 ✁. Sin embargo, note cómo el
✄ intercambio del contenido entre el
front buffer y el back buffer se realiza en la línea ✂47 ✁mediante una primitiva de SDL.
8 basic_sdl_opengl: basic_sdl_opengl.o
9 $(CC) $(LDFLAGS) -o $@ $^ $(LDLIBS)
10
11 basic_sdl_opengl.o: basic_sdl_opengl.c
12 $(CC) $(CFLAGS) $^ -o $@
13
14 clean:
15 @echo Cleaning up...
16 rm -f *~ *.o basic_sdl_opengl
17 @echo Done.
18
19 vclean: clean
Reproducción de música
C6
constructor de la clase padre para poder instanciar un recurso, además de inicializar
✄
las propias variables miembro.
Por otra parte, las funciones de manejo básico del track (líneas ✂16-22 ✁) son en
realidad una interfaz para manejar de una manera adecuada la biblioteca SDL_mixer.
Por ejemplo, la función miembro play simplemente interactúa con SDL para com-
probar si la canción estaba pausada y, en ese caso, reanudarla. Si la canción no estaba
pausada, entonces dicha función la reproduce desde el principio. Note cómo se hace
uso del gestor de logs de Ogre3D para almacenar en un archivo la posible ocurrencia
de algún tipo de error.
Figura 6.15: Es posible incluir Listado 6.15: Clase Track. Función play().
efectos de sonidos más avanzados, 1 void
como por ejemplo reducir el nivel 2 Track::play
de volumen a la hora de finalizar la 3 (int loop)
reproducción de una canción. 4 {
5 Ogre::LogManager* pLogManager =
6 Ogre::LogManager::getSingletonPtr();
7
8 if(Mix_PausedMusic()) // Estaba pausada?
9 Mix_ResumeMusic(); // Reanudación.
10
11 // Si no, se reproduce desde el principio.
12 else {
13 if (Mix_PlayMusic(_pTrack, loop) == -1) {
14 pLogManager->logMessage("Track::play() Error al....");
15 throw (Ogre::Exception(Ogre::Exception::ERR_FILE_NOT_FOUND,
16 "Imposible reproducir...",
17 "Track::play()"));
18 }
19 }
20 }
Dos de las funciones más importantes de cualquier tipo de recurso que extienda de
Ogre::Resource son loadImpl() y unloadImpl(), utilizadas para cargar y liberar el re-
curso, respectivamente. Obviamente, cada tipo de recurso delegará en la funcionalidad
necesaria para llevar a cabo dichas tareas. En el caso de la gestión básica del sonido,
estas funciones delegarán principalmente en SDL_mixer. A continuación se muestra
el código fuente de dichas funciones.
Debido a la necesidad de manejar de manera eficiente los recursos de sonido, la
solución discutida en esta sección contempla la definición de punteros inteligentes a
través de la clase TrackPtr. La justificación de esta propuesta, como ya se introdujo
anteriormente, consiste en evitar la duplicidad de recursos, es decir, en evitar que un
mismo recurso esté cargado en memoria principal más de una vez.
7 findResourceFileInfo(mGroup, mName);
8
9 for (Ogre::FileInfoList::const_iterator i = info->begin();
10 i != info->end(); ++i) {
11 _path = i->archive->getName() + "/" + i->filename;
12 }
13
14 if (_path == "") { // Archivo no encontrado...
15 // Volcar en el log y lanzar excepción.
16 }
17
18 // Cargar el recurso de sonido.
19 if ((_pTrack = Mix_LoadMUS(_path.c_str())) == NULL) {
20 // Si se produce un error al cargar el recurso,
21 // volcar en el log y lanzar excepción.
22 }
23
24 // Cálculo del tamaño del recurso de sonido.
25 _size = ...
26 }
27
28 void
29 Track::unloadImpl()
30 {
31 if (_pTrack) {
32 // Liberar el recurso de sonido.
33 Mix_FreeMusic(_pTrack);
34 }
35 }
Ogre3D permite el uso de punteros inteligentes compartidos, definidos en la clase resource 2 references
Ogre::SharedPtr, con el objetivo de parametrizar el recurso definido por el desarro-
llador, por ejemplo Track, y almacenar, internamente, un contador de referencias a
dicho recurso. Básicamente, cuando el recurso se copia, este contador se incrementa.
Si se destruye alguna referencia al recurso, entonces el contador se decrementa. Este
esquema permite liberar recursos cuando no se estén utilizando en un determinado ptr refs ptr refs
momento y compartir un mismo recurso entre varias entidades.
El listado de código 6.17 muestra la implementación de la clase TrackPtr, la cual Figura 6.16: El uso de smart poin-
incluye una serie de funciones (básicamente constructores y asignador de copia) here- ters optimiza la gestión de recursos
dadas de Ogre::SharedPtr. A modo de ejemplo, también se incluye el código asociado y facilita su liberación.
al constructor de copia. Como se puede apreciar, en él se incrementa el contador de
referencias al recurso.
Una vez implementada la lógica necesaria para instanciar y manejar recursos de
sonido, el siguiente paso consiste en definir un gestor o manager específico para cen-
tralizar la administración del nuevo tipo de recurso. Ogre3D facilita enormemente esta
tarea gracias a la clase Ogre::ResourceManager. En el caso particular de los recursos
de sonido se define la clase TrackManager, cuyo esqueleto se muestra en el listado
de código 6.18.
Esta clase no sólo hereda del gestor de recursos de Ogre, sino que también lo hace
de la clase Ogre::Singleton con el objetivo de manejar una única instancia del gestor
de recursos de sonido. Las funciones más relevantes son las siguientes:
✄
load() (líneas ✂10-11 ✁), que permite la carga de canciones por parte del desarro-
llador. Si el recurso a cargar no existe, entonces lo creará internamente utilizan-
do la función que se comenta a continuación.
✄
createImpl() (líneas ✂18-23 ✁), función que posibilita la creación de un nuevo
recurso, es decir, una nueva instancia de la clase Track. El desarrollador es res-
ponsable de realizar la carga del recurso una vez que ha sido creado.
6.2. Gestión básica de recursos [191]
C6
5 // y operador de asignación.
6 TrackPtr(): Ogre::SharedPtr<Track>() {}
7 explicit TrackPtr(Track* m): Ogre::SharedPtr<Track>(m) {}
8 TrackPtr(const TrackPtr &m): Ogre::SharedPtr<Track>(m) {}
9 TrackPtr(const Ogre::ResourcePtr &r);
10 TrackPtr& operator= (const Ogre::ResourcePtr& r);
11 };
12
13 TrackPtr::TrackPtr
14 (const Ogre::ResourcePtr &resource):
15 Ogre::SharedPtr<Track>()
16 {
17 // Comprobar la validez del recurso.
18 if (resource.isNull())
19 return;
20
21 // Para garantizar la exclusión mutua...
22 OGRE_LOCK_MUTEX(*resource.OGRE_AUTO_MUTEX_NAME)
23 OGRE_COPY_AUTO_SHARED_MUTEX(resource.OGRE_AUTO_MUTEX_NAME)
24
25 pRep = static_cast<Track*>(resource.getPointer());
26 pUseCount = resource.useCountPointer();
27 useFreeMethod = resource.freeMethod();
28
29 // Incremento del contador de referencias.
30 if (pUseCount)
31 ++(*pUseCount);
32 }
Con las tres clases que se han discutido en esta sección ya es posible realizar la
carga de recursos de sonido, delegando en la biblioteca SDL_mixer, junto con su ges-
tión y administración básicas. Este esquema encapsula la complejidad del tratamiento
del sonido, por lo que en cualquier momento se podría sustituir dicha biblioteca por
otra.
Más adelante se muestra un ejemplo concreto en el que se hace uso de este tipo
de recursos sonoros. Sin embargo, antes de discutir este ejemplo de integración se
planteará el soporte de efectos de sonido, los cuales se podrán mezclar con el tema o
track principal a la hora de desarrollar un juego. Como se planteará a continuación, la
filosofía de diseño es exactamente igual que la planteada en esta sección.
Además de llevar a cabo la reproducción del algún tema musical durante la eje-
cución de un juego, la incorporación de efectos de sonido es esencial para alcanzar
un buen grado de inmersión y que, de esta forma, el jugador se sienta como parte del
Figura 6.17: La integración de propio juego. Desde un punto de vista técnico, este esquema implica que la biblioteca
efectos de sonido es esencial para de desarrollo permita la mezcla de sonidos. En el caso de SDL_mixer es posible llevar
dotar de realismo la parte sonora del a cabo dicha integración.
videojuego y complementar la parte
gráfica.
[192] CAPÍTULO 6. GESTIÓN DE RECURSOS
C6
La diferencia más sustancial con respecto al gestor de sonido reside en que el tipo de
recurso mantiene un identificador textual distinto y que, en el caso de los efectos de
sonido, se lleva a cabo una reserva explícita de 32 canales de audio. Para ello, se hace
uso de una función específica de la biblioteca SDL_mixer.
Para llevar a cabo la integración de los aspectos básicos previamente discutidos so-
bre la gestión de música y efectos de sonido se ha tomado como base el código fuente
de la sesión de iluminación del módulo 2, Programación Gráfica. En este ejemplo se
hacía uso de una clase MyFrameListener para llevar a cabo la gestión de los eventos
de teclado.
Por una parte, este ejemplo se ha extendido para incluir la reproducción ininte-
rrumpida de un tema musical, es decir, un tema que se estará reproduciendo desde que
se inicia la aplicación hasta que ésta finaliza su ejecución. Por otra parte, la reproduc-
ción de efectos de sonido adicionales está vinculada a la generación de ciertos eventos
de teclado. En concreto, cada vez que el usuario pulsa las teclas ’1’ ó ’2’, las cuales
están asociadas a dos esquemas diferentes de cálculo del sombreado, la aplicación re-
producirá un efecto de sonido puntual. Este efecto de sonido se mezclará de manera
adecuada con el track principal.
El siguiente listado de código muestra la nueva función miembro introducida en la
clase MyApp para realizar la carga de recursos asociada a la biblioteca SDL_mixer.
Ogre::
TrackManager
FrameListener
1
1
1 1 1 1
SoundFXManager MyApp MyFrameListener
Figura 6.18: Diagrama simplificado de clases de las principales entidades utilizadas para llevar a cabo la
integración de música y efectos de sonido.
5 if (SDL_Init(SDL_INIT_AUDIO) < 0)
6 return false;
7 // Llamar a SDL_Quit al terminar.
8 atexit(SDL_Quit);
9
10 // Inicializando SDL mixer...
11 if (Mix_OpenAudio(MIX_DEFAULT_FREQUENCY, MIX_DEFAULT_FORMAT,
12 MIX_DEFAULT_CHANNELS, 4096) < 0)
13 return false;
14
15 // Llamar a Mix_CloseAudio al terminar.
16 atexit(Mix_CloseAudio);
17
18 return true;
19
20 }
Finalmente, sólo hay que reproducir los eventos de sonido cuando así sea necesa-
rio. En este ejemplo, dichos eventos se reproducirán cuando se indique, por parte del
usuario, el esquema de cálculo de sombreado mediante las teclas ’1’ ó ’2’. Dicha ac-
tivación se realiza, por simplificación, en la propia clase FrameListener; en concreto,
en la función miembro frameStarted a la hora de capturar los eventos de teclado.
El esquema planteado para la gestión de sonido mantiene la filosofía de delegar el
C6
tratamiento de eventos de teclado, al menos los relativos a la parte sonora, en la clase
principal (MyApp). Idealmente, si la gestión del bucle de juego se plantea en base a
un esquema basado en estados, la reproducción de sonido estaría condicionada por el
estado actual. Este planteamiento también es escalable a la hora de integrar nuevos
estados de juegos y sus eventos de sonido asociados. La activación de dichos eventos
dependerá no sólo del estado actual, sino también de la propia interacción por parte
del usuario.
Figura 6.20: Generalmente, los sis- En el desarrollo multiplataforma, esta API específica proporciona el nivel de
temas de archivos mantienen es- abstracción necesario para no depender del sistema de archivos y de la platafor-
tructuras de árbol o de grafo. ma subyacente.
[196] CAPÍTULO 6. GESTIÓN DE RECURSOS
/home/david/apps/firefox/firefox-bin
En el caso de las consolas, las rutas suelen seguir un convenio muy similar inclu- Encapsulación
so para referirse a distintos volúmenes. Por ejemplo, PlayStation3TM utiliza el prefijo
/dev_bdvd para referirse al lector de blu-ray, mientras que el prefijo /dev_hddx per- Una vez más, el principio de encap-
sulación resulta fundamental para
mite el acceso a un determinado disco duro. abstraerse de la complejidad asocia-
Las rutas o paths pueden ser absolutas o relativas, en función de si están definidas da al tratamiento del sistema de ar-
chivos y del sistema operativo sub-
teniendo como referencia el directorio raíz del sistema de archivos u otro directorio, yacente.
respectivamente. En sistemas UNIX, dos ejemplos típicos serían los siguientes:
/usr/share/doc/ogre-doc/api/html/index.html
apps/firefox/firefox-bin
El primero de ellos sería una ruta absoluta, ya que se define en base al directorio
raíz. Por otra parte, la segunda sería una ruta relativa, ya que se define en relación al
directorio /home/david.
6.3. El sistema de archivos [197]
C6
bin boot dev etc home lib media usr var
5 www.boost.org/libs/filesystem/
[198] CAPÍTULO 6. GESTIÓN DE RECURSOS
El lenguaje de programación C permite manejar dos APIs para gestionar las opera-
ciones más relevantes en relación al contenido de un archivo, como la apertura, lectura,
escritura o el cierre. La primera de ellas proporciona E/S con buffers mientras que la
segunda no.
La diferencia entre ambas reside en que la API con E/S mediante buffer gestiona de
manera automática el uso de los propios buffers sin necesidad de que el programador
C6
asuma dicha responsabilidad. Por el contrario, la API de C que no proporciona E/S con
buffers tiene como consecuencia directa que el programador ha de asignar memoria
para dichos buffers y gestionarlos. La tabla 6.1 muestra las principales operaciones de
dichas APIs.
Finalmente, es muy importante tener en cuenta que las bibliotecas de E/S estándar
de C son síncronas. En otras palabras, ante una invocación de E/S, el programa se
queda bloqueado hasta que se atiende por completo la petición. El listado de código
anterior muestra un ejemplo de llamada bloqueante mediante la operación fread().
Evidentemente, el esquema síncrono presenta un inconveniente muy importante,
ya que bloquea al programa mientras la operación de E/S se efectúa, degradando así
el rendimiento global de la aplicación. Un posible solución a este problema consiste
en adoptar un enfoque asíncrono, tal y como se discute en la siguiente sección.
C6
Desplazar int fseek(FILE *stream, long offset, int whence);
Obtener offset long ftell(FILE *stream);
Lectura línea char *fgets(char *s, int size, FILE *stream);
Escritura línea int fputs(const char *s, FILE *stream);
Lectura cadena int fscanf(FILE *stream, const char *format, ...);
Escritura cadena int fprintf(FILE *stream, const char *format, ...);
Obtener estado int fstat(int fd, struct stat *buf);
Cuadro 6.1: Resumen de las principales operaciones de las APIs de C para la gestión de E/S (con y sin buffers).
La figura 6.24 muestra de manera gráfica cómo dos agentes se comunican de ma-
nera asíncrona mediante un objeto de retrollamada. Básicamente, el agente notificador
envía un mensaje al agente receptor de manera asíncrona. Esta invocación tiene dos
parámetros: i) el propio mensaje m y ii) un objeto de retrollamada cb. Dicho objeto
será usado por el agente receptor cuando éste termine de procesar el mensaje. Mien-
tras tanto, el agente notificador continuará su flujo normal de ejecución, sin necesidad
de bloquearse por el envío del mensaje.
Callbacks La mayoría de las bibliotecas de E/S asíncrona existentes permiten que el progra-
Las funciones de retrollamada se
ma principal espera una cierta cantidad de tiempo antes de que una operación de E/S
suelen denominar comúnmente se complete. Este planteamiento puede ser útil en situaciones en las que los datos de
callbacks. Este concepto no sólo dicha operación se necesitan para continuar con el flujo normal de trabajo. Otras APIs
se usa en el ámbito de la E/S asín- pueden proporcionar incluso una estimación del tiempo que tardará en completarse
crona, sino también en el campo
de los sistemas distribuidos y los
una operación de E/S, con el objetivo de que el programador cuente con la mayor
middlewares de comunicaciones. cantidad posible de información. Así mismo, también es posible encontrarse con ope-
raciones que asignen deadlines sobre las propias peticiones, con el objetivo de plantear
un esquema basado en prioridades. Si el deadline se cumple, entonces también suele
ser posible asignar el código a ejecutar para contemplar ese caso en particular.
[202] CAPÍTULO 6. GESTIÓN DE RECURSOS
Agente 1
� : Agente n
Notificador Receptor
1
<<crear ()>> : Objeto
callback
recibir_mensaje (cb, m)
Procesar
mensaje
responder ()
responder ()
Figura 6.24: Esquema gráfico representativo de una comunicación asíncrona basada en objetos de retrolla-
mada.
C6
El desarrollo de esta biblioteca se justifica por la necesidad de interacción en-
tre programas, ya sea mediante ficheros, redes o mediante la propia consola, que no
pueden quedarse a la espera ante operaciones de E/S cuyo tiempo de ejecución sea
elevado. En el caso del desarrollo de videojuegos, una posible aplicación de este en-
foque, tal y como se ha introducido anteriormente, sería la carga en segundo plano
de recursos que serán utilizados en el futuro inmediato. La situación típica en este
contexto sería la carga de los datos del siguiente nivel de un determinado juego.
Según el propio desarrollador de la biblioteca7 , Asio proporciona las herramientas
necesarias para manejar este tipo de problemática sin la necesidad de utilizar, por parte
del programador, modelos de concurrencia basados en el uso de hilos y mecanismos
de exclusión mutua. Aunque la biblioteca fue inicialmente concebida para la proble-
mática asociada a las redes de comunicaciones, ésta es perfectamente aplicable para
wait llevar a cabo una E/S asíncrona asociada a descriptores de ficheros o incluso a puertos
serie.
Los principales objetivos de diseño de Boost.Asio son los siguientes:
En determinados contextos resulta muy deseable llevar a cabo una espera asíncro- In the meantime...
na, es decir, continuar ejecutando instrucciones mientras en otro nivel de ejecución se
Recuerde que mientras se lleva a
realizan otras tareas. Si se utiliza la biblioteca Boost.Asio, es posible definir mane- cabo una espera asíncrona es posi-
jadores de código asociados a funciones de retrollamada que se ejecuten mientras el ble continuar ejecutando operacio-
programa continúa la ejecución de su flujo principal. Asio también proporciona meca- nes en el hilo principal. Aproveche
nismos para que el programa no termine mientras haya tareas por finalizar. este esquema para obtener un mejor
rendimiento en sistemas con más de
El siguiente listado de código muestra cómo el ejemplo anterior se puede modificar un procesador.
para llevar a cabo una espera asíncrona, asociando en este caso un temporizador que
controla la ejecución de una función de retrollamada.
25
26 return 0;
27 }
C6
$ Esperando a print()...
$ Hola Mundo!
Sin embargo, pasarán casi 3 segundos entre la impresión de una secuencia✄y otra,
ya que inmediatamente después de la llamada a la
✄ espera asíncrona (línea ✂15 ✁) se
ejecutará la primera secuencia de impresión✄ (línea
✂17 ✁), mientras que la ejecución de
la función de retrollamada print() (líneas ✂6-8 ✁) se demorará 3 segundos.
Otra opción imprescindible a la hora de gestionar estos manejadores reside en la
posibilidad de realizar un paso de parámetros, en función del dominio de la aplica-
ción que se esté desarrollando. El siguiente listado de código muestra cómo llevar a
cabo dicha tarea.
Como se puede apreciar, las llamadas a la función async_wait() varían con res-
pecto a otros ejemplos, ya que se indica de manera explícita los argumentos relevantes
para la función de retrollamada. En este caso, dichos argumentos son la propia función
de retrollamada, para realizar llamadas recursivas, el timer para poder prolongar en un
segundo su duración en cada llamada, y una variable entera que se irá incrementando
en cada llamada.
Flexibilidad en Asio La biblioteca Boost.Asio también permite encapsular las funciones de retrollamada
que se ejecutan de manera asíncrona como funciones miembro de una clase. Este
La biblioteca Boost.Asio es muy esquema mejora el diseño de la aplicación y permite que el desarrollador se abstraiga
flexible y posibilita la llamada asín-
crona a una función que acepta un de la implementación interna de la clase. El siguiente listado de código muestra una
número arbitrario de parámetros. posible modificación del ejemplo anterior mediante la definición de una clase Counter,
Éstos pueden ser variables, punte- de manera que la función count pasa a ser una función miembro de dicha clase.
ros, o funciones, entre otros.
[206] CAPÍTULO 6. GESTIÓN DE RECURSOS
20 _timer1.async_wait(_strand.wrap
21 (boost::bind(&Counter::count1, this)));
22 }
23 }
24
25 // IDEM que count1 pero sobre timer2 y count2
26 void count2() { /* src */ }
27
C6
28 private:
29 boost::asio::strand _strand;
30 boost::asio::deadline_timer _timer1, _timer2;
31 int _count;
32 };
33
34 int main() {
35 boost::asio::io_service io;
36 // run() se llamará desde dos threads (principal y boost)
37 Counter c(io);
38 boost::thread t(boost::bind(&boost::asio::io_service::run, &io));
39 io.run();
40 t.join();
41
42 return 0;
43 }
XML mantiene un alto nivel semántico, está bien soportado y estandarizado y permite
asociar estructuras de árbol bien definidas para encapsular la información relevante.
Además, posibilita que el contenido a importar sea legible por los propios programa-
dores.
Es importante resaltar que este capítulo está centrado en la parte de importación
de datos hacia el motor de juegos, por lo que el proceso de exportación no se discutirá
a continuación (si se hará, no obstante, en el módulo 2 de Programación Gráfica).
Ogre Exporter
1. Exportación, con el objetivo de obtener una representación de los datos 3D.
2. Importación, con el objetivo de acceder a dichos datos desde el motor de juegos
o desde el propio juego.
Importer
Archivos binarios
C6
En el caso de utilizar un formato no estandarizado, es necesario explicitar los
separadores o tags existentes entre los distintos campos del archivo en texto plano.
Por ejemplo, se podría pensar en separadores como los dos puntos, el punto y coma y
el retorno de carro para delimitar los campos de una determinada entidad y una entidad
de la siguiente, respectivamente.
XML
Figura 6.29: Visión conceptual de Sin embargo, no todo son ventajas. El proceso de parseado o parsing es rela-
la relación de XML con otros len-
guajes.
tivamente lento. Esto implica que algunos motores hagan uso de formatos binarios
propietarios, los cuales son más rápidos de parsear y mucho más compactos que los
archivos XML, reduciendo así los tiempos de importación y de carga.
El parser Xerces-C++
Como se ha comentado anteriormente, una de los motivos por los que XML está
tan extendido es su amplio soporte a nivel de programación. En otras palabras, prác-
ticamente la mayoría de lenguajes de programación proporcionan bibliotecas, APIs y
herramientas para procesar y generar contenidos en formato XML.
En el caso del lenguaje C++, estándar de facto en la industria del videojuego, el
parser Xerces-C++11 es una de las alternativas más completas para llevar a cabo el
procesamiento de ficheros XML. En concreto, esta herramienta forma parte del pro-
Figura 6.30: Logo principal del yecto Apache XML12 , el cual gestiona un número relevante de subproyectos vincula-
proyecto Apache. dos al estándar XML.
11 http://xerces.apache.org/xerces-c/
12 http://xml.apache.org/
[210] CAPÍTULO 6. GESTIÓN DE RECURSOS
C6
S
Figura 6.32: Representación interna bidimensional en forma de grafo del escenario de NoEscapeDemo.
Los nodos de tipo S (spawn) representan puntos de nacimiento, mientras que los nodos de tipo D (drain)
representan sumideros, es decir, puntos en los que los fantasmas desaparecen.
El nodo raíz En principio, la información del escenario y de las cámaras virtuales será impor-
La etiqueta <data> es el nodo raíz tada al juego, haciendo uso del importador cuyo diseño se discute a continuación. Sin
del documento XML. Sus posibles embargo, antes se muestra un posible ejemplo de la representación de estos mediante
nodos hijo están asociados con las el formato definido para la demo en cuestión.
etiquetas <graph> y <camera>.
En concreto, el siguiente listado muestra la parte de información asociada a la
estructura de grafo que conforma el escenario. Como se puede apreciar, la estructura
graph está compuesta por una serie de vértices (vertex) y de arcos (edge).
Por una parte, en los vértices del grafo se incluye su posición en el espacio 3D
mediante las etiquetas <x>, <y> y <z>. Además, cada vértice tiene como atributo
un índice, que lo identifica unívocamente, y un tipo, el cual puede ser spawn (punto
de generación) o drain (punto de desaparición), respectivamente. Por otra parte, los
arcos permiten definir la estructura concreta del grafo a importar mediante la etiqueta
<vertex>.
Vértices y arcos En caso de que sea necesario incluir más contenido, simplemente habrá que ex-
tender el formato definido. XML facilita enormemente la escalabilidad gracias a su
En el formato definido, los vértices
se identifican a través del atributo esquema basado en el uso de etiquetas y a su estructura jerárquica.
index. Estos IDs se utilizan en la eti-
queta <edge> para especificar los
dos vértices que conforman un ar-
co.
[212] CAPÍTULO 6. GESTIÓN DE RECURSOS
Listado 6.30: Ejemplo de fichero XML usado para exportar e importar contenido asociado al
grafo del escenario.
1 <?xml version=’1.0’ encoding=’UTF-8’?>
2 <data>
3
4 <graph>
5
6 <vertex index="1" type="spawn">
7 <x>1.5</x> <y>2.5</y> <z>-3</z>
8 </vertex>
9 <!-- More vertexes... -->
10 <vertex index="4" type="drain">
11 <x>1.5</x> <y>2.5</y> <z>-3</z>
12 </vertex>
13
14 <edge>
15 <vertex>1</vertex> <vertex>2</vertex>
16 </edge>
17 <edge>
18 <vertex>2</vertex> <vertex>4</vertex>
19 </edge>
20 <!-- More edges... -->
21
22 </graph>
23
24 <!-- Definition of virtual cameras -->
25 </data>
Lógica de dominio
1 #include <vector>
2 #include <Camera.h>
6.4. Importador de datos de intercambio [213]
Listado 6.31: Ejemplo de fichero XML usado para exportar e importar contenido asociado a
las cámaras virtuales.
1 <?xml version=’1.0’ encoding=’UTF-8’?>
2 <data>
3 <!-- Graph definition here-->
C6
4
5 <camera index="1" fps="25">
6
7 <path>
8
9 <frame index="1">
10 <position>
11 <x>1.5</x> <y>2.5</y> <z>-3</z>
12 </position>
13 <rotation>
14 <x>0.17</x> <y>0.33</y> <z>0.33</z> <w>0.92</w>
15 </rotation>
16 </frame>
17
18 <frame index="2">
19 <position>
20 <x>2.5</x> <y>2.5</y> <z>-3</z>
21 </position>
22 <rotation>
23 <x>0.17</x> <y>0.33</y> <z>0.33</z> <w>0.92</w>
24 </rotation>
25 </frame>
26
27 <!-- More frames here... -->
28
29 </path>
30
31 </camera>
32
33 <!-- More cameras here... -->
34 </data>
3 #include <Node.h>
4 #include <Graph.h>
5
6 class Scene
7 {
8 public:
9 Scene ();
10 ~Scene ();
11
12 void addCamera (Camera* camera);
13 Graph* getGraph () { return _graph;}
14 std::vector<Camera*> getCameras () { return _cameras; }
15
16 private:
17 Graph *_graph;
18 std::vector<Camera*> _cameras;
19 };
Por otra parte, la clase Graph mantiene la lógica de gestión básica para imple-
mentar una estructura de tipo grafo mediante listas de adyacencia. El siguiente listado
de código muestra dicha clase e integra una función miembro para obtener ✄la lista
de
vértices o nodos adyacentes a partir del identificador de uno de ellos (línea ✂17 ✁).
[214] CAPÍTULO 6. GESTIÓN DE RECURSOS
1 1
Scene Importer Ogre::Singleton
1
1
1..* 1
1 1..*
Camera Graph GraphVertex
1 2 1
1
Figura 6.33: Diagrama de clases de las entidades más relevantes del importador de datos.
26 std::vector<GraphEdge*> _edges;
27 };
C6
2 class Node
3 {
4 public:
5 Node ();
6 Node (const int& index, const string& type,
7 const Ogre::Vector3& pos);
8 ~Node ();
9
10 int getIndex () const { return _index; }
11 string getType () const { return _type; }
12 Ogre::Vector3 getPosition () const { return _position; }
13
14 private:
15 int _index; // Índice del nodo (id único)
16 string _type; // Tipo: generador (spawn), sumidero
(drain)
17 Ogre::Vector3 _position; // Posición del nodo en el espacio 3D
18 };
GraphVertex y Node Respecto al diseño de las cámaras virtuales, las clases Camera y Frame son las
utilizadas para encapsular la información y funcionalidad de las mismas. En esencia,
La clase GraphVertex mantiene co- una cámara consiste en un identificador, un atributo que determina la tasa de frames
mo variable de clase un objeto de ti-
po Node, el cual alberga la informa- por segundo a la que se mueve y una secuencia de puntos clave que conforman el
ción de un nodo en el espacio 3D. camino asociado a la cámara.
La clase Importer
API DOM y memoria El punto de interacción entre los datos contenidos en el documento XML y la
lógica de dominio previamente discutida está representado por la clase Importer. Esta
El árbol generado por el API DOM clase proporciona la funcionalidad necesaria para parsear documentos XML con la
a la hora de parsear un documento
XML puede ser costoso en memo-
estructura planteada anteriormente y rellenar las estructuras de datos diseñadas en la
ria. En ese caso, se podría plantear anterior sección.
otras opciones, como por ejemplo el
uso del API SAX. El siguiente listado de código muestra la declaración de la clase Importer. Como se
puede apreciar, dicha clase hereda de Ogre::Singleton para garantizar que solamente
existe una instancia de dicha clase, accesible con las funciones miembro getSingleton()
y getSingletonPtr()13 .
Note cómo, además de estas✄ dos funciones miembro, la única función miembro
pública es parseScene() (línea ✂9 ✁), la cual se puede utilizar para parsear un docu-
mento XML cuya ruta se especifica en el primer parámetro. El efecto de realizar una
llamada a esta función tendrá como resultado el segundo parámetro de la misma, de ti-
po puntero a objeto de clase Scene, con la información obtenida a partir del documento
XML (siempre y cuando no se produzca ningún error).
6 public:
7 // Única función miembro pública para parsear.
8 void parseScene (const char* path, Scene *scn);
9
10 static Importer& getSingleton (); // Ogre::Singleton.
11 static Importer* getSingletonPtr (); // Ogre::Singleton.
12
13 private:
14 // Funcionalidad oculta al exterior.
15 // Facilita el parseo de las diversas estructuras
16 // del documento XML.
17 void parseCamera (xercesc::DOMNode* cameraNode, Scene* scn);
18
19 void addPathToCamera (xercesc::DOMNode* pathNode, Camera *cam);
20 void getFramePosition (xercesc::DOMNode* node,
21 Ogre::Vector3* position);
22 void getFrameRotation (xercesc::DOMNode* node,
23 Ogre::Vector4* rotation);
24
25 void parseGraph (xercesc::DOMNode* graphNode, Scene* scn);
26 void addVertexToScene (xercesc::DOMNode* vertexNode, Scene* scn);
27 void addEdgeToScene (xercesc::DOMNode* edgeNode, Scene* scn);
28
29 // Función auxiliar para recuperar valores en punto flotante
30 // asociados a una determinada etiqueta (tag).
31 float getValueFromTag (xercesc::DOMNode* node, const XMLCh *tag);
32 };
C6
20 for (XMLSize_t i = 0;
21 i < elementRoot->getChildNodes()->getLength(); ++i ) {
22 DOMNode* node = elementRoot->getChildNodes()->item(i);
23
24 if (node->getNodeType() == DOMNode::ELEMENT_NODE) {
25 // Nodo <camera>?
26 if (XMLString::equals(node->getNodeName(), camera_ch))
27 parseCamera(node, scene);
28 else
29 // Nodo <graph>?
30 if (XMLString::equals(node->getNodeName(), graph_ch))
31 parseGraph(node, scene);
32 }
33 }// Fin for
34 // Liberar recursos.
35 }
Bajo Nivel y Concurrencia
Capítulo 7
David Vallejo Fernández
E
ste capítulo realiza un recorrido por los sistemas de soporte de bajo nivel ne-
cesarios para efectuar diversas tareas críticas para cualquier motor de juegos.
Algunos ejemplos representativos de dichos subsistemas están vinculados al
arranque y la parada del motor, su configuración y a la gestión de cuestiones de más
bajo nivel.
El objetivo principal del presente capítulo consiste en profundizar en dichas tareas
y en proporcionar al lector una visión más detallada de los subsistemas básicos sobre
los que se apoyan el resto de elementos del motor de juegos.
From the ground up! En concreto, en este capítulo se discutirán los siguientes subsistemas:
Los subsistemas de bajo nivel del Subsistema de arranque y parada.
motor de juegos resultan esenciales
para la adecuada integración de ele- Subsistema de gestión de contenedores.
mentos de más alto nivel. Algunos
de ellos son simples, pero la funcio- Subsistema de gestión de cadenas.
nalidad que proporcionan es crítica
para el correcto funcionamiento de
otros subsistemas. El subsistema de gestión relativo a la gestión de contenedores de datos se discutió
en el capítulo 5. En este capítulo se llevó a cabo un estudio de la biblioteca STL y
se plantearon unos criterios básicos para la utilización de contenedores de datos en
el ámbito del desarrollo de videojuegos con C++. Sin embargo, este capítulo dedica
una breve sección a algunos aspectos relacionados con los contenedores que no se
discutieron anteriormente.
Por otra parte, el subsistema de gestión de memoria se estudiará en el módulo 3,
titulado Técnicas Avanzadas de Desarrollo, del presente curso de desarrollo de video-
juegos. Respecto a esta cuestión particular, se hará especial hincapié en las técnicas y
las posibilidades que ofrece C++ en relación a la adecuada gestión de la memoria del
sistema.
219
[220] CAPÍTULO 7. BAJO NIVEL Y CONCURRENCIA
Con el objetivo de abordar esta problemática desde un punto de vista práctico en El arranque y la parada de subsiste-
mas representa una tarea básica y, al
el ámbito del desarrollo de videojuegos, en este capítulo se plantean distintos meca- mismo tiempo, esencial para la co-
nismos de sincronización de hilos haciendo uso de los mecanismos nativos ofrecidos rrecta gestión de los distintos com-
en C++11 y, por otra parte, de la biblioteca de hilos de ZeroC I CE, un middleware de ponentes de la arquitectura de un
comunicaciones que proporciona una biblioteca de hilos que abstrae al desarrollador motor de juegos.
de la plataforma y el sistema operativo subyacentes. Subsistema S
Sistema de Manejo de
Motor de rendering, motor de física,
arranque y parada cadenas
herramientas de desarrollo, networking,
Gestión de Gestión de audio, subsistema de juego...
memoria archivos
Gestor de recursos
Biblioteca
C7
...
matemática
Subsistemas principales
SDKs y middlewares
Figura 7.2: Interacción del subsistema de arranque y parada con el resto de subsistemas de la arquitectura
general del motor de juegos [42].
No obstante, aunque existe cierto control sobre el arranque de los subsistemas de-
pendientes de uno en concreto, esta alternativa no proporciona control sobre la parada
de los mismos. Así, es posible que C++ destruya uno de los subsistemas dependientes
del gestor de memoria de manera previa a la destrucción de dicho subsistema.
Además, este enfoque presenta el inconveniente asociado al momento de la cons-
trucción de la instancia global del subsistema de memoria. Evidentemente, la construc-
ción se efectuará a partir de la primera llamada a la función get, pero es complicado
controlar cuándo se efectuará dicha llamada.
C7
Por otra parte, no es correcto realizar suposiciones sobre la complejidad vinculada
a la obtención del singleton, ya que los distintos subsistemas tendrán necesidades muy
distintas entre sí a la hora de inicializar su estado, considerando las interdependencias
con otros subsistemas. En general, este diseño puede ser problemático y es necesa-
rio proporcionar un esquema sobre el que se pueda ejercer un mayor control y que
simplique los procesos de arranque y parada.
Ogre::Singleton<Root> RootAlloc
C7
Ogre::Root
Figura 7.6: Clase Ogre::Root y su relación con las clases Ogre::Singleton y RootAlloc.
El código fuente de Quake III1 fue liberado bajo licencia GPLv2 el día 20 de
Agosto de 2005. Desde entonces, la comunidad amateur de desarrollo ha realizado
modificaciones y mejoras e incluso el propio motor se ha reutilizado para el desarrollo
de otros juegos. El diseño de los motores de Quake es un muy buen ejemplo de
arquitectura bien estructurada y modularizada, hecho que posibilita el estudio de su
código y la adquisición de experiencia por el desarrollador de videojuegos.
C7
En esta sección se estudiará desde un punto de vista general el sistema de arranque
y de parada de Quake III. Al igual que ocurre en el caso de Ogre, el esquema planteado
consiste en hacer uso de funciones específicas para el arranque, inicialización y parada
de las distintas entidades o subsistemas involucrados.
El siguiente listado de código muestra el punto de entrada de Quake III para sis-
temas WindowsTM . Como se puede apreciar, la función principal consiste en una serie
de casos que determinan el flujo de control del juego. Es importante destacar los ca-
sos GAME_INIT y GAME_SHUTDOWN que, como su nombre indica, están vin-
culados al arranque y la parada del juego mediante las funciones G_InitGame() y
G_ShutdownGame(), respectivamente.
20 de Agosto
Listado 7.8: Quake 3. Función vmMain().
La fecha de liberación del código de
Quake III no es casual. En realidad, 1 int vmMain( int command, int arg0, int arg1, ..., int arg11 ) {
coincide con el cumpleaños de John 2 switch ( command ) {
Carmack, nacido el 20 de Agosto de 3 // Se omiten algunos casos.
1970. Actualmente, John Carmack 4 case GAME_INIT:
es una de las figuras más reconoci- 5 G_InitGame( arg0, arg1, arg2 );
das dentro del ámbito del desarrollo 6 return 0;
de videojuegos. 7 case GAME_SHUTDOWN:
8 G_ShutdownGame( arg0 );
9 return 0;
10 case GAME_CLIENT_CONNECT:
11 return (int)ClientConnect( arg0, arg1, arg2 );
12 case GAME_CLIENT_DISCONNECT:
13 ClientDisconnect( arg0 );
14 return 0;
15 case GAME_CLIENT_BEGIN:
16 ClientBegin( arg0 );
17 return 0;
18 case GAME_RUN_FRAME:
19 G_RunFrame( arg0 );
20 return 0;
21 case BOTAI_START_FRAME:
22 return BotAIStartFrame( arg0 );
23 }
24
25 return -1;
26 }
1 https://github.com/id-Software/Quake-III-Arena
[228] CAPÍTULO 7. BAJO NIVEL Y CONCURRENCIA
13 BotAIShutdown( restart );
14 }
15 }
C7
la entidad afectada.
Programación estructura A su vez, la función BotAIShutdown() delega en otra función que es la encargada,
finalmente, de liberar la memoria y los recursos previamente reservados para el caso
El código de Quake es un buen
ejemplo de programación estructu- particular de los jugadores que participan en el juego, utilizando para ello la función
rada que gira en torno a una adecua- BotAIShutdownClient().
da definición de funciones y a un
equilibrio respecto a la complejidad
de las mismas. Listado 7.11: Quake 3. Funciones BotAIShutdown y BotAIShutdownClient.
1 int BotAIShutdown( int restart ) {
2 // Si el juego se resetea para torneo...
3 if ( restart ) {
4 // Parar todos los bots en botlib.
5 for (i = 0; i < MAX_CLIENTS; i++)
6 if (botstates[i] && botstates[i]->inuse)
7 BotAIShutdownClient(botstates[i]->client, restart);
8 }
9 // ...
10 }
11
12 int BotAIShutdownClient(int client, qboolean restart) {
13 // Se omite parte del código.
14 // Liberar armas...
15 trap_BotFreeWeaponState(bs->ws);
16 // Liberar el bot...
17 trap_BotFreeCharacter(bs->character);
18 // ..
19 // Liberar el estado del bot...
20 memset(bs, 0, sizeof(bot_state_t));
21 // Un bot menos...
22 numbots--;
23 // ...
24 // Todo OK.
25 return qtrue;
26 }
7.2. Contenedores
Como ya se introdujo en el capítulo 5, los contenedores son simplemente objetos
que contienen otros objetos. En el mundo del desarrollo de videojuegos, y en el de
las aplicaciones software en general, los contenedores se utilizan extensivamente para
almacenar las estructuras de datos que conforman la base del diseño que soluciona un
determinado problema.
Algunos de los contenedores más conocidos ya se comentaron en las seccio-
nes 5.3, 5.4 y 5.5. En dichas secciones se hizo un especial hincapié en relación a la
utilización de estos contenedores, atendiendo a sus principales caracteristicas, como
la definición subyacente en la biblioteca STL.
[230] CAPÍTULO 7. BAJO NIVEL Y CONCURRENCIA
Acceso
Bidireccional Unidireccional Entrada
directo
Salida
Figura 7.9: Esquema gráfica de las relaciones entre las distintas categorías de iteradores en STL. Cada
categoría implementa la funcionalidad de todas las categorías que se encuentran a su derecha.
7.2.1. Iteradores
Desde un punto de vista abstracto, un iterador se puede definir como una clase Patrón Iterator
que permite acceder de manera eficiente a los elementos de un contenedor específico.
Normalmente, los iteradores se im-
Según [94], un iterador es una abstracción pura, es decir, cualquier elemento que se plementan haciendo uso del pa-
comporte como un iterador se define como un iterador. En otras palabras, un itera- trón que lleva su mismo nombre,
dor es una abstracción del concepto de puntero a un elemento de una secuencia. Los tal y como se discutió en la sec-
principales elementos clave de un iterador son los siguientes: ción 4.4.3.
De este modo, un elemento primitivo int* se puede definir como un iterador sobre
un array de enteros, es decir, sobre int[]. Así mismo, std::list<std::string>::iterator Rasgos de iterador
es un iterador sobre la clase list. En STL, los tipos relacionados con
Un iterador representa la abstracción de un puntero dentro de un array, de manera un iterador se describen a partir
de una serie de declaraciones en
que no existe el concepto de iterador nulo. Como ya se introdujo anteriormente, la la plantilla de clase iterator_traits.
condición para determinar si un iterador apunta o no a un determinado elemento se Por ejemplo, es posible acceder al
evalúa mediante una comparación al elemento final de una secuencia (end). tipo de elemento manejado o el tipo
de las operaciones soportadas.
Las principales ventajas de utilizar un iterador respecto a intentar el acceso sobre
un elemento de un contenedor son las siguientes [42]:
C7
Cuadro 7.1: Resumen de las principales categorías de iteradores en STL junto con sus operaciones [94].
Los iteradores también se pueden aplicar sobre flujos de E/S gracias a que la
biblioteca estándar proporciona cuatro tipos de iteradores enmarcados en el esquema
general de contenedores y algoritmos:
Listado 7.12: Ejemplo de uso de iteradores para llevar a cabo operaciones de E/S.
1 #include <iostream>
2 #include <iterator>
3
4 using namespace std;
5
6 int main () {
7 // Escritura de enteros en cout.
8 ostream_iterator<int> fs(cout);
9
10 *fs = 7; // Escribe 7 (usa cout).
11 ++fs; // Preparado para siguiente salida.
12 *fs = 6; // Escribe 6.
13
14 return 0;
15 }
De manera independiente al hecho de considerar la opción de usar STL, existen Estandarizando Boost
otras bibliotecas relacionadas que pueden resultar útiles para el desarrollador de vi-
deojuegos. Una de ellas es STLPort, una implementación de STL específicamente Gran parte de las bibliotecas del
proyecto Boost están en proceso de
ideada para ser portable a un amplio rango de compiladores y plataformas. Esta im- estandarización con el objetivo de
plementación proporciona una funcionalidad más rica que la que se puede encontrar incluirse en el propio estándar de
en otras implementaciones de STL. C++.
7
A B A C
1 2 B A D
2 12 4
C7
E
C D
3
3
D B E
C D
2
E A B C
4
Figura 7.10: Representación gráfica del grafo de ejemplo utilizado para el cálculo de caminos mínimos. La
parte derecha muestra la implementación mediante listas de adyacencia planteada en la biblioteca Boost.
La figura 7.10 muestra un grafo dirigido que servirá como base para la discusión
del siguiente fragmento de código, en el cual se hace uso de la biblioteca Boost para
llevar a cabo el cálculo de los caminos mínimos desde un vértice al resto mediante el
algoritmo de Dijkstra.
Edsger W. Dijkstra Recuerde que un grafo es un conjunto no vacío de nodos o vértices y un conjunto
de pares de vértices o aristas que unen dichos nodos. Un grafo puede ser dirigido o
Una de las personas más relevan-
tes en el ámbito de la computación no dirigido, en función de si las aristas son unidireccionales o bidireccionales, y es
es Dijkstra. Entre sus aportaciones, posible asignar pesos a las aristas, conformando así un grafo valorado.
destacan la solución al camino más
corto, la notación polaca inversa, el 3 http://www.stlport.org
algoritmo del banquero, la defini- 4 http://www.boost.org
ción de semáforo como mecanismo
de sincronización e incluso aspec-
tos de computación distribuida, en-
tre otras muchas contribuciones.
[234] CAPÍTULO 7. BAJO NIVEL Y CONCURRENCIA
dijkstra-example.cpp
7.3. Subsistema de gestión de cadenas [235]
C7
✄
El último bloque de código (líneas ✂44-52 ✁) permite obtener la información rele-
vante a partir del cálculo de los caminos mínimos desde A, es decir, la distancia que
existe desde el propio nodo A hasta cualquier otro y el nodo a partir del cual se accede
al destino final (partiendo de A).
Para generar un ejecutable, simplemente es necesario enlazar con la biblioteca
boost_graph:
Al ejecutar, la salida del programa mostrará todos los caminos mínimos desde el
vértice especificado; en este caso, desde el vértice A.
cad
Distancias y nodos padre:
'H' Distancia(A) = 0, Padre(A) = A
Distancia(B) = 9, Padre(B) = E
p Distancia(C) = 2, Padre(C) = A
'o' Distancia(D) = 4, Padre(D) = C
Distancia(E) = 7, Padre(E) = D
'l'
7.3. Subsistema de gestión de cadenas
'a'
Al igual que ocurre con otros elementos transversales a la arquitectura de un motor
de juegos, como por ejemplos los contenedores de datos (ver capítulo 5), las cadenas
'm' de texto se utilizan extensivamente en cualquier proyecto software vinculado al desa-
rrollo de videojuegos. Aunque en un principio pueda parecer que la utilización y la
gestión de cadenas de texto es una cuestión trivial, en realidad existe una gran varie-
dad de técnicas y restricciones vinculadas a este tipo de datos. Su consideración puede
'u'
ser muy relevante a la hora de mejorar el rendimiento de la aplicación.
C7
Modificadores, que proporcionan la funcionalidad necesaria para alterar el con-
tenido de la cadena de texto. Un ejemplo es la función swap(), que intercambia
el contenido de la cadena por otra especificada como parámetro.
Operadores de cadena, que define operaciones auxiliares para tratar con cade-
nas. Un ejemplo es la función compare(), que permite comparar cadenas.
Como regla general, pase los objetos de tipo string como referencia y no
como valor, ya que esto incurriría en una penalización debido al uso de cons-
tructores de copia.
En este contexto, las cadenas de texto representan la manera más natural de lle-
var a cabo dicha identificación y tratamiento. Otra opción podría consistir en manejar
una tabla con valores enteros que sirviesen como identificadores de las distintas en-
tidadas. Sin embargo, los valores enteros no permiten expresar valores semánticos,
mientras que las cadenas de texto sí.
Debido a la importancia del tratamiento y manipulación de cadenas, las operacio-
nes asociadas han de ser eficientes. Desafortunadamente, este criterio no se cumple en
el caso de funciones como strcmp(). La solución ideal debería combinar la flexibilidad
y poder descriptivo de las cadenas con la eficiencia de un tipo de datos primitivo, como
los enteros. Este planteamiento es precisamente la base del esquema que se discute a
continuación.
7 Se recomienda la visualización del curso Introduction to Algorithms del MIT, en concreto las clases 7
y 8, disponible en la web.
7.4. Configuración del motor [239]
5
6 void funcion (StringId id) {
7 // Más eficiente que...
8 // if (id == internString ("hola"))
9 if (id == sid_hola)
10 // ...
11 else if (id == sid_mundo)
12 // ...
13 }
C7
7.4. Configuración del motor
Debido a la complejidad inherente a un motor de juegos, existe una gran variedad
de variables de configuración que se utilizan para especificar el comportamiento de
determinadas partes o ajustar cuestiones específicas. Desde un punto de vista general,
en la configuración del motor se pueden distinguir dos grandes bloques:
Tricks
En muchos juegos es bastante co- 7.4.1. Esquemas típicos de configuración
mún encontrar trucos que permi-
ten modificar significativamente al-
gunos aspectos internos del juego, Las variables de configuración se pueden definir de manera trivial mediante el
como por ejemplo la capacidad de uso de variables globales o variables miembro de una clase que implemente el patrón
desplazamiento del personaje prin- singleton. Sin embargo, idealmente debería ser posible modificar dichas variables de
cipal. Tradicionalmente, la activa- configuración sin necesidad de modificar el código fuente y, por lo tanto, volver a
ción de trucos se ha realizado me-
diante combinaciones de teclas. compilar para generar un ejecutable.
Normalmente, las variables o parámetros de configuración residen en algún tipo de
dispositivo de almacenamiento externo, como por ejemplo un disco duro o una tarjeta
de memoria. De este modo, es posible almacenar y recuperar la información asociada
a la configuración de una manera práctica y directa. A continuación se discute breve-
mente los distintas aproximaciones más utilizadas en el desarrollo de videojuegos.
En primer lugar, uno de los esquemás típicos de recuperación y almacenamiento de
información está representado por los ficheros de configuración. La principal ventaja
de este enfoque es que son perfectamente legibles ya que suelen especificar de manera
explícita la variable de configuración a modificar y el valor asociada a la misma. En
general, cada motor de juegos mantiene su propio convenio aunque, normalmente,
todos se basan en una secuencia de pares clave-valor. Por ejemplo, Ogre3D utiliza la
siguiente nomenclatura:
Dentro del ámbito de los ficheros de configuración, los archivos XML representan
otra posibilidad para establecer los parámetros de un motor de juegos. En este caso, es
necesario llevar a cabo un proceso de parsing para obtener la información asociada a
dichos parámetros.
Tradicionalmente, las plataformas de juego que sufrían restricciones de memoria, El lenguaje XML
debido a limitaciones en su capacidad, hacían uso de un formato binario para alma-
cenar la información asociada a la configuración. Típicamente, dicha información se eXtensible Markup Language es un
metalenguaje extensible basado en
almacenaba en tarjetas de memoria externas que permitían salvar las partidas guarda- etiquetas que permite la definición
das y la configuración proporcionada por el usuario de un determinado juego. de lenguajes específicos de un de-
terminado dominio. Debido a su po-
Este planteamiento tiene como principal ventaja la eficiencia en el almacenamien- pularidad, existen multitud de he-
to de información. Actualmente, y debido en parte a la convergencia de la consola de rramientas que permiten el trata-
sobremesa a estaciones de juegos con servicios más generales, la limitación de alma- miento y la generación de archivos
bajo este formato.
cenamiento en memoria permanente no es un problema, normalmente. De hecho, la
mayoría de consolas de nueva generación vienen con discos duros integrados.
Los propios registros del sistema operativo también se pueden utilizar como so-
porte al almacenamiento de parámetros de configuración. Por ejemplo, los sistemas
operativos de la familia de Microsoft WindowsTM proporcionan una base de datos glo-
bal implementada mediante una estructura de árbol. Los nodos internos de dicha es-
tructura actúan como directorios mientras que los nodos hoja representan pares clave-
valor.
También es posible utilizar la línea de órdenes o incluso la definición de variables
de entorno para llevar a cabo la configuración de un motor de juegos.
Finalmente, es importante reflexionar sobre el futuro de los esquemas de almace-
namiento. Actualmente, es bastante común encontrar servicios que permitan el alma-
cenamiento de partidas e incluso de preferencias del usuario en la red, haciendo uso de
servidores en Internet normalmente gestionados por la propia compañía de juegos.
Este tipo de aproximaciones tienen la ventaja de la redundancia de datos, sacrificando
el control de los datos por parte del usuario.
C7
7 (fade-out-seconds float :default 0.25)
8 )
9 )
10
11 ;; Instancia específica para andar.
12 (define-export anim-walk
13 (new simple-animation
14 :name "walk"
15 :speed 1.0
16 )
17 )
18 ;; Instancia específica para andar rápido.
19 (define-export anim-walk-fast
20 (new simple-animation
21 :name "walk-fast"
22 :speed 2.0
23 )
24 )
C7
hilo
(a) (b)
Figura 7.12: Esquema gráfico de los modelos de programación monohilo (a) y multihilo (b).
SECCIÓN_ENTRADA
SECCIÓN_CRÍTICA
SECCIÓN_SALIDA
SECCIÓN_RESTANTE
Esqueleto
C7
Proxy API ICE API ICE Adaptador de objetos
Red de
comunicaciones ICE runtime (servidor)
ICE runtime (cliente)
Figura 7.14: Arquitectura general de una aplicación distribuida desarrollada con el middleware ZeroC I CE.
id(), que devuelve el identificador único asociado al hilo. Este valor dependerá
del soporte de hilos nativo (por ejemplo, POSIX pthreads).
isAlive(), que permite conocer si un hilo está en ejecución, es decir, si ya se
llamó a start() y la función run() no terminó de ejecutarse.
La sobrecarga de los operadores de comparación permiten hacer uso de hilos
en contenedores de STL que mantienen relaciones de orden.
C7
Sincronización Cuando un filósofo piensa, entonces se abstrae del mundo y no se relaciona con
El problema de los filósofos comen- ningún otro filósofo. Cuando tiene hambre, entonces intenta coger a los palillos que
sales es uno de los problemas clási- tiene a su izquierda y a su derecha (necesita ambos). Naturalmente, un filósofo no
cos de sincronización y existen mu- puede quitarle un palillo a otro filósofo y sólo puede comer cuando ha cogido los dos
chas modificaciones del mismo que palillos. Cuando un filósofo termina de comer, deja los palillos y se pone a pensar.
permiten asimilar mejor los con-
ceptos teóricos de la programación La solución que se discutirá en esta sección se basa en implementar el filósofo
concurrente. como un hilo independiente. Para ello, se crea la clase FilosofoThread que se expone
en el anterior listado de código.
La implementación de la función run() es trivial a partir de la descripción del
enunciado del problema.
I CE proporciona la clase Mutex para modelar esta problemática de una forma sen-
cilla y directa.
Las funciones miembro más importantes de esta clase son las que permiten adqui-
rir y liberar el cerrojo:
lock(), que intenta adquirir el cerrojo. Si éste ya estaba cerrado, entonces el hilo
que invocó a la función se suspende hasta que el cerrojo quede libre. La llamada
a dicha función retorna cuando el hilo ha adquirido el cerrojo.
tryLock(), que intenta adquirir el cerrojo. A diferencia de lock(), si el cerrojo
está cerrado, la función devuelve false. En caso contrario, devuelve true con el
cerrojo cerrado.
unlock(), que libera el cerrojo.
C7
sincronización que mantiene una semántica recursiva.
La clase Mutex se puede utilizar para gestionar el acceso concurrente a los palillos.
En la solución planteada a continuación, un palillo es simplemente una especialización
de IceUtil::Mutex con el objetivo de incrementar la semántica de dicha solución.
Riesgo de interbloqueo
Listado 7.22: La clase Palillo
Si todos los filósofos cogen al mis-
mo tiempo el palillo que está a su 1 #ifndef __PALILLO__
izquierda se producirá un interblo- 2 #define __PALILLO__
queo ya que la solución planteada 3
no podría avanzar hasta que un filó- 4 #include <IceUtil/Mutex.h>
sofo coja ambos palillos. 5
6 class Palillo : public IceUtil::Mutex {
7 };
8
9 #endif
Para que los filósofos puedan utilizar los palillos, habrá que utilizar la funcionali-
dad previamente discutida, es decir, las funciones lock() y unlock(), en las funciones
coger_palillos() y dejar_palillos(). La solución planteada garantiza que no se ejecuta-
rán dos llamadas a lock() sobre un palillo por parte de un mismo hilo, ni tampoco una
llamada sobre unlock() si previamente no se adquirió el palillo.
La solución planteada es poco flexible debido a que los filósofos están inactivos
durante el periodo de tiempo que pasa desde que dejan de pensar hasta que cogen
los dos palillos. Una posible variación a la solución planteada hubiera sido continuar
pensando (al menos hasta un número máximo de ocasiones) si los palillos están ocu-
Gestión del deadlock pados. En esta variación se podría utilizar la función tryLock() para modelar dicha
Aunque existen diversos esquemas problemática.
para evitar y recuperarse de un in-
terbloqueo, los sistemas operativos
modernos suelen optar por asumir
que nunca se producirán, delegan-
do en el programador la implemen-
tación de soluciones seguras.
[250] CAPÍTULO 7. BAJO NIVEL Y CONCURRENCIA
Evitando interbloqueos
El uso de las funciones lock() y unlock() puede generar problemas importantes si,
por alguna situación no controlada, un cerrojo previamente adquirido con lock() no
se libera posteriormente con una llamada a unlock(). El siguiente listado de código
muestra esta problemática.
✄
En la línea ✂8 ✁, la sentencia return implica abandonar
✄ mi_funcion() sin haber li-
berado el cerrojo previamente adquirido en la línea ✂6 ✁. En este contexto, es bastante
probable que se genere una potencial situación de interbloqueo que paralizaría la eje-
cución del programa. La generación de excepciones no controladas representa otro
caso representativo de esta problemática.
Para evitar este tipo de problemas, la clase Mutex proporciona las definiciones
de tipo Lock y TryLock, que representan plantillas muy sencillas compuestas de un
constructor en el que se llama a lock() y tryLock(), respectivamente. En el destructor se
llama a unlock() si el cerrojo fue previamente adquirido cuando la plantilla quede fuera
de ámbito. En el ejemplo anterior, sería posible garantizar la liberación del cerrojo al
ejecutar return, ya que quedaría fuera del alcance de la función. El siguiente listado
de código muestra la modificación realizada para evitar interbloqueos.
C7
No olvides... Además de proporcionar cerrojos con una semántica no recursiva, I CE también
proporciona la clase IceUtil::RecMutex con el objetivo de que el desarrollador pueda
Liberar un cerrojo sólo si fue pre-
viamente adquirido y llamar a un- manejar cerrojos recursivos. La interfaz de esta nueva clase es exactamente igual que
lock() tantas veces como a lock() la clase IceUtil::Mutex.
para que el cerrojo quede disponi-
ble para otro hilo. Sin embargo, existe una diferencia fundamental entre ambas. Internamente, el ce-
rrojo recursivo está implementado con un contador inicializado a cero. Cada llamada
a lock() incrementa el contador, mientras que cada llamada a unlock() lo decrementa.
El cerrojo estará disponible para otro hilo cuando el contador alcance el valor de cero.
Datos
compartidos
x
Condiciones
y
Operaciones
Código
inicialización
lock(), que intenta adquirir el monitor. Si éste ya estaba cerrado, entonces el hilo
que la invoca se suspende hasta que el monitor quede disponible. La llamada
retorna con el monitor cerrado.
tryLock(), que intenta adquirir el monitor. Si está disponible, la llamada de- No olvides...
vuelve true con el monitor cerrado. Si éste ya estaba cerrado antes de relizar la
Comprobar la condición asociada al
llamada, la función devuelve false. uso de wait siempre que se retorne
de una llamada a la misma.
unlock(), que libera el monitor. Si existen hilos bloqueados por el mismo, en-
tonces uno de ellos se despertará y cerrará de nuevo el monitor.
7.6. La biblioteca de hilos de I CE [253]
C7
decir, si el timeout expira, timedWait() devuelve false.
notify(), que despierta a un único hilo suspendido debido a una invocación so-
bre wait() o timedWait(). Si no existiera ningún hilo suspendido, entonces la
invocación sobre notify() se pierde. Llevar a cabo una notificación no implica
que otro hilo reanude su ejecución inmediatamente. En realidad, esto ocurriría
cuando el hilo que invoca a wait() o timedWait() libera el monitor.
notifyAll(), que despierta a todos los hilos suspendidos por wait() o timedWait().
El resto del comportamiento derivado de invocar a esta función es idéntico a
notify().
get ()
wait ()
notify put ()
return item
✄ no contiene
2. Si la estructura elementos, entonces el hilo se suspende mediante
wait() (líneas ✂18-19 ✄✁) hasta
que otro hilo que ejecute put() realice la invocación
sobre notify() (línea ✂13 ✁).
7.6. La biblioteca de hilos de I CE [255]
Recuerde que para que un hilo bloqueado por wait() reanude su ejecución, otro
hilo ha de ejecutar notify() y liberar el monitor mediante unlock(). Sin embargo, en
el anterior listado de código no existe ninguna llamada explícita a unlock(). ¿Es inco-
rrecta la solución? La respuesta es no, ya que la liberación del monitor se delega en
Lock cuando la función put() finaliza, es decir, justo después de ejecutar la operación
notify() en este caso particular.
No olvides... Volviendo al ejemplo anterior, considere dos hilos distintos que interactúan con
Usar Lock y TryLock para evitar po-
la estructura creada, de manera genérica, para almacenar los slots que permitirán la
C7
sibles interbloqueos causados por la activación de habilidades especiales por parte del jugador virtual. Por ejemplo, el hilo
generación de alguna excepción o asociado al productor podría implementarse como se muestra en el siguiente listado.
una terminación de la función no
prevista inicialmente.
Suponiendo que el código del consumidor sigue la misma estructura, pero extra-
yendo elementos de la estructura de datos compartida, entonces sería posible lanzar
distintos hilos para comprobar que el acceso concurrente sobre los distintos slots se
realiza de manera adecuada. Además, sería sencillo visualizar, mediante mensajes por
la salida estándar, que efectivamente los hilos consumidores se suspenden en wait()
hasta que hay al menos algún elemento en la estructura de datos compartida con los
productores.
Antes de pasar a discutir en la sección 7.9 un caso de estudio para llevar a cabo
el procesamiento de alguna tarea en segundo plano, resulta importante volver a dis-
cutir la solución planteada inicialmente para el manejo de monitores. En particular,
la implementación de las funciones miembro put() y get() puede generar sobrecarga
debido a que, cada vez que se añade un nuevo elemento a la estructura de datos, se
realiza una invocación. Si no existen hilos esperando, la notificación se pierde. Aun-
que este hecho no conlleva ningún efecto no deseado, puede generar una reducción
del rendimiento si el número de notificaciones se dispara.
Una posible solución a este problema consiste en llevar un control explícito de
Información adicional la existencia de consumidores de información, es decir, de hilos que invoquen a la
función get(). Para ello, se puede incluir una variable miembro en la clase Queue,
Una vez el más, el uso de alguna planteando un esquema mucho más eficiente.
estructura de datos adicional puede
facilitar el diseño de una solución. Como se puede apreciar en el siguiente listado de código, el hilo productor sólo
Este tipo de planteamientos puede
incrementar la eficiencia de la so-
llevará a cabo una notificación en el caso de que haya algún hilo consumidor en espera.
lución planteada aunque haya una Para ello, consulta el valor de la variable miembro _consumidoresEsperando.
mínima sobrecarga debido al uso y
procesamiento de datos extra.
[256] CAPÍTULO 7. BAJO NIVEL Y CONCURRENCIA
Por otra parte, los hilos consumidores, es decir, los que invoquen a la función get()
incrementan dicha variable antes de realizar un wait(), decrementándola cuando se
despierten.
Note que el acceso a la variable miembro _consumidoresEsperando es exclusivo
y se garantiza gracias a la adquisición del monitor justo al ejecutar la operación de
generación o consumo de información por parte de algún hilo.
C7
7 }
8
9 int main() {
10 thread th(&func, 100);
11 th.join();
12 cout << "Fuera del hilo" << endl;
13 return 0;
14 }
9 http://es.cppreference.com/w/cpp/thread/mutex
[258] CAPÍTULO 7. BAJO NIVEL Y CONCURRENCIA
C7
24
25 std::chrono::milliseconds espera(1250);
26 std::this_thread::sleep_for(espera);
27
28 /* Los mutex se desbloquean al salir de la función */
29 };
✄
El listado que se muestra a continuación lleva a cabo la instanciación de los pali-
llos (ver línea ✂9 ✁). Éstos se almacenan en un contenedor
✄ vector que inicialmente tiene
una capacidad igual al número de filósofos (línea ✂1 ✁). Resulta interesante destacar el
uso de unique_ptr en la línea 4 para que el programador se olvide de la liberación de
los punteros asociados, la cual tendrá lugar, gracias a este enfoque, cuando queden
✄fuera
del ámbito de declaración. También se utiliza la función std::move en la línea
✂ ✁, novedad en C++11, para copiar el puntero a p1 en el vector palillos, anulándolo
12
tras su uso.
2 vector<thread> filosofos(numero_filosofos);
3
4 /* Primer filósofo */
5 /* A su derecha el palillo 1 y a su izquierda el palillo 5 */
6 filosofos[0] = thread(comer,
7 /* Palillo derecho (1) */
8 palillos[0].get(),
9 /* Palillo izquierdo (5) */
10 palillos[numero_filosofos - 1].get(),
11 /* Id filósofo */
12 1,
13 /* Id palillo derecho */
14 1,
15 /* Id palillo izquierdo */
16 numero_filosofos
17 );
18
19 /* Restos de filósofos */
20 for (int i = 1; i < numero_filosofos; i++) {
21 filosofos[i] = (thread(comer,
22 palillos[i - 1].get(),
23 palillos[i].get(),
24 i + 1,
25 i,
26 i + 1
27 )
28 );
29 }
30
31 /* A comer... */
32 for_each(filosofos.begin(),
33 filosofos.end(),
34 mem_fn(&thread::join));
El siguiente paso consiste en ejecutar la interfaz gráfica que nos ofrece cmake para
hacer explícito el soporte multi-hilo de Ogre. Para ello, simplemente hay que ejecutar
el comando cmake-gui y, a continuación, especificar la ruta en la que se encuentra el
C7
código fuente de Ogre y la ruta en la que se generará el resultado de la compilación,
como se muestra en la figura 7.21. Después, hay que realizar las siguientes acciones:
$ cd ogre_build_v1-8-1
$ make -j 2
11 http://www.ogre3d.org/tikiwiki/tiki-index.php?page=Building+Ogre+With+CMake
12 Según los desarrolladores de Ogre, sólo se recomienda utilizar un valor de 0 o de 2 en sistemas GNU/-
Linux
13 http://www.ogre3d.org/docs/api/html/classOgre_1_1Resource_1_
1Listener.html
[262] CAPÍTULO 7. BAJO NIVEL Y CONCURRENCIA
Figura 7.21: Generando el fichero Makefile mediante cmake para compilar Ogre 1.8.1.
También es muy importante considerar la naturaleza del juego, es decir, las ne-
cesidades reales de actualizar el módulo de IA. En el caso del juego Simon, está nece-
sidad no sería especialmente relevante considerando el intervalo de tiempo existente
entre la generación de una secuencia y la generación del siguiente elemento que exten-
derá la misma. Sin embargo, en juegos en los que intervienen un gran número de bots
con una gran interactividad, la actualización del módulo de IA se debería producir con
mayor frecuencia.
: MyApp
start ()
update ()
Figura 7.23: Diagrama de interacción entre la clase principal del juego y AIThread.
7 }
8
9 _AI = new AIThread(IceUtil::Time::seconds(1));
10 IceUtil::ThreadControl tc = _AI->start();
11 // Se desliga del hilo principal.
12 tc.detach();
13
14 // ...
15 }
Programación Gráfica
El objetivo de este módulo titulado «Programación Gráfica» del Cur
so de Experto en Desarrollo de Videojuegos es cubrir los aspectos
esenciales relativos al desarrollo de un motor gráfico interactivo. En
este contexto, el presente módulo cubre aspectos esenciales y básicos
relativos a los fundamentos del desarrollo de la parte gráfica, como
por ejemplo el pipeline gráfico, como elemento fundamental de la
arquitectura de un motor de juegos, las bases matemáticas, las APIs
de programación gráfica, el uso de materiales y texturas, la
iluminación o los sistemas de partículas. Así mismo, el presente
módulo también discute aspectos relativos a la exportación e
importación de datos, haciendo especial hincapié en los formatos
existentes para tratar con información multimedia. Finalmente, se
pone de manifiesto la importancia del uso de elementos directamente
conectados con el motor gráfico, como la simulación física, con el
objetivo de dotar de más realismo en el comportamiento de los
objetos y actores que intervienen en el juego.
Fundamentos de Gráficos
Capítulo 8
Tridimensionales
Carlos González Morcillo
U
no de los aspectos que más llaman la atención en los videojuegos actuales son
sus impactantes gráficos 3D. En este primer capítulo introduciremos los con-
ceptos básicos asociados al pipeline en gráficos 3D. Se estudiarán las diferen-
tes etapas de transformación de la geometría hasta su despliegue final en coordenadas
de pantalla. En la segunda parte del capítulo estudiaremos algunas de las capacidades
básicas del motor gráfico de videojuegos y veremos los primeros ejemplos básicos de
uso de Ogre.
8.1. Introducción
¡¡Aún más rápido!! Desde el punto de vista del usuario, un videojuego puede definirse como una apli-
cación software que responde a una serie de eventos, redibujando la escena y ge-
Si cada frame tarda en desplegarse nerando una serie de respuestas adicionales (sonido en los altavoces, vibraciones en
más de 40ms, no conseguiremos el
mínimo de 25 fps (frames per se- dispositivos de control, etc...).
cond) establecido como estándar en El redibujado de esta escena debe realizarse lo más rápidamente posible. La capa
cine. En videojuegos, la frecuencia
recomendable mínima es de unos de aplicación en el despliegue gráfico es una de las actividades que más ciclos de CPU
50 fps. consumen (incluso, como veremos en la sección 8.3, con el apoyo de las modernas
GPUs Graphics Processing Unit existentes en el mercado).
269
[270] CAPÍTULO 8. FUNDAMENTOS DE GRÁFICOS TRIDIMENSIONALES
Superficies. La geometría de los objetos que forman la escena debe ser definida
empleando alguna representación matemática, para su posterior procesamiento
por parte del ordenador.
Cámara. La situación del visor debe ser definida mediante un par (posición,
rotación) en el espacio 3D. El plano de imagen de esta cámara virtual definirá
el resultado del proceso de rendering. Como se muestra en la Figura 8.1, para
imágenes generadas en perspectiva, el volumen de visualización define una pirá-
mide truncada que selecciona los objetos que serán representados en la escena.
Esta pirámide se denomina Frustum.
Figura 8.1: Descripción general
Fuentes de luz. Las fuentes de luz emiten rayos que interactúan con las superfi- del proceso de rendering.
cies e impactarán en el plano de imagen. Dependiendo del modo de simulación
de estos impactos de luz (de la resolución de la denominada ecuación de ren- Tiempo de cómputo
der), tendremos diferentes métodos de rendering.
En síntesis de imagen realista (co-
Propiedades de las superficies. En este apartado se incluyen las propiedades mo por ejemplo, las técnicas de ren-
dering empleadas en películas de
de materiales y texturas que describen el modelo de rebote de los fotones sobre animación) el cálculo de un solo fo-
las superficies. tograma de la animación puede re-
querir desde varias horas hasta días,
empleando computadores de altas
Uno de los principales objetivos en síntesis de imagen es el realismo. En gene- prestaciones.
ral, según el método empleado para la resolución de la ecuación de render tendremos
diferentes niveles de realismo (y diferentes tiempos de cómputo asociados). El prin-
cipal problema en gráficos en tiempo real es que las imágenes deben ser generadas
muy rápidamente. Eso significa, como hemos visto anteriormente, que el motor gráfi-
co dispone de menos de 40 ms para generar cada imagen. Habitualmente este tiempo
es incluso menor, ya que es necesario reservar tiempo de CPU para otras tareas como
el cálculo de la Inteligencia Artificial, simulación física, sonido...
8.1. Introducción [271]
Pipeline
Raster
Punto
Punto de
de Vista
Vista
C8
Pipeline Hardware (Interactivo) RayCasting
Mapeado “hacia delante” Mapeado Inverso
Figura 8.2: Diferencias entre las técnicas de despliegue interactivas (métodos basados en ScanLine) y
realistas (métodos basados en RayCasting). En el Pipeline Hardware, la selección del píxel más cercano
relativo a cada triángulo se realiza directamente en Hardware, empleando información de los fragmentos
(ver Sección 8.2).
App
El Pipeline está dividido en etapas funcionales. Al igual que ocurre en los pipe- Aplicación
line de fabricación industrial, algunas de estas etapas se realizan en paralelo y otras
secuencialmente. Idealmente, si dividimos un proceso en n etapas se incrementará la
Coord. Modelo (Locales)
velocidad del proceso en ese factor n. Así, la velocidad de la cadena viene determinada
por el tiempo requerido por la etapa más lenta. Transf Modelado
Como señala Akenine-Möler [8], el pipeline interactivo se divide en tres etapas Coord. Universales
conceptuales de Aplicación, Geometría y Rasterización (ver Figura 8.3). A continua-
Etapa de Geometría
ción estudiaremos estas etapas. Transf Visualización
Coord. Visualización
8.2.1. Etapa de Aplicación Vertex Shader
La etapa de aplicación se ejecuta en la CPU. Actualmente la mayoría de las CPUs Transf Proyección
son multinúcleo, por lo que el diseño de esta aplicación se realiza mediante diferentes
hilos de ejecución en paralelo. Habitualmente en esta etapa se ejecutan tareas aso- Coord. Normalizadas
ciadas al cálculo de la posición de los modelos 3D mediante simulaciones físicas, Transf Recorte
detección de colisiones, gestión de la entrada del usuario (teclado, ratón, joystick...).
De igual modo, el uso de estructuras de datos de alto nivel para la aceleración del des- Coord. Recortadas
pliegue (reduciendo el número de polígonos que se envían a la GPU) se implementan
Transf Pantalla
en la etapa de aplicación.
Coord. Pantalla
Sistema de
Coordenadas Sistema de
Y Universal (SRU) Coordenadas de
Visualización
Z
Sistema de X
Coordenadas Local Y
(Objeto)
Z Z
X
C8
Y Plano de
X visualización
Figura 8.4: Sistema de coordenadas de visualización y su relación con otros sistemas de coordenadas de la
escena.
Y
X
Z
Y
X
Transformación
de Visualización
Z
X X
Transformación
de Visualización
Z Z
Figura 8.5: El usuario especifica la posición de la cámara (izquierda) que se transforma, junto con los
objetos de la escena, para posicionarlos a partir del origen del SRU y mirando en la dirección negativa del
eje Z. El área sombreada de la cámara se corresponde con el volumen de visualización de la misma (sólo
los objetos que estén contenidos en esa pirámide serán finalmente representados).
Únicamente los objetos que están dentro del volumen de visualización deben ser
generados en la imagen final. Los objetos que están totalmente dentro del volumen de
visualización serán copiados íntegramente a la siguiente etapa del pipeline. Sin em-
bargo, aquellos que estén parcialmente incluidas necesitan ser recortadas, generando
nuevos vértices en el límite del recorte. Esta operación de Transformación de Recor-
te se realiza automáticamente por el hardware de la tarjeta gráfica. En la Figura 8.6 se
muestra un ejemplo simplificado de recorte.
X
Finalmente la Transformación de Pantalla toma como entrada las coordenadas
de la etapa anterior y produce las denominadas Coordenadas de Pantalla, que ajustan
Z las coordenadas x e y del cubo unitario a las dimensiones de ventana finales.
C8
Vértices
Nuevos
8.2.3. Etapa Rasterización
A partir de los vértices proyectados (en Coordenadas de Pantalla) y la información
asociada a su sombreado obtenidas de la etapa anterior, la etapa de rasterización se
encarga de calcular los colores finales que se asignarán a los píxeles de los objetos.
X Esta etapa de rasterización se divide normalmente en las siguientes etapas funciones
para lograr mayor paralelismo.
En la primera etapa del pipeline llamada Configuración de Triángulos (Triangle
Z Setup), se calculan las coordenadas 2D que definen el contorno de cada triángulo (el
primer y último punto de cada vértice). Esta información es utilizada en la siguiente
Figura 8.6: Los objetos que inter- etapa (y en la interpolación), y normalmente se implementa directamente en hardware
secan con los límites del cubo uni- dedicado.
tario (arriba) son recortados, aña-
diendo nuevos vértices. Los objetos A continuación, en la etapa del Recorrido de Triángulo (Triangle Traversal) se
que están totalmente dentro del cu- generan fragmentos para la parte de cada píxel que pertenece al triángulo. El recorrido
bo unitario se pasan directamente a del triángulo se basa por tanto en encontrar los píxeles que forman parte del triángulo,
la siguiente etapa. Los objetos que y se denomina Triangle Traversal (o Scan Conversion). El fragmento se calcula inter-
están totalmente fuera del cubo uni- polando la información de los tres vértices denifidos en la etapa de Configuración de
tario son descartados. Triángulos y contiene información calculada sobre la profundidad desde la cámara y
el sombreado (obtenida en la etapa de geometría a nivel de todo el triángulo).
La información interpolada de la etapa anterior se utiliza en el Pixel Shader (Som-
breado de Píxel) para aplicar el sombreado a nivel de píxel. Esta etapa habitualmente
se ejecuta en núcleos de la GPU programables, y permite implementaciones propias
por parte del usuario. En esta etapa se aplican las texturas empleando diversos métodos
de proyección (ver Figura 8.7).
Finalmente en la etapa de Fusión (Merging) se almacena la información del color
de cada píxel en un array de colores denominado Color Buffer. Para ello, se combina
el resultado de los fragmentos que son visibles de la etapa de Sombreado de Píxel.
La visibilidad se suele resolver en la mayoría de los casos mediante un buffer de
profundidad Z-Buffer, empleando la información que almacenan los fragmentos.
p'
Figura 8.9: Modelo de proyección en perspectiva simple. El plano de proyección infinito está definido en
z = −d, de forma que el punto p se proyecta sobre p′ .
Empleando triángulos semejantes (ver Figura 8.9 derecha), obtenemos las siguien-
tes coordenadas:
p′x −d −d px
= ⇔ p′x = (8.1)
px pz pz
C8
8.3. Implementación del Pipeline en GPU
El hardware de aceleración gráfica ha sufrido una importante transformación en la
P Vertex Shader última década. Como se muestra en la Figura 8.11, en los últimos años el potencial de
las GPUs ha superado con creces al de la CPU.
P Geometry Shader
GK110
F Transf. Recorte 7
C Transf. Pantalla
Itanium
Poulson
C
Número de Transistores ( Miles de Millones)
U
GP
zEC12
C Recorrido Triáng.
P Pixel Shader 2
F Fusión (Merger)
GT200 IBM z196
U
CP
Figura 8.10: Implementación típi- 1 Opteron 2400
ca del pipeline en GPU con so-
porte Híbrido (OpenGL 2). La le- AMD K10
G80
tra asociada a cada etapa indica el
nivel de modificación permitida al Core 2 Duo
usuario; P indica totalmente progra- Pentium IV Itanium 2
Pentium III GF4
mable, F indica fijo (no programa-
ble), y C indica configurable pero Año 1999 2001 2003 2005 2007 2009 2011 2013
no programable.
Figura 8.11: Evolución del número de transistores en GPU y CPU (1999-2013)
Resulta de especial interés conocer aquellas partes del Pipeline de la GPU que
puede ser programable por el usuario mediante el desarrollo de shaders. Este código
se ejecuta directamente en la GPU, y permite realizar operaciones a diferentes niveles
con alta eficiencia.
[278] CAPÍTULO 8. FUNDAMENTOS DE GRÁFICOS TRIDIMENSIONALES
En GPU, las etapas del Pipeline gráfico (estudiadas en la sección 8.2) se imple-
mentan en un conjunto de etapas diferente, que además pueden o no ser programables
por parte del usuario (ver Figura 8.10). Por cuestiones de eficiencia, algunas partes
del Pipeline en GPU no son programables, aunque se prevee que la tendencia en los
próximos años sea permitir su modificación.
La etapa asociada al Vertex Shader es totalmente programable, y encapsula las
cuatro primeras etapas del pipeline gráfico que estudiamos en la sección 8.2: Trans-
formación de Modelado, Transformación de Visualización, Sombreado de Vértices
(Vertex Shader) y Transformación de Proyección.
La etapa referente al Geometry Shader es otra etapa programable que permite El término GPU
realizar operaciones sobre las primitivas geométricas. Desde el 1999, se utiliza el término
GPU (Grpahics Processing Unit)
acuñado por NVIDIA para diferen-
ciar la primera tarjeta gráfica que
La evolución natural de las dos plataformas principales de gráficos 3D en permitía al programador implemen-
tiempo real (tanto OpenGL como Direct3D) ha sido el soporte único de etapas tar sus propios algoritmos (GeForce
totalmente programables. Desde 2009, OpenGL en su versión 3.2 incluye un 256).
modo Compatibility Profile manteniendo el soporte de llamadas a las etapas
no programables. De forma similar, en 2009 Direct 3D versión 10 eliminó las
llamadas a las etapas fijas del pipeline.
C8
primitivas de entrada.
Este tipo de shaders se emplean para la simulación de pelo, para encontrar los
bordes de los objetos, o para implementar algunas técnicas de visualización avanzadas
como metabolas o simulación de telas.
Figura 8.13: Capturas de algunos videojuegos desarrollados con motores libres. La imagen de la izquierda
se corresponde con Planeshift, realizado con Crystal Space. La captura de la derecha es del videojuego
H-Craft Championship, desarrollado con Irrlicht.
Dada la gran cantidad de restricciones que deben manejarse, así como el manejo
de la heterogeneidad en términos software y hardware, es necesario el uso de un mo-
tor de despliegue gráfico para desarrollar videojuegos. A continuación enunciaremos
algunos de los más empleados.
C8
OGRE. OGRE (http://www.ogre3d.org/) es un motor para gráficos 3D
libre multiplataforma. Sus características serán estudiadas en detalle en la sec-
ción 8.6.
De entre los motores estudiados, OGRE 3D ofrece una calidad de diseño superior,
con características técnicas avanzadas que han permitido el desarrollo de varios vi-
deojuegos comerciales. Además, el hecho de que se centre exclusivamente en el capa
gráfica permite utilizar gran variedad de bibliotecas externas (habitualmente accedidas
mediante plugins) para proporcionar funcionalidad adicional. En la siguiente sección
daremos una primera introducción y toma de contacto al motor libre OGRE.
C8
que contiene. Veremos más detalles sobre el trabajo con el grafo de escena a lo
largo de este módulo.
Aceleración Hardware. OGRE necesita una tarjeta aceleradora gráfica para
poder ejecutarse (con soporte de direct rendering mode). OGRE permite definir
el comportamiento de la parte programable de la GPU mediante la definición de
Shaders, estando al mismo nivel de otros motores como Unreal o CryEngine.
Materiales. Otro aspecto realmente potente en OGRE es la gestión de materia-
les. Es posible crear materiales sin modificar ni una línea de código a compilar.
El sistema de scripts para la definición de materiales de OGRE es uno de los
más potentes existentes en motores de rendering interactivo. Los materiales de
OGRE se definen mediante una o más Técnicas, que se componen a su vez de
una o más Pasadas de rendering (el ejemplo de la Figura 8.16 utiliza una úni-
ca pasada para simular el sombreado a lápiz mediante hatching). OGRE busca
automáticamente la mejor técnica disponible en un material que esté soportada
por el hardware de la máquina de forma transparente al programador. Además,
es posible definir diferentes Esquemas asociados a modos de calidad en el des-
pliegue.
Figura 8.16: Ejemplo de Shader
desarrollado por Assaf Raman para Animación. OGRE soporta tres tipos de animación ampliamente utilizados en
simular trazos a Lápiz empleando el la construcción de videojuegos: basada en esqueletos (skeletal), basada en vér-
sistema de materiales de OGRE. tices (morph y pose). En la animación mediante esqueletos, OGRE permite el
uso de esqueletos con animación basada en cinemática directa. Existen multi-
tud de exportadores para los principales paquetes de edición 3D. En este módulo
utilizaremos el exportador de Blender.
El sistema de animación de OGRE se basa en el uso de controladores, que
modifican el valor de una propiedad en función de otro valor. En el caso de
animaciones se utiliza el tiempo como valor para modificar otras propiedades
(como por ejemplo la posición del objeto). El motor de OGRE soporta dos mo-
delos básicos de interpolación: lineal y basada en splines cúbicas.
La animación y la geometría asociada a los modelos se almacena en un único
Optimización offline formato binario optimizado. El proceso más empleado se basa en la exportación
desde la aplicación de modelado y animación 3D a un formato XML (Ogre
El proceso de optimización de al
formato binario de OGRE calcula XML) para convertirlo posteriormente al formato binario optimizado mediante
el orden adecuado de los vértices la herramienta de línea de órdenes OgreXMLConverter.
de la malla, calcula las normales de
las caras poligonales, así como ver- Composición y Postprocesado. El framework de composición facilita al pro-
siones de diferentes niveles de de- gramador incluir efectos de postprocesado en tiempo de ejecución (siendo una
talle de la malla. Este proceso evi- extensión del pixel shader del pipeline estudiado en la sección 8.3.3). La apro-
ta el cálculo de estos parámetros en
tiempo de ejecución. ximación basada en pasadas y diferentes técnicas es similar a la explicada para
el gestor de materiales.
Plugins. El diseño de OGRE facilita el diseño de Plugins como componentes
que cooperan y se comunican mediante un interfaz conocido. La gestión de
archivos, sistemas de rendering y el sistema de partículas están implementados
basados en el diseño de Plugins.
[284] CAPÍTULO 8. FUNDAMENTOS DE GRÁFICOS TRIDIMENSIONALES
Gestión de Recursos. Para OGRE los recursos son los elementos necesarios pa-
ra representar un objetivo. Estos elementos son la geometría, materiales, esque-
letos, fuentes, scripts, texturas, etc. Cada uno de estos elementos tienen asociado
un gestor de recurso específico. Este gestor se encarga de controlar la cantidad
de memoria empleada por el recurso, y facilitar la carga, descarga, creación e
inicialización del recurso. OGRE organiza los recursos en niveles de gestión
superiores denominados grupos. Veremos en la sección 8.6.1 los recursos ges-
tionados por OGRE.
Características específicas avanzadas. El motor soporta gran cantidad de ca-
racterísticas de visualización avanzadas, que estudiaremos a lo largo del mó-
dulo, tales como sombras dinámicas (basadas en diversas técnicas de cálculo),
sistemas de partículas, animación basada en esqueletos y de vértices, y un largo
etcétera. OGRE soporta además el uso de otras bibliotecas auxiliares mediante
plugins y conectores. Entre los más utilizados cabe destacar las bibliotecas de
simulación física ODE, el soporte del metaformato Collada, o la reproducción
de streaming de vídeo con Theora. Algunos de estos módulos los utilizaremos
en el módulo 3 del presente curso.
Plugin Plugin
CustomArchiveFactory GLTexture GLRenderSystem
Uno de los objetos principales del sistema es el denominado Root. Root propor-
ciona mecanismos para la creación de los objetos de alto nivel que nos permitirán
gestionar las escenas, ventanas, carga de plugins, etc. Gran parte de la funcionalidad
de Ogre se proporciona a través del objeto Root. Como se muestra en el diagrama 8.17,
existen otros tres grupos de clases principales en OGRE:
C8
Gestor de Escena (clase SceneManager) es el que se encarga de implementar el
grafo de escena. Los nodos de escena SceneNode son elementos relacionados
jerárquicamente, que pueden adjuntarse o desligarse de una escena en tiempo de
ejecución. El contenido de estos SceneNode se adjunta en la forma de instancias
de Entidades (Entity), que son implementaciones de la clase MovableObject.
Gestión de Recursos: Este grupo de objetos se encarga de gestionar los recursos
necesarios para la representación de la escena (geometría, texturas, tipografías,
etc...). Esta gestión permite su carga, descarga y reutilización (mediante cachés
de recursos). En la siguiente subsección veremos en detalle los principales ges-
tores de recursos definidos en OGRE.
Entidades Procedurales Rendering: Este grupo de objetos sirve de intermediario entre los objetos de
La mayor parte de las entidades que Gestión de Escena y el pipeline gráfico de bajo nivel (con llamadas específi-
incluyas en el SceneNode serán car- cas a APIs gráficas, trabajo con buffers, estados internos de despliegue, etc.).
gadas de disco (como por ejem-
plo, una malla binaria en formato La clase RenderSystem es un interfaz general entre OGRE y las APIs de bajo
.mesh). Sin embargo, OGRE per- nivel (OpenGL o Direct3D). La forma más habitual de crear la RenderWindow
mite la definción en código de otras es a través del objeto Root o mediante el RenderSystem (ver Figura 8.18). La
entidades, como una textura proce- creación manual permite el establecimiento de mayor cantidad de parámetros,
dural, o un plano.
aunque para la mayoría de las aplicaciones la creación automática es más que
suficiente.
Por ser la gestión de recursos uno de los ejes principales en la creación de una
aplicación gráfica interactiva, estudiaremos a continuación los grupos de gestión de
recursos y las principales clases relacionadas en OGRE.
Gestión de Recursos
ResourceManager
RenderSystem
ExternalTextureSourceManager
SceneManager
ResourceGroupManager
OverlayManager
PlatformManager
Figura 8.18: Diagrama de clases simplificado de los Gestores de alto nivel que dan acceso a los diferentes
subsistemas definidos en OGRE
Un gestor (Manager) en OGRE es una clase que gestiona el acceso a otros tipos
de objetos (ver Figura 8.18). Los Managers de OGRE se implementan mediante el
patrón Singleton que garantiza que únicamente hay una istancia de esa clase en toda
la aplicación. Este patrón permite el acceso a la instancia única de la clase Manager
desde cualquier lugar del código de la aplicación.
8.6. Introducción a OGRE [287]
C8
ControllerManager. Es el gestor de los controladores que, como hemos in-
dicado anteriormente, se encargan de producir cambios en el estado de otras
clases dependiendo del valor de ciertas entradas.
DynLibManager. Esta clase es una de las principales en el diseño del sistema
de Plugins de OGRE. Se encarga de gestionar las bibliotecas de enlace dinámico
(DLLs en Windows y objetos compartidos en GNU/Linux).
ExternalTextureSourceManager. Gestiona las fuentes de textura externas (co-
mo por ejemplo, en el uso de video streaming).
FontManager. Gestiona las fuentes disponibles para su representación en su-
perposiciones 2D (ver OverlayManager).
GpuProgramManager. Carga los programas de alto nivel de la GPU, definidos
en ensamblador. Se encarga igualmente de cargar los programas compilados por
el HighLevelGpuProgramManager.
HighLevelGpuProgramManager. Gestiona la carga y compilación de los pro-
gramas de alto nivel en la GPU (shaders) utilizados en la aplicación en HLSL,
GLSL o Cg.
LogManager. Se encarga de enviar mensajes de Log a la salida definida por
OGRE. Puede ser utilizado igualmente por cualquier código de usuario que
quiera enviar eventos de Log.
MaterialManager. Esta clase mantiene las instancias de Material cargadas en
la aplicación, permitiendo reutilizar los objetos de este tipo en diferentes obje-
tos.
MeshManager. De forma análoga al MaterialManager, esta clase mantiene las
instancias de Mesh permitiendo su reutilización entre diferentes objetos.
OverlayManager. Esta clase gestiona la carga y creación de instancias de su-
perposiciones 2D, que permiten dibujar el interfaz de usuario (botones, iconos,
números, radar...). En general, los elementos definidos como HUDs (Head Up
Display).
ParticleSystemManager. Gestiona los sistemas de partículas, permitiendo aña-
dir gran cantidad de efectos especiales en aplicaciones 3D. Esta clase gestiona
los emisores, los límites de simulación, etc.
PlatformManager. Esta clase abstrae de las particularidades del sistema opera-
tivo y del hardware subyacente de ejecución, proporcionando rutinas indepen-
dientes del sistema de ventanas, temporizadores, etc.
[288] CAPÍTULO 8. FUNDAMENTOS DE GRÁFICOS TRIDIMENSIONALES
GNU/Linux (Debian)
Tanto OGRE como OIS puede ser igualmente instalado en cualquier distribución
compilando directamente los fuentes. En el caso de OGRE, es necesario CMake para
generar el makefile específico para el sistema donde va a ser instalado. En el caso de
OIS, es necesario autotools.
Microsoft Windows
C8
los desarrollos realizados con OGRE en plataformas Windows.
Como entorno de compilación utilizaremos MinGW (Minimalist GNU for Win-
dows), que contiene las herramientas básicas de compilación de GNU para Windows.
El instalador de MinGW mingw-get-inst puede obtenerse de la página web
http://www.mingw.org/. Puedes instalar las herramientas en C:\MinGW\. Una
vez instaladas, deberás añadir al path el directorio C:\MinGW\bin. Para ello, podrás
usar la siguiente orden en un terminal del sistema.
De igual modo, hay que descargar el SDK de DirectX4 . Este paso es opcional
siempre que no queramos ejecutar ningún ejemplo de OGRE que utilice Direct3D.
Sobre Boost... A continuación instalaremos la biblioteca OGRE3D para MinGW5 . Cuando acabe,
al igual que hicimos con el directorio bin de MinGW, hay que añadir los directorios
Boost es un conjunto de bibliotecas
libres que añaden multitud de fun-
boost_1_44\lib\ y bin\Release al path.
cionalidad a la biblioteca de C++
(están en proceso de aceptación por path = %PATH %;C:\Ogre3D\boost_1_44\lib\; C:\Ogre3D\bin\
el comité de estandarización del
lenguaje).
make mode=release
28
29 all: info $(EXEC)
30
31 info:
32 @echo ’---------------------------------------------------’
33 @echo ’>>> Using mode $(mode)’
34 @echo ’ (Please, call "make" with [mode=debug|release])’
35 @echo ’---------------------------------------------------’
36
37 # Enlazado -------------------------------------
38 $(EXEC): $(OBJS)
39 $(CXX) $(LDFLAGS) -o $@ $^ $(LDLIBS)
40
C8
41 # Compilacion -----------------------------------
42 $(DIROBJ) %.o: $(DIRSRC) %.cpp
43 $(CXX) $(CXXFLAGS) -c $< -o $@ $(LDLIBS)
44
45 # Limpieza de temporales ----------------------------
46 clean:
47 rm -f *.log $(EXEC) *~ $(DIROBJ)* $(DIRSRC)*~ $(DIRHEA)*~
Como se muestra en la Figura 8.19, además de los archivos para make, en el direc-
torio raiz se encuentran tres archivos de configuración. Estudiaremos a continuación
su contenido:
ogre.cfg. Este archivo, si existe, intentará ser cargado por el objeto Root. Si el
archivo no existe, o está creado de forma incorrecta, se le mostrará al usuario
una ventana para configurar los parámetros (resolución, método de anti-aliasing,
profundidad de color, etc...). En el ejemplo que vamos a realizar, se fuerza a que
siempre se abra el diálogo (ver Figura 8.20), recuperando los parámetros que el
usuario especificó en la última ejecución.
Plugins.cfg en Windows plugins.cfg. Como hemos comentado anteriormente, un plugin es cualquier mó-
dulo que implementa alguno de los interfaces de plugins de Ogre (como Sce-
En sistemas Windows, como no es
posible crear enlaces simbólicos, se neManager o RenderSystem). En este archivo se indican los plugins que de-
deberían copiar los archivos .dll be cargar OGRE, así como su localización. En el ejemplo del holaMundo, el
al directorio plugins del proyecto e contenido del archivo indica la ruta al lugar donde se encuentran los plugins
indicar la ruta relativa a ese directo- (PluginFolder), así como el Plugin que emplearemos (pueden especificarse
rio en el archivo plugins.cfg.
Esta aproximación también puede varios en una lista) para el rendering con OpenGL.
realizarse en GNU/Linux copiando
los archivos .so a dicho directo- resources.cfg. En esta primera versión del archivo de recursos, se especifica el
rio. directorio general desde donde OGRE deberá cargar los recursos asociados a los
nodos de la escena. A lo largo del curso veremos cómo especificar manualmente
más propiedades a este archivo.
Nombres en entidades Una vez definida la estructura de directorios y los archivos que forman el ejemplo,
estudiaremos la docena de líneas de código que tiene nuestro Hola Mundo.
Si no indicamos ningún nombre,
OGRE elegirá automáticamente un
nombre para la misma. El nombre Listado 8.2: El main.cpp del Hola Mundo en OGRE
debe ser único. Si el nombre existe,
OGRE devolverá una excepción. 1 #include <ExampleApplication.h>
2
3 class SimpleExample : public ExampleApplication {
[292] CAPÍTULO 8. FUNDAMENTOS DE GRÁFICOS TRIDIMENSIONALES
C8
a b c
d e f
g h i
j k l m
Figura 8.22: Trabajos finales de los alumnos del Curso de Experto en Desarrollo de Videojuegos en las
Ediciones 2011/2012 y 2012/2013, que utilizaban OGRE como motor de representación gráfica. a) Vault
Defense (D. Frutos, J. López, D. Palomares), b) Robocorps (E. Aguilar, J. Alcázar, R. Pozuelo, M. Romero),
c) Shadow City (L.M. García-Muñoz, D. Martín-Lara, E. Monroy), d) Kartoon (R. García-Reina, E. Prieto,
J.M. Sánchez), e) Ransom (M. Blanco, D. Moreno), f) Crazy Tennis (J. Angulo), g) Camel Race (F.J. Cortés),
h) 4 Season Mini Golf (A. Blanco, J. Solana), i) Blood & Metal (J. García, F. Gaspar, F. Triviño), j) God
Mode On (J.M. Romero), k) Another Far West (R.C. Díaz, J.M. García, A. Suarez), l) Impulse (I. Cuesta,
S. Fernández) y m) Música Maestro (P. Santos, C. de la Torre).
Matemáticas para Videojuegos
Capítulo 9
Carlos González Morcillo
E
n este capítulo comenzaremos a estudiar las transformaciones afines básicas
que serán necesarias para en el desarrollo de Videojuegos. Las transformacio-
nes son herramientas imprescindibles para cualquier aplicación que manipule
geometría o, en general, objetos descritos en el espacio 3D. La mayoría de APIs y
motores gráficos que trabajan con gráficos 3D (como OGRE) implementan clases au-
xiliares para facilitar el trabajo con estos tipos de datos.
295
[296] CAPÍTULO 9. MATEMÁTICAS PARA VIDEOJUEGOS
9.1.2. Puntos
Un punto puede definirse como una localización en un espacio n-dimensional. Pa- SRU
ra identificar claramente p como un punto, decimos que p ∈ E2 que significa que un
punto 2D vive en el espacio euclídeo E2 . En el caso de los videojuegos, este espa- Figura 9.2: Sistemas de referencia
cio suele ser bidimensional o tridimensional. El tratamiento discreto de los valores Global (Sistema de Referencia Uni-
asociados a la posición de los puntos en el espacio exige elegir el tamaño mínimo y versal o SRU) y Local. Si describi-
máximo que tendrán los objetos en nuestro videojuego. Es crítico definir el convenio mos la posición del tesoro con res-
pecto del sistema de coordenadas
empleado relativo al sistema de coordenadas y las unidades entre programadores y local del barco, el tesoro siempre se
artistas. Existen diferentes sistemas de coordenadas en los que pueden especificarse moverá con el barco y será imposi-
estas posiciones, como se puede ver en la Figura 9.4: ble volver a localizarlo.
Mano
Coordenadas en OGRE. OGRE utiliza el convenio de la mano derecha. El Derecha
pulgar (eje positivo X) y el índice (eje positivo Y ) definen el plano de la
pantalla. El pulgar apunta hacia la derecha de la pantalla, y el índice hacia el
“techo”. El anular define el eje positivo de las Z, saliendo de la pantalla hacia z x
el observador.
Figura 9.3: Convenios para esta-
blecer los sistemas de coordenadas
cartesianas.
9.1. Puntos, Vectores y Coordenadas [297]
y h
py
ph
p
p pr pθ p
θ pr pФ
pz pθ
px Φ
z x θ
r
r
Coordenadas Coordenadas Coordenadas
Cartesianas Cilíndricas Esféricas
C9
Figura 9.4: Representación de un punto empleando diferentes sistemas de coordenadas.
9.1.3. Vectores
Un vector es una tupla n-dimensional que tiene una longitud (denominada mó-
dulo), una dirección y un sentido. Puede ser representado mediante una flecha. Dos
vectores son iguales si tienen la misma longitud, dirección y sentido. Los vectores son
entidades libres que no están ancladas a ninguna posición en el espacio. Para dife-
renciar un vector v bidimensional de un punto p, decimos que v ∈ R2 ; es decir, que
“vive” en un espacio lineal R2 .
Convenio en OGRE Como en algún momento es necesario representar los vectores como tuplas de
En OGRE se utilizan las clases
números en nuestros programas, en muchas bibliotecas se emplea la misma clase
Vector2 y Vector3 para tra- Vector para representar puntos y vectores en el espacio. Es imprescindible que el
bajar indistintamente con puntos y programador distinga en cada momento con qué tipo de entidad está trabajando.
vectores en 2 y 3 dimensiones.
A continuación describiremos brevemente algunas de las operaciones habituales
realizadas con vectores.
También puede ser calculado mediante la siguiente expresión que relaciona el án- Figura 9.6: La proyección de a so-
gulo θ que forman ambos vectores. bre b obtiene como resultado un es-
calar.
a · b = |a| |b| cos(θ) (9.2)
Combinando las expresiones 9.1 y 9.2, puede calcularse el ángulo que forman dos
vectores (operación muy utilizada en informática gráfica).
9.1. Puntos, Vectores y Coordenadas [299]
Otra operación muy útil es el cálculo de la proyección de un vector sobre otro, que
se define a → b como la longitud del vector a que se proyecta mediante un ángulo
recto sobre el vector b (ver Figura 9.6).
a·b
a → b = |a| cos(θ) =
|b|
El producto escalar se emplea además para detectar si dos vectores tienen la mis-
ma dirección, o si son perpendiculares, o si tienen la misma dirección o direcciones
opuestas (para, por ejemplo, eliminar geometría que no debe ser representada).
a
Colineal. Dos vectores son colineales si se encuentran definidos en la misma
C9
a·b>0 línea recta. Si normalizamos ambos vectores, y aplicamos el producto escalar
podemos saber si son colineales con el mismo sentido (a · b = 1) o sentido
opuesto (a · b = −1).
b
a Perpendicular. Dos vectores son perpendiculares si el ángulo que forman entre
ellos es 90 (o 270), por lo que a · b = 0.
Misma dirección. Si el ángulo que forman entre ambos vectores está entre 270
a·b=0 y 90 grados, por lo que a · b > 0 (ver Figura 9.7).
b Dirección Opuesta. Si el ángulo que forman entre ambos vectores es mayor que
a 90 grados y menor que 270, por lo que a · b < 0.
El sentido del vector resultado del producto escalar sigue la regla de la mano de-
recha (ver Figura 9.8): si agarramos a × b con la palma de la mano, el pulgar apunta
en el sentido positivo del vector producto si el giro del resto de los dedos va de a a b.
En caso contrario, el sentido del vector es el inverso. Por tanto, el producto vectorial
axb no es conmutativo.
El módulo del producto vectorial está directamente relacionado con el ángulo que
forman ambos vectores θ. Además, el módulo del producto vectorial de dos vectores
a es igual al área del paralelogramo formado por ambos vectores (ver Figura 9.8).
θ b
|a × b| = |a| |b| sinθ
De igual modo podemos expresar una rotación de un punto p = (x, y) a una nueva x
posición rotando un ángulo θ respecto del origen de coordenadas, especificando el eje
de rotación y un ángulo θ. Las coordenadas iniciales del punto se pueden expresar Figura 9.9: Arriba. Traslación de
un punto p a p′ empleando el vec-
como (ver Figura 9.10): tor t. Abajo. Es posible trasladar un
objeto poligonal completo aplican-
px = d cosα py = d senα (9.4) do la traslación a todos sus vértices.
C9
9.2.1. Representación Matricial
Figura 9.11: Conversión de un cua- En muchas aplicaciones gráficas los objetos deben transformarse geométricamente
drado a un rectángulo emplean-
do los factores de escala Sx =
de forma constante (por ejemplo, en el caso de una animación, en la que en cada frame
2, Sy = 1,5. el objeto debe cambiar de posición. En el ámbito de los videojuegos, es habitual que
aunque un objeto permanezca inmóvil, es necesario cambiar la posición de la cámara
virtual para que se ajuste a la interacción con el jugador.
Homogeneízate!!
De este modo resulta crítica la eficiencia en la realización de estas transforma-
Gracias al uso de coordenadas ho- ciones. Como hemos visto en la sección anterior, las ecuaciones 9.3, 9.5 y 9.6 nos
mogéneas es posible representar las
ecuaciones de transformación geo- describían las operaciones de traslación, rotación y escalado. Para la primera es nece-
métricas como multiplicación de sario realizar una suma, mientras que las dos últimas requieren multiplicaciones. Sería
matrices, que es el método están- conveniente poder combinar las transformaciones de forma que la posición final de
dar en gráficos por computador (so-
portado en hardware por las tarjetas
las coordenadas de cada punto se obtenga de forma directa a partir de las coordenadas
aceleradoras gráficas). iniciales.
Si reformulamos la escritura de estas ecuaciones para que todas las operaciones se
realicen multiplicando, podríamos conseguir homogeneizar estas transformaciones.
Si añadimos un término extra (parámetro homogéneo h) a la representación del
punto en el espacio (x, y), obtendremos la representación homogénea de la posición
descrita como (xh , yh , h). Este parámetro homogéneo h es un valor distinto de cero tal
que x = xh /h, y = yh /h. Existen, por tanto infinitas representaciones homogéneas
equivalentes de cada par de coordenadas, aunque se utiliza normalmente h = 1. Como
veremos en la sección 9.3, no siempre el parámetro h es igual a uno.
Puntos y Vectores De este modo, la operación de traslación, que hemos visto anteriormente, puede
expresarse de forma matricial como:
En el caso de puntos, la componen-
te homogénea h = 1. Los vectores
emplean como parámetro h = 0. x′ 1 0 Tx x
y ′ = 0 1 Ty y (9.7)
1 0 0 1 1
y
Al resolver la multiplicación matricial se obtienen un conjunto de ecuaciones equi-
valentes a las enunciadas en 9.3. De forma análoga, las operaciones de rotación Tr y
escalado Ts tienen su equivalente matricial homogéneo.
x cosθ −senθ 0 Sx 0 0
z Tr = senθ cosθ 0 Ts = 0 Sy 0 (9.8)
0 0 1 0 0 1
Figura 9.12: Sentido de las rotacio- Las transformaciones inversas pueden realizarse sencillamente cambiando el signo en
nes positivas respecto de cada eje de el caso de la traslación y rotación (distancias y ángulos negativos), y en el caso de la
coordenadas. escala, utilizando los valores 1/Sx y 1/Sy .
[302] CAPÍTULO 9. MATEMÁTICAS PARA VIDEOJUEGOS
Las rotaciones requieren distinguir el eje sobre el que se realizará la rotación. Las
rotaciones positivas alrededor de un eje se realizan en sentido opuesto a las agujas
del reloj, cuando se está mirando a lo largo de la mitad positiva del eje hacia el origen
del sistema de coordenadas (ver Figura 9.12).
Las expresiones matriciales de las rotaciones son las siguientes:
1 0 0 0 cosθ 0 senθ 0 cosθ −senθ 0 0
0 cosθ −senθ 0 0 1 0 0 senθ cosθ 0 0
Rx = 0 senθ cosθ 0 Ry = −senθ 0 cosθ 0 Rz = 0 0 1 0 (9.10)
0 0 0 1 0 0 0 1 0 0 0 1
Cuadro 9.1: Propiedades geométricas preservadas según la clase de transformación: Transformaciones Esca-
C. Ríg.
afines, Lineales y de Cuerpo Rígido. lado Traslación
Rotación
Propiedad T. Afines T. Lineales T. Cuerpo Rígido
Ángulos No No Sí
Figura 9.13: Esquema de subclases
Distancias No No Sí de transformaciones afines y opera-
Ratios de distancias Sí Sí Sí ciones asociadas.
Líneas paralelas Sí Sí Sí
Líneas rectas Sí Sí Sí
9.2. Transformaciones Geométricas [303]
α
x x x x
C9
Figura 9.14: Secuencia de operaciones necesarias para rotar una figura respecto de un origen arbitrario.
Matrices Inversas Tanto la matriz A como su inversa deben ser cuadradas y del mismo tamaño. Otra
No todas las matrices tienen inversa
propiedad interesante de la inversa es que la inversa de la inversa de una matriz es
(incluso siendo cuadradas). Un caso igual a la matriz original (A−1 )−1 = A.
muy simple es una matriz cuadrada
cuyos elementos son cero. En la ecuación inicial, podemos resolver el sistema utilizando la matriz inversa.
Si partimos de A = B · x, podemos multiplicar ambos términos a la izquierda por la
inversa de B, teniendo B −1 · A = B −1 · B · x, de forma que obtenemos la matriz
identidad B −1 · A = I · x, con el resultado final de B −1 · A = x.
En algunos casos el cálculo de la matriz inversa es directo, y puede obtenerse de
forma intuitiva. Por ejemplo, en el caso de una traslación pura (ver ecuación 9.9),
basta con emplear como factor de traslación el mismo valor en negativo. En el caso de
escalado, como hemos visto bastará con utilizar 1/S como factor de escala.
Cuando se trabaja con matrices compuestas, el cálculo de la inversa tiene que
realizarse con métodos generales, como por ejemplo el método de eliminación de
Gauss o la traspuesta de la matriz adjunta.
9.2.3. Composición
Como hemos visto en la sección anterior, una de las principales ventajas derivadas
del trabajo con sistemas homogéneos es la composición de matrices. Matemática-
mente esta composición se realiza multiplicando las matrices en un orden determina-
do, de forma que es posible obtener la denominada matriz de transformación neta
MN resultante de realizar sucesivas transformaciones a los puntos. De este modo,
bastará con multiplicar la MN a cada punto del modelo para obtener directamente su
posición final. Por ejemplo, si P es el punto original y P ′ es el punto transformado, y
T1 · · · Tn son transformaciones (rotaciones, escalados, traslaciones) que se aplican al
[304] CAPÍTULO 9. MATEMÁTICAS PARA VIDEOJUEGOS
y y y
Rz(π/4) Sx(2)
x x x
C9
y y y
Sx(2) Rz(π/4)
x x x
Figura 9.17: La multiplicación de matrices no es conmutativa, por lo que el orden de aplicación de las
transformaciones es relevante para el resultado final. Por ejemplo, la figura de arriba primero aplica una
rotación y luego el escalado, mientras que la secuencia inferior aplica las transformaciones en orden inverso.
1 0 0 0 px px −d px /pz
′ 0 1 0 0 py py −d py /pz
p = Mp p = 0 0 1 0 pz = pz = −d (9.13)
0 0 −1/d 0 1 −pz /d 1
Definición near y far El último paso de la ecuación 9.13 corresponde con la normalización de los com-
ponentes dividiendo por el parámetro homogéneo h = −pz /d. De esta forma tenemos
Un error común suele ser que la de- la matriz de proyección que nos aplasta los vértices de la geometría sobre el plano de
finición correcta de los planos near
y far únicamente sirve para limitar proyección. Desafortunadamente esta operación no puede deshacerse (no tiene inver-
la geometría que será representada sa). La geometría una vez aplastada ha perdido la información sobre su componente
en el Frustum. La definición correc- de profundidad. Es interesante obtener una trasnformación en perspectiva que proyec-
ta de estos parámetros es impres- te los vértices sobre el cubo unitario descrito previamente (y que sí puede deshacerse).
cindible para evitar errores de pre-
cisión en el Z-Buffer. De esta forma, definimos la pirámide de visualización o frustum, como la pirámide
truncada por un plano paralelo a la base que define los objetos de la escena que serán
representados. Esta pirámide de visualización queda definida por cuatro vértices que
definen el plano de proyección (left l, right r, top t y bottom b), y dos distancias a los
planos de recorte (near n y far f ), como se representa en la Figura 9.18. El ángulo de
visión de la cámara viene determinado por el ángulo que forman l y r (en horizontal)
y entre t y b (en vertical).
[306] CAPÍTULO 9. MATEMÁTICAS PARA VIDEOJUEGOS
Y Y
top
left
right
botto Mp
m
near
far
X Z X
Z
La matriz que transforma el frustum en el cubo unitario viene dada por la expresión
de la ecuación 9.14.
2n r+l
r−l 0 − r−l 0
0 2n t+b
− t−b 0
Mp =
0
t−b
f +n n
(9.14)
0 f −n − f2f−n
0 0 1 0
9.4. Cuaternios
Los cuaternios (quaternion) fueron propuestos en 1843 por William R. Hamilton
como extensión de los números complejos. Los cuaternios se representan mediante
una 4-tupla q = [qx , qy , qz , qw ]. El término qw puede verse como un término escalar
que se añade a los tres términos que definen un vector (qx , qy , qz ). Así, es común
representar el cuaternio como q = [qv , qs ] siendo qv = (qx , qy , qz ) y qs = qw en la
tupla de cuatro elementos inicial.
Los cuaternios unitarios, que cumplen la restricción de que (qx2 +qy2 +qz2 +qw
2
) = 1,
se emplean ampliamente para representar rotaciones en el espacio 3D. El conjunto de
todos los cuaternios unitarios definen la hiperesfera unitaria en R4 .
Si definimos como a al vector unitario que define el eje de rotación, θ como el
ángulo de rotación sobre ese eje empleando la regla de la mano derecha (si el pulgar
apunta en la dirección de a, las rotaciones positivas se definen siguiendo la dirección
Figura 9.19: Representación de un
de los dedos curvados de la mano), podemos describir el cuaternio como (ver Figu-
cuaternio unitario.
ra 9.19):
Teorema de Euler
q = [qv , qs ] = [a sin(θ/2), cos(θ/2)]
Según el Teorema de Rotación de
Leonhard Euler (1776), cualquier
De forma que la parte vectorial se calcula escalando el vector de dirección uni- composición de rotaciones sobre un
tario por el seno de θ/2, y la parte escalar como el coseno de θ/2. Obviamente, esta sólido rígido es equivalente a una
multiplicación en la parte vectorial se realiza componente a componente. sola rotación sobre un eje, llamado
Polo de Euler.
9.4. Cuaternios [307]
z z z z
a) b) c) d)
x x x x
y y y y
Figura 9.20: El problema del bloqueo de ejes (Gimbal Lock). En a) se muestra el convenio de sistema de
C9
coordenadas que utilizaremos, junto con las elipses de rotación asociadas a cada eje. En b) aplicamos una
rotación respecto de x, y se muestra el resultado en el objeto. El resultado de aplicar una rotación de 90o
en y se muestra en c). En esta nueva posición cabe destacar que, por el orden elegido de aplicación de las
rotaciones, ahora el eje x y el eje z están alineados, perdiendo un grado de libertad en la rotación. En d) se
muestra cómo una rotación en z tiene el mismo efecto que una rotación en x, debido al efecto de Gimbal
Lock.
Como vimos en la sección 9.2.1, se puede usar una matriz para resumir cualquier
conjunto de rotaciones en 3D. Sin embargo, las matrices no son la mejor forma de
representar rotaciones, debido a:
q −1 = q ∗ = [−qv , qs ]
v ′ = q2 q1 v q1−1 q2−1
9.5. Interpolación Lineal y Esférica [309]
C9
polación. Un claro ejemplo de su uso es para calcular la posición intermedia en una
animación de un objeto que se traslada desde un punto A, hasta un punto B.
6 B La interpolación lineal es un método para calcular valores intermedios mediante
5 polinomios lineales. Es una de las formas más simples de interpolación. Habitual-
4 v mente se emplea el acrónimo LERP para referirse a este tipo de interpolación. Para
3 A obtener valores intermedios entre dos puntos A y B, habitualmente se calcula el vec-
2 tor v = B − A con origen en A y destino en B.
1
1 2 3 4 5 6 7 z SLERP (p,q, t=0.5) cuaternio q
(0.816, 0, 0.408, 0.408) (0.707, 0, 0.707, 0)
Figura 9.22: Ejemplo de interpola-
ción lineal entre dos puntos. Se han
z
marcado sobre el vector v los pun- z
tos correspondientes a t = 0, 25, y
t = 0,5 y t = 0,75.
y
y
x
cuaternio p x x
(0.707, 0, 0, 0.707)
Figura 9.23: Ejemplo de aplicación de interpolación esférica SLERP en la rotación de dos cuaternios p y q
sobre un objeto.
Como podemos ver en el ejemplo de la Figura 9.22, para t = 0,25 tenemos que
v = (6, 4), por lo que v · 0,25 = (1,5, 1). Así, LERP (A, B, 0,25) = (1, 2) +
(1,5, 1) = (2,5, 3).
Como se enunció en la sección 9.4, una de las ventajas del uso de cuaternios es
la relativa a la facilidad de realizar interpolación entre ellos. La Interpolación Lineal
Esférica (también llamada slerp de Spherical Linear Interpolation) es una operación
que, dados dos cuaternios y un parámetro t ∈ (0, 1), calcula un cuaternio interpolado
entre ambos, mediante la siguiente expresión1 :
sin((1 − t)θ) sin(tθ)
SLERP (p, q, t) = p+ q
sin(θ) sin(θ)
1 Siendo θ el ángulo que forman los dos cuaternios unitarios.
[310] CAPÍTULO 9. MATEMÁTICAS PARA VIDEOJUEGOS
Modulo: |V1| = 1
Normalizar: V2n = Vector3(0, 1, 0)
Angulo entre V1 y V2 = 90
--- Algunos operadores de Cuaternios ----
Suma: P + Q = Quaternion(1.41421, 0, 0.707107, 0.707107)
Producto: P * Q = Quaternion(0.5, -0.5, 0.5, 0.5)
Producto escalar: P * Q = 0.5
SLERP(p,q,0.5) = Quaternion(0.816497, 0, 0.408248, 0.408248)
C9
A' y después una rotación de π/2 radianes respecto del eje Z. Las transformaciones
C' se realizan en ese orden y sobre el SRU.
B 2. Dado el cuadrado definido por los puntos A=(1,0,3), B=(3,0,3), C=(3,0,1) y
A D=(1,0,1), realizar un escalado del doble en el eje X respecto a su centro geo-
métrico. Compruebe que el resultado no es el mismo que si realiza el escalado
C directamente con respecto del eje X del SRU.
Figura 9.24: En el ejercicio 4, tras 3. ¿Cuáles serían las expresiones matriciales correspondientes a la operación de
aplicar el desplazamiento de 2 uni- reflexión (espejo) sobre los planos ortogonales x=0, y=0 y z=0? Calcule una
dades en la dirección del vector nor- matriz por cada plano.
mal sobre los vértices originales del
triángulo, obtenemos A′ B ′ C ′ . 4. Dado el triángulo ABC, siendo A=(1, -1, 1), B=(-1, -1, -1) y C=(1, 1, -1), despla-
ce la cara poligonal 2 unidades en la dirección de su vector normal (ver Figura
9.24). Suponga que el vector normal sale según la regla de la mano derecha.
Capítulo
Grafos de Escena
10
Carlos González Morcillo
C
omo vimos en el capítulo 8, uno de los pilares clave de cualquier motor gráfico
es la organización de los elementos que se representarán en la escena. Esta
gestión se realiza mediante el denominado Grafo de Escena que debe permitir
inserciones, búsquedas y métodos de ordenación eficientes. OGRE proporciona una
potente aproximación a la gestión de los Grafos de Escena. En este capítulo trabaja-
remos con los aspectos fundamentales de esta estructura de datos jerárquica.
10.1. Justificación
Como señala D. Eberly [30], el motor de despliegue gráfico debe dar soporte a
cuatro características fundamentales, que se apoyan en el Grafo de Escena:
Test de Visibilidad
1. Gestión Datos Eficiente. Es necesario eliminar toda la geometría posible antes
La gestión de la visibilidad de los
elementos de la escena es una de las de enviarla a la GPU. Aunque la definición del Frustum y la etapa de recor-
tareas típicas que se encarga de rea- te eliminan la geometría que no es visible, ambas etapas requieren tiempo de
lizar el motor gráfico empleando el procesamiento. Es posible utilizar el conocimiento sobre el tipo de escena a re-
Grafo de Escena. presentar para optimizar el envío de estas entidades a la GPU. Las relaciones
entre los objetos y sus atributos se modelan empleando el Grafo de Escena.
2. Interfaz de Alto Nivel. El Grafo de Escena puede igualmente verse como un
interfaz de alto nivel que se encarga de alimentar al motor gráfico de bajo nivel
empleando alguna API espećifica. El diseño de este interfaz de alto nivel per-
mite abstraernos de futuros cambios necesarios en las capas dependientes del
dispositivo de visualización.
313
[314] CAPÍTULO 10. GRAFOS DE ESCENA
Las estructuras de datos de Grafos de Escena suelen ser grafos que representen
las relaciones jerárquicas en el despliegue de objetos compuestos. Esta dependencia
espacial se modela empleando nodos que representan agregaciones de elementos (2D
o 3D), así como transformaciones, procesos y otras entidades representables (sonidos,
vídeos, etc...).
El Grafo de Escena puede definirse como un grafo dirigido sin ciclos. Los arcos
del grafo definen dependencias del nodo hijo respecto del padre, de modo que la apli-
cación de una transformación en un nodo padre hace que se aplique a todos los nodos
hijo del grafo.
El nodo raíz habitualmente es un nodo abstracto que proporciona un punto de
inicio conocido para acceder a la escena. Gracias al grafo, es posible especificar fá-
cilmente el movimiento de escenas complejas de forma relativa a los elementos padre Cabina
de la jerarquía. En la Figura 10.1, el movimento que se aplique a la Tierra (rotación, Base Llantas
traslación, escalado) se aplicará a todos los objetos que dependan de ella (como por
ejemplo a las Tiritas, o las Llantas de la exacavadora). A su vez, las modificaciones Codo
aplicadas sobre la Cabina se aplicarán a todos los nodos que herenden de la jerarquía,
pero no al nodo Llantas o al nodo Tierra.
Pala
Tierra
10.1.1. Operaciones a Nivel de Nodo
Como señala Theoharis et al. [98], en términos funcionales, la principal ventaja
asociada a la construcción de un Grafo de Escena es que cada operación se propaga de
forma jerárquica al resto de entidades mediante el recorrido del grafo. Las principales Escena
operaciones que se realizan en cada nodo son la inicialización, simulación, culling y
dibujado. A continuación estudiaremos qué realiza cada operación.
Tierra Cielo
Inicialización. Este operador establece los valores iniciales asociados a cada
entidad de la jerarquía. Es habitual que existan diferentes instancias referencia- Tiritas Llantas
das de las entidades asociadas a un nodo. De este modo se separan los datos
estáticos asociados a las entidades (definición de la geometría, por ejemplo) y
las transformaciones aplicados sobre ellos que se incluyen a nivel de nodo. Cabina
C10
Figura 10.2: Ejemplo de estructura de datos para la gestión del culling jerárquico. En el ejemplo, el nodo
referente a las Llantas (ver Figura 10.1) debe mantener la información de la caja límite (Bounding Box) de
todos los hijos de la jerarquía, remarcada en la imagen de la derecha.
Visibilidad
10.2.1. Creación de Objetos Si quieres que un nodo no se dibuje,
simplemente ejecutas la operación
de detach y no será renderizado.
La tarea más ampliamente utilizada del Gestor de Escenas es la creación de los
objetos que formarán la escena: luces, cámaras, sistemas de partículas, etc. Cualquier Destrucción de nodos
elemento que forme parte de la escena será gestionado por el Gestor de Escenas, y
La destrucción de un nodo de la es-
formará parte del Grafo de Escena. Esta gestión está directamente relacionada con el cena no implica la liberación de la
ciclo de vida completa de los objetos, desde su creación hasta su destrucción. memoria asignada a los objetos ad-
juntos al nodo. Es responsabilidad
Los Nodos del Grafo de Escena se crean empleando el Gestor de Escena. Los del programador liberar la memoria
nodos del grafo en OGRE tienen asignado un único nodo padre. Cada nodo padre de esos objetos.
puede tener cero o más hijos. Los nodos pueden adjuntarse (attach) o separarse (de-
tach) del grafo en tiempo de ejecución. El nodo no se destruirá hasta que se le indique
explícitamente al Gestor de Escenas.
En la inicialización, el Gestor de Escena se encarga de crear al menos un nodo:
el Root Node. Este nodo no tiene padre, y es el padre de toda la jerarquía. Aunque
es posible aplicar transformaciones a cualquier nodo de la escena (como veremos en
la sección 10.2.2), al nodo Root no se le suele aplicar ninguna transformación y es
un buen punto en el que adjuntar toda la geometría estática de la escena. Dado el
objeto SceneManager, una llamada al método getRootSceneNode() nos devuelve
un puntero al Root SceneNode. Este nodo es, en realidad, una variable miembro de la
clase SceneManager.
Los objetos de la escena se pueden adjuntar a cualquier nodo de la misma. En
un nodo pueden existir varios objetos. Sin embargo, no es posible adjuntar la misma
instancia de un objeto a varios nodos de escena al mismo tiempo. Para realizar es-
ta operación primero hay que separar (detach) el objeto del nodo, y posteriormente
adjuntarlo (attach) a otro nodo distinto.
Para crear un nodo en OGRE, se utiliza el método createSceneNode, que puede
recibir como parámetro el nombre del nodo. Si no se especifica, OGRE internamente
le asigna un nombre único que podrá ser accedido posteriormente.
Root
MovableObject
Hijo1 Hijo2
SimpleRenderable Entity Light AnimableObject ParticleSystem
Hijo11 Hijo12
Rectangle2D WireBoundingBox Frustum ManualObject
Hijo121
Camera
Figura 10.3: Ejemplo de grafo de
Figura 10.4: Algunos de los tipos de objetos que pueden ser añadidos a un nodo en OGRE. La clase escena válido en OGRE. Todos los
abstracta MovableObject define pequeños objetos que serán añadidos (attach) al nodo de la escena. nodos (salvo el Root) tienen un no-
do padre. Cada nodo padre tiene ce-
ro o más hijos.
10.2. El Gestor de Escenas de OGRE [317]
C10
de entidades se realiza empleando el método createEntity del Gestor de Escena,
indicando el nombre del modelo y el nombre que quiere asignarse a la entidad.
✄ Mundo
El código del siguiente listado (modificación del Hola del capítulo 8) crea
✂ ✁
Root
✄
un nodo hijo myNode de RootSceneNode en las líneas , y añade la entidad myEnt
al nodo myChild en la línea ✂6 ✁(ver Figura 10.5). A partir de ese punto del códi-
3-4
myNode go, cualquier transformación que se aplique sobre el nodo myNode se aplicarán a la
entidad myEnt.
myEnt
Listado 10.1: Creación de nodos
1 class SimpleExample : public ExampleApplication {
Figura 10.5: Jerarquía obtenida en 2 public : void createScene() {
el grafo de escena asociado al lista- 3 SceneNode* node = mSceneMgr->createSceneNode("myNode");
do de ejemplo. 4 mSceneMgr->getRootSceneNode()->addChild(node);
5 Entity *myEnt = mSceneMgr->createEntity("cuboejes", "cuboejes.
mesh");
6 node->attachObject(myEnt);
7 }
8 };
10.2.2. Transformaciones 3D
Las transformaciones 3D se realizan a nivel de nodo de escena, no a nivel de
objetos. En realidad en OGRE, los elementos que se mueven son los nodos, no los
objetos individuales que han sido añadidos a los nodos.
Como vimos en la sección 9.1, OGRE utiliza el convenio de la mano derecha para
especificar su sistema de coordenadas. Las rotaciones positivas se realizan en contra
del sentido de giro de las agujas del reloj (ver Figura 9.12).
Las transformaciones en el Grafo de Escena de OGRE siguen el convenio general
explicado anteriormente en el capítulo, de forma que se especifican de forma relativa
al nodo padre.
Traslación La traslación absoluta se realiza mediante la llamada al método setPosition,
La traslación de un nodo que ya
que admite un Vector3 o tres argumentos de tipo Real. Esta traslación se realiza de
ha sido posicionado anteriormen- forma relativa al nodo padre de la jerarquía. Para recuperar la traslación relativa de un
te se realiza mediante la llamada a nodo con respecto a su nodo padre se puede emplear la llamada getPosition.
translate.
[318] CAPÍTULO 10. GRAFOS DE ESCENA
y y
y Root
x
z x x node1 node2
node2 z node1
z node3 ent1 ent2
y
node3 node4
ent3 ent4
x nod
a) e4
b) c)
Figura 10.6: Ejemplo de transformaciones empleando el Grafo de Escena de OGRE. a) Resultado de eje-
cución del ejemplo. b) Representación de los nodos asociados a la escena. c) Jerarquía del grafo de escena.
✄ a radianes
En✄el listado anterior se utilizan las funciones de utilidad para convertir
(línea ✂7 ✁), así como algunas constantes matemáticas (como π en la línea ✂8 ✁).
Posición de Root Como se observa en la Figura 10.6, se han creado 4 nodos. La transformación in-
cial aplicada al node1 es heredada por los nodos 3 y 4. De este modo, basta con aplicar
Aunque es posible aplicar transfor-
maciones al nodo Root, se desacon- una traslación de 5 unidades en el eje x al nodo 3 para obtener el resultado mostrado
seja su uso. El nodo Root debe man- en la Figura 10.6.b). Esta traslación es relativa al sistema de referencia transformado
tenerse estático, como punto de re- del nodo padre. No ocurre lo mismo con el nodo 2, cuya posición se especifica de
ferencia del SRU. nuevo desde el origen del sistema de referencia universal (del nodo Root).
C10
10.2.3. Espacios de transformación
Las transformaciones estudiadas anteriormente pueden definirse relativas a dife-
rentes espacios de transformación. Muchos de los métodos explicados en la sección
anterior admiten un parámetro opcional de tipo TransformSpace que indica el es-
pacio de transformación relativo de la operación. OGRE define tres espacios de trasn-
formación como un tipo enumerado Node::TransformSpace:
a) b) y y
x
z
node1 x node2
y
x no d
e3
Figura 10.7: Ejemplo de trabajo con diferentes espacios de transformación. a) Resultado de ejecución del
ejemplo. b) Distribución de los nodos del Grafo de Escena.
E
n este capítulo se realizará un análisis de los recursos que son necesarios en
los gráficos 3D, centrándose sobre todo en los formatos de especificación y
requisitos de almacenamiento. Además, se analizarán diversos casos de estudio
de formatos populares como MD2, MD3, MD5, Collada y se hará especial hincapié
en el formato de Ogre 3D.
11.1.1. Introducción
En la actualidad, el desarrollo de videojuegos de última generación implica la ma-
nipulación simultánea de múltiples ficheros de diversa naturaleza, como son imágenes
estáticas (BMP (BitMaP), JPG, TGA (Truevision Graphics Adapter), PNG, ...), fiche-
ros de sonido (WAV (WAVeform), OGG, MP3 (MPEG-2 Audio Layer III)), mallas que
representan la geometría de los objetos virtuales, o secuencias de vídeo (AVI (Audio
Video Interleave), BIK (BINK Video), etc). Una cuestión relevante es cómo se cargan
estos ficheros y qué recursos son necesarios para su reproducción, sobre todo teniendo
en cuenta que la reproducción debe realizarse en tiempo real, sin producir ningún tipo
de demora que acabaría con la paciencia del usuario.
321
[322] CAPÍTULO 11. RECURSOS GRÁFICOS Y SISTEMA DE ARCHIVOS
Figura 11.1: Flujo de datos desde los archivos de recursos hasta los subsistemas que se encargan de la
reproducción de los contenidos [62].
11.1. Formatos de Especificación [323]
Normalmente estos archivos están asociados a un nivel del juego. En cada uno de
estos niveles se definen una serie de entornos, objetos, personajes, objetivos, eventos,
etc. Al cambiar de nivel, suele aparecer en la pantalla un mensaje de carga y el usuario
debe esperar; el sistema lo que está haciendo en realidad es cargar el contenido de
estos ficheros que empaquetan diversos recursos.
Cada uno de los archivos empaquetados se debe convertir en un formato adecuado
que ocupe el menor número de recursos posible. Estas conversiones dependen en la
mayoría de los casos de la plataforma hardware en la que se ejecuta el juego. Por
ejemplo, las plataformas PS3 y Xbox360 presentan formatos distintos para el sonido
y las texturas de los objetos 3D.
En la siguiente sección se verá con mayor grado de detalle los recursos gráficos 3D
que son necesarios, centrándonos en los formatos y los requisitos de almacenamiento.
C11
Los juegos actuales con al menos una complejidad media-alta suelen ocupar varios
GigaBytes de memoria. La mayoría de estos juegos constan de un conjunto de ficheros
cerrados con formato privado que encapsulan múltiples contenidos, los cuales están
distribuidos en varios DVDs (4.7 GB por DVD) o en un simple Blue-Ray ( 25GB)
como es en el caso de los juegos vendidos para la plataforma de Sony - PS3. Si tene-
mos algún juego instalado en un PC, tenemos acceso a los directorios de instalación y
podemos hacernos una idea del gran tamaño que ocupan una vez que los juegos han
sido instalados.
Lo que vamos a intentar en las siguientes secciones es hacernos una idea de cómo
estos datos se almacenan, qué formatos utilizan y cómo se pueden comprimir los datos
para obtener el producto final. Por tanto, lo primero que debemos distinguir son los
tipos de ficheros de datos que normalmente se emplean. La siguiente clasificación,
ofrece una visión general [62]:
En las siguientes secciones se describirá con mayor detalle cada uno de los puntos
anteriores.
Objetos y Mallas 3D
Listado 11.1: Representación de los vértices de un cubo con respecto al origen de coordenadas
1 Vec3 TestObject::g_SquashedCubeVerts[] =
2 {
3 Vec3( 0.5,0.5,-0.25), // Vertex 0.
4 Vec3(-0.5,0.5,-0.25), // Vertex 1.
5 Vec3(-0.5,0.5,0.5), // And so on.
6 Vec3(0.75,0.5,0.5),
7 Vec3(0.75,-0.5,-0.5),
8 Vec3(-0.5,-0.5,-0.5),
9 Vec3(-0.5,-0.3,0.5),
10 Vec3(0.5,-0.3,0.5)
11 };
Con un simple cubo es complicado apreciar los beneficios que se pueden obtener
con esta técnica, pero si nos paramos a pensar que un modelo 3D puede tener miles
y miles de triángulos, la optimización es clara. De esta forma es posible almacenar n
triángulos con n+2 índices, en lugar de n*3 vértices como sucede en el primer caso.
11.1. Formatos de Especificación [325]
Datos de Animación
C11
Una animación es en realidad la variación de la posición y la orientación de los
vértices que forman un objeto a lo largo del tiempo. Como se comentó anteriormente,
una forma de representar una posición o vértice en el espacio es mediante tres va-
lores reales asociados a las tres coordenadas espaciales X, Y y Z. Estos números se
representan siguiendo el estándar IEEE (Institute of Electrical and Electronics Engi-
neers)-754 para la representación de números reales en coma flotante mediante 32 bits
(4 bytes). Por tanto, para representar una posición 3D será necesario emplear 12 bytes.
Además de la posición es necesario guardar información relativa a la orientación,
y el tamaño de las estructuras de datos empleadas para ello suele variar entre 12 y 16
bytes, dependiendo del motor de rendering. Existen diferentes formas de representar la
orientación, el método elegido influirá directamente en la cantidad de bytes necesarios.
Dos de los métodos más comunes en el desarrollo de videojuegos son los ángulos de
Euler y el uso de cuaterniones o también llamados cuaternios.
Para hacernos una idea del tamaño que sería necesario en una sencilla animación,
supongamos que la frecuencia de reproducción de frames por segundo es de 25. Por
otro lado, si tenemos en cuenta que son necesarios 12 bytes por vértice más otros
12 (como mínimo) para almacenar la orientación de cada vértice, necesitaríamos por
cada vértice 12 + 12 = 24 bytes por cada frame. Si en un segundo se reproducen 25
frames, 25 x 24 = 600 bytes por cada vértice y cada segundo. Ahora supongamos
que un objeto consta de 40 partes movibles (normalmente las partes movibles de un
personaje las determina el esqueleto y los huesos que lo forman). Si cada parte movible
necesita 600 bytes y existen 40 de ellas, se necesitaría por cada segundo un total de
24.000 bytes.
Naturalmente 24.000 bytes puede ser un tamaño excesivo para un solo segundo
y existen diferentes formas de optimizar el almacenamiento de los datos de anima-
ción, sobre todo teniendo en cuenta que no todas las partes del objeto se mueven en
cada momento y, por tanto, no sería necesario almacenarlas de nuevo. Tampoco es
necesario almacenar la posición de cada parte movible en cada uno de los 25 frames
que se reproducen en un segundo. Una solución elegante es establecer frames claves
y calcular las posiciones intermedias de un vértice desde un frame clave al siguiente
mediante interpolación lineal. Es decir, no es necesario almacenar todas las posiciones
(únicamente la de los frames claves) ya que el resto pueden ser calculadas en tiempo
de ejecución.
[326] CAPÍTULO 11. RECURSOS GRÁFICOS Y SISTEMA DE ARCHIVOS
Otra posible mejora se podría obtener empleando un menor número de bytes para
representar la posición de un vértice. En muchas ocasiones, el desplazamiento o el
cambio de orientación no son exageradamente elevados y no es necesario emplear 12
bytes. Si los valores no son excesivamente elevados se pueden emplear, por ejemplo,
números enteros de 2 bytes. Estas técnicas de compresión pueden reducir considera-
blemente el tamaño en memoria necesario para almacenar los datos de animación.
Mapas/Datos de Nivel
Cada uno de los niveles que forman parte de un juego tiene asociado diferentes
elementos como objetos estáticos 3D, sonido de ambientación, diálogos, entornos,
etc. Normalmente, todos estos contenidos son empaquetados en un fichero binario
que suele ser de formato propietario. Una vez que el juego es instalado en nuestro
equipo es complicado acceder a estos contenidos de manera individual. En cambio,
durante el proceso de desarrollo estos datos se suelen almacenar en otros formatos
que sí son accesibles por los miembros del equipo como, por ejemplo, en formato
XML. El formato XML es un formato accesible, interpretable y permite a personas que
trabajan con diferentes herramientas y en diferentes ámbitos, disponer de un medio
para intercambiar información de forma sencilla.
Texturas
32-bits (8888 RGBA (Red Green Blue Alpha)). Las imágenes se representan
mediante cuatro canales, R(red), G(green), B(blue), A(alpha), y por cada uno de
estos canales se emplean 8 bits. Es la forma menos compacta de representar un
mapa de bits y la que proporciona un mayor abanico de colores. Las imágenes
representadas de esta forma poseen una calidad alta, pero en muchas ocasiones
es innecesaria y otros formatos podrían ser más apropiados considerando que Figura 11.2: Ejemplo de una ima-
hay que ahorrar el mayor número de recursos posibles. gen RGBA con porciones transpa-
rentes (canal alpha).
11.1. Formatos de Especificación [327]
24-bits (888 RGB (Red Green Blue)). Este formato es similar al anterior pero
sin canal alpha, de esta forma se ahorran 8 bits y los 24 restantes se dividen en
partes iguales para los canales R(red), G(green) y B(blue). Este formato se suele
emplear en imágenes de fondo que contienen una gran cantidad de colores que
no podrían ser representados con 16 u 8 bits.
24-bits (565 RGB, 8A). Este formato busca un equilibrio entre los dos anterio-
res. Permite almacenar imágenes con una profundidad de color aceptable y un
rango amplio de colores y, además, proporciona un canal alfa que permite in-
cluir porciones de imágenes traslúcidas. El canal para el color verde tiene un bit
extra debido a que el ojo humano es más sensible a los cambios con este color.
16-bits (565 RGB). Se trata de un formato compacto que permite almacenar
imágenes con diferentes variedades de colores sin canal alfa. El canal para el
color verde también emplea un bit extra al igual que en el formato anterior.
C11
lores rojo, verde y azul.
8-bits indexado. Este formato se suele emplear para representar iconos o imá-
genes que no necesitan alta calidad, debido a que la paleta o el rango de colores
empleado no es demasiado amplio. Sin embargo, son imágenes muy compactas
que ahorran mucho espacio en memoria y se transmiten o procesan rápidamente.
En este caso, al emplearse 8 bits, la paleta sería de 256 colores y se represen-
ta de forma matricial, donde cada color tiene asociado un índice. Precisamente
los índices son los elementos que permiten asociar el color de cada píxel en la
imagen a los colores representados en la paleta de colores.
Figura 11.4: Paleta de 256 colores
con 8 bits
11.1.3. Casos de Estudio
Formato MD2/MD3
El formato MD2 es uno de los más populares por su simplicidad, sencillez de uso
y versatilidad para la creación de modelos (personajes, entornos, armas, efectos, etc).
Fue creado por la compañía id Software y empleado por primera vez en el videojuego
Quake II (ver Figura 11.5).
El formato MD2 representa la geometría de los objetos, texturas e información
sobre la animación de los personajes en un orden muy concreto, tal como muestra la
Figura 11.6 [72]. El primer elemento en la mayoría de formatos 3D es la cabecera. La
cabecera se sitúa al comienzo del fichero y es realmente útil ya que contiene informa-
ción relevante sobre el propio fichero sin la necesidad de tener que buscarla a través
de la gran cantidad de datos que vienen a continuación.
Cabecera
La cabecera del formato MD2 contiene los siguientes campos:
Figura 11.5: Captura del vídeojuego Quake II, desarrollado por la empresa id Software, donde se empleó
por primera vez el formato MD2
10 int m_iNumTriangles;
11 int m_iNumGLCommands;
12 int m_iOffsetSkins;
13 int m_iOffsetTexCoords;
14 int m_iOffsetTriangles;
15 int m_iOffsetFrames;
16 int m_iOffsetGlCommands;
17 int m_iFileSize;
18 int m_iNumFrames;
19
20 };
El tamaño total es de 68 bytes, debido a que cada uno de los enteros se repre-
senta haciendo uso de 4 bytes. A continuación, en el siguiente listado se describirá
brevemente el significado de cada uno de estos campos [72].
C11
Figura 11.6: Jerarquía de capas en un fichero con formato MD2
Las cinco variables siguientes se emplean para definir el offset o dirección relativa
(el número de posiciones de memoria que se suman a una dirección base para obtener
la dirección absoluta) en bytes de cada una de las partes del fichero. Esta información
es fundamental para facilitar los saltos en las distintas partes o secciones del fichero
durante el proceso de carga. Por último, la variable m_iFileSize representa el tamaño
en bytes desde el inicio de la cabecera hasta el final del fichero.
Frames y Vértices
A continuación se muestra la estructura que representa la información que se ma-
neja por frame en el formato MD2 [72]:
Como se puede apreciar en la estructura anterior, cada frame comienza con seis
números representados en punto flotante que representan la escala y traslación de los
vértices en los ejes X, Y y Z. Posteriormente, se define una cadena de 16 caracteres
que determinan el nombre del frame, que puede resultar de gran utilidad si se quiere
hacer referencia a éste en algún momento. Por último se indica la posición de todos los
vértices en el frame (necesario para animar el modelo 3D). En cada uno de los frames
habrá tantos vértices como se indique en la variable de la cabecera m_iNumVerts. En la
última parte de la estructura se puede diferenciar un constructor y destructor, que son
necesarios para reservar y liberar memoria una vez que se ha reproducido el frame, ya
que el número máximo de vértices que permite almacenar el formato MD2 por frame
es 2048.
Si se renderizaran únicamente los vértices de un objeto, en pantalla tan sólo se
verían un conjunto de puntos distribuidos en el espacio. El siguiente paso consistirá
en unir estos puntos para definir los triángulos y dar forma al modelo.
Triangularización
En el formato MD2 los triángulos se representan siguiendo la técnica descrita en
la Sección 11.1.2. Cada triángulo tiene tres vértices y varios triángulos tienen índices
en común [72]. No es necesario representar un vértice más de una vez si se representa
la relación que existe entre ellos mediante el uso de índices en una matriz.
Además, cada triángulo debe tener asociado una textura, o sería más correcto de-
cir, una parte o fragmento de una imagen que representa una textura. Por tanto, es Figura 11.7: Modelado de un ob-
jeto tridimensional mediante el uso
necesario indicar de algún modo las coordenadas de la textura que corresponden con de triángulos.
cada triángulo. Para asociar las coordenadas de una textura a cada triángulo se em-
plean el mismo número de índices, es decir, cada índice de un vértice en el triángulo
tiene asociado un índice en el array de coordenadas de una textura. De esta forma se
puede texturizar cualquier objeto fácilmente.
11.1. Formatos de Especificación [331]
Listado 11.5: Estructura de datos para representar los triángulos y las coordenadas de la tex-
tura
1 struct SMD2Tri {
2 unsigned short m_sVertIndices[3];
3 unsigned short m_sTexIndices[3];
4 };
Inclusión de Texturas
En el formato MD2 existen dos formas de asociar texturas a los objetos. La prime-
ra de ellas consiste en incluir el nombre de las texturas embebidos en el fichero MD2.
El número de ficheros de texturas y la localización, se encuentran en las variables de
la cabecera m_iNumSkins y m_iOffsetSkins. Cada nombre de textura ocupa 64 carac-
C11
teres alfanuméricos como máximo. A continuación se muestra la estructura de datos
empleada para definir una textura [72]:
Listado 11.6: Estructura de datos para definir una textura embebida en un fichero MD2
1 struct SMD2Skin
2 {
3 char m_caSkin[64];
4 CImage m_Image;
5 };
Formato MD5
COLLADA
Ser un formato de código libre, representado en XML para liberar los recursos
digitales de formatos binarios propietarios.
Proporcionar un formato estándar que pueda ser utilizado por una amplia ma-
yoría de herramientas de edición de modelos tridimensionales.
Intentar que sea adoptado por una amplia comunidad de usuarios de contenidos
digitales.
Proveer un mecanismo sencillo de integración, tal que toda la información po-
sible se encuentre disponible en este formato.
Ser la base común de todas las transferencias de datos entre aplicaciones 3D.
C11
COLLADA Core Elements. Donde se define la geometría de los objetos, in-
formación sobre la animación, cámaras, luces, etc.
COLLADA Physics. En este apartado es posible asociar propiedades físicas
a los objetos, con el objetivo de reproducir comportamientos lo más realistas
posibles.
COLLADA FX. Se establecen las propiedades de los materiales asociados a
los objetos e información valiosa para el renderizado de la escena.
No es nuestro objetivo ver de forma detallada cada uno de estos tres bloques, pero
sí se realizará una breve descripción de los principales elementos del núcleo ya que
éstos componen los elementos básicos de una escena, los cuales son comunes en la
mayoría de formatos.
COLLADA Core Elements
En este bloque se define la escena (<scene>) donde transcurre una historia y los
objetos que participan en ella, mediante la definición de la geometría y la animación
de los mismos. Además, se incluye información sobre las cámaras empleadas en la
escena y la iluminación. Un documento con formato COLLADA sólo puede contener
un nodo <scene>, por tanto, será necesario elaborar varios documentos COLLADA
para definir escenas diferentes. La información de la escena se representa mediante un
grafo acíclico y debe estar estructurado de la mejor manera posible para que el pro-
cesamiento sea óptimo. A continuación se muestran los nodos/etiquetas relacionados
con la definición de escenas [68].
Listado 11.10: Nodos utilizados para definir la geometría de un objeto en el formato COLLADA
1 <control_vertices>
2 <geometry>
3 <instance_geometry>
4 <library_geometries>
5 <lines>
6 <linestrips>
7 <mesh>
8 <polygons>
9 <polylist>
10 <spline>
11 <triangles>
12 <trifans>
Luces ambientales
Luces puntuales
C11
Luces direccionales
Puntos de luz
Por otro lado, COLLADA también permite la definición de cámaras. Una cámara
declara una vista de la jerarquía del grafo de la escena y contiene información sobre la
óptica (perspectiva u ortográfica). Los nodos que se utilizan para definir una cámara
son los siguientes [68]:
7 </aspect_ratio>
8 <znear>1.0</znear>
9 <zfar>1000.0</zfar>
10 </perspective>
11 </technique_common>
12 </optics>
13 </camera>
Los tres elementos esenciales en el formato de Ogre son los siguientes [45]:
Para la asignación de los huesos se indican los huesos y sus pesos. Como se puede
observar, el índice utilizado para los huesos empieza por el 0.
C11
Listado 11.17: Asignación de huesos a una malla en OgreXML
1 <boneassignments>
2 <vertexboneassignment vertexindex="0" boneindex="26" weight="
1.000000"/>
3 .............................
4 </boneassignments>
5 </submesh>
Por último, si se han creado animaciones asociadas al esqueleto y han sido exporta-
das, se mostrará cada una de ellas identificándolas con el nombre definido previamente
en Blender y la longitud de la acción medida en segundos. Cada animación contendrá
información sobre los huesos que intervienen en el movimiento (traslación, rotación y
escalado) y el instante de tiempo en el que se ha definido el frame clave:
Otros Formatos
A continuación se incluye una tabla con alguno de los formatos más comunes para
la representación de objetos 3D, su extensión, editores que lo utilizan y un enlace
donde se puede obtener más información sobre el formato (ver Tabla 3.1) [72].
C11
DMO Duke Nukem 3D http://www.3drealms.com
DXF Autodesk Autocad http://www.autodesk.com
HRC Softimage 3D http://www.softimage.com
INC POV-RAY http://www.povray.org
KF2 Animaciones y poses en Max Payne http://www.maxpayne.com
KFS Max Payne: información de la ma- http://www.maxpayne.com
lla y los materiales
LWO Lightwave http://www.newtek.com
MB Maya http://www.aliaswavefront.com
MAX 3D Studio Max http://www.discreet.com
MS3D Milkshape 3D http://www.swissquake.ch
OBJ Alias|Wavefront http://aliaswavefront.com
PZ3 Poser http://www.curioslab.com
RAW Triángulos RAW http://
RDS Ray Dream Studio http://www.metacreations.com
RIB Renderman File http://www.renderman.com
VRLM Lenguaje para el modelado de reali- http://www.web3d.org
dad virtual
X Formato Microsoft Direct X http://www.microsoft.com
XGL Formato que utilizan varios progra- http://www.xglspec.com
mas CAD
Cada uno de los recuadros corresponde a la textura que se aplicará a una de las
caras del cubo en el eje indicado. En la Figura 11.11 se muestra la textura real que se
aplicará al modelo; tal como se puede apreciar está organizada de la misma forma que
se presenta en la Figura 11.10 (cargar la imagen llamada textura512.jpg que se aporta
con el material del curso). Todas las texturas se encuentran en una misma imagen
cuya resolución es 512x512. Los módulos de memoria de las tarjetas gráficas están
11.2. Exportación y Adaptación de Contenidos [341]
C11
Figura 11.11: Imagen con resolución 512x512 que contiene las texturas que serán aplicadas a un cubo
Una vez conocida la textura a aplicar sobre el cubo y cómo está estructurada, se
describirán los pasos necesarios para aplicar las texturas en las caras del cubo mediante
la técnica UV Mapping. En primer lugar ejecutamos Blender y dividimos la pantalla
en dos partes. Para ello situamos el puntero del ratón sobre el marco superior hasta
que el cursor cambie de forma a una flecha doblemente punteada, pulsamos el botón
derecho y elegimos en el menú flotante la opción Split Area.
En el marco de la derecha pulsamos el menú desplegable situado en la esquina
inferior izquierda y elegimos la opción UV/Image Editor. A continuación cargamos la
imagen de la textura (textura512.jpg); para ello pulsamos en el menú Image >Open
y elegimos la imagen que contiene las texturas. Después de realizar estos datos la
apariencia del editor de Blender debe ser similar a la que aparece en la Figura 11.12
En el marco de la izquierda se pueden visualizar las líneas que delimitan el cubo,
coloreadas de color rosa, donde el modo de vista por defecto es Wireframe. Para vi-
sualizar por pantalla las texturas en todo momento elegimos el modo de vista Textured
en el menú situado ✄en el marco
✄ inferior
Viewport Shading. Otra posibilidad consiste
en pulsar las teclas ✂SHIFT ✁+ ✂Z ✁.
[342] CAPÍTULO 11. RECURSOS GRÁFICOS Y SISTEMA DE ARCHIVOS
Figura 11.12: Editor de Blender con una división en dos marcos. En la parte izquierda aparece la figu-
ra geométrica a texturizar y en la parte derecha las texturas que serán aplicadas mediante la técnica de
UV/Mapping
Por otro lado, el cubo situado en la escena tiene asociado un material por defecto,
pero éste no tiene asignada ninguna textura (en el caso de que no tenga un material
asociado, será necesario crear uno). Para ello, nos dirigimos al menú de materiales
situado en la barra de herramientas de la derecha (ver Figura 11.13).
✄
Pulsamos ✂A ✁ para que ninguno de los vértices quede seleccionado y, de forma
manual, seleccionamos los vértices de la cara superior (z+). Para seleccionar los vérti-
✄ existen
ces dos alternativas; la primera es seleccionar uno a uno manteniendo la tecla
✂SHIFT ✁ pulsada y presionar el botón derecho del ratón en cada uno de los vértices.
Una segunda opción es✄ dibujar un área que encuadre los vértices, para ello hay que
pulsar primero la tecla ✂B ✁(Pulsa el botón intermedio del ratón y muévelo para cambiar
la perspectiva y poder asegurar así que se han elegido los vértices correctos).
En la parte derecha, en el editor UV se puede observar un cuadrado con los bordes
de color amarillo y la superficie morada. Este recuadro corresponde con la cara del
cubo seleccionada y con el que se pueden establecer correspondencias con coordena-
das de la textura. El siguiente paso consistirá en adaptar la superficie del recuadro a la
imagen que debe aparecer en la parte superior del cubo (z+), tal como indica la Figu-
ra 11.10. Para adaptar la ✄superficie
✄ una de ellas es escalar
existen varias alternativas;
el cuadrado con la tecla ✂S ✁y para desplazarlo la tecla ✂G ✁(para hacer zoom sobre la
textura se gira la rueda central del ratón). Un segunda opción es ajustar cada vértice
✄ para ello se selecciona un vértice con el botón derecho del ra-
de forma individual,
tón, pulsamos ✂G ✁y desplazamos el vértice a la posición deseada. Las dos alternativas
C11
descritas anteriormente no son excluyentes y se pueden combinar, es decir, se podría
escalar el recuadro en primer lugar y luego ajustar los vértices, e incluso escalar de
nuevo si fuera necesario.
Los pasos que se han realizo para establecer la textura de la parte superior del cubo
✄
deben ser repetidos para el resto de caras del cubo. Si renderizamos la escena pulsando
✂F12 ✁podemos apreciar el resultado final.
Por último vamos a empaquetar el archivo .blend para que sea autocontenido. La
textura es un recurso externo; si cargáramos el fichero .blend en otro ordenador posi-
blemente no se visualizaría debido a que las rutas no coinciden. Si empaquetamos el
fichero eliminamos este tipo de problemas. Para ello, seleccionamos File >External
Data >Pack into .blend file.
Directorios:
C11
• Include
Figura 11.16: Sistema de coorde-
nadas utilizado en Ogre. • Media
• Obj
• Plugins
• src
Ficheros en el directorio raíz del proyecto Ogre:
• ogre.cfg
• plugins.cfg
• resources.cfg
Figura 11.17: Sistema de coorde- Una vez que se ha exportado el cubo con las texturas asociadas y se han gene-
nadas utilizado en Blender. rado los ficheros Cube.mesh, Cube.mesh.xml y Scene.material, los incluimos en el
directorio media (también incluimos la textura "textura512.jpg"). Los ficheros deben
incluirse en este directorio porque así está configurado en el fichero resources.cfg, si
deseáramos incluir los recursos en otro directorio deberíamos variar la configuración
en dicho fichero.
En segundo lugar, cambiamos el nombre del ejecutable; para ello, abrimos el ar-
chivo makefile y en lugar de EXEC := helloWorld, ponemos EXEC:= cubo. Después
es necesario cambiar el código de ejemplo para que cargue nuestro modelo. Abrimos
el archivo /src/main.cpp que se encuentra dentro en el directorio /src. El código es el
siguiente:
En nuestro caso la entidad a crear es aquella cuya malla se llama Cube.Mesh, ésta
ya contiene información sobre la geometría de la malla y las coordenadas UV en el
caso de utilización de texturas.
Por lo tanto el código tras la modificación de la función createScene, sería el si-
guiente:
Listado 11.22: Modificación del fichero main.cpp para cargar el cubo modelado en Blender
1 public : void createScene() {
2 Ogre::Entity *ent = mSceneMgr->createEntity("Caja", "cubo.mesh"
);
3 mSceneMgr->getRootSceneNode()->attachObject(ent);
4 }
Al compilar (make) y ejecutar (./Cubo) aceptamos las opciones que vienen por
defecto y se puede observar el cubo pero muy lejano. A partir de ahora todas las
operaciones que queramos hacer (translación, escalado, rotación, ...) tendrá que ser
a través de código. A continuación se realiza una operación de traslación para acercar
el cubo a la cámara:
C11
este sistema local. posición.
Con el objeto seleccionado en Modo de Objeto se puede indicar a Blender que
recalcule✄ el centro geométrico
✄ del objeto en el panel de Object Tools (accesible con
la tecla ✂T ✁), en el botón ✂Origin ✁(ver Figura 11.20). Una vez pulsado Origin, podemos
elegir entre Geometry to Origin, que desplaza los vértices del objeto de modo que
se ajustan a la posición fijada del centro geométrico, Origin to Geometry que realiza
la misma operación de alineación, pero desplazando el centro en lugar de mover los
vértices, o Origin to 3D Cursor que cambia la posición del centro del objeto a la
localización actual del cursor 3D.
✄
En muchas ocasiones, es necesario situar el centro y los vértices de un modelo
con absoluta precisión. Para realizar esta operación, basta con pulsar la tecla ✂N ✁, y
Figura 11.20: Botón Origin para aparecerá el panel de Transformación Transform a la derecha de la vista 3D (ver Fi-
elegir el centro del objeto, en el pa- gura 11.21), en el que podremos especificar las coordenadas específicas del centro
✄ de
nel Object Tools. del objeto. Si estamos en modo edición, podremos indicar las coordenadas a nivel
vértice. Es interesante que esas coordenadas se especifiquen localmente (botón ✂Local ✁
activado), porque el exportador de Ogre trabajará con coordenadas locales relativas a
ese centro.
El centro del objeto puede igualmente situarse de forma precisa, empleando la po-
sición del puntero 3D. El puntero 3D puedes situarse en cualquier posición del espacio
✄
con precisión. En el panel Transform comentado anteriormente (accesible mediante la
tecla ✂T ✁) pueden especificarse numéricamente
✄ las coordenadas 3D (ver Figura 11.23).
Posteriormente, utilizando el botón ✂Origin ✁(ver Figura 11.20), y eligiendo la opción
Figura 11.21: Propiedades del pa-
Origin to 3D Cursor podremos situar el centro del objeto en la posición del puntero
nel Transform en modo edición (te- 3D.
cla T). Permiten indicar las coorde- Es muy importante que las transformaciones de modelado se apliquen finalmente
nadas de cada vértice con respecto
a las coordenadas locales del objeto, para que se apliquen de forma efectiva a las
✄ de los vértices. Es decir, si después de trabajar con el modelo, pulsando
del Sistema de Referencia Local y
coordenadas
la tecla ✂N ✁en modo Objeto, el valor Scale es distinto de 1 en alguno de los ejes,
del Sistema de Referencia Univer-
sal (Global).
implica que esa transformación se ha realizado en modo objeto, por lo que los vértices
del mismo no tendrán esas coordenadas asociadas. De igual modo, también hay que
prestar atención a la rotación del objeto (estudiaremos cómo resolver este caso más
adelante).
La Figura 11.22 muestra un ejemplo de este problema; el cubo de la izquierda se
ha escalado en modo edición, mientras que el de la derecha se ha escalado en modo
objeto. El modelo de la izquierda se exportará correctamente, porque los vértices tie-
Figura 11.23: Posicionamiento nu- nen asociadas sus coordenadas locales. El modelo de la derecha sin embargo aplica
mérico del cursor 3D. una transformación a nivel de objeto, y será exportado como un cubo.
[348] CAPÍTULO 11. RECURSOS GRÁFICOS Y SISTEMA DE ARCHIVOS
b)
a) c) d)
Figura 11.22: Aplicación de transformaciones geométricas en modo Edición. a) Situación inicial del mo-
delo al que le aplicaremos un escalado de 0.5 respecto del eje Z. b) El escalado se aplica en modo Edición.
El campo Vertex Z muestra el valor correcto. c) Si el escalado se aplicó en modo objeto, el campo Vertex Z
no refleja la geometría real del objeto. Esto puede comprobarse fácilmente si alguno de los campos Scale
no son igual a uno, como en d).
Otra operación muy interesante consiste en posicionar un objeto con total preci-
sión. Para realizar esta operación puede ser suficiente con situarlo numéricamente,
como✄hemos
visto anteriormente, accediendo al panel de transformación mediante la
tecla ✂N ✁. Sin embargo, otras veces necesitamos situarlo en una posición relativa a otro
objeto; necesitaremos situarlo empleando el cursor 3D.
El centro de un objeto puede situarse en la posición del puntero 3D, y el puntero
3D puede situarse en cualquier posición del espacio. Podemos, por ejemplo, en modo
edición situar el puntero 3D en la posición de un vértice de un modelo, y situar ahí
el centro del objeto. Desde ese momento, los desplazamientos del modelo se harán
✄ ✄
tomando como referencia ese punto.
Mediante el atajo ✂Shift ✁✂S ✁Cursor to Selected podemos situar el puntero 3D en la
posición de un elemento seleccionado (por ejemplo, un vértice, o✄en el ✄centro
de otro
objeto que haya sido seleccionado en modo objeto) y mediante ✂Shift ✁✂S ✁Selection to
Cursor moveremos el objeto seleccionado a la posición del puntero 3D (ver Figura
11.24). Mediante este sencillo mecanismo de 2 pasos podemos situar los objetos con
precisión, y modificar el centro del objeto a cualquier punto de interés.
a) b) c) d)
C11
e) f)
Figura 11.24: Ejemplo de uso del operador Cursor to Selected y Selection to Cursor. a) Posición inicial de
los dos modelos. Queremos llegar a la posición f). En modo edición, elegimos un vértice superior del cubo
y posicionamos el puntero 3D ahí (empleando Shift S Cursor to Selected). c) A continuación modificamos
el valor de X e Y de la posición del cursor numéricamente, para que se sitúe en el centro de la cara. d)
Elegimos Origin to 3D Cursor entre las opciones disponibles en el botón Origin del panel Object Tools.
Ahora el cubo tiene su centro en el punto medio de la cara superior. e) Elegimos el vértice señalado en la
cabeza del mono y posicionamos ahí el puntero 3D. Finalmente en f) cambiamos la posición del cubo de
forma que interseca exactamente en ese vértice (Shift S/ Selection to Cursor).
✄
El problema viene asociado a que el modelo ha sido construido empleando trans-
formaciones a nivel de objeto. Si accedemos a las propiedades de transformación ✂N ✁
para cada objeto, obtenemos la información mostrada en la Figura 11.26. Como ve-
mos, la escala a nivel de objeto de ambos modelos es distinta de 1.0 para alguno de
los ejes, lo que indica que la transformación se realizó a nivel de objeto.
Aplicaremos la escala (y la rotación, aunque en este caso el objeto no fue rotado)
✄ realizada
a los vértices del modelo, eliminando cualquier operación ✄ en modo objeto.
Para ello, con cada objeto seleccionado, pulsaremos ✂Control ✁✂A ✁Apply / Rotation &
Scale. Ahora la escala (ver Figura 11.26) debe mostrar un valor de 1.0 en cada eje.
Como hemos visto, la elección correcta del centro del objeto facilita el código en
etapas posteriores. Si el modelo está normalizado (con escala 1.0 en todos los ejes),
las coordenadas del espacio 3D de Blender pueden ser fácilmente transformadas a
coordenadas de Ogre. En este caso, vamos a situar el centro de cada objeto de la
✄ exactamente
escena de forma que esté situado en el Z = 0. Para ello, con cada objeto
seleccionado, pulsaremos en ✂Origin ✁Origin to 3D Cursor (del panel Object Tools).
✄ ✄
Figura 11.26: Propiedades de Ahora posicionaremos el cursor 3D en esa posición ✂Shift ✁✂S ✁Cursor to Selected
✄ del cursor 3D, para que se sitúe en
transformación de los dos objetos y modificaremos numéricamente la coordenada
del ejemplo.
Z = 0 (accediendo al panel de Propiedades ✂N ✁). El resultado de posicionar el cursor
3D se muestra en la Figura 11.27.
[350] CAPÍTULO 11. RECURSOS GRÁFICOS Y SISTEMA DE ARCHIVOS
Figura 11.27: Posicionamiento del puntero 3D en relación al centro del objeto. En muchas situaciones
puede ser conveniente especificar que varios objetos tengan el centro en una misma posición del espacio
(como veremos en la siguiente sección). Esto puede facilitar la construcción de la aplicación.
C11
Obviamente, el proceso de exporta- Listado 11.27: Fragmento de MyApp.c
ción de los datos relativos al posi- 1 Ogre::Entity* ent1 = _sceneManager->createEntity("MS.mesh");
cionamiento de objetos en la esce- 2 Ogre::SceneNode* node1 = _sceneManager->createSceneNode("MS");
na (junto con otros datos de inte- 3 node1->attachObject(ent1);
rés) deberán ser automatizados de- 4 _sceneManager->getRootSceneNode()->addChild(node1);
finiendo un formato de escena pa- 5
ra Ogre. Este formato tendrá la in- 6 Ogre::Entity* ent2 = _sceneManager->createEntity("Mando.mesh");
formación necesaria para cada jue- 7 Ogre::SceneNode* node2 = _sceneManager->createSceneNode("Mando");
go particular. 8 node2->attachObject(ent2);
9 node2->translate(-1.8,0,2.8);
10 node1->addChild(node2);
Por último, sería deseable poder exportar la posición de las cámara para percibir
la escena con la misma configuración que en Blender. Existen varias formas de con-
figurar el camera pose. Una de las más cómodas para el diseñador es trabajar con la
posición de la cámara y el punto al que ésta mira. Esta especificación es equivalente a
✄ ✄ y el punto look at (en el primer fragmento de código de la sección,
indicar la posición
en las líneas ✂4 ✁y ✂5 ✁.
Para que la cámara apunte hacia un objeto de la escena, puede crearse una restric-
ción de tipo Track To. Para✄ ello,
✄ añadimos un objeto vacío a la escena (que no tiene
✂Shift ✁✂A ✁Add / Empty. Ahora seleccionamos primero la cá-
✄
representación), mediante ✄ ✄
mara, y luego con ✂Shift ✁pulsado seleccionamos el Empty y pulsamos ✂Control ✁✂T ✁Track
To Constraint. De este modo, si desplazamos la cámara, obtendremos que siempre
está mirando hacia el objeto Empty creado (ver Figura 11.29).
Cuando tengamos la “fotografía” de la escena montada, bastará con consultar el
valor de posición de la cámara y el Empty, y asignar esos valores al código de posi-
cionamiento y look at de la misma (teniendo en cuenta las diferencias entre sistemas
de coordenadas de Blender y Ogre). El resultado se encuentra resumido en la Figura
11.30.
cam->setPosition (Vector3(5.7,6.3,7.1));
cam->lookAt (Vector3(0.9,0.24,1.1));
cam->setFOVy (Ogre::Degree(52));
Figura 11.30: Resultado de aplicar la misma cámara en Blender y Ogre. El campo de visión de la lente
puede consultarse en las propiedades específicas de la cámara, indicando que el valor quiere representarse
en grados (ver lista desplegable de la derecha)
C11
Figura 11.33: Ejemplo de aplicación que desarrollaremos en esta sección. El interfaz permite seleccionar
objetos en el espacio 3D y modificar algunas de sus propiedades (escala y rotación).
✄
El desplazamiento de la rueda del ratón se obtiene en la coordenada Z con getMou-
seState (línea ✂5 ✁). Utilizamos esta información para desplazar la cámara relativamente
en su eje local Z.
11.4.4. Queries
El gestor de escena permite resolver preguntas relativas a la relación espacial entre
los objetos de la escena. Estas preguntas pueden planterase relativas a los objetos
móviles MovableObject y a la geometría del mundo WorldFragment.
Ogre no gestiona las preguntas relativas a la geometría estática. Una posible so-
lución pasa por crear objetos móviles invisibles de baja poligonalización que servirán
para realizar estas preguntas. Estos objetos están exportados conservando las mismas
dimensiones y rotaciones que el bloque de geometría estática (ver Figura 11.35). Ade-
más, para evitar tener que cargar manualmente los centros de cada objeto, todos los
bloques han redefinido su centro en el origen del SRU (que coincide con el centro de
la geometría estática). El siguiente listado muestra la carga de estos elementos en el
grafo de escena.
C11
23 sauxnode.str(""); sauxmesh.str(""); // Limpiamos el stream
24 }
✄ ✄
Cabe destacar el establecimiento de flags propios (en las líneas ✂5 ✁ y ✂19 ✁) que
✄ ✄
estudiaremos ✄ así como la propiedad de visibilidad del nodo (en
en la sección 11.4.5,
✂7 ✁y ✂21 ✁). El bucle definido en ✂13-23 ✁sirve para cargar las cajas límite mostradas en
la Figura 11.35.
Las preguntas que pueden realizarse al gestor de escena se dividen en cuatro cate-
gorías principales: 1. Caja límite, definida mediante dos vértices opuestos, 2. Esfera,
definida por una posición y un radio, 3. Volumen, definido por un conjunto de tres o
más planos y 4. Rayo, denifido por un punto (origen) y una dirección.
Otras intersecciones En este ejemplo utilizaremos las intersecciones Rayo-Plano. Una vez creado el
Ogre soporta igualmente realizar
objeto de tipo RaySceneQuery en el constructor (mediante una llamada a createRay-
consultas sobre cualquier tipo de in- Query del SceneManager), que posteriormente será eliminado en el destructor (me-
tersecciones arbitrarias entre obje- diante una llamada a destroyQuery del SceneManager), podemos utilizar el objeto
✄
tos de la escena. para realizar consultas a la escena.
En la línea ✂20 ✁se utiliza un método auxiliar para crear la Query, ✄ indicando las
coordenadas del ratón. Empleando la función de utilidad de la línea ✂2 ✁, Ogre crea un
rayo con origen en la cámara y la dirección indicada por las dos coordendas X, Y
✄
normalizadas (entre 0.0 y 1.0) que recibe como argumento (ver Figura ✄ 11.36). En la
línea ✂4 ✁se establece ese rayo para la consulta y se indica en la línea ✂5 ✁que ordene los
resultados por distancia de intersección. La consulta de tipo rayo devuelve una lista
con todos los objetos que han intersecado con el rayo1 . Ordenando el resultado por
distancia, tendremos como primer elemento el primer objeto✄ con el que ha chocado el
rayo. La ejecución de la consulta se realiza en la línea ✂21 ✁.
Para recuperar los datos de la consulta, se emplea un iterador. En el caso de esta
aplicación, sólo nos interesa el primer elemento devuelto ✄ por la consulta (el primer
Primera
rayMouse
punto de intersección), por lo que en el if de la línea ✂25 ✁preguntamos si hay algún
inter- elemento devuelto por la consulta.
sección origen
1 El rayo se describe por un punto de origen y una dirección. Desde ese origen, y siguiendo esa dirección,
el rayo describe una línea infinita que podrá intersecar con infinitos objetos
Figura 11.36: Empleando la llama-
da a getCameraToViewportRay, el
usuario puede obtener un rayo con
origen en la posición de la cáma-
ra, y con la dirección definida por
la posición del ratón.
[356] CAPÍTULO 11. RECURSOS GRÁFICOS Y SISTEMA DE ARCHIVOS
ox4
Col_B Col_
Box5
Col_ Col_
Box2 Box3
Box1 Col_
o
Suel
Col_
Figura 11.35: A la izquierda la geometría utilizada para las preguntas al gestor de escena. A la derecha la
geometría estática. El centro de todas las entidades de la izquierda coinciden con el centro del modelo de
la derecha. Aunque en la imagen los modelos están desplazados con respecto del eje X, en realidad ambos
están exactamente en la misma posición del mundo.
C11
16 if (_selectedNode != NULL) { // Si hay alguno seleccionado...
17 _selectedNode->showBoundingBox(false); _selectedNode = NULL;
18 }
19
20 Ray r = setRayQuery(posx, posy, mask);
21 RaySceneQueryResult &result = _raySceneQuery->execute();
22 RaySceneQueryResult::iterator it;
23 it = result.begin();
24
25 if (it != result.end()) {
26 if (mbleft) {
27 if (it->movable->getParentSceneNode()->getName() ==
28 "Col_Suelo") {
29 SceneNode *nodeaux = _sceneManager->createSceneNode();
30 int i = rand() %2; stringstream saux;
31 saux << "Cube" << i+1 << ".mesh";
32 Entity *entaux=_sceneManager->createEntity(saux.str());
33 entaux->setQueryFlags(i?CUBE1:CUBE2);
34 nodeaux->attachObject(entaux);
35 nodeaux->translate(r.getPoint(it->distance));
36 _sceneManager->getRootSceneNode()->addChild(nodeaux);
37 }
38 }
39 _selectedNode = it->movable->getParentSceneNode();
40 _selectedNode->showBoundingBox(true);
41 }
42 }
11.4.5. Máscaras
Las máscaras permiten restringir el ámbito✄ de✄ las consultas realizadas al gestor de
✄ ✂13 ✁y ✂14 ✁especificamos la máscara binaria
escena. En el listado anterior, en las líneas
que utilizaremos en la consulta (línea ✂6 ✁). Para que una máscara sea válida (y permita
realizar operaciones lógicas con ella), debe contener un único uno.
Así, la forma más sencilla de definirlas es desplazando el 1 tantas posiciones como
se indica tras el operador << como se indica en la Figura 11.38. Como el campo
de máscara es de 32 bits, podemos definir 32 máscaras personalizadas para nuestra
aplicación.
[358] CAPÍTULO 11. RECURSOS GRÁFICOS Y SISTEMA DE ARCHIVOS
✄ la llamada a setQueryFlags
En el listado de la sección 11.4.4, vimos que ✄mediante
se podían asociar máscaras a entidades (líneas ✂5 ✁y ✂19 ✁). Si no se especifica ninguna
máscara en la creación de la entidad, ésta responderá a todas las Queries, planteando
así un esquema bastante flexible.
Los operadores lógicos pueden emplearse para combinar ✄ varias
✄ máscaras, como el
uso del AND &&, el OR || o la negación ∼ (ver líneas ✂13 ✁y ✂14 ✁del pasado código
fuente). En este código fuente, la consulta ✄a ∼ STAGE sería equivalente a consultar por
CUBE1 | CUBE2. La consulta de la línea ✂13 ✁sería equivalente a preguntar por todos
los objetos de la escena (no especificar ninguna máscara).
E
n este capítulo se introducirán los aspectos más relevantes de OpenGL. Como
hemos señalado en el capítulo de introudcción, muchas bibliotecas de visuali-
zación (como OGRE) nos abstraen de los detalles de bajo nivel. Sin embargo,
resulta interesante conocer el modelo de estados de este ipo de APIs, por compartir
multitud de convenios con las bibliotecas de alto nivel. Entre otros aspectos, en este
capítulo se estudiará la gestión de pilas de matrices y los diferentes modos de trans-
formación de la API.
12.1. Introducción
Actualmente existen en el mercado dos alternativas principales como bibliotecas
de representación 3D a bajo nivel: Direct3D y OpenGL. Estas dos bibliotecas son so-
portadas por la mayoría de los dispositivos hardware de aceleración gráfica. Direct3D
forma parte del framework DirectX de Microsoft. Es una biblioteca ampliamente ex-
tendida, y multitud de motores 3D comerciales están basados en ella. La principal
desventaja del uso de DirectX es la asociación exclusiva con el sistema operativo Mi-
crosoft Windows. Aunque existen formas de ejecutar un programa compilado para esta
plataforma en otros sistemas, no es posible crear aplicaciones nativas utilizando Di-
rectX en otros entornos. De este modo, en este primer estudio de las APIs de bajo nivel
nos centraremos en la API multiplataforma OpenGL.
OpenGL es, probablemente, la biblioteca de programación gráfica más utilizada
del mundo; desde videojuegos, simulación, CAD, visualización científica, y un largo
etecétera de ámbitos de aplicación la configuran como la mejor alternativa en multitud
de ocasiones. En esta sección se resumirán los aspectos básicos más relevantes para
comenzar a utilizar OpenGL.
359
[360] CAPÍTULO 12. APIS DE GRÁFICOS 3D
En este breve resumen de OpenGL se dejarán gran cantidad de aspectos sin men-
cionar. Se recomienda el estudio de la guía oficial de OpenGL [86] (también conocido
como El Libro Rojo de OpenGL para profundizar en esta potente biblioteca gráfica.
Otra fantástica fuente de información, con ediciones anteriores del Libro Rojo es la
página oficial de la biblioteca1 .
El propio nombre OpenGL indica que es una Biblioteca para Gráficos Abierta2 .
Una de las características que ha hecho de OpenGL una biblioteca tan famosa es que es
independiente de la plataforma sobre la que se está ejecutando (en términos software
y hardware). Esto implica que alguien tendrá que encargarse de abrir una ventana
gráfica sobre la que OpenGL pueda dibujar los preciosos gráficos 3D.
Para facilitar el desarrollo de aplicaciones con OpenGL sin preocuparse de los
detalles específicos de cada sistema operativo (tales como la creación de ventanas,
gestión de eventos, etc...) M. Kilgard creó GLUT, una biblioteca independiente de
OpenGL (no forma parte de la distribución oficial de la API) que facilita el desarro-
llo de pequeños prototipos. Esta biblioteca auxiliar es igualmente multiplataforma. En
este capítulo trabajaremos con FreeGLUT, la alternativa con licencia GPL totalmente
compatible con GLUT. Si se desea mayor control sobre los eventos, y la posibilidad
de extender las capacidades de la aplicación, se recomienda el estudio de otras APIs Programa de Usuario
multiplataforma compatibles con OpenGL, como por ejemplo SDL3 o la que emplea-
remos con OGRE (OIS). Existen alternativas específicas para sistemas de ventanas
GLUT (FreeGLUT)
GLX, AGL, WGL...
WGL para sistemas Windows.
Widget Opengl
De este modo, el núcleo principal de las biblioteca se encuentra en el módulo GL
(ver Figura 12.2). En el módulo GLU (OpenGL Utility) se encuentran funciones de
uso común para el dibujo de diversos tipos de superficies (esferas, conos, cilindros,
curvas...). Este módulo es parte oficial de la biblioteca.
Uno de los objetivos principales de OpenGL es la representación de imágenes de
alta calidad a alta velocidad. OpenGL está diseñado para la realización de aplicacio-
nes interactivas, como videojuegos. Gran cantidad de plataformas actuales se basan GLU
en esta especificación para definir sus interfaces de dibujado en gráficos 3D.
GL
Otras Bibliotecas
12.2. Modelo Conceptual Software
glVertex4dv (v);
C12
bujado de elementos.
Cambio de Estado La operación de Cambiar el Estado se encarga de inicializar las variables internas
de OpenGL que definen cómo se dibujarán las primitivas. Este cambio de estado puede
A modo de curiosidad: OpenGL
cuenta con más de 400 llamadas a ser de mútiples tipos; desde cambiar el color de los vértices de la primitiva, establecer
función que tienen que ver con el la posición de las luces, etc. Por ejemplo, cuando queremos dibujar el vértice de un
cambio del estado interno de la bi- polígono de color rojo, primero cambiamos el color del vértice con glColor() y
blioteca. después dibujamos la primitiva en ese nuevo estado con glVertex().
Algunas de las formas más utilizadas para cambiar el estado de OpenGL son:
entre 0 y 1. Las primeras tres componentes se corresponden con los canales RGB, y la cuarta es el valor de
transparencia Alpha.
[362] CAPÍTULO 12. APIS DE GRÁFICOS 3D
-Y
12.3.1. Transformación de Visualización
C12
Figura 12.6: Descripción del siste- La transformación de Visualización debe especificarse antes que ninguna otra
ma de coordenadas de la cámara de- transformación de Modelado. Esto es debido a que OpenGL aplica las transforma-
finida en OpenGL.
ciones en orden inverso. De este modo, aplicando en el código las transformaciones
de Visualización antes que las de Modelado nos aseguramos que ocurrirán después
que las de Modelado.
Para comenzar con la definición de la Transformación de Visualización, es nece-
sario limpiar la matriz de trabajo actual. OpenGL cuenta con una función que carga la
matriz identidad como matriz actual glLoadIdentity().
Una vez hecho esto, podemos posicionar la cámara virtual de varias formas:
Matriz Identidad
La Matriz Identidad es una matriz 1. glulookat. La primera opción, y la más comunmente utilizada es mediante la
4x4 con valor 1 en la diagonal prin-
cipal, y 0 en el resto de elementos. función gluLookAt. Esta función recibe como parámetros un punto (eye) y dos
La multiplicación de esta matriz I vectores (center y up). El punto eye es el punto donde se encuentra la cámara
por una matriz M cualquiera siem- en el espacio y mediante los vectores libres center y up orientamos hacia dónde
pre obtiene como resultado M . Esto mira la cámara (ver Figura 12.7).
es necesario ya que OpenGL siem-
pre multiplica las matrices que se le 2. Traslación y Rotación. Otra opción es especificar manualmente una secuencia
indican para modificar su estado in-
terno. de traslaciones y rotaciones para posicionar la cámara (mediante las funciones
glTraslate() y glRotate(), que serán estudiadas más adelante.
3. Carga de Matriz. La última opción, que es la utilizada en aplicaciones de
Realidad Aumentada, es la carga de la matriz de Visualización calculada exter-
namente. Esta opción se emplea habitualmente cuando el programador quiere
calcular la posición de la cámara virtual empleando métodos externos (como
por ejemplo, mediante una biblioteca de tracking para aplicaciones de Realidad
Aumentada).
center up
(cx,cy,cz) (ux,uy,uz)
12.3.2. Transformación de Modelado
Las transformaciones de modelado nos permiten modificar los objetos de la es-
cena. Existen tres operaciones básicas de modelado que implementa OpenGL con
eye llamadas a funciones. No obstante, puede especificarse cualquier operación aplicando
(ex,ey,ez) una matriz definida por el usuario.
Figura 12.7: Parámetros de la fun- Traslación. El objeto se mueve a lo largo de un vector. Esta operación se realiza
ción glulookat. mediante la llamada a glTranslate(x,y,z).
[364] CAPÍTULO 12. APIS DE GRÁFICOS 3D
Posición
final
Z Z Z
X X X
Posición
inicial
b) Visto como «Sistema de Referencia Local» Y' X'
Sis
Re tema d Y Y Y
Unifverenciae Z'
ersal
Z
Y' X'
X Y'
C12
decidir el orden de las transfor- Z Z Z
maciones en OpenGL: SRU o SRL.
X X X
✄
El código anterior dibuja el objeto en la posición deseada, pero ¿cómo hemos
✄
llegado a ese código y no hemos intercambiado las instrucciones de las líneas ✂3 ✁y
✂4 ✁?. Existen dos formas de imaginarnos cómo se realiza el dibujado que nos puede
ayudar a plantear el código fuente. Ambas formas son únicamente aproximaciones
conceptuales, ya que el resultado en código debe ser exactamente el mismo.
Como puede verse en el listado anterior, se ha incluido una variable global hours
que se incrementa cada vez que se llama a la función display. En este ejemplo, esta
función se llama cada vez que se pulsa cualquier tecla. Esa variable modela el paso
✄ de
de las horas, de forma que la traslación y ✄rotación la Tierra se calculará a partir del
número de horas que han pasado (líneas ✂11 ✁y ✂12 ✁). En la simulación se ha acelerado
10 veces el movimiento de traslación para que se vea más claramente.
Empleando la Idea de Sistema de Referencia Local podemos
✄ pensar que despla-
zamos el sistema de referencia para dibujar el sol. En la línea ✂15 ✁, para dibujar una
esfera alámbrica especificamos como primer argumento el radio, y a continuación el
número de rebanadas (horizontales y verticales) en que se dibujará la esfera.
En ese punto dibujamos la primera esfera correspondiente al Sol. Hecho esto,✄ ro-
✄ (línea ✂16 ✁)
C12
tamos los grados correspondientes al movimiento de traslación de la tierra
y nos desplazamos 3 unidades respecto del eje X local del objeto (línea ✂17 ✁). Antes de
✄ la Tierra tendremos que realizar el movimiento
dibujar ✄ de rotación de la tierra (línea
✂18 ✁). Finalmente dibujamos la esfera en la línea ✂20 ✁.
Veamos ahora un segundo ejemplo que utiliza glPushMatrix() y glPopMatrix()
Figura 12.9: Salida por pantalla del para dibujar un brazo robótico sencillo. El ejemplo simplemente emplea dos cubos
ejemplo del planetario. (convenientemente escalados) para dibujar la estructura jerárquica de la figura 12.10.
✄
En este ejemplo, se asocian manejadores de callback para los eventos de teclado,
que se corresponden con la función keyboard (en ✂8-16 ✁). Esta función simplemente
Ejemplo Brazo Robot
✄ de✄ las
incrementa o decrementa los ángulos de rotación dos articulaciones del robot,
que están definidas como variables globales en ✂2 ✁y ✂3 ✁.
Un segundo ejemplo que utiliza las
funciones de añadir y quitar ele-
✄ interesante
mentos de la pila de matrices.
La parte más del ejemplo se encuentra definido en la función dibujar
en las líneas ✂19-42 ✁.
1. Modifique el ejemplo del listado del planetario para que dibuje una esfera de
color blanco que representará a la Luna, de radio 0.2 y separada 1 unidad del
centro de la Tierra (ver Figura 12.11). Este objeto tendrá una rotación completa
alrededor de la Tierra cada 2.7 días (10 veces más rápido que la rotación real
de la Luna sobre la Tierra. Supondremos que la luna no cuenta con rotación
interna6 .
2. Modifique el ejemplo del listado del brazo robótico para que una base (añadida Figura 12.11: Ejemplo de salida
con un toroide glutSolidTorus) permita rotar el brazo robótico respecto de del ejercicio propuesto.
la base (eje Y ) mediante las teclas 1 y 2. Además, añada el código para que el
extremo cuente con unas pinzas (creadas con glutSolidCone) que se abran y
cierren empleando las teclas z y x, tal y como se muestra en la Figura 12.12.
E
n las sesiones anteriores hemos delegado la gestión del arranque, incialización y
parada de Ogre en una clase SimpleExample proporcionada junto con el motor
gráfico para facilitar el desarrollo de los primeros ejemplos. En este capítulo
estudiaremos la gestión semi-automática de la funcionalidad empleada en los ejemplos
anteriores, así como introduciremos nuevas características, como la creación manual
de entidades, y el uso de Overlays, luces y sombras dinámicas.
Inicio Casi-Manual En esta sección introduciremos un esqueleto de código fuente que emplearemos
en el resto del capítulo, modificándolo de manera incremental. Trabajaremos con
En este capítulo no describiremos el
inicio totalmente manual de Ogre; dos clases; MyApp que proporciona el núcleo principal de la aplicación, y la clase
emplearemos las llamadas de alto MyFrameListener que es una isntancia de clase que hereda de Ogre::FrameListener,
nivel para cargar plugins, inicializar basada en el patrón Observador.
ventana, etc...
Como puede verse en el primer listado, en el fichero de cabecera de MyApp.h se
declara la clase que contiene tres miembros privados; la instancia de root (que hemos
estudiado en capítulos anteriores), el gestor de escenas y un puntero a un objeto de la
clase propia MySceneManager que definiremos a continuación.
369
[370] CAPÍTULO 13. GESTIÓN MANUAL OGRE 3D
6 Ogre::SceneManager* _sceneManager;
7 Ogre::Root* _root;
8 MyFrameListener* _framelistener;
9
10 public:
11 MyApp();
12 ~MyApp();
13 int start();
14 void loadResources();
15 void createScene();
16 };
El programa principal únicamente tendrá que crear una instancia de la clase MyApp
y ejecutar su método ✄start . La definición de este método puede verse en el siguiente
listado, en las líneas ✂13-44 ✁.
13.1.1. Inicialización
✄
En la línea ✂14 ✁se crea el objeto Root. Esta es la primera operación que se debe
realizar antes de ejecutar cualquier otra operación con Ogre. El constructor de Root
está sobrecargado, y permite que se le pase como parámetros el nombre del archivo
de configuración de plugins (por defecto, buscará plugins.cfg en el mismo directorio
donde se encuentra el ejecutable), el de configuración de vídeo (por defecto ogre.cfg),
y de log (por
✄ defecto ogre.log). Si no le indicamos ningún parámetro (como en el caso
de la línea ✂14 ✁), tratará de cargar los ficheros con el nombre por defecto.
✄
En la línea ✂16 ✁se intenta cargar la configuración de vídeo existente en el archi-
vo ogre.cfg. Si el archivo no existe, o no es correcto, podemos
✄ abrir el diálogo que
empleamos en los ejemplos de las sesiones anteriores ✄ (línea ✂17 ✁), y guardar a con-
tinuacion los valores elegidos por el usuario (línea ✂18 ✄✁). Con
el objeto Root creado,
podemos pasar a crear la ventana. Para ello, en la línea ✂21 ✁solicitamos al objeto Root
que finalice su inicialización creando una ventana que utilice la configuración que
eligió el usuario, con el título especificado como segundo parámetro (MyApp en este
caso).
El primer parámetro booleano del método indica a Ogre que se encarge de crear
automáticamente la ventana. La llamada a este método nos devuelve un puntero a un
objeto RenderWindow, que guardaremos en la variable window.
Lo que será representado en la ventana vendrá definido por el volumen de visua-
lización de la cámara virtual. Asociado a esta cámara, crearemos una superficie (algo
que conceptualmente puede verse como el lienzo, o el plano de imagen) sobre la que se
dibujarán los objetos 3D. Esta superficie se denomina viewport. El Gestor de Escena
admite diversos modos de gestión, empleando el patrón de Factoría, que son cargados
como plugins (y especificados en el archivo plugins.cfg). El parámetro indicado en la
13.1. Inicialización Manual [371]
✄
llamada de ✂22 ✁especifica el tipo de gestor de escena que utilizaremos ST_GENERIC1 .
Esta gestión de escena mínima no está optimizada para ningún tipo de aplicación en
particular, y resulta especialmente adecuada para pequeñas aplicaciones, menús de
introducción, etc.
C13
18 _root->saveConfig(); // Guardamos la configuracion
19 }
20
21 Ogre::RenderWindow* window = _root->initialise(true,"MyApp");
22 _sceneManager = _root->createSceneManager(Ogre::ST_GENERIC);
23
24 Ogre::Camera* cam = _sceneManager->createCamera("MainCamera");
25 cam->setPosition(Ogre::Vector3(5,20,20));
26 cam->lookAt(Ogre::Vector3(0,0,0));
27 cam->setNearClipDistance(5); // Establecemos distancia de
28 cam->setFarClipDistance(10000); // planos de recorte
29
30 Ogre::Viewport* viewport = window->addViewport(cam);
31 viewport->setBackgroundColour(Ogre::ColourValue(0.0,0.0,0.0));
32 double width = viewport->getActualWidth();
33 double height = viewport->getActualHeight();
34 cam->setAspectRatio(width / height);
35
36 loadResources(); // Metodo propio de carga de recursos
37 createScene(); // Metodo propio de creacion de la escena
38
39 _framelistener = new MyFrameListener(); // Clase propia
40 _root->addFrameListener(_framelistener); // Lo anadimos!
41
42 _root->startRendering(); // Gestion del bucle principal
43 return 0; // delegada en OGRE
44 }
45
46 void MyApp::loadResources() {
47 Ogre::ConfigFile cf;
48 cf.load("resources.cfg");
49
50 Ogre::ConfigFile::SectionIterator sI = cf.getSectionIterator();
51 Ogre::String sectionstr, typestr, datastr;
52 while (sI.hasMoreElements()) { // Mientras tenga elementos...
53 sectionstr = sI.peekNextKey();
54 Ogre::ConfigFile::SettingsMultiMap *settings = sI.getNext();
55 Ogre::ConfigFile::SettingsMultiMap::iterator i;
56 for (i = settings->begin(); i != settings->end(); ++i) {
57 typestr = i->first; datastr = i->second;
58 Ogre::ResourceGroupManager::getSingleton().
addResourceLocation(datastr, typestr, sectionstr);
1 Existen otros métodos de gestión, como ST_INTERIOR, ST_EXTERIOR... Estudiaremos en
detalle las opciones de estos gestores de escena a lo largo de este módulo.
[372] CAPÍTULO 13. GESTIÓN MANUAL OGRE 3D
59 }
60 }
61 Ogre::ResourceGroupManager::getSingleton().
initialiseAllResourceGroups();
62 }
63
64 void MyApp::createScene() {
65 Ogre::Entity* ent = _sceneManager->createEntity("Sinbad.mesh");
66 _sceneManager->getRootSceneNode()->attachObject(ent);
67 }
C13
la línea ✂58 ✁.
13.1.3. FrameListener
Como hemos comentado anteriormente, el uso de FrameListener se basa en el
patrón Observador. Añadimos instancias de esta clase al método Root de forma que
será notificada cuando ocurran ciertos eventos. Antes de representar un frame, Ogre
itera sobre todos los FrameListener añadidos, ejecutando el método frameStarted de
cada uno de ellos. Cuando el frame ha sido dibujado, Ogre ejecutará igualmente el
método frameEnded asociado a cada FrameListener. En el siguiente listado se declara
la clase MyFrameListener.
FrameStarted
Si delegamos la gestión de bucle de Listado 13.3: MyFrameListener.h
dibujado a Ogre, la gestión de los 1 #include <OgreFrameListener.h>
eventos de teclado, ratón y joystick 2
se realiza en FrameStarted, de mo- 3 class MyFrameListener : public Ogre::FrameListener {
do que se atienden las peticiones 4 public:
antes del dibujado del frame. 5 bool frameStarted(const Ogre::FrameEvent& evt);
6 bool frameEnded(const Ogre::FrameEvent& evt);
7 };
false en el método frameStarted del FrameListener cuando esto ocurra. Como✄ vemos
Recordemos que OIS no forma par-
en el siguiente listado, es necesario añadir al constructor de la clase (línea ✂11 ✁) un
te de la distribución de Ogre. Es
posible utilizar cualquier biblioteca
puntero a la RenderWindow. para la gestión de eventos.
✄
En las líneas ✂8-9 ✁convertimos el manejador a cadena, y añadimos el par de objetos
(empleando la plantilla pair de std) como parámetros de OIS. Como puede verse el
convenio de Ogre y de OIS es similar a la hora de nombrar el manejador de la ventana
(salvo por el hecho de que OIS requiere que se especifique como cadena, y Ogre lo
✄
devuelve como un entero).
Con este parámetro, creamos un InputManager de OIS en ✂11 ✁, que nos permitirá
✄ interfaces para trabajar con eventos de teclado, ratón, etc. Así, en las
crear diferentes
líneas ✂12-13 ✁creamos un objeto de tipo OIS::Keyboard, que nos permitirá ✄ obtener
las pulsaciones de tecla del usuario. El segundo parámetro de la línea ✂13 ✁permite
indicarle a OIS si queremos que almacene en un buffer los eventos de ese objeto. En
este caso, la entrada por teclado se consume directamente sin almacenarla en un buffer
intermedio.
Tanto el InputManager como el ojbeto de teclado deben ser eliminados explíci-
tamente en el destructor de nuestro FrameListener. Ogre se encarga de liberar los
recursos asociados a todos sus Gestores. Como Ogre es independiente de OIS, no tie-
ne constancia de su uso y es responsabilidad del programador liberar los objetos de
entrada de eventos, así como el propio gestor InputManager.
C13
1 #include "MyFrameListener.h"
2
3 MyFrameListener::MyFrameListener(Ogre::RenderWindow* win) {
4 OIS::ParamList param;
5 unsigned int windowHandle; std::ostringstream wHandleStr;
6
7 win->getCustomAttribute("WINDOW", &windowHandle);
8 wHandleStr << windowHandle;
9 param.insert(std::make_pair("WINDOW", wHandleStr.str()));
10
11 _inputManager = OIS::InputManager::createInputSystem(param);
12 _keyboard = static_cast<OIS::Keyboard*>
13 (_inputManager->createInputObject(OIS::OISKeyboard, false));
14 }
15
16 MyFrameListener::~MyFrameListener() {
17 _inputManager->destroyInputObject(_keyboard);
18 OIS::InputManager::destroyInputSystem(_inputManager);
19 }
20
21 bool MyFrameListener::frameStarted(const Ogre::FrameEvent& evt) {
22 _keyboard->capture();
23 if(_keyboard->isKeyDown(OIS::KC_ESCAPE)) return false;
24 return true;
25 }
✄
La implementación del método frameStarted es muy sencilla. En la línea✄ ✂22 ✁se
obtiene si hubo alguna pulsación de tecla. Si se presionó la tecla Escape (línea ✂23 ✁), se
devuelve false de modo que Ogre finalizará el bucle principal de dibujado y liberará
todos los recursos que se están empleando.
✄ muestra
Hasta ahora, la escena es totalmente estática. La Figura 13.2 el resultado
de ejecutar el ejemplo (hasta que el usuario presiona la tecla ✂ESC ✁). A continuación
veremos cómo modificar la posición de los elementos de la escena, definiendo un
interfaz para el manejo del ratón.
a) 43 _mouse->capture();
44 float rotx = _mouse->getMouseState().X.rel * deltaT * -1;
45 float roty = _mouse->getMouseState().Y.rel * deltaT * -1;
46 _camera->yaw(Ogre::Radian(rotx));
47 _camera->pitch(Ogre::Radian(roty));
48
49 return true;
50 }
Espacio
Tiempo (Frames)
C13
anterior. En esta ocasión, se definen en las líneas ✂28-30 ✁una serie de variables que
Espacio comentaremos a continuación. En vt almacenaremos el vector de traslación relativo
que aplicaremos a la cámara, dependiendo de la pulsación de teclas del usuario. La
Tiempo (Segundos) Tiempo (Segundos)
c)
variable r almacenará la rotación que aplicaremos al nodo de la escena (pasado como
tercer argumento al constructor). En deltaT guardaremos el número de segundos
transcurridos desde el despliegue del último frame. Finalmente la variable tSpeed
servirá para indicar la traslación (distancia en unidades del mundo) que queremos
recorrer con la cámara en un segundo.
Espacio La necesidad de medir el tiempo transcurrido desde el despliegue del último frame
es imprescindible si queremos que las unidades del espacio recorridas por el modelo
d) (o la cámara en este caso) sean dependientes del tiempo e independientes de las capa-
cidades de representación gráficas de la máquina sobre la que se están ejecutando. Si
aplicamos directamente un incremento del espacio sin tener en cuenta el tiempo, como
se muestra en la Figura 13.3, el resultado del espacio recorrido dependerá de la velo-
cidad de despliegue (ordenadores más potentes avanzarán más espacio). La solución
es aplicar incrementos en la distancia dependientes del tiempo.
Espacio De este modo, aplicamos un movimiento relativo a la cámara (en función de sus
ejes locales), multiplicando el vector de traslación (que✄ ha sido definido dependien-
do de la pulsación de teclas del usuario en las líneas ✂34-37 ✁), por deltaT y por la
Figura 13.3: Comparación entre
animación basada en frames y ani-
✄
mación basada en tiempo. El eje de velocidad de traslación (de modo que avanzará 20 unidades por segundo).
abcisas representa el espacio reco- De forma similar, cuando el usuario pulse la tecla ✂R ✁, se rotará
✄ el modelo respecto
rrido en una trayectoria, mientras
de su eje Y local a una velocidad de 180 por segundo (líneas ✂40-41 ✁).
o
que el eje de ordenadas representa
el tiempo. En las gráficas a) y b) el Finalmente, se aplicará una rotación a la cámara respecto de sus ejes Y y Z locales
✄ el movimiento relativo del ratón. Para ello, se obtiene el estado del ratón
empelando
tiempo se especifica en frames. En
las gráficas c) y d) el tiempo se es- (líneas ✂44-45 ✁), obteniendo el incremento relativo en píxeles desde la última captura
pecifica en segundos. Las gráficas (mediante el atributo abs se puede obtener el valor absoluto del incremento).
a) y c) se corresponden con los rsul-
tados obtenidos en un computador
con bajo rendimiento gráfico (en el
mismo tiempo presenta una baja ta- 13.3. Creación manual de Entidades
sa de frames por segundo). Las grá-
ficas b) y d) se corresponden a un
computador con el doble de frames Ogre soporta la creación manual de multitud de entidades y objetos. Veremos a
por segundo. continuación cómo se pueden añadir planos y fuentes de luz a la escena.
[378] CAPÍTULO 13. GESTIÓN MANUAL OGRE 3D
✄
✄ manual del plano se realiza en las líneas ✂8-11 ✁del siguiente listado.
La creación
En la línea ✂8 ✁se especifica que el plano tendrá como vector normal, el vector unitario
en Y , y que estará situado a -5 unidades respecto del vector normal. Esta definición
se corresponde con un plano infinito (descripción matemática abstracta). Si queremos
representar el plano, tendremos que indicar a Ogre el tamaño (finito) del mismo, así
como la resolución que queremos aplicarle (número ✄ de divisiones
horizontales y ver-
ticales). Estas operaciones se indican en las líneas ✂9-11 ✁.
✄
Finalmente las líneas ✂18-22 ✁se encargan de definir una fuente de luz dinámica
direccional 2 y habilitar el cálculo de sombras. Ogre soporta 3 tipos básicos de fuentes
de luz y 11 modos de cálculo de sombras dinámicas, que serán estudiados en detalle
en próximos capítulos.
C13
necesario especificar la carga de una fuente de texto. Ogre soporta texturas basadas
en imagen o truetype. La definición de las fuentes se realiza empleando un fichero de
extensión .fontdef (ver Figura 13.7).
Figura 13.6: Valores de repeti- En el listado de la Figura 13.8 se muestra la definición de los elementos y conten-
ción en la textura. La imagen supe- dores del Overlay, así como los ficheros de material (extensión .material) asociados
rior se corresponde con valores de al mismo. En el siguiente capítulo estudiaremos en detalle la definición de materiales
uT ile = 1, vT ile = 1. La ima- en Ogre, aunque un simple vistazo al fichero de materiales nos adelanta que estamos
gen inferior implica un valor de 2 creando dos materiales, uno llamado panelInfoM, y otro matUCLM. Ambos utilizan
en ambos parámetros (repitiendo la texturas de imagen. El primero de ellos utiliza una técnica de mezclado que tiene en
textura 2x2 veces en el plano). cuenta la componente de opacidad de la pasada.
El primer contenedor de tipo Panel (llamado PanelInfo), contiene cuatro TextArea
en su interior. El posicionamiento de estos elementos se realiza de forma relativa al
posicionamiento del contenedor. De este modo, se establece una relación de jerarquía
implícita entre el contenedor y los objetos contenidos. El segundo elemento contene-
dor (llamado logoUCLM) no tiene ningún elemento asociado en su interior.
La Tabla 13.1 describe los principales atributos de los contenedores y elementos
de los overlays. Estas propiedades pueden ser igualmente modificadas en tiempo de
Figura 13.7: Fichero de definición ejecución, por lo que son animables. Por ejemplo, es posible modificar la rotación de
de la fuente Blue.fontdef. un elemento del Overlay consiguiendo bonitos efectos en los menús del juego.
El sistema de scripting de Overlays de Ogre permite definir plantillas de estilo
que pueden utilizar los elementos y contenedores. En este ejemplo se han definido
dos plantillas, una para texto genérico (de nombre MyTemplates/Text), y otra para tex-
to pequeño (MyTemplates/SmallText). Para aplicar estas plantillas a un elemento del
Overlay, basta con indicar seguido de dos puntos el nombre de la plantilla a continua-
ción del elemento (ver Figura 13.8).
En el listado del Overlay definido, se ha trabajado con el modo de especificación
del tamaño en píxeles. La alineación de los elementos se realiza a nivel de píxel (en
lugar de ser relativo a la resolución de la pantalla), obteniendo un resultado como se
muestra en la Figura 13.8. El uso de valores negativos en los campos de alineación
permite posicionar con exactitud los paneles alineados a la derecha y en el borde infe-
2 Este tipo de fuentes de luz únicamente tienen dirección. Definen rayos de luz paralelos de una fuente
Figura 13.8: Definición de los Overlays empleando ficheros externos. En este fichero se describen dos
paneles contenedores; uno para la zona inferior izquierda que contiene cuatro áreas de texto, y uno para la
zona superior derecha que representará el logotipo de la UCLM.
rior. Por ejemplo, en el caso del panel logoUCLM, de tamaño (150x120), la alineación
horizontal se realiza a la derecha. El valor de -180 en el campo left indica que que-
remos que quede un margen de 30 píxeles entre el lado derecho de la ventana y el
extremo del logo.
Los Overlays tienen asociada una profundidad, definida en el campo zorder (ver
Figura 13.8), en el rango de 0 a 650. Overlays con menor valor de este campo serán
dibujados encima del resto. Esto nos permite definir diferentes niveles de despliegue
de elementos 2D.
Para finalizar estudiaremos la modificación en el código de MyApp y MyFrame-
✄
Listener. ✄
En el siguiente listado se muestra que el constructor del FrameListener (línea
✂7 ✁) necesita conocer el OverlayManager, cuya referencia se obtiene en la línea ✂14 ✁.
En realidad no sería necesario pasar el puntero al constructor, pero evitamos de esta
forma que el FrameListener tenga que solicitar la referencia.
13.4. Uso de Overlays [381]
Atributo Descripción
metrics_mode Puede ser relative (valor entre 0.0 y 1.0), o pixels (valor en píxeles). Por defecto es relative. En
coordenadas relativas, la coordenada superior izquierda es la (0,0) y la inferior derecha la (1,1).
horz_align Puede ser left (por defecto), center o right.
vert_align Puede ser top (por defecto), center o bottom.
left Posición con respecto al extremo izquierdo. Por defecto 0. Si el valor es negativo, se interpreta
como espacio desde el extremo derecho (con alineación right).
top Posición con respecto al extremo superior. Por defecto 0. Si el valor es negativo, se interpreta como
espacio desde el extremo inferior (con alineación vertical bottom).
width Ancho. Por defecto 1 (en relative).
height Alto. Por defecto 1 (en relative).
material Nombre del material asociado (por defecto Ninguno).
caption Etiqueta de texto asociada al elemento (por defecto Ninguna).
rotation Ángulo de rotación del elemento (por defecto sin rotación).
C13
1024x768
1280x800 800x600
Figura 13.9: Resultado de ejecución del ejemplo de uso de Overlays, combinado con los resultados parcia-
les de las secciones anteriores. La imagen ha sido generada con 3 configuraciones de resolución diferentes
(incluso con diferente relación de aspecto): 1280x800, 1024x768 y 800x600.
✄
En las líneas ✂15-16 ✁, obtenemos el Overlay llamado Info (definido en el listado de
✄
la Figura 13.8), y lo mostramos.
Para finalizar, el código del FrameListener definido en las líneas ✂6-16 ✁del siguien-
te listado parcial, modifica el valor del texto asociado a los elementos de tipo TextArea.
Hacemos uso de la clase auxiliar Ogre::StringConverter que facilita la conversión a
String de ciertos tipos de datos (vectores, cuaternios...).
3 loadResources();
4 createScene();
5 createOverlay(); // Metodo propio para crear el overlay
6 ...
7 _framelistener = new MyFrameListener(window, cam, node,
8 _overlayManager);
9 _root->addFrameListener(_framelistener);
10 ...
11 }
12
13 void MyApp::createOverlay() {
14 _overlayManager = Ogre::OverlayManager::getSingletonPtr();
15 Ogre::Overlay *overlay = _overlayManager->getByName("Info");
16 overlay->show();
17 }
Interacción y Widgets
14
César Mora Castro
C
omo se ha visto a lo largo del curso, el desarrollo de un videojuego requiere
tener en cuenta una gran variedad de disciplinas y aspectos: gráficos 3D o 2D,
música, simulación física, efectos de sonido, jugabilidad, eficiencia, etc. Uno
de los aspectos a los que se les suele dar menos importancia, pero que juegan un papel
fundamental a la hora de que un juego tenga éxito o fracase, es la interfaz de usuario.
Sin embargo, el mayor inconveniente es que la mayoría de los motores gráficos no dan
soporte para la gestión de Widgets, y realizarlos desde cero es un trabajo más costoso
de lo que pueda parecer.
En este capítulo se describe de forma general la estructura y las guías que hay que
tener en cuenta a la hora de diseñar y desarrollar una interfaz de usuario. Además, se
explicará el uso de CEGUI, una biblioteca de gestión de Widgets para integrarlos en
motores gráficos.
Eficiencia y diseño
Para desarrollar un videojuego, tan
importante es cuidar la eficiencia y
14.1. Interfaces de usuario en videojuegos
optimizarlo, como que tenga un di-
seño visualmente atractivo.
Las interfaces de usuario específicas de los videojuegos deben tener el objetivo
de crear una sensación positiva, que consiga la mayor inmersión del usuario posible.
Estas interfaces deben tener lo que se denomina flow. El flow[50] es la capacidad de
atraer la atención del usuario, manteniendo su concentración, la inmersión dentro de
la trama del videojuego, y que consiga producir una experiencia satisfactoria.
383
[384] CAPÍTULO 14. INTERACCIÓN Y WIDGETS
Cada vez se realizan estudios más serios sobre cómo desarrollar interfaces que
tengan flow, y sean capaces de brindar una mejor experiencia al usuario. La principal
diferencia con las interfaces de usuario de aplicaciones no orientadas al ocio, es que
estas centran su diseño en la usabilidad y la eficiencia, no en el impacto sensorial.
Si un videojuego no consigue atraer y provocar sensaciones positivas al usuario, este
posiblemente no triunfará, por muy eficiente que sea, o por muy original o interesante
que sea su trama.
A continuación se detalla una lista de aspectos a tener en cuenta que son reco-
mendables para aumentar el flow de un videojuego, aunque tradicionalmente se han
considerado perjudiciales a la hora de diseñar interfaces tradicionales según las reglas
de interacción persona-computador:
Se ha visto cómo el caso particular de las interfaces de los videojuegos puede con-
tradecir reglas que se aplican en el diseño de interfaces de usuario clásicas en el campo
de la interacción persona-computador. Sin embargo, existen muchas recomendaciones
que son aplicables a ambos tipos de interfaces. A continuación se explican algunas:
C14
2. Eficiencia: cuán rápido se puede realizar una tarea, sobretodo si es muy repeti-
tiva.
3. Simplicidad: mantener los controles y la información lo más minimalista posi-
ble.
4. Estética: cuán sensorialmente atractiva es la interfaz.
Una vez vistas algunas guías para desarrollar interfaces de usuario de videojuegos
atractivas y con flow, se va a describir la estructura básica que deben tener.
Existe una estructura básica que un videojuego debe seguir. No es buena práctica
comenzar el juego directamente en el “terreno de juego” o “campo de batalla”. La
estructura típica que debe seguir la interfaz de un videojuego es la siguiente:
En primer lugar, es buena práctica mostrar una splash screen. Este tipo de pantallas
se muestran al ejecutar el juego, o mientras se carga algún recurso que puede durar un
tiempo considerable, y pueden ser usadas para mostrar información sobre el juego o
sobre sus desarrolladores. Suelen mostrarse a pantalla completa, o de menor tamaño
pero centradas. La Figura 14.1 muestra la splash screen del juego free orion.
Las otros dos elementos de la esctructura de un videojuego son el Menú y el HUD.
Suponen una parte muy importante de la interfaz de un juego, y es muy común utilizar
Widgets en ellos. A continuacion se analizarán más en detalle y se mostrarán algunos
ejemplos.
Figura 14.2: Extracto del menú de configuración de Nexuiz (izquierda), e interfaz de ScummVM (derecha).
14.1.1. Menú
Todos los videojuegos deben tener un Menú desde el cual poder elegir los modos
de juego, configurar opciones, mostrar información adicional y otras características.
Dentro de estos menús, es muy frecuente el uso de Widgets como botones, barras des-
lizantes (por ejemplo, para configurar la resolución de pantalla), listas desplegables
(para elegir idioma), o check buttons (para activar o desactivar opciones). Por eso es
importante disponer de un buen repertorio de Widgets, y que sea altamente personali-
zable para poder adaptarlos al estilo visual del videojuego.
En la Figura 14.2 se puede apreciar ejemplos de interfaces de dos conocidos jue-
gos open-source. La interfaz de la izquierda, correspondiente al juego Nexuiz, muestra
un trozo de su dialogo de configuración, mientras que la de la derecha corresponde a
la interfaz de ScummVM. En estos pequeños ejemplos se muestra un número conside-
rable de Widgets, cuyo uso es muy común:
14.1.2. HUD
En relación a las interfaces de los videojuegos, concretamente se denomina HUD
(del inglés, Head-Up Display), a la información y elementos de interacción mostrados
durante el propio transcurso de la partida. La información que suelen proporcionar son
la vida de los personajes, mapas, velocidad de los vehículos, etc.
En la Figura 14.3 se muestra una parte de la interfaz del juego de estrategia am-
bientada en el espacio FreeOrion. La interfaz utiliza elementos para mostrar los pará-
metros del juego, y también utiliza Widgets para interactuar con él.
Como se puede intuir, el uso de estos Widgets es muy común en cualquier vi-
deojuego, independientemente de su complejidad. Sin embargo, crear desde cero un
conjunto medianamente funcional de estos es una tarea nada trivial, y que roba mucho
tiempo de la línea principal de trabajo, que es el desarrollo del propio videojuego.
14.2. Introducción CEGUI [387]
Una vez que se ha dado unas guías de estilo y la estructura básicas para cualquier
videojuego, y se ha mostrado la importancia de los Widgets en ejemplos reales, se va
a estudiar el uso de una potente biblioteca que proporciona estos mismos elementos
para distintos motores gráficos, para que el desarrollo de Widgets para videojuegos sea
lo menos problemático posible.
C14
Figura 14.3: Screenshot del HUD del juego FreeOrion.
CEGUI’s mission
Como dice en su página web, “CE-
GUI está dirigido a desarrolladores 14.2. Introducción CEGUI
de videojuegos que deben invertir
su tiempo en desarrollar buenos jue-
gos, no creando subsistemas de in- CEGUI (Crazy Eddie’s GUI)[2] (Crazy Eddie’s GUI) es una biblioteca open sour-
terfaces de usuario”. ce multiplataforma que proporciona entorno de ventanas y Widgets para motores grá-
ficos, en los cuales no se da soporte nativo, o es muy deficiente. Es orientada a objetos
y está escrita en C++.
CEGUI es una biblioteca muy potente en pleno desarrollo, por lo que está
sujeta a continuos cambios. Todas las características y ejemplos descritos a lo
largo de este capítulo se han creado utilizando la versión actualmente estable,
la 0.7.x. No se asegura el correcto funcionamiento en versiones anteriores o
posteriores.
CEGUI y Ogre3D CEGUI es muy potente y flexible. Es compatible con los motores gráficos OpenGL,
Direct3D, Irrlicht y Ogre3D.
A lo largo de estos capítulos,
se utilizará la fórmula CEGUI/O-
gre3D/OIS para proveer interfaz
gráfica, motor de rendering y ges-
De la misma forma que Ogre3D es únicamente un motor de rendering, CEGUI
tión de eventos a los videojuegos, es sólo un motor de de gestión de Widgets, por lo que el renderizado y la gestión de
aunque también es posible utilizarlo eventos de entrada deben ser realizadas por bibliotecas externas.
con otras bibliotecas (ver documen-
tación de CEGUI).
En sucesivas secciones se explicará cómo integrar CEGUI con las aplicaciones de
este curso que hacen uso de Ogre3D y OIS. Además se mostrarán ejemplos prácticos
de las características más importantes que ofrece.
[388] CAPÍTULO 14. INTERACCIÓN Y WIDGETS
14.2.1. Instalación
En las distribuciones actuales más comunes de GNU/Linux (Debian, Ubuntu), está
disponible los paquetes de CEGUI para descargar, sin embargo estos dependen de la
versión 1.7 de Ogre, por lo que de momento no es posible utilizar la combinación CE-
GUI+OGRE 1.8 desde los respositorios. Para ello, es necesario descargarse el código
fuente de la última versión de CEGUI y compilarla. A continuación se muestran los
pasos.
./configure cegui_enable_ogre=yes
make && sudo make install
Estos pasos instalarán CEGUI bajo el directorio /usr/local/, por lo que es im-
portante indicar en el Makefile que las cabeceras las busque en el directorio
/usr/local/include/CEGUI.
Además, algunos sistemas operativos no buscan las bibliotecas de enlazado
dinámico en /usr/local/lib por defecto. Esto produce un error al ejecutar la
aplicación indicando que no puede encontrar las bibliotecas libCEGUI*. Pa-
ra solucionarlo, se puede editar como superusuario el fichero /etc/ld.so.conf,
y añadir la línea include /usr/local/lib. Para que los cambios surtan efecto,
ejecutar sudo ldconfig.
14.2.2. Inicialización
La arquitectura de CEGUI es muy parecida a la de Ogre3D, por lo que su uso es
similar. Está muy orientado al uso de scripts, y hace uso del patrón Singleton para
implementar los diferentes subsistemas. Los más importantes son:
Estos subsistemas ofrecen funciones para poder gestionar los diferentes recursos
que utiliza CEGUI. A continuación se listan estos tipos de recursos:
14.2. Introducción CEGUI [389]
C14
Listado 14.1: Inicializacón de CEGUI para su uso con Ogre3D
1 #include <CEGUI.h>
2 #include <RendererModules/Ogre/CEGUIOgreRenderer.h>
3
4 CEGUI::OgreRenderer* renderer = &CEGUI::OgreRenderer::
bootstrapSystem();
5
6 CEGUI::Scheme::setDefaultResourceGroup("Schemes");
7 CEGUI::Imageset::setDefaultResourceGroup("Imagesets");
8 CEGUI::Font::setDefaultResourceGroup("Fonts");
9 CEGUI::WindowManager::setDefaultResourceGroup("Layouts");
10 CEGUI::WidgetLookManager::setDefaultResourceGroup("LookNFeel");
Y las opciones que hay que añadir al Makefile para compilar son:
Es importante incluir los flags de compilado, en la línea 2, para que encuentre las
cabeceras según se han indicado en el ejemplo de inicialización.
Esto es sólo el código que inicializa la biblioteca en la aplicación para que pueda
comenzar a utilizar CEGUI como interfaz gráfica, todavía no tiene ninguna funciona-
lidad. Pero antes de empezar a añadir Widgets, es necesario conocer otros conceptos
primordiales.
CEGUI::UDim(scale, offset)
Indica la posición en una dimensión. El primer parámetro indica la posición rela- (left, top)
tiva, que toma un valor entre 0 y 1, mientras que el segundo indica un desplazamiento
absoluto en píxeles.
Por ejemplo, supongamos que posicionamos un Widget con UDim 0.5,20 en la di- Widget
mensión x. Si el ancho de la pantalla fuese 640, la posición sería 0.5*640+20 = 340,
mientras que si el ancho fuese 800, la posición seriá 0.5*800+20 = 420. En la Figu-
ra 14.5 se aprecian los dos ejemplos de forma gráfica. De esta forma, si la resolución
de la pantalla cambia, por ejemplo, el Widget se reposicionará y se redimensionará de
forma automática. (right, bottom)
Teniendo en cuenta cómo expresar el posicionamiento en una única dimensión Figura 14.4: Área rectangular defi-
utilizando UDim, se definen dos elementoss más. nida por URect.
14.2. Introducción CEGUI [391]
0.5 20 px
0.5 20 px
640x480 800x600
Coordenada x
CEGUI::UVector2(UDim x, UDim y)
C14
que está compuesto por dos UDim, uno para la coordenada x, y otro para la y, o
para el ancho y el alto, dependiendo de su uso.
El segundo elemento, se utiliza para definir un área rectangular:
Como muestra la Figura 14.4, los dos primeros definen la esquina superior izquier-
da, y los dos últimos la esquina inferior derecha.
14 }
15
16 bool MyFrameListener::mousePressed(const OIS::MouseEvent& evt, OIS
::MouseButtonID id)
17 {
18 CEGUI::System::getSingleton().injectMouseButtonDown(
convertMouseButton(id));
19 return true;
20 }
21
22 bool MyFrameListener::mouseReleased(const OIS::MouseEvent& evt, OIS
::MouseButtonID id)
23 {
24 CEGUI::System::getSingleton().injectMouseButtonUp(
convertMouseButton(id));
25 return true;
26 }
Además, es necesario convertir la forma en que identifica OIS los botones del
ratón, a la que utiliza CEGUI, puesto que no es la misma, al contrario que sucede con
las teclas del teclado. Para ello se ha escrito la función convertMouseButton():
Listado 14.5: Función de conversión entre identificador de botones de ratón de OIS y CEGUI.
1 CEGUI::MouseButton MyFrameListener::convertMouseButton(OIS::
MouseButtonID id)
2 {
3 CEGUI::MouseButton ceguiId;
4 switch(id)
5 {
6 case OIS::MB_Left:
7 ceguiId = CEGUI::LeftButton;
8 break;
9 case OIS::MB_Right:
10 ceguiId = CEGUI::RightButton;
11 break;
12 case OIS::MB_Middle:
13 ceguiId = CEGUI::MiddleButton;
14 break;
15 default:
16 ceguiId = CEGUI::LeftButton;
17 }
18 return ceguiId;
19 }
Por otro lado, también es necesario decir a CEGUI cuánto tiempo ha pasado desde
la detección del último evento, por lo que hay que añadir la siguiente línea a la función
frameStarted():
Listado 14.6: Orden que indica a CEGUI el tiempo transcurrido entre eventos.
1 CEGUI::System::getSingleton().injectTimePulse(evt.
timeSinceLastFrame)
Es importante tener en cuenta que en CEGUI, todos los elementos son Win-
dows. Cada uno de los Windows puede contener a su vez otros Windows. De
este modo, pueden darse situaciones raras como que un botón contenga a otro
botón, pero que en la práctica no suceden.
C14
8 CEGUI::WidgetLookManager::setDefaultResourceGroup("LookNFeel");
9
10 CEGUI::SchemeManager::getSingleton().create("TaharezLook.scheme")
;
11 CEGUI::System::getSingleton().setDefaultFont("DejaVuSans-10");
12 CEGUI::System::getSingleton().setDefaultMouseCursor("TaharezLook"
,"MouseArrow");
13
14 //Creating GUI Sheet
15 CEGUI::Window* sheet = CEGUI::WindowManager::getSingleton().
createWindow("DefaultWindow","Ex1/Sheet");
16
17 //Creating quit button
18 CEGUI::Window* quitButton = CEGUI::WindowManager::getSingleton().
createWindow("TaharezLook/Button","Ex1/QuitButton");
19 quitButton->setText("Quit");
20 quitButton->setSize(CEGUI::UVector2(CEGUI::UDim(0.15,0),CEGUI::
UDim(0.05,0)));
21 quitButton->setPosition(CEGUI::UVector2(CEGUI::UDim(0.5-0.15/2,0)
,CEGUI::UDim(0.2,0)));
22 quitButton->subscribeEvent(CEGUI::PushButton::EventClicked,
23 CEGUI::Event::Subscriber(&MyFrameListener::quit,
24 _framelistener));
25 sheet->addChildWindow(quitButton);
26 CEGUI::System::getSingleton().setGUISheet(sheet);
27 }
Los Widgets de la interfaz gráfica se organizan de forma jerárquica, de forma Convenio de nombrado
análoga al grafo de escena Ogre. Cada Widget (o Window) debe pertenecer a otro CEGUI no exige que se siga ningún
que lo contenga. De este modo, debe de haber un Widget “padre” o “raíz”. Este Wid- convenio de nombrado para sus ele-
get se conoce en CEGUI como Sheet (del inglés, hoja, refiriéndose a la hoja en blanco mentos. Sin embargo, es altamen-
te recomendable utilizar un conve-
que contiene todos los elementos). Esta se crea en la línea 15. El primer parámetro nio jerárquico, utilizando la barra
indica el tipo del Window, que será un tipo genérico, DefaultWindow. El segundo es “/” como separador.
el nombre que se le da a ese elemento. Con Ex1 se hace referencia a que es el primer
ejemplo (Example1), y el segundo es el nombre del elemento.
MouseClicked
MouseEnters
MouseLeaves
EventActivated
Figura 14.6: Screenshot de la pri-
EventTextChanged mera aplicación de ejemplo.
14.4. Tipos de Widgets [395]
EventAlphaChanged
EventSized
Listado 14.8: Función que implementa el comportamiento del botón al ser pulsado.
1 bool MyFrameListener::quit(const CEGUI::EventArgs &e)
2 {
3 _quit = true;
4 return true;
5 }
C14
En la Figura 14.6 se puede ver una captura de esta primera aplicación.
Button
Check Box
Combo Box
Frame Window
[396] CAPÍTULO 14. INTERACCIÓN Y WIDGETS
List Box
Progress Bar
Slider
Static Text
etc
El siguiente paso en el aprendizaje de CEGUI es crear una interfaz que bien podría
servir para un juego, aprovechando las ventajas que proporcionan los scripts.
14.5. Layouts
Los scripts de layouts especifican qué Widgets habrá y su organización para una
ventana específica. Por ejemplo, un layout llamado chatBox.layout puede contener la
estructura de una ventana con un editBox para insertar texto, un textBox para mostrar
la conversación, y un button para enviar el mensaje. De cada layout se pueden crear
tantas instancias como se desee.
No hay que olvidar que estos ficheros son xml, por lo que deben seguir su estruc-
tura. La siguiente es la organización genérica de un recurso layout:
En la línea 1 se escribe la cabecera del archivo xml, lo cual no tiene nada que ver
con CEGUI. En la línea 2 se abre la etiqueta GUILayout, para indicar el tipo de script
y se cierra en la línea 13.
A partir de aquí, se definen los Windows que contendrá la interfaz (¡en CEGUI todo
es un Window!). Para declarar uno, se indica el tipo de Window y el nombre, como se
puede ver en la línea 3. Dentro de él, se especifican sus propiedades (líneas 4 y 5).
Estas propiedades pueden indicar el tamaño, la posición, el texto, la transparencia,
etc. Los tipos de Widgets y sus propiedades se definían en el esquema escogido, por
lo que es necesario consultar su documentación específica. En la Sección 14.6 se verá
un ejemplo concreto.
En la línea 6 se muestra un comentario en xml.
Después de la definición de las propiedades de un Window, se pueden definir más
Windows que pertenecerán al primero, ya que los Widgets siguen una estructura jerár-
quica.
14.6. Ejemplo de interfaz [397]
C14
16 <Property Name="CurrentValue" Value="75" />
17 <Property Name="MaximumValue" Value="100" />
18 <Property Name="MinimumValue" Value="0" />
19 <Property Name="UnifiedAreaRect" Value="
{{0.0598,0},{0.046,0},{0.355,0},{0.166,0}}" />
20 </Window>
21 <!-- Fullscreen parameter -->
22 <Window Type="TaharezLook/StaticText" Name="Cfg/FullScrText" >
23 <Property Name="Text" Value="Fullscreen" />
24 <Property Name="UnifiedAreaRect" Value="
{{0.385,0},{0.226,0},{0.965,0},{0.367,0}}" />
25 </Window>
26 <Window Type="TaharezLook/Checkbox" Name="Cfg/FullscrCheckbox"
>
27 <Property Name="UnifiedAreaRect" Value="
{{0.179,0},{0.244,0},{0.231,0},{0.370,0}}" />
28 </Window>
29 <!-- Port parameter -->
30 <Window Type="TaharezLook/StaticText" Name="Cfg/PortText" >
31 <Property Name="Text" Value="Port" />
32 <Property Name="UnifiedAreaRect" Value="
{{0.385,0},{0.420,0},{0.9656,0},{0.551,0}}" />
33 </Window>
34 <Window Type="TaharezLook/Editbox" Name="Cfg/PortEditbox" >
35 <Property Name="Text" Value="1234" />
36 <Property Name="MaxTextLength" Value="1073741823" />
37 <Property Name="UnifiedAreaRect" Value="
{{0.0541,0},{0.417,0},{0.341,0},{0.548,0}}" />
38 <Property Name="TextParsingEnabled" Value="False" />
39 </Window>
40 <!-- Resolution parameter -->
41 <Window Type="TaharezLook/StaticText" Name="Cfg/ResText" >
42 <Property Name="Text" Value="Resolution" />
43 <Property Name="UnifiedAreaRect" Value="
{{0.385,0},{0.60,0},{0.965,0},{0.750,0}}" />
44 </Window>
45 <Window Type="TaharezLook/ItemListbox" Name="Cfg/ResListbox" >
46 <Property Name="UnifiedAreaRect" Value="
{{0.0530,0},{0.613,0},{0.341,0},{0.7904,0}}" />
47 <Window Type="TaharezLook/ListboxItem" Name="Cfg/Res/Item1">
48 <Property Name="Text" Value="1024x768"/>
49 </Window>
[398] CAPÍTULO 14. INTERACCIÓN Y WIDGETS
Cada uno de los Windows tiene unas propiedades para personalizarlos. En la do-
cumentación se explican todas y cada una de las opciones de los Window en función
del esquema que se utilice.
Text: indica el texto del Widget. Por ejemplo, la etiqueta de un botón o el título
de una ventana.
14.6. Ejemplo de interfaz [399]
C14
del rectángulo (los dos primeros UDims), y la inferior derecha.
Es importante tener en cuenta que si al Widget hijo se le indique que ocupe
todo el espacio (con el valor para la propiedad de {{0,0},{0,0},{1,0},{1,0}}),
ocupará todo el espacio del Window al que pertenezca, no necesariamente toda
la pantalla.
TitlebarFont: indica el tipo de fuente utilizado en el título de la barra de una
FrameWindow.
TitlebarEnabled: activa o desactiva la barra de título de una FrameWindow.
CurrentValue: valor actual de un Widget al que haya que indicárselo, como el
Spinner.
MaximumValue y MinimumValue: acota el rango de valores que puede tomar un
Widget.
Cada Widget tiene sus propiedades y uso especial. Por ejemplo, el Widget utilizado
para escoger la resolución (un ItemListBox), contiene a su vez otros dos Window del
tipo ListBoxItem, que representan cada una de las opciones (líneas 47 y 50).
Para poder añadir funcionalidad a estos Widgets (ya sea asociar una acción o uti-
lizar valores, por ejemplo), se deben recuperar desde código mediante el WindowMa-
nager, con el mismo nombre que se ha especificado en el layout. Este script por tanto
se utiliza únicamente para cambiar la organización y apariencia de la interfaz, pero no
su funcionalidad.
En este ejemplo concreto, sólo se ha añadido una acción al botón con la etiqueta
“Exit” para cerrar la aplicación.
[400] CAPÍTULO 14. INTERACCIÓN Y WIDGETS
www.cegui.org.uk/wiki/index.php/CEED
Al tratarse de una versión inestable, es mejor consultar este tutorial para mantener
los pasos actualizados de acuerdo a la última versión en desarrollo.
14.8.1. Scheme
Los esquemas contienen toda la información para ofrecer Widgets a una interfaz,
su apariencia, su comportamiento visual o su tipo de letra.
Al igual que sucedía con los layout, comienzan con una etiqueta que identifican el
tipo de script. Esta etiqueta es GUIScheme, en la línea 2.
De las líneas 3-6 se indican los scripts que utilizará el esquema de los otros tipos.
Qué conjunto de imágenes para los botones, cursores y otro tipo de Widgets mediante
el Imageset (línea 3), qué fuentes utilizará mediante un Font (línea 4), y el comporta-
miento visual de los Widgets a través del LookNFeel (línea 5). Además se indica qué
sistema de renderizado de skin utilizará (línea 6). CEGUI utiliza Falagard.
14.8. Scripts en detalle [401]
14.8.2. Font
Describen los tipos de fuente que se utilizarán en la interfaz.
C14
14.8.3. Imageset
Contiene las verdaderas imágenes que compondrán la interfaz. Este recurso es,
junto al de las fuentes, los que más sujetos a cambio están para poder adaptar la apa-
riencia de la interfaz a la del videojuego.
14.8.4. LookNFeel
Estos tipos de scripts son mucho más complejos, y los que CEGUI proporciona
suelen ser más que suficiente para cualquier interfaz. Aún así, en la documentación se
puede consultar su estructura y contenido.
C14
27 CEGUI::Imageset& imageSet = CEGUI::ImagesetManager::getSingleton().
create("RTTImageset", guiTex);
28 imageSet.defineImage("RTTImage",
29 CEGUI::Point(0.0f,0.0f),
30 CEGUI::Size(guiTex.getSize().d_width, guiTex.getSize()
.d_height),
31 CEGUI::Point(0.0f,0.0f));
32
33 CEGUI::Window* ex1 = CEGUI::WindowManager::getSingleton().
loadWindowLayout("render.layout");
34
35 CEGUI::Window* RTTWindow = CEGUI::WindowManager::getSingleton().
getWindow("CamWin/RTTWindow");
36
37 RTTWindow->setProperty("Image",CEGUI::PropertyHelper::imageToString
(&imageSet.getImage("RTTImage")));
38
39 //Exit button
40 CEGUI::Window* exitButton = CEGUI::WindowManager::getSingleton().
getWindow("ExitButton");
41 exitButton->subscribeEvent(CEGUI::PushButton::EventClicked,
42 CEGUI::Event::Subscriber(&MyFrameListener::quit,
43 _framelistener));
44 //Attaching layout
45 sheet->addChildWindow(ex1);
46 CEGUI::System::getSingleton().setGUISheet(sheet);
14.10.1. Introducción
El formato de las etiquetas utilizadas son el siguiente:
14.10. Formateo de texto [405]
[tag-name=’value’]
Si se quiere mostrar como texto la cadena “[Texto]” sin que lo interprete como
una etiqueta, es necesario utilizar un carácter de escape. En el caso concreto
de C++ se debe anteponer “\\” únicamente a la primera llave, de la forma
“\\[Texto]”
14.10.2. Color
Para cambiar el color del texto, se utiliza la etiqueta “colour”, usada de la siguiente
forma:
C14
[colour=’FFFF0000’]
Esta etiqueta colorea el texto que la siguiese de color rojo. El formato en el que
se expresa el color es ARGB de 8 bits, es decir ’AARRGGBB’. El primer parámetro
expresa la componente de transparencia alpha, y el resto la componente RGB.
14.10.3. Formato
Para cambiar el formato de la fuente (tipo de letra, negrita o cursiva, por ejemplo)
es más complejo ya que se necesitan los propios ficheros que definan ese tipo de
fuente, y además deben estar definidos en el script Font.
Estando seguro de tener los archivos .font y de tenerlos incluidos dentro del fichero
del esquema, se puede cambiar el formato utilizando la etiqueta:
[font=’Arial-Bold-10’]
Esta en concreto corresponde al formato en negrita del tipo de letra Arial, de ta-
maño 10. El nombre que se le indica a la etiqueta es el que se especificó en el .scheme.
[imageset=’set:<imageset> image:<image>’]
Una vez más, CEGUI trata las imágenes individuales como parte de un Imageset,
por lo que hay que indicarle el conjunto, y la imagen.
[406] CAPÍTULO 14. INTERACCIÓN Y WIDGETS
[image=’set:TaharezLook image=CloseButtonNormal’]
Además, existe otra etiqueta poder cambiar el tamaño de las imágenes insertadas.
El formato de la etiqueta es el siguiente:
[image-size=’w:<width_value> h:<height_value>’]
El valor del ancho y del alto se da en píxeles, y debe ponerse antes de la etiqueta
que inserta la imagen. Para mostrar las imágenes en su tamaño original, se deben poner
los valores de width y height a cero.
[vert-alignment=’<tipo_de_alineamiento>’]
14.10.6. Padding
El padding consiste en reservar un espacio alrededor del texto que se desee. Para
definirlo, se indican los píxeles para el padding izquierdo, derecho, superior e inferior.
Así, además de el espacio que ocupe una determinada cadena, se reservará como un
margen el espacio indicado en el padding. Viendo la aplicación de ejemplo se puede
apreciar mejor este concepto.
El formato de la etiqueta es:
C14
14.10.7. Ejemplo de texto formateado
El siguiente es un ejemplo que muestra algunas de las características que se han
descrito. En la Figura 14.11 se aprecia el acabado final. El siguiente es el layout utili-
zado:
Y el siguiente listado muestra cada una de las cadenas que se han utilizado, junto
a las etiquetas:
2
3 "El tipo de letra puede [font=’Batang-26’]cambiar de un momento a
otro, [font=’fkp-16’]y sin previo aviso!"
4
5 "Si pulsas aqui [image-size=’w:40 h:55’][image=’set:TaharezLook
image:CloseButtonNormal’] no pasara nada :("
6
7 "[font=’Batang-26’] Soy GRANDE, [font=’DejaVuSans-10’][vert-
alignment=’top’] puedo ir arriba, [vert-alignment=’bottom’]o
abajo, [vert-alignment=’centre’]al centro..."
8
9 "En un lugar de la [padding=’l:20 t:15 r:20 b:15’]Mancha[padding=’l
:0 t:0 r:0 b:0’], de cuyo nombre no quiero acordarme, no ha
mucho..."
La primera cadena (línea 1) utiliza las etiquetas del tipo colour para cambiar el
color del texto escrito a partir de ella. Se utilizan los colores rojo, verde y azul, en ese
orden.
La segunda (línea 3) muestra cómo se pueden utilizar las etiquetas para cambiar
totalmente el tipo de fuente, siempre y cuando estén definidos los recursos .font y estos
estén reflejados dentro el .scheme.
La tercera (línea 5) muestra una imagen insertada en medio del texto, y además
redimensionada. Para ello se utiliza la etiqueta de redimensionado para cambiar el ta-
maño a 40x55, y después inserta la imagen “CloseButtonNormal”, del conjunto “Taha-
rezLook”
La cuarta (línea 7) muestra una cadena con un texto (“Soy GRANDE”) de un tipo
de fuente con un tamaño 30, y el resto con un tamaño 10. Para el resto del texto, sobra
espacio vertical, por lo que se utiliza la etiqueta vertical-alignment para indicar dónde
posicionarlo.
Por último, la quinta cadena (línea 9), utiliza padding para la palabra “Mancha”. A
esta palabra se le reserva un margen izquierdo y derecho de 20 píxeles, y un superior
e inferior de 15.
Materiales y Texturas
15
Carlos González Morcillo
Iluminación
E
Local n este capítulo estudiaremos los conceptos fundamentales con la definición de
materiales y texturas. Introduciremos la relación entre los modos de sombrea-
L do y la interacción con las fuentes de luz, describiendo los modelos básicos
Punto
Vista
soportados en aplicaciones interactivas. Para finalizar, estudiaremos la potente apro-
ximación de Ogre para la definición de materiales, basándose en los conceptos de
técnicas y pasadas.
15.1. Introducción
Los materiales describen las propiedades físicas de los objetos relativas a cómo
Iluminación reflejan la luz incidente. Obviamente, el aspecto final obtenido será dependiente tanto
Global de las propiedades del material, como de la propia definición de las fuentes de luz. De
este modo, materiales e iluminación están íntimamente relacionados.
L
Desde los inicios del estudio de la óptica, investigadores del campo de la física
Punto
Vista han desarrollado modelos matemáticos para estudiar la interacción de la luz en las
superficies. Con la aparición del microprocesador, los ordenadores tuvieron suficiente
potencia como para poder simular estas complejas interacciones.
Así, usando un ordenador y partiendo de las propiedades geométricas y de mate-
riales especificadas numéricamente es posible simular la reflexión y propagación de la
luz en una escena. A mayor precisión, mayor nivel de realismo en la imagen resultado.
409
[410] CAPÍTULO 15. MATERIALES Y TEXTURAS
a) b)
Figura 15.2: Ejemplo de resultado utilizando un modelo de iluminación local y un modelo de iluminación
global con la misma configuración de fuentes de luz y propiedades de materiales. a) En el modelo de ilumi-
nación local, si no existen fuentes de luz en el interior de la habitación, los objetos aparecerán totalmente “a
oscuras”, ya que no se calculan los rebotes de luz indirectos. b) Los modelos de iluminación global tienen
en cuenta esas contribuciones relativas a los rebotes de luz indirectos.
+ + + =
Sombreado difuso. Muchos objetos del mundo real tienen una acabado emi-
nentemente mate (sin brillo). Por ejemplo, el papel o una tiza pueden ser su-
perficies con sombreado principalmente difuso. Este tipo de materiales reflejan
la luz en todas las direcciones, debido principalmente a las rugosidades mi-
C15
croscópicas del material. Como efecto visual, el resultado de la iluminación es
mayor cuando la luz incide perpendicularmente en la superficie. La intensidad
final viene determinada por el ángulo que forma la luz y la superficie (es inde-
pendiente del punto de vista del observador). En su expresión más simple, el
modelo de reflexión difuso de Lambert dice que el color de una superficie es
proporcional al coseno del ángulo formado entre la normal de la superficie y el
vector de dirección de la fuente de luz (ver Figura 15.3).
Sombreado especular. Es el empleado para simular los brillos de algunas su-
perficies, como materiales pulidos, pintura plástica, etc. Una característica prin-
cipal del sombreado especular es que el brillo se mueve con el observador. Esto
implica que es necesario tener en cuenta el vector del observador. La dureza del
brillo (la cantidad de reflejo del mismo) viene determinada por un parámetro h.
A mayor valor del parámetro h, más concentrado será el brillo. El comporta-
miento de este modelo de sombreado está representado en la Figura 15.5, donde
r es el vector reflejado del l (forma el mismo ángulo α con n) y e es el vector
que se dirige del punto de sombreado al observador. De esta forma, el color final
de la superficie es proporcional al ángulo θ.
L
Sombreado ambiental. Esta componente básica permite añadir una aproxima-
n ción a la iluminación global de la escena. Simplemente añade un color base
r independiente de la posición del observador y de la fuente de luz. De esta for-
α α ma se evitan los tonos absolutamente negros debidos a la falta de iluminación
l θ e global, añadiendo este término constante. En la Figura 15.4 se puede ver la
componente ambiental de un objeto sencillo.
Sombreado de emisión. Finalmente este término permite añadir una simula-
Figura 15.5: En el modelo de som-
breado especular, el color c se ob-
ción de la iluminación propia del objeto. No obstante, debido a la falta de inter-
tiene como c ∝ (r · e)h . Los vecto- acción con otras superficies (incluso con las caras poligonales del propio obje-
res r y e deben estar normalizados. to), suele emplearse como una alternativa al sombreado ambiental a nivel local.
El efecto es como tener un objeto que emite luz pero cuyos rayos no interactúan
con ninguna superficie de la escena (ver Figura 15.4).
[412] CAPÍTULO 15. MATERIALES Y TEXTURAS
Una de las características interesantes de los modelos de mapeado de texturas Figura 15.6: Definición de una sen-
(tanto ortogonales como mediante mapas paramétricos UV) es la indepen- cilla textura procedural que define
dencia de la resolución de la imagen. De este modo es posible tener textu- bandas de color dependiendo del
ras de diferentes tamaños y emplearlas aplicando técnicas de nivel de detalle valor de coordenada Z del punto
(LOD). Así, si un objeto se muestra a una gran distancia de la cámara es po- 3D.
sible cargar texturas de menor resolución que requieran menor cantidad de
memoria de la GPU.
u=0.35 Asociación de
coordendas de textura
UV a cada vértice de
cada cara del modelo.
Figura 15.7: Asignación de coordenadas UV a un modelo poligonal. Esta operación suele realizarse con el
soporte de alguna herramienta de edición 3D.
C15
Proyección Proyección Proyección Proyección
Textura a Mapear Plana Cúbica Cilíndrica Esférica
Figura 15.8: Métodos básicos de mapeado ortogonal sobre una esfera. Los bordes marcados con líneas de
colores en la textura a mapear en la izquierda se proyectan de forma distinta empleado diversos métodos de
proyección.
Material
Technique 1 Technique 2 Technique N
Pass 2 Pass 2
... Pass 2
Figura 15.10: Descripción de un material en base a técnicas y pasadas. El número de técnicas y pasadas
asociadas a cada técnica puede ser diferente.
Con la idea de realizar estas optimizaciones, Ogre define que por defecto los mate-
riales son compartidos entre objetos. Esto implica que el mismo puntero que referencia
a un material es compartido por todos los objetos que utilizan ese material. De este
modo, si queremos cambiar la propiedad de un material de modo que únicamente afec-
te a un objeto es necesario clonar este material para que los cambios no se propaguen
al resto de objetos.
Los materiales de Ogre se definen empleando técnicas y esquemas. Una Técnica
puede definirse como cada uno de los modos alternativos en los que puede renderi-
zarse un material. De este modo, es posible tener, por ejemplo, diferentes niveles de
detalle asociados a un material. Los esquemas agrupan técnicas, permitiendo definir
nombres y grupos que identifiquen esas técnicas como alto nivel de detalle, medio
rendimiento, etc.
15.4.1. Composición
Un material en Ogre está formado por una o varias técnicas, que contienen a su
vez una o varias Pasadas (ver Figura 15.10). En cada momento sólo puede existir
una técnica activa, de modo que el resto de técnicas no se emplearán en esa etapa de
render.
Una vez que Ogre ha decidido qué técnica empleará, generará tantas pasadas (en el
mismo orden de definición) como indique el material. Cada pasada define una opera-
ción de despliegue en la GPU, por lo que si una técnica tiene cuatro pasadas asociadas
a un material tendrá que desplegar el objeto tres veces en cada frame.
Las Unidades de Textura (Texture Unit) contienen referencias a una única textura.
Esta textura puede ser generada en código (procedural), puede ser obtenida mediante
un archivo o mediante un flujo de vídeo. Cada pasada puede utilizar tantas unidades
de textura como sean necesarias. Ogre optimiza el envío de texturas a la GPU, de
modo que únicamente se descargará de la memoria de la tarjeta cuando no se vaya a
utilizar más.
Atributo Descripción
lod_values Lista de distancias para aplicar diferentes niveles de detalle. Está relacionado
con el campo lod_strategy (ver API de Ogre).
receive_shadows Admite valores on (por defecto) y off. Indica si el objeto sólido puede recibir
sombras. Los objetos transparentes nunca pueden recibir sombras (aunque sí
arrojarlas, ver el siguiente campo).
transparency_casts_shadows Indica si el material transparente puede arrojar sombras. Admite valores on y
off (por defecto).
set_texture_alias Permite crear alias de un nombre de textura. Primero se indica el nombre del
alias y después del nombre de la textura original.
C15
depth_check on | off Por defecto on. Indica si se utilizará el depth-buffer para comprobar la profun-
didad.
lighting on | off Por defecto on. Indica si se empleará iluminación dinámica en la pasada.
shading (Ver valores) Indica el tipo de método de interpolación de iluminación a nivel de vértice. Se
especifican los valores de interpolación de sombreado flat o gouraud o phong.
Por defecto se emplea el método de gouraud.
polygon_mode (Ver valores) Indica cómo se representarán los polígonos. Admite tres valores: solid (por
defecto), wireframe o points.
Cuadro 15.2: Atributos generales de las Pasadas. Ver API de Ogre para una descripción completa de todos los atributos soportados.
Figura 15.13: Un material más complejo que incluye la definición de una texture_unit con diversas com-
ponentes de sombreado.
Los mapas de entorno se utilizan para simular la reflexión del mundo (representado
en el mapa) dependiendo de la relación entre las normales de las caras poligionales y la
posición del observador. En este caso, se indica a Ogre que el tipo de mapa de entorno
es esférico (tipo ojo de pez, ver Figura 15.12). El operador de colour_op empleado
indica cómo se combinará el color de la textura con el color base del objeto. Existen
diversas alternativas; mediante modulate se indica a Ogre que multiplique el color base
(en este caso, un color amarillo indicado en la componente difusa del color) y el color
del mapa.
A continuación se describen algunas de las propiedades generales de los mate-
riales. En la tabla 15.1 se resumen los atributos generales más relevantes empleados
en la definición de materiales, mientras que las tablas 15.2 y 15.3 resumen los atri-
butos globales más utilizados en la definición de las pasadas y unidades de textura
resepectivamente. Cada pasada tiene asociada cero o más unidades de textura. En los
siguientes ejemplos veremos cómo combinar varias pasadas y unidades de textura para
definir materiales complejos en Ogre.
15.5. Mapeado UV en Blender [417]
Atributo Descripción
texture Especifica el nombre de la textura (estática) para esta texture_unit. Permite especificar el tipo, y el
uso o no de canal alpha separado.
anim_texture Permite utilizar un conjunto de imágenes como textura animada. Se especifica el número de frames
y el tiempo (en segundos).
filtering Filtro empleado para ampliar o reducir la textura. Admite valores entre none, bilinear (por defec-
to), trilinear o anisotropic.
colour_op Permite determinar cómo se mezcla la unidad de textura. Admite valores entre replace, add, mo-
dulate (por defecto) o alpha_blend.
colour_op_ex Versión extendida del atributo anterior, que permite especificar con mucho mayor detalle el tipo
de mezclado.
env_map Uso de mapa de entorno. Si se especifica (por defecto está en off, puede tomar valores entre sphe-
rical, planar, cubic_reflection y cubic_normal.
rotate Permite ajustar la rotación de la textura. Requiere como parámetro el ángulo de rotación (en contra
de las agujas del reloj).
scale Ajusta la escala de la textura, especificando dos factores (en X e Y).
Cuadro 15.3: Atributos generales de las Unidades de Textura. Ver API de Ogre para una descripción completa de todos los atributos soportados
C15
es el mapeado paramétrico UV. Como hemos visto, a cada vértice del modelo (con
El mapeado UV es el estándar en coordenadas X,Y,Z) se le asocian dos coordenadas paramétricas 2D (U,V).
desarrollo de videojuegos, por lo
que prestaremos especial atención Los modelos de mapeado ortogonales no se ajustan bien en objetos complejos.
en este documento. Por ejemplo, la textura asociada a una cabeza de un personaje no se podría mapear
adecuadamente empleando modelos de proyección ortogonal. Para tener el realismo
necesario en modelos pintados manualmente (o proyectando texturas basadas en foto-
grafías) es necesario tener control sobre la posición final de cada píxel sobre la textura.
El mapa UV describe qué parte de la textura se asociará a cada polígono del modelo,
de forma que, mediante una operación de despliegue (unwrap) del modelo obtenemos
la equivalencia de la superficie en el plano 2D.
En esta sección estudiaremos con más detalle las opciones de Blender para trabajar
con este tipo de coordenadas.
Como vimos en el capítulo 11, para comenzar es necesario aplicar una operación
de despliegue del modelo para obtener una o varias regiones en la ventana de UV/Ima-
ge Editor . Esta operación de despliegue se realiza siempre en modo edición. Con
el objeto en modo✄ edición,
en los botones de edición , y tras aplicar el despliegue
(Unwrap) (tecla ✂U ✁), aparece un nuevo
✄ subpanel en el panel de herramientas (Tool
Shelf ), accesible mediante la tecla ✂T ✁(ver Figura 15.15) que controla el modo en el
que se realiza el despliegue del modelo. Este panel aparece mientras el objeto está en
Figura 15.14: El problema del des- modo de edición, y es dependiente del modo de despliegue elegido (por ejemplo, el de
pliegue se ha afrontado desde los la Figura 15.15 contiene las opciones de Unwrap, pero otros modos de despliegue ten-
orígenes de la cartografía. En la drán otras opciones asociadas). A continuación describimos las principales opciones
imagen, un mapa de 1482 muestra que pueden encontrarse en este tipo de subpaneles:
una proyección del mundo conoci-
do hasta el momento.
Angle Based | Conformal. Define el método de cálculo de la proyección. El
Isla UV basado en ángulo crea una nueva isla cuando el ángulo entre caras vecinas sea
relevante. En el modo conformal se realiza dependiendo del método de pro-
Se define una isla en un mapa UV
como cada región que contiene vér-
yección seleccionado. El método basado en ángulo ofrece, en general, mejores
tices no conectados con el resto. resultados.
[418] CAPÍTULO 15. MATERIALES Y TEXTURAS
Fill Holes. Intenta ordenar todas las islas para evitar que queden huecos en la
textura sin ninguna cara UV.
Correct Aspect. Permite el escalado de los vértices desplegados en UV para
ajustarse a la relación de aspecto de la textura a utilizar.
Margin. Define la separación mínima entre islas.
Use Subsurf Data. Utiliza el nivel de subdivisión especificado en Subsurf Tar-
get para el cálculo de las coordenadas UV en lugar de la malla original (Red de
Control).
Transform Correction. Corrige la distorsión del mapa UV mientras se está
editando.
Cube Size. Cuando se emplea una proyección cúbica, este parámetro indica el
porcentaje de la imagen que se emlpeará por el mapa UV (por defecto el 100 %).
Figura 15.15: Opciones del panel
Radius. Cuando se emplea el método de proyección Cylinder projection, este Unwrap.
parámetro define el radio del cilindro de proyección. A menores valores, las
coordenadas del objeto aparecerán estiradas en el eje Y.
Align: Polar ZX | Polar ZY. Determina el plano frontal de proyección en los
métodos cilíndrico y esférico.
a b c d
e f g h
Figura 15.19: Resultado obtenido tras aplicar diferentes métodos de despliegue (unwrap) al modelo de
Suzanne con todas las caras seleccionadas: a) Unwrap, b) Cube, c) Cylinder, d) Sphere, e) Project from
C15
view, f) Reset, g) Lightmap y h) Smart UV.
Figura 15.23: Utilización de costuras y despliegue del modelo utilizando diferentes técnicas de desplie-
gue. En la imagen de la izquerda se marcan perfectamente los seams definidos para recortar el modelo
adecuadamente.
15.5.1. Costuras
En el despliegue de mallas complejas, es necesario ayudar a los métodos de des-
pliegue definiendo costuras (seams). Estas costuras servirán para definir las zonas de
recorte, guiando así al método de desplegado.
Para definir una costura basta con elegir las aristas que la definen en modo edición,
tal y como se muestra en la Figura 15.23 . La selección de bucles de aristas (como en
el caso del bote de spray del modelo) puede realizarse cómodamente✄ seleccionando ✄
una de las aristas del bucle y empleando la combinación de teclas ✂Control ✁ ✂E ✁
Edge Loop. Cuando tenemos✄ la zona ✄ de recorte seleccionada, definiremos la costura
mediante la combinación ✂Control ✁✂E ✁Mark Seam. En este menú aparece igualmente
la opción para eliminar una costura Clear Seam.
15.6. Ejemplos de Materiales en Ogre [421]
C15
proyectar la misma textura que en el suelo de la escena.
En la definición del Material4 se utiliza una textura de tipo cebra donde las bandas
Figura 15.22: Subpanel de opcio- negras son totalmente transparentes. En la pasada de este material se utiliza el atributo
nes de Texture Paint en el que puede scene_blend que permite especificar el tipo de mezclado de esta pasada con el resto de
elegirse el color de pintado, tamaño contenido de la escena. Como veremos en el ejemplo del Material6, la pricipal dife-
del pincel, opacidad, etc... rencia entre las operaciones de mezclado a nivel de pasada o a nivel de textura es que
las primeras definen la mezcla con el contenido global de la escena, mientras que las
segundas están limitadas entre capas de textura. Mediante el modificador alpha_blend
se indica que utilizaremos el valor alpha de la textura. A continuación estudiaremos
las opciones permitidas por el atributo scene_blend en sus dos versiones.
El formato de scene_blend permite elegir cuatro parámetros en su forma básica:
Figura 15.24: Ejemplos de definición de algunos materiales empleando diversos modos de composición en
Ogre.
C15
utilizará su valor directamente sin modificación. La operación blend_current_alpha
(como veremos en el siguiente ejemplo) utiliza la información de alpha de las capas
anteriores. La operación modulate multiplica los valores de src1 y src2.
El Material6 define tres capas de textura. La primera carga una textura de césped.
La segunda capa carga la textura de cebra con transparencia, e indica que la combinará
con la anterior empleando la información de alpha de esta segunda textura. Finalmen-
te la tercera capa utiliza el operador extendido de colour_op_ex, donde define que
combinará el color de la capa actual (indicado como primer parámetro en src_texture
con el obtenido en las capas anteriores src_current. El parámetro blend_current_alpha
multiplica el alpha de src2 por (1-alpha(src1)).
Los ejemplos de la Figura 15.27 utilizan algunas de las opciones de animación
existentes en las unidades de textura. El Material7 por ejemplo define dos capas de
textura. La primera aplica la textura del suelo al objeto, utilizando el atributo rota-
te_anim. Este atributo requiere un parámetro que indica el número de revoluciones
por segundo (empleando velocidad constante) de la textura.
La composición de la segunda capa de textura (que emplea un mapeado de en-
torno esférico) se realiza utilizando el atributo colour_op_ex estudiado anteriormente.
La versión de la operación modulate_x2 multiplica el resultado de modulate (multi-
plicación) por dos, para obtener un resultado más brillante.
En el ejemplo del Material8 se definen dos capas de textura, ambas con anima-
ción. La segunda capa utiliza una operación de suma sobre la textura de cebra sin
opacidad, de modo que las bandas negras se ignoran. A esta textura se aplica una rota-
ción de 0.15 revoluciones por segundo. La primera capa utiliza el atributo scroll_anim
que permite definir un desplazamiento constante de la textura en X (primer parámetro)
y en Y (segundo parámetro). En este ejemplo la textura únicamente se desplaza en el
eje X. De igual modo, la primera capa emplea wave_xform para definir una animación
basado en una función de onda. En este caso se utiliza una función senoidal indicando
los parámetros requeridos de base, frecuencia, fase y amplitud (ver el manual de Ogre
para más detalles sobre el uso de la función).
[424] CAPÍTULO 15. MATERIALES Y TEXTURAS
a)
b) c) d)
Figura 15.28: Construcción del ejemplo de RTT. a) Modelo empleado para desplegar la textura de RTT.
El modelo se ha definido con tres materiales; uno para la carcasa, otro para las letras del logotipo (ambos
C15
desplegados en c), y uno para la pantalla llamado “pantallaTV”, desplegado en b). En d) se muestra el
resultado de la ejecución del programa de ejemplo.
33 }
✄
En la línea ✂5 ✁se obtiene un puntero a un RenderTexture que es una especialización
de la clase RenderTarget específica para renderizar sobre una textura.
Puede haber varios RenderTargets que generen resultados sobre la misma ✄ textura.
✄
A este objeto de tipo RenderTexture le asociamos una cámara en la línea ✂13 ✁(que ha
sido creada en las líneas ✂7-11 ✁), y configuramos las propiedades ✄ específidas del view-
port asociado (como que ✄
se limpie en cada frame en la línea ✂14 ✁, y que no represente
los overlays en la línea ✂16 ✁).
✄
En la línea ✂17 ✁indicamos que la textura se actualice automáticamente (gestionada
por el bucle principal de Ogre). En otro caso, será necesario ejecutar manualmente el
✄
método update del RenderTarget.
Las líneas ✂19-25 ✁declaran manualmente el material sobre ✄ el que emplearemos la
Texture Unit con la textura anteriormente definida. ✄ ✂
En ✁creamos un✄ material
✂21 ✁ (línea ✂22 ✁). Esa
19-20
llamado “RttMat”, con una única técnica (línea ✄ ) y una pasada
pasada define sus propiedades en las líneas ✂23-25 ✁, y añade como TextureUnit a la
textura “RttT”.
El último bucle recorre todas las SubEntities que forman a la entidad de la tele-
visión. En el caso✄ de que el material asociado a la subentidad sea el llamado “pan-
✄
tallaTV” (línea ✂ ✁), le asignamos el material que hemos creado anteriormente (línea
✂32 ✁).
31
C15
Listado 15.4: Utilización en MyApp.cpp
1 _textureListener = new MyTextureListener(entTV);
2 rtex->addListener(_textureListener);
✄
Para concluir, en las líneas ✂42-43 ✁se configuran los aspectos relativos a la cámara
Mirror en relación al plano de reflexión. La llamada a enableReflection hace que se
modifique el Frustum de la cámara de modo que renderice empleando la reflexión con
respecto del plano que se le pasa como argumento.
Finalmente, la llamada a enableCustomNearClipPlane permite recortar la geome-
tría situada debajo del plano pasado como argumento, de modo que únicamente la
geometría que está situada sobre el plano será finalmente desplegada en el reflejo,
evitando así errores de visualización.
E
n el capítulo anterior hemos estudiado algunas de las características relativas a
la definición de materiales básicos. Estas propiedades están directamente rela-
cionadas con el modelo de iluminación y la simulación de las fuentes de luz
que realicemos. Este capítulo introduce algunos conceptos generales sobre ilumina-
ción en videojuegos, así como técnicas ampliamente utilizadas para la simulación de
la iluminación global.
16.1. Introducción
Una pequeña parte de los rayos de luz son visibles al ojo humano. Aquellos rayos
que están definidos por una onda con longitud de onda λ entre 700 y 400nm. Variando
las longitudes de onda obtenemos diversos colores.
En gráficos por computador es habitual emplear los denominados colores-luz, don-
de el Rojo, Verde y Azul son los colores primarios y el resto se obtienen de su combi-
nación. En los colores-luz el color blanco se obtiene como la suma de los tres colores
básicos.
Figura 16.1: Gracias a la proyec- El RGB es un modelo clásico de este tipo. En el mundo físico real se trabaja
ción de sombras es posible cono- con colores-pigmento, donde el Cyan, el Magenta y el Amarillo forman los colores
cer la posición relativa entre obje- primerarios. La combinación de igual cantidad de los tres colores primarios obtiene el
tos. En la imagen superior no es po- color negro1 . Así, el CMYK (Cyan Magenta Yellow Key) empleado por las impresoras
sible determinar si el modelo de Su- es un clásico modelo de este tipo. De un modo simplificado, podemos definir los pasos
zanne reposa sobre algún escalón o
más relevantes para realizar la representación de una escena sintética:
está flotando en el aire. La imagen
inferior, gracias al uso de sombras 1 En realidad se obtiene un tono parduzco, por lo que habitualmente es necesario incorporar el negro
elimina esa ambigüedad visual. como color primario.
429
[430] CAPÍTULO 16. ILUMINACIÓN
1. La luz es emitida por las fuentes de luz de la escena (como el sol, una lámpara de
luz situada encima de la mesa, o un panel luminoso en el techo de la habitación).
2. Los rayos de luz interactúan con los objetos de la escena. Dependiendo de las
propiedades del material de estos objetos, parte de la luz será absorbida y otra
parte reflejada y propagada en diversas direcciones. Todos los rayos que no son
totalmente absorbidos continuarán rebotando en el entorno.
3. Finalmente algunos rayos de luz serán capturados por un sensor (como un ojo
humano, el sensor CCD (Charge-Coupled Device) de una cámara digital o una
película fotográfica).
Las fuentes puntuales (point lights) irradian energía en todas las direcciones
a partir de un punto que define su posición en el espacio. Este tipo de fuentes Fuente
permite variar su posición pero no su dirección. En realidad, las fuentes de luz Direccional
puntuales no existen como tal en el mundo físico (cualquier fuente de luz tiene
asociada un área, por lo que para realizar simulaciones realistas de la ilumina-
ción tendremos que trabajar con fuentes de área. Ogre define este tipo de fuente
como LT_POINT.
Uno de los tipos de fuentes más sencillo de simular son las denominadas fuentes
direccionales (directional lights), que pueden considerarse fuentes situadas a
una distancia muy grande, por lo que los rayos viajan en una única dirección
Fuente Foco
en la escena (son paralelos entre sí). El sol podría ser modelado mediante una
fuente de luz direccional. De este modo, la dirección viene determinada por un
Figura 16.2: Tipos de fuentes de
vector l (especificado en coordenadas universales). Este tipo de fuentes de luz luz utilizadas en aplicaciones in-
no tienen por tanto una posición asociada (únicamente dirección). Este tipo de teractivas. Ogre soporta únicamente
fuente está descrito como LT_DIRECTIONAL en Ogre. estos tres tipos de fuentes básicos.
16.3. Sombras Estáticas Vs Dinámicas [431]
Finalmente los focos (spot lights) son en cierto modo similares a las fuentes
de luz puntuales, pero añadiendo una dirección de emisión. Los focos arrojan
luz en forma cónica o piramidal en una dirección específica. De este modo,
requieren un parámetro de dirección, además de dos ángulos para definir los
conos de emisión interno y externo. Ogre las define como LT_SPOTLIGHT.
C16
Ogre soporta dos técnicas básicas de sombreado dinámico: sombreado empleando
el Stencil Buffer y mediante Mapas de Texturas (ver Figura 16.4). Ambas aproxima-
ciones admiten la especificación como Additive o Modulative.
a Las técnicas de tipo Modulative únicamente oscurecen las zonas que quedan en
sombra, sin importar el número de fuentes de luz de la escena. Por su parte, si la
técnica se especifica de tipo Additive es necesario realizar el cálculo por cada fuente
de luz de modo acumulativo, obteniendo un resultado más preciso (pero más costoso
computacionalmente).
Cada técnica tiene sus ventajas e inconvenientes (como por ejemplo, el relativo a
la resolución en el caso de los Mapas de Textura, como se muestra en la Figura 16.3).
b No es posible utilizar varias técnicas a la vez, por lo que deberemos elegir la técnica
que mejor se ajuste a las características de la escena que queremos representar. En
Figura 16.3: Comparativa entre términos generales, los métodos basados en mapas de textura suelen ser más precisos,
sombras calculadas por a) Mapas pero requieren el uso de tarjetas aceleradoras más potentes. Veamos las características
de Texturas y b) Stencil Buffer. Las generales de las técnicas de sombreado para estudiar a continuación las características
basadas en mapas de texturas son
claramente dependientes de la re-
particulares de cada método.
solución del mapa, por lo que de-
ben evitarse con áreas de proyec-
ción muy extensas.
[432] CAPÍTULO 16. ILUMINACIÓN
1. Para cada fuente de luz, obtener la lista de aristas de cada objeto que comparten
polígonos cuyo vector normal apunta “hacia” la fuente de luz y las que están
“opuestas” a la misma. Estas aristas definen la silueta del objeto desde el punto
de vista de la fuente de luz.
2. Proyectar estas aristas de silueta desde la fuente de luz hacia la escena. Obtener
el volumen de sombra proyectado sobre los objetos de la escena.
3. Finalmente, utilizar la información de profundidad de la escena (desde el punto
de vista de la cámara virtual) para definir en el Stencil Buffer la zona que debe
recortarse de la sombra (aquella cuya profundidad desde la cámara sea mayor
que la definida en el ZBuffer). La Figura 16.5 muestra cómo la proyección del
volumen de sombra es recortado para aquellos puntos cuya profundidad es me-
nor que la relativa a la proyección del volumen de sombra.
a b c
d e f
Figura 16.4: Utilización de diversos modos de cálculo de sombras y materiales asociados. a) Sombras
mediante Mapas de Texturas y Material simple. b) Sombras por Stencil Buffer y Material simple. c) Som-
bras por Stencil Buffer y Material con Ambient Occlusion precalculado. d) Sombras mediante Mapas de
C16
Texturas y Material basado en textura de mármol procedural precalculada. e) Sombras por Stencil Buffer
y Material basado en textura de mármol procedural precalculada. f) Sombras por Stencil Buffer y Material
combinado de c) + e) (Ambient Occlusion y Textura de Mármol precalculados).
Fuente
Foco
+
Volumen de
Cámara sombra
proyectada
Volumen de Stencil
sombra buffer
proyectada
Figura 16.5: Proceso de utilización del Stencil Buffer para el recorte de la proyección del volumen de
sombra.
[434] CAPÍTULO 16. ILUMINACIÓN
b
a
Mapa Profundidad
desde la fuente de luz Pb
Pa
Resultado final
C16
10 light->setPosition(-5,12,2);
11 light->setType(Light::LT_SPOTLIGHT);
12 light->setDirection(Vector3(1,-1,0));
13 light->setSpotlightInnerAngle(Degree(25.0f));
14 light->setSpotlightOuterAngle(Degree(60.0f));
15 light->setSpotlightFalloff(0.0f);
16 light->setCastShadows(true);
17
18 Light* light2 = _sceneManager->createLight("Light2");
19 light2->setPosition(3,12,3);
20 light2->setDiffuseColour(0.2,0.2,0.2);
21 light2->setType(Light::LT_SPOTLIGHT);
22 light2->setDirection(Vector3(-0.3,-1,0));
23 light2->setSpotlightInnerAngle(Degree(25.0f));
24 light2->setSpotlightOuterAngle(Degree(60.0f));
25 light2->setSpotlightFalloff(5.0f);
26 light2->setCastShadows(true);
27
28 Entity* ent1 = _sceneManager->createEntity("Neptuno.mesh");
29 SceneNode* node1 = _sceneManager->createSceneNode("Neptuno");
30 ent1->setCastShadows(true);
31 node1->attachObject(ent1);
32 _sceneManager->getRootSceneNode()->addChild(node1);
33
34 // ... Creamos plano1 manualmente (codigo eliminado)
35
36 SceneNode* node2 = _sceneManager->createSceneNode("ground");
37 Entity* groundEnt = _sceneManager->createEntity("p", "plane1");
38 groundEnt->setMaterialName("Ground");
39 groundEnt->setCastShadows(false);
40 node2->attachObject(groundEnt);
41 _sceneManager->getRootSceneNode()->addChild(node2);
42 }
[436] CAPÍTULO 16. ILUMINACIÓN
✄
En la línea ✂2 ✁se define la técnica de cálculo de sombras que se utilizará por de-
✄ se cambiará en tiempo de ejecución empleando las
✄ ✄ en el FrameListener
fecto, aunque
teclas ✂1 ✁y ✂2 ✁. Las líneas ✂2-3 ✁definen propiedades generales de la escena, como el
color con el que se representarán las sombras y el color de la luz ambiental (que en
✄
este ejemplo, debido a la definición de los materiales, tendrá especial importancia).
Las líneas ✂6-9 ✁únicamente tienen relevancia en el caso del uso del método de
cálculo basado en mapas de textura. Si ✄ el método utilizado es el de Stencil Buffer
simplemente serán ignoradas. La línea ✂6 ✁configura el número de texturas que se em-
plearán para calcular las sombras. Si se dispone de más de una fuente de luz (como en
✄
este ejemplo), habrá que especificar el número de texturas a utilizar.
El tamaño de la textura (cuadrada) se indica en la línea ✂7 ✁. A mayor tamaño,
mejor resolución en la sombra (pero mayor cantidad de memoria gastada). El tamaño
✄
por defecto es 512, y debe ser potencia de dos.
A continuación en las líneas ✂9-26 ✁se definen dos fuente de luz de tipo SPOTLIGHT.
✄
Cada luz define su posición y rotación. ✄
En el caso de la segunda fuente luz se define
de
además un color difuso (línea ✂20 ✁). El ángulo interno y externo ✂13-14 ✁define el cono
de iluminación de la fuente. En ambas fuentes se ha activado la propiedad de que la
fuente de luz permita el cálculo de sombras.
Figura 16.9: Ejemplo de uso de un mapa de iluminación para definir sombras suaves en un objeto. El mapa
de color y el mapa de iluminación son texturas independiente que se combinan (multiplicando su valor) en
la etapa final.
C16
minación por píxel (per pixel lighting), de modo que por cada píxel de la escena se
calcula la contribución real de la iluminación. El cálculo preciso de las sombras es
posible, aunque a día de hoy todavía es muy costoso para videojuegos.
Los mapas de luz permiten precalcular la iluminación por píxel de forma estáti-
ca. Esta iluminación precalculada puede ser combinada sin ningún problema con los
métodos de iluminación dinámicos estudiados hasta el momento. La calidad final de
la iluminación es únicamente dependiente del tiempo invertido en el precálculo de la
misma y la resolución de las texturas.
De este modo, para cada cara poligonal del modelo se definen una o varias capas de
textura. La capa del mapa de iluminación es finalmente multiplicada con el resultado
de las capas anteriores (como se puede ver en la Figura 16.9).
A diferencia de las texturas de color donde cada vértice del modelo puede com-
partir las mismas coordenadas UV con otros vértices, en mapas de iluminación cada
vértice de cada cara debe tener una coordenada única. Los mapas de iluminación se
cargan de la misma forma que el resto de texturas de la escena. En la Figura 16.8
hemos estudiado un modo sencillo de composición de estos mapas de iluminación,
[438] CAPÍTULO 16. ILUMINACIÓN
aunque pueden definirse otros métodos que utilicen varias pasadas y realicen otros
modos de composición. A continuación estudiaremos dos técnicas ampliamente utili-
zadas en el precálculo de la iluminación empleando mapas de iluminación: Ambient
Occlusion y Radiosidad.
C16
Estas técnicas de ocultación (obscurances) se desacoplan totalmente de la fase de
iluminación local, teniendo lugar en una segunda fase de iluminación secundaria difu-
sa. Se emplea un valor de distancia para limitar la ocultación únicamente a polígonos
b c cercanos a la zona a sombrear mediante una función. Si la función toma valor de ocul-
tación igual a cero en aquellos puntos que no superan un umbral y un valor de uno si
Figura 16.12: Descripción esque- están por encima del umbral, la técnica de ocultación se denomina Ambient Occlusion.
mática del cálculo de Ambient Oc- Existen multitud de funciones exponenciales que se utilizan para lograr estos efectos.
clusion. a) Esquema de cálculo del
valor de ocultación W (P ). b) Ren- La principal ventaja de esta técnica es que es bastante más rápida que las técnicas
der obtenido con iluminación lobal que realizan un cálculo correcto de la iluminación indirecta. Además, debido a la
(dos fuentes puntuales). c) La mis- sencillez de los cálculos, pueden realizarse aproximaciones muy rápidas empleando
ma configuración que en b) pero la GPU, pudiendo utilizarse en aplicaciones interactivas. El principal inconveniente
con Ambient Occlusion de 10 mue-
es que no es un método de iluminación global y no puede simular efectos complejos
tras por píxel.
como caústicas o contribuciones de luz entre superficies con reflexión difusa.
Para activar el uso de Ambient Occlusion (AO) en Blender, en el grupo de botones
del mundo , en la pestaña Ambient Occlusion activamos el checkbox (ver Figura
16.13). La pestaña Gather permite elegir el tipo de recolección de muestras entre
trazado de rayos Raytrace o Aproximada Approximate (ver Figura 16.13).
El cálculo de AO mediante Trazado de Rayos ofrece resultados más precisos a
costa de un tiempo de render mucho mayor. El efecto del ruido blanco debido al nú-
mero de rayos por píxel puede disminuirse a costa de aumentar el tiempo de cómputo
aumentando el número de muestras (Samples).
Falloff: Esta opción controla el tamaño de las sombras calculadas por AO. Si
está activa, aparece un nuevo control Strength que permite variar el factor de
atenuación. Con valores mayores de Strength, la sombra aparece más enfocada
(es más pequeña).
Add (Por defecto): El punto recibe luz según los rayos que no se han chocado
con ningún objeto. La escena esta más luminosa que la original sin AO.
Multiply: El punto recibe sombra según los rayos que han chocado con algún
objeto. La escena es más oscura que la original sin AO.
16.7. Radiosidad
En esta técnica se calcula el intercambio de luz entre superficies. Esto se consigue
subdividiendo el modelo en pequeñas unidades denominadas parches, que serán la
base de la distribución de luz final. Inicialmente los modelos de radiosidad calculaban
las interacciones de luz entre superficies difusas (aquellas que reflejan la luz igual en
todas las direcciones), aunque existen modelos más avanzados que tienen en cuenta
modelos de reflexión más complejos.
El modelo básico de radiosidad calcula una solución independiente del punto de
vista. Sin embargo, el cálculo de la solución es muy costoso en tiempo y en espacio
de almacenamiento. No obstante, cuando la iluminación ha sido calculada, puede uti-
lizarse para renderizar la escena desde diferentes ángulos, lo que hace que este tipo
de soluciones se utilicen en visitas interactivas y videojuegos en primera persona ac-
tuales. En el ejemplo de la figura 16.15, en el techo de la habitación se encuentran las
caras poligonales con propiedades de emisión de luz. Tras aplicar el proceso del cálcu-
lo de radiosidad obtenemos la malla de radiosidad que contiene información sobre la
distribución de iluminación entre superficies difusas.
En el modelo de radiosidad, cada superficie tiene asociados dos valores: la intensi-
dad luminosa que recibe, y la cantidad de energía que emite (energía radiante). En este
algoritmo se calcula la interacción de energía desde cada superficie hacia el resto. Si
Figura 16.14: El modelo de Radio-
tenemos n superficies, la complejidad del algoritmo será O(n2 ). El valor matemático sidad permite calcular el intercam-
que calcula la relación geométrica entre superficies se denomina Factor de Forma, y bio de luz entre superficies difusas,
se define como: mostrando un resultado de render
correcto en el problema planteado
cosθi cosθj en la histórica Cornell Box.
Fij = Hij dAj (16.2)
πr2
Siendo Fij el factor de forma de la superficie i a la superficie j, en el numerador de
la fracción definimos el ángulo que forman las normales de las superficies, πr2 mide
la distancia entre las superficies, Hij es el parámetro de visibilidad, que valdrá uno si
la superfice j es totalmente visible desde i, cero si no es visible y un valor entre uno
y cero según el nivel de oclusión. Finalmente, dAj indica el área de la superfice j (no
tendremos el mismo resultado con una pequeña superficie emisora de luz sobre una
superfice grande que al contrario). Este factor de forma se suele calcular empleando
un hemi-cubo.
16.7. Radiosidad [441]
Figura 16.15: Ejemplo de aplicación del método de radiosidad sobre una escena simple. En el techo de
la habitación se ha definido una fuente de luz. a) Malla original. b) Malla de radiosidad. c) Resultado del
proceso de renderizado.
C16
Será necesario calcular n2 factores de forma (no cumple la propiedad conmutativa)
debido a que se tiene en cuenta la relación de área entre superficies. La matriz que
contiene los factores de forma relacionando todas las superficies se denomina Matriz
de Radiosidad. Cada elemento de esta matriz contiene un factor de forma para la
interacción desde la superficie indexada por la columna hacia la superficie indexada
por la fila (ver figura 16.16).
como una nueva malla poligonal que tiende a desenfocar los límites de las sombras.
Como en videojuegos suelen emplearse modelos en baja poligonalización, y es po-
sible mapear esta iluminación precalculada en texturas, el modelo de Radiosidad se
ha empleado en diveros títulos de gran éxito comercial. Actualmente incluso existen
motores gráficos que calculan soluciones de radiosidad en tiempo real.
2
1 4
Figura 16.16: Esquema de la matriz de radiosidad. En cada posición de la matriz se calcula el Factor de
Forma. La diagonal principal de la matriz no se calcula.
Necesario Blender 2.49b. Aunque la radiosidad es un método muy empleado Figura 16.17: Escena de ejemplo
en videojuegos, fue eliminado de la implementación de Blender a partir de la para el cálculo de la Radiosidad.
versión 2.5. Probablemente vuelva a incorporarse en futuras versiones, pero
de momento es necesario utilizar versiones anteriores del programa. La última
versión que la incorporó (Blender 2.49b) puede ser descargada de la página
oficial http://download.blender.org/release/.
Figura 16.19: Opciones generales de Radiosidad (Paneles Radio Render, Radio Tool y Calculation.
Los elementos que emiten luz tendrán el campo Emit del material con un valor
mayor que 0. Los emisores de este ejemplo tienen un valor de emisión de 0.4.
Accedemos al menú de radiosidad ✄
dentro de los botones de sombreado .
Seleccionamos todas las mallas que forman nuestra escena ✂A ✁y pinchamos en el botón
✄
Collect Meshes (ver Figura 16.19). La escena aparecerá con colores sólidos.
Hecho esto, pinchamos en ✂Go ✁. De esta forma comienza el proceso de cálculo de
la
✄ solución de radiosidad. Podemos parar el proceso en cualquier momento pulsando
✂Escape ✁, quedándonos con la aproximación que se ha conseguido hasta ese instante.
Si no estamos satisfechos con la solución de Radiosidad calculada, podemos eli-
minarla pinchando en Free Radio Data.
C16
En la Figura 16.19 se muestran algunas opciones relativas al cálculo de la solución
de radiosidad. El tamaño de los Parches (PaMax - PaMin) determina el detalle final
(cuanto más pequeño sea, más detallado será el resultado final), pero incrementamos el
tiempo de cálculo. De forma similar ocurre con el tamaño de los Elementos2 (ElMax
- ElMin). En Max Iterations indicamos el número de pasadas que Blender hará en
el bucle de Radiosidad. Un valor 0 indica que haga las que estime necesarias para
minimizar el error (lo que es conveniente si queremos generar la malla de radiosidad
final). MaxEl indica el número máximo de elementos para la escena. Hemires es el
tamaño del hemicubo para el cálculo del factor de forma.
✄
Una vez terminado el proceso de cálculo (podemos pararlo cuando la calidad sea
aceptable con ✂Esc ✁), podemos añadir la nueva malla calculada reemplazando las crea-
das anteriormente (Replace Meshes) o añadirla como nueva a la escena (Add new
Meshes). Elegiremos esta segunda opción, para aplicar posteriormente el resultado a
✄ información
la malla en baja poligonalización. Antes de deseleccionar la malla con la
de radiosidad calculada, la moveremos a otra capa (mediante la tecla ✂M ✁) para tener
los objetos mejor organizados. Hecho esto, podemos liberar la memoria ocupada por
estos datos pinchando en Free Radio Data.
El resultado de la malla de radiosidad se muestra en la Figura 16.20. Como se pue-
de comprobar, es una malla extremadamente densa, que no es directamente utilizable
en aplicaciones de tiempo real. Vamos a utilizar el Baking de Blender para utilizar esta
información de texturas sobre la escena original en baja poligonalización.
Por simplicidad, uniremos ✄todos ✄ de ✄la escena original en una única
los objetos
malla (seleccionándolos todos ✂A ✁y pulsando ✂Control ✁✂J ✁Join Selected Meshes). A con-
tinuación, desplegaremos el objeto empleando el modo de despliegue de Light Map, y
Figura 16.20: Resultado de la ma- crearemos una nueva imagen que recibirá el resultado del render Baking.
lla de radiosidad.
2 En Blender, cada Parche está formado por un conjunto de Elementos, que describen superficies de
Figura 16.21: Resultado de aplicación del mapa de radiosidad al modelo en baja resolución.
✄
Ahora seleccionamos primero la malla de radiosidad, y después con ✂Shift ✁pulsado
✄ to Active
seleccionamos la malla en baja resolución. Pinchamos en el botón Selected
de la pestaña Bake, activamos Full Render y pinchamos en el botón ✂Bake ✁. Con esto
debemos haber asignado el mapa de iluminación de la malla de radiosidad al objeto
en baja poligonalización (ver Figura 16.21).
Exportación y Uso de Datos de
Capítulo 17
Intercambio
Carlos González Morcillo
H
asta ahora hemos empleado exportadores genéricos de contenido de anima-
ción, mallas poligonales y definición de texturas. Estas herramientas facilitan
enormemente la tarea de construcción de contenido genérico para nuestros
videojuegos. En este capítulo veremos cómo crear nuestros propios scripts de expor-
tación y su posterior utilización en una sencilla demo de ejemplo.
17.1. Introducción
En capítulos anteriores hemos utilizado exportadores genéricos de contenido para
su posterior importación. En multitud de ocasiones en necesario desarrollar herra-
mientas de exportación de elementos desde las herramientas de diseño 3D a formatos
de intercambio.
La comunidad de Ogre ha desarrollado algunos exportadores de geometría y ani-
maciones para las principales suites de animación y modelado 3D. Sin embargo, estos
elementos almacenan su posición relativa a su sistema de coordenadas, y no tienen en
cuenta otros elementos (como cámaras, fuentes de luz, etc...).
En esta sección utilizaremos las clases definidas en el Capítulo 6 del Módulo 1
para desarrollar un potencial juego llamado NoEscapeDemo. Estas clases importan
contenido definido en formato XML, que fue descrito en dicho capítulo.
A continuación enumeraremos las características esenciales que debe soportar el
Figura 17.1: El Wumpus será el exportador a desarrollar:
personaje principal del videojuego
demostrador de este capítulo NoEs-
cape Demo.
445
[446] CAPÍTULO 17. EXPORTACIÓN Y USO DE DATOS DE INTERCAMBIO
Múltiples nodos. El mapa del escenario (ver Figura 17.4) describe un grafo.
Utilizaremos un objeto de tipo malla poligonal para modelarlo en Blender. Cada
vértice de la malla representará un nodo del grafo. Los nodos del grafo podrán
ser de tres tipos: Nodo productor de Wumpus (tipo spawn), Nodo destructor de
Wumpus (tipo drain) o un Nodo genérico del grafo (que servirá para modelar
las intersecciones donde el Wumpus puede cambiar de dirección).
Múltiples aristas. Cada nodo del grafo estará conectado por uno o más nodos,
definiendo múltiples aristas. Cada arista conectará una pareja de nodos. En este
grafo, las aristas no tienen asociada ninguna dirección.
Múltiples cámaras animadas. El exportador almacenará igualmente las ani-
maciones definidas en las cámaras de la escena. En el caso de querer almacenar
cámaras estáticas, se exportará únicamente un fotograma de la animación. Para
cada frame de la animación de cada cámara, el exportador almacenará la posi-
ción y rotación (mediante un cuaternio) de la cámara en el XML.
La versión de Blender 2.65 utiliza Python 3.3, por lo que será necesaria su insta-
lación para ejecutar los scripts desarrollados. En una ventana de tipo Text Editor
✄ ✄ bastará con situar el puntero del ratón
podemos editar los scripts. Para su ejecución,
dentro de la ventana de texto y pulsar ✂Alt ✁✂P ✁.
1 Resulta especialmente interesante la comparativa empírica de Python con otros len-
Aunque la versión 2.65 de Blender permite añadir nuevas propiedades a los obje-
tos de la escena, por mantener la compatibilidad con versiones anteriores lo haremos
codificándolo como parte del nombre. En la pestaña Object, es posible especificar el
nombre del objeto, como se muestra en la Figura 17.3. En nuestro ejemplo, se ha
utilizado el siguiente convenio de nombrado:
Cámaras. Las cámaras codifican en el nombre (separadas por guión bajo), co-
mo primer parámetro el identificador de la misma (un índice único), y como
segundo parámetro el número de frames asociado a su animación. De este mo-
do, la cámara llamada GameCamera_1_350 indica que es la primera cámara
Figura 17.3: Especificación del
(que será la utilizada en el estado de Juego), y que tiene una animación definida
nombre del objeto en la pestaña Ob- de 350 frames.
ject.
Empty. Como no es posible asignar tipos a los vértices de la malla poligonal
con la que se definirá el grafo, se han añadido objetos de tipo Empty que ser-
virán para asociar los generadores (spawn) y destructores (drain) de Wumpus.
Estos objetos Empty tendrán como nombre el literal “spawn” o “drain” y a con-
tinuación un número del 1 al 9.
(como hemos indicado anteriormente será una malla poligonal), y ✂10 ✁un valor ǫ que
utilizaremos como distancia máxima de separación entre un vértice del grafo y cada
Empty de la escena (para considerar que están asociados).
C17
2
3 import bpy, os, sys
4 import mathutils
5 from math import *
6 from bpy import *
7
8 FILENAME = "output.xml" # Archivo XML de salida
9 GRAPHNAME = "Graph" # Nombre del objeto Mesh del grafo
10 EPSILON = 0.01 # Valor de distancia Epsilon
11
12 # --- isclose --------------------------------------------------
13 # Decide si un empty coincide con un vertice del grafo
14 # --------------------------------------------------------------
15 def isclose(empty, coord):
16 xo, yo, zo = coord
17 xd, yd, zd = empty.location
18 v = mathutils.Vector((xo-xd, yo-yd, zo-zd))
19 if (v.length < EPSILON):
20 return True
21 else:
22 return False
23
24 # --- gettype --------------------------------------------------
25 # Devuelve una cadena con el tipo del nodo del grafo
26 # --------------------------------------------------------------
27 def gettype (dv, key):
28 obs = [ob for ob in bpy.data.objects if ob.type == ’EMPTY’]
29 for empty in obs:
30 empName = empty.name
31 if ((empName.find("spawn") != -1) or
32 (empName.find("drain") != -1)):
33 if (isclose(empty, dv[key])):
34 return ’type ="’+ empName[:-1] +’"’
35 return ’type=""’
[448] CAPÍTULO 17. EXPORTACIÓN Y USO DE DATOS DE INTERCAMBIO
36
37 ID1 = ’ ’*2 # Identadores para el xml
38 ID2 = ’ ’*4 # Solo con proposito de obtener un xml "bonito"
39 ID3 = ’ ’*6
40 ID4 = ’ ’*8
41
42 graph = bpy.data.objects[GRAPHNAME]
43
44 dv = {} # Diccionario de vertices
45 for vertex in graph.data.vertices:
46 dv[vertex.index+1] = vertex.co
47
48 de = {} # Diccionario de aristas
49 for edge in graph.data.edges: # Diccionario de aristas
50 de[edge.index+1] = (edge.vertices[0], edge.vertices[1])
51
52 file = open(FILENAME, "w")
53 std=sys.stdout
54 sys.stdout=file
55
56 print ("<?xml version=’1.0’ encoding=’UTF-8’?>\n")
57 print ("<data>\n")
58
59 # --------- Exportacion del grafo ---------------------
60 print ("<graph>")
61 for key in dv.keys():
62 print (ID1 + ’<vertex index="’ + str(key) + ’" ’+
63 gettype(dv,key) +’>’)
64 x,y,z = dv[key]
65 print (ID2 + ’<x> %f</x> <y> %f</y> <z> %f</z>’ % (x,y,z))
66 print (ID1 + ’</vertex>’)
67 for key in de.keys():
68 print (ID1 + ’<edge>’)
69 v1,v2 = de[key]
70 print (ID2 + ’<vertex> %i</vertex> <vertex> %i</vertex>’
71 % (v1,v2))
72 print (ID1 + ’</edge>’)
73 print ("</graph>\n")
74
75 # --------- Exportacion de la camara -------------------
76 obs = [ob for ob in bpy.data.objects if ob.type == ’CAMERA’]
77 for camera in obs:
78 camId = camera.name
79 camName = camId.split("_")[0]
80 camIndex = int(camId.split("_")[1])
81 camFrames = int (camId.split("_")[2])
82 print (’<camera index=" %i" fps=" %i">’ %
83 (camIndex, bpy.data.scenes[’Scene’].render.fps))
84 print (ID1 + ’<path>’)
85 for i in range (camFrames):
86 cFrame = bpy.data.scenes[’Scene’].frame_current
87 bpy.data.scenes[’Scene’].frame_set(cFrame+1)
88 x,y,z = camera.matrix_world.translation
89 qx,qy,qz,qw = camera.matrix_world.to_quaternion()
90 print (ID2 + ’<frame index=" %i">’ % (i+1))
91 print (ID3 + ’<position>’)
92 print (ID4 + ’<x> %f</x> <y> %f</y> <z> %f</z>’ % (x,y,z))
93 print (ID3 + ’</position>’)
94 print (ID3 + ’<rotation>’)
95 print (ID4 + ’<x> %f</x> <y> %f</y> <z> %f</z> <w> %f</w>’ %
96 (qx,qy,qz,qw))
97 print (ID3 + ’</rotation>’)
98 print (ID2 + ’</frame>’)
99 print (ID1 + ’</path>’)
100 print (’</camera>’)
101
102 print ("</data>")
103
104 file.close()
105 sys.stdout = std
17.1. Introducción [449]
C17
la vacía para tipo (que indica que no es un nodo especial del grafo) en la línea
✂35 ✁.
✄
La función auxiliar isclose (definida en las líneas ✂15-22 ✁devolverá True si el empty
está a una distancia menor que ǫ respecto del nodo del grafo. En otro caso, devolverá
False. Para realizar esta comparación, simplemente creamos un vector restando las
coordenadas del vértice✄ con la posición del Empty y utilizamos directamente el atri-
buto de longitud (línea ✂19 ✁) de la clase Vector de la biblioteca mathutils.
✄
Trayectorias genéricas La segunda parte del código (definido en las líneas ✂76-100 ✁) se encarga de exportar
✄
las animaciones asociadas a las cámaras. Según el convenio explicado anteriormente,
el propio nombre de la cámara codifica el índice de la✄ cámara (ver línea ✂80 ✁) y el nú-
Aunque en este ejemplo exportare-
mero de frames que contiene su animación (ver línea ✂81 ✁). En el caso de este ejemplo
mos trayectorias sencillas, el expor-
tar la posición de la cámara en ca-
da frame nos permite tener un con- se han definido dos cámaras (ver Figura 17.4). La cámara del menú inicial es estática,
trol absoluto sobre la pose en cada por lo que define en la parte relativa a los frames un valor de 1 (no hay animación). La
instante. En la implementación en
OGRE tendremos que utilizar algún cámara del juego describirá rotaciones siguiendo una trayectoria circular.
mecanismo de interpolación entre
cada pose clave.
[450] CAPÍTULO 17. EXPORTACIÓN Y USO DE DATOS DE INTERCAMBIO
spawn4
spawn1
drain2
spawn2
drain1
spawn3
Figura 17.4: Definición del grafo asociado al juego NoEscapeDemo en Blender. En la imagen de la izquier-
da se han destacado la posición de los Emptys auxiliares para describir el tipo especial de algunos nodos
(vértices) del grafo. La imagen de la derecha muestra las dos cámaras de la escena y la ruta descrita para la
cámara principal del juego.
El número de frame se
✄ modifica directamente en el atributo frame_current del la
escena actual (ver línea ✂86 ✁). Para cada frame se exporta la posición de la cámara
✄
(obtenida en la última columna de la matriz de transformación del objeto, en✄ la línea
✂88 ✁), y el cuaternio de rotación (mediante una conversión de la matriz en ✂89 ✁). La
Figura 17.5 muestra un fragmento del XML exportado relativo a la escena base del
ejemplo. Esta exportación puede realizarse ejecutando el fichero .py desde línea de
órdenes, llamando a Blender con el parámetro -P <script.py> (siendo script.py el
nombre del script de exportación).
Figura 17.5: Fragmento del resultado de la exportación del XML del ejemplo.
Capítulo 18
Animación
Carlos González Morcillo
E
n este capítulo estudiaremos los fundamentos de la animación por computador,
analizando los diversos métodos y técnicas de construcción, realizando ejem-
plos de composición básicos con Ogre. Se estudiará el concepto de Animation
State, exportando animaciones definidas previamente en Blender.
18.1. Introducción
estroboscópico.
2 también, aunque menos extendido en el ámbito de la animación por computador se utiliza el término
cuadro o fotograma
451
[452] CAPÍTULO 18. ANIMACIÓN
Dependiendo del tipo de método que se utilice para el cálculo de los fotogramas
intermedios estaremos ante una técnica de interpolación u otra. En general, sue-
len emplearse curvas de interpolación del tipo Spline, que dependiendo de sus
propiedades de continuidad y el tipo de cálculo de los ajustes de tangente obten-
dremos diferentes resultados. En el ejemplo de la Figura 18.2, se han añadido
claves en los frames 1 y 10 con respecto a la posición (localización) y rotación
en el eje Z del objeto. El ordenador calcula automáticamente la posición y rota-
ción de las posiciones intermedias. La figura muestra las posiciones intermedias
calculadas para los frames 4 y 7 de la animación.
Animación Procedural. En esta categoría, el control sobre el movimiento se
realiza empleando procedimientos que definen explícitamente el movimiento
como función del tiempo. La generación de la animación se realiza mediante
descripciones físicas, como por ejemplo en visualizaciones científicas, simula-
ciones de fluidos, tejidos, etc... Estas técnicas de animación procedural serán
igualmente estudiadas a continuación este módulo curso. La simulación diná-
mica, basada en descripciones matemáticas y físicas suele emplearse en anima-
ción de acciones secundarias. Es habitual contar con animaciones almacenadas
a nivel de vértice simulando comportamiento físico en videojuegos. Sería el
equivalente al Baking de animaciones complejas precalculadas.
C18
ria.
Una vez realizada esta introducción general a las técnicas de animación por compu-
tador, estudiaremos en la siguiente sección los tipos de animación y las características
generales de cada uno de ellos en el motor gráfico Ogre.
[454] CAPÍTULO 18. ANIMACIÓN
Posición X
Posición Y
Posición Z
1 2 3 4 5 6 7 8 9 10 Frame
Figura 18.2: Ejemplo de uso de Frames Clave y Splines asociadas a la interpolación realizada.
Ogre utiliza el término pista Track para referirse a un conjunto de datos alma-
cenados en función del tiempo. Cada muestra realizada en un determinado instante
de tiempo es un Keyframe. Dependiendo del tipo de Keyframes y el tipo de pista, se
definen diferentes tipos de animaciones.
Las animaciones asociadas a una Entidad se representan en un AnimationState.
Este objeto tiene una serie de propiedades que pueden ser consultadas:
18.2. Animación en Ogre [455]
Hombro
Codo
Posición
del Efector Base Muñeca
Posición
del Efector
Figura 18.3: Ejemplo de uso de Frames Clave y Splines asociadas a la interpolación realizada.
C18
Activa. Es posible activar y consultar el estado de una animación mediante las
llamadas a getEnabled y setEnabled.
Longitud. Obtiene en punto flotante el número de segundos de la animación,
mediante la llamada a getLength.
Posición. Es posible obtener y establecer el punto de reproducción actual de la
animación mediante llamadas a getTimePosition y setTimePosition. El tiempo
se establece en punto flotante en segundos.
Bucle. La animación puede reproducirse en modo bucle (cuando llega al final
continua reproduciendo el primer frame). Las llamadas a métodos son getLoop
y setLoop.
Peso. Este parámetro controla la mezcla de animaciones, que será descrita en la
sección 18.4.
Figura 18.4: Principales ventanas de Blender empleadas en la animación de objetos. En el centro se en-
cuentran las ventanas del NLA Editor y del Dope Sheet, que se utilizarán para definir acciones que serán
exportadas empleando el script de Exportación a Ogre.
18.2.2. Controladores
La animación basada en frames clave permite animar mallas poligionales. Me-
diante el uso de controladores se posicionan los nodos, definiendo una función. El
controlador permite modificar las propiedades de los nodos empleando un valor que
se calcula en tiempo de ejecución.
Las acciones aparecerán en el interfaz del exportador de Ogre, siempre que hayan
sido correctamente creadas y estén asociadas a un hueso de un esqueleto. En la Figu-
ra 18.4 se han definido dos acciones llamadas “Saltar” y “Rotar” (la acción Rotar está
seleccionada y aparece resaltada en la figura). A continuación estudiaremos el proceso
de exportación de animaciones.
Como se ha comentado anteriormente, las animaciones deben exportarse emplean-
do animación basada en esqueletos. En el caso más sencillo de animaciones de cuerpo
rígido, bastará con crear un esqueleto de un único hueso y emparentarlo con el objeto
que queremos animar.
✄ un esqueleto con un único hueso al objeto que queremos añadir median-
✄ Añadimos
te ✂Shift ✁✂A ✁Add/Armature/Single Bone. Ajustamos el extremo superior del hueso para
que tenga un tamaño similar al objeto a animar (como se muestra en la Figura 18.5).
A continuación crearemos una relación de parentesco entre el objeto y el hueso del
esqueleto, de forma que el objeto sea hijo de el esqueleto.
Este parentesco debe definirse en modo Pose . Con el hueso del esqueleto selec-
cionado, elegiremos ✄ el modo
✄ Pose en la cabecera de la ventana 3D (o bien empleando
el atajo de teclado ✂Control ✁✂TAB ✁). El hueso deberá representarse en color azul en el inter-
faz de Blender. Con el✄ hueso en modo pose, ahora seleccionamos primero al caballo,
y a continuación con✄ ✂Shift ✁pulsado seleccionamos el hueso (se deberá elegir en color
✄
azul), y pulsamos ✂Control ✁✂P ✁, eligiendo Set Parent to ->Bone. Si ahora desplazamos el
hueso del esqueleto en modo pose, el objeto deberá seguirle.
Antes de crear las animaciones, debemos añadir un Modificador de objeto sobre
Figura 18.5: Ajuste del tamaño del la malla poligional. Con el objeto Horse seleccionado, en el panel Object Modifiers
hueso para que sea similar al objeto añadimos un modificador de tipo Armature. Este modificador se utiliza para animar
a animar. las poses de personajes y objetos asociados a esqueletos. Especificando el esqueleto
que utilizaremos con cada objeto podremos incluso deformar los vértices del propio
modelo.
Como se muestra en la Figura 18.6, en el campo Object especificaremos el nom-
bre del esqueleto que utilizaremos con el modificador (“Armature” en este caso). En
el método de Bind to indicamos qué elementos se asociarán al esqueleto. Si quere-
C18
mos que los vértices del modelo se deformen en función de los huesos del esqueleto,
activaremos Vertex Groups (en este ejemplo deberá estar desactivado). Mediante Bo-
ne Envelopes podemos indicar a Blender que utilice los límites de cada hueso para
definir la deformación que se aplicará a cada hueso.
A continuación creamos un par de animaciones asociadas a este hueso. Recorde-
Figura 18.6: Opciones del Modifi-
cador Armature.
mos que debemos trabajar en modo Pose , por lo que siempre el hueso debe estar
seleccionado en color azul en el interfaz de Blender. Comenzaremos definiendo una
acción que se llamará “Saltar”. Añadiremos los frames clave de la animación (en este
caso, hemos definido frames clave de LocRot en 1, 21 y 35).
Tras realizar esta operación, las ventanas de DopeSheet y NLA Editor mostrarán
un aspecto como el de la Figura 18.7. La ventana DopeSheet permite modificar
fácilmente las posiciones de los frames clave asociados al hueso. Pinchando y des-
plazando los rombos asociados a cada clave, podemos modificar el timing de la ani-
Figura 18.7: Ventanas de DopeS- mación. En la ventana NLA podemos crear una acción (Action Strip), pinchando
heet y NLA Editor.
sobre el icono del copo de nieve . Tras esta operación, se habrá creado una acción
definida mediante una barra amarilla.
Es posible ✄indicar el nombre de la acción en el campo Active Strip, accesible pul-
sando la tecla ✂N ✁. Aparecerá un nuevo subpanel, como se muestra en la Figura 18.8,
en la zona derecha de la ventana, donde se podrán configurar los parámetros asociados
a la acción (nombre, frames, método de mezclado con otras acciones, etc...).
Figura 18.8: Especificación del
nombre de la acción en el Panel de
Propiedades.
[458] CAPÍTULO 18. ANIMACIÓN
✄ ✄
Mediante el atajo de teclado ✂Alt ✁✂A ✁podemos reproducir la animación relativa a la
acción que tenemos seleccionada. Resulta muy cómodo emplear una ventana de tipo
Timeline para definir el intervalo sobre el que se crearán las animaciones.
A la hora de exportar las animaciones, procedemos como vimos en el Capítulo
11. En File/ Export/ Ogre3D, accederemos al exportador de Blender a Ogre3D. Es
interesante activar las siguientes opciones de exportación:
El uso de las animaciones exportadas en Ogre es relativamente sencillo. En si- Huesos y vértices
guiente código muestra un ejemplo✄de aplicación
✄ de las animaciones previamente ex-
✄ ✂ ✁ ✂ ✁
portadas. Cuando se pulsa la tecla o se reproduce la animación Saltar o Rotar En este capítulo trabajaremos con
hasta que finaliza. En la línea ✂18 ✁se actualiza el tiempo transcurrido desde la última
A Z
huesos que tienen una influencia to-
✄ teclas
tal sobre el objeto (es decir, influen-
actualización. Cuando se pulsa alguna de las anteriores, la animación seleccio-
nada se reproduce desde el principio (línea ✂10 ✁).
cia de 1.0 sobre todos los vértices
del modelo).
19 }
C18
25 if (_animState->getEnabled() && !_animState->hasEnded())
26 _animState->addTime(deltaT);
27 if (_animState2->getEnabled() && !_animState2->hasEnded())
28 _animState2->addTime(deltaT);
29 // ...
Obviamente, será necesario contar con alguna clase de nivel superior que nos ges-
tione las animaciones, y se encarge de gestionar los AnimationState, actualizándolos
adecuadamente.
4 Más información sobre cómo se implementó el motor de mezclado de animaciones del MechWarrior
en http://www.gamasutra.com/view/feature/3456/
18.4. Mezclado de animaciones [461]
✄ Veamos a continuación los principales métodos de la clase, declarados en las líneas
✂20-23 ✁del listado anterior.
✄
En el constructor de la clase inicializamos todas las animaciones asociadas a la en-
✄
tidad (recibida como único parámetro). Mediante un iterador (línea ✂6✁
) desactivamos
todos los AnimationState asociados (línea ✂7 ✁), y reseteamos la✄ posición
de reproduc-
ción y su peso asociado para su posterior composición (líneas ✂8-9 ✁).
La clase AnimationBlender dispone de un método principal para cargar animacio-
nes, indicando (como segundo parámetro) el tipo de mezcla que quiere realiarse con
la animación que esté reproduciéndose. La implementación actual de la clase admite
dos modos de mezclado; un efecto de mezclado suave tipo cross fade y una transición
básica de intercambio de canales.
La transición de tipo Blend implementa una transición simple lineal de tipo Cross
Fade. En la Figura 18.10 representa este tipo de transición, donde el primer canal de
animación se mezclará de forma suave con el segundo, empleando una combinación
lineal de pesos.
C18
Saltar
Listado 18.5: AminationBlender.cpp (Blend)
27 }
28 }
C18
Figura 18.13: Ejemplo de aplicación que utiliza la clase AnimationBlender. Mediante las teclas indicadas
en el interfaz es posible componer las animaciones exportadas empleandos los dos modos de transición
implementados.
Simulación Física
19
Carlos González Morcillo
E
n prácticamente cualquier videojuego (tanto 2D como 3D) es necesaria la de-
tección de colisiones y, en muchos casos, la simulación realista de dinámica
de cuerpo rígido. En este capítulo estudiaremos la relación existente entre sis-
temas de detección de colisiones y sistemas de simulación física, y veremos algunos
ejemplos de uso del motor de simulación física libre Bullet.
19.1. Introducción
La mayoría de los videojuegos requieren en mayor o menor medida el uso de téc-
nicas de detección de colisiones y simulación física. Desde un videojuego clásico co-
mo Arkanoid, hasta modernos juegos automovilísticos como Gran Turismo requieren
definir la interacción de los elementos en el mundo físico.
El motor de simulación física puede abarcar una amplia gama de características y
funcionalidades, aunque la mayor parte de las veces el término se refiere a un tipo con-
creto de simulación de la dinámica de cuerpos rígidos. Esta dinámica se encarga de
Figura 19.1: “Anarkanoid, el ma-
chacaladrillos sin reglas es un jue-
determinar el movimiento de estos cuerpos rígidos y su interacción ante la influencia
go tipo Breakout donde la simula- de fuerzas.
ción física se reduce a una detec- En el mundo real, los objetos no pueden pasar a través de otros objetos (salvo ca-
ción de colisiones 2D.
sos específicos convenientemente documentados en la revista Más Allá). En nuestro
videojuego, a menos que tengamos en cuenta las colisiones de los cuerpos, tendre-
Cuerpo Rígido mos el mismo efecto. El sistema de detección de colisiones, que habitualmente es un
Definimos un cuerpo rígido como módulo del motor de simulación física, se encarga de calcular estas relaciones, deter-
un objeto sólido ideal, infinitamente minando la relación espacial existente entre cuerpos rígidos.
duro y no deformable.
465
[466] CAPÍTULO 19. SIMULACIÓN FÍSICA
PhysX. Este motor privativo, es actualmente mantenido por NVidia con ace-
leración basada en hardware (mediante unidades específicas de procesamiento
físico PPUs Physics Processing Units o mediante núcleos CUDA. Las tarjetas
gráficas con soporte de CUDA (siempre que tengan al menos 32 núcleos CU-
DA) pueden realizar la simulación física en GPU. Este motor puede ejecutarse
en multitud de plataformas como PC (GNU/Linux, Windows y Mac), PlaySta-
tion 3, Xbox y Wii. El SDK es gratuito, tanto para proyectos comerciales como
no comerciales. Existen multitud de videojuegos comerciales que utilizan este
motor de simulación. Gracias al wrapper NxOgre se puede utilizar este motor
en Ogre.
Figura 19.4: Logotipo del motor de
simulación físico estrella en el mun- Havok. El motor Havok se ha convertido en el estándar de facto en el mun-
do del software privativo. do del software privativo, con una amplia gama de características soportadas y
plataformas de publicación (PC, Videoconsolas y Smartphones). Desde que en
Física 2007 Intel comprara la compañía que originalmente lo desarrolló, Havok ha si-
do el sistema elegido por más de 150 videojuegos comerciales de primera línea.
Títulos como Age of Empires, Killzone 2 & 3, Portal 2 o Uncharted 3 avalan la
calidad del motor.
... Gráficos
Existen algunas bibliotecas específicas para el cálculo de colisiones (la mayoría
Lógica del distribuidas bajo licencias libres). Por ejemplo, I-Collide, desarrollada en la Universi-
Juego
dad de Carolina del Norte permite calcular intersecciones entre volúmenes convexos.
Existen versiones menos eficientes para el tratamiento de formas no convexas, llama-
GUI Networking das V-Collide y RAPID. Estas bibliotecas pueden utilizarse como base para la cons-
trucción de nuestro propio conjunto de funciones de colisión para videojuegos que no
IA requieran funcionalidades físicas complejas.
C19
Predictibilidad. El uso de un motor de simulación física afecta a la predictibili-
dad del comportamiento de sus elementos. Además, el ajuste de los parámetros
relativos a la definición de las características físicas de los objetos (coeficientes,
constantes, etc...) son difíciles de visualizar.
Realización de pruebas. La propia naturaleza caótica de las simulaciones (en
muchos casos no determinista) dificulta la realización de pruebas en el video-
juego.
Integración. La integración con otros módulos del juego puede ser compleja.
Por ejemplo, ¿qué impacto tendrá en la búsqueda de caminos el uso de simula-
ciones físicas? ¿cómo garantizar el determinismo en un videojuego multijuga-
dor?.
Realismo gráfico. El uso de un motor de simulación puede dificultar el uso
Figura 19.7: La primera incursión de ciertas técnicas de representación realista (como por ejemplo el precálculo
de Steven Spielberg en el mundo de la iluminación con objetos que pueden ser destruidos). Además, el uso de
de los videojuegos fue en 2008 con cajas límite puede producir ciertos resultados poco realistas en el cálculo de
Boom Blox, un título de Wii desa- colisiones.
rrollado por Electronic Arts con
una componente de simulación fí-
sica crucial para la experiencia del
jugador.
[468] CAPÍTULO 19. SIMULACIÓN FÍSICA
Figura 19.6: Gracias al uso de PhysX, las baldosas del suelo en Batman Arkham Asylum pueden ser des-
truidas (derecha). En la imagen de la izquierda, sin usar PhysX el suelo permanece inalterado, restando
realismo y espectacularidad a la dinámica del juego.
donde F es la fuerza resultante que actúa sobre un cuerpo de masa m, y con una
aceleración lineal a aplicada sobre el centro de gravedad del cuerpo.
19.2. Sistema de Detección de Colisiones [469]
C19
e i-2
Enl
ac
αθi-1i 19.2. Sistema de Detección de Colisiones
xx
i-1i
La responsabilidad principal del Sistema de Detección de Colisiones (SDC) es
calcular cuándo colisionan los objetos de la escena. Para calcular esta colisión, los
Figura 19.9: Un controlador puede objetos se representan internamente por una forma geométrica sencilla (como esferas,
intentar que se mantenga constante cajas, cilindros...).
el ángulo θi formado entre dos es-
labones de un brazo robótico. Además de comprobar si hubo colisión entre los objetos, el SDC se encarga de
proporcionar información relevante al resto de módulos del simulador físico sobre las
Test de Intersección propiedades de la colisión. Esta información se utiliza para evitar efectos indeseables,
como la penetración de un objeto en otro, y consegir la estabilidad en la simulación
En realidad, el Sistema de Detec-
ción de Colisiones puede entender- cuando el objeto llega a la posición de equilibrio.
se como un módulo para realizar
pruebas de intersección complejas.
[470] CAPÍTULO 19. SIMULACIÓN FÍSICA
Como se muestra en la Figura 19.10, las entidades del juego pueden tener dife- (a)
rentes formas de colisión, o incluso pueden compartir varias primitivas básicas (para
representar por ejemplo cada parte de la articulación de un brazo robótico).
El Mundo Físico sobre el que se ejecuta el SDC mantiene una lista de todas las
entidades que pueden colisionar empleando habitualmente una estructura global Sin-
gleton. Este Mundo Físico es una representación del mundo del juego que mantiene
la información necesaria para la detección de las colisiones. Esta separación evita que (b)
el SDC tenga que acceder a estructuras de datos que no son necesarias para el cálculo
de la colisión.
Los SDC mantienen estructuras de datos específicas para manejar las colisiones,
proporcionando información sobre la naturaleza del contacto, que contiene la lista de
las formas que están intersectando, su velocidad, etc...
Para gestionar de un modo más eficiente las colisiones, las formas que suelen uti- (c)
lizarse con convexas. Una forma convexa es aquella en la que un rayo que surja desde
su interior atravesará la superfice una única vez. Las superficies convexas son mucho
Figura 19.10: Diferentes formas de
más simples y requieren menor capacidad computacional para calcular colisiones que
colisión para el objeto de la imagen.
las formas cóncavas. Algunas de las primitivas soportadas habitualmente en SDC son: (a) Aproximación mediante una ca-
ja. (b) Aproximación mediante un
Esferas. Son las primitivas más simples y eficientes; basta con definir su centro volumen convexo. (c) Aproxima-
y radio (uso de un vector de 4 elementos). ción basada en la combinación de
varias primitivas de tipo cilíndrico.
Cajas. Por cuestiones de eficiencia, se suelen emplear cajas límite alineadas con
los ejes del sistema de coordenadas (AABB o Axis Aligned Bounding Box). Las Formas de colisión
cajas AABB se definen mediante las coordenadas de dos extremos opuestos. El A cada cuerpo dinámico se le aso-
principal problema de las cajas AABB es que, para resultar eficientes, requie- cia habitualmente una única forma
ren estar alineadas con los ejes del sistema de coordenas global. Esto implica de colisión en el SDC.
19.2. Sistema de Detección de Colisiones [471]
Figura 19.11: Gestión de la forma de un objeto empleando cajas límite alineadas con el sistema de referen-
cia universal AABBs. Como se muestra en la figura, la caja central realiza una aproximación de la forma
del objeto muy pobre.
C19
formas que son aproximadamente cilíndricas (como las extremidades del cuerpo
humano).
Volúmenes convexos. La mayoría de los SDC permiten trabajar con volúmenes
convexos (ver Figura 19.10). La forma del objeto suele representarse interna-
mente mediante un conjunto de n planos. Aunque este tipo de formas es menos
eficiente que las primitivas estudiadas anteriormente, existen ciertos algoritmos
como el GJK que permiten optimizar los cálculos en este tipo de formas.
Malla poligional. En ciertas ocasiones puede ser interesante utilizar mallas ar-
bitrarias. Este tipo de superficies pueden ser abiertas (no es necesario que defi-
nan un volumen), y se construyen como mallas de triángulos. Las mallas poli-
gonales se suelen emplear en elementos de geometría estática, como elementos
del escenario, terrenos, etc. (ver Figura 19.13) Este tipo de formas de colisión
son las más complejas computacionalmente, ya que el SDC debe probar con ca-
da triángulo. Así, muchos juegos tratan de limitar el uso de este tipo de formas
de colisión para evitar que el rendimiento se desplome.
Formas compuestas. Este tipo de formas se utilizan cuando la descripción de
Figura 19.12: Algunas primitivas un objeto se aproxima más convenientemente con una colección de formas. Este
de colisión soportadas en ODE: Ca- tipo de formas es la aproximación deseable en el caso de que tengamos que uti-
jas, cilindros y cápsulas. La imagen lizar objetos cóncavos, que no se adaptan adecuadamente a volúmenes convexos
inferior muestra los AABBs asocia-
dos a los objetos.
(ver Figura 19.14).
[472] CAPÍTULO 19. SIMULACIÓN FÍSICA
19.2.2. Optimizaciones
La detección de colisiones es, en general, una tarea que requiere el uso intensivo de
la CPU. Por un lado, los cálculos necesarios para determianr si dos formas intersecan
no son triviales. Por otro lado, muchos juegos requiren un alto número de objetos en
la escena, de modo que el número de test de intersección a calcular rápidamente crece.
En el caso de n objetos, si empleamos un algoritmo de fuerza bruta tendríamos una
complejidad O(n2 ). Es posible utilizar ciertos tipos de optimizaciones que mejoran
esta complejidad inicial:
En muchos motores, como en Bullet, se utilizan varias capas o pasadas para de-
tectar las colisiones. Primero suelen emplearse cajas AABB para comprobar si los
objetos pueden estar potencialmente en colisión (detección de la colisión amplia). A
continuación, en una segunda capa se hacen pruebas con volúmenes generales que
engloban los objetos (por ejemplo, en un objeto compuesto por varios subobjetos, se a) b)
calcula una esfera que agrupe a todos los subobjetos). Si esta segunda capa de colisión
da un resultado positivo, en una tercera pasada se calculan las colisiones empleando
las formas finales.
Ray Casting. Este tipo de query requiere que se especifique un rayo, y un ori-
gen. Si el rayo interseca con algún objeto, se devolverá un punto o una lista de
puntos. Como vimos, el rayo se especifica habitualmente mediante una ecua-
ción paramétrica de modo que p(t) = po + td, siendo t el parámetro que toma
19.3. Dinámica del Cuerpo Rígido [473]
d d
d d
Po
Po Po Po
a) b) c) d)
Figura 19.15: Cálculo de los puntos de colisión empleando Shape Casting. a) La forma inicialmente se
encuentra colisionando con algún objeto de la escena. b) El SDC obtiene un punto de colisión. c) La forma
interseca con dos objetos a la vez. d) En este caso el sistema calcula todos los puntos de colisión a lo largo
de la trayectoria en la dirección de d.
valores entre 0 y 1. d nos define el vector dirección del rayo, que determinará la
Uso de Rayos distancia máxima de cálculo de la colisión. Po nos define el punto de origen del
rayo. Este valor de t que nos devuelve la query puede ser fácilmente convertido
El Ray Casting se utiliza amplia- al punto de colisión en el espacio 3D.
mente en videojuegos. Por ejemplo,
para comprobar si un personaje es- Shape Casting. El Shape Casting permite determinar los puntos de colisión de
tá dentro del área de visión de otro una forma que viaja en la dirección de un vector determinado. Es similar al Ray
personaje, para detectar si un dispa-
ro alcanza a un enemigo, para que Casting, pero en este caso es necesario tener en cuenta dos posibles situaciones
los vehículos permanezcan en con- que pueden ocurrir:
tacto con el suelo, etc.
1. La forma sobre la que aplicamos Shape Casting está inicialmente interse-
Uso de Shape Casting cando con al menos un objeto que evita que se desplace desde su posición
Un uso habitual de estas queries inicial. En este caso el SDC devolverá los puntos de contacto que pueden
permite determinar si la cámara en- estar situados sobre la superficie o en el interior del volumen.
tra en colisión con los objetos de la
escena, para ajustar la posición de 2. La forma no interseca sobre ningún objeto, por lo que puede desplazarse
los personajes en terrenos irregula- libremente por la escena. En este caso, el resultado de la colisión suele ser
res, etc. un punto de colisión situado a una determinada distancia del origen, aun-
que puede darse el caso de varias colisiones simultáneas (como se muestra
Lista de colisiones en la Figura 19.15). En muchos casos, los SDC únicamente devuelven el
C19
Para ciertas aplicaciones puede ser resultado de la primera colisión (una lista de estructuras que contienen el
igualmente conveniente obtener la valor de t, el identificador del objeto con el que han colisionado, el punto
lista de todos los objetos con los que de contacto, el vector normal de la superficie en ese punto de contacto, y
se interseca a lo largo de la trayec-
toria, como se muestra en la Figu- algunos campos extra de información adicional).
ra 19.15.d).
a b c
Figura 19.16: Ejemplo de simulación de cuerpos blandos con Bullet. a) Uso de softbodies con formas
convexas. b) Simulación de una tela sostenida por cuatro puntos. c) Simulación de cuerdas.
19.4. Restricciones
Las restricciones sirven para limitar el movimiento de un objeto. Un objeto sin
ninguna restricción tiene 6 grados de libertad. Las restricciones se usan en multitud de
situaciones en desarrollo de videojuegos, como en puertas, suspensiones de vehículos,
cadenas, cuerdas, etc. A continuación enumeraremos brevemente los principales tipos
de restricciones soportadas por los motores de simulación física.
Punto a punto. Este es el tipo de restricciones más sencillas; los objetos están
conectados por un punto. Los objetos tienen libertad de movimiento salvo por
el hecho de que tienen que mantenerse conectados por ese punto.
19.5. Introducción a Bullet [475]
Restricción Restricción
Punto a Muelle
Punto Rígido
Articulación Articulación
“Bola” Articulación Prismática
“Bisagra” “Pistón”
Figura 19.17: Representación de algunas de las principales restricciones que pueden encontrarse en los
motores de simulación física.
Muelle rígido. Un muelle rígido (Stiff Spring) funciona como una restricción
de tipo punto a punto salvo por el hecho de que los objetos están separados por
una determinada distancia. Así, puede verse como unidos por una barra rígida
que los separa una distancia fija.
Bisagras. Este tipo de restricción limitan el movimiento de rotación en un deter-
minado eje (ver Figura 19.17). Este tipo de restricciones pueden definir además
un límite de rotación angular permitido entre los objetos (para evitar, por ejem-
plo, que el codo de un brazo robótico adopte una posición imposible).
Pistones. Este tipo de restricciones, denominadas en general restricciones pris-
máticas, permiten limitar el movimiento de traslación a un único eje. Estas res-
tricciones podrían permitir opcionalmente rotación sobre ese eje.
Bolas. Estas restricciones permiten definir límites de rotación flexibles, esta-
bleciendo puntos de anclaje. Sirven por ejemplo para modelar la rotación que
C19
ocurre en el hombro de un personaje.
Otras restricciones. Cada motor permite una serie de restricciones específicas,
como planares (que restringen el movimiento en un plano 2D), cuerdas, cadenas
de objetos, rag dolls (definidas como una colección de cuerpos rígidos conecta-
dos utilizando una estructura jerárquica), etc...
Existen multitud de videojuegos comerciales que han utilizado Bullet como motor
de simulación física. Entre otros se pueden destacar Grand Theft Auto IV (de Red
Dead Redemption), Free Realms (de Sony), HotWheels (de BattleForce), Blood Drive
(de Activision) o Toy Story 3 (The Game) (de Disney).
De igual forma, el motor se ha utilizado en películas profesionales, como Hancock
(de Sony Pictures), Bolt de Walt Disney, Sherlock Holmes (de Framestore) o Shrek 4
(de DreamWorks).
Muchos motores gráficos y de videojuegos permiten utilizar Bullet. La comunidad
ha desarrollado multitud de wrappers para facilitar la integración en Ogre, Crystal
Space, Irrlich y Blitz3D entre otros.
En el diseño de Bullet se prestó especial atención en conseguir un motor fácilmente
adaptable y modular. Tal y como se comenta en su manual de usuario, el desarrollador
puede utilizar únicamente aquellos módulos que necesite (ver Figura 19.19):
Dinámica Bullet
.dea, .bsp, .obj...
SoftBody MultiHilo
ra 19.20. El pipeline se ejecuta desde la izquierda a la derecha, comenzando por la
etapa de cálculo de la gravedad, y finalizando con la integración de las posiciones Dinámica
(actualizando la transformación del mundo). Cada vez que se calcula un paso de la Cuerpo Rígido
simulación stepSimulation en el mundo, se ejecutan las 7 etapas definidas en la
imagen anterior. Detección de Colisiones
Contenedores, Gestión de
El Pipeline comienza aplicando la fuerza gravedad a los objetos de la escena. Memoria, Bib. Matemática...
Posteriormente se realiza una predicción de las transformaciones calculando la po-
sición actual de los objetos. Hecho esto se calculan las cajas AABBs, que darán una Figura 19.19: Esquema de los prin-
estimación rápida de la posición de los objetos. Con estas cajas se determinan los cipales módulos funcionales de Bu-
pares, que consiste en calcular si las cajas AABBs se solapan en algún eje. Si no hay llet.
19.5. Introducción a Bullet [477]
Inicio Time Step Time Step Time Step Time Step Time Step Fin
Figura 19.20: Pipeline de Bullet. En la parte superior de la imagen se describen las principales etapas
computacionales, y en la parte inferior las estructuras de datos más importantes.
ningún solape, podemos afirmar que no hay colisión. De otra forma, hay que realizar
un estudio más detallado del caso. Si hay posibilidad de contacto, se pasa a la siguien-
te etapa de calcular los contactos, que calcula utilizando la forma de colisión real del
objeto el punto de contacto exacto. Estos puntos de contacto se pasan a la última etapa
de cálculo de la dinámica, que comienza con la resolución de las restricciones, don-
de se determina la respuesta a la colisión empleando las restricciones de movimientos
que se han definido en la escena. Finalmente se integran las posiciones de los objetos,
obteniendo las posiciones y orientaciones nuevas para este paso de simulación.
Tipos básicos Las estructuras de datos básicas que define Bullet se dividen en dos grupos: los da-
tos de colisión, que dependen únicamente de la forma del objeto (no se tiene en cuenta
Bullet cuenta con un subconjunto las propiedades físcas, como la masa o la velocidad asociada al objeto), y los datos
de utilidades matemáticas básicas
con tipos de datos y operadores de- de propiedades dinámicas que almacenan las propiedades físicas como la masa, la
C19
finidos como btScalar (escalar en inercia, las restricciones y las articulaciones.
punto flotante), btVector3 (vector en
el espacio 3D), btTransform (trans- En los datos de colisión se distinguen las formas de colisión, las cajas AABBs, los
formación afín 3D), btQuaternion, pares superpuestos que mantiene una lista de parejas de cajas AABB que se solapan
btMatrix3x3... en algún eje, y los puntos de contacto que han sido calculados como resultado de la
colisión.
Altura: 49.9972 Una vez compilado el programa de ejemplo, al ejecutarlo obtenemos un resultado
Altura: 49.9917 puramente textual, como el mostrado en la Figura 19.21. Como hemos comentado
Altura: 49.9833 anteriormente, los Bullet está diseñado para permitir un uso modular. En este primer
Altura: 49.9722 ejemplo no se ha hecho uso de ninguna biblioteca para la representación gráfica de la
Altura: 49.9583
Altura: 49.9417 escena.
Altura: 49.9222 Si representamos los 300 valores de la altura de la esfera, obtenemos una gráfica
Altura: 49.9 como muestra la Figura 19.22. Como vemos, cuando la esfera (de 1 metro de radio)
Altura: 49.875 llega a un metro del suelo (medido desde el centro), rebota levemente y a partir del
✄
Altura: 49.8472 frame 230 aproximadamente se estabiliza.
El primer include definido en la línea ✂2 ✁del programa anterior se encarga de
Altura: 49.8167
Altura: 49.7833
Altura: 49.7472 incluir todos los archivos de cabecera necesarios para crear una aplicación que haga
Altura: 49.7083 uso del módulo de dinámicas (cuerpo rígido, restricciones, etc...). Necesitaremos ins-
Altura: 49.6667 tanciar un mundo sobre el que realizar la simulación. En nuestro caso crearemos un
... mundo discreto, que es el adecuado salvo que tengamos objetos de movimiento muy
Figura 19.21: Salida por pantalla rápido sobre el que tengamos que hacer una detección de su movimiento (en cuyo caso
de la ejecución del Hola Mundo en podríamos utilizar la clase aún experimental btContinuousDynamicsWorld).
Bullet.
60
50
Creación del mundo
40
30 Como vimos en la sección 19.2.2, los motores de simulación física utilizan varias
20 capas para detectar las colisiones entre los objetos del mundo. Bullet permite utilizar
10 algoritmos en una primera etapa (broadphase) que utilizan las cajas límite de los ob-
0 jetos del mundo para obtener una lista de pares de cajas que pueden estar en colisión.
50 100 150 200 250
Esta lista es una lista exhaustiva de todas las cajas límite que intersecan en alguno
C19
Figura 19.22: Representación de de los ejes (aunque, en etapas posteriores lleguen a descartarse porque en realidad no
los valores obtenidos en el Hola colisionan).
Mundo.
Muchos sistemas de simulación física se basan en el Teorema de los Ejes Separa-
dos. Este teorema dice que si existe un eje sobre el que la proyección de dos formas
convexas no se solapan, entonces podemos asegurar que las dos formas no colisio-
nan. Si no existe dicho eje y las dos formas son convexas, podemos asegurar que las
formas colisionan. Si las formas son cóncavas, podría ocurrir que no colisionaran (de-
pendiendo de la suerte que tengamos con la forma de los objetos). Este teorema se
puede visualizar fácilmente en 2 dimensiones, como se muestra en la Figura 19.23.a.
El mismo teorema puede aplicarse para cajas AABB. Además, el hecho de que las
cajas AABB estén perfectamente alineadas con los ejes del sistema de referencia, hace
que el cálculo de la proyección de estas cajas sea muy rápida (simplemente podemos
utilizar las coordenadas mínimas y máximas que definen las cajas).
La Figura 19.23 representa la aplicación de este teorema sobre cajas AABB. En
el caso c) de dicha figura puede comprobarse que la proyección de las cajas se solapa
en todos los ejes, por lo que tendríamos un caso potencial de colisión. En realidad,
como vemos en la figura las formas que contienen dichas cajas AABB no colisionan,
pero este caso deberá ser resuelto por algoritmos de detección de colisión de menor
granularidad.
[480] CAPÍTULO 19. SIMULACIÓN FÍSICA
Y Y Y
a) b) c)
B B B
Pr. B
A A A
Pr. A
Proyección Proyección
de A en X de B en X X Ejemplo de AABBs que X Ejemplo de AABBs que X
no intersecan intersecan
Figura 19.23: Aplicación del Teorema de los Ejes Separados y uso con cajas AABB. a) La proyección
de las formas convexas de los objetos A y B están separadas en el eje X, pero no en el eje Y. Como
podemos encontrar un eje sobre el que ambas proyecciones no intersecan, podemos asegurar que las formas
no colisionan. b) El mismo principio aplicados sobre cajas AABB definidas en objetos cóncavos, que no
intersecan. c) La proyección de las cajas AABB se solapa en todos los ejes. Hay un posible caso de colisión
entre formas.
✄
En la línea ✂5 ✁creamos un objeto que implementa un algoritmo de optimización
en una primera etapa broadphase. En posteriores etapas Bullet calculará las colisiones
exactas. Existen dos algoritmos básicos que implementa Bullet para mejorar al apro-
ximación a ciegas de complejidad O(n2 ) que comprobaría toda la lista de pares. Estos
algoritmos añaden nuevas parejas de cajas que en realidad no colisionan, aunque en
general mejoran el tiempo de ejecución.
✄
cuando los objetos se encuentren cerca). encargará de resolver la interacción
El objeto solver (línea ✂9 ✁) se encarga de que los objetos interactúen adecuada-
entre objetos.
✄
que hacen uso de paralelismo empleando hilos. de colisión. Si varios objetos de la
En la línea ✂10 ✁se instancia el mundo. Este objeto nos permitirá añadir los objetos
escena pueden compartir la misma
forma de colisión (por ejemplo, to-
Formas de Colisión
C19
nar formas de cualquier tipo (tanto primitivas como formas de colisión de tipo
malla que veremos a continuación). Permite obtener formas compuestas, como
la que se estudió en la Figura 19.14.
btConvexHull. Este es el tipo de forma de tipo malla más rápido. Se define co-
mo una nube de vértices que forman la forma convexa más pequeña posible. El
número de vértices debe ser pequeño para que la forma funcione adecuadamen-
te. El número de vértices puede reducirse empleando la utilidad proporcionada
por la clase btShapeHull. Existe una versión similar a este tipo llamado btCon-
vexTriangleMeshShape, que está formado por caras triangulares, aunque es
deseable utilizar btConvexHull porque es mucho más eficiente.
btBvhTriangleMeshShape. Malla triangular estática. Puede tener un número
considerable de polígonos, ya que utiliza una jerarquía interna para calcular la
colisión. Como la construcción de esta estructura de datos puede llevar tiempo,
se recomienda serializar el árbol para cargarlo rápidamente. Bullet incorpora
utilidades para la serialización y carga del árbol BVH.
btHeightfieldTerrainShape. Malla poligonal estática optimizada descrita por
un mapa de alturas.
[482] CAPÍTULO 19. SIMULACIÓN FÍSICA
✄
En la línea ✂16-17 ✁creamos una forma de colisión de tipo plano, pasando como
parámetro el vector normal del plano (vector unitario en Y), y una distancia respecto
del origen. Así, el plano de colisión queda definido por la ecuación y = 1.
De igual modo, la forma de colisión✄ del cuerpo que dejaremos caer sobre el suelo
será una esfera de radio 1 metro (línea ✂18 ✁).
Una vez definidas las formas de colisión, las posicionaremos asociándolas a ins-
tancias de cuerpos rígidos. En la siguiente subsección añadiremos los cuerpos rígidos
al mundo.
Cuerpos Rígidos
la forma de colisión del plano. El resultado sería el mismo si en ambos parámetros se hubiera puesto 0.
19.5. Introducción a Bullet [483]
✄
En las líneas ✂22-23 ✁ se emplea la estructura btRigidBodyConstructionInfo para
establecer la información para crear un cuerpo rígido.
El primer parámetro es la masa del objeto. Estableciendo una masa igual a cero
(primer parámetro), se crea un objeto estático (equivale a establecer una masa infinita,
de modo que el objeto no se puede mover). El último parámetro es la inercia del suelo
✄
(que se establece igualmente a 0, por ser un objeto estático).
En la línea ✂24 ✁creamos el objeto rígido a partir de la ✄información
almacenada en
la estructura anterior, y lo añadimos al mundo en la línea ✂25 ✁.
✄
La creación de la esfera sigue un patrón de código similar. En la línea ✂27 ✁se crea
✄el MotionState
para el objeto que dejaremos caer, situado a 50 metros del suelo (línea
✂ ✁
✄
28 ).
En las líneas ✂29-31 ✁se establecen las propieades del cuerpo; una masa de 1Kg y
se llama a un método de btCollisionShape que nos calcula la inercia de una esfera a
partir de su masa.
Bucle Principal
✄
Para finalizar, el bucle principal se ejecuta en las líneas ✂36-41 ✁. El bucle se ejecuta
300 veces, llamando al paso de simulación con un intervalo de 60hz. En cada paso de
la simulación se imprime la altura de la esfera sobre el suelo.
Como puede verse, la posición y la orientación del objeto dinámico se encapsulan
en un objeto de tipo btTransform. Como se comentó anteriormente, esta información
C19
puede obtenerse a partir del MotionState asociado al btRigidBody a través de la es-
✄
tructura de inicialización btRigidBodyConstructInfo.
Tiempos! El método para avanzar un paso en la simulación (línea ✂38 ✁) requiere dos paráme-
tros. El primero describe la cantidad de tiempo que queremos avanzar la simulación.
Cuidado, ya que las funciones de Bullet tiene un reloj interno que permite mantener constante esta actualización, de for-
cálculo de tiempo habitualmente
devuelven los resultados en milise- ma que sea independiente de la tasa de frames de la aplicación. El segundo parámetro
gundos. Bullet trabaja en segundos, es el número de subpasos que debe realizar bullet cada vez que se llama stepSimula-
por lo que ésta es una fuente habi- tion. Los tiempos se miden en segundos.
tual de errores.
El primer parámetro debe ser siempre menor que el número de subpasos multipli-
cado por el tiempo fijo de cada paso tStep < maxSubStep × tF ixedStep .
No olvides... Decrementando el tamaño de cada paso de simulación se está aumentado la reso-
lución de la simulación física. De este modo, si en el juego hay objetos que “atravie-
Cuando cambies el valor de los
tiempos de simulación, recuerda san” objetos (como paredes), es posible decrementar el fixedTimeStep para aumentar
calcular el número de subpasos de la resolución. Obviamente, cuando se aumenta la resolución al doble, se necesitará
simulación para que la ecuación si- aumentar el número de maxSubSteps al doble, lo que requerirá aproximadamente el
ga siendo correcta. doble de tiempo de CPU para el mismo tiempo de simulación física.
[484] CAPÍTULO 19. SIMULACIÓN FÍSICA
✄
En las líneas ✂15-19 ✁se define el método principal, que actualiza la posición y
rotación del SceneNode en Ogre. Dado que Bullet y Ogre definen clases distintas
para trabajar con Vectores y Cuaternios, es necesario obtener la rotación y posición
✄ y✄ asignarlo
del objeto por separado al nodo mediante las llamadas a setOrientation y
setPosition (líneas ✂17 ✁y ✂19 ✁).
✄
La llamada a setWorldTransform puede retornar en la línea ✂15 ✁si no se ha estable-
cido nodo en el constructor. Se habilita un método específico para establecer el nodo
más adelante. Esto es interesante si se quieren añadir objetos a la simulación que no
tengan representación gráfica.
Una vez creada la clase que utilizaremos para definir el MotionState, la utilizare-
mos en el “Hola Mundo” construido en la sección anterior para representar la simula-
ción con el plano y la esfera. El resultado que tendremos se muestra en la Figura 19.25.
Para la construcción del ejemplo emplearemos como esqueleto base el FrameListener
C19
del Módulo 2 del curso.
24 void MyFrameListener::CreateInitialWorld() {
25 // Creacion de la entidad y del SceneNode -----------------------
26 Plane plane1(Vector3::Vector3(0,1,0), 0);
27 MeshManager::getSingleton().createPlane("p1",
28 ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, plane1,
29 200, 200, 1, 1, true, 1, 20, 20, Vector3::UNIT_Z);
30 SceneNode* node = _sceneManager->createSceneNode("ground");
31 Entity* groundEnt = _sceneManager->createEntity("planeEnt","p1");
32 groundEnt->setMaterialName("Ground");
33 node->attachObject(groundEnt);
34 _sceneManager->getRootSceneNode()->addChild(node);
35
36 // Creamos las formas de colision -------------------------------
37 _groundShape = new btStaticPlaneShape(btVector3(0,1,0),1);
38 _fallShape = new btSphereShape(1);
39
40 // Creamos el plano ---------------------------------------------
41 MyMotionState* groundMotionState = new MyMotionState(
42 btTransform(btQuaternion(0,0,0,1),btVector3(0,-1,0)), node);
43 btRigidBody::btRigidBodyConstructionInfo groundRigidBodyCI
44 (0,groundMotionState,_groundShape,btVector3(0,0,0));
45 _groundRigidBody = new btRigidBody(groundRigidBodyCI);
46 _world->addRigidBody(_groundRigidBody);
47
48 // Creamos la esfera --------------------------------------------
49 Entity *entity2= _sceneManager->createEntity("ball","ball.mesh");
50 SceneNode *node2= _sceneManager->getRootSceneNode()->
createChildSceneNode();
51 node2->attachObject(entity2);
52 MyMotionState* fallMotionState = new MyMotionState(
53 btTransform(btQuaternion(0,0,0,1),btVector3(0,50,0)), node2);
54 btScalar mass = 1; btVector3 fallInertia(0,0,0);
55 _fallShape->calculateLocalInertia(mass,fallInertia);
56 btRigidBody::btRigidBodyConstructionInfo fallRigidBodyCI(
57 mass,fallMotionState,_fallShape,fallInertia);
58 _fallRigidBody = new btRigidBody(fallRigidBodyCI);
59 _world->addRigidBody(_fallRigidBody);
60 }
61
62 bool MyFrameListener::frameStarted(const Ogre::FrameEvent& evt) {
63 Real deltaT = evt.timeSinceLastFrame;
64 int fps = 1.0 / deltaT;
65
66 _world->stepSimulation(deltaT, 5); // Actualizar fisica
67
68 _keyboard->capture();
69 if (_keyboard->isKeyDown(OIS::KC_ESCAPE)) return false;
70
71 btVector3 impulse;
72 if (_keyboard->isKeyDown(OIS::KC_I)) impulse=btVector3(0,0,-.1);
73 if (_keyboard->isKeyDown(OIS::KC_J)) impulse=btVector3(-.1,0,0);
74 if (_keyboard->isKeyDown(OIS::KC_K)) impulse=btVector3(0,0,.1);
75 if (_keyboard->isKeyDown(OIS::KC_L)) impulse=btVector3(.1,0,0);
76 _fallRigidBody->applyCentralImpulse(impulse);
77
78 // Omitida parte del codigo fuente (manejo del raton, etc...)
79 return true;
80 }
81
82 bool MyFrameListener::frameEnded(const Ogre::FrameEvent& evt) {
83 Real deltaT = evt.timeSinceLastFrame;
84 _world->stepSimulation(deltaT, 5); // Actualizar fisica
85 return true;
86 }
19.7. Hola Mundo en OgreBullet [487]
C19
mina la llamada a stepSimulation, el 1 # Flags de compilacion -----------------------------
resultado de la simulación es mucho 2 CXXFLAGS := -I $(DIRHEA) -Wall ‘pkg-config --cflags OGRE‘ ‘pkg-
más brusco. config --cflags bullet‘
3
4 # Flags del linker -------------------------------
5 LDFLAGS := ‘pkg-config --libs-only-L OGRE‘ ‘pkg-config --libs-only-
l bullet‘
6 LDLIBS := ‘pkg-config --libs-only-l OGRE‘ ‘pkg-config --libs-only-l
bullet‘ -lOIS -lGL -lstdc++
✄
Para añadir un objeto de simulación de OgreBullet debemos crear dos elementos
básicos;✄ por un
lado la CollisionShape (líneas ✂ ✁), y por otro lado el RigidBody
(líneas ✂17-18 ✁).
14-16
C19
colisión a un RigidBody; setShape y setStaticShape. La segunda cuenta con varias
versiones; una de ellas✄ no requiere especificar el SceneNode, y se corresponde con la
utilizada en la línea ✂21 ✁para añadir la forma de colisión al plano.
Añadir a las colas
Una vez añadido el objeto, deben Listado 19.9: CreateInitialWorld
añadirse las referencias a la forma 1 void MyFrameListener::CreateInitialWorld() {
de colisión y al cuerpo rígido en las 2 // Creacion de la entidad y del SceneNode -----------------------
colas de la clase (línea 24). 3 Plane plane1(Vector3::Vector3(0,1,0), 0);
4 MeshManager::getSingleton().createPlane("p1",
5 ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, plane1,
6 200, 200, 1, 1, true, 1, 20, 20, Vector3::UNIT_Z);
7 SceneNode* node= _sceneManager->createSceneNode("ground");
8 Entity* groundEnt= _sceneManager->createEntity("planeEnt", "p1");
9 groundEnt->setMaterialName("Ground");
10 node->attachObject(groundEnt);
11 _sceneManager->getRootSceneNode()->addChild(node);
12
13 // Creamos forma de colision para el plano ----------------------
14 OgreBulletCollisions::CollisionShape *Shape;
15 Shape = new OgreBulletCollisions::StaticPlaneCollisionShape
16 (Ogre::Vector3(0,1,0), 0); // Vector normal y distancia
17 OgreBulletDynamics::RigidBody *rigidBodyPlane = new
18 OgreBulletDynamics::RigidBody("rigidBodyPlane", _world);
19
20 // Creamos la forma estatica (forma, Restitucion, Friccion) ----
[490] CAPÍTULO 19. SIMULACIÓN FÍSICA
33 }
✄
En el listado
anterior, el nodo asociado a cada caja se añade en la llamada a setSha-
pe (líneas ✂25-27 ✁). Pese a que Bullet soporta multitud de propiedades en la estructura
btRigidBodyConstructionInfo, el wrapper se centra exclusivamente en la definición de
la masa, y los coeficientes de fricción y restitución. La posición inicial y el cuaternio
se indican igualmente en la llamada al método, que nos abstrae de la necesidad de
definir el MotionState.
✄ a la escena con una velocidad lineal relativa a la rotación de
Las cajas se añaden
la cámara (ver líneas ✂28-29 ✁).
19.8. RayQueries
Al inicio del capítulo estudiamos algunos tipos de preguntas que podían realizarse
al motor de simulación física. Uno de ellos eran los RayQueries que permitían obtener
las formas de colisión que intersecaban con un determinado rayo.
Ogre? Bullet? Utilizaremos esta funcionalidad del SDC para aplicar un determinado impulso
al primer objeto que sea tocado por el puntero del ratón. De igual forma, en este
Recordemos que los objetos de si- ejemplo se añadirán objetos definiendo una forma de colisión convexa. El resultado
mulación física no son conocidos
por Ogre. Aunque en el módulo 2 de la simulación (activando la representación de las formas de colisión) se muestra en
del curso estudiamos los RayQue- la Figura 19.27.
ries en Ogre, es necesario realizar la
pregunta en Bullet para obtener las La llamada al método AddDynamicObject recibe como parámetro un tipo enume-
referencias a los RigidBody. rado, que indica si queremos añadir una oveja o una caja. La forma de colisión de
la caja✄se calcula automáticamente empleando la clase StaticMeshToShapeConverter
(línea ✂17 ✁).
C19
(para las ovejas y para las cajas), y comprobar la diferencia de rendimiento
en frames por segundo cuando el número de objetos de la escena crece.
23
24 if (body) {
25 if (!body->isStaticObject()) {
26 body->enableActiveState ();
27 Vector3 relPos(p - body->getCenterOfMassPosition());
28 Vector3 impulse (r.getDirection ());
29 body->applyImpulse (impulse * F, relPos);
30 }
31 }
32 }
33 // Omitido resto de codigo del metodo ---------------------------
34 }
✄
Para finalizar, si hubo colisión con algún cuerpo que no sea estático (líneas ✂24-25 ✁),
se aplicará un impulso. La llamada a enableActiveState permite activar un cuerpo. Por
defecto, Bullet automáticamente desactiva objetos dinámicos cuando la velocidad es
menor que un determinado umbral.
Los cuerpos desactivados en realidad están se encuentran en un estado de dormi-
dos, y no consumen tiempo de ejecución salvo por la etapa de detección de colisión
broadphase. Esta etapa automáticamente despierta a los objetos que estuvieran dor-
✄
midos si se encuentra colisión con otros elementos de la escena.
Impulso En las líneas ✂27-29 ✁se aplica un impulso sobre el objeto en la dirección del rayo
que se calculó desde la cámara, con una fuerza proporcional a F . El impulso es una
El impulso� puede definirse como
�
F dt = (dp/dt)dt, siendo p el fuerza que actúa en un cuerpo en un determinado intervalo de tiempo. El impulso
momento. implica un cambio en el momento, siendo la Fuerza definida como el cambio en el
momento. Así, el impulso aplicado sobre un objeto puede ser definido como la integral
de la fuerza con respecto del tiempo.
El wrapper de OgreBullet permite definir un pequeño subconjunto de propiedades
de los RigidBody de las soportadas en Bullet. Algunas de las principales propiedades
son la Velocidad Lineal, Impulsos y Fuerzas. Si se requieren otras propiedades, será
necesario acceder al objeto de la clase btRigidBody (mediante la llamada a getBulle-
tRigidBody) y especificar manualmente las propiedades de simulación.
C19
19.9. TriangleMeshCollisionShape
En este ejemplo se cargan dos objetos como mallas triangulares estáticas. El re-
sultado de la ejecución puede verse en la Figura 19.28. Al igual que en el ejemplo
anterior, se utiliza la funcionalidad proporcionada ✄por el conversor de mallas, pero
generando una TriangleMeshCollisionShape (línea ✂11-12 ✁).
Figura 19.29: Configuración de la malla estática utilizada en el ejemplo. Es importante aplicar la escala y
rotación a los objetos antes de su exportación, así como las dimensiones del objeto “ball” para aplicar los
mismos límites a la collision shape.
15 OgreBulletCollisions::Object *obDrain =
16 _world->findObject(drain);
17 OgreBulletCollisions::Object *obOB_A = _world->findObject(obA);
18 OgreBulletCollisions::Object *obOB_B = _world->findObject(obB);
19
20 if ((obOB_A == obDrain) || (obOB_B == obDrain)) {
21 Ogre::SceneNode* node = NULL;
22 if ((obOB_A != obDrain) && (obOB_A)) {
23 node = obOB_A->getRootNode(); delete obOB_A;
24 }
25 else if ((obOB_B != obDrain) && (obOB_B)) {
26 node = obOB_B->getRootNode(); delete obOB_B;
27 }
28 if (node) {
29 std::cout << node->getName() << std::endl;
30 _sceneManager->getRootSceneNode()->
31 removeAndDestroyChild (node->getName());
32 }
33 }
34 }
35 }
✄
OgreBullet... En la línea ✂2 ✁se obtiene el puntero directamente a la clase btCollisionWorld, que
se encuentra oculta en la implementación de OgreBullet. Con este puntero se accederá
El listado anterior muestra además
cómo acceder al objeto del mundo
directamente a la funcionalidad de Bullet sin emplear la clase de recubrimieneto de
de bullet, que permite utilizar gran OgreBullet. La clase btCollisionWorld sirve a la vez como interfaz y como contenedor
✄
cantidad de métodos que no están de las funcionalidades relativas a la detección de colisiones.
Mediante la llamada a getDispatcher (línea ✂3 ✁) se obtiene un puntero a la clase
implementados en OgreBullet.
C19
Los puntos de contacto se crean en la etapa de detección de colisiones fina
(narrow phase). La cache de btPersistentManifold puede estar vacía o conte-
ner hasta un máximo de 4 puntos de colisión. Los algoritmos de detección de
la colisión añaden y eliminan puntos de esta caché empleando ciertas heurís-
ticas que limitan el máximo de puntos a 4. Es posible obtener el número de
puntos de contacto asociados a la cache en cada instante mediante el método
getNumContacts().
✄
La cache de colisión mantiene punteros a los dos objetos que están colisionando.
Estos objetos pueden obtenerse mediante la llamada a métodos get (líneas ✂8-11 ✁).
La clase CollisionsWorld de OgreBullet proporciona un método findObject que
✄
permite obtener un puntero a objeto genérico a partir de un SceneNode o un btColli-
sionObject (ver líneas ✂15-18 ✁).
✄
La última parte del código (líneas ✂20-32 ✁) comprueba si alguno de los dos objetos
de la colisión son el sumidero. En ese caso, se obtiene el puntero al otro objeto (que
se corresponderá con un objeto de tipo esfera creado dinámicamente), y se elimina de
la escena. Así, los objetos en esta segunda versión del ejemplo no llegan a añadirse en
la caja de la parte inferior del circuito.
[496] CAPÍTULO 19. SIMULACIÓN FÍSICA
C19
1 OgreBulletDynamics::WheeledRigidBody *mCarChassis;
2 OgreBulletDynamics::VehicleTuning *mTuning;
3 OgreBulletDynamics::VehicleRayCaster *mVehicleRayCaster;
4 OgreBulletDynamics::RaycastVehicle *mVehicle;
5 Ogre::Entity *mChassis;
6 Ogre::Entity *mWheels[4];
7 Ogre::SceneNode *mWheelNodes[4];
8 float mSteering;
A continuación estudiaremos la✄definición del vehículo en el método CreateInitial-
World del FrameListener. La línea ✂1 ✁del siguiente listado define el vector de altura ✄del
chasis (elevación sobre el suelo), y la altura de conexión de las ruedas en él (línea ✂2 ✁)
✄
que será utilizado más adelante. En la construcción inicial del vehículo se establece la
dirección del vehículo como 0.0 (línea ✂3 ✁).
✄ ✄
La líneas ✂5-9 ✁crean la entidad del chasis y el nodo que la contendrá. La línea ✂10 ✁
utiliza el vector de altura del chasis para posicionar el nodo del chasis.
✄
El chasis tendrá asociada una forma de colisión de tipo caja (línea ✂1 ✁). ✄Esta
caja
formará parte de una forma de colisión compuesta, que se define en la línea✄ ✂2 ✁, y a la
que se añade la caja anterior desplazada según el vector chassisShift (línea ✂3 ✁).
✄
En la línea ✂4 ✁se define el cuerpo ✄rígido
del vehículo,
✄ al que se asocia la forma de
colisión creada anteriormente (línea ✂6 ✁). En la línea ✂9 ✁se establecen los valores
✄ de
suspensión del vehículo, y se evita que el vehículo pueda desactivarse (línea ✂8 ✁), de
modo que el objeto no se «dormirá» incluso si se detiene durante un tiempo continua-
do.
En el siguiente ✄fragmento
se comienza definiendo algunos parámetros de tuning
del vehículo (línea ✂1 ✁). Estos parámetros son la rigidez, compresión
✄ y amortiguación
de la suspensión y la fricción de deslizamiento. La línea ✂5 ✁establece el sistema de
✄ ✄
coordenadas local del vehículo mediante los índices derecho, superior y adelante.
Las líneas ✂7 ✁y ✂8 ✁definen los ejes que se utilizarán como dirección del vehículo y
✄
en la definición de las ruedas.
El bucle de las líneas ✂10-16 ✁construye los nodos de las ruedas, cargando 4 instan-
cias de la malla «wheel.mesh».
19.11. Restricción de Vehículo [499]
El ejemplo desarrollado en esta sección trabaja únicamente con las dos ruedas
delanteras (índices 0 y 1) como ruedas motrices. Además, ambas ruedas giran
de forma simétrica según la variable de dirección mSteering. Queda propuesto
como ejercicio modificar el código de esta sección para que la dirección se
pueda realizar igualmente con las ruedas traseras, así como incorporar otras
opciones de motricidad (ver Listado de FrameStarted).
C19
Listado 19.20: Fragmento de CreateInitialWorld (IV).
1 Ogre::Vector3 connectionPointCS0 (1-(0.3*gWheelWidth),
connectionHeight, 2-gWheelRadius);
2
3 mVehicle->addWheel(mWheelNodes[0], connectionPointCS0,
wheelDirectionCS0, wheelAxleCS, gSuspensionRestLength,
gWheelRadius, isFrontWheel, gWheelFriction, gRollInfluence);
Figura 19.32: La gestión del determinismo puede ser un aspecto crítico en muchos videojuegos. El error
de determinismo rápidamente se propaga haciendo que la simulación obtenga diferentes resultados.
19.12. Determinismo
El determinismo en el ámbito de la simulación física puede definirse de forma
intuitiva como la posibilidad de «repetición» de un mismo comportamiento. En el
caso de videojuegos esto puede ser interesante en la repetición de una misma jugada
en un videojuego deportivo, o en la ejecución de una misma simulación física en los
diferentes ordenadores de un videojuego multijugador. Incluso aunque el videojuego
siga un enfoque con cálculos de simulación centrados en el servidor, habitualmente es
necesario realizar ciertas interpolaciones del lado del cliente para mitigar los efectos
de la latencia, por lo que resulta imprescindible tratar con enfoques deterministas.
Para lograr determinismo es necesario lograr que la simulación se realice exacta-
mente con los mismos datos de entrada. Debido a la precisión en aritmética en punto
flotante, es posible que v × 2 × dt no de el mismo resultado que v × dt + v × dt. Así,
es necesario emplear el mismo valor de dt en todas las simulaciones. Por otro lado,
utilizar un dt fijo hace que no podamos representar la simulación de forma indepen-
diente de las capacidades de la máquina o la carga de representación gráfica concreta
en cada momento. Así, nos interesa tener lo mejor de ambas aproximaciones; por un
lado un tiempo fijo para conseguir el determinismo en la simulación, y por otro lado
la gestión con diferentes tiempos asociados al framerate para lograr independencia de
la máquina.
19.12. Determinismo [501]
Puede ayudar pensar que el motor de render produce chunks de tiempo dis-
creto, mientras que el motor de simulación física los consume.
A continuación se muestra un sencillo game loop que puede emplearse para con-
seguir determinismo de una forma sencilla.
Los tiempos mostrados en este pseudocódigo se especifican en milisegundos, y se
✄
obtienen a partir de una hipotética función getMillisecons().
La línea ✂1 ✁define TickMs, una variable que nos define la velocidad del reloj in-
terno de nuestro juego (por ejemplo, 32ms). Esta variable no tiene que ver con el reloj
✄ el comporta-
de Bullet. Las variables relativas al reloj de simulación física describen
miento independiente ✄y asíncrono del reloj interno de Bullet (línea ✂2 ✁) y el reloj del
motor de juego (línea ✂3 ✁).
C19
Listado 19.22: Pseudocódigo física determinista.
1 const unsigned int TickMs 32
2 unsigned long time_physics_prev, time_physics_curr;
3 unsigned long time_gameclock;
4
5 // Inicialmente reseteamos los temporizadores
6 time_physics_prev = time_physics_curr = getMilliseconds();
7 time_gameclock = getMilliseconds();
8
9 while (1) {
10 video->renderOneFrame();
11 time_physics_curr = getMilliseconds();
12 mWorld->stepSimulation(((float)(time_physics_curr -
13 time_physics_prev))/1000.0, 10);
14 time_physics_prev = time_physics_curr;
15 long long dt = getMilliseconds() - time_gameclock;
16
17 while(dt >= TickMs) {
18 dt -= TickMs;
19 time_gameclock += TickMs;
20 input->do_all_your_input_processing();
21 }
22 }
[502] CAPÍTULO 19. SIMULACIÓN FÍSICA
✄
Como se indica en las líneas ✂6-7 ✁, inicialmente se resetean los temporizadores.
✄
El pseudocódigo del bucle principal del juego se resume en las líneas ✂9-21 ✁. Tras
representar un
✄ frame, se obtiene el tiempo transcurrido desde la última simulación
física (línea ✂11 ✁), y se avanza un paso de simulación en segundos (como la llamada al
sistema lo obtiene en milisegundos y Bullet lo requiere en segundos, hay que dividir
por 1000).
Por último, se actualiza la parte relativa al reloj de juego. Se calcula en dt la
diferencia entre los milisegundos que pasaron desde la última vez✄que se acualizó el
reloj de juego, y se dejan pasar (en el bucle definido en las líneas ✂17-21 ✁) empleando
ticks discretos. En cada tick consumido se procesan los eventos de entrada.
19.14. Serialización
La serialización de objetos en Bullet es una característica propia de la biblioteca
que no requiere de ningún plugin o soporte adicional. La serialización de objetos pre-
senta grandes ventajas relativas al precálculo de formas de colisión complejas. Para
guardar un mundo dinámico en un archivo .bullet, puede utilizarse el siguiente frag-
mento de código de ejemplo:
C19
Técnicas Avanzadas
El objetivo de este módulo� ����lado «Técnicas Avanzadas» dentro
del Curso de Experto en Desarrollo de Videojuegos, es profundizar
es aspectos de desarrollo más avanzados que complementen el resto
de contenidos de dicho curso y permitan explorar soluciones más
eficientes en el contexto del desarrollo de videojuegos. En este
módulo se introducen aspectos básicos de jugabilidad y se describen
algunas metodologías de desarrollo de videojuegos. Así mismo,
también se estudian los fundamentos básicos de la validación y
pruebas en este proceso de desarrollo. No obstante, uno de los
componentes más importantes del presente módulo está relacionado
con aspectos avanzados del lenguaje de programación C++, como
por ejemplo el estudio en profundidad de la biblioteca STL, y las
optimizaciones. Finalmente, el presente módulo se complementa con
aspectos de representación avanzada, como los filtros de partículas o
la programación de shaders, y con un estudio en detalle de técnicas
de optimización para escenarios interiores y exteriores. Por otra
parte, se realiza un estudio de la plataforma de desarrollo de
videojuegos Unity, especialmente ideada para el desarrollo de juegos
en plataformas móviles.
Aspectos de Jugabilidad y
Capítulo 20
Metodologías de Desarrollo
Miguel Ángel Redondo Duque
José Luis González
E
n este capítulo se introducen aspectos básicos relativos al concepto de jugabili-
dad, como por ejemplo aquellos vinculados a su caractarización en el ámbito
del desarrollo de videojuegos o las facetas más relevantes de los mismos, ha-
ciendo especial hincapié en la parte relativa a su calidad.
Por otra parte, en este capítulo también se discuten los fundamentos de las me-
todologías de desarrollo para videojuegos, estableciendo las principales fases y las
actividades desarrolladas en ellas.
20.1.1. Introducción
En el desarrollo de sistemas interactivos es fundamental la participación del usua-
rio. Por ello, se plantean los denominados métodos de Diseño Centrado en el Usuario
que se aplican, al menos, al desarrollo software que soporta directamente la interac-
ción con el usuario. En otras palabras, es fundamental contar con su participación para
que tengamos la garantía de que se le va a proporcionar buenas experiencias de uso.
El software para videojuegos se puede considerar como un caso particular de sistema
interactivo por lo que requiere de un planteamiento similar en este sentido, aunque
en este ámbito, los términos y conceptos que se emplean para este propósito, varían
ligeramente.
507
[508] CAPÍTULO 20. ASPECTOS DE JUGABILIDAD Y METODOLOGÍAS DE DESARROLLO
atributos o propiedades que lo caracterizan. Estos atributos podrán ser medidos y va-
lorados, para así comparar y extraer conclusiones objetivas. Este trabajo fue realizado
por José Luis González [40] que define la Jugabilidad como el conjunto de propieda-
des que describen la experiencia del jugador ante un sistema de juego determinado,
cuyo principal objetivo es divertir y entretener “de forma satisfactoria y creíble”, ya
sea solo o en compañía.
Es importante remarcar los conceptos de satisfacción y credibilidad. El primero
es común a cualquier sistema interactivo. Sin embargo, la credibilidad dependerá del
grado en el que se pueda lograr que los jugadores se impliquen en el juego.
Hay que significar que los atributos y propiedades que se utilizan para caracterizar
la Jugabilidad y la Experiencia del Jugador, en muchos casos ya se han utilizado para
caracterizar la Usabilidad, pero en los videojuegos presentan matices distintos. Por
ejemplo, el “Aprendizaje” en un videojuego puede ser elevado, lo que puede provocar
que el jugador se vea satisfecho ante el reto que supone aprender a jugarlo y, pos-
teriormente, desarrollar lo aprendido dentro del juego. Un ejemplo lo tenemos en el
videojuego Prince of Persia, donde es difícil aprender a controlar nuestro personaje a
través de un mundo virtual, lo que supone un reto en los primeros compases del juego.
Sin embargo, en cualquier otro sistema interactivo podría suponer motivo suficiente
de rechazo. Por otro lado, la “Efectividad” en un juego no busca la rapidez por com-
pletar una tarea, pues entra dentro de la naturaleza del videojuego que el usuario esté
jugando el máximo tiempo posible y son muchos los ejemplos que podríamos citar.
Los atributos a los que hacemos referencia para caracterizar la Jugabilidad son los
siguientes:
C20
Inmersión. Capacidad para creerse lo que se juega e integrarse en el mundo
virtual mostrado en el juego.
Motivación. Característica del videojuego que mueve a la persona a realizar
determinadas acciones y a persistir en ellas para su culminación.
Emoción. Impulso involuntario originado como respuesta a los estímulos del
videojuego, que induce sentimientos y que desencadena conductas de reacción
automática.
Socialización. Atributos que hacen apreciar el videojuego de distinta manera al
jugarlo en compañía (multijugador), ya sea de manera competitiva, colaborativa
o cooperativa.
La figura 20.1 muestra como estos atributos y algunos otros más pueden estar rela-
cionados con el concepto de Usabilidad tal y como se recoge en las normas ISO/IEC-
9241. Hay algunos atributos que están relacionados con el videojuego (producto) y
otros se vinculan al proceso de desarrollo del juego (desarrollo), algunos hacen refe-
rencia a su influencia sobre el jugador/es (usuarios o grupos de usuarios).
[510] CAPÍTULO 20. ASPECTOS DE JUGABILIDAD Y METODOLOGÍAS DE DESARROLLO
En [40] incluso se relacionan, a nivel interactivo, estas facetas para ilustrar cómo
pueden ser las implicaciones e influencias que presentan. Esta relación se resume en
la figura 20.2.
C20
Con todo lo anterior, se puede concluir que la Jugabilidad de un juego podría con-
siderarse como el análisis del valor de cada una de las propiedades y de los atributos
en las facetas consideradas.
C20
Figura 20.3: Clasificación de las propiedades de la calidad del producto y del proceso en un videojuego
[514] CAPÍTULO 20. ASPECTOS DE JUGABILIDAD Y METODOLOGÍAS DE DESARROLLO
C20
Figura 20.8: Métricas para atributos de Seguridad
habilidades y maneras de jugar del jugador. Por otro lado, en un videojuego hay metas
que debe realizar el jugador, pero sin embargo, la facilidad de consecución de esas
metas no es el principal objetivo. De hecho, más bien podría decirse que es justo lo
contrario, el uso del juego debe ser una motivación y presentar cierta dificultad de
consecución, de lo contrario el jugador perderá motivación por el uso del videojuego.
Así mismo, la medida de la frecuencia de error en el software tradicional con
un valor cercano a 0 siempre es mejor, pero en videojuegos podemos encontrar tanto
valores cercanos a 0 como a 1. Si el valor es cercano a 0, nos encontramos ante un
jugador experto o que la dificultad del juego es muy baja. Cercano a 1, nos informa
que nos encontramos ante un jugador novato, o que se encuentra en los primeros com-
pases del juego, o que la dificultad es muy elevada. Es por ello que los videojuegos
ofrecen distintos niveles de dificultad para atraer a los nuevos jugadores, evitando, por
ejemplo, que una dificultad extremadamente fácil haga que el juego pierda interés y
se vuelva aburrido.
La eficacia en el caso de los videojuegos es relativa, es decir, el usuario querrá ju-
gar de forma inmediata, sin perder tiempo en recibir excesiva información, pero, de la
misma manera que comentábamos con anterioridad, el juego debe aportar dificultad y
el usuario debería encontrar cierta resistencia y progresiva dificultad en la consecución
de las metas que lleve asociado.
La personalización también es algo especialmente deseable en el mundo del vi-
deojuego, porque en él coexisten muchos elementos de diseño que tratan de distraer,
de acompañar y de establecer la forma de interacción. Ésta última debería ser flexible
en cuanto a poder dar soporte a diferentes formas de interactuar: teclas, mandos, soni-
dos, etc. El atributo de la accesibilidad, aunque deseable y exigible, tradicionalmente
no ha contado con mucha atención en el desarrollo de videojuegos.
Este aspecto está cambiando y la presencia de este atributo contribuye al uso del
mismo ya sea en la interfaz de usuario o en las mecánicas del juego. En este modelo de
Jugabilidad este atributo se consideró implícitamente dentro de otros. Los problemas
de la accesibilidad pueden considerarse problemas de Usabilidad/Jugabilidad para,
por ejemplo, jugadores con algún tipo de discapacidad.
Si un jugador no puede entender lo que se dice en determinadas escenas u oír
si otro personaje camina detrás de él por problemas de sonido, es recomendable el
uso de subtítulos. Si el jugador no puede manejar determinado control de juego, se
recomienda el uso de dispositivos alternativos para facilitar el control de juego.
La seguridad/prevención es un factor que, en el caso de los videojuegos, toma
cada vez más importancia. El juego, en la actualidad, no es sólo estático, mental y
de sobremesa, sino que supone, en algunos casos, exigencias físicas, por ejemplo el
uso de un control de juego que demande un esfuerzo corporal o movimientos bruscos,
los cuales pueden ser potencialmente peligrosos o dañinos para la salud si el jugador
desarrolla la actividad de juego con ellos durante un tiempo prolongado de ocio.
La satisfacción es el atributo más determinante al tratar con videojuegos. Muchos
aspectos: cognitivos, emocionales, físicos, de confianza y sociales pueden considerar-
se bajo este factor de la Jugabilidad. La estimación de la misma se realiza fundamen-
talmente con cuestionarios y observando al jugador mientras juega y viendo cuáles son
sus preferencias de un momento de ocio para el siguiente. Probablemente, este atribu-
to en videojuegos es el más rico y subjetivo. Por lo tanto, es el que se ha enriquecido
más con atributos y propiedades para mejorar su medida y estimación.
Finalmente, en la última columna de la tabla se proponen diversos métodos de
evaluación para cada métrica. Estos métodos pueden enriquecerse y guiarse por las
facetas, las cuales nos pueden ayudar a identificar la calidad de elementos concretos
de un videojuego según el uso y las acciones mostradas por el conjunto de jugadores.
20.1. Jugabilidad y Experiencia del Jugador [517]
Las principales formas de medición son la observación, donde podemos medir con
herramientas cómo y de qué manera actúa el jugador con el videojuego, usando por
ejemplo las métricas presentadas o usar cuestionarios o tests heurísticos para preguntar
o interrogar por atributos de la Jugabilidad. Estos cuestionarios pueden ir guiados por
facetas para facilitar su análisis.
C20
[518] CAPÍTULO 20. ASPECTOS DE JUGABILIDAD Y METODOLOGÍAS DE DESARROLLO
Describimos a continuación cada una de sus etapas de forma más detallada para
comprender su objetivo de una forma más clara.
20.2.1. Pre-Producción
En la fase de Pre-Producción se lleva a cabo la concepción de la idea del juego,
C20
identificando los elementos fundamentales que lo caracterizarán y finalizando, si es
posible, en un diseño conceptual del mismo. Esta información se organiza para dar
lugar a lo que puede considerarse una primera versión del documento de diseño del
juego o más conocido como GDD (Game Design Document). En este GDD, que debe
ser elaborado por el equipo creativo del diseño de videojuegos, se debe identificar
y fijar todo lo relacionado con el Diseño del Videojuego que será necesario abordar
posteriormente (normalmente en la fase de Producción).
Como patrón de referencia y de acuerdo a lo establecido en [12], el GDD debe
contener lo siguiente:
Como se ha indicado anteriormente, esta primera versión del GDD será el punto
de partida para iniciar la fase de Producción, pero cabe insistir sobre la importancia de
uno de sus elementos: se trata del Gameplay. Dado el carácter un tanto difuso de este
concepto, consideremos como ejemplo el caso particular del conocido y clásico jue-
go “Space Invaders”. En este juego indicaríamos que se debe poder mover una nave
alrededor del cuadrante inferior de la pantalla y disparar a una serie de enemigos que
aparecen por la parte superior de la pantalla y que desaparecen cuando son alcanzados
C20
por los disparos. Estos enemigos tratan de atacarnos con sus disparos y presionán-
donos mediante la reducción de nuestro espacio de movimientos e intentando chocar
contra nuestra nave.
El Gameplay tiene una implicación importantísima en la calidad final del juego
y, por lo extensión, en la Jugabilidad del mismo. Luego los esfuerzos destinados a su
análisis y planteamiento revertirán directamente en las propiedades que caracterizan la
Juabilidad. No obstante y para profundizar en más detalle sobre este aspecto, se reco-
mienda consultar los siguientes libros: “Rules of Play: Game Design Fundamentals”
[80] y “Game Design: Theory and Practice” [77].
20.2.2. Producción
La fase de Producción es la fase donde se concentra el trabajo principal, en volu-
men y en número de participantes, del proceso de desarrollo del videojuego, especial-
mente en lo que se denomina Diseño del Juego y Diseño Técnico. Hay que significar
que este curso está orientado, fundamentalmente, a las tareas y técnicas relacionadas
con el Diseño Técnico, pero no queremos dejar de situarlo en el contexto del proceso
global que requiere llevarse a cabo para concebir y desarrollar un producto de estas
características.
[522] CAPÍTULO 20. ASPECTOS DE JUGABILIDAD Y METODOLOGÍAS DE DESARROLLO
C20
se publicará y que se producirá. Obviamente, incluye todo el contenido artístico,
técnico y documental (es decir, los manuales de usuario). En este momento, la
publicidad deber ser la mayor posible, incluyéndose la realización de reportajes,
artículos, etc.
20.2.3. Post-Producción
La fase de Post-Producción, en la que no nos vamos a detener ya que se aleja
bastante del contenido tratado en el curso, aborda fundamentalmente la explotación y
el mantenimiento del juego como si de cualquier otro producto software se tratase.
[524] CAPÍTULO 20. ASPECTOS DE JUGABILIDAD Y METODOLOGÍAS DE DESARROLLO
C20
perfiles de jugadores existentes. Además, esto contribuye directamente a la reducción
de la proliferación de productos que requieren numerosos “parches” incluso desde los
primeros meses de vida en el mercado.
En este sentido [40] propone un método inspirado directamente en la metodología
PPIu+a propuesta en [41] para Ingeniería de la Usabilidad y que se resume en la
figura 20.12. Para facilitar su comprensión puede utilizarse la figura 20.13 en la que
se relaciona y compara esta metodología MPIu+a.
En las fuentes citadas pueden encontrar muchos más detalles sobre la fases más
destacables que son las de análisis, diseño, desarrollo y evaluación de elementos juga-
bles. Especialmente, se plantea un patrón a seguir para la obtención de requisitos de
Jugabilidad con ejemplos de aplicación, se proponen una serie de guías de estilo para
llevar a cabo un diseño que fomente la Jugabilidad, se muestra cómo aplicar Scrum y
programación extrema para la construcción de prototipos jugables y se describe cómo
evaluar la Jugabilidad de los prototipos para obtener conclusiones sobre la experiencia
del jugador.
[526] CAPÍTULO 20. ASPECTOS DE JUGABILIDAD Y METODOLOGÍAS DE DESARROLLO
Figura 20.13: Comparación entre el método de Diseño Centrado en el Jugador y el de Diseño Centrado en
el Usuario de MPIu+a
Capítulo
C++ Avanzado
21
Francisco Moya Fernández
David Villa Alises
Sergio Pérez Camacho
Cleto Martín Angelina
527
[528] CAPÍTULO 21. C++ AVANZADO
10 1
4 17 4
1 5 16 21 10
5 17
16 21
Figura 21.1: Dos árboles de búsqueda binaria. Ambos contienen los mismos elementos pero el de la iz-
quierda es mucho más eficiente
Todos los nodos del subárbol izquierdo de un nodo tienen una clave
menor o igual a la de dicho nodo. Análogamente, la clave de un nodo es
siempre menor o igual que la de cualquier otro nodo del subárbol derecho.
Por tratarse del primer tipo de árboles expondremos con cierto detalle su imple-
mentación. Como en cualquier árbol necesitamos modelar los nodos del árbol, que
corresponden a una simple estructura:
C21
3 typedef Node<KeyType> NodeType;
4
5 KeyType key;
6 NodeType* parent;
7 NodeType* left;
8 NodeType* right;
Para obtener el mínimo basta recorrer todos los subárboles de la izquierda y análo-
gamente para encontrar el máximo hay que recorrer todos los subárboles de la derecha
hasta llegar a un nodo sin subárbol derecho.
Las instancias de Node no tienen por qué ser visibles directamente al programa-
dor, al igual que los contenedores tipo set de la STL. Por ejemplo, esto puede lograrse
utilizando un namespace privado.
La búsqueda de un elemento también puede plantearse con un algoritmo recursivo
aprovechando la propiedad que define a los árboles de búsqueda binaria:
El atributo root mantiene cuál es el nodo raíz del árbol. Los métodos de inserción
y borrado deben actualizarlo adecuadamente.
C21
de debe insertar el elemento, manteniendo el padre del elemento actual para poder
recuperar el punto adecuado al llegar a un nodo nulo.
El procedimiento más complejo es el de borrado de un nodo. De acuerdo a [20] se
identifican los cuatro casos que muestra la figura 21.2. Un caso no representado es el
caso trivial en el que el nodo a borrar no tenga hijos. En ese caso basta con modificar
el nodo padre para que el hijo correspondiente sea el objeto nulo. Los dos primeros
casos de la figura corresponden al borrado de un nodo con un solo hijo, en cuyo caso el
hijo pasa a ocupar el lugar del nodo a borrar. El tercer caso corresponde al caso en que
el hijo derecho no tenga hijo izquierdo o el hijo izquierdo no tenga hijo derecho, en
cuyo caso se puede realizar la misma operación que en los casos anteriores enlazando
adecuadamente las dos ramas. El cuarto caso corresponde al caso general, con dos
hijos no nulos. En ese caso buscamos un sucesor del subárbol izquierdo que no tenga
hijo izquierdo, que pasa a reemplazar al nodo, reajustando el resto para mantener la
condición de árbol de búsqueda binaria.
Con el objetivo de facilitar el movimiento de subárboles definimos el método
transplant(). El subárbol con raíz u se reemplaza con el subárbol con raíz v.
[532] CAPÍTULO 21. C++ AVANZADO
Red-black trees
(a) q q
z r
NIL r
(b) q q
z l
l NIL
(c) q q
z y
l y l x
NIL x
C21
(d) q q q
z z y y
l r l NIL r l r
y x x
NIL x
Figura 21.2: Casos posibles según [20] en el borrado de un nodo en un árbol de búsqueda binaria
[534] CAPÍTULO 21. C++ AVANZADO
10
4 17
1 5 16 21
Figura 21.3: Ejemplo de árbol rojo-negro. Los nodos hoja no se representarán en el resto del texto.
y rotate right
x
x c a y
rotate left
a b b c
C21
Figura 21.4: Operación de rotación a la derecha o a la izquierda en un árbol de búsqueda binaria
9 x = x->right;
10 }
11 z->parent = y;
12 if (y == NodeType::nil())
13 root = z;
14 else if (z->key < y->key)
15 y->left = z;
16 else
17 y->right = z;
18 z->left = NodeType::nil();
19 z->right = NodeType::nil();
20 z->color = Node::Color::Red;
21 insert_fixup(z);
22 }
Al asumir el color rojo podemos haber violado las reglas de los árboles rojo-negro.
Por esta razón llamamos a la función insert_fixup() que garantiza el cumpli-
miento de las reglas tras la inserción:
La inserción de un nodo rojo puede violar la regla 2 (el nodo raíz queda como
rojo en el caso de un árbol vacío) o la regla 4 (el nodo insertado pasa a ser hijo de
un nodo rojo). Este último caso es el que se contempla en el bucle de la función
insert_fixup(). Cada una de las dos ramas del if sigue la estrategia dual, depen-
diendo de si el padre es un hijo derecho o izquierdo. Basta estudiar el funcionamiento
de una rama, dado que la otra es idéntica pero intercambiando right y left. Bási-
camente se identifican tres casos.
✄ ✄
El primero corresponde a las líneas ✂6 ✁a ✂9 ✁. Es el caso en que el nodo a insertar
pasa a ser hijo de un nodo rojo cuyo hermano también es rojo (e.g. figura 21.5.a).
En este caso el nodo padre y el nodo tío se pasan a negro mientras que el abuelo
se pasa a rojo (para mantener el número de nodos negros en todos los caminos).
Al cambiar a rojo el nodo abuelo es posible que se haya vuelto a violar alguna
regla, y por eso se vuelven a comprobar los casos.
Otra posibilidad es que el nodo tío sea negro y además el nodo insertado sea hijo
derecho (e.g. figura 21.5.b). En ese caso se realiza una rotación a la izquierda
para reducirlo al caso siguiente y se aplica lo correspondiente al último caso.
El último caso corresponde a que el nodo tío sea negro y el nodo insertado sea
hijo izquierdo (e.g. figura 21.5.c). En ese caso se colorea el padre como negro, y
el abuelo como rojo, y se rota a la derecha el abuelo. Este método deja un árbol
correcto.
z z z
C21
(a) (b) (c)
Figura 21.5: Casos contemplados en la función insert_fixup().
El nodo y corresponde al nodo que va a eliminarse o moverse dentro del árbol. Será
el propio z si tiene menos de dos hijos o el nodo y de los casos c y d en la figura 21.2.
Mantenemos la variable y_orig_color con el color que tenía ese nodo que se ha
eliminado o movido dentro del árbol. Solo si es negro puede plantear problemas de
violación de reglas, porque el número de nodos negros por cada rama puede variar.
Para arreglar los problemas potenciales se utiliza una función análoga a la utilizada en
la inserción de nuevos nodos.
19 w->color = Node::Color::Red;
20 rotate_right(w);
21 w = x->parent->right;
22 }
23 w->color = x->parent->color;
24 x->parent->color = Node::Color::Black;
25 w->right->color = Node::Color::Black;
26 rotate_left(x->parent);
27 x = root;
28 }
29 }
30 else {
31 NodeType* w = x->parent->left;
32 if (w->color == Node::Color::Red) {
33 w->color = Node::Color::Black;
34 x->parent->color = Node::Color::Red;
35 rotate_right(x->parent);
36 w = x->parent->left;
37 }
38 if (w->right->color == Node::Color::Black
39 && w->left->color == Node::Color::Black) {
40 w->color = Node::Color::Red;
41 x = x->parent;
42 }
43 else {
44 if (w->left->color == Node::Color::Black) {
45 w->right->color = Node::Color::Black;
46 w->color = Node::Color::Red;
47 rotate_left(w);
48 w = x->parent->left;
49 }
50 w->color = x->parent->color;
51 x->parent->color = Node::Color::Black;
52 w->left->color = Node::Color::Black;
53 rotate_right(x->parent);
54 x = root;
55 }
56 }
57 }
58 x->color = Node::Color::Black;
59 }
C21
El hermano w es rojo. En ese caso forzosamente los hijos de w deben ser negros.
Por tanto se puede intercambiar los colores del hermano y del padre y hacer una
rotación a la izquierda sin violar nuevas reglas. De esta forma el nuevo hermano
será forzosamente negro, por lo que este caso se transforma en alguno de los
posteriores.
El hermano w es negro y los dos hijos de w son negros. En ese caso cambiamos
el color del hermano a rojo. De esta forma se equilibra el número de negros por
cada rama, pero puede generar una violación de reglas en el nodo padre, que se
tratará en la siguiente iteración del bucle.
El hermano w es negro y el hijo izquierdo de w es rojo. En ese caso intercambia-
mos los colores de w y su hijo izquierdo y hacemos una rotación a la derecha.
De esta forma hemos reducido este caso al siguiente.
El hermano w es negro y el hijo derecho de w es rojo. En este caso cambiando
colores en los nodos que muestra la figura 21.6.d y rotando a la izquierda se
obtiene un árbol correcto que compensa el número de negros en cada rama.
[540] CAPÍTULO 21. C++ AVANZADO
(a)
B D
x A D w B E
C E x A w C
(b)
B x B
x A D w A D
C E C E
(c)
B B
x A D w x A C w
C E D
(d)
B D
x A D w B E
C E A C
AVL trees
Los árboles AVL (Adelson-Velskii and Landis) son otra forma de árbol balanceado
en el que se utiliza la altura del árbol como criterio de balanceo. Solo puede haber una
diferencia de 1 entre la altura de dos ramas. Es por tanto un criterio más estricto que
los red-black trees, lo que lo hace menos eficiente en las inserciones y borrados pero
más eficiente en las lecturas.
Árboles balanceados
Los red-black trees son más eficien-
tes en insert() y remove(),
pero los AVL trees son más eficien-
tes en search().
21.1. Estructuras de datos no lineales [541]
Cada nodo tiene información adicional con la altura del árbol en ese punto. En
realidad tan solo es necesario almacenar el factor de equilibrio que es simplemente
la diferencia entre las alturas del subárbol izquierdo y el derecho. La ventaja de esta
última alternativa es que es un número mucho más reducido (siempre comprendido en
el rango -2 a +2) por lo que puede almacenarse en solo 3 bits.
Para insertar elementos en un árbol AVL se utiliza un procedimiento similar a
cualquier inserción en árboles de búsqueda binaria, con dos diferencias:
Radix tree
Aún hay otro tipo de árboles binarios que vale la pena comentar por sus implicacio-
nes con los videojuegos. Se trata de los árboles de prefijos, frecuentemente llamados
tries1 .
La figura 21.8 muestra un ejemplo de árbol de prefijos con un conjunto de enteros
binarios. El árbol los representa en orden lexicográfico. Para cada secuencia binaria si
empieza por 0 está en el subárbol izquierdo y si empieza por 1 en el subárbol derecho.
Conforme se recorren las ramas del árbol se obtiene la secuencia de bits del número
a buscar. Es decir, el tramo entre el nodo raíz y cualquier nodo intermedio define el
prefijo por el que empieza el número a buscar. Por eso a este árbol se le llama prefix
C21
tree o radix tree.
Un árbol de prefijos (pero no binario) se utiliza frecuentemente en los diccionarios
predictivos de los teléfonos móviles. Cada subárbol corresponde a una nueva letra de
la palabra a buscar.
También se puede utilizar un árbol de prefijos para indexar puntos en un segmento
de longitud arbitraria. Todos los puntos en la mitad derecha del segmento están en
el subárbol derecho, mientras que todos los puntos de la mitad izquierda están en
el subárbol izquierdo. Cada subárbol tiene las mismas propiedades con respecto al
subsegmento que representa. Es decir, el subárbol derecho es un árbol de prefijos que
representa a medio segmento derecho, y así sucesivamente. El número de niveles del
árbol es ajustable dependiendo de la precisión que requerimos en el posicionamiento
de los puntos.
1 El nombre en singular es trie, que deriva de retrieve. Por tanto la pronunciación correcta se asemeja a
(a) A A
h+2 h h h+2
B B
C C
(b) A A
h+2 h h h+2
C C
B B
(c) C C
h+1 h+1 h+1 h+1
B A B A
En los árboles de prefijos la posición de los nodos está prefijada a priori por el
valor de la clave. Estos árboles no realizan ninguna función de equilibrado por lo que
su implementación es trivial. Sin embargo estarán razonablemente equilibrados si los
nodos presentes están uniformemente repartidos por el espacio de claves.
0 1
0
1 0
10
1 0 1
011 100
1
1011
Figura 21.8: Un ejemplo de trie extraido de [20]. Contiene los elementos 1011, 10, 011, 100 y 0.
C21
1 template <typename Func>
2 void preorder_tree_walk(Func f) {
3 preorder_tree_walk(root, f);
4 }
5
6 template <typename Func>
7 void preorder_tree_walk(NodeType* x, Func f) {
8 if (x == 0) return;
9 f(x);
10 preorder_tree_walk(x->left, f);
11 preorder_tree_walk(x->right, f);
12 }
3 postorder_tree_walk(root, f);
4 }
5
6 template <typename Func>
7 void postorder_tree_walk(NodeType* x, Func f) {
8 if (x == 0) return;
9 postorder_tree_walk(x->left, f);
10 postorder_tree_walk(x->right, f);
11 f(x);
12 }
Pero para el recorrido de estructuras de datos con frecuencia es mucho mejor em-
plear el patrón iterador. En ese caso puede reutilizarse cualquier algoritmo de la STL.
Incluir el orden de recorrido en el iterador implica almacenar el estado necesario.
Las funciones de recorrido anteriormente descritas son recursivas, por lo que el es-
tado se almacenaba en los sucesivos marcos de pila correspondientes a cada llamada
anidada. Por tanto necesitamos un contenedor con la ruta completa desde la raíz has-
ta el nodo actual. También tendremos que almacenar el estado de recorrido de dicho
nodo, puesto que el mismo nodo es visitado en tres ocasiones, una para el subárbol
izquierdo, otra para el propio nodo, y otra para el subárbol derecho.
41
42 template<typename KeyType>
43 inline bool operator== (inorder_iterator<KeyType> const& lhs,
44 inorder_iterator<KeyType> const& rhs) {
45 return lhs.equal(rhs);
46 }
C21
21.1.3. Quadtree y octree
Los árboles binarios particionan de forma eficiente un espacio de claves de una
sola dimensión. Pero con pequeñas extensiones es posible particionar espacios de dos
y tres dimensiones. Es decir, pueden ser usados para indexar el espacio de forma
eficiente.
Los quadtrees y los octrees son la extensión natural de los árboles binarios de
prefijos (tries) para dos y tres dimensiones respectivamente. Un quadtree es un árbol
cuyos nodos tienen cuatro subárboles correspondientes a los cuatro cuadrantes de un
espacio bidimensional. Los nodos de los octrees tienen ocho subárboles correspon-
dientes a los ocho octantes de un espacio tridimensional.
La implementación y el funcionamiento es análogo al de un árbol prefijo utilizado
para indexar los puntos de un segmento. Adicionalmente, también se emplean para
indexar segmentos y polígonos.
[546] CAPÍTULO 21. C++ AVANZADO
Cuando se utilizan para indexar segmentos o polígonos puede ocurrir que un mis-
mo segmento cruce el límite de un cuadrante o un octante. En ese caso existen dos
posibles soluciones:
Hacer un recortado (clipping) del polígono dentro de los límites del cuadrante
u octante.
Poner el polígono en todos los cuadrantes u octantes con los que intersecta.
En este último caso es preciso disponer de alguna bandera asociada a los polígonos
para no recorrerlos más veces de las necesarias.
Simon Perreault distribuye una implementación sencilla y eficiente de octrees en
C++2 . Simplificando un poco esta implementación los nodos son representados de esta
forma:
octree.html.
21.1. Estructuras de datos no lineales [547]
17 Node* children[2][2][2];
18 };
19
20 class Leaf : public Node {
21 public:
22 Leaf( const T& v );
23
24 const T& value() const;
25 T& value();
26 void setValue( const T& v );
27
28 private:
29 T value_;
30 };
✄
La línea ✂5 ✁ construye un octree de 4096 puntos de ancho en cada dimensión.
Esta implementación requiere que sea una potencia de dos, siendo posible indexar
4096 × 4096 × 4096 nodos.
C21
La regularidad de los octree los hacen especialmente indicados para la parale-
lización con GPU y recientemente están teniendo cierto resurgimiento con su
utilización en el renderizado de escenas con raycasting o incluso raytracing
en una técnica denominada Sparse Voxel Octree.
thesis/CCrassinThesis_EN_Web.pdf
[548] CAPÍTULO 21. C++ AVANZADO
Bjarne Stroustrup inventó una técnica de aplicación general para resolver este tipo
de problemas. Se llama RAII (Resource Acquisition Is Initialization) y básicamente
consiste en encapsular las operaciones de adquisición de recursos y liberación de re-
cursos en el constructor y destructor de una clase normal. Esto es precisamente lo que
hace auto_ptr con respecto a la reserva de memoria dinámica. El mismo código
del fragmento anterior puede reescribirse de forma segura así:
Como puede verse hemos ligado el tiempo de vida del objeto construido en memo-
ria dinámica al tiempo de vida del auto_ptr, que suele ser una variable automática o
un miembro de clase. Se dice que el auto_ptr posee al objeto dinámico. Pero puede
ceder su posesión simplemente con una asignación o una copia a otro auto_ptr.
Es decir, auto_ptr garantiza que solo hay un objeto que posee el objeto di-
námico. También permite desligar el objeto dinámico del auto_ptr para volver a
gestionar la memoria de forma explícita.
C21
1 unique_ptr<T> p(new T());
2 unique_ptr<T> q;
3 q = p; // error (no copiable)
4 q = std:move(p);
Listado 21.31: Función que reserva memoria dinámica y traspasa la propiedad al llamador.
También funcionaría correctamente con auto_ptr.
1 unique_ptr<T> f() {
2 unique_ptr<T> p(new T());
3 // ...
4 return p;
5 }
La función f() devuelve memoria dinámica. Con simples punteros eso implicaba
que el llamante se hacía cargo de su destrucción, de controlar su ciclo de vida. Con esta
construcción ya no es necesario. Si el llamante ignora el valor de retorno éste se libera
automáticamente al destruir la variable temporal correspondiente al valor de retorno.
Si en cambio el llamante asigna el valor de retorno a otra variable unique_ptr
entonces está asumiendo la propiedad y se liberará automáticamente cuando el nuevo
unique_ptr sea destruido.
Tanto con auto_ptr como con unique_ptr se persigue que la gestión de me-
moria dinámica sea análoga a la de las variables automáticas con semántica de copia.
Sin embargo no aprovechan la posibilidad de que el mismo contenido de memoria sea
utilizado desde varias variables. Es decir, para que la semántica de copia sea la natural
en los punteros, que se generen dos objetos equivalentes, pero sin copiar la memoria
dinámica. Para ese caso el único soporte que ofrecía C++ hasta ahora eran los punteros
y las referencias. Y ya sabemos que ese es un terreno pantanoso.
La biblioteca estándar de C++11 incorpora dos nuevas plantillas para la gestión
del ciclo de vida de la memoria dinámica que ya existían en la biblioteca Boost:
shared_ptr y weak_ptr. Ambos cooperan para disponer de una gestión de me-
moria muy flexible. La plantilla shared_ptr implementa una técnica conocida co-
mo conteo de referencias.
Cuando se asigna un puntero por primera vez a un shared_ptr se inicializa
un contador interno a 1. Este contador se almacena en memoria dinámica y es com-
partido por todos los shared_ptr que apunten al mismo objeto. Cuando se asigna
este shared_ptr a otro shared_ptr o se utiliza el constructor de copia, se in-
crementa el contador interno. Cuando se destruye un shared_ptr se decrementa
el contador interno. Y finalmente cuando el contador interno llega a 0, se destruye
automáticamente el objeto dinámico.
7 }
8 // ...
✄
En la línea ✂1 ✁se construye un shared_ptr que apunta a un ✄ objeto dinámi-
co. Esto pone el contador interno de referencias a 1. En la línea ✂4 ✁se asigna este
shared_ptr a otro. No se copia el objeto dinámico, sino solo su dirección y la del
✄ que además es automáticamente incrementado (pasa a va-
contador de referencias,
ler 2). En la línea ✂5 ✁se utiliza el constructor de copia de otro shared_ptr, que
✄
nuevamente copia solo el puntero y el puntero al contador de referencias, además de
incrementar su valor (pasa a valer 3). En la línea ✂7 ✁se destruye automáticamente r,
con lo que se decrementa el contador de referencias (vuelve a valer 2). Cuando acabe
el bloque en el que se han declarado p y q se destruirán ambas variables, y con ello se
decrementará dos veces el contador de referencias. Al llegar a 0 automáticamente se
invocará el operador delete sobre el objeto dinámico.
El conteo de referencias proporciona una poderosa herramienta para simplificar
la programación de aplicaciones con objetos dinámicos. Los shared_ptr pueden
copiarse o asignarse con total libertad y con una semántica intuitiva. Pueden emplearse
en contenedores de la STL y pasarlos por valor libremente como parámetros a función
o como valor de retorno de una función. Sin embargo no están totalmente exentos de
problemas. Considera el caso en el que main() tiene un shared_ptr apuntando
a una clase A y ésta a su vez contiene directa o indirectamente un shared_ptr que
vuelve a apuntar a A. Tendríamos un ciclo de referencias y el contador de referencias
con un balor de 2. En caso de que se destruyera el shared_ptr inicial seguiríamos
teniendo una referencia a A, por lo que no se destruirá.
Para romper los ciclos de referencias la biblioteca estándar incluye la plantilla
weak_ptr. Un weak_ptr es otro smart pointer a un objeto que se utiliza en estas
condiciones:
C21
los asteroides vecinos para detectar colisiones. Una colisión lleva a la destrucción
de uno o más asteroides. Cada asteroide debe almacenar una lista de los asteroides
vecinos. Pero el hecho de pertenecer a esa lista no mantiene al asteroide vivo. Por
tanto el uso de shared_ptr sería inapropiado. Por otro lado un asteroide no debe ser
destruido mientras otro asteroide lo examina (para calcular los efectos de una colisión,
por ejemplo), pero debe llamarse al destructor en algún momento para liberar los
recursos asociados. Necesitamos una lista de asteroides que podrían estar vivos y una
forma de sujetarlos por un tiempo. Eso es justo lo que hace weak_ptr.
Listado 21.33: Esquema de funcionamiento del propietario de los asteroides. Usa shared_ptr
para representar propiedad.
1 vector<shared_ptr<Asteroid>> va(100);
2 for (int i=0; i<va.size(); ++i) {
3 // ... calculate neighbors for new asteroid ...
4 va[i].reset(new Asteroid(weak_ptr<Asteroid>(va[neighbor])))
;
5 launch(i);
5 http://www.research.att.com/~bs/C++0xFAQ.html#std-weak_ptr
[552] CAPÍTULO 21. C++ AVANZADO
6 }
7 // ...
La primera regla impide tener punteros normales coexistiendo con los smart poin-
ters. Eso solo puede generar quebraderos de cabeza, puesto que el smart pointer no es
capaz de trazar los accesos al objeto desde los punteros normales.
La segunda regla garantiza la liberación correcta de la memoria en presencia de
excepciones6 . Veamos un ejemplo extraído de la documentación de Boost:
Para entender por qué la linea 10 es peligrosa basta saber que el orden de eva-
luación de los argumentos no está especificado. Podría evaluarse primero el operador
new, después llamarse a la función g(), y finalmente no llamarse nunca al constructor
de shared_ptr porque g() eleva una excepción.
6 Este caso ha sido descrito en detalle por Herb Sutter en http://www.gotw.ca/gotw/056.htm.
21.2. Patrones de diseño avanzados [553]
C21
2 public:
3 C();
4 /*...*/
5 private:
6 class CImpl;
7 auto_ptr<CImpl> pimpl_;
8 };
7 Por ejemplo, en
http://www.gotw.ca/publications/using_auto_ptr_effectively.htm.
[554] CAPÍTULO 21. C++ AVANZADO
21.2.2. Command
El patrón command (se traduciría como orden en castellano) se utiliza frecuente-
mente en interfaces gráficas para el manejo de las órdenes del usuario. Consiste en
encapsular las peticiones en objetos que permiten desacoplar la emisión de la orden
de la recepción, tanto desde el punto de vista lógico como temporal.
Problema
Solución
C21
Figura 21.14: Ejemplo de aplicación del patrón command.
El interfaz de usuario crea las órdenes a realizar por el personaje o los personajes
que están siendo controlados, así como la asociación con su personaje. Estas acciones
se van procesando por el motor del juego, posiblemente en paralelo.
Implementación
Entre estos dos extremos se encuentran las órdenes que realizan algunas funcio-
nes pero delegan otras en el receptor. En general a todo este tipo de órdenes se les
denomina active commands.
Desde el punto de vista de la implementación hay poco que podamos añadir a una
orden activa. Tienen código de aplicación específico que hay que añadir en el método
execute().
Sin embargo, los forwarding commands actúan en cierta forma como si se tratara
de punteros a función. El Invoker invoca el método execute() del objeto orden y
éste a su vez ejecuta un método del objeto Receiver al que está asociado. En [9] se
describe una técnica interesante para este fin, los generalized functors o adaptadores
polimórficos para objetos función. Se trata de una plantilla que encapsula cualquier
objeto, cualquier método de ese objeto, y cualquier conjunto de argumentos para dicho
método. Su ejecución se traduce en la invocación del método sobre el objeto con
los argumentos almacenados. Este tipo de functors permiten reducir sensiblemente
el trabajo que implicaría una jerarquía de órdenes concretas. Boost implementa una
técnica similar en la plantilla function, que ha sido incorporada al nuevo estándar
de C++ (en la cabecera functional). Por ejemplo:
Consideraciones
El patrón command desacopla el objeto que invoca la operación del objeto que
sabe cómo se realiza.
Al contrario que la invocación directa, las órdenes son objetos normales. Pueden
ser manipulados y extendidos como cualquier otro objeto.
Las órdenes pueden ser agregadas utilizando el patrón composite.
Las órdenes pueden incluir transacciones para garantizar la consistencia sin nin-
gún tipo de precaución adicional por parte del cliente. Es el objeto Invoker el
que debe reintentar la ejecución de órdenes que han abortado por un interblo-
queo.
Si las órdenes a realizar consisten en invocar directamente un método o una fun-
ción se puede utilizar la técnica de generalized functors para reducir el código
necesario sin necesidad de implementar una jerarquía de órdenes.
Problema
Solución
C21
1 template<typename T> class Base;
2
3 class Derived: public Base<Derived> {
4 // ...
5 };
La clase derivada hereda de una plantilla instanciada para ella misma. La clase
base cuenta en su implementación con un tipo que deriva de ella misma. Por tanto la
propia clase base puede llamar a funciones especializadas en la clase derivada.
Implementación
Se han propuesto multitud de casos donde puede aplicarse este patrón. Nosotros
destacaremos en primer lugar su uso para implementar visitantes alternativos a los ya
vistos en el módulo 1.
Recordaremos brevemente la estructura del patrón visitante tal y como se contó en
el módulo 1. Examinando la figura 21.15 podemos ver que:
[558] CAPÍTULO 21. C++ AVANZADO
La clase base Visitor (y por tanto todas sus clases derivadas) es tremen-
damente dependiente de la jerarquía de objetos visitables de la izquierda. Si
se implementa un nuevo tipo de elemento ElementC (una nueva subclase de
Element) tendremos que añadir un nuevo método visitElementB() en
la clase Visitor y con ello tendremos que reescribir todos y cada uno de
las subclases de Visitor. Cada clase visitable tiene un método específico de
visita.
La jerarquía de elementos visitables no puede ser una estructura arbitraria, debe
estar compuesta por subclases de la clase Element e implementar el método
accept().
Si se requiere cambiar la estrategia de visita. Por ejemplo, unificar el método
de visita de dos tipos de elementos, es preciso cambiar la jerarquía de objetos
visitables.
El orden de visita de los elementos agregados está marcado por la implementa-
ción concreta de las funciones accept() o visitX(). O bien se introduce
el orden de recorrido en los métodos accept() de forma que no es fácil cam-
biarlo, o bien se programa a medida en los métodos visitX() concretos. No
es fácil definir un orden de recorrido de elementos (en orden, en preorden, en
postorden) común para todos las subclases de Visitor.
En este caso en que no sea necesario el primer despachado virtual se puede lograr
de una manera mucho más eficiente sin ni siquiera usar funciones virtuales, gracias al
patrón CRTP. Por ejemplo, el mismo ejemplo del módulo 1 quedaría así:
C21
40 << endl;
41 for (auto n : _names) cout << n << endl;
42 }
43 };
44
45 class BombVisitor : public Visitor<BombVisitor> {
46 Bomb _bomb;
47 public:
48 BombVisitor(const Bomb& bomb) : _bomb(bomb) {}
49 void visitObject(ObjectScene* o) {
50 Point new_pos = calculateNewPosition(o->position,
51 o->weight,
52 _bomb.intensity);
53 o->position = new_pos;
54 }
55 };
[560] CAPÍTULO 21. C++ AVANZADO
Y con ello podemos definir todos los operadores de golpe sin más que definir
operator <.
Listado 21.43: Ejemplo de mixin con CRTP para implementación automática de operadores.
1 class Person : public Comparisons<Person> {
2 public:
3 Person(string name_, unsigned age_)
4 : name(name_), age(age_) {}
5
6 friend bool operator<(const Person& p1, const Person& p2);
7 private:
8 string name;
8 http://eli.thegreenplace.net/2011/05/17/the-curiously-recurring-template-pattern-in-c/
21.2. Patrones de diseño avanzados [561]
9 unsigned age;
10 };
11
12 bool operator<(const Person& p1, const Person& p2) {
13 return p1.age < p2.age;
14 }
Consideraciones
C21
21.2.4. Reactor
El patrón Reactor es un patrón arquitectural para resolver el problema de cómo
atender peticiones concurrentes a través de señales y manejadores de señales.
Problema
En los videojuegos ocurre algo muy similar: diferentes entidades pueden lanzar
eventos que deben ser tratados en el momento en el que se producen. Por ejemplo, la
pulsación de un botón en el joystick de un jugador es un evento que debe ejecutar el
código pertinente para que la acción tenga efecto en el juego.
Solución
En el patrón Reactor se definen una serie de actores con las siguientes responsabi-
lidades (véase figura 21.16):
Eventos: los eventos externos que puedan ocurrir sobre los recursos (Handles).
Normalmente su ocurrencia es asíncrona y siempre está relaciona a un recurso
determinado.
Recursos (Handles): se refiere a los objetos sobre los que ocurren los eventos.
La pulsación de una tecla, la expiración de un temporizador o una conexión en-
trante en un socket son ejemplos de eventos que ocurren sobre ciertos recursos.
La representación de los recursos en sistemas tipo GNU/Linux es el descriptor
de fichero.
Manejadores de Eventos: Asociados a los recursos y a los eventos que se pro-
ducen en ellos, se encuentran los manejadores de eventos (EventHandler)
que reciben una invocación a través del método handle() con la información
del evento que se ha producido.
Reactor: se trata de la clase que encapsula todo el comportamiento relativo a
la desmultiplexación de los eventos en manejadores de eventos (dispatching).
Cuando ocurre un cierto evento, se busca los manejadores asociados y se les
invoca el método handle().
Nótese que aunque los eventos ocurran concurrentemente el Reactor serializa las
llamadas a los manejadores. Por lo tanto, la ejecución de los manejadores de eventos
ocurre de forma secuencial.
Implementación
C21
17 reactor.register_handler(ACE_STDIN,
18 &h,
19 ACE_Event_Handler::READ_MASK);
20 for(;;)
21 reactor.handle_events();
22
23 return 0;
24 }
9 http://www.cs.wustl.edu/~schmidt/ACE.html
[564] CAPÍTULO 21. C++ AVANZADO
Consideraciones
21.2.5. Acceptor/Connector
Acceptor-Connector es un patrón de diseño propuesto por Douglas C. Schmidt [82]
y utilizado extensivamente en ACE, su biblioteca de comunicaciones.
La mayoría de los videojuegos actuales necesitan comunicar datos entre jugadores
de distintos lugares físicos. En toda comunicación en red intervienen dos ordenadores
con roles bien diferenciados. Uno de los ordenadores toma el rol activo en la comu-
nicación y solicita una conexión con el otro. El otro asume un rol pasivo esperando
solicitudes de conexión. Una vez establecida la comunicación cualquiera de los or-
denadores puede a su vez tomar el rol activo enviando datos o el pasivo, esperando
la llegada de datos. Es decir, en toda comunicación aparece una fase de conexión e
inicialización del servicio y un intercambio de datos según un patrón de intercambio
de mensajes pre-establecido.
El patrón acceptor-connector se ocupa de la primera parte de la comunicación.
Desacopla el establecimiento de conexión y la inicialización del servicio del procesa-
miento que se realiza una vez que el servicio está inicializado. Para ello intervienen
tres componentes: acceptors, connectors y manejadores de servicio (service handlers.
Un connector representa el rol activo, y solicita una conexión a un acceptor, que re-
presenta el rol pasivo. Cuando la conexión se establece ambos crean un manejador de
servicio que procesa los datos intercambiados en la conexión.
Problema
El procesamiento de los datos que viajan por la red es en la mayoría de los ca-
sos independiente de qué protocolos, interfaces de programación de comunicaciones,
o tecnologías específicas se utilicen para transportarlos. El establecimiento de la co-
municación es un proceso inherentemente asimétrico (uno inicia la conexión mientras
otro espera conexiones) pero una vez establecida la comunicación el transporte de
datos es completamente ortogonal.
Desde el punto de vista práctico resuelve los siguientes problemas:
Facilita el cambio de los roles de conexión sin afectar a los roles en el intercam-
bio de datos.
Facilita la adición de nuevos servicios y protocolos sin afectar al resto de la
arquitectura de comunicación.
21.2. Patrones de diseño avanzados [565]
Solución
Un Acceptor es una factoría que implementa el rol pasivo para establecer co-
nexiones. Ante una conexión crea e inicializa un Transport Handle y un
Service Handler asociados. En su inicialización, un Acceptor se asocia
a una dirección de transporte (e.g. dirección IP y puerto TCP), y se configura pa-
ra aceptar conexiones en modo pasivo. Cuando llega una solicitud de conexión
realiza tres pasos:
C21
1. Acepta la conexión creando un Transport Handle que encapsula un
extremo conectado.
2. Crea un Service Handler que se comunicará directamente con el del
otro extremo a través del Transport Handle asociado.
3. Activa el Service Handler para terminar la inicialización.
Un Connector es una factoría que implementa el rol activo de la conexión. En
la inicialización de la conexión connect()() crea un Transport Handle
que encapsula un extremo conectado con un Acceptor remoto, y lo asocia a
un Service Handler preexistente.
World of Warcraft Tanto Acceptor como Connector pueden tener separadas las funciones de
inicialización de la conexión de la función de completado de la conexión (cuando ya
WoW es el mayor MMORPG de la se tiene garantías de que el otro extremo ha establecido la conexión). De esta forma es
actualidad con más de 11.5 millo-
nes de suscriptores mensuales. fácil soportar conexiones asíncronas y síncronas de forma completamente transparen-
te.
[566] CAPÍTULO 21. C++ AVANZADO
Implementación
C21
Téngase en cuenta que estos ejemplos son simples en exceso, con el propósito de
ilustrar el uso del patrón. En un videojuego habría que tratar los errores adecuadamente
y ACE permite también configurar el esquema de concurrencia deseado.
Consideraciones
21.3.1. Algoritmos
Para conseguir estructuras de datos genéricas, los contenedores se implementan
como plantillas —como ya se discutió en capítulos anteriores— de modo que el tipo
de dato concreto que han de almacenar se especifica en el momento de la creación de
la instancia.
Aunque es posible implementar algoritmos sencillos del mismo modo —parametrizando
el tipo de dato— STL utiliza un mecanismo mucho más potente: los iteradores. Los
iteradores permiten desacoplar tanto el tipo de dato como el modo en que se organizan
y almacenan los datos en el contenedor.
Lógicamente, para que un algoritmo pueda hacer su trabajo tiene que asumir que
tanto los elementos del contenedor como los iteradores tienen ciertas propiedades, o
siendo más precisos, un conjunto de métodos con un comportamiento predecible. Por
ejemplo, para poder comparar dos colecciones de elementos, deben ser comparables
dos a dos para determinar su igualdad —sin entrar en qué significa eso realmente. Así
10 también llamado polimorfismo «de subclase» o «de inclusión», en contraposición con el «polimorfismo
paramétrico»
21.3. Programación genérica [569]
pues, el algoritmo equal() espera que los elementos soporten en «modelo» Equa-
lityComparable, que implica que tienen sobrecargado el método operator==(),
además de cumplir éste ciertas condiciones como reflexibidad, simetría, transitividad,
etc.
algoritmos «escalares»
Aunque la mayoría de los algorit- Escribiendo un algoritmo genérico
mos de la STL manejan secuencias
delimitadas por dos iteradores, tam- El mejor modo de comprender en qué consiste la «genericidad» de un algoritmo
bién hay algunos que utilizan da-
tos escalares, tales como min(), es crear uno desde cero. Escribamos nuestra propia versión del algoritmo genérico
max(), power() o swap() que count() (uno de los más sencillos). Este algoritmo sirve para contar el número
pueden resultar útiles para compo- de ocurrencias de un elemento en una secuencia. Como una primera aproximación
ner algoritmos más complejos. veamos cómo hacerlo para un array de enteros. Podría ser algo como:
C21
modificarlos.
20 }
21
22 void test_my_count4_letters() {
23 const int size = 5;
24 const int value = ’a’;
25 char letters[] = {’a’, ’b’, ’c’, ’a’, ’b’};
26 vector<char> letters_vector(letters, letters + size);
27
28 assert(my_count4(letters, letters+size, value) == 2);
29 assert(my_count4(letters_vector.begin(), letters_vector.end(),
30 value) == 2);
31 }
C21
21.3.2. Predicados
En el algoritmo count(), el criterio para contar es la igualdad con el elemento
proporcionado. Eso limita mucho sus posibilidades porque puede haber muchos otros
motivos por los que sea necesario contar elementos de una secuencia: esferas de color
rojo, enemigos con nivel mayor al del jugador, armas sin munición, etc.
Lógica de predicados Por ese motivo, muchos algoritmos de la STL tienen una versión alternativa que
permite especificar un parámetro adicional llamado predicado. El algoritmo invocará
En lógica, un predicado es una ex- el predicado para averiguar si se cumple la condición indicada por el programador y
presión que se puede evaluar como
cierta o falsa en función del valor de así determinar cómo debe proceder con cada elemento de la secuencia.
sus variables de entrada. En progra-
mación, y en particular en la STL,
En C/C++, para que una función pueda invocar a otra (en este caso, el algoritmo
un predicado es una función (en el al predicado) se le debe pasar como parámetro un puntero a función.
sentido amplio) que acepta un va-
11 Para utilizar los algoritmos estándar se debe incluir el fichero <algorithm>.
lor del mismo tipo que los elemen-
tos de la secuencia sobre la que se
usa y devuelve un valor booleano.
[572] CAPÍTULO 21. C++ AVANZADO
Igual que con cualquier otro tipo de dato, cuando se pasa un puntero a función
como argumento, el parámetro de la función que lo acepta debe estar declarado con
ese mismo tipo. Concretamente el tipo del predicado not_equal_2 sería algo como:
21.3.3. Functors
Existe sin embargo una solución elegante para conseguir «predicados configura-
bles». Consiste es declarar una clase que sobrecargue el operador de invocación —
método operator()()— que permite utilizar las instancias de esa clase como si
fueran funciones. Las clases que permiten este comportamiento se denominan «fun-
ctors»13 . Veamos como implementar un predicado not_equal() como un functor:
Pero los predicados no son la única utilidad interesante de los functors. Los pre-
dicados son una particularización de las «funciones» (u operadores). Los operadores
pueden devolver cualquier tipo. no sólo booleanos.
La STL clasifica los operadores en 3 categorías básicas:
C21
Unario Una función que acepta un argumento.
Binario Una función que acepta dos argumentos.
Como ejemplo, el siguiente listado multiplica los elementos del array numbers:
21.3.4. Adaptadores
Es habitual que la operación que nos gustaría que ejecute el algoritmo sea un
método (una función miembro) en lugar de una función estándar. Si tratamos de pasar
al algoritmo un puntero a método no funcionará, porque el algoritmo no le pasará el
parámetro implícito this que todo método necesita.
Una posible solución sería escribir un functor que invoque el método deseado,
como se muestra en el siguiente listado.
4 assert(count_if(enemies.begin(), enemies.end(),
5 mem_fun_ref(&Enemy::is_alive)) == 2);
6 }
Nótese que bind2nd() espera un functor como primer parámetro. Como lo que
tenemos es una función normal es necesario utilizar otro adaptador llamado ptr_fun(),
que como su nombre indica adapta un puntero a función a functor.
bind2nd() pasa su parámetro adicional (el 2 en este caso) como segundo pará-
metro en la llamada a la función not_equal(), es decir, la primera llamada para
la secuencia del ejemplo será not_equal(1, 2). El primer argumento (el 1) es
el primer elemento obtenido de la secuencia. El adaptador bind1st() los pasa en
orden inverso, es decir, pasa el valor extra como primer parámetro y el elemento de la
secuencia en segunda posición.
Hay otros adaptadores de menos uso:
C21
not1() devuelve un predicado que es la negación lógica del predicado unario al que
se aplique.
not2() devuelve un predicado que es la negación lógica del predicado binario al
que se aplique.
Nomenclatura compose1() devuelve un operador resultado de componer las dos funciones una-
Los nombres de los algoritmos si- rias que se le pasan como parámetros. Es decir, dadas las funciones f (x) y g(x)
guen ciertos criterios. Como ya he- devuelve una función f (g(x)).
mos visto, aquellos que tienen una
versión acabada en el sufijo _if
aceptan un predicado en lugar de compose2() devuelve un operador resultado de componer una función binaria y
utilizar un criterio implícito. Los dos funciones unarias que se le pasan como parámetro del siguiente modo. Da-
que tienen el sufijo _copy gene- das las funciones f (x, y), g1 (x) y g2 (x) devuelve una función h(x) = f (g1 (x), g2 (x)).
ran su resultado en otra secuencia,
en lugar de modificar la secuencia
original. Y por último, los que aca-
ban en _n aceptan un iterador y un
entero en lugar de dos iteradores; de
ese modo se pueden utilizar para in-
sertar en «cosas» distintas de conte-
nedores, por ejemplo flujos.
[576] CAPÍTULO 21. C++ AVANZADO
for_each()
find() / find_if()
Devuelve un iterador al primer elemento del rango que coincide con el indicado
(si se usa find()) o que cumple el predicado (si se usa find_if()). Devuelve el
iterador end si no encuentra ninguna coincidencia. Un ejemplo en el que se busca el
primer entero mayor que 6 que haya en el rango:
count() / count_if()
mismatch()
Dados dos rangos, devuelve un par de iteradores a los elementos de cada rango en
el que las secuencias difieren. Veamos el siguiente ejemplo —extraído de la documen-
tación de SGI15 :
equal()
Indica si los rangos indicados son iguales. Por defecto utiliza el operator==(),
pero opcionalmente es posible indicar un predicado binario como cuarto parámetro
para determinar en qué consiste la «igualdad». Veamos un ejemplo en el que se com-
C21
paran dos listas de figuras que se considerarán iguales simplemente porque coincida
su color:
search()
copy()
Copia los elementos de un rango en otro. No debería utilizarse para copiar una
secuencia completa en otra ya que el operador de asignación que tienen todos los
contenedores resulta más eficiente. Sí resulta interesante para copiar fragmentos de
secuencias. Veamos un uso interesante de copy() para enviar a un flujo (en este caso
cout) el contenido de una secuencia.
21.3. Programación genérica [579]
swap_ranges()
transform()
C21
Veamos un ejemplo en el que se concatenan las cadenas de dos vectores y se
almacenan en un tercero haciendo uso del operador plus():
replace() / replace_if()
Dado un rango, un valor antiguo y un valor nuevo, substituye todas las ocurrencias
del valor antiguo por el nuevo en el rango. La versión replace_if() substituye
los valores que cumplan el predicado unario especificado por el valor nuevo. Ambos
utilizan un única secuencia, es decir, hacen la substitución in situ.
Existen variantes llamadas replace_copy() y replace_copy_if() res-
pectivamente en la que se copian los elementos del rango de entrada al de salida a la
vez que se hace la substitución. En este caso la secuencia original no cambia.
fill()
generate()
En realidad es una variante de fill() salvo que los valores los obtiene de un
operador que se le da como parámetro, en concreto un «generador», es decir, una
función/functor sin parámetros que devuelve un valor:
rotate()
Rota los elementos del rango especificado por 3 iteradores, que indican el inicio,
el punto medio y el final del rango. Existe una modalidad rotate_copy(), que
como siempre aplica el resultado a un iterador en lugar de modificar el original.
Algoritmos aleatorios
Hay tres algoritmos que tienen que ver con operaciones aleatorias sobre una se-
cuencia:
Los tres aceptan opcionalmente una función que genere números aleatorios.
partition()
sort()
nth_element()
Dada una secuencia y tres iteradores, ordena la secuencia de modo que todos los
elementos en el subrango por debajo del segundo iterador (nth) son menores que
los elementos que quedan por encima. Además, el elemento apuntado por el segundo
iterador es el mismo que si se hubiera realizado una ordenación completa.
Operaciones de búsqueda
C21
lower_bound() devuelve un iterador a la primera posición en la que es posible
insertar el elemento indicado manteniendo el orden en la secuencia.
upper_bound() devuelve un iterador a la última posición en la que es posible
insertar el elemento indicado manteniendo el orden en la secuencia.
equal_range() combina los dos algoritmos anteriores. Devuelve un par con los
iteradores a la primera y última posición en la que es posible insertar el elemento
indicado manteniendo el orden de la secuencia.
merge() combina dos secuencias, dadas por dos pares de iteradores, y crea una
tercera secuencia que incluye los elementos de ambas, manteniendo el orden.
Mínimo y máximo
partial_sum()
3 vector<int> values(size);
4 fill(values.begin(), values.end(), 1);
5
6 partial_sum(values.begin(), values.end(), values.begin());
7
8 int expected[size] = {1, 2, 3, 4, 5};
9 assert(equal(values.begin(), values.end(), expected));
10 }
adjacent_difference()
C21
Listado 21.78: Inventario de armas: Clase Weapon
1 class Weapon {
2 int ammo;
3
4 protected:
5 virtual int power(void) const = 0;
6 virtual int max_ammo(void) const = 0;
7
8 public:
9 Weapon(int ammo=0) : ammo(ammo) { }
10
11 void shoot(void) {
12 if (ammo > 0) ammo--;
13 }
14
15 bool is_empty(void) {
16 return ammo == 0;
17 }
18
19 int get_ammo(void) {
20 return ammo;
[586] CAPÍTULO 21. C++ AVANZADO
21 }
22
23 void add_ammo(int amount) {
24 ammo = min(ammo + amount, max_ammo());
25 }
26
27 void add_ammo(Weapon* other) {
28 add_ammo(other->ammo);
29 }
30
31 int less_powerful_than(Weapon* other) const {
32 return power() < other->power();
33 }
34
35 bool same_weapon_as(Weapon* other) {
36 return typeid(*this) == typeid(*other);
37 }
38 };
C21
recorrer el contenedor.
En la línea 4, la clase WeaponNotFound se utiliza como excepción en las
búsquedas de armas, como veremos a continuación.
El método add() se utiliza para añadir un nuevo arma al inventario, pero con-
templa específicamente el caso —habitual en los shotters— en el que coger un arma
que ya tiene el jugador implica únicamente coger su munición y desechar el arma.
Para ello, utiliza el algoritmo find_if() para recorrer el propio contenedor espe-
cificando como predicado el método Weapon::same_weapon_as(). Nótese el
uso de los adaptadores mem_fun() (por tratarse de un método) y de bind2nd()
para pasar a dicho método la instancia del arma a buscar. Si se encuentra un arma del
mismo tipo (líneas 12–16) se añade su munición al arma existente usando el iterador
devuelto por find_if() y se elimina (línea 14). En otro caso se añade la nueva arma
al inventario (línea 18).
[588] CAPÍTULO 21. C++ AVANZADO
Principio de Pareto
21.4.1. Eficiencia
El principio de Pareto también es
aplicable a la ejecución de un pro-
La eficiencia es sin duda alguna uno de los objetivos principales de la librería STL. grama. Estadísticamente el 80 %
Esto es así hasta el punto de que se obvian muchas comprobaciones que harían su uso del tiempo de ejecución de un
más seguro y productivo. El principio de diseño aplicado aquí es: programa es debido únicamente al
20 % de su código. Eso significa
que mejorando ese 20 % se pue-
Es factible construir decoradores que añadan comprobaciones adiciona- den conseguir importantes mejoras.
Por ese motivo, la optimización del
les a la versión eficiente. Sin embargo no es posible construir una versión programa (si se necesita) debería
eficiente a partir de una segura que realiza dichas comprobaciones. ocurrir únicamente cuando se haya
identificado dicho código por me-
dio de herramientas de perfilado y
Algunas de estas comprobaciones incluyen la dereferencia de iteradores nulos, un adecuado análisis de los flujos
invalidados o fuera de los límites del contenedor, como se muestra en el siguiente de ejecución. Preocuparse por opti-
listado. mizar una función lenta que solo se
invoca en el arranque de un servidor
Para subsanar esta situación el programador puede optar entre utilizar una imple- que se ejecuta durante días es perju-
mentación que incorpore medidas de seguridad —con la consiguiente reducción de dicial. Supone un gasto de recursos
y tiempo que probablemente produ-
eficiencia— o bien especializar los contenedores en clases propias y controlar especí- cirá código menos legible y mante-
ficamente las operaciones susceptibles de ocasionar problemas. nible.
21.4. Aspectos avanzados de la STL [589]
En cualquier caso el programador debería tener muy presente que este tipo de de-
cisiones ad hoc (eliminar comprobaciones) forman parte de la fase de optimización y
sólo deberían considerarse cuando se detecten problemas de rendimiento. En general,
tal como dice Ken Beck, «La optimización prematura es un lastre». Es costosa (en
tiempo y recursos) y produce normalmente código más sucio, difícil de leer y mante-
ner, y por tanto, de inferior calidad.
Sin embargo, existen otro tipo de decisiones que el programador puede tomar
cuando utiliza la STL, que tienen un gran impacto en la eficiencia del resultado y que
no afectan en absoluto a la legibilidad y mantenimiento del código. Estas decisiones
tienen que ver con la elección del contenedor o algoritmo adecuado para cada proble-
ma concreto. Esto requiere conocer con cierto detalle el funcionamiento y diseño de
los mismos.
A continuación se listan los aspectos más relevantes que se deberían tener en cuen-
ta al elegir un contenedor, considerando las operaciones que se realizarán sobre él:
C21
Tamaño medio del contenedor.
En general, la eficiencia –en cuanto a tiempo de acceso– solo es significativa pa-
ra grandes cantidades de elementos. Para menos de 100 elementos (seguramente
muchos más considerando las computadoras o consolas actuales) es muy pro-
bable que la diferencia entre un contenedor con tiempo de acceso lineal y uno
logarítmico sea imperceptible. Si lo previsible es que el número de elementos
sea relativamente pequeño o no se conoce bien a priori, la opción más adecuada
es vector.
Inserción de elementos en los dos extremos de la secuencia.
Si necesita añadir al comienzo con cierta frecuencia (>10 %) debería elegir un
contenedor que implemente esta operación de forma eficiente como deque.
Inserción y borrado en posiciones intermedias.
El contenedor más adecuado en este caso es list. Al estar implementado como
una lista doblemente enlazada, la operación de inserción o borrado implica poco
más que actualizar dos punteros.
[590] CAPÍTULO 21. C++ AVANZADO
Contenedores ordenados.
Algunos contenedores, como set y multiset, aceptan un operador de orde-
nación en el momento de su instanciación. Después de cualquier operación de
inserción o borrado el contenedor quedará ordenado. Esto es órdenes de mag-
nitud más rápido que utilizar un algoritmo de ordenación cuando se necesite
ordenarlo.
Si se obtiene un iterador a un nodo, sigue siendo válido durante toda la vida del
elemento. Sin embargo, en los basados en bloque los iteradores pueden quedar
invalidados si se realoja el contenedor.
Ocupan más memoria por cada elemento almacenado, debido a que se requiere
información adicional para mantener la estructura: árbol o lista enlazada.
Aunque los algoritmos de STL están diseñados para ser eficientes (el estándar in-
cluso determina la complejidad ciclomática máxima permitida) ciertas operaciones
sobre grandes colecciones de elementos pueden implicar tiempos de cómputo muy
considerables. Para reducir el número de operaciones a ejecutar es importante consi-
derar los condicionantes específicos de cada problema.
Uno de los detalles más simples a tener en cuenta es la forma en la que se espe-
cifica la entrada al algoritmo. En la mayoría de ellos la secuencia queda determinada
por el iterador de inicio y el de fin. Lo interesante de esta interfaz es que darle al al-
goritmo parte del contenedor es tan sencillo como dárselo completo. Se pueden dar
innumerables situaciones en las que es perfectamente válido aplicar cualquiera de los
algoritmos genéricos que hemos visto a una pequeña parte del contenedor. Copiar,
buscar, reemplazar o borrar en los n primeros o últimos elementos puede servir para
lograr el objetivo ahorrando muchas operaciones innecesarias.
Otra forma de ahorrar cómputo es utilizar algoritmos que hacen solo parte del
trabajo (pero suficiente en muchos casos), en particular los de ordenación y búsqueda
como partial_sort(), nth_element(), lower_bound(), etc.
Por ejemplo, una mejora bastante evidente que se puede hacer a nuestro inventario
de armas (ver listado 21.80) es cambiar el algoritmo sort() por max_element()
en el método more_powerful_weapon().
C21
del programa.
9 void test_copy_semantics(void) {
10 vector<Counter> counters;
11 Counter c1;
12 counters.push_back(c1);
13 Counter c2 = counters[0];
14
15 counters[0].inc();
16
17 assert(c1.get() == 0);
18 assert(counters[0].get() == 1);
19 assert(c2.get() == 0);
20 }
created: foo
copy of foo
destroyed: foo
21.4. Aspectos avanzados de la STL [593]
created: bar
copy of bar
copy of copy of foo
destroyed: copy of foo
destroyed: bar
created: buzz
copy of buzz
copy of copy of copy of foo
copy of copy of bar
destroyed: copy of copy of foo
destroyed: copy of bar
destroyed: buzz
-- init ready
copy of copy of copy of bar
destroyed: copy of copy of copy of bar
copy of copy of buzz
destroyed: copy of copy of buzz
-- sort complete
copy of copy of copy of copy of bar
-- end
destroyed: copy of copy of copy of copy of bar
destroyed: copy of copy of copy of bar
destroyed: copy of copy of buzz
destroyed: copy of copy of copy of foo
C21
Para profundizar en este asunto vea «Implementing Reference Semantics» [51].
Creando un contenedor
Dependiendo del modo en que se puede utilizar, los contenedores se clasifican por
modelos. A menudo, soportar un modelo implica la existencia de métodos concretos.
Los siguientes son los modelos más importantes:
[594] CAPÍTULO 21. C++ AVANZADO
Forward container
Son aquellos que se organizan con un orden bien definido, que no puede cambiar
en usos sucesivos. La característica más interesante es que se puede crear más
de un iterador válido al mismo tiempo.
Reversible container
Puede ser iterado de principio a fin y viceversa.
Random-access container
Es posible acceder a cualquier elemento del contenedor empleando el mismo
tiempo independientemente de su posición.
Front insertion sequence
Permite añadir elementos al comienzo.
Back insertion sequence
Permite añadir elementos al final.
Associative container
Aquellos que permiten acceder a los elementos en función de valores clave en
lugar de posiciones.
Cada tipo de contenedor determina qué tipo de iteradores pueden aplicarse para
recorrerlo y por tanto qué algoritmos pueden utilizarse con él.
Para ilustrar cuáles son las operaciones que debe soportar un contenedor se incluye
a continuación la implementación de carray. Se trata de una adaptación (wrapper)
para utilizar un array C de tamaño constante, ofreciendo la interfaz habitual de un con-
tenedor. En concreto se trata de una modificación de la clase carray propuesta ini-
cialmente por Bjarne Stroustrup en su libro «The C++ Programming Language» [93]
y que aparece en [51].
31 T* as_array() { return v; }
32 };
El siguiente listado muestra una prueba de su uso. Como los iteradores de carray
son realmente punteros ordinarios16 , este contenedor soporta los modelos forward y
reverse container además de random access ya que también dispone del operador de
indexación.
Functor adaptables
C21
Del mismo modo que los predicados y operadores, STL considera los tipos de
functors adaptables correspondientes. Así pues:
21.4.4. Allocators
Los contenedores ocultan el manejo de la memoria requerida para almacenar los
elementos que contienen. Aunque en la gran mayoría de las situaciones el comporta-
miento por defecto es el más adecuado, pueden darse situaciones en las que el progra-
mador necesita más control sobre el modo en que se pide y libera la memoria. Algunos
de esos motivos pueden ser:
Para lograrlo la STL proporciona una nueva abstracción: el allocator. Todos los
contenedores estándar utilizan por defecto un tipo de allocator concreto y permiten
especificar uno alternativo en el momento de su creación, como un parámetro de la
plantilla.
Como sabemos, todos los contenedores de STL son plantillas que se instancian
con el tipo de dato que van a contener. Sin embargo, tienen un segundo paráme-
tro: el allocator que debe aplicar. Veamos las primeras líneas de al definición
de vector.
Creando un allocator
El allocator es una clase que encapsula las operaciones de petición (a través del
método allocate()) y liberación (a través del método deallocate()) de una
C21
cantidad de elementos de un tipo concreto. La signatura de estos métodos es:
3 public:
4 typedef T value_type;
5 typedef T* pointer;
6 typedef const T* const_pointer;
7 typedef T& reference;
8 typedef const T& const_reference;
9 typedef size_t size_type;
10 typedef ptrdiff_t difference_type;
11
12
13 template <typename U>
14 struct rebind {
15 typedef custom_alloc<U> other;
16 };
17
18 custom_alloc() {}
19
20 custom_alloc(const custom_alloc&) {}
21
22 template <typename U>
23 custom_alloc(const custom_alloc<U>&) {}
24
25 pointer address(reference value) const {
26 return &value;
27 }
28
29 const_pointer address(const_reference value) const {
30 return &value;
31 }
32
33 size_type max_size() const {
34 return numeric_limits<size_t>::max() / sizeof(T);
35 }
36
37 pointer allocate(size_type n, const void* hint=0) {
38 return (pointer) (::operator new(n * sizeof(T)));
39 }
40
41 void deallocate(pointer p, size_type num) {
42 delete p;
43 }
44
45 void construct(pointer p, const T& value) {
46 new (p) T(value);
47 }
48
49 void destroy(pointer p) {
50 p->~T();
51 }
Las líneas 4 a 15 definen una serie de tipos anidados que todo allocator debe tener:
value_type
El tipo del dato del objeto que almacena.
reference y const_reference
El tipo de las referencia a los objetos.
pointer y const_pointer
El tipo de los punteros a los objetos.
size_type
El tipo que representa los tamaños (en bytes) de los objetos.
difference_type
El tipo que representa la diferencia entre dos objetos.
21.4. Aspectos avanzados de la STL [599]
La función bind()
C21
El argumento especial _1 es un placeholder (ver línea 18) que representa al primer
argumento posicional que recibirá el functor generado. Es decir, si asumimos que la
invocación a bind() crea un hipotético bool greater_than_6(int n), la
variable _1 representa al parámetro n.
Con esta nomenclatura tan simple es posible adaptar una llamada a función o mé-
todo a otra aunque los parámetros estén dispuestos en un orden diferente o pudiendo
añadir u omitir algunos de ellos.
También substituye a los adaptadores ptr_fun(), mem_fun() y, finalmente,
mem_fun_ref() dado que acepta cualquier entidad invocable y funciona tanto con
punteros como con referencias. Veamos la versión con bind() del listado 21.60:
5
6 assert(count_if(enemies.begin(), enemies.end(),
7 bind(&Enemy::is_alive, _1)) == 2);
8 }
Pero bind() hace mucho más. Permite construir funciones parcialmente especi- bind()
ficadas. Una función parcialmente especificada es una utilidad típica de los lenguajes
Es el adaptador de funciones defini-
funcionales. Como su nombre indica, es una forma de fijar el valor de uno o más tivo. Substituye a todos los adapta-
parámetros de una función (o algo que se comporta como tal). El resultado es una dores que ofrecía la STL antes del
nueva función (un functor realmente) con menos parámetros. Veamos un ejemplo (lis- estándar C++11.
tado 21.97). Tenemos una función que imprime un texto en un color determinado
indicado por parámetro. Utilizando bind() vamos a crear otra función que, a par-
tiendo de la primera, permite escribir texto en verde para imprimir un mensaje que
informa de un comportamiento correcto (como en un logger).
30 bind(&Car::forward_left, &car));
31 listener.add_hook({OIS::KC_S, OIS::KC_D},
32 bind(&Car::backward_right, &car));
33 listener.add_hook({OIS::KC_S, OIS::KC_A},
34 bind(&Car::backward_left, &car));
35 listener.add_hook({OIS::KC_W},
36 bind(&Car::forward, &car));
37 listener.add_hook({OIS::KC_S},
38 bind(&Car::backward, &car));
39 listener.add_hook({OIS::KC_ESCAPE},
40 bind(&WindowEventListener::shutdown, &listener)
);
41
42 // ...
Fíjese en el atributo triggers_ (línea 9), el cual consiste en un mapa que rela-
ciona un vector de KeyCode (una combinación de teclas) con un manejador (tipo
function<void()>). Esto permite cambiar las asignaciones de teclas según las
preferencias del usuario, leyéndolas por ejemplo de un fichero de configuración, y
sería muy sencillo también cambiarlas con la aplicación en marcha si fuese necesario.
Contenedores
Con el nuevo estándar, todos los tipos se pueden inicializar de forma equivalente
a como se hacía con los arrays, es decir, escribiendo los datos entre llaves:
C21
Puedes ver más detalles y ejemplos sobre inicialización uniforme en la sección 21.5.2.
Se ha añadido el contenedor array. Es de tamaño fijo y tan eficiente como un
array C nativo, y por supuesto, con una interfaz estilo STL similar a vector (ver
listado 21.101).
Iteradores
Algoritmos
all_of() Devuelve cierto sólo si el predicado es cierto para todos los elementos
de la secuencia.
any_of() Devuelve cierto si el predicado es cierto para al menos uno de los ele-
mentos de la secuencia.
none_of() Devuelve cierto sólo si el predicado es falso para todos los elementos
de la secuencia.
iota() Asigna valores crecientes a los elementos de una secuencia.
minmax() Devuelve los valores mínima y máximo de una secuencia.
is_sorted() Devuelve cierto si la secuencia está ordenada.
21.5. C++11: Novedades del nuevo estándar [603]
C21
Expresiones constantes
1 int a = 1 + 2;
2 cout << 3.2 - 4.5 << endl;
3
4 int miArray[4 * 2];
Inicializador de listas
1 struct miStruct {
2 int a;
3 float b;
4 };
se podría inicializar un vector como sigue (también se incluyen ejemplos con en-
teros).
Esto es posible gracias al uso de usa nueva sintaxis y del contenedor std::ini-
tializer_list.
Si se utiliza como parámetro en el constructor de una clase
Hay que tener en cuenta que este tipo de contenedores se utilizan en tiempo de
compilación, que sólo pueden ser construidos estáticamente por el compilador y que
no podrán ser modificados en tiempo de ejecución. Aun así, como son un tipo, pueden
ser utilizados en cualquier tipo de funciones.
También se pueden utilizar las llaves junto con el operador =, para inicializar o
para asignar nuevos valores.
C21
4
5 miVS2 = {{9, 1.2}};
Inicialización uniforme
operador de asignación. Tampoco tendrán miembros privados o protegidos que no sean estáticos, ni una
clase base o funciones virtuales.
[606] CAPÍTULO 21. C++ AVANZADO
1 class Vector3D {
2 public:
3 Vector3D(float x, float y, float z):
4 _x(x), _y(y), _z(z) {}
5
6 private:
7 float _x;
8 float _y;
9 float _z;
10
11 friend Vector3D normalize(const Vector3D& v);
12 };
Esta notación no sustituye a la anterior. Cabe destacar que cuando se utiliza esta
sintaxis para inicializar un objeto, el constructor que acepta una lista de inicialización
como las presentadas anteriormente tendrá prioridad sobre otros. Debido a esto, algu-
nas veces será necesario invocar directamente al constructor adecuado con la notación
antigua.
Esta forma de devolver objetos es compatible con RVO (Return Value Optimi-
zation), que se verá en optimizaciones. Con lo cual una llamada como la siguiente
generará código óptimo y seguramente sin ninguna copia.
1 Vector3D p2 = normalize(p);
Inferencia de tipos
Hasta ahora cada vez que se declaraba una variable en C++ había que especificar
de qué tipo era de manera explícita. En C++11 existe la inferencia de tipos. Usando la
palabra reservada auto en una inicialización en vez del tipo, el compilador deducirá
el mismo de manera automática.
En el ejemplo siguiente se ve cómo funciona esta característica, tanto para tipos
básicos como para la clase definida en el apartado anterior.
Existe otra palabra reservada que se usa de forma similar a sizeof(), pero que
devuelve el tipo de una variable. Esta palabra es decltype y se puede usar para
extraer el tipo de una variable y usarlo para la declaración de otra.
En C++11 se introduce una característica muy útil para recorrer listas de ele-
mentos, ya sean arrays, lista de inicialización o contenedores con las operaciones
begin() y end().
C21
Funciones Lambda
Las funciones lambda son simplemente funciones anónimas. La sintaxis para de-
clarar este tipo de funciones es especial y es posible no declarar el tipo devuelto de
manera explícita sino que está definido de forma implícita mediante la construcción
decltype(<expresión_devuelta>). Las dos formas posible de declarar y
definir estas funciones son las siguientes.
[captura](parámetros)->tipo_de_retorno{cuerpo}
[captura](parámetros){cuerpo}
La primera hace explícito el tipo que se devuelve. De esto modo, las funciones que
se muestran a continuación son equivalentes.
Las variables que se utilizan dentro de estas funciones pueden ser capturadas para
utilizarse en el exterior. Se pueden capturar por valor o por referencia, dependiendo de
la sintaxis dentro de []. Si se utiliza por ejemplo [p1, &p2], p1 será capturada por
valor y p2 por referencia. Si se usa [=,&p1], todas las variables serán capturadas por
valor (al usar =) excepto p1 que será capturada por referencia. Si se utiliza [&,p2],
todas se capturarán por referencia (usando &), excepto p2.
En el siguiente ejemplo, se utiliza una función lambda para sumar la puntacio-
nes de todos los jugadores, que han sido previamente almacenadas en una lista. Se
muestran tres formas de hacerlo.
1 class K {
2 public:
3 int operator*(const K& k) const {return 2;}
4 };
5
6 template <typename T>
7 T pow2Bad(const T& t){return t*t;}
8
9 template <typename T>
10 auto pow2(const T& t)->decltype(t*t){return t*t;}
1 K kObj;
2 cout << pow2Bad(kObj) << endl; // <- no compila
3 cout << pow2(kObj) << endl;
1 class playerInfo {
2 public:
3 playerInfo(const string& name) :
4 _name(name) {}
5
6 playerInfo() : playerInfo("default") {}
7
8 private:
9 string _name;
10 };
C21
13 return _anotherX;
14 }
15 //Fallo al compilar
16 virtual int getX(int a) override {
17 return _anotherX;
18 };
19 bool isValid() final { return false; }
20 private:
21 int _anotherX;
22 };
23
24 class MasDerivada : public Derivada {
25 public:
✄ ✄
En el ejemplo anterior también se muestra (líneas ✂4-6 ✁y ✂22 ✁) el uso de final.
Cuando se utiliza en la declaración de un método, indica que ninguna clase que herede
de ésta podrá sobrescribirlo.
[610] CAPÍTULO 21. C++ AVANZADO
Puntero null
1 int* c = nullptr;
Cabe destacar que es un tipo compatible con los booleanos, pero que no es com-
patible con los enteros.
Alias de plantillas
Ya que typedef no se puede utilizar con plantillas, C++11 incluye una forma de
crear alias para las mismas. Se basa en utilizar using.
También se puede utilizar la nueva sintaxis para realizar las definiciones de tipo
que se hacían con typedef.
21.5. C++11: Novedades del nuevo estándar [611]
1 class Vector3D {
2 public:
3 Vector3D(float x, float y, float z) {}
4 };
5
6 union miUnion {
7 int a;
8 float b;
9 Vector3D v;
10 };
C21
se usa R"(literal)‘". También es posible usar cadenas raw con la modificación
Unicode.
A partir de ahora se pueden crear nuevos literales usando sufijos. Los sufijos po-
drán ir detrás de números (los que puedan ser representados por unsigned long
long o long double) o detrás de literales de cadena. Estos sufijos corresponden a
funciones con el prototipo retval operator”” _sufijo ( unsigned long
long ).
La función anterior define el sufijo _d, que podrá ser usado para crear un double
usando un número natural como literal.
1 auto d = 30_d;
Un ejemplo un poco más complejo del uso de este operador se expone a continua-
ción. Sea la siguiente una clase que podría representar un vector de tres dimensiones,
incluyendo la operación de suma.
1 class Vector3D {
2 public:
3 Vector3D() :
4 x_(0), y_(0), z_(0) {} ;
5
6 Vector3D(float x, float y, float z) :
7 x_(x), y_(y), z_(z) {} ;
8
9 Vector3D operator+(const Vector3D& v) {
10 return Vector3D(x_ + v.x_,
11 y_ + v.y_,
12 z_ + v.z_ );
13 }
14
15 private:
16 float x_;
17 float y_;
18 float z_;
19
20 friend Vector3D operator"" _vx(long double x);
21 friend Vector3D operator"" _vy(long double y);
22 friend Vector3D operator"" _vz(long double z);
23 };
Se podrían definir los siguientes literales de usuario, por ejemplo para construir
vectores ortogonales.
Para utilizar los sufijos con los literales de cadenas, se muestra el siguiente ejem-
plo, que representa un jugador, con un nombre.
1 class Player {
2 public:
3 Player(string name):
4 name_(name) {}
5
6 private:
7 string name_;
8 };
9
10 Player operator"" _player(const char* name, size_t nChars) {
11 return Player(name);
12 };
1 auto p = "bRue"_player;
Aserciones estáticas
Algo muy útil que ya incluía Boost es una aserción estática. Este tipo de aserciones
se comprobarán en tiempo de compilación, y será el mismo compilador el que avise
de la situación no deseada.
En C++11 se puede usar static_assert (cont-expr, error-message)
para utilizar estas aserciones en cualquier punto del código.
C21
9 int main(int argc, char *argv[])
10 {
11 equal(8.0, 8.0000001, 0.00001); // OK (double 8 bytes)
12 equal(8.0f, 8.0000001f, 0.00001f); // Error!!
13
14 return 0;
15 }
1 class NoCopiable {
2 public:
3 NoCopiable(){}
4 NoCopiable(const NoCopiable&) = delete;
5 NoCopiable& operator=(const NoCopiable&) = delete;
6 };
1 class Ex {
2 public:
3 explicit Ex(double a) :
4 a_(a) {}
5
6 Ex() = default;
7
8 void setA(double a) {
9 a_ = a;
10 }
En el mismo ejemplo se usa default con uno de los constructores, lo que pide
de forma explícita al compilador que él cree uno por defecto.
Constructores de movimiento
C21
35 if (this == &m)
36 return *this;
37
38 cout << "Asignacion de movimiento" << endl;
39 size_ = m.size_;
40 buffer_ = m.buffer_;
41 m.buffer_ = nullptr;
42
43 return *this;
44 }
45
46
47 ~Movible()
48 {
49 cout << "Destructor" << endl;
50 if (buffer_ == nullptr)
51 cout << "--> con nullptr (moviendo)" << endl;
52
53 delete [] buffer_;
54 }
55
56
57
[616] CAPÍTULO 21. C++ AVANZADO
58
59 private:
60 char* buffer_;
61 unsigned size_;
62 };
63
64
65 Movible getM()
66 {
67 cout << "getM()" << endl;
68 Movible nuevo_objecto(20000);
69 return nuevo_objecto;
70 }
71
72 int main(int argc, char *argv[])
73 {
74 vector<Movible> v;
75
76 Movible k(234303);
77 k = getM();
78
79 v.push_back(Movible(4000));
80
81 return 0;
82 }
$ ./move
getM()
Asignacion de movimiento
Destructor
--> con nullptr (moviendo)
Constructor de movimiento
Destructor
--> con nullptr (moviendo)
Destructor
Destructor
Cuando se usa la asignación, el objeto temporal que se genera para devolver por
copia un objecto, es capturado por la asignación con movimiento y no se realiza nin-
guna copia extra.
La biblioteca estándar está preparada para el uso de constructores de movimiento,
y como se ve en el ejemplo anterior, lo que en c++03 supondría una copia por cada
push_back() en c++11 supone una llamada transparente al constructor de movi-
miento, evitando así todas las copias que se realizarían de otra forma.
Nótese como en los movimientos se ejecuta el destructor del objeto,
1 #include <iostream>
2 #include <functional>
3 #include <random>
4 #include <sys/time.h>
5
6 using namespace std;
7
8 int main(int argc, char *argv[])
9 {
10 struct timeval now;
11 gettimeofday(&now, 0);
12
13 minstd_rand motor;
14 motor.seed(now.tv_usec);
15
16 uniform_int_distribution<int> dist(1,6);
17 uniform_int_distribution<int> dist_2(1,50);
18
19 int loto = dist(motor); // Uso directo
20
21 auto generador = bind(dist, motor); // Bind
22 int valor_dado = generador(); // Uso "bindeado"
23
24 cout << loto << " : " << valor_dado << endl;
25
26 return 0;
27 }
C21
Tablas Hash
1 #include <iostream>
2 #include <unordered_map>
3
4 int main(int argc, char *argv[])
5 {
6 std::unordered_map<int, float> miHash;
7 miHash[13] = 1.1;
8 std::cout << miHash[13] << std::endl;
9 return 0;
[618] CAPÍTULO 21. C++ AVANZADO
10 }
Expresiones regulares
Una de las características nuevas más interesantes que se han añadido al están-
dar son las expresiones regulares. Para ello se utiliza un objeto std::regex para
construir la expresión.
1
2 std::regex eRE1("\\b((c|C)ur)([^ ]*)");
1 std::smatch match;
Es posible buscar todas las coincidencias dentro de una cadena como se muestra a
continuación.
1 if (regex_match(entrada, eRE2))
2 cout << "[" << entrada << "] cumple la regex" << endl;
Tuplas
C++11 da soporte a la creación de tuplas que contengan diferentes tipos. Para ello
se utiliza la plantilla std::tuple. Como se ve en el ejemplo siguiente, para obtener
los valores se utiliza la función templatizada std::get().
1 #include <iostream>
2 #include <tuple>
3
4 using namespace std;
5
6 typedef tuple <string, int, float> tuplaPuntos;
7
8 int main(int argc, char *argv[])
9 {
10 tuplaPuntos p1("Bilbo", 20, 35.0);
11
12 cout << "El jugador " << get<0>(p1)
13 << " ha conseguido " << get<2>(p1)
21.6. Plugins [619]
Otras características
21.6. Plugins
En términos generales se denomina plug-in (o add-on) a cualquier componente
que añade (o modifica) la funcionalidad de una aplicación principal integrándose con
ella mediante un API proporcionando ex profeso. Los plugins son un mecanismo que
se emplea habitualmente cuando se desea que programadores ajenos al desarrollo del
proyecto matriz puedan integrarse con la aplicación. Ofrece algunas ventajas intere-
santes respecto a una aplicación monolítica:
C21
gins. También ocurre cuando partes distintas tienen licencias diferentes.
Empotrar un interprete para un lenguaje dinámico, tal como Lua, Python o Sche-
me. Esta opción se estudia más adelante en el presente documento. Si la apli-
cación matriz está escrita en un lenguaje dinámico no se requiere normalmente
ningún mecanismo especial más allá de localizar y cargar los plugins desde sus
ficheros.
Proporcionar un protocolo basado en mensajes para que la aplicación principal
se pueda comunicar con los plugins. Este es el caso de OSGi y queda fuera
el ámbito de este curso. Este tipo de arquitectura es muy versátil (los plugins
pueden incluso estar escritos en distintos lenguajes) aunque resulta bastante in-
eficiente.
[620] CAPÍTULO 21. C++ AVANZADO
Para ejecutarlo hay que indicarle al sistema operativo que también tiene que bus-
car bibliotecas en el directorio actual. Para eso basta definir la variable de entorno
LD_LIBRARY_PATH.
$ LD_LIBRARY_PATH=. ./main
mylib_func test 12345
Sin tocar para nada todo lo hecho hasta ahora, vamos a generar otra biblioteca
dinámica con la misma función definida de otra forma.
21.6. Plugins [621]
$ ldd main
linux-vdso.so.1 => (0x00007fff701ff000)
libmylib.so => not found
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f13043dd000)
/lib64/ld-linux-x86-64.so.2 (0x00007f130477c000)
Todos los ejecutables dinámicos están montados con la biblioteca ld.so o ld--
linux.so. Se trata del montador dinámico. Obsérvese cómo se incluye en el eje-
cutable la ruta completa (última línea). Esta biblioteca se encarga de precargar las
bibliotecas especificadas en LD_PRELOAD, buscar el resto en las rutas del sistema o
de LD_LIBRARY_PATH, y de cargarlas. El proceso de carga en el ejecutable incluye
resolver todos los símbolos que no estuvieran ya definidos.
Cuando desde la biblioteca dinámica es preciso invocar funciones (o simplemente
utilizar símbolos) definidas en el ejecutable, éste debe ser compilado con la opción
C21
-rdynamic. Por ejemplo:
$ LD_LIBRARY_PATH=. ./main2
mylib_func 12345 test
main_func 54321
Si todas estas actividades son realizadas por una biblioteca (ld.so) no debería
extrañar que esta funcionalidad esté también disponible mediante una API, para la
carga explícita de bibliotecas desde nuestro programa.
dlopen() abre una biblioteca dinámica (un fichero .so) y devuelve un mane-
jador.
dlsym() carga y devuelve la dirección de símbolo cuyo nombre se especifique
como symbol.
dlclose() le indica al sistema que ya no se va a utilizar la biblioteca y puede
ser descargada de memoria.
dlerror() devuelve una cadena de texto que describe el último error produ-
cido por cualquiera de las otras funciones de la biblioteca.
21.6. Plugins [623]
C21
2
3
4 void b(int i);
5 int b2(int i);
6
7 #endif
2
3 int b2(int i) {
4 return i * i;
5 }
Carga explícita
En primer lugar veamos cómo cargar y ejecutar un símbolo (la función b()) de
forma explícita, es decir, el programador utiliza libdl para buscar la biblioteca y
cargar el símbolo concreto que desea:
Carga implícita
C21
12 p->key = key;
13 p->function = function;
14 p->next = plugins;
15 plugins = p;
16 printf("** Plugin ’ %s’ successfully registered.\n", key);
17 }
18
19 void
20 plugin_unregister(char* key) {
21 plugin_t *prev = NULL, *p = plugins;
22
23 while (p) {
24 if (0 == strcmp(p->key, key))
25 break;
26
27 prev = p;
28 p = p->next;
29 }
30
31 if (!p)
32 return;
33
34 if (prev)
[626] CAPÍTULO 21. C++ AVANZADO
35 prev->next = p->next;
36 else
37 plugins = p->next;
38
39 free(p);
40 }
41
42 static plugin_t*
43 plugin_find(char* key) {
44 plugin_t* p = plugins;
45 while (p) {
46 if (0 == strcmp(p->key, key))
47 break;
48
49 p = p->next;
50 }
51 return p;
52 }
53
54 void
55 call(char* key, int i) {
56 plugin_t* p;
57
58 p = plugin_find(key);
59 if (!p) {
60 char libname[PATH_MAX];
61 sprintf(libname, "./dir %s/lib %s.so", key, key);
62 printf("Trying load ’ %s’.\n", libname);
63 dlopen(libname, RTLD_LAZY);
64 p = plugin_find(key);
65 }
66
67 if (p)
68 p->function(i);
69 else
70 fprintf(stderr, "Error: Plugin ’ %s’ not available.\n", key);
71 }
Los plugins (líneas 1–5) se almacenan en una lista enlazada (línea 7). Las funcio-
nes plugin_register() y plugin_unregister() se utilizan para añadir y
eliminar plugins a la lista. La función call() (líneas 54–71) ejecuta la función espe-
cifica (contenida en el plugin) que se le pasa el parámetro, es decir, invoca una función
a partir de su nombre19 . Esa invocación se puede ver en la línea 10 del siguiente lista-
do:
Para que los plugins (en este caso libb) se registren automáticamente al ser carga-
dos se requiere un pequeño truco: el «atributo» constructor (línea 9) que provoca
que la función que lo tiene se ejecute en el momento de cargar el objeto:
19 Este proceso se denomina enlace tardío (late binding) o name binding.
21.6. Plugins [627]
C21
GModule ofrece un API muy similar a libdl con funciones prácticamente equi-
valentes:
Carga explícita
El siguiente listado muestra cómo hacer la carga y uso de la función b(), equiva-
lente al listado 21.118:
Carga implícita
18 }
19
20 void
21 plugin_unregister(const char* key) {
22 if (plugins != NULL)
23 g_hash_table_remove(plugins, key);
24 }
25
26 void
27 call(const char* key, int i) {
28 void (*p)(int) = NULL;
29
30 if (plugins != NULL)
31 p = g_hash_table_lookup(plugins, key);
32
33 if (!p) {
34 char libname[PATH_MAX];
35
36 sprintf(libname, "./dir %s/lib %s.so", key, key);
37 g_message("Trying load ’ %s’.", libname);
38 if (g_module_open(libname, G_MODULE_BIND_LAZY) == NULL)
39 g_error("Plugin ’ %s’ not available", libname);
40
41 if (plugins != NULL)
42 p = g_hash_table_lookup(plugins, key);
43 }
44
45 if (!p)
46 g_error("Plugin ’ %s’ not available", key);
47
48 p(i);
49 }
C21
Listado 21.125: Carga de símbolos desde Python con ctypes
1 LIBB_PATH = "./dirb/libb.so.1.0.0"
2
3 import ctypes
4
5 plugin = ctypes.cdll.LoadLibrary(LIBB_PATH)
6 plugin.b(3)
Para añadir o eliminar nuevas subclases de weapon tenemos que llamar a reg()
o unreg() respectivamente. Esto es adecuado para la técnica RAII en la que la crea-
ción y destrucción de un objeto se utiliza para el uso y liberación de un recurso:
7 : factory_(factory), key_(key) {
8 factory_.reg(key_, new weapon_type());
9 }
10 ~weapon_reg() {
11 factory_.unreg(key_);
12 }
13 };
Tanto la factoría como los objetos de registro podrían ser también modelados con
el patrón singleton, pero para simplificar el ejemplo nos limitaremos a instanciarlos
sin más:
C21
16 };
C21
-Wl,--enable-runtime-pseudo-reloc \
-o fmethod-bow-win.dll fmethod-bow-win.cc \
fmethod-fac-win.dll
$ wine fmethod-win.exe rifle bow 2>/dev/null
Técnicas específicas
22
Francisco Moya Fernández
Félix J. Villanueva Molina
Sergio Pérez Camacho
David Villa Alises
635
[636] CAPÍTULO 22. TÉCNICAS ESPECÍFICAS
22.1.1. Streams
Un stream es como una tubería por donde fluyen datos. Existen streams de en-
trada o de salida, y de ambas, de modo que un programa puede leer (entrada) de una
abstrayéndose por completo de qué es lo que está llenando la misma. Esto hace que
los streams sean una forma de desacoplar las entradas de la forma de acceder a las
mismas, al igual que las salidas. No importa si quien rellena un stream es la entrada
del teclado o un archivo, la forma de utilizarla es la misma para ambos casos. De este
modo, controlar la entrada supondría conectar un stream a un fichero (o al teclado)
y su salida al programa. Justo al revés (donde el teclado sería ahora la pantalla) se
controlaría la salida.
Normalmente los streams tienen un buffer asociado puesto que escribir o leer en
bloques suele ser mucho más eficiente en los dispositivos de entrada y salida. El stream
se encargará (usando un streambuf1 ) de proporcionar o recoger el número de bytes
que se requiera leer o escribir en el mismo
En la figura 22.1 se muestra la jerarquía de streams en la biblioteca estándar de
C++. La clase ios_base representa la propiedades generales de un stream, como por
ejemplo si este es de entrada o de salida o si es de texto o binaria. La clase ios, que
hereda de la anterior, contiene un streambuf. Las clases ostream y istream,
derivan de ios y proporcionan métodos de salida y de entrada respectivamente.
istream
La clase istream implementa métodos que se utilizan para leer del buffer in-
terno de manera transparente. Existen dos formas de recoger la entrada: formateada
y sin formatear. La primera usa el operador >> y la segunda utiliza los siguientes
miembros de la clase:
1 streambuf es una clase que provee la memoria para dicho buffer incluyendo además funciones para
ostream
ifstream y ofstream
C22
1 #include <iostream>
2 #include <fstream>
3 #include <string>
4
5 using namespace std;
6
7 int main(int argc, char *argv[])
8 {
9 ifstream infile("prueba.txt", ios_base::binary);
10
11 if (!infile.is_open()) {
12 cout << "Error abriendo fichero" << endl;
13 return -1;
14 }
15
16 string linea;
17 getline(infile, linea);
18 cout << linea << endl;
19
20 char buffer[300];
[638] CAPÍTULO 22. TÉCNICAS ESPECÍFICAS
21 infile.getline(buffer, 300);
22 cout << buffer << endl;
23
24 infile.read(buffer,3);
25 buffer[3] = ’\0’;
26 cout << "[" << buffer << "]" << endl;
27
28 streampos p = infile.tellg();
29 infile.seekg(2, ios_base::cur);
30 infile.seekg(-4, ios_base::end);
31 infile.seekg(p);
32
33 int i;
34 while ((i = infile.get()) != -1)
35 cout << "\’" << (char) i << "\’=int(" << i << ")" << endl;
36
37 return 0;
38 }
✄
En la línea ✂9 ✁de crea
✄ el stream del fichero, y se intenta abrir para lectura como
un fichero binario. En ✂11-14 ✄ ✁se comprueba que el archivo se abrió y se termina el
programa si no es así. En ✂16-18 ✁se usa una función global de string para rellenar
una de estas con una línea desde✄ el fichero. Se hace lo mismo con un buffer limitado
a 300 caracteres en la líneas✄ ✂20-22
✁. Después
✄ se leen 3 caracteres sueltos (sin tener en
cuenta el final de linea) (✂24-26 ✁). En ✂28-31 ✁se juega con la posición del puntero de
lectura, y en el resto, se lee carácter a carácter hasta el final del archivo.
Los modos de apertura son los siguientes:
in Permitir sacar datos del stream
out Permitir introducir datos en el stream
ate Al abrir el stream, situar el puntero al final del archivo.
app Poner el puntero al final en cada operación de salida
trunc Trunca el archivo al abrirlo
binary El stream será binario y no de texto
✄ del vector,
El programa anterior imprime el valor original y espera a la entrada
de un vector con el mismo formato. Al pulsar ✂RETURN ✁ el vector original se relle-
nará con los nuevos datos tomados de la entrada estándar. De hecho, el programa
funciona también con una tubería del tipo echo "(1.0, 2.912, 3.123)"|
./ejecutable.
stringstream
C22
En las líneas ✂6-12 ✁se define una función templatizada que se usa en ✂33 ✁para
transformar un número en una cadena, usando streamstream e invocando luego
✄
su método str(), que devuelve la cadena asociada.
En ✂14-23 ✁se define otra que se puede
✄ utilizar para extraer un número de una cade-
na. Se ve un ejemplo de uso en la línea ✂34 ✁.
Sin dependencias
De este modo, todos los objetos que deriven de esta clase tendrán que implementar
la forma de escribir y leer de un stream. Es útil delegar los detalles de serialización al
objeto.
Supóngase ahora una clase muy sencilla y sin dependencias, con un double, un
int y un string para serializar.
22.1. Serialización de objetos [641]
C22
1 class ObjetoSimple : public ISerializable
2 {
3 public:
4 ObjetoSimple(double a, int b, std::string cad);
5
6 ObjetoSimple();
7 virtual ~ObjetoSimple();
8
9 virtual void read (std::istream& in);
10 virtual void write(std::ostream& out);
11
12 private:
13
14 double a_;
15 int b_;
16 std::string cad_;
17 };
En la lectura y escritura se realiza un cast a char* puesto que así lo requieren las
funciones read y write de un stream. Lo que se está pidiendo a dichas funciones es:
“desde/en esta posición de memoria, tratada como un char*, lee/escribe el siguiente
número de caracteres”.
El número de caracteres (bytes/octetos en x86+) viene determinado por el segundo
parámetro, y en este ejemplo se calcula con sizeof, esto es, con el tamaño del tipo
que se está guardando o leyendo.
Un caso especial es la serialización de un string, puesto que como se aprecia,
no se está guardando todo el objeto, sino los caracteres que contiene.✄ Hay que tener
en cuenta que será necesario guardar la longitud de la misma ✄ (línea ✂30 ✁
) para poder
reservar la cantidad de memoria correcta al leerla de nuevo (✂8-9 ✁).
A continuación se muestra un ejemplo de uso de dichos objetos utilizando archivos
para su serialización y carga.
Con dependencias
Almacenar todos los objetos, teniendo en cuenta que lo primero que se almace-
nará será la dirección de memoria que ocupa el objeto actual. Los punteros del
mismo se almacenarán como el resto de datos.
Al leer los objetos, poner en una tabla el puntero antiguo leído, asociado a la
C22
nueva dirección de memoria.
Hacer una pasada corrigiendo el valor de los punteros, buscando la correspon-
dencia en la tabla.
Para ello necesitamos una interfaz nueva, que soporte la nueva función fixPtrs()
y otras dos para leer y recuperar la posición de memoria del propio objeto.
8 protected:
9 virtual void readMemDir (std::istream& in) = 0;
10 virtual void writeMemDir(std::ostream& out) = 0;
11 };
✄
En la línea ✂15 ✁se añade un puntero que almacenará la dirección de memoria de la
propia clase.
La implementación de las funciones de lectura y escritura se muestra a continua-
ción.
11 private:
12 LookUpTable(){}
13
14 std::map<Serializable*, Serializable*> sMap_;
15
16 };
C22
✄ Uno de los constructores ahora acepta un puntero a un objeto del mismo tipo. En
✂23 ✁se declara un puntero a un objeto del mismo tipo, y tendrá que ser serializado,
recuperado y arreglado. Con motivo de probar si la lectura ha sido correcta, se han
añadido un par de funciones, printCad, que imprime la cadena serializada del pro-
pio objeto y printOther, que imprime la cadena del objeto apuntado a través del
primer método.
De esto modo, la implementación de la clase anterior sería la siguiente. Primero
para las funciones de impresión, que son las más sencillas:
6 void ObjetoCompuesto::printOther()
7 {
8 if (obj_) obj_->printCad();
9 }
3 return;
4
5 LookUpTable::itMapS it;
6 it = LookUpTable::getMap().find(obj_);
7 if (it == LookUpTable::getMap().end()) {
8 std::cout << "Puntero no encontrado" << std::endl;
9 throw;
10 }
11 obj_ = (ObjetoCompuesto*) it->second;
12 std::cout << "obj_ FIXED: " << obj_ << std::endl;
13 }
C22
26
27 std::vector<Serializable*> objetosLeidos;
28
29 for (int i = 0; i < 5; ++i) {
30 ObjetoCompuesto* o = new ObjetoCompuesto();
31 o->read(fin);
32 objetosLeidos.push_back(o);
33 }
34
35 cout << "\nFix punteros" << endl;
36 cout << "------------" << endl;
37
38 for_each(objetosLeidos.begin(), objetosLeidos.end(),
39 mem_fun(&Serializable::fixPtrs));
40
41 cout << "\nProbando" << endl;
42 cout << "--------" << endl;
43
44 std::vector<Serializable*>::iterator it;
45 for (it = objetosLeidos.begin();
[648] CAPÍTULO 22. TÉCNICAS ESPECÍFICAS
46 it != objetosLeidos.end();
47 ++it)
48 static_cast<ObjetoCompuesto*>((*it))->printOther();
49
50 return 0;
51 }
✄
En las líneas ✂5-23 ✁se crea el archivo que se usará como un stream y algunos
objetos que se van serializando. Algunos de ellos se crean en el stack y otro en el
heap. El archivo se cerrará puesto que la variable fout sale de contexto al terminar
✄ ✄
el bloque.
En la línea ✂27 ✁se abre el mismo archivo para proceder a su lectura.
✄ En ✂29-35 ✁se
leen los datos del archivo y se van metiendo en un vector. En ✂40-41 ✁se procede a
ejecutar la función fixPtrs de cada uno de los objetos almacenados dentro del vec-
tor. Justo después se ejecutan las funciones que imprimen las cadenas de los objetos
apuntados, para comprobar que se han restaurado correctamente las dependencias.
La salida al ejecutar el programa anterior se muestra a continuación:
Serializando
------------
* obj_: 0
* obj_: 0x7fff3f6dad80
* obj_: 0x7fff3f6dadb0
* obj_: 0x7fff3f6dadb0
* obj_: 0x11b3320
Recuperando
-----------
a_: 3.1371
b_: 1337
cad_: CEDV
obj_: 0
this: 0x11b3260
--------------
a_: 9.235
b_: 31337
cad_: UCLM
obj_: 0x7fff3f6dad80
this: 0x11b3370
--------------
a_: 9.235
b_: 6233
cad_: ESI
obj_: 0x7fff3f6dadb0
this: 0x11b3440
--------------
a_: 300.2
b_: 1000
cad_: BRUE
obj_: 0x7fff3f6dadb0
this: 0x11b3520
--------------
a_: 10.2
b_: 3243
cad_: 2012
obj_: 0x11b3320
this: 0x11b35d0
--------------
22.1. Serialización de objetos [649]
Fix punteros
------------
obj_ FIXED: 0x11b3260
obj_ FIXED: 0x11b3370
obj_ FIXED: 0x11b3370
obj_ FIXED: 0x11b3520
Probando
--------
CEDV
UCLM
UCLM
BRUE
Para serializar la clase simple expuesta en la sección anterior, primero habría del
siguiente modo:
C22
10 virtual ~ObjetoSimple();
11
12 void print();
13
14 template<class Archive>
15 void serialize(Archive & ar, const unsigned int version) {
16 ar & a_;
17 ar & b_;
18 ar & cad_;
19 }
20
21 private:
22 double a_;
23 int b_;
24 std::string cad_;
25 };
[650] CAPÍTULO 22. TÉCNICAS ESPECÍFICAS
✄
En la línea ✂8 ✁se permite el acceso a esta clase✄ desde la función access de Boost,
que se usará para la invocación de serialize ✂18-22 ✁. El símbolo & utilizado dentro
de dicha función templatizada representa a << o >> según sea el tipo de Archive,
que será el envoltorio de fstreams de Boost usado para la serialización. Es preci-
samente en esa función donde se lleva a cabo la serialización, puesto que para cada
variable de la clase, se procede a su lectura o escritura.
A continuación se muestra cómo utilizar esta clase en un programa:
✄ ✄dos objetos.
En el primer bloque se crea un archivo de salida, y se crean y escriben
En el segundo se leen y se imprimen. Como se muestra en la líneas ✂5 ✁y ✂12 ✁, se usan
los operadores de inserción y extracción de las clases de Boost utilizadas.
28 ObjetoSimple simple_;
29 ObjetoCompuesto* obj_;
30 };
De hecho, dos de los pocos casos donde esta forma difiere se muestran en el si-
guiente apartado.
C22
Listado 22.24: Declarando un objeto base serializable con Boost
1 class Base {
2 friend class boost::serialization::access;
3 public:
4 Base(const std::string& bName) :
5 baseName_(bName) {}
6
7 virtual void print() {
8 std::cout << "Base::print(): " << baseName_;
9 };
10
11 virtual ~Base() {}
12
13 template<class Archive>
14 void serialize(Archive & ar, const unsigned int version) {
15 ar & baseName_;
16 }
17
[652] CAPÍTULO 22. TÉCNICAS ESPECÍFICAS
18 protected:
19 std::string baseName_;
20 };
Listado 22.25: Declarando un objeto derivado y con contenedores serializable con Boost
1 class ObjetoDerivadoCont : public Base
2 {
3 friend class boost::serialization::access;
4 public:
5 ObjetoDerivadoCont(std::string s) :
6 Base(s) { }
7
8 ObjetoDerivadoCont() : Base("default") {}
9
10 virtual ~ObjetoDerivadoCont(){}
11
12 virtual void print();
13
14 void push_int(int i) {
15 v_.push_back(i);
16 };
17
18 template<class Archive>
19 void serialize(Archive & ar, const unsigned int version) {
20 ar & boost::serialization::base_object<Base>(*this);
21 ar & v_;
22 }
23
24 private:
25 std::vector<int> v_;
26 };
La única cosa que hay que tener en cuenta a la hora de serializar este tipo de clases
✄ la parte relativa a la clase base. Esto
es que hay que ser explícito a la hora de serializar
se lleva a cabo como se muestra en la línea ✂20 ✁del código anterior.
Para que se puedan serializar contenedores, simplemente habrá que incluir la ca-
becera de Boost correspondiente:
Con todos los ejemplos anteriores se puede afrontar casi cualquier tipo de seriali-
zación. Queda claro que el uso de Boost acelera el proceso, pero aun existen platafor-
mas donde Boost no está portada (aquellas con compiladores que no soportan todas
las características de C++, por ejemplo) y donde la STL aun lucha por parecerse al
estándar. Es en éstas donde habrá que realizar una serialización más artesana y usar
algún tipo de técnica parecida a la vista en las primeras secciones.
C22
esta interacción entre diversos lenguajes de programación. En concreto vamos a coger
C++, como ya hemos comentado, un lenguaje orientado a objetos muy eficiente en
su ejecución y Python, un lenguaje interpretado (como java, php, Lua etc.) muy apro-
piado por su simpleza y portabilidad que nos permite desarrollar prototipos de forma
rápida y sencilla.
En el caso del desarrollo de juegos cuyo lenguaje principal sea de scripting (por
ejemplo, Python), una aproximación genérica sería, desarrollar el juego por completo,
y después, mediante técnicas de profiling se identifican aquellas partes críticas pa-
ra mejorar las prestaciones, que son las que se implementan en C/C++. Obviamente
aquellas partes que, a priori, ya sabemos que sus prestaciones son críticas podemos
anticiparnos y escribirlas directamente en C/C++.
En el caso de que la aplicación se implemente en C/C++, utilizamos un lenguaje Lua vs Python
de scripting para el uso de alguna librería concreta o para poder modificar/adapta-
r/extender/corregir el comportamiento sin tener que recompilar. En general, cuando Mientras que Lua está pensado para
extender aplicaciones y como len-
hablamos de C++ y scripting hablamos de utilizar las características de un lenguaje guaje de configuración, Python es
de prototipado rápido desde C++, lo cual incluye, a grandes rasgos: mas completo y puede ser utilizado
para funciones mas complejas.
Crear y borrar objetos en el lenguaje de scripting e interaccionar con ellos invo-
cando métodos .
pasar datos y obtener resultados en invocaciones a funciones
Gestionar posibles errores que pudieran suceder en el proceso de interacción,
incluyendo excepciones.
Otro ejemplo de las posibilidades de los lenguajes de scripting son utilizar lengua-
jes específicos ampliamente usados en otros entornos como la inteligencia artificial,
para implementar las partes relacionadas del juego. Ejemplos de este tipo de lenguajes
serían LISP y Prolog ampliamente usados en inteligencia artificial, y por lo tanto, muy
apropiados para modelar este tipo de problemas.
En la actualidad, las decisiones de diseño en cuanto a qué lenguaje de scripting
usar viene determinado por las características de dichos lenguajes. Sin tener en cuen-
ta lenguajes muy orientados a problemas concretos como los mencionados LISP y
Prolog, y considerando sólo aquellos lenguajes de scripting de propósito general, las
opciones actuales pasan por Lua y Python principalmente.
Atendiendo a sus características, Python:
Es cierto que tanto Lua como Python pueden ser utilizados para extender apli-
caciones desarrolladas en C/C++, la decisión de qué lenguaje usar depende de qué
características queremos implementar en el lenguaje de scripting. Al ser Python un
lenguaje mas genérico, y por tanto mas versátil, que Lua será el que estudiaremos mas
en profundidad.
22.2. C++ y scripting [655]
Uso de lenguajes compilados En nomenclatura Python, hablamos de extender Python cuando usamos funciones
y objetos escritos en un lenguaje (por ejemplo C++) desde programas en Python. Por
el contrario, se habla de Python embebido cuando es Python el que se invoca desde
Aquellas partes de cálculo intensivo
deben ir implementadas en los len- una aplicación desarrollada en otro lenguaje. Desde la nomenclatura C/C++ se habla
guajes eficientes (compilados) de scripting cuando accedemos a un lenguaje de script desde C++.
El interprete Python ya incluye extensiones para empotrar Python en C/C++. Es
requisito imprescindible tener instalado en la máquina a ejecutar los ejemplos de esta
sección, el intérprete de Python (usaremos la versión 2.7) aunque dichas extensiones
están desde la versión 2.2.
En el primer ejemplo, vamos a ver la versión Python del intérprete y que nos sirve
para ver cómo ejecutar una cadena en dicho intérprete desde un programa en C++.
C22
tipo PyObject (concretamente punteros a este tipo) podemos obtener referencias a
cualquier módulo e invocar funciones en ellas. En la tabla 22.1 podemos ver, de forma
muy resumida, algunas funciones que nos pueden ser muy útiles. Por supuesto no están
todas pero nos pueden dar una referencia para los pasos principales que necesitaríamos
de cara a la interacción C++ y Python.
La gestión de errores (del módulo sys) en la actualidad está delegada en la función
exc_info()() que devuelve una terna que representan el tipo de excepción que se
ha producido, su valor y la traza (lo que hasta la versión 1.5 representaban las variables
sys.exc_type, sys.exc_value y sys.exc_traceback).
Con el ejemplo visto en esta subsección no existe un intercambio entre nuestro
programa C++ y el entorno Python. Por supuesto, el soporte nativo de Python nos
permite realizar cualquier forma de interacción que necesitemos. No obstante, pode-
mos beneficiarnos de librerías que nos hacen esta interacción mas natural y orientada
a objetos. Vamos a estudiar la interacción entre ambos entornos mediante la librería
boost.
[656] CAPÍTULO 22. TÉCNICAS ESPECÍFICAS
Función Cometido
Py_Initialize() Inicializa el intérprete
PyString_FromString(“cadena”) Retorna un puntero a PyObject con una cadena
(E.j. nombre del módulo a cargar).
PyImport_Import(PyObject* name) Carga un módulo, retorna un puntero a PyOb-
ject.
PyModule_GetDict(PyObject* modu- Obtiene el diccionario con atributos y métodos
lo) del módulo. Retorna un puntero a PyObject.
PyDict_GetItemString(PyObject *Dic- Obtiene una referencia a una función. Retorna
cionario, "función") un puntero a PyObject
PyObject_CallObject(PyObject *fun- Llama a la función con los argumentos propor-
ción, argumentos) cionados.
PyCallable_Check(PyObject *funcion) Comprueba que es un objeto invocable.
PyRun_File Interpreta un archivo
PyTuple_New(items) Crea una tupla
PyTuple_SetItem(tupla, posición, item) Almacena un Item en una tupla
PyErr_Print() Imprime error.
PyList_Check(PyObject*) Comprueba si PyObject es una lista
10
11 Py_Initialize();
12 PyRun_SimpleString("import sys; major, minor = sys.version_info
[:2]");
13 object mainobj = import("__main__");
14 object dictionary = mainobj.attr("__dict__");
15 object major = dictionary["major"];
16 int major_version = extract<int>(major);
17 object minor = dictionary["minor"];
18 int minor_version = extract<int>(minor);
19 cout<<major_version<<"."<<minor_version<<endl;
20 Py_Finalize();
21 return 0;
22 }
C22
plejas (por ejemplo, tuplas), esta extracción de elementos se puede realizar mediante
el anidamiento de llamadas a la plantilla extract.
Modificando brevemente el ejemplo anterior podemos mostrar el caso más básico
de una tupla. tal y como podemos ver en este listado:
11 Py_Finalize();
12 return 0;
13 }
C22
tiva a atributos y a funciones definidas en dicho archivo. A continuación ya podemos
obtener un objeto que representa a la función Python que vamos a invocar (línea 8).
Si este objeto es válido (línea 9), obtenemos un puntero compartido al objeto que
vamos a compartir entre el intérprete Python y el espacio C++. En este caso, creamos
un objeto de la clase hero (línea 11).
Ya estamos listo para invocar la función proporcionándole la instancia que acaba-
mos de crear. Para ello, utilizamos la instancia del puntero compartido y obtenemos
con get() la instancia en C++, con el cual podemos llamar a la función (línea 13) y
por supuesto comprobar que, efectivamente, nuestro héroe se ha configurado correc-
tamente (línea 14).
[660] CAPÍTULO 22. TÉCNICAS ESPECÍFICAS
Veamos ahora el caso contrario, es decir, vamos a tener una clase en C++ y vamos
a acceder a ella como si de un módulo en Python se tratara. De hecho el trabajo duro
ya lo hemos realizado, en el ejemplo anterior, ya usábamos un objeto definido en C++
desde el interprete en Python.
Aprovechemos ese trabajo, si tenemos en un archivo el código relativo a la clase
hero (listado 22.31) y la exposición realizada de la misma (listado 22.33) lo que nos
falta es construir un módulo dinámico que el intérprete Python pueda cargar. En este
punto nos puede ayudar el sistema de construcción del propio interprete. Efectivamen-
te podemos realizar un archivo setup.py tal y como aparece en el listado 22.35
Listado 22.35: Configuración para generar el paquete Python a partir de los fuentes C++
1
2 from distutils.core import setup, Extension
3
4 module1 = Extension(’ConfActors’, sources = [’hero.cc’] , libraries
= [’boost_python-py27’])
5
6 setup (name = ’PackageName’,
7 version = ’1.0’,
8 description = ’A C++ Package for python’,
9 ext_modules = [module1])
Esto nos generará la librería específica para la máquina donde estamos y lo alo-
jará en el directorio build que creará en el mismo directorio donde esté el setup.py
(build/lib.linux-i686-2.7/ en nuestro caso) y con el nombre del módulo (ConfAc-
tors.so) que le hemos indicado. A partir de este punto, previa importación del mó-
dulo ConfActors, podemos acceder a todas sus clases y métodos directamente desde
el interprete de python como si fuera un módulo mas escrito de forma nativa en este
lenguaje.
Y su implementación:
C22
Con este archivo de configuración generamos player_wrap.cc y player.py:
22.2.5. Conclusiones
Realizar un tutorial completo y guiado de la interacción entre C++ y los lenguajes
de scripting queda fuera del ámbito de este libro. Hemos proporcionado, no obstante,
algunos ejemplos sencillos que permiten al lector hacerse una idea de los pasos básicos
para una interacción básica entre C++ y un lenguaje de scripting de propósito general
como es Python.
Se inició esta sección proporcionando los motivos por los que la integración de
varios lenguajes de programación en una misma aplicación es una técnica muy útil
y ampliamente utilizada en el mundo de los videojuegos. El objetivo final es utilizar
el lenguaje mas apropiado para la tarea que estamos desarrollando, lo cual da como
resultado una mayor productividad y juegos mas flexibles y extensibles.
A continuación hemos proporcionado algunas directivas básicas de cómo deci-
dir entre lenguajes de scripting y compilados y qué partes son apropiadas para unos
lenguajes u otros.
La mayor parte de esta sección se ha dedicado a mostrar cómo podemos integrar
C++ y Python de tres maneras posibles:
El soporte nativo del intérprete de Python es lo mas básico y de más bajo ni-
vel que hay para integrar Python en C++ o viceversa. La documentación del
intérprete puede ayudar al lector a profundizar en este API.
La librería boost nos aporta una visión orientada a objetos y de mas alto nivel
para la interacción entre estos lenguajes. Esta librería, o mejor dicho conjunto
de librerías, de propósito general nos ayuda en este aspecto particular y nos pro-
porciona otras potentes herramientas de programación en otros ámbitos como
hemos visto a lo largo de este curso.
Por último, hemos introducido la herramienta SWIG que nos puede simplificar
de manera extraordinaria la generación de wrappers para nuestro código C++
de una forma automática y sin tener que introducir código adicional en nuestro
código para interaccionar con Python.
Optimización
23
Francisco Moya Fernández
A
ntes de entrar en materia vamos a matizar algunos conceptos. Optimizar ha-
ce referencia a obtener el mejor resultado posible. Pero la bondad o maldad
del resultado depende fuertemente de los criterios que se pretenden evaluar.
Por ejemplo, si queremos hacer un programa lo más pequeño posible el resultado se-
rá bastante diferente a si lo que queremos es el programa más rápido posible. Por
tanto cuando hablamos de optimización debemos acompañar la frase con el objetivo,
con la magnitud que se pretende mejorar hasta el limite de lo posible. Así se habla
frecuentemente de optimizar en velocidad u optimizar en tamaño.
La optimización normalmente es un proceso iterativo e incremental. Cada etapa
produce un resultado mejor (o por lo menos más fácil de mejorar). A cada una de
estas etapas del proceso se les suele denominar también optimizaciones, aunque sería
más correcto hablar de etapas del proceso de optimización. Pero además el objetivo
de optimización se enmarca en un contexto:
663
[664] CAPÍTULO 23. OPTIMIZACIÓN
C23
mance counters. Estos registros son capaces de contar determinados eventos,
tales como ciclos de la CPU, ciclos de bus, instrucciones, referencias a la cache,
fallos de la memoria caché, saltos o fallos en la predicción de saltos. Estos regis-
tros pueden ser utilizados en profilers tales como perf para realizar mediciones
muy precisas.
Es importante conocer cuándo se emplea cada una de estas técnicas para poder
interpretar con precisión los datos del perfilado. Así, por ejemplo, las técnicas basadas
en muestreo de la traza de llamadas debe entenderse en un contexto estadístico. Valo-
res bajos en los contadores de llamadas no tienen significado absoluto, sino en relación
a otros contadores. Es muy posible que tengamos que ejecutar el mismo fragmento de
código múltiples veces para eliminar cualquier sesgo estadístico.
[666] CAPÍTULO 23. OPTIMIZACIÓN
A continuación conviene configurar el kernel para que permita a los usuarios nor-
males recabar estadísticas de todo tipo. Esto no debe hacerse con carácter general,
sino solo en las computadoras empleadas en el desarrollo, puesto que también facilita
la obtención de información para realizar un ataque.
$ perf list
C23
En la lista de eventos podemos apreciar seis tipos diferentes.
Software event. Son simples contadores del kernel. Entre otros permite contar
cambios de contexto o fallos de página.
Hardware event. Este evento se refiere a los contadores incluidos en las PMU (Per-
formance Monitoring Units) de los procesadores modernos. Permite contar ci-
clos, intrucciones ejecutadas, fallos de caché. Algunos de estos contadores se
ofrecen de forma unificada como contadores de 64 bits, de tal forma que oculta
los detalles de la PMU subyacente. Pero en general su número y tipo dependerá
del modelo de rocesador donde se ejecuta.
[668] CAPÍTULO 23. OPTIMIZACIÓN
Hardware cache event. Dentro del subsistema de memoria las PMU modernas2
permiten extraer estadísticas detalladas de las memorias caché de primer nivel
de último nivel o del TLB (Translation Lookaside Buffer). Nuevamente se trata
de contadores que dependen fuertemente del modelo de procesador sobre el que
se ejecuta.
Hardware breakpoint. Los puntos de ruptura hardware permiten detener la eje-
cución del programa cuando el procesador intenta leer, escribir o ejecutar el
contenido de una determinada posición de memoria. Esto nos permite monito-
rizar detalladamente objetos de interés, o trazar la ejecución de instrucciones
concretas.
Tracepoint event. En este caso se trata de trazas registradas con la infraestructura
ftrace de Linux. Se trata de una infraestructura extremadamente flexible para
trazar todo tipo de eventos en el kernel o en cualquier módulo del kernel. Esto
incluye eventos de la GPU, de los sistemas de archivos o del propio scheduler.
Raw hardware event. En el caso de que perf no incluya todavía un nombre
simbólico para un contador concreto de una PMU actual se puede emplear el
código hexadecimal correspondiente, de acuerdo al manual del fabricante.
2 Los eventos de las PMU se documentan en los manuales de los fabricantes. Por ejemplo, los conta-
Y podemos dividir entre los eventos que ocurren en espacio de usuario y los que
ocurren en espacio del kernel.
Todos los eventos hardware aceptan los modificadores u para filtrar solo los que
ocurren en espacio de usuario, k para filtrar los que ocurren en espacio del kernel y
uk para contabilizar ambos de forma explícita. Hay otros modificadores disponibles,
incluso alguno dependiente del procesador en el que se ejecuta.
C23
efecto con un ejemplo. El computador sobre el que se escriben estas líneas dispone de
un procesador Intel Core i5. Estos procesadores tienen 4 contadores genéricos3 .
Vamos a ver qué pasa cuando se piden 4 eventos idénticos:
Puede verse que la precisión es absoluta, los cuatro contadores han contado prácti-
camente la misma cantidad de ciclos. En cambio, veamos qué pasa cuando se solicitan
5 eventos idénticos:
Los valores son significativamente diferentes, pero los porcentajes entre corchetes
nos previenen de que se ha realizado un escalado. Por ejemplo, el primer contador ha
estado contabilizando ciclos durante el 79,06 % del tiempo. El valor obtenido en el
contador se ha escalado dividiendo por 0,7906 para obtener el valor mostrado.
En este caso los contadores nos dan una aproximación, no un valor completamente
fiable. Nos vale para evaluar mejoras en porcentajes significativos, pero no mejoras
de un 1 %, porque como vemos el escalado ya introduce un error de esa magnitud.
Además en algunas mediciones el resultado dependerá del momento concreto en que
se evalúen o de la carga del sistema en el momento de la medida. Para suavizar todos
estos efectos estadísticos se puede ejecutar varias veces empleando la opción -r.
233 faults
Nótese que utilizamos la orden sleep para no consumir ciclos y de esta forma
no influir en la medida.
C23
$ perf record -a sleep 10
[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.426 MB perf.data (~18612 samples)
]
$ perf report
El muestreo permite analizar qué funciones se llevan la mayor parte del tiempo
de ejecución y, en caso de muestrear también la traza de llamada permite identificar
de forma rápida los cuellos de botella. No se trata de encontrar la función que más
tiempo se lleva, sino de identificar funciones que merezca la pena optimizar. No tiene
sentido optimizar una función que se lleva el 0,01 % del tiempo de ejecución, porque
simplemente no se notaría.
[672] CAPÍTULO 23. OPTIMIZACIÓN
Para poder utilizar las características de anotación del código de los perfila-
dores es necesario compilar el programa con información de depuración.
Para ilustrar la mecánica veremos un caso real. Ingo Molnar, uno de los principales
desarrolladores de la infraestructura de perfilado de Linux, tiene multitud de mensajes
en diversos foros sobre optimizaciones concretas que fueron primero identificadas
mediante el uso de perf. Uno de ellos4 describe una optimización significativa de
git, el sistema de control de versiones.
En primer lugar realiza una fase de análisis de una operación concreta que revela
un dato intranquilizador. Al utilizar la operación de compactación git gc descubre
un número elevado de ciclos de estancamiento (stalled cycles5 ):
stalled-cycles pero mantenemos el texto del caso de uso original para no confundir al lector.
23.1. Perfilado de programas [673]
C23
8,821,931,740 instructions # 1.00 insns per cycle
# 0.24 stalled cycles per insn ( +- 0.04 % )
1,750,408,175 branches # 631.661 M/sec ( +- 0.04 % )
74,612,120 branch-misses # 4.26 % of all branches ( +- 0.07 % )
La opción -sync hace que se ejecute una llamada sync() (vuelca los buffers
pendientes de escritura de los sistemas de archivos) antes de cada ejecución para re-
ducir el ruido en el tiempo transcurrido.
Después de la optimización el resultado es:
Por alguna razón el bucle abierto es más sencillo de predecir por parte de la CPU
por lo que produce menos errores de predicción.
No obstante es importante entender que estas optimizaciones corresponden a pro-
blemas en otros puntos (compilador que genera código subóptimo, y arquitectura que
privilegia un estilo frente a otro). Por tanto se trata de optimizaciones con fecha de
caducidad. Cuando se utilice una versión más reciente de GCC u otro compilador más
agresivo en las optimizaciones esta optimización no tendrá sentido.
Un caso célebre similar fue la optimización del recorrido de listas en el kernel
Linux6 . Las listas son estructuras muy poco adecuadas para la memoria caché. Al
no tener los elementos contiguos generan innumerables fallos de caché. Mientras se
produce un fallo de caché el procesador está parcialmente parado puesto que necesita
el dato de la memoria para operar. Por esta razón en Linux se empleó una optimización
denominada prefetching. Antes de operar con un elemento se accede al siguiente. De
esta forma mientras está operando con el elemento es posible ir transfiriendo los datos
de la memoria a la caché.
Desgraciadamente los procesadores modernos incorporan sus propias unidades de
prefetch que realizan un trabajo mucho mejor que el manual, puesto que no interfiere
con el TLB. El propio Ingo Molnar reporta que esta optimización estaba realmente
causando un impacto de 0,5 %.
La lección que debemos aprender es que nunca se debe optimizar sin medir, que
las optimizaciones dependen del entorno de ejecución, y que si el entorno de ejecución
varía las optimizaciones deben re-evaluarse.
6 https://lwn.net/Articles/444336/
23.1. Perfilado de programas [675]
C23
los programas con la opción -pg el programa quedará instrumentado para perfilado.
Todas las ejecuciones del programa generan un archivo gmon.out con la infor-
mación recolectada, que puede examinarse con gprof. GNU Profiler utiliza muestreo
estadístico sin ayuda de PMU. Esto lo hace muy portable pero notablemente impreci-
so.
Google Performance Tools aporta un conjunto de bibliotecas para perfilado de
memoria dinámica o del procesador con apoyo de PMU. Por ejemplo, el perfilado
de programas puede realizarse con la biblioteca libprofiler.so. Esta biblioteca
puede ser cargada utilizando la variable de entorno LD_PRELOAD y activada mediante
la definición de la variable de entorno CPUPROFILE. Por ejemplo:
$ LD_PRELOAD=/usr/lib/libprofiler.so.0 CPUPROFILE=prof.data \
./light-model-test
[676] CAPÍTULO 23. OPTIMIZACIÓN
Esto genera el archivo prof.data con los datos de perfilado, que luego pueden GPU Profilers
examinarse con google-pprof. Entre otras capacidades permite representación
gráfica del grafo de llamadas o compatibilidad con el formato de kcachegrind. De momento solo nVidia pro-
porciona un profiler con capaci-
Una característica interesante de Google Performance Tools es la capacidad de dades gráficas sobre GNU/Linux.
realizar el perfilado solo para una sección concreta del código. Para ello, en lugar de AMD APP Profiler funciona en
GNU/Linux pero no con interfaz
definir la variable CPUPROFILE basta incluir en el código llamadas a las funciones gráfica.
ProfilerStart() y ProfilerStop().
Para un desarrollador de videojuegos es destacable la aparición de perfiladores
específicos para GPUs. Las propias GPUs tienen una PMU (Performance Monitoring
Unit) que permite recabar información de contadores específicos. De momento en el
mundo del software libre han emergido nVidia Visual Profiler, AMD APP Profiler
y extensiones de Intel a perf para utilizar los contadores de la GPU (perf gpu).
Probablemente en un futuro cercano veremos estas extensiones incorporadas en la
distribución oficial de linux-tools.
23.2. Optimizaciones del compilador [677]
C fue diseñado con el objetivo inicial de programar un sistema operativo. Por este
motivo, desde las primeras versiones incorpora características de muy bajo nivel que
permite dirigir al compilador para generar código más eficiente. Variables registro,
funciones en línea, paso por referencia, o plantillas son algunas de las características
que nos permiten indicar al compilador cuándo debe esforzarse en buscar la opción
más rápida. Sin embargo, la mayoría de las construcciones son simplemente indica-
C23
ciones o sugerencias, que el compilador puede ignorar libremente si encuentra una
solución mejor. En la actualidad tenemos compiladores libres maduros con capacida-
des comparables a los mejores compiladores comerciales, por lo que frecuentemente
las indicaciones del programador son ignoradas.
Listado 23.1: Utilización arcaica de register para sumar los 1000 primeros números natu-
rales.
1 register unsigned i, sum = 0;
2 for (i=1; i<1000; ++i)
3 sum += i;
Esta palabra clave está en desuso porque los algoritmos de asignación de registros
actuales son mucho mejores que la intuición humana. Pero además, aunque se utiliza-
ra, sería totalmente ignorada por el compilador. La mera aparición de register en
un programa debe ser considerada como un bug, porque engaña al lector del programa
haciéndole creer que dicha variable será asignada a un registro, cuando ese aspecto
está fuera del control del programador.
El resultado es el siguiente:
14 je .L2
15 movq %rdi, %r8
16 movq %rdi, %rcx
17 andl $15, %r8d
18 shrq $2, %r8
19 negq %r8
20 andl $3, %r8d
21 cmpl %esi, %r8d
22 cmova %esi, %r8d
23 xorl %edx, %edx
24 testl %r8d, %r8d
25 movl %r8d, %ebx
26 je .L11
27 .p2align 4,,10
28 .p2align 3
29 .L4:
30 addl $1, %edx
31 addl ( %rcx), %eax
32 addq $4, %rcx
33 cmpl %r8d, %edx
34 jb .L4
35 cmpl %r8d, %esi
36 je .L2
37 .L3:
38 movl %esi, %r11d
39 subl %r8d, %r11d
40 movl %r11d, %r9d
41 shrl $2, %r9d
42 leal 0(, %r9,4), %r10d
43 testl %r10d, %r10d
44 je .L6
45 pxor %xmm0, %xmm0
46 leaq ( %rdi, %rbx,4), %r8
47 xorl %ecx, %ecx
48 .p2align 4,,10
49 .p2align 3
50 .L7:
51 addl $1, %ecx
52 paddd ( %r8), %xmm0
53 addq $16, %r8
54 cmpl %r9d, %ecx
55 jb .L7
56 movdqa %xmm0, %xmm1
57 addl %r10d, %edx
58 psrldq $8, %xmm1
59 paddd %xmm1, %xmm0
60 movdqa %xmm0, %xmm1
61 psrldq $4, %xmm1
62 paddd %xmm1, %xmm0
63 movd %xmm0, -4( %rsp)
64 addl -4( %rsp), %eax
65 cmpl %r10d, %r11d
66 je .L2
C23
67 .L6:
68 movslq %edx, %rcx
69 leaq ( %rdi, %rcx,4), %rcx
70 .p2align 4,,10
71 .p2align 3
72 .L9:
73 addl $1, %edx
74 addl ( %rcx), %eax
75 addq $4, %rcx
76 cmpl %edx, %esi
77 ja .L9
78 .L2:
79 popq %rbx
80 .cfi_remember_state
81 .cfi_def_cfa_offset 8
82 ret
83 .L11:
84 .cfi_restore_state
[680] CAPÍTULO 23. OPTIMIZACIÓN
C23
Listado 23.6: Esta biblioteca solo exporta la función sum10().
1 static int sum(int* a, unsigned size)
2 {
3 int ret = 0;
4 for (int i=0; i<size; ++i) ret += a[i];
5 return ret;
6 }
7
8 int sum10(int* a)
9 {
10 return sum(a,10);
11 }
[682] CAPÍTULO 23. OPTIMIZACIÓN
Estas funciones, que eran expandidas en línea por el compilador, exhibían un com-
portamiento anómalo con respecto a los ciclos de estancamiento y a la predicción de
saltos. Por lo que Ingo propone la siguiente optimización:
Cuadro 23.3: Resultados de la optimización de Ingo Molnar con compiladores actuales (100 repeticiones).
C23
elisión de copia, se permite en las siguientes circunstancias (que pueden
ser combinadas para eliminar copias múltiples):
En una sentencia return de una función cuyo tipo de retorno sea
una clase, cuando la expresión es el nombre de un objeto automáti-
co no volátil (que no sea un parámetro de función o un parámetro de
una cláusula catch) con el mismo tipo de retorno de la función (que
no puede ser const ni volatile), la operación de copia o movimiento
puede ser omitida mediante la construcción directa del objeto auto-
mático en el propio valor de retorno de la función.
En una expresión throw, cuando el operando es el nombre de un ob-
jeto automático no volátil (que no sea un parámetro de función o un
parámetro de una cláusula catch) cuyo ámbito de declaración no se
extienda más allá del final del bloque try más interior que contenga a
[684] CAPÍTULO 23. OPTIMIZACIÓN
1 class Thing {
2 public:
3 Thing();
4 ~Thing();
5 Thing(const Thing&);
6 };
7
8 Thing f() {
9 Thing t;
10 return t;
11 }
12
13 Thing t2 = f();
Aquí los criterios de elisión pueden combinarse para eliminar dos lla-
madas al constructor de copia de Thing: la copia del objeto automático
local t en el objeto temporal para el valor de retorno de la función f() y
la copia de ese objeto temporal al objeto t2. Por tanto la construcción del
objeto local t puede verse como la inicialización directa del objeto t2, y
la destrucción de dicho objeto tendrá lugar al terminar el programa. Aña-
dir un constructor de movimiento a Thing tiene el mismo efecto, en cuyo
caso es el constructor de movimiento del objeto temporal a t2 el que se
elide.
23.2.4. Volatile
Las optimizaciones del compilador pueden interferir con el funcionamiento del
programa, especialmente cuando necesitamos comunicarnos con periféricos. Así por
ejemplo, el compilador es libre de reordenar y optimizar las operaciones mientras
mantenga una equivalencia funcional. Así, por ejemplo este caso se encuentra no po-
cas veces en código de videojuegos caseros para consolas.
1 _Z5resetRj:
2 movl $0, ( %rdi)
3 ret
23.3. Conclusiones
La optimización de programas es una tarea sistemática, pero a la vez creativa.
Toda optimización parte de un análisis cuantitativo previo, normalmente mediante
el uso de perfiladores. Existe un buen repertorio de herramientas que nos permite
caracterizar las mejores oportunidades, pero no todo lo que consume tiempo está en
nuestra mano cambiarlo. Las mejores oportunidades provienen de la re-estructuración
de los algoritmos o de las estructuras de datos.
Por otro lado el programador de videojuegos deberá optimizar para una platafor-
ma o conjunto de plataformas que se identifican como objetivo. Algunas de las opti-
mizaciones serán específicas para estas plataformas y deberán re-evaluarse cuando el
entorno cambie.
C23
Capítulo
Validación y Pruebas
24
David Villa Alises
L
a programación, igual que cualquier otra disciplina técnica, debería ofrecer ga-
rantías sobre los resultados. La formalización de algoritmos ofrece garantías
indiscutibles y se utiliza con éxito en el ámbito de los algoritmos numéricos y
lógicos. Sin embargo, el desarrollo de software en muchos ámbitos está fuertemente
ligado a requisitos que provienen directamente de necesidades de un cliente. En la
mayoría de los casos, esas necesidades no se pueden formalizar dado que el cliente
expresa habitualmente requisitos ambiguos o incluso contradictorios. El desarrollo de
software no puede ser ajeno a esa realidad y debe integrar al cliente de forma que
pueda ayudar a validar, refinar o rectificar la funcionalidad del sistema durante todo el
proceso.
Un programador responsable comprueba que su software satisface los requisitos
del cliente, comprueba los casos típicos y se asegura que los errores detectados (ya
sea durante el desarrollo o en producción) se resuelven y no vuelven a aparecer. Es
imposible escribir software perfecto (a día de hoy) pero un programador realmente
profesional escribe código limpio, legible, fácil de modificar y adaptar a nuevas ne-
cesidades. En este capítulo veremos algunas técnicas que pueden ayudar a escribir
código más limpio y robusto.
687
[688] CAPÍTULO 24. VALIDACIÓN Y PRUEBAS
Listado 24.1: Una función que define una invariante sobre su parámetro
1 double sqrt(double x) {
2 assert(x >= 0);
3 [...]
4 }
$ ./assert-argc hello
$ ./assert-argc
assert-argc: assert-argc.cc:4: int main(int, char**): Assertion ‘argc
== 2’ failed.
Abortado
24.1.1. Sobrecarga
Las aserciones facilitan la depuración del programa porque ayudan a localizar el
punto exacto donde se desencadena la inconsistencia. Por eso deberían incluirse desde
el comienzo de la implementación. Sin embargo, cuando el programa es razonable-
mente estable, las aserciones siempre se cumplen (o así debería ser). En una versión
de producción las aserciones ya no son útiles2 y suponen una sobrecarga que puede
afectar a la eficiencia del programa.
Obviamente, eliminar «a mano» todas las aserciones no parece muy cómodo. La
mayoría de los lenguajes incorporan algún mecanismo para desactivarlas durante la
compilación. En C/C++ se utiliza el preprocesador. Si la constante simbólica NDEBUG
está definida la implementación de assert() (que en realidad es una macro de
preprocesador) se substituye por una sentencia vacía de modo que el programa que se
compila realmente no tiene absolutamente nada referente a las aserciones.
C24
En los casos en los que necesitamos hacer aserciones más complejas, que requieran
variables auxiliares, podemos aprovechar la constante NDEBUG para eliminar también
ese código adicional cuando no se necesite:
1 [...]
2 #ifndef NDEBUG
3 vector<int> values = get_values();
4 assert(values.size());
5 #endif
2 Por contra, algunos autores como Tony Hoare, defienden que en la versión de producción es dónde más
Esto es, aunque valoramos los elementos de la derecha, valoramos más Figura 24.1: Kent Beck, uno de
los de la izquierda. los principales creadores de eXtre-
me programing, TDD y los métodos
ágiles.
Las técnicas de desarrollo ágil pretenden entregar valor al cliente pronto y a me-
nudo, es decir, priorizar e implementar las necesidades expresadas por el cliente para
ofrecerle un producto que le pueda resultar útil desde el comienzo. También favorecen
la adopción de cambios importantes en los requisitos, incluso en las últimas fases del
desarrollo.
24.3. TDD
Una de las técnicas de desarrollo ágil más efectiva es el Desarrollo Dirigido por
Pruebas o TDD (Test Driven Development). La idea básica consiste en empezar el pro-
ceso escribiendo pruebas que representen directamente requisitos del cliente. Algunos
autores creen que el término «ejemplo» describe mejor el concepto que «prueba». Una
prueba es un pequeño bloque de código que se ejecuta sin ningún tipo de interacción
con el usuario (ni entrada ni salida) y que determina de forma inequívoca (la prueba
pasa o falla) si el requisito correspondiente se está cumpliendo.
En el desarrollo de software tradicional las pruebas se realizan una vez terminado
el desarrollo asumiendo que desarrollo y pruebas son fases estancas. Incluso en otros
modelos como el iterativo, en espiral o el prototipado evolutivo las pruebas se realizan
después de la etapa de diseño y desarrollo, y en muchas ocasiones por un equipo de
programadores distinto al que ha escrito el código.
24.3. TDD [691]
Escribe la prueba haciendo uso de las interfaces del sistema (¡es probable que
aún no existan!) y ejecútala. La prueba debería fallar y debes comprobar que es
así (rojo).
A continuación escribe el código de producción mínimo necesario para que la
prueba pase (verde). Ese código «mínimo» debe ser solo el imprescindible, lo
más simple posible, hasta el extremo de escribir métodos que simplemente re-
tornan el valor que la prueba espera3 . Eso ayuda a validar la interfaz y confirma
que la prueba está bien especificada. Pruebas posteriores probablemente obli-
garán a modificar el código de producción para que pueda considerar todas las
posibles situaciones. A esto se le llama «triangulación» y es la base de TDD:
Las pruebas dirigen el diseño.
Por último refactoriza si es necesario. Es decir, revisa el código de producción
y elimina cualquier duplicidad. También es el momento adecuado para renom-
brar tipos, métodos o variables si ahora se tiene más claro cuál es su objetivo
real. Por encima de cualquier otra consideración el código debe expresar clara-
mente la intención del programador. Es importante refactorizar tanto el código
de producción como las propias pruebas.
C24
Este sencillo método de trabajo (el algoritmo TDD) favorece que los programa-
dores se concentren en lo que realmente importa: satisfacer los requisitos del usuario.
También ayuda al personal con poca experiencia en el proyecto a decidir cuál es el
próximo paso en lugar de divagar o tratando de «mejorar» el programa añadiendo
funcionalidades no solicitadas.
3 Kent Beck se refiere a esto con la expresión “Fake until make it”.
[692] CAPÍTULO 24. VALIDACIÓN Y PRUEBAS
Desde el punto de vista ágil hay una pauta clara: las pruebas se escriben para eje-
cutarse, y debería ocurrir tan a menudo como sea posible. Lo ideal sería ejecutar todas
las pruebas después de cada cambio en cualquier parte de la aplicación. Obviamente
eso resulta prohibitivo incluso para aplicaciones pequeñas. Hay que llegar a una solu-
ción de compromiso. Por este motivo, las pruebas unitarias son las más importantes.
Si están bien escritas, las pruebas unitarias se deberían poder ejecutar en muy pocos
segundos. Eso permite que, con los frameworks y herramientas adecuadas se pueda
lanzar la batería de pruebas unitarias completa mientras se está editando el código
(sea la prueba o el código de producción).
Para que una prueba se considere unitaria no basta con que esté escrita en un
framework xUnit, debe cumplir los principios FIRST [60], que es un acrónimo para:
24.5. Pruebas unitarias con google-tests [693]
Fast Las pruebas unitarias deberían ser muy rápidas. Como se ha dicho, todas las
pruebas unitarias de la aplicación (o al menos del módulo) deberían ejecutarse
en menos de 2–3 segundos.
Independent Cada prueba debe poder ejecutarse por separado o en conjunto, y en
cualquier orden, sin que eso afecte al resultado.
Repeatable La prueba debería poder ejecutarse múltiples veces dando siempre el
mismo resultado. Por este motivo no es buena idea incorporar aleatoriedad a
los tests. También implica que la prueba debe poder funcionar del mismo modo
en entornos distintos.
Self-validating La prueba debe ofrecer un resultado concreto: pasa o falla, sin que el
programador tenga que leer o interpretar un valor en pantalla o en un fichero.
Timely El test unitario debería escribirse justo cuando se necesite, es decir, justo antes
de escribir el código de producción relacionado, ni antes ni después.
Se incluye el archivo de cabecera de gtest (línea 1) donde están definidas las ma-
cros que vamos a utilizar para definir las pruebas. La línea 4 define una prueba llamada
C24
Zero mediante la macro TEST para el casa de prueba (TestCase) FactorialTest.
La línea 5 especifica una expectativa: el resultado de invocar factorial(0) debe
ser igual a 1.
Además del fichero con la prueba debemos escribir el código de producción, su
fichero de cabecera y un Makefile. Al escribir la expectativa ya hemos decidido el
nombre de la función y la cantidad de parámetros (aunque no es tipo). Veamos estos
ficheros:
4 Inspirado en el primer ejemplo del tutorial de GTests.
[694] CAPÍTULO 24. VALIDACIÓN Y PRUEBAS
$ make
g++ -c -o factorial.o factorial.cc
g++ factorial-test.o factorial.o -lpthread -lgtest -lgtest_main -o
factorial-test
$ ./factorial-test
Running main() from gtest_main.cc
[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from FactorialTest
[ RUN ] FactorialTest.Zero
[ OK ] FactorialTest.Zero (0 ms)
[----------] 1 test from FactorialTest (0 ms total)
Esta primera prueba no la hemos visto fallar porque sin el fichero de producción
ni siquiera podríamos haberla compilado.
Añadamos ahora un segundo caso de prueba al fichero:
24.6. Dobles de prueba [695]
1 $ ./factorial-test
2 Running main() from gtest_main.cc
3 [==========] Running 2 tests from 1 test case.
4 [----------] Global test environment set-up.
5 [----------] 2 tests from FactorialTest
6 [ RUN ] FactorialTest.Zero
7 [ OK ] FactorialTest.Zero (0 ms)
8 [ RUN ] FactorialTest.Positive
9 factorial-test.cc:10: Failure
10 Value of: factorial(2)
11 Actual: 1
12 Expected: 2
13 [ FAILED ] FactorialTest.Positive (0 ms)
14 [----------] 2 tests from FactorialTest (0 ms total)
15
16 [----------] Global test environment tear-down
17 [==========] 2 tests from 1 test case ran. (0 ms total)
18 [ PASSED ] 1 test.
19 [ FAILED ] 1 test, listed below:
20 [ FAILED ] FactorialTest.Positive
21
22 1 FAILED TEST
El resultado nos indica la prueba que ha fallado (línea 8), el valor obtenido por
la llamada (línea 11) y el esperado (línea 12).
A continuación se debe modificar la función factorial() para que cumpla la
nueva expectativa. Después escribir nuevas pruebas que nos permitan comprobar que
la función cumple con su cometido para unos cuantos casos representativos.
C24
orientado a objetos porque proporciona «sustituibilidad» (LSP (Liskov Substitution
Principle)). Pero la ocultación dificulta la definición de pruebas en base a aserciones
sobre de estado porque el estado del objeto está definido por el valor de sus atributos.
Si tuviéramos acceso a todos los atributos del objeto (sea con getters o no) sería una
pista de un mal diseño.
[696] CAPÍTULO 24. VALIDACIÓN Y PRUEBAS
Debido a ello, suele ser más factible definir la prueba haciendo aserciones sobre la
interacción que el objeto que se está probando (el SUT (Subject Under Test)5 ) realiza
con sus colaboradores. El problema de usar un colaborador real es que éste tendrá a
su vez otros colaboradores de modo que es probable que para probar un único método
necesitemos montar gran parte de la aplicación. Como lo que queremos es instanciar
lo mínimo posible del sistema real podemos recurrir a los dobles6 de prueba.
Un doble de prueba es un objeto capaz de simular la interfaz que un determinado SOLID
colaborador ofrece al SUT, pero que realmente no implementa nada de lógica. El doble
SOLID (SRP, OCP, LSP, ISP, DIP)7
(dependiendo de su tipo) tiene utilidades para comprobar qué métodos y parámetros es una serie de 5 principios esencia-
usó el SUT cuando invocó al doble. les para conseguir diseños orienta-
dos a objetos de calidad. Estos son:
SRP — Single Responsibility
OCP — Open Closed
Una regla básica: Nunca se deben crear dobles para clases implementadas por LSP — Liskov Substitution
DIP — Dependency Inversion
terceros, sólo para clases de la aplicación.
ISP — Interface Segregation
Un requisito importante para poder realizar pruebas con dobles es que las clases
de nuestra aplicación permitan «inyección de dependencias». Consiste en pasar (in-
yectar) las instancias de los colaboradores (dependencias) que el objeto necesitará en
el momento de su creación. Pero no estamos hablando de un requisito impuesto por
las pruebas, se trata de otro de los principios SOLID, en concreto DIP (Dependency
Inversion Principle).
Aunque hay cierta confusión con la terminología, hay bastante consenso en distin-
guir al menos entre los siguientes tipos de dobles:
Para escribir un test que pruebe el método notify() necesitamos un mock para
su colaborador (el observador). El siguiente listado muestra la interfaz que deben
implementar los observadores:
C24
Listado 24.10: Patrón observador con TDD: observer.h
1 #ifndef _OBSERVER_H_
2 #define _OBSERVER_H_
3
4 class Observer {
5 public:
6 virtual void update(void) = 0;
7 virtual ~Observer() {}
8 };
9
10 #endif
14 gmock: gmock-1.7.0.zip
15 unp $<
16 ln -s gmock-1.7.0 gmock
17
18 gmock-1.7.0.zip:
19 wget https://googlemock.googlecode.com/files/gmock-1.7.0.zip
20
21 libgmock.a: gtest-all.o gmock-all.o gmock_main.o
22 ar rv $@ $^
23
24 gtest-all.o: $(GTEST_DIR)/src/gtest-all.cc
25 g++ $(CXXFLAGS) -c $<
26
27 gmock %.o: $(GMOCK_DIR)/src/gmock %.cc
28 g++ $(CXXFLAGS) -c $<
29
30 test: $(TARGET)
31 ./$<
32
33 clean:
34 $(RM) $(TARGET) *.o *~ *.a
Ejecutemos el Makefile:
1 $ make test
2 g++ -I /usr/src/gmock -c -o observable-tests.o observable-tests.cc
3 g++ -I /usr/src/gmock -c -o observable.o observable.cc
4 g++ -I /usr/src/gmock -c -o gmock_main.o /usr/src/gmock/src/
gmock_main.cc
5 g++ -I /usr/src/gmock -c -o gmock-all.o /usr/src/gmock/src/gmock-all
.cc
6 g++ observable-tests.o observable.o gmock_main.o gmock-all.o -
lpthread -lgtest -o observable-tests
7 $ ./observable-tests
8 Running main() from gmock_main.cc
9 [==========] Running 1 test from 1 test case.
10 [----------] Global test environment set-up.
11 [----------] 1 test from ObserverTest
12 [ RUN ] ObserverTest.UpdateObserver
13 observable-tests.cc:11: Failure
14 Actual function call count doesn’t match EXPECT_CALL(observer, update
())...
15 Expected: to be called once
16 Actual: never called - unsatisfied and active
17 [ FAILED ] ObserverTest.UpdateObserver (1 ms)
18 [----------] 1 test from ObserverTest (1 ms total)
19
20 [----------] Global test environment tear-down
21 [==========] 1 test from 1 test case ran. (1 ms total)
22 [ PASSED ] 0 tests.
23 [ FAILED ] 1 test, listed below:
24 [ FAILED ] ObserverTest.UpdateObserver
25
26 1 FAILED TEST
C24
Después de la compilación (líneas 2-6) se ejecuta el binario correspondiente al
test (línea 7). El test falla porque se esperaba una llamada a update() (línea 15)
y no se produjo ninguna (línea 16) de modo que la expectativa no se ha cumplido.
Es lógico porque el cuerpo del método notify() está vacío. Siguiendo la filosofía
TDD escribir el código mínimo para que la prueba pase:
1 $ make test
2 g++ -I /usr/src/gmock -c -o observable.o observable.cc
3 g++ observable-tests.o observable.o gmock_main.o gmock-all.o -
lpthread -lgtest -o observable-tests
4 $ ./observable-tests
5 Running main() from gmock_main.cc
6 [==========] Running 1 test from 1 test case.
7 [----------] Global test environment set-up.
8 [----------] 1 test from ObserverTest
9 [ RUN ] ObserverTest.UpdateObserver
10 [ OK ] ObserverTest.UpdateObserver (0 ms)
11 [----------] 1 test from ObserverTest (0 ms total)
12
13 [----------] Global test environment tear-down
14 [==========] 1 test from 1 test case ran. (0 ms total)
15 [ PASSED ] 1 test.
La prueba pasa. Ahora comprobemos que funciona también para dos observado-
res:
Y ejecutamos la prueba:
1 $ make test
2 Running main() from gmock_main.cc
3 [==========] Running 3 tests from 1 test case.
4 [----------] Global test environment set-up.
5 [----------] 3 tests from ObserverTest
6 [ RUN ] ObserverTest.UpdateObserver
7 [ OK ] ObserverTest.UpdateObserver (0 ms)
8 [ RUN ] ObserverTest.NeverUpdateObserver
9 [ OK ] ObserverTest.NeverUpdateObserver (0 ms)
10 [ RUN ] ObserverTest.TwoObserver
11 observable-tests.cc:30: Failure
24.8. Limitaciones [701]
1 $ make test
2 Running main() from gmock_main.cc
3 [==========] Running 3 tests from 1 test case.
4 [----------] Global test environment set-up.
5 [----------] 3 tests from ObserverTest
6 [ RUN ] ObserverTest.UpdateObserver
7 [ OK ] ObserverTest.UpdateObserver (0 ms)
8 [ RUN ] ObserverTest.NeverUpdateObserver
9 [ OK ] ObserverTest.NeverUpdateObserver (0 ms)
10 [ RUN ] ObserverTest.TwoObserver
11 [ OK ] ObserverTest.TwoObserver (0 ms)
12 [----------] 3 tests from ObserverTest (0 ms total)
13
14 [----------] Global test environment tear-down
15 [==========] 3 tests from 1 test case ran. (0 ms total)
16 [ PASSED ] 3 tests.
Todo correcto, aunque sería conveniente una prueba adicional para un mayor nú-
mero de observadores registrados. También podríamos comprobar que los observado-
C24
res des-registrados (detached) efectivamente no son invocados, etc.
Aunque los ejemplos son sencillos, es fácil ver la dinámica de TDD.
24.8. Limitaciones
Hay ciertos aspectos importantes para la aplicación en los que TDD, y el testing
en general, tienen una utilidad limitada (al menos hoy en día). Las pruebas permiten
comprobar fácilmente aspectos funcionales, pero es complejo comprobar requisitos
no funcionales.
[702] CAPÍTULO 24. VALIDACIÓN Y PRUEBAS
C
ada desarrollador de videojuegos tiene sus propios motivos para invertir su
tiempo y esfuerzo en la elaboración de un producto tan complejo. Algunos
programan juegos de forma altruista como carta de presentación o como
contribución a la sociedad. Pero la mayoría de los desarrolladores de videojuegos
trabajan para ver recompensado su esfuerzo de alguna forma, ya sea con donaciones,
publicidad empotrada en el juego, o directamente cobrando por su distribución, o por
las actualizaciones, o por nuevos niveles. En todos estos casos hay un factor común, el
desarrollador quiere que el juego sea usado por la mayor cantidad posible de usuarios.
En capítulos anteriores hemos visto diferencias entre MS Windows y GNU/Linux
desde el punto de vista de programación (plugins). En general son diferencias
relativamente pequeñas, es perfectamente posible programar el videojuego para que
se compile sin problemas en cualquiera de estas plataformas.
Pero el videojuego no solo tiene que compilarse, tiene que llegar al usuario
de alguna forma que le permita instalarlo sin dificultad. En este capítulo veremos
precisamente eso, la construcción de paquetes instalables para algunas plataformas
(MS Windows y GNU/Linux).
A diferencia del resto de actividades de desarrollo los sistemas de empaquetado
de software son completamente diferentes de unas plataformas a otras, e incluso hay
alternativas muy diferentes en una misma plataforma. Nos centraremos en las que
consideramos con mayor proyección.
703
[704] CAPÍTULO 25. EMPAQUETADO Y DISTRIBUCIÓN
Pre-requisitos
Antes de empezar el empaquetado tenemos que tener disponible una versión del
proyecto correctamente compilada en MS Windows y probada. Puede utilizarse el
conjunto de compiladores de GNU para Windows (MinGW) o Visual Studio. No es
importante para esta sección cómo se generan los ejecutables.
En la documentación en línea de OGRE 1.8.1 se explica cómo construir proyec-
tos con el SDK como proyecto de Visual Studio 2010 Express (Ver http://www.
ogre3d.org/tikiwiki/tiki-index.php, apartado Installing the Ogre SDK).
Es preciso puntualizar que no es necesario copiar el ejecutable al directorio de binarios
de OGRE. Tan solo hay que garantizar que:
1 Una comparativa simple con un subconjunto de las alternativas disponibles puede verse en http:
//en.wikipedia.org/wiki/List_of_installation_software.
2 Ver http://msdn.microsoft.com/en-us/library/ee721500(v=vs.100).aspx
25.1. Empaquetado y distribución en Windows [705]
MS Windows Installer
Windows Installation XML Toolset es el primer proyecto liberado con una licencia
libre por Microsoft en 2004. Inicialmente fue programado por Rob Mensching con el
objetivo de facilitar la creación de archivos msi y msp sin necesidad de utilizar una
interfaz gráfica.
WIX utiliza una aproximación puramente declarativa. En un archivo XML (con
extensión wxs) se describe el producto y dónde se pueden encontrar cada uno de
sus elementos. Este archivo es posteriormente compilado mediante la herramienta
candle.exe para analizar su consistencia interna y generar un archivo intermedio,
con extensión wixobj. Estos archivos intermedios siguen siendo XML aunque su
contenido no está pensado para ser editado o leído por seres humanos.
Uno o varios archivos wixobj pueden ser procesados por otra herramienta,
denominada light.exe para generar el archivo msi definitivo.
Este flujo de trabajo, muy similar al de la compilación de programas, es el más
simple de cuantos permite WIX, pero también va a ser el más frecuente. Además
de esto, WIX incluye herramientas para multitud de operaciones de gran utilidad en
proyectos grandes.
WIX sigue la filosofía de que el paquete de distribución es parte del desarrollo del
programa. Por tanto el archivo wxs que describe el paquete debe escribirse de forma
incremental junto al resto del programa.
Desgraciadamente en muchas ocasiones nos encontramos con que la tarea de
empaquetado no se ha considerado durante el desarrollo del proyecto. Ya sea porque se
ha utilizado otra plataforma para el desarrollo del videojuego, o por simple desidia, lo
cierto es que frecuentemente llegamos al final del proceso de desarrollo sin ni siquiera
habernos planteado la construcción del paquete. También en esos casos WIX aporta
una solución, mediante la herramienta heat.exe. Esta herramienta puede construir
fragmentos del archivo wxs mediante el análisis de directorios o archivos que van a
incluirse en el paquete.
Una de las formas más sencillas de generar un paquete instalable es instalar los
binarios y los archivos de medios necesarios durante la ejecución en un directorio
independiente. El archivo generado no especialmente legible ni siquiera está completo,
pero puede usarse como punto de partida.
Por ejemplo, si un programa instala todos los ejecutables y archivos auxiliares en
el subdirectorio bin podemos generar el archivo wxs inicial con:
Sin embargo, para mayor legibilidad en este primer ejemplo vamos a construir
el archivo desde cero. Volvamos al ejemplo ogre-hello. Una vez generados los
ejecutables en modo Release tenemos un subdirectorio Release que contiene todos
los ejecutables, en este caso OgreHello.exe. Por sí solo no puede ejecutarse,
25.1. Empaquetado y distribución en Windows [707]
necesita las bibliotecas de OGRE, los archivos del subdirectorio media y los archivos
de configuración resources.cfg y plugins.cfg que se incluyen con el código
fuente. Es bastante habitual copiar las DLL al subdirectorio Release para poder
probar el ejemplo.
La estructura del archivo wxs es la siguiente:
La etiqueta Wix se usa para envolver toda la descripción del instalador. Dentro
de ella debe haber un Product que describe el contenido del archivo msi. Todos los
productos tienen los atributos Id y UpgradeCode que contienen cada uno un GUID
(Globally Unique Identifier). Un GUID (o UUID) es un número largo de 128 bits, que
puede generarse de manera que sea muy poco probable que haya otro igual en ningún
otro sitio. Se utilizan por tanto para identificar de manera unívoca. En este caso se
identifica el producto y el código de actualización.
Todas las versiones del producto tienen el mismo Id, mientras que para cambio
de major version se genera otro nuevo GUID para UpgradeCode (al pasar de
1.x a 2.x, de 2.x a 3.x, etc.). El UpgradeCode es utilizado por Windows Installer
para detectar cuando debe eliminar la versión vieja para reemplazarla por la nueva o
simplemente reemplazar determinados archivos. Para generar un GUID nuevo puede
utilizarse, por ejemplo, un generador de GUIDs en línea, como http://www.
guidgenerator.com/.
Los códigos numéricos de Language y Codepage se corresponden a los valores in-
dicados por microsoft en http://msdn.microsoft.com/en-us/library/
Aa369771.aspx. En nuestro caso el idioma elegido es el español de España y la
página de códigos es la correspondiente al alfabeto latino.
Dentro de un producto pueden declararse multitud de aspectos sobre el instalador.
Lo primero es definir el paquete MSI que se va a generar:
1 <Package Id=’*’
2 Description=’Instalador de OgreHello 0.1’
3 Manufacturer=’UCLM’
4 InstallerVersion=’100’
5 Languages=’3082’
6 Compressed=’yes’
7 SummaryCodepage=’1252’ />
C25
Cada nuevo paquete generado tendrá su propio GUID. Por este motivo WIX
permite simplificar la construcción con el código especial * que indica que debe
ser auto-generado por el compilador. El atributo InstallerVersion indica la
versión mínima de Windows Installer requerida para poder instalar el paquete. Si no
se implementan aspectos avanzados, siempre será 100, que corresponde a la versión
1.0.
[708] CAPÍTULO 25. EMPAQUETADO Y DISTRIBUCIÓN
En este caso hemos creado un directorio OgreHello 1.0 dentro del directorio
estándar para los archivos de programa (normalmente C:\Program Files).
Dentro de este directorio hemos hecho un subdirectorio media que contendrá los
archivos de medios (recursos).
Ahora podemos añadir componentes dentro de estos directorios. Cada componente
es un conjunto de archivos muy fuertemente relacionado, hasta el punto de que no
tiene sentido actualizar uno sin actualizar los demás. En general se tiende hacia
componentes lo más pequeños posibles (un solo archivo), con objeto de que se puedan
hacer parches más pequeños. Por ejemplo, el ejecutable principal es un componente,
pero por simplicidad añadiremos también los archivos de configuración.
Cada componente tiene también un GUID que lo identifica. En este caso contiene
tres archivos, el ejecutable y los dos archivos de configuración. Además, para el
ejecutable creamos también un atajo en el menú de inicio de Windows.
El atributo KeyPath de los archivos se pone a yes solamente para un archivo
dentro del componente. Este archivo será utilizado por Windows Installer para
identificar si el componente está previamente instalado.
25.1. Empaquetado y distribución en Windows [709]
Para simplificar el resto del paquete vamos a meter todas las bibliotecas de OGRE
en un único componente. En un caso real probablemente convendría dividirlo para
permitir parches más pequeños en caso de que no afecten a todas las bibliotecas de
OGRE.
1 <DirectoryRef Id=’ProgramMenuDir’>
2 <Component Id="ProgramMenuDir" Guid="b16ffa2a-d978-4832-a5f2
-01005e59853c">
3 <RemoveFolder Id=’ProgramMenuDir’ On=’uninstall’ />
4 <RegistryValue Root=’HKCU’ Key=’Software\[Manufacturer]\[
ProductName]’ Type=’string’ Value=’’ KeyPath=’yes’ /> C25
5 </Component>
6 </DirectoryRef>
> cd release
> candle.exe ..\wix\ogre-hello.wxs
> light.exe ogre-hello.wixobj
Por ejemplo, para añadir un formulario que pregunta al usuario dónde se desea
instalar tan solo tenemos que cambiar la sección Feature de este modo:
12 </Feature>
Tan solo hemos añadido dos referencias externas y algunos atributos, como
Title, Description, Display y ConfigurableDirectory. El resultado
es el que se muestra en la figura.
➊
Figura 25.1: Ejemplo de diálogos generados por WIX.
$ wget https://bitbucket.org/arco_group/ogre-hello/downloads/ogre-hello-0.1.tar.
gz
$ tar xvfz ogre-hello-0.1.tar.gz
$ cd ogre-hello-0.1
ogre-hello-0.1$
DEBEMAIL="juan.nadie@gmail.com"
DEBFULLNAME="Juan Nadie"
export DEBEMAIL DEBFULLNAME
Estas variables son útiles para otras operaciones relacionadas con las construcción
de paquetes, por lo que es buena idea añadirlas al fichero $HOME/.bashrc para
futuras sesiones. Después de eso, ejecuta dh_make:
manpages
ogre-hello-0.1$ dh_make -f ../ogre-hello-0.1.tar.gz
Type of package: single binary, indep binary, multiple binary, library, kernel Todo fichero ejecutable en el PATH
module, kernel patch? debería tener su página de ma-
[s/i/m/l/k/n] s
nual. Las páginas de manual es-
Maintainer name : Juan Nadie tán escritas en groff, pero es mu-
Email-Address : juan.nadie@gmail.com cho más sencillo crearlas a par-
Date : Wed, 24 Apr 2013 12:59:40 +0200 tir de formatos más sencillos como
Package Name : ogre-hello SGML (Standard Generalized Mar-
Version : 0.1
License : blank kup Language), XML, asciidoc o
Type of Package : Single reStructuredText y convertirlas en
Hit <enter> to confirm: el momento de la construcción del
Done. Please edit the files in the debian/ subdirectory now. You should also paquete.
check that the ogre-hello Makefiles install into $DESTDIR and not in / .
debian/control
Source
Es el nombre del paquete fuente, normalmente el mismo nombre del programa
tal como lo nombró el autor original (upstream author).
Section
La categoría en la que se clasifica el paquete, dentro de una lista establecida,
vea http://packages.debian.org/unstable/.
Priority
La importancia que tiene el paquete para la instalación. Las categorías son:
required, important, standard, optional y extra. Este paquete, al ser un juego y
no causar ningún conflicto, es optional. Puede ver el significado de cada una
de estas prioridades en http://www.debian.org/doc/debian-policy/
ch-archive.html.
Maintainer
Es el nombre y la dirección de correo electrónico de la persona que mantiene el
paquete actualmente. C25
Build-Depends
Es una lista de nombres de paquetes (opcionalmente con las versiones requeri-
das) que son necesarios para compilar el presente paquete. Estos paquetes deben
estar instalados en el sistema (además del paquete build-essential) para
poder construir los paquetes «binarios».
Standards-Version
La versión más reciente de la policy que cumple el paquete.
[716] CAPÍTULO 25. EMPAQUETADO Y DISTRIBUCIÓN
Package
El nombre del paquete binario, que dará lugar al archivo .deb. Este nombre
debe cumplir una serie de reglas que dependen del lenguaje en que está escrito
el programa, su finalidad, etc.
Architecture
Indica en qué arquitecturas hardware puede compilar el programa. Aparte de las
arquitecturas soportadas por Debian hay dos identificadores especiales:
«all», indica que el mismo programa funciona en todas las plataformas.
Normalmente se trata de programas escritos en lenguajes interpretados o
bien ficheros multimedia, manuales, etc, que no requieren compilación. Se
dice que son paquetes «independientes de arquitectura».
«any», indica que el programa debe ser compilado pero está soportado en
todas las arquitecturas.
Depends
Es una lista de los nombres de los paquetes necesarios para instalar el paquete
y ejecutar los programas que contiene.
debian/changelog
Contiene una descripción breve de los cambios que el mantenedor hace a los
ficheros específicos del paquete (los que estamos describiendo). En este fichero no
se describen los cambios que sufre el programa en sí; eso corresponde al autor del
programa y normalmente estarán en un fichero CHANGES (o algo parecido) dentro del
.tgz que descargamos.
El siguiente listado muestra el fichero changelog generado por dh_make:
Cada vez que el mantenedor haga un cambio debe crear una nueva entrada como
esa al comienzo del fichero. Fíjese que la versión del paquete está formada por el
número de versión del programa original más un guión y un número adicional que
indica la revisión del paquete debian. Si el mantenedor hace cambios sobre el paquete
manteniendo la misma versión del programa irá incrementando el número tras el
guión. Sin embargo, cuando el mantenedor empaqueta una nueva versión del programa
(supongamos la 0.2 en nuestro ejemplo) el número tras el guión vuelve a empezar
desde 1.
Como se puede apreciar, la primera versión del paquete debería cerrar (solucionar)
un bug existente. Ese bug (identificado por un número) corresponde al ITP que el
mantenedor debió enviar antes de comentar con el proceso de empaquetado.
Cuando una nueva versión del paquete se sube a los repositorios oficiales, las
sentencias Closes son procesadas automáticamente para cerrar los bugs notificados
a través del DBTS. Puedes ver más detalles en http://www.debian.org/doc/
debian-policy/ch-source.html#s-dpkgchangelog
debian/copyright
Este fichero debe contender toda la información sobre el autor del programa y las
licencias utilizadas en cada una de sus partes (si es que son varias). También debería
indicar quién es el autor y la licencia de los ficheros del paquete debian. El fichero
generado por dh_make contiene algo similar a:
El fichero debería contener una sección (que comienza con «Files:)» por cada
autor y licencia involucrados en el programa. La sección «Files: debian/*» ya
está completa y asume que el mantenedor va a utilizar la licencia GPL-2 y superior
para los ficheros del paquete.
Debemos modificar este fichero para incluir las licencias específicas del programa
que estamos empaquetando. Las secciones nuevas serían:
debian/rules
Por fortuna, actualmente el programa debhelper (o dh) hace la mayor parte del
trabajo aplicando reglas por defecto para todos los objetivos. Solo es necesario sobre-
escribir (reglas override_ dichos objetivos en el fichero rules si se requiere un
comportamiento especial.
El listado anterior contiene una pequeña diferencia respecto al generado por
dh_make que consiste en la adición del parámetro -with quilt. El programa
quilt es una aplicación especializada en la gestión de parches de un modo muy
cómodo.
build-depends Una vez contamos con los ficheros básicos se puede proceder a una primera
compilación del paquete. Para ello utilizamos el programa dpkg-buildpackage.
Para construir el paquete de- El siguiente listado muestra el comando y parte del resultado. Se omiten algunas partes
be tener instalados todos los dado que la salida es bastante verbosa:
paquetes listados en los cam-
pos Build-Depends y
Build-Depends-Indep, ogre-hello-0.1$ dpkg-buildpackage -us -us
además del paquete dpkg-buildpackage: source package ogre-hello
build-essential. dpkg-buildpackage: source version 0.1-1
dpkg-buildpackage: source changed by Juan Nadie <Juan.Nadie@gmail.com>
dpkg-buildpackage: host architecture amd64
dpkg-source --before-build ogre-hello-0.1
dpkg-source: info: applying make-install
fakeroot debian/rules clean
dh clean --with quilt
dh_testdir
dh_auto_clean
make[1]: Entering directory ‘/home/david/repos/ogre-hello/ogre-hello-0.1’
rm -f helloWorld *.log *.o *~
make[1]: Leaving directory ‘/home/david/repos/ogre-hello/ogre-hello-0.1’ C25
dh_quilt_unpatch
dpkg-source -b ogre-hello-0.1
dpkg-source: info: using source format ‘3.0 (quilt)’
dpkg-source: info: applying make-install
dpkg-source: info: building ogre-hello using existing ./ogre-hello_0.1.orig.tar.
gz
dpkg-source: info: building ogre-hello in ogre-hello_0.1-1.debian.tar.gz
dpkg-source: info: building ogre-hello in ogre-hello_0.1-1.dsc
debian/rules build
dh build --with quilt
[...]
dh_testdir
[720] CAPÍTULO 25. EMPAQUETADO Y DISTRIBUCIÓN
ogre-hello_0.1-1.dsc
Es una descripción del paquete fuente, con una serie de campos extraídos del fi-
chero debian/control además de los checksums para los otros dos ficheros
que forman el paquete fuente: el .orig.tar.gz y el .debian.tar.gz.
ogre-hello_0.1-1_amd64.changes
Este fichero es utilizado por el archivo (el repositorio) de paquetes de Debian
y se utiliza en la subida (upload) de paquetes por parte de los DD (Debian
Developer). Contiene checksum para los otros ficheros y, en una versión oficial,
debería estar firmado digitalmente para asegurar que el paquete no ha sido
manipulado por terceros.
ogre-hello_0.1-1.debian.tar.gz
Es un archivo que contiene todos los cambios que el mantenedor del paquete ha
hecho respecto al fuente del programa original, es decir, el directorio debian
principalmente.
ogre-hello_0.1-1_amd64.deb
. Es el paquete binario resultado de la compilación (en concreto para la
arquitectura AMD-64) que puede ser instalado con dpkg o indirectamente con
los gestores de paquetes apt-get, aptitude u otros.
25.2. Empaquetado y distribución en GNU/Linux [721]
Este aviso indica que el paquete no contiene nada aparte de los ficheros aportados
por el propio sistema de construcción. Veamos qué contiene realmente:
*** Contents:
drwxr-xr-x root/root 0 2013-04-29 14:05 ./
drwxr-xr-x root/root 0 2013-04-29 14:05 ./usr/
drwxr-xr-x root/root 0 2013-04-29 14:05 ./usr/share/
drwxr-xr-x root/root 0 2013-04-29 14:05 ./usr/share/doc/
drwxr-xr-x root/root 0 2013-04-29 14:05 ./usr/share/doc/ogre-hello/
-rw-r--r-- root/root 2348 2013-04-29 13:15 ./usr/share/doc/ogre-hello/
copyright
-rw-r--r-- root/root 175 2013-04-29 13:15 ./usr/share/doc/ogre-hello/
changelog.Debian.gz
Se debe a que no está permitido modificar los ficheros extraídos del tarball del
autor del programa. Es necesario crear un parche. Un parche es un fichero que contiene
los cambios que es preciso realizar en otro fichero para lograr un resultado concreto,
indicando el número de línea y otro contenido que ayuda a las herramientas a localizar
el lugar correcto. Por suerte, el propio error nos ofrece una forma muy sencilla que
crear este parche:
Estos parches serán aplicados por el programa quilt, que se invocará automáti-
camente al usar dpkg-buildpackage. Veamos las diferencias:
Aunque ahora el paquete contiene los ficheros deseados y los instalará en su ruta
correcta, pero aún tiene algunos problemas ya que el programa no estaba pensado para
trabajar con esta estructura de ficheros:
Con esto último tendremos un total de tres parches y el programa será funcional
tras la instalación. Quedan aún dos problemas (no tan graves) por resolver según
informa lintian. El primero (binary-without-manpage) indica que todo fichero
ejecutable en el PATH debería tener una página de manual. El segundo (hardening-
no-relro) indica que el programa debería estar compilado con determinadas opciones C25
que evitan problemas comunes.
[724] CAPÍTULO 25. EMPAQUETADO Y DISTRIBUCIÓN
Las cuatro últimas líneas de la salida de uscan confirman que tenemos empaque-
tada la última versión y que además tenemos en disco el tarball del autor.
Una vez que disponemos de la nueva versión del programa, debemos crear una
nueva entrada en el fichero debian/changelog (que se puede automatizar en
parte con el programa dch). Para aplicar los cambios del nuevo tarball puedes
utilizar el programa uupdate aunque uscan puede encargarse también de esto.
A continuación debe comprobar que la construcción de la nueva versión es correcta
poniendo especial atención a la aplicación de los parches sobre la nueva versión del
código fuente.
6 Como ejemplo vea http://qa.debian.org/developer.php?login=
pkg-games-devel%40lists.alioth.debian.ora
25.3. Otros formatos de paquete [725]
C25
Representación Avanzada
Capítulo 26
Sergio Pérez Camacho
Jorge López González
César Mora Castro
L
os sistemas de partículas suponen una parte muy importante del impacto
visual que produce una aplicación 3D. Sin ellos, las escenas virtuales se
compondrían básicamente de geometría sólida y texturas. Los elementos que
se modelan utilizando estos tipos de sistemas no suelen tener una forma definida,
como fuego, humo, nubes o incluso aureolas simulando ser escudos de fuerza. En las
siguientes secciones se van a introducir los fundamentos de los sistemas de partículas
y billboards, y cómo se pueden generar utilizando las múltiples características que
proporciona Ogre.
26.1. Fundamentos
Para poder comprender cómo funcionan los sistemas de partículas, es necesario
conocer primero el concepto de Billboard, ya que dependen directamente de estos. A
Figura 26.1: Ejemplo típico de vi- continuación se define qué son los Billboard, sus tipos y los conceptos matemáticos
deojuego que utiliza billboards para básicos asociados a estos, para despues continuar con los sistemas de partículas.
modelar la vegetación. Screenshot
tomado del videojuego libre Stunt
Rally. 26.1.1. Billboards
Billboard significa, literalmente, valla publicitaria, haciendo alusión a los grandes
carteles que se colocan cerca de las carreteras para anunciar un producto o servicio.
Aquellos juegos en los que aparece el nombre de un jugador encima de su personaje,
siempre visible a la cámara, utilizan billboards. Los árboles de juegos de carrera que
daban la sensación de ser un dibujo plano, utilizan Billboards.
727
[728] CAPÍTULO 26. REPRESENTACIÓN AVANZADA
u u u' u
a) b) c) n'
n n
n
r r
Figura 26.2: En a) se puede observar un Billboard con el vector up y el vector normal. En b) y c) se muestran
ejemplos de cómo calcular uno de los vectores dependiendo del otro en el caso de no ser perpendiculares.
r =u·v
u′ = n · r
Estos son los tipos más simples de billboard. Su vector up u siempre coincide con
el de la cámara, mientras que el vector normal n se toma como el inverso del vector
normal de la cámara (hacia donde la cámara está mirando). Estos dos vectores son
siempre perpendiculares, por lo que no es necesario recalcular ninguno con el método
anterior. La matriz de rotación de estos billboard es la misma para todos.
Por lo tanto estos billboard siempre estarán alienados con la pantalla, aun cuando
la cámara realice giros sobre el eje Z (roll), como se puede apreciar en la Figura 26.3.
Esta técnica también puede utilizarse para sprites circulares como partículas.
En [7] se pueden encontrar más técnicas utilizadas para dar más sensación de
realismo a estos billboard.
[730] CAPÍTULO 26. REPRESENTACIÓN AVANZADA
a) b)
Cámara Cámara
Figura 26.5: Billboards con el mismo vector normal que la cámara en a), mientras que en b) son orientados
al punto de vista.
Billboard axial
Impostors
Nubes de Billboards
Billboards individuales
También es posible controlar la En Ogre, no existe un elemento billboard por sí sólo que se pueda representar.
representación individual de cada Estos deben pertenecer a un objeto de la clase BillboardSet. Esta clase se encarga de
Billboard dentro de un Billboard- gestionar los diferentes billboards que están contenidos en ella.
Set, pero la penalización en rendi-
miento es mucho mayor. En la ma-
yoría de los casos es más eficiente
crear distintos BillboardSets.
[732] CAPÍTULO 26. REPRESENTACIÓN AVANZADA
✄
De las líneas ✂7-9 ✁se crean tantos Billboard como capacidad se dió al BillboardSet
en el momento de su creación. A cada uno de ellos se le indica la posición
✄ relativa al
SceneNode al que pertenece el BillboardSet. Por último, en las líneas ✂11-13 ✁se crea
un nodo y se adjunta el BillboardSet a él.
26.2. Uso de Billboards [733]
C26
mismo sitio. Esto es interesante si se quiere dejar una estela, por ejemplo, de humo.
Si se desea que las partículas ya creadas se trasladen con el nodo al que pertenece
el sistema, se puede indicar que el posicionamiento se haga referente al sistema de
coordenadas local.
Los sistemas de partículas deben tener siempre una cantidad límite de estas, o
quota. Una vez alcanzado esta cantidad, el sistema dejará de emitir hasta que
se eliminen algunas de las partículas antiguas. Las partículas tienen un límite
de vida configurable para ser eliminadas. Por defecto, este valor de quota es
10, por lo que puede interesar al usuario indicar un valor mayor en la plantilla.
Eficiencia ante todo Ogre necesita calcular el espacio físico que ocupa un sistema de partículas (su
Los sistemas de partículas pueden BoundingBox) de forma regular. Esto es computacionalmente costoso, por lo que
rápidamente convertirse en una par- por defecto, deja de recalcularlo pasados 10 segundos. Este comportamiento se
te muy agresiva que requiere mucho puede configurar mediante el método ParticleSystem::setBoundsAutoUpdated(), el
tiempo de cómputo. Es importante
dedicar el tiempo suficiente a opti-
cual recibe como parámetro los segundos que debe recalcular la BoundingBox. Si
mizarlos, por el bien del rendimien- se conoce de antemano el tamaño aproximado del espacio que ocupa un sistema de
to de la aplicación. partículas, se puede indicar a Ogre que no realice este cálculo, y se le indica el tamaño
fijo mediante el método ParticleSystem::setBounds(). De esta forma se ahorra mucho
tiempo de procesamiento. Se puede alcanzar un compromiso indicando un tamaño
inicial aproximado, y luego dejando a Ogre que lo recalcule durante poco tiempo
pasados algunos segundos desde la creación del sistema.
A continuación se describen los dos elementos básicos que definen los sistemas de
partículas en Ogre: los emisores y los efectores.
26.3.1. Emisores
Los emisores definen los objetos que literalmente emiten las partículas a la escena.
Los distintos emisores que proporciona Ogre son:
Puntos: point. Todas las partículas son emitidas desde un mismo punto.
Caja: box. Las partículas son emitidas desde cualquier punto dentro de un
volumen rectangular.
Cilindro: cylinder. Las partículas son emitidas desde un volumen cilíndrico
definido.
Elipsoide: ellipsoid. Las partículas se emiten desde un volumen elipsoidal.
Superficie de elipsoide: hollow elipsoid. Las partículas se emiten desde la
superficie de un volumen elipsoidal.
Anillo: ring. Las partículas son emitidas desde los bordes de un anillo.
26.3.2. Efectores
Los efectores o affectors realizan cambios sobre los sistemas de partículas. Estos
cambios pueden ser en su dirección, tiempo de vida, color, etc. A continuación se
explican cada uno de los efectores que ofrece Ogre.
LinearForce: aplica una fuerza a las partículas del sistema. Esta fuerza
se indica mediante un vector, cuya dirección equivale a la dirección de la
fuerza, y su módulo equivale a la magnitud de la fuerza. La aplicación de
una fuerza puede resultar en un incremento enorme de la velocidad, por lo
que se dispone de un parámetro, force_application para controlar esto. El
valor force_application average ajusta el valor de la fuerza para estabilizar
la velocidad de las partículas a la media entre la magnitud de la fuerza y la
velocidad actual de estas. Por el contrario, force_application add deja que la
velocidad aumente o se reduzca sin control.
ColourFader: modifica el color de una partícula mientras ésta exista. El
valor suministrado a este modificador significa ‘ ‘ cantidad de cambio de una
componente de color en función del tiempo”. Por lo tanto, un valor de red -0.5
decrementará la componente del color rojo en 0.5 cada segundo.
ColourFader2: es similar a ColourFader, excepto que el modificador puede ColourFader
cambiar de comportamiento pasada una determinada cantidad de tiempo. Por Un valor de -0.5 no quiere decir que
ejemplo, el color de una partícula puede decrementarse suavemente hasta el se reduzca a la mitad cada segundo,
50 % de su valor, y luego caer en picado hasta 0. y por lo tanto, nunca alcanzará el
valor de 0. Significa de al valor
de la componente (perteneciente al
ColourInterpolator: es similar a ColourFader2, sólo que se pueden especificar intervalo de 0 a 1) se le restará 0.5.
hasta 6 cambios de comportamiento distintos. Se puede ver como una generali- Por lo tanto a un valor de blanco
zación de los otros dos modificadores. (1) se reducirá a negro (0) en dos
segundos.
Scaler: este modificador cambia de forma proporcional el tamaño de la partícula
en función del tiempo.
Rotator: rota la textura de la partícula por bien un ángulo aleatorio, o a una
velocidad aleatoria. Estos dos parámetros son definidos dentro de un rango (por
defecto 0).
ColourImage: este modificador cambia el color de una partícula, pero estos
valores se toman de un fichero imagen (con extensión .png, .jpg, etc.). Los
valores de los píxeles se leen de arriba a abajo y de izquierda a derecha. Por
lo tanto, el valor de la esquina de arriba a la izquierda será el color inicial, y el
de abajo a la derecha el final.
26.3. Uso de Sistemas de Partículas [737]
✄
A continuación se declaran tantos emisores y modificadores como se deseen. En
las líneas ✂8-19 ✁se declara un emisor del tipo anillo. Los parámetros especificados para
C26
estudio decidió contratar programadores gráficos para que, comandados por Edwin
Catmull, empezaran a informatizar la industria de los efectos especiales. Casi nada.
Aquel grupo de pioneros se embarcó en varios proyectos diferentes, logrando que
cada uno de ellos desembocara en cosas muy interesantes. Uno de estos proyectillos
acabó siendo el germen de lo que más adelante se conocería como Pixar Animation
Studios. Y fue en esta compañía donde, durante el desarrollo del API abierto RISpec
[740] CAPÍTULO 26. REPRESENTACIÓN AVANZADA
El motivo por el que se usan tarjetas gráficas busca que la CPU delegue en la
medida de lo posible la mayor cantidad de trabajo en la GPU, y que así la CPU
quede liberada de trabajo. Delegar ciertas tareas en alguien o algo especializado en
algo concreto es siempre una buena idea. Existen además dos claves de peso que
justifican la decisión de usar un hardware específico para las tareas gráficas. La
primera es que las CPU son procesadores de propósito general mientras que la tarea
de procesamiento de gráficos tiene características muy específicas que hacen fácil
extraer esa funcionalidad aparte. La segunda es que hay mercado para ello, hoy en día
nos encontramos con que muchas aplicaciones actuales (videojuegos, simulaciones,
diseño gráfico, etc...) requieren de rendering interactivo con la mayor calidad posible.
Conectividad de vértices
Fragmentos Fragmentos
Coloreados
Ensamblado Interpolación,
Transformación Operaciones
de Primitivas Texturizado
del Vértice de Rasterización
y Rasterización y Coloreado
Vértices Píxeles
Vértices Actualizados
Transformados
Tipos de Shader
Aplicación 3D
API 3D:
OpenGL o
Direct3D
Límite CPU - GPU
Comandos
Primitivas Primitivas
de la GPU Flujo de índices Flujo de localizaciones Actualizaciones
ensambladas ensambladas
y flujo de datos. de vértices de píxeles de píxeles
Procesador
GPU Ensamblado Rasterización Operaciones
Programable Framebuffer
Front End de Primitivas e Interpolación de Raster
de Geometría
Cada vertex shader afecta a un sólo vértice, es decir, se opera sobre vértices
individuales no sobre colecciones de ellos. A su vez, tampoco se tiene acceso
a la información sobre otros vértices, ni siquiera a los que forman parte de su
propia primitiva.
Al igual que los vertex shader, sólo puede procesar un fragmento cada vez y
no puede influir sobre, ni tener acceso a, ningún otro fragmento.
10 tFOutput OUT;
11 // Se aplica el color al fragmento
12 OUT.color = color;
13
14 return OUT;
15 }
Vertex Skinning
Los vértices de una superficie, al igual que el cuerpo humano, son movidos a causa
de la influencia de una estructura esquelética. Como complemento, se puede aplicar
una deformación extra para simular la dinámica de la forma de un músculo.
En este caso el shader ayuda a establecer cómo los vértices se ven afectados por
el esqueleto y aplica las transformaciones en consecuencia.
Los vértices pueden ser desplazados, por ejemplo verticalmente, en función de los
resultados ofrecidos por un algoritmo o mediante la aplicación de un mapa de alturas
(una textura en escala de grises), consiguiendo con ello, por ejemplo, la generación de
un terreno irregular.
Screen Effects
Los shader pueden ser muy útiles para lograr todo tipo de efectos sobre la imagen
ya generada tras el renderizado. Gracias a las múltiples pasadas que se pueden aplicar,
es posible generar todo tipo de efectos de post-procesado, del estilo de los que se usan
en las películas actuales.
Los fragment shader realizan el renderizado en una textura temporal (un frame-
buffer alternativo) que luego es procesada con filtros antes de devolver los valores de
color.
C26
Light and Surface Models
una altísima calidad, aunque habría que tener cuidad con su uso. El conseguir, por
ejemplo, luz por pixel implicaría unas operaciones bastante complejas por cada pixel
que se vaya a dibujar en pantalla, lo cual, contando los pixeles de una pantalla, pueden
ser muchas operaciones.
Figura 26.17: Diferencia entre Per-Vertex Lighting (izquierda) y Per-Pixel Lighting (derecha).
Gracias a estas técnicas es posible lograr que la visualización de los modelos sea
mucho mejor, sin incrementar la calidad del modelo. Ejemplo de esta técnica sería el
Normal mapping, donde a partir de una textura con información sobre el valor de las
normales (codificada cada normal como un valor RGB, correspondiente al XYZ de
la misma) a lo largo de toda su superficie, permite crear la ilusión de que el modelo
cuenta con más detalle del que realmente tiene.
Non-Photorealistic Rendering
Antes de empezar debemos tener claro que para usar los shader en Ogre debemos
contar con dos tipos de ficheros. Por un lado tendremos los programas, o archivos .cg,
en los que se encontrarán los shaders escritos en Cg, y por otro lado tendremos los
archivos de efectos, o materiales, que definirán cómo se usan nuestros programas
Una vez sabido esto, el siguiente paso debería ser dejar claro donde se han colocar Log de Ogre
los ficheros correspondientes a los shader, para que así puedan ser usados por Ogre.
Estos se deben colocar en la ruta: En el fichero de log podremos en-
contrar información sobre los per-
files y funcionalidad soportada por
/[directorio_proyecto_ogre]/media/materials/programs nuestra tarjeta gráfica, así cómo in-
formación sobre posibles errores en
la compilación de nuestros materia-
Si queremos usar otro directorio deberemos indicarlo en el archivo resources.cfg, les o shaders.
bajo la etiqueta [popular] por ejemplo (hay que tener cuidado sin embargo con lo que
se coloca bajo esa etiqueta en un desarrollo serio).
[Popular]
...
FileSystem=../../media/materials/programs/mis_shaders
...
Por otro lado, es conveniente tener a mano el fichero de log que genera Ogre en
cada ejecución, para saber por qué nuestro shader hace que todo aparezca blanco (o
lo que es lo mismo ¿por qué ha fallado su compilación?).
Por último, antes de definir el material en si, hay que indicar también a los shader
cuales son aquellos parámetros que Ogre les pasará. En este caso sólo pasamos la
matriz que usaremos para transformar las coordenadas de cada vértice a coordenadas
de cámara. Es importante definir esta información al principio del fichero de material.
26.5. Desarrollo de shaders en Ogre [753]
Para este caso, el material quedaría tal y como se ve en el listado 26.8. Y queda
claro cómo en la pasada se aplican los dos shader.
Declaración shaders
Los dos programas que definen nuestros primeros fragment y vertex shader
Para más información sobre la aparecen en los listados 26.9 y 26.10, y los dos deberían incluirse en el fichero
declaración de los shader, se-
ría buena idea dirigirse al ma- firstshaders.cg (o en el archivo que queramos), tal y como indicamos en el material.
nual en: http://www.ogre3d.org/-
docs/manual/manual_18.html
[754] CAPÍTULO 26. REPRESENTACIÓN AVANZADA
El vertex shader recibe cómo único parámetro del vértice su posición, esta
es transformada a coordenadas del espacio de cámara gracias a la operación que
realizamos con la variable que representa la matriz de transformación.
Por último se asigna el color primario al vértice, que se corresponde con su
componente difusa, y que en este caso es verde.
El pipeline debe proveer de algún mecanismo para comunicar a los shader los
valores de aquellos elementos necesarios para conocer el estado de la simulación (la
posición de las luces, la delta de tiempo, las matrices de transformación, etc...). Esto
se consigue mediante el uso de las variable declaradas como uniform. Para Cg, estas
son variables externas, cuyos valores deben ser especificados por otro elemento.
[756] CAPÍTULO 26. REPRESENTACIÓN AVANZADA
46 return manual;
47 }
El vertex shader lo único que tendrá que hacer es dejar pasar el vértice, pero esta
vez, en vez de cambiarle el color dejamos que siga con el que se le ha asignado. Para
ello, en el método que define el punto de entrada del shader hay que indicarle que
recibe como parametro el color del vértice.
El fragment shader podemos dejarlo igual, no es necesario que haga nada. Incluso
es posible no incluirlo en el fichero de material (cosa que no se puede hacer con los
vertex shader).
El resultado obtenido, debería ser el mostrado en la figura 26.21.
Ahora que sabes cómo modificar el color de los vértices. ¿Serías capaz
Figura 26.22: Ogro dibujado como de pintar la entidad típica del Ogro como si de textura tuviera un mapa
si tuviera un mapa de normales en- de normales? Figura 26.22 Tip: Has de usar la normal del vértice para
cima. conseguirlo.
11 tFOutput OUT;
12 OUT.color = 1 - tex2D(texture, uv);
13 return OUT;
14 }
6 }
Listado 26.22: La composición del color de la textura en varias posiciones diferentes da lugar
a un efecto borroso
1 // Como si dibujaramos tres veces la textura
2 tFOutput f_tex_blurry(
3 float2 uv : TEXCOORD0,
4 uniform sampler2D texture)
5 {
6 tFOutput OUT;
Figura 26.28: Efecto ondulado. 7 OUT.color = tex2D(texture, uv);
8 OUT.color.a = 1.0f;
9 OUT.color += tex2D(texture, uv.xy + 0.01f);
10 OUT.color += tex2D(texture, uv.xy - 0.01f);
11 return OUT;
12 } C26
Listado 26.23: Este efecto se consigue con una combinación del efecto blur con la conversión
del color a escala de grises
1 // Dibuja la textura como si estuviera grabada en piedra
2 tFOutput f_tex_emboss(
Figura 26.29: Efecto borroso 3 float2 uv : TEXCOORD0,
(blur). 4 uniform sampler2D texture)
[762] CAPÍTULO 26. REPRESENTACIÓN AVANZADA
5 {
6 float sharpAmount = 30.0f;
7
8 tFOutput OUT;
9 // Color inicial
10 OUT.color.rgb = 0.5f;
11 OUT.color.a = 1.0f;
12
13 // Añadimos el color de la textura
14 OUT.color -= tex2D(texture, uv - 0.0001f) * sharpAmount;
15 OUT.color += tex2D(texture, uv + 0.0001f) * sharpAmount;
16
17 // Para finalizar hacemos la media de la cantidad de color de
cada componente
18 // para convertir el color a escala de grises
19 OUT.color = (OUT.color.r + OUT.color.g + OUT.color.b) / 3.0f;
20
21 return OUT;
22 }
34 }
35 }
36 }
37 }
El anterior era un shader muy sencillo, por lo que ahora intentaremos crear
nuestras propias animaciones mediante shaders.
Para ello necesitamos tener acceso a la delta de tiempo. En la página oficial1
aparecen listados los diferentes parámetros que Ogre expone para ser accedidos
mediante variables uniform. En este caso, en la definición del vertex shader realizada
en el material debemos indicar que queremos tener acceso a la delta de tiempo, como
se ve en el Listado 26.26.
Figura 26.31: Resultado de las dos
pasadas. Listado 26.26: Acceso a la variable que expone la delta de tiempo
1 vertex_program VertexPulse cg
2 {
3 source vertex_modification.cg
4 entry_point v_pulse
5 profiles vs_1_1 arbvp1
6
7 default_params
8 {
9 param_named_auto worldViewMatrix worldviewproj_matrix
10 param_named_auto pulseTime time
11 }
12 }
Una vez tenemos accesible el tiempo transcurrido entre dos vueltas del bucle
principal, sólo necesitamos pasarlo como parámetro a nuestro shader y usarlo para
nuestros propósitos. En este caso, modificaremos el valor del eje Y de todos los
vértices del modelo, siguiendo una función cosenoidal, como se puede ver en el
Listado 26.32.
8 {
9 tVOutput OUT;
10
11 OUT.position = mul(worldViewMatrix, position);
12 OUT.position.y *= (2-cos(pulseTime));
13 OUT.uv = uv;
14
15 return OUT;
16 }
Sabiendo cómo hacer esto, se tiene la posibilidad de conseguir muchos otros tipos
de efectos, por ejemplo en el siguiente se desplaza cada cara del modelo en la dirección
de sus normales.
Listado 26.28: Uso de la normal y la delta de tiempo para crear un efecto cíclico sobre los
vértices
1 tVOutput v_extrussion(
2 float4 position : POSITION,
3 float4 normal : NORMAL,
4 float2 uv : TEXCOORD0,
5 uniform float pulseTime,
6 uniform float4x4 worldViewMatrix)
7 { Figura 26.33: Figura con sus vérti-
8 tVOutput OUT; ces desplazados en dirección de sus
9 OUT.position = position + (normal * (cos(pulseTime)*0.5f)); normales.
10 OUT.position = mul(worldViewMatrix, OUT.position);
11 OUT.uv = uv;
12
13 return OUT;
14 }
¿Serías capaz de montar una escena con un plano y crear un shader que
simule un movimiento ondulatorio como el de la Figura 26.34?
Listado 26.29: Ejemplo de declaración de parámetros para ser usados como uniform por un
shader
1 default_params
2 {
26.6. Optimización de interiores [765]
¿Más velocidad? En las dos secciones siguientes se tratarán diferentes maneras de acelerar la
representación de escenas. Cabe destacar que estas optimizaciones difieren de las que
Las optimizaciones no sólo aumen- tienen que ver con el código generado, o con los ciclos de reloj que se puedan usar
tan el número de frames por segun-
do, sino que puede hacer interactivo para realizar una multiplicación de matrices o de vectores. Esta optimizaciones tienen
algo que no lo sería sin ellas. que ver con una máxima: lo que no se ve no debería representarse.
26.6.1. Introducción
Quizá cabe preguntarse si tiene sentido optimizar el dibujado de escenas ya que
“todo eso lo hace la GPU”. No es una mala pregunta, teniendo en cuenta que muchos
monitores tienen una velocidad de refresco de 60Hz, ¿para qué intentar pasar de
una tasa de 70 frames por segundo a una de 100?. La respuesta es otra pregunta:
¿por qué no utilizar esos frames de más para añadir detalle a la escena? Quizá se
podría incrementar el número de triángulos de los modelos, usar algunos shaders
más complejos o añadir efectos de post-proceso, como antialiasing o profundidad
de campo.
En este curso las optimizaciones se han dividido en dos tipos: optimización de
interiores y optimización de exteriores. En este tema vamos a ver la optimización de
interiores, que consiste en una serie de técnicas para discriminar qué partes se pintan
de una escena y cuáles no.
Una escena de interior es una escena que se desarrolla dentro de un recinto cerrado,
como por ejemplo en un edificio, o incluso entre las calles de edificios si no hay
grandes espacios abiertos.
Ejemplos claros de escenas interiores se dan en la saga Doom y Quake, de Id
Software, cuyo título Wolfenstein 3D fue precursor de este tipo de optimizaciones.
Sin el avance en la representación gráfica que supuso la división del espacio de
manera eficiente, estos títulos jamás hubieran sido concebidos. Fue el uso de árboles
BSP (2D y 3D, respectivamente) lo que permitió determinar qué parte de los mapas se
renderizaba en cada momento.
C26
Dado que esta complejidad es demasiado alta para una aplicación interactiva, se
hace necesario idear alguna forma para reducir el número de cálculos. Una forma
sencilla es reducir la lista de posibles oclusores. Los triángulos más cercanos a la
cámara tienen más posibilidades de ser oclusores que aquellos que están más alejados,
ya que estadísticamente estos van a ocupar más área de la pantalla. De este modo,
usando estos triángulos como oclusores y los lejanos como ocluidos, se podrá reducir
la complejidad hasta casi O(n). Si además se tiene en cuenta que el frustum de la
cámara tiene un límite (plano far), se podrán descartar aun más triángulos, tomando
como ocluidos los que estén más allá que este plano.
Este algoritmo podría beneficiarse del clipping y del culling previo de los
triángulos. No es necesario incluir en el algoritmo de oclusores ningún triángulo que
quede fuera del frustum de la vista actual, tampoco los triángulos que formen parte de
las caras traseras se pintarán, ni tendrán que formar parte de los oclusores. La pega es
que hay que realizar clipping y culling por software. Tras esto, se tendrían que ordenar
los triángulos en Z y tomar un conjunto de los n primeros, que harán de oclusores
en el algoritmo. Computacionalmente, llevar a cabo todas estas operaciones es muy Recursividad
caro. Una solución sería hacerlas sólo cada algunos frames, por ejemplo cuando la
Como casi todos los algoritmos de
cámara se moviese lo suficiente. Mientras esto no pasase, el conjunto de oclusores no construcción de árboles, el BSP es
cambiaría, o lo haría de manera mínima. un algoritmo recursivo. Se dice que
una función es recursiva cuando se
llama a sí misma hasta que se cum-
Listado 26.31: Actualización de los oclusores ple una condición de parada. Una
función que se llama a sí misma dos
1 const size_t maxOccluders = 300; veces se podrá representar con un
2 árbol binario; una que se llame n-
3 newPos = calculateNewCameraPosition(); veces, con un árbol n-ario.
26.6. Optimización de interiores [767]
Algoritmo BSP
La solución pasa por tomar los vértices del triángulo atravesado como segmentos
y hallar el punto de intersección de los mismos con el plano. Con esos puntos será
trivial reconstruir los triángulos resultantes.
La estructura de árbol BSP podría estar representada en C++ como en el listado
siguiente:
10 vector<Triangle>::iterator it;
11
12 for (it = vt.begin(); vt != vt.end(); ++it) {
13 if (cuts(bsp->p, *it))
14 split(*it, bsp->p);
15 }
16 vector<Triangle> frontSet = getFrontSet(vtNew, t);
17 vector<Triangle> backSet = getBackSet(vtNew, t);
18
19 bsp->front = createBSP(frontSet);
20 bsp->back = createBSP(backSet);
21
22 return bsp;
23 }
La función anterior pinta los triángulos (incluidos los que quedan detrás de la
vista) en orden, desde el más lejano al más cercano.
Esto era muy útil cuando el hardware no implementaba un Z-buffer, ya que está
función obtenía los triángulos ordenados con un coste linear.
Si cambiamos el algoritmo anterior (le damos la vuelta) recorreremos las caras
desde las más cercanas a las más lejanas. Esto sí puede suponer un cambio con el
hardware actual, ya que si pintamos el triángulo cuyo valor va a ser mayor en el Z-
buffer, el resto de los triángulos ya no se tendrán que pintar (serán descartados por el
hardware).
Clipping Jerárquico
Detección de la oclusión
También es posible utilizar un árbol BSP para detectar oclusiones. Este uso se
popularizó gracias al motor de Quake, que utilizaba un nuevo tipo de árbol llamado
leafy-BSP, donde se utilizaron por primera vez para el desarrollo de un videojuego. Su
propiedad principal es la de dividir de manera automática el conjuntos de triángulos
entrante en un array de celdas convexas.
Este nuevo tipo de árboles son BSPs normales donde toda la geometría se ha
propagado a las hojas, en vez de repartirla por todos los nodos a modo de triángulos
divisores. De este modo, en un BSP normal, las hojas sólo almacenan el último
triángulo divisor.
Para transformar un BSP en un leafy-BSP lo que hay que hacer es “agitar” el árbol
y dejar caer los triángulos de los nodos intermedios en las hojas (ver figura 26.42)
Una vez que el árbol se haya generado, se podrá almacenar la lista de triángulos
de cada nodo como una lista de celdas numeradas. Para el ejemplo anterior las celdas
se muestran en la figura 26.43.
Cada celda representa una zona contigua y convexa del conjunto de triángulos
inicial. Las paredes de las celdas pueden ser o bien áreas ocupadas por geometría del
nivel o espacios entre las celdas. Cada espacio abierto debe pertenecer exactamente a
dos celdas.
Nótese que el algoritmo anterior convierte cualquier conjunto de triángulos en una
lista de celdas y de pasillos que las conectan, que es la parte más complicada.
Es posible precalcular la visibilidad entre las celdas. Para ello se utilizan los
pasillos (o portales, aunque diferentes a los que se verán un poco más adelante). Se
mandan rayos desde algunos puntos en un portal hacia los demás, comprobándose si
llegan a su destino. Si un rayo consigue viajar del portal 1 al portal 2, significa que
las habitaciones conectadas a ese portal son visibles mutuamente. Este algoritmo fue
presentado por Teller [97].
Esta información sobre la visibilidad se almacenará en una estructura de datos
conocida como PVS (Potential Visibility Set), que es sólo una matriz de bits de N xN
que relaciona la visibilidad de la fila i (celda i) con la de la columna j (celda j).
Rendering
Para representar los niveles de Quake III: Arena se utilizaba un algoritmo más o
menos como el que se explica a continuación.
Portal Rendering
dirigido. Cada nodo del grafo es una habitación y cada vértice del grafo es un portal.
Ya que es posible que en una habitación se encuentren varios portales, es necesario
que la estructura de datos permita conectar un nodo con varios otros, o que dos nodos
estén conectados por dos vértices.
Figura 26.45: Grafo que representa las conexiones del mapa de habitaciones.
Figura 26.44: Mapa de habitacio-
nes conectadas por portales
Al contrario que los BSP, la geometría de un nivel no determina de manera
automática la estructura de portales. Así, será necesario que la herramienta que se use
como editor de niveles soporte la división de niveles en habitaciones y la colocación
de portales en los mismos. De esto modo, la creación de la estructura de datos
es un proceso manual. Estas estructuras no almacenan datos precalculados sobre la
visibilidad; esta se determinará en tiempo de ejecución.
El algoritmo de renderizado comienza por ver dónde se encuentra la cámara en un
momento dado, utilizando los bounding volumes de cada habitación para determinar
dentro de cuál está posicionada. Primero se pintará esta habitación y luego las que
estén conectadas por los portales, de forma recursiva, sin pasar dos veces por un
mismo nodo (con una excepción que se verá en el siguiente apartado). Lo complejo
del algoritmo es utilizar los portales para hacer culling de la geometría.
Es necesario detectar qué triángulos se pueden ver a través de la forma del portal,
ya que normalmente habrá un gran porcentaje no visible, tapados por las paredes que
rodean al mismo. Desde un portal se puede ver otro, y esto tendrá que tenerse en
cuenta al calcular las oclusiones. Se utiliza una variante de la técnica de view frustum
(que consiste en descartar los triángulos que queden fuera de un frustum, normalmente
el de la cámara), que Dalmau llama portal frustum. El frustum que se utilizará para
realizar el culling a nivel de portal tendrá un origen similar al de la cámara, y pasará
por los vértices del mismo. Para calcular las oclusiones de un segundo nivel en el
grafo, se podrá obtener la intersección de dos o más frustums.
Portal Un portal puede tener un número de vértices arbitrario, y puede ser cóncavo o
¿Tendrá algo que ver esta técnica
convexo. La intersección de dos portales no es más que una intersección 2D, en la
con algún juego de ValveTM ? Gra- que se comprueba vértice a vértice cuáles quedan dentro de la forma de la recursión
cias a ella el concepto de una de sus anterior. Este algoritmo puede ralentizar mucho la representación, puesto que el
franquicias ha sido posible. número de operaciones depende del número de vértices, y la forma arbitraria de los
portales no ayuda a aplicar ningún tipo de optimizaciones.
Luebke y Jobes [59] proponen que cada portal tenga asociada un bounding
volume, que simplificará enormemente los cálculos. Este bounding volume rodea a
todos los vértices por portal, lo que hará que el algoritmo de como visibles algunos
C26
¿Se le ocurre algún tipo de efecto más o alguna otra forma de aprovechar las
características de los portales?
Dalmau afirma que con esta técnica se evita pintar de media entre un 40 % y un
60 % de toda la geometría entrante.
Enfoques híbridos
Quadtree-BSP
Hay juegos que poseen escenarios gigantestos, con un área de exploración muy
grande. Si se enfoca la partición de este tipo de escenas como la de un árbol
BSP, el gran número de planos de división hará que crezca la geometría de manera
exponencial, debido a los nuevos triángulos generados a partir de la partición.
Una forma de afrontar este problema es utilizar dos estructuras de datos. Una
de ellas se usará para realizar una primera división espacial de la superficie (2D, un
Quadtree, por ejemplo) y la otra para una división más exhaustiva de cada una de esas
particiones. De esto modo, se podrá utilizar un Quadtree donde cada nodo contiene un
BSP.
De este modo, se pueden utilizar las características especiales de cada uno de
ellos para acelerar la representación. En un primer paso, el Quadtree facilitará la
determinación de la posición global de una manera muy rápida. Una vez que se sepa en
qué parte del escenario se encuentra la acción, se tomará el BSP asociado a la misma
y se procederá a su representación como se mostró en el apartado anterior.
Este tipo de representaciones espaciales más complejas no son triviales, pero a
veces son necesarias para llevar a cabo la implementación exitosa de un videojuego.
En el siguiente capítulo ese introducirán los quadtrees.
26.6. Optimización de interiores [777]
Las tarjetas gráficas actuales proveen de mecanismos para llevar a cabo los
cálculos de detección de la oclusión por hardware. Estos mecanismos consisten en
llamadas a funciones internas que reducirán la complejidad del código. El uso de
estas llamadas no evitará la necesidad de tener que programar pruebas de oclusión,
pero puede ser una ayuda bastante importante.
Hardware La utilización del hardware para determinar la visibilidad se apoya en pruebas
sobre objetos completos, pudiendo rechazar la inclusión de los triángulos que los
El uso del hardware para realizar
los tests de oclusión es el futuro, forman antes de entrar en una etapa que realice cálculos sobre los mismos. Así, las
pero eso no quita que se deban co- GPUs actuales proveen al programador de llamadas para comprobar la geometría de
nocer las técnicas en las que se ba- objetos completos contra el Z-buffer. Nótese como estas llamadas evitarán mandar
sa para poder utilizarlo de manera estos objetos a la GPU para se pintados, ahorrando las transformaciones que se
efectiva.
producen antes de ser descartados. Además, como retorno a dichas llamadas se puede
obtener el número de píxeles que modificría dicho objeto en el Z-buffer, lo que
permitiría tomar decisiones basadas en la relevancia del objeto en la escena.
Cabe destacar que si se usa la geometría completa del objeto, mandando todos
los triángulos del mismo al test de oclusión de la GPU, el rendimiento global podría
incluso empeorar. Es algo normal, puesto que en una escena pueden existir objetos con
un gran número de triángulos. Para evitar este deterioro del rendimiento, y utilizar esta
capacidad del hardware en beneficio propio, lo más adecuado es utilizar bounding-
boxes que contengan a los objetos. Una caja tiene tan solo 12 triángulos, permitiendo
realizar tests de oclusión rápidos y bastante aproximados. Es fácil imaginarse la
diferencia entre mandar 12 triángulos o mandar 20000.
Además, si las pruebas de oclusión para todos los objetos se llevan a cabo de forma
ordenada, desde los más cercanos a los más lejanos, las probabilidades de descartar
algunos de ellos aumentan.
Como ejemplo, uno tomado de las especificaciones de occlusion query de las
extensiones de OpenGL [69].
El grafo de escena que se utiliza en OGRE se conoce como scene manager (gestor
de escena) y está representado por la clase Scene-Manager. El interfaz de la misma
lo implementan algunos de los gestores de escena que incluye OGRE, y también
algunos otros desarrollados por la comunidad o incluso comerciales.
En OGRE es posible utilizar varios gestores de escena a la vez. De esto modo,
es posible utilizar uno de interiores y de exteriores a la vez. Esto es útil por ejemplo
cuando desde un edificio se mira por una ventana y se ve un terreno.
Un gestor de escena de OGRE es responsable entre otras cosas de descartar los
objetos no visibles (culling) y de colocar los objetos visibles en la cola de renderizado.
Interiores en OGRE
El gestor de escena para interiores de OGRE está basado en BSP. De hecho, este
gestor se utiliza con mapas compatibles con Quake 3. Hay dos formas de referirse a
este gestor, la primera como una constante de la enumeración Ogre::SceneType
(ST_INTERIOR) y otra como una cadena ("BspSceneManager") que se refiere
al nombre del Plugin que lo implementa. La forma más moderna y la preferida es la
✄ ✄
segunda.
En la línea ✂9 ✁se crea el SceneManager de BSP, y en la línea ✂30 ✁se carga el
mapa y se usa como geometría estática.
Cabe destacar que para poder navegar por los mapas BSP de manera correcta hay
que rotar 90 grados en su vector pitch y cambiar el vector up de la cámara por el eje
Z.
26.7.1. Introducción
Las diferencias entre una escena de interiores y una de exteriores son evidentes.
Mientras una escena de interiores se dará en entornos cerrados, con muchas paredes o
pasillos que dividen en espacio en habitaciones, una escena de exteriores normalmente
no tiene ningún límite que no esté impuesto por la naturaleza. Si bien es cierto que es
una escena de este tipo, por ejemplo, pudiesen existir colinas que se tapasen unas a
las otras, si estamos situados frente a algunas de ellas en una posición muy lejana, el
número de triángulos que se deberían representar sería tan elevado que quizá ningún
hardware podría afrontar su renderizado.
Está claro que hay que afrontar la representación de exteriores desde un enfoque
diferente: hacer variable el nivel de detalle LOD). De este modo, los detalles de una
montaña que no se verían a cierta distancia no deberían renderizarse. En general, el
detalle de los objetos que se muestran grandes en la pantalla (pueden ser pequeños
pero cercanos), será mayor que el de los objetos menores.
Si bien el nivel de detalle es importante, tampoco se descarta el uso de oclusiones
en algunos de los algoritmos que se presentarán a continuación, siguiendo de nuevo
la propuesta de Dalmau. Se comenzará haciendo una pasada rápida por algunas de las
estructuras de datos necesarias para la representación de exteriores eficiente.
Los mapas de altura (heigtfields o heightmaps) han sido utilizados desde hace
mucho tiempo como forma para almacenar grandes superficies. No son más que
imágenes en las que cada uno de sus píxeles almacenan una altura.
Cuando se empezó a usar esta técnica, las imágenes utilizaban tan solo la escala
de grises de 8-bit, lo que suponía poder almacenar un total de 256 alturas diferentes.
Los mapas de altura de hoy en día pueden ser imágenes de 32-bit, lo que permite
que se puedan representar un total de 4.294.967.296 alturas diferentes, si se usa el Figura 26.48: Mapa de altura ren-
derizado (W IKIMEDIA C OMMONS)
canal alpha.
26.7. Optimización de Exteriores [781]
Quadtrees
Un quadtree es un árbol donde cada nodo tendrá exactamente cuatro hijos, así, se
dice que es un árbol 4-ario. Un quadtree divide un espacio en cuatro partes iguales por
cada nivel de profundidad del árbol (figura 26.49).
Un quadtree permite que ciertas áreas del terreno se puedan representar con más
detalle puesto que es posible crear árboles no equilibrados, utilizando más niveles
C26
donde sea necesario. Una aproximación válida es comenzar con un mapa de altura y
crear un quadtree a partir del mismo. Las partes del escenario donde exista más detalle
(en el mapa de altura, habrá muchas alturas diferentes), se subdividirán más veces.
GeoMipmapping
View-Frustum Culling
Será necesario descartar los pedazos de terreno que no caigan dentro del frustum
de la vista (cámara) puesto que no serán visibles. Para ello, lo ideal es utilizar un
quadtree.
El quadtree será precalculado antes de comenzar la parte interactiva de la
aplicación y consistirá tan solo en bounding boxes de tres dimensiones, que a su vez
contendrán otras correspondientes a los subnodos del nodo padre. En cada hoja del
árbol quedará un pedazo del nivel 0 de divisiones que se ha visto al principio. Es
suficiente utilizar quadtrees y no octrees ya que la división se realiza de la superficie,
y no del espacio.
Para descartar partes del terreno, se recorrerá el árbol desde la raíz, comprobando
si la bounding box está dentro del frustum al menos parcialmente, y marcando dicho
nodo en caso afirmativo. Si una hoja está marcada, quiere decir que será visible y que
se mandará a la cola de representación. A no ser que el terreno sea muy pequeño,
se terminarán mandando muchos triángulos a la cola, y esta optimización no será Figura 26.54: Ejemplo de una
suficiente. Es aquí donde De Boer introduce el concepto de Geomipmapping. bounding-box (M ATH I MAGES
P ROJECT ).
26.7. Optimización de Exteriores [785]
Esta técnica se basa en el hecho de que los bloques que están más lejos de la
cámara no necesitan representarse con tan nivel de detalle como los más cercanos.
De este modo, podrán ser representados con un número mucho menor de triángulos,
lo que reducirá enormemente el número de triángulos del terreno que se mandarán a
la cola de renderizado. Otro algorítmos utilizan una aproximación en la que hay que
analizar cada triángulo para poder aplicar una política de nivel de detalle. Al contrario,
esta técnica propone una política discreta que se aplicará en un nivel más alto.
La técnica clásica de mipmapping se aplica a las texturas, y consiste en la
generación de varios niveles de subtexturas a partir de la original. Este conjunto de
texturas se utilizan en una política de nivel de detalle para texturas, usando unas u
otras dependiendo de la distancia a la cámara. Esta es la idea que se va a aplicar a la
mallas 3D de terrenos.
Cada bloque de terreno tendrá asociado varios mipmaps, donde el original corres-
ponde al bloque del mapa de altura. Estos GeoMipMaps pueden ser precalculados y
Figura 26.55: Construcción de los almacenados en memoria para poder ser utilizados en tiempo de ejecución directa-
mipmaps. En cada nivel, la textura mente.
se reduce a un cuarto de su área.
(A KENINE -M OLLER)
Figura 26.56: Diferentes niveles de detalle de la malla desde los GeoMipMaps : niveles 0, 1 y 2.
Para elegir qué geomipmap es adecuado para cada distancia y evitar saltos
(producto de usar distancias fijas para cada uno) habrá que utilizar un método un poco
más elaborado. En el momento en que se se pase del nivel 0 al 1 (ver figura 26.56)
existirá un error en la representación del terreno, que vendrá dado por la diferencia
en altura entre ambas representaciones. Debido a la perspectiva, la percepción del
error tendrá que ver con los píxeles en pantalla a la que corresponda esa diferencia.
Ya que cada nivel tiene muchos cambios en altura, se utilizará el máximo, que podrá
almacenarse durante la generación para realizar decisiones más rápidas. Si el error en
píxeles cometido es menor que un umbral, se utilizará un nivel más elevado.
Hay que tener en cuenta que la mayoría de las ocasiones el terreno estará formado
por bloques con deferentes niveles, lo que puede hacer que existan vértices no
conectados. Será necesario reorganizar las conexiones entre los mismos, creando
nuevas aristas.
En la figura 26.57 se muestra una propuesta que consiste en conectar los vértices
de la malla de nivel superior con los de la de nivel inferior pero saltando un vértice
cada vez.
C26
ROAM
De este modo se construye un árbol que representa la malla, donde cada nivel
del mismo corresponde a un conjunto de triángulos que pueden ser encolados para
su renderizado. El árbol podrá expandirse en tiempo real si fuera necesario. Bajar un
nivel en el árbol equivale a una operación de partición y subir a una de unión.
El problema viene de la unión de regiones triangulares con diferente nivel de
detalle, ya que si no se tienen en cuenta aparecerán agujeros en la malla representada.
Mientras que en el geomipmapping se parchea la malla, en ROAM, cuando se detecta
una discontinuidad se utiliza un oversampling de los bloques vecinos para asegurar
que están conectados de manera correcta. Para ello, se añade información a cada lado
de un triángulo y se utilizan alguna reglas que garantizarán la continuidad de la malla.
Se conoce como vecino base de un triángulo al que está conectado a este a través
de la hipotenusa. A los otros dos triángulos vecinos se los conocerá como vecino
izquierdo y derecho (figura 26.59). Analizando diferentes árboles se deduce que el
vecino base será del mismo nivel o del anterior (menos fino) nivel de detalle, mientras Figura 26.59: Etiquetado de un
que los otros vecinos podrán ser del mismo nivel o de uno más fino. triángulo en ROAM. El triángulo
junto a su vecino base forman un
diamante.
26.7. Optimización de Exteriores [787]
Las reglas propuestas para seguir explorando el árbol y evitar roturas en la malla
serán las siguientes:
Figura 26.60: Partición recursiva El algoritmo en tiempo de ejecución recorrerá el árbol utilizando alguna métrica
hasta encontrar un diamante para el para determinar cuándo tiene que profundizar en la jerarquía, y cómo ha de realizar
nodo de la izquierda. las particiones cuando lo haga dependerá de las reglas anteriores.
Realizar en tiempo real todos estos cálculos es muy costoso. Para reducir este
coste, se puede dividir el terreno en arrays de BTTs y sólo recalcular el árbol cada
ciertos frames y cuando la cámara se haya movido lo suficiente para que cambie la
vista por encima de un umbral.
Otra aproximación para optimizar el algoritmo es utilizar el frustum para deter-
minar qué nodos hay que reconstruir de nuevo, marcando los triángulos que quedan
dentro o fuera, o parcialmente dentro. Sólo estos últimos necesitarán atención para
mantener el árbol actualizado. A la hora de representarlo, lo único que habrá que ha-
cer es mandar los nodos marcados como dentro, total o parcialmente.
Chunked LODs
Figura 26.61: Nodo y subnodos de las texturas en un quadtree para Chunked LODs.
C26
A cada uno de los nodos se le añade información acerca de la pérdida del nivel de
detalle se produce al subir un nivel en la jerarquía. Si las hojas contienen imágenes de
32x32 píxeles, los pedazos de terreno contendrán 32x32 vértices. El árbol de mallas
también forma parte del preproceso normalmente, partiendo de una malla muy grande
y con mucha resolución y partiéndola en trozos más pequeños.
[788] CAPÍTULO 26. REPRESENTACIÓN AVANZADA
Terrenos y GPU
Scenegraphs de Exteriores
Una escena de exteriores es mucho más grande que una de interiores. La cantidad
de datos que es necesario manejar es en consecuencia gigantesca en comparaciones
con escenas mucho más pequeñas. Aunque se tenga una buena política de nivel de
detalle, hay que tener en cuenta que el número de triángulos totales que se tendrán
que manejar es enorme.
Para implementar un grafo de escena de exteriores es importante tener en cuenta
que:
Cada objeto sólo tendrá una instancia, y lo único que se almacenará aparte de
esta serán enlaces a la misma.
Es necesario implementar alguna política de nivel de detalle, puesto que es
imposible mostrar por pantalla (e incluso mantener en memoria) absolutamente
todos los triángulos que se ven en una escena.
Se necesita una rutina muy rápida para descartar porciones no visibles del
terreno y del resto de objetos de la escena.
26.7. Optimización de Exteriores [789]
Mientras que para almacenar un terreno y las capas estáticas que lleva encima es
muy buena opción utilizar un quadtree, será mejor utilizar alguna tabla de tipo rejilla
para almacenar la posición de los objetos dinámicos para determinar cuáles de ellos
se pintar en cada fotograma de manera rápida.
Terrenos
✄
En la lineas ✂3-5 ✁se configura niebla en
✄ la escena, usando un color oscuro para
hacer que parezca oscuridad. En las líneas ✂7-14 ✁se configura una luz direccional que
✄ en el terreno. A partir de ella se calcularán las sombras
se utilizará ✄ en el mismo. En
la línea ✂16 ✁se configura la luz ambiental de la escena. En la ✂19 ✁se crea✄ un nuevo
objeto de configuración del terreno, que se rellenará más adelante. En la ✂20 ✁se crea
un objeto agrupador de terrenos. Este objeto es el responsable de agrupar diferentes
✄ del terreno juntos para que puedan ser representados como proceda. En la
pedazos
línea ✂26 ✁se configura el error máximo✄ medido en píxeles de pantalla que se permitirá
al representar el terreno. En las líneas ✂28-31 ✁se configura la textura que compodrá con
él, dibujando las sombras. Primero se determina la distancia de representación de las
luces, luego se selecciona la dirección de las sombras, que parte de una luz direccional
y luego se configuran el valor ambiental y difuso de iluminación.
17
18
19 _tGlobals = OGRE_NEW Ogre::TerrainGlobalOptions();
20 _tGroup = OGRE_NEW Ogre::TerrainGroup(_sMgr,
21 Ogre::Terrain::ALIGN_X_Z,
22 513, 12000.0f);
23
24 _tGroup->setOrigin(Ogre::Vector3::ZERO);
25
26 _tGlobals->setMaxPixelError(8);
27
28 _tGlobals->setCompositeMapDistance(3000);
29 _tGlobals->setLightMapDirection(light->getDerivedDirection());
30 _tGlobals->setCompositeMapAmbient(_sMgr->getAmbientLight());
31 _tGlobals->setCompositeMapDiffuse(light->getDiffuseColour());
32
33 Ogre::Terrain::ImportData& di;
34 di = _tGroup->getDefaultImportSettings();
35
36 di.terrainSize = 513;
37 di.worldSize = 12000.0f;
38 di.inputScale = 600;
39 di.minBatchSize = 33;
40 di.maxBatchSize = 65;
41
42 di.layerList.resize(3);
43 di.layerList[0].worldSize = 100;
44 di.layerList[0].textureNames.push_back("dirt_diff_spec.png");
45 di.layerList[0].textureNames.push_back("dirt_normal.png");
46 di.layerList[1].worldSize = 30;
47 di.layerList[1].textureNames.push_back("grass_diff_spec.png");
48 di.layerList[1].textureNames.push_back("grass_normal.png");
49 di.layerList[2].worldSize = 200;
50 di.layerList[2].textureNames.push_back("growth_diff_spec.png");
51 di.layerList[2].textureNames.push_back("growth_normal.png");
52
53
54 Ogre::Image im;
55 im.load("terrain.png",
56 Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME);
57
58 long x = 0;
59 long y = 0;
60
61 Ogre::String filename = _tGroup->generateFilename(x, y);
62 if (Ogre::ResourceGroupManager::getSingleton().resourceExists(
_tGroup->getResourceGroup(), filename))
63 {
64 _tGroup->defineTerrain(x, y);
65 }
66 else
67 {
68 _tGroup->defineTerrain(x, y, &im);
69 _tsImported = true;
70 }
71
72 _tGroup->loadAllTerrains(true);
73
74 if (_tsImported)
75 {
76 Ogre::TerrainGroup::TerrainIterator ti;
77 ti = _tGroup->getTerrainIterator();
C26
78 while(ti.hasMoreElements())
79 {
80 Ogre::Terrain* t = ti.getNext()->instance;
81 initBlendMaps(t);
82 }
83 }
84
85 _tGroup->freeTemporaryResources();
86
[792] CAPÍTULO 26. REPRESENTACIÓN AVANZADA
87 Ogre::Plane plane;
88 plane.d = 100;
89 plane.normal = Ogre::Vector3::NEGATIVE_UNIT_Y;
90
91 _sMgr->setSkyPlane(true, plane,"Skybox/SpaceSkyPlane",
92 500, 20, true, 0.5, 150, 150);
93
94 }
95
96 void MyApp::initBlendMaps(Ogre::Terrain* t)
97 {
98 Ogre::TerrainLayerBlendMap* blendMap0 = t->getLayerBlendMap(1);
99 Ogre::TerrainLayerBlendMap* blendMap1 = t->getLayerBlendMap(2);
100
101 Ogre::Real minHeight0 = 70;
102 Ogre::Real fadeDist0 = 40;
103 Ogre::Real minHeight1 = 70;
104 Ogre::Real fadeDist1 = 15;
105
106 float* pBlend1 = blendMap1->getBlendPointer();
107 for (Ogre::uint16 y = 0; y < t->getLayerBlendMapSize(); ++y) {
108 for (Ogre::uint16 x = 0; x < t->getLayerBlendMapSize(); ++x){
109 Ogre::Real tx, ty;
110
111 blendMap0->convertImageToTerrainSpace(x, y, &tx, &ty);
112 Ogre::Real height = t->getHeightAtTerrainPosition(tx, ty);
113 Ogre::Real val = (height - minHeight0) / fadeDist0;
114 val = Ogre::Math::Clamp(val, (Ogre::Real)0, (Ogre::Real)1);
115 val = (height - minHeight1) / fadeDist1;
116 val = Ogre::Math::Clamp(val, (Ogre::Real)0, (Ogre::Real)1);
117 *pBlend1++ = val;
118 }
119 }
120 blendMap0->dirty();
121 blendMap1->dirty();
122 blendMap0->update();
123 blendMap1->update();
124 }
✄
En ✂34 ✁ se obtiene la instancia que configura algunos parámetros del terreno.
El primero es el tamaño del terreno, que corresponde al número de píxeles del
mapa de altura. El segundo el tamaño total del mundo, en unidades virtuales. El
tercero (inputScale) corresponde a la escala aplicada al valor del píxel, que se
transformará en la altura del mapa. Las dos siguientes corresponden al tamaño de
los bloques de terrenos que se incluirán en la jerarquía (diferentes LOD). Estos tres
últimos valores tendrán que ser del tipo 2n + 1. El atributo layerList contiene un
vector de capas, que se rellenará con las texturas (color y mapa de normales en este
✄ worldSize corresponde a la relación de tamaño entre la textura y
caso). El atributo
el mundo. En ✂55 ✁se carga la imagen con el mapa de altura.
Las siguientes líneas son las que asocian el mapa de altura con el terreno que se va
✄
a generar. En este ejemplo sólo se genera el subterreno (0,0) con lo que sólo se cargará
una imagen. En las siguientes líneas ✂61-70 ✁se define el terreno, en este caso, sólo para
el slot (0,0), aunque el ejemplo está preparado para ser extendido fácilmente y añadir
la definición algunos más. La llamada defineTerrain() expresa la intención de
✄ que contiene el mapa de altura. La ejecución
crear ese pedazo de terreno con la imagen
de este deseo se realiza en la linea ✂72 ✁. Sin esta llamada, el ejemplo no funcionaría
puesto que se ejecutaría el siguiente paso sin haber cargado (generado) el terreno.
26.7. Optimización de Exteriores [793]
✄
En el bloque ✂74-83 ✁se crea la capa de blending, esto es, la fusión entre las tres
texturas del terreno (habrá dos de blending). Esta operación se realiza en el
✄ mapas
método initBlendMaps ✂96-124 ✁. Dependiendo de la altura a la que corresponda
un píxel de las imágenes a fusionar (el tamaño ha de ser coherente con el mapa de
altura) así será el valor de blending aplicado, con lo que se conseguirá que cada altura
✄ ✄
presente una textura diferente.
En las líneas ✂87-89 ✁se añade un plano, y justo debajo, en la ✂91 ✁se usa como un
Skyplane.
Ejemplo de Skybox
material SkyBoxCEDV
{
technique
{
pass
{
lighting off
depth_write off
C26
texture_unit
{
cubic_texture cubemap_fr.jpg cubemap_bk.jpg cubemap_lf.jpg\
cubemap_rt.jpg cubemap_up.jpg cubemap_dn.jpg separateUV
tex_address_mode clamp
}
}
[794] CAPÍTULO 26. REPRESENTACIÓN AVANZADA
}
}
nombre de los archivos que queda delate de “_”. En el caso anterior, sería cubemap.jpg. El
último parámetro puede ser combinedUVW, si en una misma imagen están contenidas las seis
caras, o separateUV si se separan por imágenes. La primera forma utilizará coordenadas de
textura 3D, la segunda usará coordenadas 2D ajustando cada imagen a cada una de las caras.
Usando tex_address_mode clamp hará que los valores de coordenadas de texturizado
mayores que 1,0 se queden como 1,0.
Para utilizar una Skybox simplemente habrá que ejecutar esta línea:
Como cabría esperar, la skybox se configura para el gestor de escenas. Ésta se encontrará
a una distancia fija de la cámara (el tercer parámetro), y podrá estar activada o no (primer
parámetro).
Ejemplo de SkyDome
Aunque sólo se utiliza una textura, realmente una SkyDome está formada por las cinco
caras superiores de un cubo. La diferencia con una skybox es la manera en la que se proyecta
la textura sobre dichas caras. Las coordenadas de texturizado se generan de forma curvada y
por eso se consigue tal efecto de cúpula. Este tipo de objetos es adecuado cuando se necesita
un cielo más o menos realista y la escena no va a contener niebla. Funcionan bien con texturas
repetitivas como las de nubes. Una curvatura ligera aportará riqueza a una escena grande y una
muy pronunciada será más adecuada para una escena más pequeña donde sólo se vea pedazos
de cielo de manera intermitente (y el efecto exagerado resulte atractivo).
Un ejemplo de material es el siguiente:
material SkyDomeCEDV
{
[...]
texture_unit
{
texture clouds.jpg
scroll_anim 0.15 0
}
[...]
}
Ejemplo de SkyPlane
Se propone crear una escena con cada uno de las tres técnicas anteriores. Si
fuera posible, utilice el ejemplo de terreno anterior.
Materiales
En los materiales no sólo se remite a las texturas sino a todas las propiedades
editables dentro del bloque technique. Para configurar un material con soporte para
LOD lo primero es configurar la estrategia (lod_strategy) que se va a utilizar de
las dos disponibles:
Tras esto, lo siguiente es determinar los valores en los que cambiará el nivel de de-
talle para dicha estrategia. Se usará para tal labor la palabra reservada lod_values
seguida de dichos valores. Es importante destacar que tendrán que existir al menos
tantos bloques de technique como valores, ya que cada uno de ellos estará relacio-
nado con el otro respectivamente. Cada uno de los bloques technique debe tener
asociado un índice para el nivel de detalle (lod_index). Si no aparece, el índice se-
rá el 0 que equivale al mayor nivel de detalle, opuestamente a 65535 que corresponde
con el menor. Lo normal es sólo tener algunos niveles configurados y probablemente
jamás se llegue a una cifra tan grande. Aun así, es importante no dejar grandes sal-
C26
tos y hacer un ajuste más o menos óptimo basado en las pruebas de visualización de
la escena. Es posible que varias técnicas tengan el mismo índice de nivel de detalle,
siendo OGRE el que elija cuál es la mejor de ellas según el sistema en el que se esté
ejecutando.
Un ejemplo de material con varios niveles de detalle:
material LOD_CEDV
[796] CAPÍTULO 26. REPRESENTACIÓN AVANZADA
{
lod_values 200 600
lod_strategy Distance
technique originalD {
lod_index 0
[...]
}
technique mediumD {
lod_index 1
[...]
}
technique lowD {
lod_index 2
[...]
}
technique shaders {
lod_index 0
lod_index 1
lod_index 2
[...]
}
}
Como ejercicio se propone construir una escena que contenga 1000 objetos
con un material con tres niveles de detalle diferente. Siendo el nivel 0 bastante
complejo y el nivel 3 muy simple. Muestre los fotogramas por segundo y
justifique el uso de esta técnica. Se sugiere mostrar los objetos desde las
diferentes distancias configuradas y habilitar o deshabilitar los niveles de
detalle de baja calidad.
Modelos
OGRE proporciona tres formas de utilizar LOD para mallas. La primera es una
donde modelos con menos vértices se generan de forma automática, haciendo uso
de progressiveMesh internamente. Para utilizar la generación automática será
necesario que la malla que se cargue no tenga ya de por sí LOD incluido (por ejemplo,
la cabeza de ogro de las demos ya contiene esta información y no se podrá aplicar).
Una vez que la entidad está creada, se recuperará la instancia de la malla contenida
dentro.
✄
En la línea ✂3 ✁ del ejemplo anterior se crea una lista de distancias para el
nivel de detalle. Esta lista no es más que un vector de reales que determina
los valores que se utilizarán según la estrategia elegida para esa entidad. Por
defecto corresponde con la distancia y en ese caso los valores corresponden a las
raíces cuadradas de la distancia en la que se producen cambios. La estrategia se
puede cambiar con setLodStrategy() usando como parámetro un objeto de
tipo base lodStrategy, que será DistanceLodStrategy o PixelCount-
LodStrategy. Estas dos clases son singletons, y se deberá obtener su instancia
utilizando getSingleton(). La segunda de ellas corresponde con la política
basada en el número de píxeles que se dibujan✄ en la pantalla de una esfera que contiene
al objeto (bounding-sphere). En la línea ✂12 ✁se llama a la función que genera los
diferentes niveles a partir de la malla original. El segundo parámetro es corresponde
con el método que se usará para simplificar la malla:
26.7.6. Conclusiones
En los dos últimas secciones se ha realizado una introducción a algunas de las
técnicas utilizadas para aumentar el rendimiento de las representaciones en tiempo
real. Sin algunas de ellas, sería del todo imposible llevar a cabo las mismas. Si
bien es cierto que cualquier motor gráfico actual soluciona este problema, brindando
al programador las herramientas necesarias para pasar por alto su complejidad, se
debería conocer al menos de forma aproximada en qué consisten las optimizaciones
relacionadas.
Saber elegir la técnica correcta es importante, y conocer cuál se está utilizando
también. De esto modo, será posible explicar el comportamiento del juego en
determinadas escenas y saber qué acciones hay que llevar a cabo para mejorarla.
Por desgracia, OGRE está cambiando alguno de los gestores de escena y se está
haciendo evidente en la transición de versión que está sucediendo en el momento de
escribir esta documentación.
La forma de realizar optimizaciones de estos tipos ha cambiado con el tiempo,
pasando de utilizar el ingenio para reducir las consultas usando hardware genérico, a
usar algoritmos fuertemente apoyados en la GPU.
Sea como sea, la evolución de la representación en tiempo real no sólo pasa
por esperar a que los fabricantes aceleren sus productos, o que aparezca un nuevo
paradigma, sino que requiere de esfuerzos constantes para crear un código óptimo,
usando los algoritmos correctos y eligiendo las estructuras de datos más adecuadas
para cada caso.
Capítulo
Plataformas Móviles
27
Miguel García Corchero
U
Motores de videojuego n motor de videojuegos es un termino que hace referencia a una serie de
herramientas que permiten el diseño, la creación y la representación de un
Existen otros motores de videojue-
gos más utilizados en la industria videojuego. La funcionalidad básica de un motor es proveer al videojuego
como CryEngine o Unreal Engine renderización, gestión de físicas, colisiones, scripting, animación, administración de
y tienen más funcionalidades que memoria o gestión del sonidos entre otras cosas.
Unity3D pero también es más com-
plejo trabajar con ellos. En este capítulo se trata el caso específico del motor de videojuegos Unity3D y se
realizará un repaso superficial por la forma de trabajar con un motor de videojuegos
mientras se realiza un videjuego de ejemplo para dispositivos móviles con el sistema
operativo iOS o Android.
El videojuego será un shoot’em up de aviones con vista cenital.
799
[800] CAPÍTULO 27. PLATAFORMAS MÓVILES
Importación de assets: Uno de los pasos iniciales dentro del entorno integrado Los scripts que utilizamos se pue-
den escribir en los lenguajes C#, Ja-
de Unity3D es añadir al proyecto todo el material generado anteriormente y vascript o BOO.
ajustar sus atributos; como formatos, tamaños de textura, ajuste de propiedades,
calculo de normales, etc.
Creación de escenas: Crearemos una escena por cada nivel o conjunto de
menús del videojuego. En la escena estableceremos relaciones entre objetos y
crearemos instancias de ellos.
Creación de prefabs: Los prefabs son agrupaciones de objetos que se salvan
como un objeto con entidad propia.
Optimización de la escena: Uno de los pasos fundamentales se lleva a cabo
al final del desarrollo de la escena y es la optimización. Para ello emplearemos
técnicas de lightmapping y occlusion culling con las herramientas del entorno.
1. Una escena por cada nivel del juego: Utilizaremos este enfoque cuando cada
nivel tenga elementos diferentes. Podrán repetirse elementos de otros niveles,
pero trataremos que estos elementos sean prefabs para que si los modificamos
en alguna escena, el cambio se produzca en todas.
27.2. Creación de escenas [801]
Figura 27.3: En nuestro ejemplo se han utilizado capturas de pantalla de google map para obtener texturas
de terreno y se han mapeado sobre un plano en Blender. Posteriormente se ha utilizado el modo scuplt para
dar relieve al terreno y generar un escenario tridimensional.
Figura 27.4: Interface de Unity3D. Dividida en las vistas más utilizadas: Jerarquía de escena, Assets del
proyecto, Vista del videojuego, Vista de escena y Vista de propiedades del asset seleccionado.
Como las escenas pueden cargarse desde otra escena. Podemos realizar un cambio
de escena cuando se ha llegado al final de un determinado nivel por ejemplo. Este
cambio de escena mantendrá en memoria los elementos comunes como texturas o
modelos 3D o sonidos que pertenezcan a los dos escenas, la escena que se descarga y
la escena que se carga, por lo que dependiendo del caso esta carga suele ser bastante
rápida. También podemos realizar los menús en una escena y desde ahí cargar la
escena del primer nivel del juego.
Figura 27.6: Para la escena de nuestro ejemplo hemos añadido el modelo 3D del escenario, los enemigos,
el jugador y hemos establecido las relaciones de jerarquía necesarias entre estos elementos.
Los scripts tienen algunos métodos especiales que podemos implementar como:
Update: Este método es invocado por el motor gráfico cada vez que el objeto
va a ser renderizado.
Start: Este método es invocado por el motor gráfico cuando se instancia el
objeto que contiene a este script.
En nuestro escenario se han añadido nubes modeladas mediante planos y con una
textura de una nube con transparencias. Para darle mayor realismo a este elemento se
va a programar un script para mover las coordenadas u,v del mapeado de este plano
provocando que la textura se mueve sobre el plano y simulando un movimiento de
nubes.
C27
3 function Start(){
4 //Realizar una llamada al método destruir pasados los segundos
de timeOUT
5 Invoke ("Destruir", timeOut);
6 }
7
8 function Destruir(){
9 //Destruir el objeto que contiene este script
10 DestroyObject (gameObject);
11 }
1 #pragma strict
2
3 var SonidoDisparo : AudioSource;
4 var SonidoExplosionAire : AudioSource;
5 var SonidoExplosionSuelo : AudioSource;
6 var SonidoVuelo : AudioSource;
7 var MusicaJuego : AudioSource;
[808] CAPÍTULO 27. PLATAFORMAS MÓVILES
20 function Awake(){
21 //Hacemos que el juego corra a 60 FPS como máximo
22 Application.targetFrameRate = 60;
23 }
24
25 function Start(){
26 //Obtenemos el puntero a Sonidos y ajustamos algunos valores
iniciales
27 var SonidosPointer : Transform = (GameObject.FindWithTag("
Sonidos")).transform;
28 SonidosStatic = (SonidosPointer.GetComponent("Sonidos") as
Sonidos);
29 SonidosStatic.PlayMusicaJuego();
30 SonidosStatic.PlaySonidoVuelo();
31 ScoreGUI.text="Score : "+Score;
32 }
33
34 function Update () {
35 if (enZonaFinal && velocidadCamara>0.0){
36 //Si estamos en la zona final paramos el movimiento de
manera gradual
37 velocidadCamara*=0.95;
38 if (velocidadCamara<0.1) { velocidadCamara=0; }
39 }
40
41 if (velocidadCamara>0.0){
42 //Movemos la cámara en su componente z para hacer scroll
43 Camara.position.z+=Time.deltaTime * velocidadCamara;
44 }
45 }
46
47 function EntrarEnZonaFinal(){
48 //Se ha entrado en el trigger de la zona final
49 enZonaFinal=true;
50
51 SonidosStatic.StopMusicaJuego();
52 SonidosStatic.PlayMusicaFinal();
53 }
54
55 function FinDeJuegoGanando(){
56 //Fin de partida cuando hemos completado la misión
57 FondoFinal.texture = TexturaFinalBien;
58 Restart();
59 }
60
61 function FinDeJuegoPerdiendo(){
62 //Fin de partida cuando hemos fallado la misión
63 FondoFinal.texture = TexturaFinalMal;
64 Restart();
65 }
66
67 function AddScore(valor : int){
68 //Añadimos puntos, por lo que hay que hacer la suma y
actualizar el texto
69 Score+=valor;
70 ScoreGUI.text="Score : "+Score;
71 }
72
73 function Restart(){
74 //Ocultamos los textos y botones
75 LifeGUI.enabled=false;
76 ScoreGUI.enabled=false;
77 BotonDISPARO.enabled=false;
78 BotonIZ.enabled=false;
C27
79 BotonDE.enabled=false;
80 FondoFinal.enabled=true;
81
82 //Esperamos 5 segundos y hacemos un reload de la escena
83 yield WaitForSeconds (5);
84 Application.LoadLevel(Application.loadedLevel);
85 }
[810] CAPÍTULO 27. PLATAFORMAS MÓVILES
13
14 private var llamasInstancia : Transform;
15 private var cantidadRotationCaida : Vector3 = Vector3(0,0,0);
16
17 function Update () {
18 if (transform.gameObject.rigidbody.useGravity){
19 //Si el enemigo está callendo, el avión rota sobre sus ejes
porque entra en barrena
20 transform.Rotate(Time.deltaTime * cantidadRotationCaida.x,
Time.deltaTime * cantidadRotationCaida.y,Time.deltaTime
* cantidadRotationCaida.z);
21 } else {
22 if (player!=null){
23 var distancia : float = transform.position.z-player.
position.z;
24 if (distancia<=rangoDisparo && distancia>0) {
25 //Si estamos en rango de disparo el avión dispara
al frente
26 if (Time.time > siguienteTiempoDisparo){
27 siguienteTiempoDisparo = Time.time +
tiempoRecarga;
28 Instantiate(disparoPrefab, transform.position,
Quaternion.identity);
29 }
30 }
31 }
32 }
33 }
34
35 function OnCollisionEnter(collision : Collision) {
36 //Determinar posición y rotación del punto de contacto de la
colisión
37 var contact : ContactPoint = collision.contacts[0];
38 var rot : Quaternion = Quaternion.FromToRotation(Vector3.up,
contact.normal);
39 var pos : Vector3 = contact.point;
40
41 var SonidosPointer : Transform = (GameObject.FindWithTag("
Sonidos")).transform;
42 var SonidosStatic : Sonidos = (SonidosPointer.GetComponent("
Sonidos") as Sonidos);
43
44 if (transform.gameObject.rigidbody.useGravity || collision.
collider.tag=="Player"){
45 //Si estamos callendo y hemos vuelto a colisionar entonces
explota
46 var ControlJuegoPointer : Transform = (GameObject.
FindWithTag("ControlJuego")).transform;
47 var ControlJuegoStatic : ControlJuego = (
ControlJuegoPointer.GetComponent("ControlJuego") as
ControlJuego);
48
49 SonidosStatic.PlaySonidoExplosionSuelo();
50
51 //Instanciamos la explosión final en la posición del
impacto
52 Instantiate(explosionPrefab, pos, rot);
53 if (llamasInstancia!=null){
54 Destroy (llamasInstancia.gameObject);
55 }
56
57 if (jefeFinal) {
58 ControlJuegoStatic.AddScore(500);
59 ControlJuegoStatic.FinDeJuegoGanando();
C27
60 } else {
61 var cantidadScore : float = (rangoDisparo * (1.0/
tiempoRecarga))*5;
62 ControlJuegoStatic.AddScore(cantidadScore);
63 }
64
[812] CAPÍTULO 27. PLATAFORMAS MÓVILES
31 Instantiate(disparoPrefab, posicionDisparoDE.position,
posicionDisparoDE.rotation);
32 } else {
33 Instantiate(disparoPrefab, posicionDisparoIZ.position,
posicionDisparoIZ.rotation);
34 }
35 anteriorDisparoIZ=!anteriorDisparoIZ;
36 }
37
38 if (botonIzquierda || Input.GetButton("Left")){
39 //Si hay moverse a la izquierda se actualiza la posición
del jugador
40 //También se mueve un poco la cámara para simular un poco
de efecto parallax
41 if (transform.position.x>topeIZ.position.x) {
42 transform.position.x-= Time.deltaTime *
cantidadMovimiento;
43 camara.position.x-= Time.deltaTime * cantidadMovimiento
/2;
44 }
45 } else if (botonDerecha || Input.GetButton("Right")) {
46 //Si hay moverse a la derecha se actualiza la posición del
jugador
47 //También se mueve un poco la cámara para simular un poco
de efecto parallax
48 if (transform.position.x<topeDE.position.x) {
49 transform.position.x+= Time.deltaTime *
cantidadMovimiento;
50 camara.position.x+= Time.deltaTime * cantidadMovimiento
/2;
51 }
52 }
53 }
54
55 function OnCollisionEnter(collision : Collision) {
56 if (collision.collider.tag=="DisparoEnemigo" || collision.
collider.tag=="Enemigo"){
57 //Si el jugador colisiona con un disparo o un enemigo la
vida disminuye
58 vida--;
59 LifeGUI.text="Life : "+vida;
60
61 if (vida<=0){
62 //Si la vida es 0 entonces acaba la partida
63 var ControlJuegoPointer : Transform = (GameObject.
FindWithTag("ControlJuego")).transform;
64 var ControlJuegoStatic : ControlJuego = (
ControlJuegoPointer.GetComponent("ControlJuego") as
ControlJuego);
65 ControlJuegoStatic.FinDeJuegoPerdiendo();
66
67 //Reproducimos sonido de explosión
68 var SonidosPointer : Transform = (GameObject.
FindWithTag("Sonidos")).transform;
69 var SonidosStatic : Sonidos = (SonidosPointer.
GetComponent("Sonidos") as Sonidos);
70 SonidosStatic.PlaySonidoExplosionSuelo();
71
72 //Instanciamos un prefab de explosión en la posición
del avión del jugador
73 Instantiate(explosionPrefab, transform.position,
Quaternion.identity);
74 //Eliminamos el avión del jugador
75 Destroy(gameObject);
C27
76 }
77 }
78 }
79
80
81 //Métodos para controlar la pulsación de los botones virtuales
[814] CAPÍTULO 27. PLATAFORMAS MÓVILES
82
83 function ActivarBotonIzquierda(){
84 botonIzquierda=true;
85 }
86
87 function ActivarBotonDerecha(){
88 botonDerecha=true;
89 }
90
91 function ActivarBotonDisparo(){
92 botonDisparo=true;
93 }
94
95 function DesactivarBotonIzquierda(){
96 botonIzquierda=false;
97 }
98
99 function DesactivarBotonDerecha(){
100 botonDerecha=false;
101 }
102
103 function DesactivarBotonDisparo(){
104 botonDisparo=false;
105 }
27 case tipoGUI.BotonShoot:
28 guiTexture.pixelInset = Rect (Screen.width/2 -
anchoBoton - margen, - Screen.height/2 + margen,
anchoBoton, altoBoton);
29 break;
30 }
31 }
52 case tipoGUI.BotonLeft:
53 PlayerStatic.DesactivarBotonIzquierda();
54 break;
55 case tipoGUI.BotonRight:
56 PlayerStatic.DesactivarBotonDerecha();
57 break;
58 case tipoGUI.BotonShoot:
59 PlayerStatic.DesactivarBotonDisparo();
60 break;
61 }
62 }
63
64 function Start () {
65 //Obtenemos el puntero a Player y ajustamos algunos valores
iniciales
66 var PlayerPointer : Transform = (GameObject.FindWithTag("Player
")).transform;
67 PlayerStatic = (PlayerPointer.GetComponent("Player") as Player)
;
68 wasClicked = false;
69 Boton.texture= TextureOFF;
70 }
27.5. Optimización
El motor gráfico nos proporciona dos herramientas imprescindibles para optimizar
nuestros videojuegos: lightmapping y occlusion culling. Aplicando estas técnicas
reduciremos mucho la carga de renderizado y nos permitirá que nuestros videojuegos
puedan correr a buena velocidad en dispositivos de poca potencia gráfica como
smartphones o tablets.
Figura 27.14: Ejemplo de las sombras que proyectan sobre el terreno los modelos tridimensionales de unas
ruinas colocados en la escena.
C27
Figura 27.16: Ejemplo de mapa generado mediante esta técnica que después se mapeará sobre el modelo
3D de la escena. Este mapa de sombreado es de la zona que se aprecia en la figura 27.18.
[818] CAPÍTULO 27. PLATAFORMAS MÓVILES
Figura 27.19: En nuestro ejemplo se ha dividido el escenario en porciones para poder aplicar la técnica, de
este modo en un momento determinado sólo se renderiza una pequeña parte del escenario.
C27
Figura 27.20: Nivel de granularidad elegido para la matriz de discretización del espacio en nuestro ejemplo.
Desarrollo de Componentes
El objetivo de este módulo, titulado «Desarrollo de Componentes»
dentro del Curso de Experto en Desarrollo de Videojuegos, consiste
en profundizar en técnicas específicas vinculadas al desarrollo de
videojuegos, como por ejemplo el uso de técnicas de Inteligencia
Artificial o la programación multijugador en red. Para ello, una de
las principales metas es la de complementar la visión general de la
arquitectura de un motor de juegos con cuestiones específicas que
resultan fundamentales para su desarrollo. Dentro del contexto de la
Inteligencia Artificial, en este módulo se estudian técnicas
fundamentales como la Lógica Difusa o los algoritmos genéricos,
entre otras. Así mismo, se realiza una discusión del diseño orientado
a agentes como pilar esencial en el desarrollo del componente
inteligente de un videojuego. En la parte relativa al juego
multijugador se exploran las posibilidades que ofrecen los sockets y,
posteriormente, se discute cómo el uso de herramientas de más alto
nivel, como los middlewares de comunicaciones pueden contribuir a
facilitar el desarrollo del módulo de networking. Finalmente, este
módulo también contempla aspectos relativos a la edición de audio,
la gestión de vídeo y la importancia de la integración de nuevos
dispositivos de interacción. En el contexto del desarrollo de
videojuegos, técnicas como la visión por computador o la realidad
aumentada pueden contribuir a mejorar la experiencia del jugador.
Capítulo
Inteligencia Artificial
28
Javier Alonso Albusac Jiménez
José Jesús Castro Sánchez
David Vallejo Fernández
Luis Jiménez Linares
Manuel Palomo Duarte
L
a Inteligencia Artificial (IA) es un elemento fundamental para dotar de realismo
a un videojuego. Uno de los retos principales que se plantean a la hora
de integrar comportamientos inteligentes es alcanzar un equilibrio entre la
sensación de inteligencia y el tiempo de cómputo empleado por el subsistema de
IA. Dicho equilibrio es esencial en el caso de los videojuegos, como exponente más
representativo de las aplicaciones gráficas en tiempo real.
Este planteamiento gira, generalmente, en torno a la generación de soluciones que,
sin ser óptimas, proporcionen una cierta sensación de inteligencia. En concreto, dichas
soluciones deberían tener como meta que el jugador se enfrente a un reto que sea
factible de manera que suponga un estímulo emocional y que consiga engancharlo al
juego.
En los últimos años, la IA ha pasado de ser un componente secundario en el
proceso de desarrollo de videojuegos a convertirse en uno de los aspectos más
importantes. Actualmente, lograr un alto nivel de IA en un juego sigue siendo uno
de los retos más emocionantes y complejos y, en ocasiones, sirve para diferenciar un
juego normal de uno realmente deseado por los jugadores.
Tal es su importancia, que las grandes desarrolladores de videojuegos mantienen
en su plantilla a ingenieros especializados en la parte de IA, donde los lenguajes de
scripting, como Lua o Python, y la comunicación con el resto de programadores del
juego resulta esencial.
Figura 28.1: El robot-humanoide En este capítulo se discuten cuestiones con gran relevancia en el ámbito de la IA
Asimo, creado por Honda en el año aplicada a videojuegos, haciendo especial hincapié en las técnicas más consolidadas,
2000, es uno de los exponentes más
como por ejemplo el uso de máquinas de estados finitas, el diseño basado en agentes,
reconocidos de la aplicación de téc-
nicas de IA sobre un prototipo físi-
la aplicación de algoritmos de búsqueda o la lógica difusa. En la última sección del
co real. capítulo se discute un caso de estudio práctico enfocado a la IA.
823
[824] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
a buscar soluciones óptimas, mien- la idea de mostrar algún tipo de inteligencia, aunque sea mínima.
tras que en el contexto de los video-
juegos la tendencia general consiste
en encontrar buenas soluciones.
[828] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
Agente Sensores
Percepciones
Medio o contexto
Actuadores
Acciones
Agente
ver acción
siguiente estado
Entorno
Acción, reacción Una máquina de estados define el comportamiento que especifica las secuencias
de estados por las que atraviesa un objeto durante su ciclo de ejecución en respuesta
El modelo de diseño de las máqui- a una serie de eventos, junto con las respuestas a dichos eventos. En esencia, una
nas de estados está basado en el tra-
dicional esquema de acción y reac- máquina de estados permite descomponer el comportamiento general de un agente en
ción. Básicamente, ante el cumpli- pedazos o subestados más manejables. La figura 28.9 muestra un ejemplo concreto de
miento de una condición (acción) se máquinas de estados, utilizada para definir el comportamiento de un NPC en base a
produce una transición (reacción). una serie de estados y las transiciones entre los mismos.
Como el lector ya habrá supuesto, los conceptos más importantes de una máquina
de estados son dos: los estados y las transiciones. Por una parte, un estado define una
condición o una situación durante la vida del agente, la cual satisface alguna condición
o bien está vinculada a la realización de una acción o a la espera de un evento. Por
Fuzzy Logic otra parte, una transición define una relación entre dos estados, indicando lo que ha
La lógica difusa o borrosa es una de ocurrir para pasar de un estado a otro. Los cambios de estado se producen cuando
técnica que permite tratar la incerti- la transición se dispara, es decir, cuando se cumple la condición que permite pasar de
dumbre y la vaguedad presenta en la un estado a otro.
mayoría de los problemas del mun-
C28
after (30s)
ruido
Inactivo Patrullando Rastreando
sin enemigos
enemigo
detectado
Atacando
El anterior listado de código contiene una función predecir que debería implemen-
tar una IA más sofisticada, más allá de devolver valores aleatorios. Una posible opción
sería utilizar la estructura de datos jugadas para generar algún tipo de patrón que mo-
delara el comportamiento del humano después de varias rondas de juego. Se plantea
como ejercicio al lector la implementación de la función predecir() con el objetivo
de estudiar la secuencia previa de predicciones por parte del jugador humano, con el
objetivo de ganar un mayor número de partidas.
5 5 x
5 5 x
4 4 4 4 x
3
22 2 2 2 2 2 · ···
11 1 1 1 1 1 1 1 1 1
Figura 28.11: Planteando un módulo de IA para el Tetris. a) Limpieza de una línea, b) Cuantificando la
altura del montón de fichas, c) Problema de huecos perdidos (los puntos representan los huecos mientras
que las ’x’ representan los potenciales bloqueos).
donde:
Para aquellos que no estén familiarizados con esta técnica, los dos principios
anteriores pueden resultar un tanto confusos; desarrollemos con mayor detalle la
principal idea en la que se basa cada uno de ellos.
En cuanto al primero, los computadores son máquinas que trabajan de forma
metódica y que resuelven los problemas con total exactitud. Normalmente, un
computador necesita unos parámetros de entrada, los procesa y genera unos datos
de salida como solución a un problema. Sin embargo, los humanos suelen analizar las
situaciones o resolver los problemas de una manera más imprecisa, sin la necesidad de
conocer todos los parámetros o que la información relativa a los mismos esté completa.
Por ejemplo, supongamos el caso en el que dos personas se están pasando una
pelota. El lanzador no es consciente en ningún momento, del ángulo que forma su
brazo con el cuerpo, ni la fuerza exacta que realiza para lanzar la bola. Uno de ellos
podría pedir al otro que le lanzara la pelota más fuerte. El término lingüístico más
fuerte es impreciso en sí; en ningún momento se especifica cuanto debe aumentar
el valor de la fuerza para cumplir el objetivo deseado. La persona encargada de
lanzar, puede resolver el problema rápidamente, aumentando la fuerza con respecto
Figura 28.14: Profesor Lofti Za-
al lanzamiento anterior sin la necesidad de realizar cálculos precisos. Esta forma de
deh, padre de la lógica difusa, pre- trabajar, en la que se manejan términos lingüísticos, conceptos vagos e imprecisos
sentó su teoría en el año 1965 en la es un tanto opuesta a la forma en la que el computador trabaja. Sin embargo,
revista Information and Control. puede ofrecer soluciones rápidas y eficientes a problemas reales, con un bajo coste
computacional.
Por otro lado, el segundo principio enuncia que la lógica difusa se basa principal-
mente en el estudio de los grados de pertenencia a los conjuntos difusos definidos.
C28
riables pueden tomar) en el que se incluyen los conjuntos difusos. Según el valor de
entrada de la variable, éste pertenecerá a uno o varios conjuntos del dominio con un
cierto grado de pertenencia. Esta es una de las principales características que diferen-
cian a la lógica difusa de la lógica tradicional.
En la lógica bivaluada existen dos valores de verdad posibles: verdadero y falso.
En la lógica difusa pueden existir valores intermedios, es decir, un hecho podría ser
no del todo cierto y no del todo falso. En cuanto a la teoría de conjuntos tradicional,
un elemento pertenece absolutamente a un conjunto o no pertenece. En el caso de
la lógica difusa, un elemento puede pertenecer a diferentes conjuntos con distintos
grados. Los grados de pertenencia pertenecen al intervalo [0,1], donde 1 representa
pertenencia absoluta y 0 el caso contrario. Por tanto, podemos decir que la lógica
difusa es una generalización de la lógica clásica.
Veamos un ejemplo; supongamos que un sistema posee una variable de entrada
llamada altura. El objetivo de este sistema podría ser catalogar a un conjunto de
personas adultas como muy bajo, bajo, mediano, alto o muy alto. Supongamos que la
altura puede variar entre 1.40 y 2.20. En el dominio de definición de la variable altura
habrá que indicar de algún modo la correspondencia entre los términos muy bajo,
bajo, mediano, alto y muy alto, y el rango posible de valores numéricos [1.40-2.20].
Supongamos que en un sistema clásico, se considera personas bajas hasta 1.69 y las
personas medianas desde 1.70 hasta 1.75. En este sistema, una persona que mida 1.69
sería clasificada como baja, mientras que una persona que mida 1.70 sería clasificada
como mediana.
Tal como se puede apreciar, la discriminación en el proceso de clasificación entre
una persona y otra es excesiva teniendo en cuenta que la diferencia es mínima (sólo
un centímetro). La lógica difusa contempla la pertenencia a varios conjuntos al mismo
tiempo, es decir, una persona que mida 1.70 podría pertenecer al conjunto de los
medianos con un grado 0.6 mientras que al conjunto de los bajos podría ser 0.4. Este
es un ejemplo orientativo para concebir una idea general sobre la esencia en la que
se basa la lógica difusa; en secciones posteriores se explicará con mayor detalle la
definición de conjuntos difusos y el cálculo de grados de pertenencia.
Sistemas de escritura.
Mejora de la eficiencia en el consumo de combustibles en vehículos.
Sistemas expertos que simulan el comportamiento humano.
Tecnología informática.
Figura 28.18: Definición de domi- información sobre el personaje principal, o bien, la información de la que dispone es
nio difuso de la variable V mediante incompleta. Esto hace también que el comportamiento de los adversarios sea mucho
conjuntos curvilíneos. menos previsible y rompe la monotonía del videojuego.
[838] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
Proceso de Fuzzificación
Entrada crisp → Entrada difusa
Motor de inferencia
Proceso de inferencia → Salida difusa
Proceso de defuzzificación
Salida difusa → Salida crisp
Altura
0
1.40 1.45 1.55 1.60 1.65 1.70 1.75 1.80 1.85 2.20
Figura 28.20: Dominio de definición o espacio difuso definido para la variable altura.
Una vez definidas las variables y sus dominios de definición, el siguiente paso
consiste en la fuzzificación de variables a medida que reciban valores de entrada. Este
paso consiste básicamente en el estudio de los valores de pertenencia a los conjuntos
difusos en función de los valores crisp de entrada. Por ejemplo, supongamos que una
persona mide 1.64, es decir, altura = 1.64. Dicho valor de altura estaría comprendido
entre los conjuntos bajo y medio, con una pertenencia mayor al conjunto medio.
Un espacio difuso está correctamente definido, si la suma de todas las pertenencias
a los conjuntos siempre es 1, independientemente del valor de entrada. Formalmente,
la definición de la función de pertenencia asociada a un trapecio o conjunto difuso
trapezoidal es de la siguiente manera:
0 u<a
(u−a)
(b−a) a≤u<b
(u; a, b, c, d) = 1 b≤u≤c (28.2)
(d−u)
(d−c) c<u≤d
0 u>d
C28
Altura
0
1.40 1.45 1.55 1.60 1.65 1.70 1.75 1.80 1.85 2.20
Figura 28.21: Estudio de los valores de pertenencia a los conjuntos difusos a partir de una altura de 1.64.
8 else if (u == b)
9 result = 1;
10 else if (b < u && u <= c)
11 result = (c-u)/(c-b);
12
13 return result;
14 }
Motor de inferencia
Una vez que se ha realizado el proceso de fuzzificación, para determinar el
grado de pertenencia de las variables a los conjuntos difusos, comienza el proceso
de inferencia o razonamiento para la toma de decisiones. Uno de los motores de
inferencia más comunes es el basado en reglas difusas de tipo SI-ENTONCES. Este
tipo de reglas está formado por un conjunto de antecedentes conectados mediante
operadores lógicos (AND, OR, NOT) y uno o varios consecuentes. Cada antecedente
a su vez consiste en una variable difusa y el rango de etiquetas lingüísticas que ésta
debería tomar para que se produzca la activación de la regla. Veamos un ejemplo:
Método Método
Indirecto Simplificado
Veamos con mayor detalle uno de los métodos de inferencia más populares en la
lógica difusa: Método de Mandani.
Proceso de razonamiento mediante el método de Mandani
Según Tanaka [96], los sistemas de razonamiento aproximado se pueden clasificar
como se muestra en la figura 28.22.
Uno de los métodos más utilizado es el Método Directo de Mandani. Veamos un
ejemplo de funcionamiento con dos reglas:
Regla 1: IF x es A1 e y es B1 THEN z es C1
Regla 2: IF x es A2 e y es B2 THEN z es C2
A1 B1 C1
1 1 1
w1
0 x 0 y 0 z
A2 B2 C2
1 1 1
w2
0 x 0 y 0 z
x0 y0
C1 C2
1
0 z
z0 (centroide)
Proceso de defuzzificación
El proceso de defuzzificación es necesario sólo si se desea un valor real o crisp
de salida en nuestro sistema (valor z0 en la figura 28.23). El proceso más sencillo y
ampliamente utilizado se conoce como método de centro de gravedad, en el que se
obtiene el punto central del área generada en el consecuente de la regla.
Si tenemos en cuenta de nuevo el ejemplo anterior, donde se plantea el método de
razonamiento de Mandani, los métodos de defuzzificación más comunes son:
µc (z)z dz
z0 =
µc (z) dz
z0 = máx µc (z)
z
Tal como se vio en las las secciones anteriores, existen diferentes alternativas para
definir los conjuntos difusos correspondientes a las etiquetas lingüísticas especificadas
en el listado anterior: trapezoidales, triangulares, mediante curvas, etc. Queda a
elección del lector, elegir una de las opciones en función del comportamiento del
sistema deseado.
Por otro lado, las variables de salida son las que se incluirán en el consecuente
de las reglas y determinarán las acciones básicas de cada individuo: acercarse, huir,
atacar y defenderse. A continuación se detalla una breve descripción sobre cada una
de ellas:
Si finalmente optamos por realizar una de las cuatro acciones posibles en cada
turno: acercarse, huir, atacar o defenderse; tan sólo sería necesario estudiar la
activación de las reglas y actuar según aquella que tenga un grado de pertenencia
mayor.
Una vez implementando el sistema difuso, sería interesante realizar modificacio-
nes del dominio de definición de cada variable para poder observar como varía el
comportamiento de los personajes sin la necesidad de variar la lógica de programa.
La inclusión de nuevas reglas o modificación de las existentes, también contribuye al
C28
cambio de conductas.
[846] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
Selección de individuos
NO ¿Solución optima?/
Condición de parada
SI
viceversa; el segundo de ellos incrementando o decrementando los valores numéricos Reales 2.1 3.4 5.6 4.5 3.2 9.1 8.8
con una cantidad adicional; finalmente, el último método de representación permite el Caracteres A C A A B C A
intercambio de letras.
Figura 28.29: Modos de represen-
tación para secuencias genéticas
Operadores Genéticos
Operadores de selección
0.125 Selección por rueda de ruleta: En este caso, el individuo también tiene
0.25 una probabilidad de ser seleccionado. Sin embargo, pi es proporcional a la
0.125 diferencia entre su aptitud y la del resto de soluciones. Se puede entender como
una ruleta en la que cada sector representa la probabilidad de cada individuo
para ser seleccionado. Cuanto mayor sea la probabilidad, mayor será el sector
0.5 para ese individuo. Cada vez que se gira la ruleta y se detiene en alguno de
los sectores se realiza la selección de uno de los miembros. A medida que se
obtengan nuevas generaciones, las soluciones deberían ir convergiendo hacia
un punto en común de tal forma que la diferencia de aptitud entre los individuos
debe ser menor y la probabilidad de ser seleccionados muy similar.
Figura 28.30: Selección genética
mediante el método de ruleta. Cada Selección escalada: este método soluciona el problema planteado en el punto
sector representar la probabilidad anterior, en el que los miembros de una población poseen valores de aptitud
de un individuo de ser seleccionado similares. Esto suele suceder en generaciones posteriores y no es conveniente
hasta entonces utilizarlo. Con este tipo de selección se permite que la función
de aptitud sea más discriminatoria.
Selección por torneo: En este método se eligen subgrupos de la población y se
enfrentan unos con otros. El vencedor de la competición es seleccionado para
el proceso de reproducción.
Selección por rango: En este método se realiza un ranking de individuos en
base a un rango numérico asignado a cada uno de ellos en función de la aptitud.
La ventaja principal de este método es que evita que individuos con un grado de
aptitud demasiado elevado ganen dominancia sobre los que tienen un valor más
bajo. Esto es crítico sobre todo al principio, ya que soluciones que podrían ser
descartadas en las primeras generaciones podrían evolucionar progresivamente
hasta soluciones óptimas globales.
Selección generacional: Solo pasa a la siguiente generación la descendencia de
la generación anterior. En ningún momento se copian individuos.
Selección por estado estacionario: En las primeras generaciones se realiza una
selección menos compleja, poco rigurosa y menos discriminatoria, mientras que
en las sucesivas ocurre justo lo contrario. De esta forma se reduce el tiempo total
de cálculo en la búsqueda de soluciones.
C28
[850] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
Mutación
La mutación consiste en alterar de forma aleatoria una o varias partes de la
solución. En el caso de la naturaleza las mutaciones pueden llegar a ser letales, sin
embargo, contribuyen a la diversidad genética de la especie. No es conveniente utilizar
con una frecuencia alta este operador para no reducir el AG a una búsqueda aleatoria;
tan sólo es aconsejable cuando el algoritmo esté estancado. Una buena frecuencia de
mutación es 1/100 o 1/1000.
Para cada uno de los genes que constituyen una solución se genera un número Original 1 0 1 1 0 0 1
Cruce (Crossover)
Este es el operador principal en un AG y el que se utiliza con mayor frecuencia. Figura 28.31: Ejemplo de muta-
Consiste en el intercambio genético entre dos cromosomas; para ello se escogen dos ción en una cadena binaria
de los miembros elegidos en el proceso de selección y se intercambian algunas de las
partes de la cadena formada por los genes. Existen diferentes tipos de operadores de
cruce:
Además de elegir los operadores genéticos que se van a emplear hay que
determinar también su frecuencia de uso. Dependiendo de la fase o etapa de la
búsqueda en la que se encuentre un AG, es conveniente emplear unos tipos u otros.
En un principio, los métodos más eficaces son la mutación y el cruce; posteriormente,
cuando la población o gran parte de ésta converge el cruce no es demasiado útil ya
que nos vamos a encontrar con individuos bastantes similares. Por otro lado, si se
produce un estancamiento, y no se consiguen soluciones en las nuevas generaciones
que mejoren el valor de aptitud, la mutación tampoco es aconsejable ya que se reduce
el AG a una búsqueda aleatoria. En tal caso, es aconsejable utilizar otro tipo de
operadores más especializados.
Procesamiento paralelo
Se exploran varias soluciones de
forma simultánea
28.2. Técnicas Fundamentales [851]
Padre
Madre
Descendientes
Padre
Madre
Descendientes
Padre
Madre
Descendientes
Una de las grandes ventajas de los AG es que estos tienen descendencia múltiple
y pueden explorar el espacio de soluciones de forma paralela. A diferencia de los
algoritmos que buscan una solución siguiendo un único camino, los AG no se ven
realmente penalizados en el caso de que uno de los caminos no llegue a una solución
satisfactoria. Gracias a esta ventaja, los AG tienen un gran potencial para resolver
problemas cuyo espacio de soluciones es grande o para resolver problemas no lineales.
[852] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
Otra ventaja importante es que los AG son apropiados para manejar múltiples
parámetros simultáneamente. Es posible, que el AG no encuentre una solución óptima
global y pueden existir varias soluciones al problema en cuestión y que algunas de
ellas optimicen algunos de los parámetros de entrada y otras soluciones, optimicen
otros distintos.
Además, los AG no tienen porque conocer nada sobre el problema a resolver. Múltiples parámetros
De hecho, el espacio inicial de soluciones puede ser generado aleatoriamente en su
Manejo simultáneo de múltiples pa-
totalidad, y que estos individuos converjan hacia soluciones válidas para el problema. rámetros de entrada, hasta encon-
Esta característica es muy importante y hace que los AG sean realmente útiles para trar la configuración adecuada
resolver problemas en el que se posee un gran desconocimiento sobre las posibles
soluciones y no sería posible definir una de ellas de forma analítica.
Desconocimiento
Limitaciones de los algoritmos genéticos Permite llegar a las soluciones de
forma no analítica en caso de que
exista desconocimiento sobre como
Una de las posibles limitaciones de los AG reside en el modo de representación resolver el problema
de las cadenas de genes. Como se comentó anteriormente, el método utilizado debe
tolerar cambios aleatorios que no produzcan una gran cantidad de soluciones o
cadenas que supongan resultados sin sentido. Normalmente, los tres métodos de
representación expuestos con anterioridad (representación mediante cadenas binarias,
cadenas formadas por números enteros o reales, cadenas de letras) ofrecen la
posibilidad de selección, mutación y cruce.
Otro problema con el que nos encontramos en los AG es la definición de una
función de aptitud apropiada. Si la definición no es correcta puede que el AG sea
incapaz de encontrar las soluciones al problema. Además, de elegir una función de
aptitud apropiada, es necesario también configurar el resto de parámetros del AG
correctamente, como por ejemplo, tamaño inicial de la población, frecuencia de cruce,
frecuencia de mutación, etc.
Si el conjunto inicial es demasiado pequeño, es posible que el AG no explore el Representación
espacio de soluciones correctamente; por otro lado, si el ritmo de cambio genético es
El modo de representación elegido
demasiado alto o los procesos de selección no hacen su trabajo correctamente, puede debe facilitar la aplicación de los
que no se alcancen las soluciones apropiadas. operadores genéticos
Cuando un individuo destaca los suficiente en un principio con respecto al resto
Función de aptitud
de elementos de la población, se puede producir una convergencia prematura. El
problema es que este individuo se puede reproducir tan abundantemente que merma Es fundamental diseñar una función
la diversidad de la población. De esta forma el AG converge hacia un óptimo local de aptitud adecuada para encontrar
demasiado pronto y se limita la posibilidad de encontrar óptimos globales. Esto las soluciones deseadas
suele suceder sobre todo en poblaciones pequeñas. Para solucionar este problema, los
métodos de selección empleados deben reducir o limitar la ventaja de estos individuos;
los métodos de selección por rango, escalada y torneo son aptos para solucionar este
problema.
Por último, cabe destacar que no es muy aconsejable el uso de AG para problemas Máximos locales
que se pueden resolver de forma analítica, sobre todo por el alto coste computacional Se producen cuando ocurre una
que implican. El uso de AG es apropiado en problemas no lineales, donde el espacio convergencia prematura por la do-
de soluciones es grande y no se tiene una idea clara sobre la/las posibles soluciones. Si minancia de uno de los individuos
esa solución además depende de la configuración de múltiples parámetros que deben
ser ajustados, los AG son idóneos para encontrarla.
28.2. Técnicas Fundamentales [853]
Una vez que el conjunto de soluciones inicial está disponible, debe comenzar
el proceso de selección y generar los descendientes. En este ejemplo, se ha elegido
un modo de selección por torneo, en el que los cromosomas se enfrentan por pares.
La forma de organizar los enfrentamientos es aleatoria y, al menos, cada cromosoma
debe participar en alguno de los torneos. En este caso, al tener 6 cromosomas, serán
necesarios 6/2 = 3 enfrentamientos. En la tabla 28.2 se puede apreciar como en una
primera ronda, de forma aleatoria, se han enfrentado los pares de cromosomas (2,5) ,
(1,6) y (3,4). Para mantener una población de seis individuos, se ha vuelto a realizar
un segundo torneo (filas 3-6) en el que se han enfrentado los pares: (1,5) , (2,3) y (4,6).
Después de la selección se aprecia un incremento notable de la aptitud media en los
miembros de la generación, aunque se mantiene el valor máximo.
Cuadro 28.2: Selección por torneo. Enfrentamiento entre los cromosomas del conjunto inicial
[854] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
Q
Q
Q
Q
Q
Q
Q
Q
Sinapsis
Dendrita Axón
Núcleo
Entrada 1 w1
Entrada 2
w2
Σ ai
Salida
w3 h g
...
Entrada n
1 si x ≥ t
escalon(x) = 0 si x < t
1 si x ≥ 0
signo(x) = -1 si x < 0
Por último, la función sigmoidea se emplea cuando se desea una activación más
suavizada y no una función escalonada como en los casos anteriores. El valor de salida
viene dado por la siguiente expresión:
sigmoide(x) = 1
1+e−x
28.2. Técnicas Fundamentales [857]
Las unidades neuronales también pueden funcionar como puertas lógicas [79], tal
como se muestra en la figura 28.42. De esta forma, es posible construir redes que
calculen cualquier función booleana de las entradas.
W=1 W=1
W=-1
t = 1.5 t = 0.5 t = -0.5
W=1 W=1
Puerta AND Puerta OR Puerta NOT
Figura 28.42: Unidades con función escalón que actúan como puertas lógicas.
Por último, cabe mencionar una de las principales características de las redes
neuronales: su alto nivel de adptabilidad. Mediante procesos de aprendizaje es posible
ajustar los pesos de las conexiones y adaptarse correctamente a cualquier situación.
Dicho aprendizaje puede ser supervisado o automático. En el caso del aprendizaje
supervisado se conocen las entradas de la red y como deberían ser las salidas para cada
una de esas entradas. El ajuste de los pesos se realiza a partir del estudio de la salida
real obtenida a partir de las entradas y de la salida esperada, intentando minimizar el
error en la medida de los posible. Por otro lado, el aprendizaje automático dispone
únicamente de los valores de entrada. Este tipo de algoritmos trata de ajustar los pesos
hasta encontrar una estructura, configuración o patrón en los los datos de entrada.
1. Aplicaciones generales
Reconocimiento facial
Análisis de imagen
Modelos financieros
Perfiles de mercado-cliente
Aplicaciones médicas (síntomas y diagnóstico de enfermedades)
Optimización de procesos industriales
Control de calidad
Detección de SPAM en correo electrónico
Reconocimiento de voz
Reconocimiento de símbolos en escritura
C28
Clasificación de personajes
Sistemas de control y búsqueda de equilibrio: minimización de pérdidas o
maximización de ganancias
Toma de decisiones
Elaboración de estrategias
Simulaciones físicas
Estructuras de red
Existen multitud de tipos de estructuras de red y cada una de ellas otorga diferentes
características de cómputo [79]. Todos ellos se pueden clasificar en dos grandes
grupos: redes de alimentación progresiva y redes recurrentes. En el primer caso,
las conexiones son unidireccionales y no se forman ciclos. Las neuronas de un nivel
o capa sólo pueden estar conectadas con las del nivel siguiente. En la figura 28.43 se
muestra un ejemplo de una red de alimentación progresiva con dos niveles; dicha red
consta de dos entradas, dos neuronas en la capa oculta y una única salida. Al no existir
ciclos en las conexiones, el cálculo se realiza de forma uniforme desde las unidades
de entrada hasta las unidades de salida.
W13
Entrada 1
W14 H3 W35 Salida
W23 O5
H4 W45
Entrada 2 W24
Figura 28.43: Red de alimentación progresiva de dos niveles: dos entradas, dos neuronas en la capa oculta
y un nodo de salida
Precisión de la red
no, no tendría memoria a corto plazo; una red de recurrente, a diferencia de las de
Nº de Neuronas
Número de entradas
Nº de Neuronas
Generalidad
Número de salidas
Número de niveles
Cantidad de neuronas en cada una de las capas o niveles intermedios. Figura 28.45: Relación entre nú-
mero de neuronas y generalidad de
Cantidad de ejemplos de entrada para entrenar la red la red. Cuanto menor es el número
de neuronas más fácil es conseguir
la generalidad.
28.2. Técnicas Fundamentales [859]
A las dos primeras cuestiones es fácil encontrar una respuesta. Para la mayoría
de problemas conocemos el número de entradas y las salidas deseadas. En el caso de
encontrar ciertas dificultades a la hora de elegir el número de parámetros de entrada
existe una relación directa con los patrones que pretendemos aprender en la red.
Parámetros = log2 P
donde P es el número de patrones. Por otro lado, si queremos calcular un número de
patrones estimado, se puede emplear la siguiente fórmula:
P = W · 1/ε
donde W es el número de pesos en la capa intermedia y ε el error mínimo deseado en
la red, dicho error se podría fijar en el siguiente intervalo: 0 ≤ ε ≤ 1/8
Sin embargo, establecer una configuración adecuada en cuanto al número de
niveles y neuronas es más complicado. Normalmente, la configuración se establece
mediante un proceso de aprendizaje supervisado por prueba y error. En dicho proceso
se conocen las entradas y las salidas que se deberían obtener ante tales entradas.
Los errores producidos en la salida producen un ajuste de pesos para el correcto
funcionamiento de la red. Si después del proceso de entrenamiento, no se obtiene
el comportamiento deseado, es necesario modificar la configuración de la red.
Al diseñar una red neuronal siempre hay que tratar de encontrar un equilibrio
entre precisión y generalización. Precisión se refiere a obtener el menor error posible
en la salida ante los casos de entrada y generalización a la posibilidad de abarcar el
mayor número de casos posibles. Cuando el número de neuronas en la capa oculta es
alto, normalmente se consigue una mayor precisión a costa de un incremento de la
complejidad (red más compleja implica mayor tiempo de entrenamiento).
Búsqueda de Equilibrio Por el contrario, cuando el número de neuronas es más bajo, obtenemos una red
más simple que nos permite alcanzar una mayor generalización. El uso de funciones de
En el diseño de una red neuronal activación sigmoidales también fomenta la generalización de la red. Además se debe
hay que buscar siempre un equi-
librio entre precisión y generaliza- tener especial cuidado a la hora de elegir el número de parámetros de entrada; cuanto
ción mayor sea la cantidad de parámetros, mayores distinciones podremos hacer entre
los individuos de una población, pero más complejo será alcanzar esa generalidad
deseada.
Evidentemente, cuanto más complejo sea el problema a tratar, mayor será también
el número de neuronas necesarias en la capa oculta. En casi la totalidad de los casos,
la capa oculta necesita un menor número de neuronas que la capa de entrada. Según
el teorema de Kolgomorov [58] "El número de neuronas en la capa oculta debe ser
como máximo dos veces el número de entradas".
Para la configuración del número de neuronas en la capa oculta se pueden utilizar
algoritmos constructivos. Básicamente, un algoritmo de este tipo parte de una
configuración inicial en el que el número de neuronas es reducido. Se entrena la red
hasta que el error se mantenga constante. Posteriormente, si no se ha alcanzado el
error mínimo deseado, se puede agregar una neurona más a la capa intermedia con
un peso pequeño y se repite el proceso anterior. Es importante tener en cuenta, que la
agregación de neuronas no va a solucionar el problema si: i) el conjunto de datos de
entrada es insuficiente, ii) hay datos que no se pueden aprender.
Para el cálculo del error de salida en función de las entradas se puede utilizar el
error cuadrático medio:
etotal = e21 + e22 + ... + e2n
donde ei es el error obtenido en la salida para el ejemplo o patrón de entrada i. Para
calcular el error cometido en uno de los patrones:
C28
En esta sección se explica como se pueden ajustar los pesos de una red mediante
la regla de aprendizaje de Hebb. Para ello supongamos una red sencilla constituida por
dos entradas y una capa de salida formada por una única neurona. El valor umbral de
activación de la neurona se va a ajustar de forma dinámica y para ello se considerará
como una entrada más con peso negativo. El esquema de dicha red se muestra en la
figura 28.46
-W0
X0 1
W1
X1 Salida
O
X2 W2
Para entrenar la red se toman de forma aleatoria los casos expuestos anteriormente
y se introducen en las entradas. Supongamos que inicialmente todos los pesos valen
cero (tal como se comentó anteriormente, el valor inicial de los pesos debe ser bajo).
Supongamos también, que el valor de incremento/decremento empleado para los pesos
es 1.
Si el caso 1: X1 = 0, X2 = 0 fuera el primero en utilizarse para entrenar la red,
X0 W0 + X1 W1 + X2 W2 = 0, debido a que todos los pesos valen cero. Por tanto,
la neurona de salida no se activa. En realidad es el valor esperado y, por tanto, no se
producen modificación de los pesos. En realidad, el único ejemplo o caso que puede
producir un error con todos los pesos a cero, es el caso 4.
Supongamos que el caso 4 es el siguiente ejemplo empleado para entrenar la red.
X1 = 1, X2 = 1 y la salida esperada es 1; sin embargo X0 W0 + X1 W1 + X2 W2 = 0.
Se esperaba una activación de la salida que no se ha producido y, así, es necesario
modificar los pesos incrementando los pesos de las neuronas activadas (en este caso
todas las entradas):
W0 = W0 + C = 0 + 1 = 1
W1 = W1 + C = 0 + 1 = 1
W2 = W2 + C = 0 + 1 = 1
De forma iterativa se van aplicando los casos de entrada hasta que no se producen
errores en la salida. Finalmente, para este ejemplo, el ajuste apropiado de los pesos es
el siguiente: W0 = −2, W1 = 1, W2 = 2.
Cuando tenemos más de un nivel en la red (ver figura 28.47), el método anterior
resulta insuficiente y es necesario utilizar otros como el de retropropagación. Una de
las condiciones indispensables para poder aplicar este método, es que las funciones de
activación empleadas en las neuronas deben ser derivables. Básicamente se calcula el
error producido en la salida y lo propaga hacia las capas intermedias para ajustar los
pesos. El algoritmo consta de los siguientes pasos:
X0
H1
X1 O1
X2 H2
X3 O2
H3
X4
Figura 28.47: Esquema de un perceptrón o red neuronal multicapa
Calcular las derivadas parciales del error con respecto a los pesos de salida
(unión de la capa oculta y la de salida)
Calcular las derivadas parciales del error con respecto a los pesos iniciales
(unión de las entradas con la capa oculta)
Ajustar los pesos de cada neurona para reducir el error.
4. FIN REPETIR
5. Continuar hasta minimizar el error.
En el caso de tener un solo nivel, la actualización de los pesos en función del error
es sencilla:
Error = valor esperado (T ) - valor real obtenido (O)
Si el error es positivo, el valor esperado es mayor que el real obtenido y por tanto será
necesario aumentar O para reducir el error. En cambio, si el error es negativo habrá
que realizar la acción contraria: reducir O.
Por otro lado, cada valor de entrada contribuirá en la entrada de la neurona según
el peso: Wj Ij (peso W y valor de entrada I). De esta forma, si Ij es positivo, un
aumento del peso contribuirá a aumentar la salida y, si por el contrario, la entrada Ij
es negativa, un aumento del peso asociado reducirá el valor de salida O. El efecto
deseado se resume en la siguiente regla:
Wj = Wj + α × Ij × Error
Salida Oi
Wj,i
���dad��
aj
�cultas
���
Entrada �
Wj,i = Wj,i + α × aj × Δi
�
Δj = g ′ (entradaj ) i Wj,i Δi
C28
Wk,j = Wk,j + α × Ik × Δj
Ejercicio propuesto: Dada una red neuronal con una única neurona oculta,
con patrones de entrada P1 (1 0 1), P2 (1 1 0), P3 (1 0 0), llevar acabo un
proceso de entrenamiento mediante backpropagation para ajustar los pesos
de la red y que ésta se comporte como la función XOR
Las redes neuronales se han aplicado con frecuencia en el juego de los asteroides,
en el que una nave debe evitar el impacto con asteroides que se mueven de forma
aleatoria es un espacio común. La red neuronal permite modelar el comportamiento
de la nave para evitar el impacto con los asteroides más cercanos. En [83] se plantea
una configuración de red neuronal para solucionar el problema. Dicha configuración
consta de cuatro entradas, una capa oculta con 8 neuronas y una capa de salida de tres
neuronas, donde cada una representa una posible acción.
Las dos primeras entradas corresponden con la componente x e y del vector entre
la nave y el asteroide más cercano. La tercera entrada corresponde a la velocidad con
la que la nave y el asteroide más cercano se aproximan. Finalmente, la cuarta entrada
representa la dirección que está siguiendo la nave.
La salida de la red neuronal son tres posibles acciones: i) aceleración puntual
(turbo), ii) giro a la izquierda, iii) giro a la derecha. En [83] se encuentra publicada
una posible implementación al problema.
O#BLC Una vez fijados los estados de nuestro problema de la isla, tendremos que enumerar
>B <B >C <C las acciones que se pueden desarrollar en un determinado estado. Pongamos por
OB#LC OCB#L ejemplo el estado LOCB#, en esta situación las acciones que podemos realizar son:
<O >O <O >O
coger la barca e ir de vacio a IslaB, que representamos mediante la secuencia de
caracteres >B, llevar el lobo a IslaB >L, llevar la oveja >O o llevar la col >C.
#BLOC C#BLO Considerando solamente las acciones válidas, que no provocan la pérdida de algún
elemento, tendremos que en el estado LOCB# sólo son correctas las acciones: >B y
<B >B
>O.
B#LOC
En los términos fijados, una solución a un problema es, precisamente, un camino >C
o secuencia de acciones válidas que dirige al sistema de un estado inicial a un
estado meta. Al proceso de buscar entre todos los caminos posibles, que parten de
un estado inicial establecido, aquellos que son soluciones se denomina resolución de L#BOC
problemas mediante técnicas de búsqueda. Una solución para el problema de la isla
se puede ver en la figura 28.51.
<O
33 else:
34 return None
35
36 def meta(self,estado):
37 return estado=="#BLOC"
>B >O
ID1* ID2* El árbol de búsqueda es una estructura que permite generar y almacenar todos
LOC#B LC#BO los posibles caminos de un espacio de estado.
Una posible realización del concepto de nodo del árbol de búsqueda se muestra
en la clase que se lista a continuación. En esta clase class NodoArbol se crean un
nodo con los atributos que hemos indicado. También se facilitan los métodos para la
construcción del camino desde el nodo raiz del árbol a un nodo concreto (línea 19),
y la creación de un hijo (línea 28). Se le ha añadido una valoración general del nodo
val, que se usará posteriormente.
El algoritmo descrito selecciona, del conjunto de nodos hoja del árbol de búsque-
da, un nodo para analizar. Primero verifica que no es una solución y posteriormente lo
analiza. Este análisis consiste en obtener a partir del problema, con la función sucesor,
el conjunto de nuevos nodos hijos e introducirlos en el árbol de búsqueda como nuevas
hojas al mismo tiempo que el nodo estudiado deja de ser hoja.
De lo descrito anteriormente el proceso de selección del nodo hoja a expandir es
de una importancia , ya que de una buena o mala selección dependerá que la búsqueda
de un nodo meta sea más o menos eficiente.
28.3. Algoritmos de búsqueda [869]
Al mecanismo seleccionado para la elección del nodo hoja que hay que
expandir se denomina estrategia de búsqueda.
1. Anchura Las hojas son seleccionadas por antiguedad, es decir, los nodos de
menor profundidad.
2. Profundida Las hojas son seleccionadas por novedad, es decir, los nodos de
mayor profundidad.
Una implentación de las estructuras necesarias para realizar estas dos estrategias
son mostradas en las siguientes clases: AlmacenNodos, PilaNodos y ColaNodos.
10 7
#BLOC
Listado 28.12: Clases de Cola Pila
1 class AlmacenNodos:
[>O,1]
2 """Modela la frontera."""
9 6 3 def __init__(self,nombre="NoName"):
OB#LC 4 self.id=nombre
5 def add(self,elem):
[<B,1] 6 """Introduce un elemento."""
7 pass
8 5
8 def esVacia(self):
O#BLC
9 """Retorna True si no tiene ningun elemento."""
[>L,1]
10 pass
11 def get(self):
6 4 7 4 12 """Retorna un elemento."""
LOB#C OCB#L 13 pass
14 def esta(self,id):
[<O,1] [<O,1] 15 """Retorna True si el elemento con Id esta en la estructura
."""
4 3 5 3
16 pass
L#BOC C#BLO
17
[<C,1] [>L,1]
18 class PilaNodo(AlmacenNodos):
19 """Modela un Almacen como una Pila: Ultimo en entrar, primero
3 2 en salir."""
LCB#O 20 def __init__(self,nombre):
21 AlmacenNodos.__init__(self,nombre)
[<B,1] 22 self.p=[]
23 self.lest=[]
1 1 2 1
24 def esVacia(self):
LC#BO LOC#B
25 return len(self.p)==0
[>O,1] [>B,1]
26 def add(self,elem):
27 self.p.append(elem)
0 0 28 self.lest.append(elem.estado)
LOCB# 29 def get(self):
30 return self.p.pop()
31 def esta(self,estado):
32 return estado in self.lest
Figura 28.56: Árbol de búsqueda 33
completo Anchura. 34 class ColaNodo(AlmacenNodos):
35 """ Modela un Almacen como una Cola: Primero en entrar, primero
en salir."""
36 def __init__(self,nombre):
37 AlmacenNodos.__init__(self,nombre)
C28
38 self.c=[]
39 self.lest=[]
40 def esVacia(self):
41 return len(self.c)==0
[870] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
42 def add(self,elem):
43 self.c.insert(0,elem)
44 self.lest.append(elem.estado)
45 def get(self):
46 return self.c.pop()
47 def esta(self,estado):
48 return estado in self.lest
10 7
Listado 28.13: Algoritmo de búsqueda #BLOC
2 if estrategia==’PROFUNDIDAD’:
8 6 9 6
3 frontera=PilaNodo(’Profundidad’) LOB#C OB#LC
4 elif estrategia==’ANCHURA’:
5 frontera=ColaNodo(’Anchura’) [<L,1] [<B,1]
6 n=NodoArbol(None,None,prob.estadoInit(),{’Prof’:0})
7 5
7 solucion=None O#BLC
8 frontera.add(n)
9 while (not frontera.esVacia()) and (solucion==None): [>C,1]
10 n=frontera.get()
11 if prob.meta(n.estado): 6
OCB#L
4
12 solucion=n
13 else: [<O,1]
14 for ac,est in prob.sucesor(n.estado):
15 if not frontera.esta(est): 4 3 5 3
L#BOC C#BLO
16 h=GeneraNodo(n,ac,est)
17 frontera.add(h) [<C,1] [>L,1]
18 return solucion
3 2
LCB#O
Sobre la eficiencia de estas estrategias hay que destacar que la estrategia de [<B,1]
una complejidad temporal (tiempo de ejecución) del orden O(bn ) para un problema [>O,1] [>B,1]
con b acciones en cada estado y una logitud de n acciones hasta un estado meta. La 0 0
estrategia de profundidad tiene el problema que se puede perder si el árbol de búsqueda LOCB#
Inicial
Ahora ya tenemos un valor para decidir que nodo hoja coger, el menor valor de
costo de los nodos hoja, o que el conjunto de nodos hojas estén ordenados de forma
n C(n)
creciente por el valor del costo.
(Acc,val) Con el objeto de generalizar esta estrategia y poder utilizar las estructuras
n1 C(n1)=C(n)+val
posteriormente, se ha definido una nueva estructura de almacen como una lista
ordenada por una valor del nodo. La estructura elegida para realizar la cola ordenada
ha sido el montículo, una estructura vectorial donde la posición inicial del vector se
Figura 28.58: Costo asociado a un encuentran siempre el elemento menor.
nodo.
10 def add(self,elem):
11 print elem
12 valor,n=elem
13 heapq.heappush(self.p,(valor,n))
[872] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
14
15 def get(self):
16 return heapq.pop(self.p)
A la estrategia que selecciona un nodo hoja con valor de costo menor se denomina
estrategia de costo uniforme.
Como ejemplo definimos un nuevo problema. El problema es el conocido como 8
puzzle donde se tiene un artilugio con 8 piezas numeradas del 1 al 8 y un espacio sin
pieza. Las piezas se puden deslizar al espacio en blanco y así cambiar la configuración
del puzzle. Si se representa una configuración concreta del 8 puzzle con una cadena
de 9 caracteres donde los 3 primeros representan la primera fila, y así sucesivamente
y el espacio en blanco por el caracter B, y consideramos que los movimientos son los
del espacio blanco. Si consideramos que de los cuatro movimientos posibles el costo
de los movimientos hacia arriba e izquierda van a costar 2 unidades frente a la unidad
de los movimientos abajo y derecha.
El problema propuesto es definido en el siguiente código ejemplo.
47 else:
48 return None
49
50 def meta(self,estado):
51 return estado==self.estadoFin
12345678B
El coste de esta solución es de 23 que podremos asegurar que es el menor coste
posible, o en otras palabras, que no existe otra solución con un coste menor.
AL considerar el coste que se sufre al generar un nodo, hemos asegurado que el
resultado que obtenemos es el mejor de todos; pero no se ha solucionado el problema
de la complejidad. La complejidad de la estrategia de costo uniforme se puede
equiparar a la de estrategia en anchura haciendo que cada paso sea un incremento
Ic del costo O(b(n/Ic) ).
Considerando que la mejor estrategia, siempre que no se pierda, es la estrategia
en profundidad una opción muy interesante podría ser combinar la estrategia de costo
uniforme con algo parecida a la estrategia en profundidad. Para poder llevar acabo esta
estrategia deberíamos ser capaces de decidirnos por la acción que mejor nos lleve a
C28
una solución. En otras palabras, tendríamos que ser capaces de poder evaluar el coste
que nos queda por realizar desde la situación actual a una meta.
[874] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
Este valor del costo que nos queda por realizar desde un estado para alcanzar un
estado meta se denomina valor de la heurística.
Para añadir esta heurística modificaremos la definición de un problema incorpo-
rando una nueva función que evalue el valor de la heurística. Dado un estado n la
función heuristica h(n) será el valor estimado del costo que queda por realizar hasta
un estado meta.
22 n=0
23 for i in range(len(estado1)):
24 if estado1[i]<>estado2[i]:
25 n=n+1
26 return n
27 def movDistintas(self,estado1,estado2):
28 n=0
29 for i in range(len(estado1)):
30 if estado1[i]<>estado2[i]:
31 py1=i/3
32 px1=i %3
33 pc=estado2.find(estado1[i])
34 py2=pc/3
35 px2=pc %3
36 n=abs(px2-px1)+(py2-py1)
37 return n
38
39 def setHeuristica(self,h=’DESCOLOCADAS’):
40 self.h=h
41
42 def heuristica(self,estado):
43 if self.h==’DESCOLOCADAS’:
44 return self.posDistintas(estado,self.estadoFin)
45 elif self.h==’CUNIFORME’:
46 return 0
47 else:
48 return self.movDistintas(estado,self.estadoFin)
Llegados a este punto, y para combinar la información del costo desarrollado con
la del costo por desarrollar o función heurística, definiremos el valor del nodo como
la suma del costo más el valor de la función heurística.
12 return NodoArbol(n,ac,est,{’Prof’:prof,’Coste’:coste,’h’:h},val
)
13
14 def BuscaSolucionesA(prob,estr=’A’):
15 frontera=AlmacenOrdenado()
16 h=prob.heuristica(prob.estadoInit())
17 n=NodoArbol(None,None,prob.estadoInit(),{’Prof’:0,’Coste’:0,’h’
:h},0)
18 solucion=None
19 frontera.add((n.val,n.estado,n))
20 while (not frontera.esVacia()) and (solucion==None):
21 n=frontera.get()
22 if prob.meta(n.estado):
23 solucion=n
24 else:
25 for ac,est in prob.sucesor(n.estado):
26 if not frontera.esta(est):
27 h=GeneraNodo(prob,n,ac,est,estr)
28 frontera.add((h.val,h.estado,h))
29 return solucion
Las operaciones básicas definidas son: añadir un arco,un nodo y obtener los
sucesores de un nodo. Según sea el acceso de un nodo a otro, encontraremos nodos
donde solo es posible en una dirección que denominaremos grafos dirigidos; en caso
contrario, tendremos grafos no dirigidos. Esta diferencia la introducimos en la clase
grafo mediante el valor tipo de la clase, que por defecto será dirigido.
Definido la estructura de grafo, podemos construir un problema como se comentó
en 28.3.1 para lo que definimos la clase ProblemaGrafo.
La incorporación de la heurística al problema del grafo se muentra en el siguiente Figura 28.63: Heurísticas para la
código. estrategia A*.
28.4. Planificación de caminos [879]
14 if m==None:
15 self.CreaMap(self.tablero[0],self.tablero[1])
16 else:
17 self.mapa=m
18
19 def CreaCelda(self,color):
20 s=pygame.Surface((self.celda[0],self.celda[1]),0,16)
21 s.fill(color)
22 pygame.draw.rect(s,(255,181,66),pygame.Rect(0,0,20,20),2)
23 return s
24
25 def AnadeCelda(self,color):
26 self.elementos.append(self.CreaCelda(color))
27
28 def CreaMap(self,w,h):
29 self.mapa=[]
30 for y in range(h):
31 self.mapa.append([])
32 for x in range(w):
33 self.mapa[y].append(0)
34
35 def PintaMapa(self):
36 for y in range(self.tablero[1]):
37 for x in range(self.tablero[0]):
38 rect=pygame.Rect(x*self.celda[0],y*self.celda[1],
self.celda[0],self.celda[1])
39 self.s.blit(self.elementos[self.mapa[y][x]],rect)
40
41 def ModificaMapa(self,celdaIndx,infor):
42 titulo=pygame.display.get_caption()
43 pygame.display.set_caption(infor)
44 NoFin=True
45 while NoFin:
46 for event in pygame.event.get():
47 if event.type == pygame.KEYDOWN and event.key ==
pygame.K_ESCAPE:
48 NoFin=False
49 pos=pygame.mouse.get_pos()
50 pos=pos[0]/self.celda[0],pos[1]/self.celda[1]
51 b1,b2,b3= pygame.mouse.get_pressed()
52 rect=pygame.Rect(pos[0]*self.celda[0],pos[1]*self.celda
[1],self.celda[0],self.celda[1])
53 if b1:
54 self.s.blit(self.elementos[celdaIndx],rect)
55 self.mapa[pos[1]][pos[0]]=celdaIndx
56 elif b3:
57 self.s.blit(self.elementos[0],rect)
58 self.mapa[pos[1]][pos[0]]=0
59 pygame.display.flip()
60 pygame.display.set_caption(titulo[0])
61
62 def SalvaMapa(self,fn):
63 f=csv.writer(open(fn,"wr"),delimiter=’,’)
64 for l in self.mapa:
65 f.writerow(l)
66
67 def CargaMapa(self,fn):
68 f=csv.reader(open(fn,’rb’),delimiter=’,’)
69 self.mapa=[]
70 for r in f:
71 self.mapa.append([int(x) for x in r])
72 def DefMuros(self):
73 self.ModificaMapa(1,’Definir Muros’)
74 def Entrada(self):
75 self.ModificaMapa(2,’Definir Entrada’)
76 def Salida(self):
77 self.ModificaMapa(3,’Defir Salida’)
C28
[882] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
dicando que sus aplicaciones son Reactivo, con capacidad de respuesta a cambios en el entorno donde está
agentes. Los agentes están de mo- situado.
da.
[884] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
Proactivo, con capacidad de decidir cómo comportarse para cumplir sus planes
u objetivos.
Social, con capacidad de comunicación con otros agentes.
Así, para que un software pudiera ser considerado un “agente” debería de tener las
características antes expuestas. No obstante, a lo largo de los años, los investigadores
del campo han usado nuevas características para calificar a los agentes, a continuación
se citan algunas de las recopiladas por Franklin y Graesser [36]:
puede determinar con precisión el estado que se alcanza desde el estado actual y
la acción del agente. Los entornos reales generalmente son estocásticos, siendo
lo preferible tratar con entornos deterministas.
[886] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
Estudiar el entorno.
Analizar que es lo qué debe ser capaz de hacer el agente. Estudiar cuales son
los comportamientos deseados y cuando ocurren en función del entorno.
Asociar a cada comportamiento una funcionalidad. Establecer que es lo que
debe ocurrir como acción asociada a cada comportamiento.
Relacionar los comportamientos. Establecer las conexiones que existen entre
Figura 28.74: Pasos en el diseño
los comportamientos, es decir como se conectan y cuando ocurrren. de agentes empleando los compor-
tamientos como guía de diseño.
El agente reactivo, en alguna de sus variantes, es el más fácil de construir para
programar agentes basados en comportamientos, ya que realiza tareas sencillas y
de una forma rápida. Es por esto, por lo que resulta más útil en el campo de los
videojuegos. El esquema general del programa de agente reactivo es el que se muestra
en la figura 28.75.
Como se puede observar su funcionamiento se basa en una recepción de percep-
ciones del entorno (proporcionados por los sensores) que al ser interpretada produce
un cambio en el estado interno del agente, lo cual lleva asociada la selección del pro-
cedimiento o rutina con la que se debe reaccionar en ese estado a esa percepcion, y
por último se produce la reacción propiamente dicha. Ésta consiste en la ejecución
del procedimiento o rutina. En este esquema de funcionamiento no hay ningún meca-
nismo explícito de representación del conocimiento ni hay procesos de manipulación
sobre el mismo, es esto lo que simplifica el diseño y construcción del agente, así como
su uso con tiempos de respuesta inmediatos.
28.5. Diseño basado en agentes [889]
estado ← INTERPRETAR(percepciones;
regla ← ELEGIR-REGLA(estado,reglas);
accion ← DISPARO(regla);
return accion;
Este tipo de agente se suele diseñar de forma parecida a los sistemas basados en
reglas, en los que existe una base de reglas y un motor de inferencia. Cada regla enlaza
una percepción con una acción y el sistema de inferencia determina que regla ejecutar
en función de la percepción recibida.
Los agentes reactivos más usados en el campo de los videojuegos son los agentes
reactivos basados en modelos. Para el diseño de este tipo de agentes se emplean
autómatas de estados finitos (en la Sección 28.1.4 ya se introdujo la idea). Se divide el
comportamiento general del objeto o personaje virtual del videojuego en un número
finito de estados, cada uno de ellos corresponde a una situación del entorno, que tendrá
asociado un comportamiento particular del personaje y se establecen las transiciones
que pueden ocurrir entre ellos y las condiciones o reglas que se deben cumplir
para pasar de un estado a otro de una manera determinista, lo cual implica que sea
predecible el comportamiento.
En la figura 28.76 se muestra cual es el esquema de funcionamiento del agente
reactivo basado en un atómata de estados finitos (FSM (Finite State Machine)). El
funcionamiento de este agente se basa en a partir del estado actual, almacenado en
la memoria, comprobar que regla de las posibles reglas que le permiten cambiar de
estado puede ser disparada en función de las percepciones del entorno recibidas. El
disparo de esa regla le permite cambiar de estado y ejecutar la acción asociada al
nuevo estado que se alcance. Una vez ejecutada esta acción hay que convertir el estado
alcanzado como estado actual. Conviene notar que solo será posible disparar una única
regla y que por lo tanto en cada instante el autómata se encontrará en un único estado.
La elección de los autómatas como arquitectura de funcionamiento se debe a que:
regla ← ELEGIR-REGLA(percepcion,memoria,reglas);
estado ← REALIZAR-CAMBIO-ESTADO(memoria,regla);
accion ← EJECUTAR-ACCION(estado);
memoria ← ACTUALIZAR-ESTADO(estado);
return accion;
Figura 28.76: Visión abstracta del funcionamiento de un agente reactivo basado en un atómata de estados
finitos (FSM).
No es objetivo de esta sección dar una definición formal del autómata, para el
propósito de este curso basta con saber que es un modelo de comportamiento de un
sistema o un objeto complejo y que se compone de estados, eventos y acciones. Un
estado representa una situación. Un evento es un suceso que provoca el cambio de un
estado a otro. Una acción es un tipo de comportamiento.
a,b
a,b,c
a q1 c
q3
b,c
q2 a,b,c
q0
Figura 28.77: Autómata que reconoce el lenguaje a(a|b)∗ |(b|c)(a|b|c)∗ , p.e. la cadena abb pertenecería
al lenguaje que reconoce el autómata, sin embargo la cadena acc no pertenecería a dicho lenguaje.
Shadow (Blinky)
Si han pasado
2 seg
Persig � iendo Huyendo
Inactivo
Perseguir(); Si Comecocos ha
Huir();
Comido una pastilla
o punto especial
Si Comecocos lo traga
Figura 28.78: Comportamiento del fantasma Shadow (Blinky) del juego del Comecocos (PacMan),
modelado por medio de una máquina de Moore.
estado_siguiente = matriz[estado_actual][entrada]
Esto no es más que una implementación de la tabla de transiciones del autómata.
La matriz asociada al autómata de la figura 28.77, es la que se muestra en la figura
28.79. El código en lenguaje C, que corresponde a la implementación del autómata de
la figura 28.77 de esta forma, es el que se muestra a continuación.
a b c
Listado 28.24: Autómata de la figura 28.77 (tabla transición).
→ q0 q1 q2 q2
1 #include <stdio.h>
2 • q1 q1 q1 q3
3 /* Se define la Tabla de Transicion del automata */
4 • q2 q2 q2 q2
5 #define N 4 //numero de filas de la tabla de transicion
6 #define M 3 //numero de columnas de la tabla de transicion q3 q3 q3 q3
7
8 int TablaTrans[N][M] = { 1,2,2, Figura 28.79: Tabla de Transición
9 1,1,3, para el autómata de la figura 28.77.
10 2,2,2,
11 3,3,3};
12
13 int AFD() {
14 int estado_actual, estado_siguiente;
15 int c;
16
17 estado_actual=0;
18 c=getchar();
19
20 while (c!=’\n’) {
21 switch (c) {
22 case ’a’: estado_siguiente=TablaTrans[estado_actual][0];
23 break;
24 case ’b’: estado_siguiente=TablaTrans[estado_actual][1];
25 break;
26 case ’c’: estado_siguiente=TablaTrans[estado_actual][2];
27 break;
28 default: return 0;
29 }
30 if (c!=’\n’) estado_actual=estado_siguiente;
31 c=getchar();
32 }
33 if ((estado_actual==1) || (estado_actual==2)) return 1;
34 else return 0;
35 }
36
37 int main (int argc,char *argv[]) {
38 if(AFD())
39 printf("Es una cadena del Lenguaje\n");
40 else
41 printf("\nNo es una cadena del Lenguaje \n");
42 return 1;
43 }
7
8 int main() {
9 string palabra;
10 int n, i, mensaje=0;
11 StateType CurrentState;
12
13 cout << "Introduzca la cadena a analizar: "; cin >> palabra;
14 n = palabra.length(); i = 0;
15 CurrentState = q0;
16 while (i<=n-1) {
17 switch(CurrentState) {
18
19 case q0:
20 if (palabra[i]==’a’) CurrentState=q1;
21 else if (palabra[i]==’b’ || palabra[i]==’c’) CurrentState=q2;
22 else CurrentState=error;
23 break;
24
25 case q1:
26 if (palabra[i]==’a’ || palabra[i]==’b’) CurrentState=q1;
27 else if (palabra[i]==’c’) CurrentState=q3;
28 else CurrentState=error;
29 break;
30
31 case q2:
32 if ((palabra[i]==’a’) || (palabra[i]==’b’) || (palabra[i]==’c
’))
33 CurrentState=q2;
34 else CurrentState=error;
35 break;
36
37 case q3:
38 if ((palabra[i]==’a’) || (palabra[i]==’b’) || (palabra[i]==’c
’))
39 CurrentState=q3;
40 else CurrentState=error;
41 break;
42
43 default:
44 if (!mensaje) {
45 cout << "Error alfabeto no valido" << endl;
46 cout << palabra[i-1] << endl;
47 mensaje=1;
48 }
49 }
50 i++;
51 }
52
53 if ((CurrentState==q1) || (CurrentState==q2))
54 cout << "La cadena " + palabra + " pertenece al lenguaje." <<
endl;
55 else cout << "La cadena " + palabra + " NO pertenece al lenguaje.
" << endl;
56
57 return 0;
58 }
2 #include <string>
3
4 using namespace std;
5
[896] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
74 i = 0;
75 ejemplo.Inicializar();
76
77 while (i<=n-1) {
78 ejemplo.UpdateState();
79 i++;
80 }
81
82 State=ejemplo.Informar();
83 if ((State==q1) || (State==q2))
84 cout << "La cadena " + palabra + " pertenece al lenguaje." <<
endl;
85 else cout << "La cadena " + palabra + " NO pertenece al lenguaje.
" << endl;
86 return 0;
87 }
Se ha creado una clase llamada “automata”, que se usará para crear un objeto
autómata. Esta clase contiene una variable privada, CurrentState, que almacenará
el estado en el que se encuentra el autómata en cada momento. A esta variable so-
lo se podrá acceder desde las funciones miembro de la clase, que son: Inicializar(),
ChangeState(), UpdateState() y Informar(). La función Inicializar(() es una fun-
ción empleada para poner al autómata en el estado inicial. La función ChangeState()
es empleada para cambiar el estado en el que se encuentra el autómata.
La función Informar() devuelve el estado en el que está el autómata en un instante
cualquiera. Por último, la función UpdateState() es la que implementa la tabla de
transiciones del autómata. Para conseguir el funcionamiento deseado lo que se hará
será crear un objeto de la clase automata, posteriormente será inicializado, luego se
consultará la tabla de transiciones (ejecuciones de la función UpdateState()) mientras
la cadena que se esté analizando tenga elementos, momento en el que se consultará el
estado en el que se queda el autómata tras leer toda la cadena para chequear si es un
estado de aceptación o no.
En todas las implementaciones, la cadena de entrada será aceptada si, una vez
leída, el estado que se alcanza es uno perteneciente al conjunto de estados finales. En
caso contrario, la cadena no será un elemento del lenguaje que reconoce el autómata.
los estímulos que disparan cada regla deben ser diferentes, se recuerda la necesidad
de estar en un único estado en cada instante del funcionamiento del autómata (son
deterministas).
[898] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
Los juegos que son desarrollados usando lenguajes de alto nivel, como por ejemplo
C o C++, típicamente almacenan todos los datos relacionados a cada agente en una
estructura o clase. Esta estructura o clase puede contener variables para almacenar
información como la posición, vida, fuerza, habilidades especiales, y armas, entre
muchas otras. Por supuesto, junto a todos estos elementos, en la estructura o clase
también se almacenará el estado actual del autómata, y será éste el que determine el
comportamiento del agente.
En el siguiente código se muestra cómo se podría almacenar los datos que definen
al agente dentro de una clase.
c� r32
r01
r11 r31
c0 c3
r21
c2
Para implementar este tipo de máquinas de estado finito dotadas con salida, o
más concretamente agentes como los comentados en la sección anterior para su uso
en videojuegos, existen varias alternativas. Éstas están basadas en las que ya se
han presentado en la sección anterior pero con mejoras. La manera más simple es
por medio de múltiples sentencias “If-Else” o la mejora inmediata al uso de estas
sentencias, por medio de una sentencia “switch”. La sentencia switch(estado_actual)
es una sentencia de selección. Esta sentencia permite seleccionar las acciones a
realizar de acuerdo al valor que tome la variable “estado_actual”, habrá un “case”
para cada estado posible. La forma general de usar esta sentencia para implementar
una máquina de estados es la siguiente:
1 switch (estado_actual)
2 {
3 case estado 1:
4 Funcionalidad del comportamiento del estado 1;
5 Transiciones desde este estado;
6 break;
7 case estado 2:
8 Funcionalidad del comportamiento del estado 2;
9 Transiciones desde este estado;
10 break;
11 case estado N:
12 Funcionalidad del comportamiento del estado N;
13 Transiciones desde este estado;
14 break;
15 default:
16 Funcionalidad por defecto;
17 }
Listado 28.29: Implementación del autómata que simula el comportamiento del fantasma en el
juego del ComeCocos.
1 enum StateType{Inactivo, Huyendo, Persiguiento};
2
3 void Ghost::UpdateState(StateType CurrentState, GhostType GType)
4 {
5
6 switch(CurrentState)
7 {
8
9 case Huyendo:
10 Huir(GType);
11 if (PacManTraga())
12 {
13 ChangeState(Inactivo);
14 }
15 else if (Temporizador(10)) ChangeState(Persiguiendo);
16 break;
17
18 case Persiguiendo:
19 Perseguir(GType);
20 if (PacManComePastilla()) ChangeState(Huyendo);
21 break;
22
23 case Inactivo:
24 Regenerar();
25 if (Temporizador(2)) ChangeState(Persiguiendo);
26 break;
27 }
28 }
Figura 28.81: Los juegos de fran-
cotiradores son juegos con gran Otro ejemplo más sobre esta forma de implementar los autómatas, se muestra en
aceptación (p.e. Sniper Elite V2), el siguiente código, que corresponde a la implementación de la máquina de estados
probablemente por estar conectados
C28
Listado 28.30: Implementación del autómata que simula el comportamiento de un soldado que
se enfrenta a un francotirador en un juego de tipo FPS (p.e. Sniper Elite V2)
1 enum StateType{Patrullando, Persiguiendo, Disparando, Huyendo,
Inactivo};
2
3 void Soldier::UpdateState(StateType CurrentState)
4 {
5 switch(CurrentState)
6 {
7 case Patrullando:
8 Patrullar();
9 if (ObjetivoLocalizado()) ChangeState(Persiguiendo);
10 else (AtaqueRecibido()) ChangeState(Huyendo);
11 break;
12
13 case Persiguiendo:
14 Perseguir();
15 if (ObjetivoVulnerable()) ChangeState(Disparando);
16 else ChangeState(Huyendo);
17 break;
18
19 case Disparando:
20 if (ObjetivoAbatible()) Disparar();
21 else ChangeState(Persiguiendo);
22 break;
23
24 case Huyendo:
25 Huir();
26 if (PuestoaSalvo()) ChangeState(Patrullando);
27 else if (Abatido()) ChangeState(Inactivo);
28 break;
29
30 case Inactivo:
31 Regenerar();
32 if (TranscurridoTiempo()) ChangeState(Patrullando);
33 break;
34 }
35 }
Aunque a primera vista, este enfoque puede parecer razonable por lo menos en
autómatas no complejos, cuando se aplica a juegos, o mejor aún, a comportamientos
de objetos más complejos, la solución presentada se convierte en un largo y tortuoso
camino. Cuantos más estados y condiciones se tengan que manejar, más riesgo hay de
convertir este tipo de estructura en código spaghetti (vea la figura 28.82), por lo que
el flujo del programa será difícil de entender y la tarea de depuración una pesadilla.
Además, este tipo de implementación es difícil de extender más allá del propósito de
28.5. Diseño basado en agentes [901]
Cuadro 28.6: Tabla de Transición para el autómata implementado para simular el comportamiento del
fantasma en el juego del Comecocos (vea Figura 28.78.
Listado 28.31: Implementación del autómata que simula el comportamiento del fantasma en el
juego del Comecocos
1 #include <iostream>
2 #include <string>
3
4 using namespace std;
C28
5
6 #define N 4 //numero de filas de la tabla de transicion
7 #define M 4 //numero de columnas de la tabla de transicion
[902] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
8
9 enum StateType {inactivo, persiguiendo, huyendo, error};
10 enum Rule {regenerado, pastillacomida, transcurridotiempo, comido};
11
12 class ghost {
13
14 StateType CurrentState;
15
16 /* Se define la Tabla de Transicion del automata */
17 StateType TablaTrans[N][M];
18
19 public:
20 void Inicializar(void);
21 void ChangeState(StateType State);
22 void UpdateState(Rule Transicion);
23 StateType Informar(void);
24 void Huir(void);
25 void Perseguir(void);
26 void Regenerar(void);
27 void Error(void);
28 };
29
30 void ghost::Inicializar(void) {
31 CurrentState=inactivo;
32 TablaTrans[0][0] = persiguiendo; TablaTrans[0][1] = error;
33 TablaTrans[0][2] = error; TablaTrans[0][3] = error;
34 TablaTrans[1][0] = error; TablaTrans[1][1] = huyendo;
35 TablaTrans[1][2] = error; TablaTrans[1][3] = error;
36 TablaTrans[2][0] = error; TablaTrans[2][1] = error;
37 TablaTrans[2][2] = persiguiendo; TablaTrans[2][3] = inactivo;
38 TablaTrans[3][0] = error; TablaTrans[3][1] = error;
39 TablaTrans[3][2] = error; TablaTrans[3][3] = error;
40 }
41
42 StateType ghost::Informar(void) {
43 return CurrentState;
44 }
45
46 void ghost::ChangeState(StateType State) {
47 CurrentState=State;
48 }
49
50 void ghost::UpdateState(Rule Transicion) {
51 StateType NextState;
52
53 NextState=TablaTrans[CurrentState][Transicion];
54 ChangeState(NextState);
55
56 switch(CurrentState) {
57 case huyendo:
58 Huir();
59 break;
60 case persiguiendo:
61 Perseguir();
62 break;
63 case inactivo:
64 Regenerar();
65 break;
66 default: Error();
67 }
68 }
69
70 /* Funciones de prueba */
71 void ghost::Huir(void) {
72 cout << "El fantasma huye!" << endl;
73 }
74
75 void ghost::Perseguir(void) {
76 cout << "El fantasma te persigue!" << endl;
77 }
78
28.5. Diseño basado en agentes [903]
79 void ghost::Regenerar(void) {
80 cout << "El fantasma se regenera!" << endl;
81 }
82
83 void ghost::Error(void) {
84 cout << "El fantasma esta en un estado de error!" << endl;
85 }
86
87 int main() {
88 ghost shadow;
89 StateType State;
90 string palabra;
91 shadow.Inicializar();
92 cout<<"Introduzca la regla: ";
93 cin>>palabra;
94
95 while (palabra.compare("salir")!=0) {
96 if (palabra.compare("regenerado")==0)
97 shadow.UpdateState(regenerado);
98 else if (palabra.compare("pastillacomida")==0)
99 shadow.UpdateState(pastillacomida);
100 else if (palabra.compare("transcurridotiempo")==0)
101 shadow.UpdateState(transcurridotiempo);
102 else if (palabra.compare("comido")==0)
103 shadow.UpdateState(comido);
104 else cout << "Accion no valida" << endl;
105 cout<<"Introduzca la regla: ";
106 cin>>palabra;
107 }
108
109 State=shadow.Informar();
110
111 if (State==error)
112 cout << "El fantasma no funciona bien." << endl;
113 else cout << "El fantasma funciona perfecto." << endl;
114
115 return 0;
116 }
La idea de este patrón es tener una clase, Contexto (Context class), que tendrá
un comportamiento dependiendo del estado actual en el que se encuentre, es decir un
comportamiento diferente según su estado. También tiene una clase Estado abstracta
(State Class) que define la interfaz pública de los estados. En ella, se pondrán
todas las funciones del Contexto cuyo comportamiento puede variar. Luego hay una
implementación de los estados concretos, que heredarán de la clase Estado abstracto.
La clase Contexto, poseerá un puntero sobre el estado actual, que será almacenado en
una variable miembro de la clase. Cuando se desea que el Contexto cambie de estado,
solo hay que modificar el estado actual.
El uso de este patrón implicará:
Definir una clase “contexto” para presentar una interfaz simple al exterior.
Definir una clase base abstracta “estado”.
Representar los diferentes “estados” del autómata como clases derivadas de la
clase base “estado”.
Definir la conducta específica de cada estado en la clase apropiada de las
definidas en el paso anterior (derivadas de la clase abstracta “estado”).
Mantener un puntero al estado actual en la clase “contexto”.
Para cambiar el estado en el que se encuentra el autómata, hay que cambiar el
puntero hacia el estado actual.
El patrón de diseño State no especifica donde son definidas las transiciones entre
estados. Esto se puede hacer de dos formas diferentes: en la clase “Context”, o en cada
estado individual derivado de la clase “State”. En el código mostrado anteriormente
se ha realizado de esta segunda forma. La ventaja de hacerlo así es la facilidad que
ofrece para añadir nuevas clases derivadas de la clase “State”. Su inconveniente es
que cada estado derivado de la clase “State” tiene que conocer como se conecta con
otras clases, lo cual introduce dependencias entre las subclases.
A modo de resumen, conviene indicar que no hay una única manera de decribir
estados. Dependiendo de la situación podría ser suficiente con crear una clase abstracta
llamada State que posea unas funciones Enter(), Exit() y Update(). La creación de
los estados particulares del autómata que se quiera implementar implicaría crear una
subclase de la clase State por cada uno de ellos.
Listado 28.32: Implementación del autómata que simula el comportamiento del fantasma en el
juego del Comecocos.
1 #include <iostream>
2 #include <string>
3
4 using namespace std;
5
6 class FSM;
7
8 // Clase base State.
9 // Implementa el comportamiento por defecto de todos sus métodos.
10 class FSMstate {
11 public:
12 virtual void regenerado( FSM* ) {
13 cout << " No definido" << endl; }
14 virtual void pastillacomida( FSM* ) {
15 cout << " No definido" << endl; }
16 virtual void transcurridotiempo( FSM* ) {
17 cout << " No definido" << endl; }
18 virtual void comido( FSM* ) {
19 cout << " No definido" << endl; }
20 protected:
21 void changeState(FSM*, FSMstate*);
22 };
23
24 // Automa context class. Reproduce el intefaz de la clase State y
25 // delega todo su comportamiento a las clases derivadas.
26 class FSM {
27 public:
28 FSM();
29 void regenerado() { _state->regenerado(this); }
30 void pastillacomida() { _state->pastillacomida(this); }
31 void transcurridotiempo() { _state->transcurridotiempo(this); }
32 void comido() { _state->comido( this ); }
33
34 private:
35 friend class FSMstate;
36 void changeState( FSMstate* s ) { _state = s; }
37 FSMstate* _state;
38 };
39
40 void FSMstate::changeState( FSM* fsm, FSMstate* s ) {
41 fsm->changeState( s ); }
42
43 // Clase derivad de State.
44 class Inactivo : public FSMstate {
45 public:
46 static FSMstate* instance() {
if ( ! _instance ) _instance = new Inactivo;
C28
47
48 cout << "El fantasma se regenera!" << endl;
49 return _instance; };
50 virtual void regenerado( FSM* );
[906] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
51 private:
52 static FSMstate* _instance; };
53
54 FSMstate* Inactivo::_instance = 0;
55
56 class Persiguiendo : public FSMstate {
57 public:
58 static FSMstate* instance() {
59 if ( ! _instance ) _instance = new Persiguiendo;
60 cout << "El fantasma te persigue!" << endl;
61 return _instance; };
62 virtual void pastillacomida( FSM* );
63 private:
64 static FSMstate* _instance; };
65
66 FSMstate* Persiguiendo::_instance = 0;
67
68 class Huyendo : public FSMstate {
69 public:
70 static FSMstate* instance() {
71 if ( ! _instance ) _instance = new Huyendo;
72 cout << "El fantasma huye!" << endl;
73 return _instance; };
74 virtual void transcurridotiempo( FSM* );
75 virtual void comido( FSM* );
76
77 private:
78 static FSMstate* _instance; };
79 FSMstate* Huyendo::_instance = 0;
80
81 void Inactivo::regenerado( FSM* fsm ) {
82 cout << "Cambio de Estado a Persiguiendo." << endl;
83 changeState( fsm, Persiguiendo::instance()); };
84
85 void Persiguiendo::pastillacomida( FSM* fsm ) {
86 cout << "Cambio de Estado a Huyendo." << endl;
87 changeState( fsm, Huyendo::instance()); };
88
89 void Huyendo::transcurridotiempo( FSM* fsm ) {
90 cout << "Cambio de Estado a Persiguiendo." << endl;
91 changeState( fsm, Persiguiendo::instance()); };
92
93 void Huyendo::comido( FSM* fsm ) {
94 cout << "Cambio de Estado a Inactivo." << endl;
95 changeState( fsm, Inactivo::instance()); };
96
97 // El estado de comienzo es Inactivo
98 FSM::FSM() {
99 cout << "Inicializa:" << endl;
100 changeState( Inactivo::instance() );
101 }
102
103 int main() {
104 FSM ghost;
105 string input;
106
107 cout<<"Introduzca regla: ";
108 cin>> input;
109
110 while (input.compare("salir")!=0) {
111 if (input.compare("regenerado")==0)
112 ghost.regenerado();
113 else if (input.compare("pastillacomida")==0)
114 ghost.pastillacomida();
115 else if (input.compare("transcurridotiempo")==0)
116 ghost.transcurridotiempo();
117 else if (input.compare("comido")==0)
118 ghost.comido();
119 else cout << "Regla no existe, intente otra" << endl;
120
121 cout<<"Introduzca regla: ";
28.5. Diseño basado en agentes [907]
Usando sentencias Switch-If/Else. Como pros hay que destacar dos, la primera
es que es muy fácil añadir y chequear condiciones que permitan cambiar de
estado, y la segunda es que permite la construcción rápida de prototipos. La
principal contra es que cuando se tiene un número elevado de estados, el
código se puede hacer con relativa rapidez, muy enredado y díficil de seguir
(código “spaghetti”’). Otra contra importante, es que es difícil programar
efectos asociados a la entrada salida de un estado (y se recuerda que esto es
muy habitual en el desarrollo de videojuegos).
Usando una implementación orientada a objetos. Este método posee como
Pros:
• Altamente extensible: Incluir un nuevo estado simplemente implica crear
una nueva clase que hereda de la clase abstracta State.
• Fácil de mantener: Cada estado reside en su propia clase, se podrá ver
fácilmente las condiciones asociadas a ese estado que le permiten cambiar
de estado sin tener que preocuparse acerca de los otros estados.
• Intuitivo: Este tipo de implementación es más fácil de entender cuando se
usa para modelar comportamientos complejos.
Como contra principal, hay que destacar el tiempo de aprendizaje de uso de este
método que es superior al anterior.
c0 c1
c02 r32
r01 c11
r11 r31
c01 c04
c0
c12
c03 r21
Una lista de precondiciones. Estas deben ser ciertas para poder aplicar el
operador.
Una lista de proposiciones a añadir. La aplicación del operador implica añadir
estas proposiciones al estado actual.
Una lista de proposiciones a eliminar. La aplicación del operador implica
eliminar estas proposiones del estado actual.
Huir
Estado Inicial
Correr
Coger Descansar
Arma
Cargar
Arma
Ponerse a Apuntar
salvo
Curarse Disparar
Estado Objetivo
Jugador Abatido
El plan se ejecutará hasta que se complete, se invalide o hasta que haya un cambio
de objetivo. Si cambia el objetivo o no se puede continuar con el plan actual por alguna
razón, se aborta el plan actual y se genera uno nuevo. Existe también la posibilidad de
que cuando exista más de un plan válido, el planificador obtenga el de menor coste,
considerando que cada acción acarrea un coste. En cierto modo, tras todo este proceso
lo que hay es una búsqueda de un camino o del camino de menor coste.
En la figura 28.85 se muestra un plan para matar al jugador, en el se pasa del estado
inicial al estado objetivo mediante las acciones: Coger Arma, Cargar Arma, Apuntar
y Disparar.
Con el uso de GOAP:
Mushroom Men: The Spore Wars (Wii) - Red Fly Studio, 2008
Los Cazafantasmas (Wii) - Red Fly Studio, 2008
Silent Hill: Homecoming (X360/PS3) - Double Helix Games / Konami de 2008
Fallout 3 (X360/PS3/PC) - Bethesda Softworks, 2008
Figura 28.86: Los desarrolladores
del juego S.T.A.L.K.E.R.: Shadow Empire: Total War (PC) - Creative Assembly / Sega, 2009
of Chernobyl han usado agentes
reactivos basados en FSM, después FEAR 2: Project Origin (X360/PS3/PC) - Monolith Productions Bros / Warner,
han empleado agentes basados en 2009
HFSM, para acabar usando agentes
deliberativos basados en planifica- Demigod (PC) - Gas Powered Games / Stardock, 2009
dores GOAP jerárquicos.
Just Cause 2 (PC/X360/PS3) - Avalanche Studios / Eidos Interactive, 2010
Transformers: War for Cybertron (PC/X360/PS3) - High Moon Studios /
Activision, 2010
Trapped Dead (PC) - Juegos Headup, 2011
Deus Ex: Human Revolution (PC/X360/PS3) - Eidos Interactive, 2011
Abatir al Jugador
Armarse
Apuntar Disparar
Coger Cargar
Arma Arma
El reto en el desarrollo de los videojuegos, es hacer que estos sean más naturales,
más interactivos y más sociables. El uso de la IA en los videojuegos está en una fase
de progreso significativo.
Por otra parte hay que indicar que los videojuegos son un gran reto para la IA y
representan un campo de investigación en sí mismos, tratando de proponer métodos
y técnicas que aporten un tipo de inteligencia que aunque no sea perfecta, si permita
mejorar la experiencia de juego.
Hay que tener en cuenta que las situaciones manejadas por la IA son altamente
dependientes del tipo juego y de sus requisitos de diseño, por lo que es posible que
una solución utilizada en un videojuego no pueda ser aplicable en otro. Esto conduce
al desarrollo de técnicas específicas. Por lo tanto, éste es un campo que ofrece un gran
potencial en términos de investigación y desarrollo de nuevos algoritmos.
El objetivo de los videojuegos es desarrollar mundos virtuales, con el máximo
parecido posible al mundo real, tanto a nivel visual como funcional y de interacción.
En este sentido hay que destacar que en el mundo real es habitual el concepto de
organización, es decir dos o más personas se juntan para cooperar entre sí y alcanzar
objetivos comunes, que no pueden lograrse, o bien cuesta más lograrlos, mediante
iniciativa individual.
En el campo del desarrollo de videojuegos este concepto deberá ser tratado,
permitiendo a los personajes organizarse y cooperar para lograr un objetivo común,
por ejemplo abatir al jugador o conseguir ganar una batalla o un partido. Por una
parte, la organización implicará la existencia de una jerarquía y una distribución
y especialización en tareas, y por otra la cooperación, implica la existencia de un
mecanismo de comunicación común. Además, en el mundo real también es necesario
facilitar la movilidad de profesionales, las organizaciones mejoran sus modos de
trabajo gracias a la aportación de nuevos miembros que poseen mejor cualificación.
En el campo de los videojuegos, la movilidad implicaría permitir que personajes
virtuales con un desempeño demostrado pudieran desplazarse de unas instacias de
juego a otras, a través la red. Y esto último permite introducir otro aspecto de suma
importancia, la capacidad de adaptabilidad o aprendizaje, un personaje mejorará
su desempeño si tiene capacidad para aprender. Todo esto conduce a los sistemas
multiagentes que tendrán mucho que aportar en los próximos años en el campo del
desarrollo de videojuegos.
Los sistemas multiagentes proporcionan el marco donde un conjunto de agentes
con tareas y habilidades claramente definidas colaboren y cooperen para la consecu-
sión de sus objetivos. Existen multitud de metodologías y enfoques para el diseño de
sistemas multiagentes así como plataformas para su desarrollo. Estas proporcionan
soporte a los diseñadores y desarrolladores para construir sistemas fiables y robustos,
pero seguro que será necesaria su adaptación para el mundo de los videojuegos.
...se espera que un equipo de fútbol particularmente en el fútbol, no es una tarea trivial debido a la complejidad que supone
compuesto de robots físicos autóno- crear agentes autónomos capaces de jugar de una forma similar a la de un contrincante
mos sea capaz de derrotar a un equi-
po de fútbol real. humano.
[914] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
Pygame es altamente portable y, por lo tanto, se puede ejecutar sobre una gran
cantidad de plataformas y sistemas operativos. Además, mantiene una licencia LGPL5 ,
permitiendo la creación de open source, free software, shareware e incluso juegos
comerciales.
A continuación se enumeran las principales características de Pygame:
5 http://www.pygame.org/LGPL
6 http://www.pygame.org/docs/ref/surface.html
[916] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
Por otra parte, el método render() contiene las primitivas de Pygame necesarias
para dibujar los aspectos básicos del terreno de juego. Note cómo se hace uso de las
primitivas geométricas rect, circle y line, respectivamente, para dibujar las líneas de
fuera y el centro del campo. Además de especificar el tipo de primitiva, es necesario
indicar el color y, en su caso, el grosor de la línea.
Típicamente, el método render() se ejecutará tan rápidamente como lo permitan
las prestaciones hardware de la máquina en la que se ejecute el ejemplo, a no ser que
se establezca un control explícito del número de imágenes renderizadas por segundo. Figura 28.91: Resultado del rende-
rizado de un terreno de juego sim-
El siguiente listado de código muestra cómo inicializar Pygame (línea 14) e plificado mediante Pygame.
instanciar el terreno de juego (línea 17). El resto de código, es decir, el bucle infinito
permite procesar los eventos recogidos por Pygame en cada instante de renderizado. Event-oriented
Note cómo en cada iteración se controla si el usuario ha cerrado la ventana gráfica,
capturado mediante el evento QUIT, y se lleva a cabo el renderizado del terreno de La combinación de un esquema ba-
sado en eventos y una arquitectura
juego (línea 27) junto con la actualización de Pygame (línea 29). con varias capas lógicas facilita la
delegación y el tratamiento de even-
La gestión de la tasa de frames generados por segundo es simple mediante la clase tos.
Clock. De hecho, sólo es necesario especificar la tasa de frames deseada mediante la
función clock.tick(FPS) para obtener una tasa constante.
framework propuesto [16]. Más adelante se prestará especial atención a cómo incluir
nuevos tipos de comportamiento asociados a los propios jugadores.
[918] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
La figura 28.93 muestra, desde un punto de vista de alto nivel, el diagrama de cla-
ses de la arquitectura propuesta. Como se puede apreciar, la clase SoccerField aparece
en la parte superior de la figura y está compuesta de dos equipos (SoccerTeam), dos
porterías (SoccerGoal), un balón (SoccerBall) y un determinado número de regiones
(SoccerRegion).
Por otra parte, el equipo de fútbol está compuesto por un determinado número de
jugadores, cuatro en concreto, modelados mediante la clase SoccerPlayer, que a su
vez mantiene una relación de herencia con la clase MovingEntity. A continuación se
realizará un estudio más detallado de cada una de estas clases.
El terreno de juego
SoccerField
1..n 2 2 1
SoccerPlayer MovingEntity
ball, de tipo SoccerBall, que representa al balón usado para llevar a cabo las
simulaciones.
teams, de tipo diccionario, que permite indexar a dos estructuras de tipo
SoccerTeam a partir del identificador del equipo (red o blue).
goals, de tipo diccionario, que permite indexar a dos estructuras de tipo
SoccerGoal a partir del identificador del equipo (red o blue).
3
4 class SoccerField:
5
6 def __init__ (self, width, height):
7
8 self.width, self.height = width, height
9
10 self.surface = pygame.display.set_mode(
11 (self.width, self.height), 0, 32)
12 # Terreno real de juego.
13 self.playing_area = Rect(TOP_LEFT, WIDTH_HEIGHT)
14
15 # Walls.
16 self.walls = []
17 # Cálculo de coordenadas clave del terreno de juego.
18 self.walls.append(Wall2d(top_left, top_right))
19 self.walls.append(Wall2d(top_right, bottom_right))
20 self.walls.append(Wall2d(top_left, bottom_left))
21 self.walls.append(Wall2d(bottom_left, bottom_right))
22
23 # Zonas importantes en el campo.
24 self.regions = {}
25 # Cálculo de las regiones...
26
27 # Bola.
28 self.ball = SoccerBall(Vec2d(self.playing_area.center),
29 10, 2, Vec2d(0, 0), self)
30
31 # Equipos
32 self.teams = {}
33 self.teams[’red’] = SoccerTeam(’red’, RED, 4, self)
34 self.teams[’blue’] = SoccerTeam(’blue’, BLUE, 4, self)
35
36 # Porterías.
37 self.goals = {}
38 self.goals[’red’] = SoccerGoal(
39 ’red’, RED, self.playing_area.midleft, self)
40 self.goals[’blue’] = SoccerGoal(
41 ’blue’, BLUE, self.playing_area.midright, self)
En la figura 28.94 se muestra la división lógica del terreno de juego en una serie
de regiones con el objetivo de facilitar la implementación del juego simulado, los
comportamientos inteligentes de los jugadores virtuales y el diseño de las estrategias
en equipo.
Internamente, cada región está representada por una instancia de la clase Soc-
cerRegion, cuya estructura de datos más relevante es un rectángulo que define las
dimensiones de la región y su posición.
Aunque cada jugador conoce en cada momento su posición en el espacio 2D, como Más info...
se discutirá más adelante, resulta muy útil dividir el terreno de juego en regiones para El uso de información o estructuras
implementar la estrategia interna de cada jugador virtual y la estrategia a nivel de de datos adicionales permite gene-
equipo. Por ejemplo, al iniciar una simulación, y como se muestra en la figura 28.94, ralmente optimizar una solución y
cada jugador se posiciona en el centro de una de las regiones que pertenecen a su plantear un diseño que sea más sen-
cillo.
campo. Dicha posición se puede reutilizar cuando algún equipo anota un gol y los
jugadores han de volver a su posición inicial para efectuar el saque de centro.
El balón de juego
12
13 class MovingEntity:
14
15 def __init__ (self, pos, radius, velocity, max_speed,
[922] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
Figura 28.94: División del terreno de juego en regiones para facilitar la integración de la IA.
16 heading, mass):
17
18 self.pos = pos
19 self.radius = radius
20 self.velocity = velocity
21 self.max_speed = max_speed
22 self.heading = heading
23 # Vector perpendicular al de dirección (heading).
24 self.side = self.heading.perpendicular
25 self.mass = mass
Dentro de la clase SoccerBall existen métodos que permiten, por ejemplo, detectar
cuándo el balón sale fuera del terreno de juego. Para ello, es necesario comprobar,
para cada uno de los laterales, la distancia existente entre el balón y la línea de fuera.
Si dicha distancia es menor que un umbral, entonces el balón se posicionará en la
posición más cercana de la propia línea para efectuar el saque de banda.
5 for w in self.walls:
6 if distToWall(w.a, w.b, self.pos) < TOUCHLINE:
7 self.reset(self.pos)
Por otra parte, el balón sufrirá el efecto del golpeo por parte de los jugadores
virtuales mediante el método kick(). Básicamente, su funcionalidad consistirá en
modificar la velocidad del balón en base a la fuerza del golpeo y la dirección del
mismo.
pi También resulta muy interesante incluir la funcionalidad necesaria para que los
jugadores puedan prever la futura posición del balón tras un intervalo de tiempo. Para
llevar a cabo el cálculo de esta futura posición se ha de obtener la distancia recorrida
por el balón en un intervalo de tiempo Δt, utilizando la ecuación 28.7,
1
distancia cubierta
14
15 # La posición predicha es la actual
16 # más la suma de los dos términos anteriores.
17 return self.pos + ut + scalarToVector
[924] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
El jugador virtual
8 http://opensteer.sourceforge.net/
[926] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
1
MovingEntity SoccerPlayer SteeringBehaviours
GoalKeeper FieldPlayer
1 4
SoccerTeam
Figura 28.98: Diagrama de clases del modelo basado en comportamientos para la simulación de fútbol.
15 lookAheadTime = 0.0
16
17 if self.ball.velocity.get_length() != 0.0:
18 sc_velocity = self.ball.velocity.get_length()
19 lookAheadTime = toBall.get_length() / sc_velocity
20
21 # ¿Dónde estará la bola en el futuro?
22 target = self.ball.futurePosition(lookAheadTime)
23
24 # Delega en el comportamiento arrive.
25 return self.arrive(target)
Facilidad para llevar a cabo el pase sin que el equipo contrario lo intercepte.
3
Para ello, se puede considerar la posición de los jugadores del equipo rival y el
tiempo que tardará el balón en llegar a la posición deseada. Figura 28.99: Esquema de apoyo
Probabilidad de marcar un gol desde la posición del jugador al que se le quiere de jugadores. El jugador 2 repre-
senta una buena alternativa mien-
pasar.
tras que el jugador 3 representa una
Histórico de pases con el objetivo de evitar enviar el balón a una posición que mala.
ya fue evaluada recientemente.
28.7.1. Introducción
Los sistemas expertos son una rama de la Inteligencia Artificial simbólica que
simula comportamientos humanos razonando con comportamientos implementados
por el diseñador del software. Los sistemas expertos se usan desde hace más de 30
años, siendo unos de los primeros casos de éxito de aplicación práctica de técnicas de
C28
Dado que el número de hechos que suele cambiar tras cada activación de una regla
suele ser bajo, una adecuada organización de condicionantes permite, a nivel práctico,
dar unos tiempos de ejecución adecuados. Si se desea ampliar información sobre este
asunto, el principal libro de referencia es [39].
9 No confundir con Common LISP (CLISP), otro lenguaje cuya sintaxis también se basa en paréntesis
anidados.
[932] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
6
7 (ficha (equipo "A") (num 111) (pos-x 1) (pos-y 1)
8 (puntos 3) (descubierta 0))
9
10 (ficha (equipo "A") (num 929) (pos-x 1) (pos-y 2)
11 (puntos 3) (descubierta 1))
12
13 (ficha (equipo "B") (pos-x 1) (pos-y 3)
14 (descubierta 0)
15
16 (ficha (equipo "B") (pos-x 2) (pos-y 2) (puntos 3)
17 (descubierta 1))
A primera vista, esta regla puede parecer adecuada, al fin y al cabo sólo mueve una
ficha hacia delante, que es donde están los contrarios al comenzar la partida. Y como
su prioridad es bastante baja, sólo se ejecutará si no hay otra regla de mayor prioridad
(que se supone hará una acción mejor). Sin embargo es muy mejorable. Por ejemplo,
10 Los movimientos están definidos como 1 avanza en X, 2 retrocede en X, 3 avanza en Y y 4 retrocede
en Y.
28.7. Sistemas expertos basados en reglas [933]
pudiera ser que la ficha se dirigiera hacia una ficha contraria de mayor puntuación,
como podría ser la ficha de 2 puntos del equipo púrpura que está en la primera fila
del tablero en la figura 28.106. Si se aplica la regla anterior a dicha ficha se estarían
perdiendo varios turnos para suicidarla.
Así que se podría cambiar la regla por la que aparece en el listado 28.47. En este
caso se han añadido dos condiciones nuevas. La primera (línea 5) busca la existencia
de una ficha contraria (equipo “B”) en la misma columna (pues es usa la misma
variable ?x1 para los dos campos pos-x en su línea 5). Y la segunda condición (línea
7) comprueba que la ficha contraria esté arriba de la propia y que tenga menos valor
que esta. Nótese el uso de notación prefija, primero aparece el operador y después los
operandos.
Puede observarse que la primera vez que aparece una variable se instancia al
valor de un hecho del sistema, y a partir de ahí el resto de apariciones en esa y otras
condiciones ya se considera ligada a una constante. En caso de que se cumplan todas
Figura 28.106: Estado de partida las condiciones, la regla se considera activable. Si alguna de las condiciones no puede
para ataque. satisfacerse, se intenta satisfacer de nuevo instanciando las variables con otros hechos
del sistema.
Para evitar el suicidio hay que el elemento condicional not, que comprueba
la no existencia de hechos. Si se desea indicar la no existencia de hechos con
determinados valores constantes en sus campos simplemente se antepone not a la
condición. Por ejemplo (not (ficha (equipo “A”) (num 111))). Pero si se quieren incluir
comprobaciones más complejas hay que incluir la conectiva “Y”, &. A continuación
se muestra un ejemplo en que se aplicará la una regla de huida del listado 28.49,
quedando como ejercicio la aplicación a las reglas anteriores de ataque.
Esta regla, sin embargo, adolece los problemas anteriormente comentados: posibi-
lidad de suicidio y aplicación incluso con fichas contrarias muy lejanas (véase estado
de la figura 28.107). Se propone como mejora la regla del listado 28.50.
tener dos reglas, una con una huida para cada lado, que al tener menos condiciones sería más aplicables.
28.7. Sistemas expertos basados en reglas [935]
hay veces que los programadores se plantean poner reglas que tengan condiciones
excluyentes u ordenar todas las reglas por prioridades. Aunque esta estrategia no es
necesariamente mala, se pierde el potencial de los sistemas expertos basados en reglas,
pues realmente se estaría implementando un árbol de decisión determinista.
Otro aspecto interesante es que también se pueden crear otros hechos para
facilitar la planificación de estrategias. Por ejemplo, si una estrategia cambia su
comportamiento cuando pierde las fichas de 5 y 6 puntos se añadiría la primera regla
del listado 28.51 y el hecho fase 2 se usaría como condición de las reglas exclusivas
de dicho comportamiento.
C28
[936] CAPÍTULO 28. INTELIGENCIA ARTIFICIAL
I
nternet, tal y como la conocemos hoy en día, permite el desarrollo de videojuegos
en red escalando el concepto de multi-jugador a varias decenas o centenas de
jugadores de forma simultánea. Las posibilidades que ofrece la red al mercado de
los videojuegos ha posibilitado:
937
[938] CAPÍTULO 29. NETWORKING
Como podemos ver en la figura 29.1 cada funcionalidad necesaria se asocia a una
capa y es provista por uno o varios protocolos. Es decir, cada capa tiene la misión
específica de resolver un problema y, generalmente, existen varios protocolos que
resuelven ese problema de una forma u otra. En función de la forma en la cual resuelve
el problema el protocolo, dicha solución tiene unas características u otras. En la capa
de aplicación la funcionalidad es determinada por la aplicación.
La agregación de toda esta información dividida en capas se realiza mediante
un proceso de encapsulación en el cual los protocolos de las capas superiores se
encapsulan dentro de las capas inferiores y al final se manda la trama de información
completa. En el host destino de dicha trama de información se realiza el proceso
inverso. En la imagen 29.2 podemos ver un ejemplo de protocolo desarrollado para
un videojuego (capa de aplicación) que utiliza UDP (User Datagram Protocol) (capa
de transporte), IPv4 (Capa de red) y finalmente Ethernet (capa de Acceso a Red).
En las siguientes secciones vamos a dar directrices para el diseño e implementa-
ción del protocolo relacionado con el videojuego así como aspectos a considerar para
seleccionar los protocolos de las capas inferiores.
29.2. Consideraciones de diseño [939]
Una vez implementadas estas técnicas se debe estimar y medir los parámetros de
networking para ver la efectividad de las técnicas, estudiar el comportamiento de la
arquitectura, detectar cuellos de botella, etc... Por lo tanto, las estimaciones de retardo
y en general, todos los parámetros de red deben ser incluidos en los tests.
Con estas técnicas en mente, la responsabilidad del cliente, a grandes rasgos, queda
en un bucle infinito mientras se encuentre conectado. En dicho bucle, para cada turno
o intervalo de sincronización, envía comandos de usuario al servidor, comprueba si
ha recibido mensajes del servidor y actualiza su visión del estado del juego si es así,
establece los cálculos necesarios: predicción, interpolación, etc. y por último muestra
los efectos al jugador: gráficos, mapas, audio, etc.
El servidor por su parte, actualiza el estado del mundo con cada comando recibido
desde los clientes y envía las actualizaciones a cada uno de los clientes con los cambios
que se perciben en el estado del juego.
En ambos casos se necesita un algoritmo de sincronización que permita a clientes
y servidores tener consciencia del paso del tiempo. Existen varios algoritmos y
protocolos empleados para esta sincronización (a menudo heredados del diseño y
desarrollo de sistemas distribuidos).
C29
[942] CAPÍTULO 29. NETWORKING
29.3.2. Cliente-servidor
Con la llegada de los primeros FPS y la adopción masiva de internet (mediante
módem) a mediados de los 90, los efectos de la latencia de los que adolece
especialmente el modelo peer-to-peer obligaron a los desarrolladores a buscar otras
alternativas. Uno de los primeros juegos en probar algo diferente fue Quake, de «id
Software R »
En el modelo cliente-servidor muchos jugadores se conectan a un único servidor
que controla el juego y mantiene la imagen global de lo que ocurre. Este modelo
elimina muchos de los problemas y restricciones del modelo peer-to-peer: ya no se
requiere mantener una realidad coherente compartida por todos los jugadores, puesto
que únicamente el servidor determina el estado del juego. Los clientes muestran
dicho estado con pequeñas modificaciones que representan la evolución inmediata
del escenario en función de la entrada del usuario. La topología lógica pasó de ser una
malla completa a una estrella de modo que mejoró la escalabilidad y redujo la latencia;
ya no era necesario esperar al cliente más lento. También simplifica la incorporación
de nuevos jugadores a una partida en curso.
Este modelo desacopla en muchos casos el desarrollo del juego y las interacciones
entre los jugadores simplificando las tareas de la parte cliente.
29.4.3. Latencia
Es el tiempo que tarda un mensaje desde que sale del nodo origen hasta que llega
al nodo destino. Hay muchos factores que afectan a la latencia: las limitaciones físicas
del medio, los procedimientos de serialización de los mensajes, el sistema operativo,
el tiempo que los paquetes pasan en las colas de los encaminadores, los dispositivos
de conmutación, etc.
Muy relacionado con la latencia está el tiempo de respuesta, que es el tiempo total
desde que se envía un mensaje hasta que se recibe la respuesta. En ese caso se incluye
también el tiempo de procesamiento del mensaje en el servidor, además de la latencia
de ida y vuelta. Un juego con restricciones importantes debería evitar depender de
información que se obtiene como respuesta a una petición directa. Es frecuente utilizar
mensajes con semántica oneway, es decir, que no implican esperar una respuesta en
ese punto.
Aún hay otra consideración muy importante relacionada con la latencia: el jitter.
El jitter es la variación de la latencia a lo largo del tiempo. Cuando se utilizan
redes basadas en conmutación de paquetes con protocolos best-effort —como IP—
las condiciones puntuales de carga de la red en cada momento (principalmente
los encaminadores) afectan decisivamente al retardo de modo que puede cambiar
significativamente entre un mensaje y el siguiente.
La latencia y el jitter permisible dependen mucho del tipo de juego y están muy
influenciados por factores psicomotrices, de percepción visual del espacio, etc. Los
juegos más exigentes son los que requieren control continuo, como la mayoría de
los shotters, simuladores de vuelo, carreras de coches, etc., que idealmente deberían
tener latencias menores a 100 ms. El punto de vista subjetivo aumenta la sensación de
realismo y provoca que el jugador sea mucho más sensible, afectando a la jugabilidad.
En otros tipos de juegos como los de estrategia o que dependen de decisiones
colaborativas, el retardo admisible puede ser de hasta 500 ms o incluso mayor.
29.5. Distribución de información [945]
vidor.
[948] CAPÍTULO 29. NETWORKING
Figura 29.3: Cuando la predicción calcula una posición fuera del umbral permitido (fuera de la línea roja),
el servidor envía una actualización (marcas azules) que incluye la posición, dirección y velocidad real
actual. El cliente recalcula su predicción con la nueva información
a(t1 − t1 )2
C29
Figura 29.4: Cuando se reajusta la posición se producen saltos (líneas discontinuas azules) mientras que
aplicando un algoritmo de convergencia (líneas verdes) el objeto sigue una trayectoria continua a pesar de
no seguir fielmente la trayectoria real y tener que hacer ajustes de velocidad
Aunque es una técnica muy efectiva para evitar que el usuario sea consciente de
los errores cometidos por el algoritmo de predicción, pueden presentarse situaciones
más complicadas. Si en un juego de carreras, la ruta predicha implica que un coche
sigue en línea recta, pero en la real toma un desvío, la ruta de convergencia llevaría
el coche a atravesar una zona sin asfaltar, que puede transgredir las propias reglas del
juego. Este escenario es muy común en los receptores de GPS (Global Positioning
System) que utilizan una técnica de predicción muy similar (ver figura 29.5).
Dentro de esta familia también se incluyen otro tipo de sockets como SOCK_RAW
que permite al programador acceder a las funciones de la capa de red directamente y
poder, por ejemplo, construirnos nuestro propio protocolo de transporte. El otro tipo
de socket que nos podemos encontrar es SOCK_SEQPACKET que proporciona una
comunicación fiable, orientada a conexión y a mensajes. Mientras que las tres familias
anteriores están soportadas por la mayoría de los sistemas operativos modernos, la
familia SOCK_SEQPACKET no tiene tanta aceptación y es mas difícil encontrar una
implementación para según qué sistemas operativos.
Si nos fijamos, los tipos de sockets nos describen qué tipo de comunicación
proveen. El protocolo utilizado para proporcionar dicho servicio debe ser especificado
en el tercer parámetro. Generalmente, se asocian SOCK_DGRAM al protocolo UDP,
SOCK_STREAM al protocolo TCP, SOC_RAW directamente sobre IP. Estos son los
protocolos que se usan por defecto, no obstante, podemos especificar otro tipos de
protocolos siempre y cuando estén soportados por nuestro sistema operativo.
Como ya hemos comentado, cada tipología de socket nos proporciona una
comunicación entre procesos con unas determinadas características y usa unos
protocolos capaces de proporcionar, como mínimo, esas características. Normalmente,
esas características requieren de un procesamiento que será mas o menos eficiente en
cuanto a comunicaciones, procesamiento, etc.
Es imprescindible que el desarrollador de la parte de networking conozca el coste
de esas características de cara a diseñar un sistema de comunicaciones eficiente y que
cumpla con las necesidades del videojuego. La decisión de qué tipo de comunicación
quiere determina:
Esta decisión, a groso modo está relacionada con si queremos una comunicación
confiable o no, es decir, en el caso de Internet, decidir entre usar un SOCK_STREAM
o SOCK_DGRAM respectivamente y por ende, en la mayoría de los casos, entre TCP
o UDP.
El uso de SOCK_STREAM implica una conexión confiable que lleva asociado
unos mecanismos de reenvío, ordenación, etc. que consume tiempo de procesamien-
to a cambio de garantizar la entrega en el mismo orden de envío. En el caso de
SOCK_DGRAM no se proporciona confiabilidad con lo que el tiempo de procesa-
miento se reduce.
¿Qué tipo de comunicación empleo?, bien, en función del tipo de datos que este-
mos transmitiendo deberemos usar un mecanismo u otro. Como norma general todos
aquellos datos en tiempo real que no tiene sentido reenviar irán sobre SOCK_DGRAM
mientras que aquellos tipos de datos mas sensibles y que son imprescindibles para el
correcto funcionamiento deben ir sobre SOCK_STREAM.
Algunos ejemplos de uso de ambos protocolos nos pueden clarificar qué usos se le
dan a uno y otro protocolo. En las últimamente populares conexiones multimedia en
tiempo real, por ejemplo una comunicación VoIP, se suele usar para la transmisión del
audio propiamente dicho el protocolo RTP (Real Time Protocol) que proporciona una
conexión no confiable. Efectivamente, no tiene sentido reenviar una porción de audio
que se pierde ya que no se puede reproducir fuera de orden y almacenar todo el audio
hasta que el reenvío se produce generalmente no es aceptable en las comunicaciones.
No obstante, en esas mismas comunicaciones el control de la sesión se delega en
el protocolo (SIP (Session Initiation Protocol)) encargado del establecimiento de la
llamada. Este tipo de datos requieren de confiabilidad y por ello SIP se implementa
sobre TCP.
Podemos trasladar este tipo de decisiones al diseño de los módulos de networking
de un determinado videojuego. Una vez decidida la información necesaria que se debe
transmitir entre los diversos participantes, debemos clasificar y decidir, para cada flujo
de información si va a ser mediante una conexión confiable no confiable.
Es difícil dar normas genéricas por que, como adelantamos en la introducción,
cada videojuego es diferente, no obstante, generalmente se utilizará comunicaciones
no confiables para:
El resto de información que no cae en estas dos secciones puede ser susceptible de
implementarse utilizando comunicaciones confiables.
Este primer paso de decisión de qué tipo de comunicación se necesita es
importante ya que determina la estructura del cliente y el servidor.
C29
[954] CAPÍTULO 29. NETWORKING
struct hostent *gethostbyname(const char *name): Esta estructura nos sirve para
identificar al servidor donde nos vamos a conectar y puede ser un nombre (e.j.
ejemplo.nombre.es), dirección IPv4 (e.j. 161.67.27.1) o IPv6. En el caso de
error devuelve null.
int connect(int sockfd, const struct sockaddr
*serv_addr, socklen_t addrlen): Esta primitiva nos permite realizar la conexión
con un servidor especificado por serv_addr usando el socket sockfd. Devuelve
0 si se ha tenido éxito.
ssize_t
send(int s, const void *msg, size_t len, int flags): esta primitiva es utilizada para
mandar mensajes a través de un socket indicando, el mensaje (msg), la longitud
del mismo (len) y con unas opciones definidas en flags. Devuelve el número de
caracteres enviados.
ssize_t
recv(int s, void *buf, size_t lon, int flags): esta primitiva es utilizada para recibir
mensajes a través de un socket ((s)), el mensaje es guardado en buf, se leen los
bytes especificados por lon y con unas opciones definidas en flags. Devuelve el
número de caracteres recibidos.
close(int sockfd): cierra un socket liberando los recursos asociados.
Con estas primitivas podemos escribir un cliente que se conecta a cualquier ser-
vidor e interacciona con él. Para la implementación del servidor tenemos las mismas
primitivas de creación, envío, recepción y liberación. No obstante, necesitamos algu-
nas primitivas mas:
Veamos una implementación de ejemplo que nos sirva para inscribir a un personaje
en una partida gestionada por un servidor. En este primer ejemplo un usuario se
desea registrar en un servidor de cara a enrolarse en una partida multijugador. La
información de usuario será expresada por una estructura en C que podemos ver en el
listado 29.1 conteniendo el nick del jugador, su tipo de cuenta y su clave. Este tipo
de información es necesario transmitirlo por una conexión confiable ya que queremos
que el proceso se realice de forma confiable.
29.10. Sockets TCP/IP [955]
20 serverAddr.sin_port = htons(serverPort);
[956] CAPÍTULO 29. NETWORKING
21
22 bind(serverSocket, (struct sockaddr *)&serverAddr, sizeof(
serverAddr));
23 listen(serverSocket, MAXCONNECTS);
24 while(1)
25 {
26 printf("Listening clients..\n");
27 len_client = sizeof(clientAddr);
28 serviceSocket = accept(serverSocket, (struct sockaddr *) &
clientAddr, &(len_client));
29 printf("Cliente %s conectado\n", inet_ntoa(clientAddr.
sin_addr));
30 new_player = (struct player *)malloc(sizeof(struct player));
31 read_bytes= recv(serviceSocket, new_player,sizeof(struct
player) ,0);
32 printf("Nuevo jugador registrado %s con cuenta %d y password
%s\n", new_player->nick, new_player->account_type,
new_player->passwd);
33 close(serviceSocket);
34 }
35
36 }
Una vez creado y vinculado un socket a una dirección, estamos listos para entrar
en un bucle de servicio donde, se aceptan conexiones, se produce el intercambio de
mensajes con el cliente conectado y con posterioridad se cierra el socket de servicio y
se vuelven a aceptar conexiones. Si mientras se atiende a un cliente se intenta conectar
otro, queda a la espera hasta un máximo especificado con la función listen. Si se supera
ese máximo se rechazan las conexiones. En este caso la interacción se limita a leer los
datos de subscripción del cliente.
El cliente, tal y como vemos en el listado 29.3, sigue unos pasos similares en
cuanto a la creación del socket e inicialización de las direcciones. En este caso se debe
introducir la dirección IP y puerto del servidor al cual nos queremos conectar. En este
caso localhost indica que van a estar en la misma máquina (linea 19). A continuación
nos conectamos y enviamos la información de subscripción.
En este ejemplo, el diseño del protocolo implementado no podría ser mas
simple, la sintaxis de los paquetes están definidos por la estructura, la semántica es
relativamente simple ya que el único paquete que se envía indican los datos de registro
mientras que la temporización también es muy sencilla al ser un único paquete.
22 serverAddr.sin_family = AF_INET;
23 serverAddr.sin_port = htons(serverPort);
24
25 len_server = sizeof(serverAddr);
26 printf("Connecting to server..");
27 connect(serviceSocket,(struct sockaddr *) &serverAddr,(
socklen_t)len_server);
28 printf("Done!\n Sending credentials.. %s\n", myData.nick);
29 send_bytes = send(serviceSocket, &myData, sizeof(myData),0);
30 printf("Done! %d\n",send_bytes);
31 close(serviceSocket);
32 return 0;
33 }
Información de estado En el caso de las comunicaciones no confiables, y tal y como vimos al principio
de esta sección, se utilizan para enviar información cuya actualización debe ser muy
La información de estado que un
servidor le envía a un cliente es sólo eficiente y en la cual, la pérdida de un paquete o parte de la información, o no tiene
aquella información relativa a su sentido un proceso de reenvío o bien se puede inferir. Para mostrar este ejemplo
visión parcial del estado del juego. podemos ver, para un juego FPS, como un cliente va a enviar información relativa a su
Es decir, la mínima imprescindible posición y orientación, y el servidor le envía la posición y orientación de su enemigo.
para que el cliente pueda tener la
información de qué le puede afectar Asumiremos que la información de la posición del enemigo siempre es relevante
en cada momento de cara a la representación del juego. En el listado 29.4 podemos ver la estructura
position que nos sirve para situar un jugador en un juego tipo FPS. Este es un ejemplo
de estructura que podemos asociar a comunicaciones no confiables ya que:
Cada cambio en la posición y orientación del jugador debe generar una serie
de mensajes al servidor que, a su vez, debe comunicarlo a todos los jugadores
involucrados.
Al ser un tipo de información que se modifica muy a menudo y vital para el
juego, se debe notificar con mucha eficiencia.
En principio la pérdida ocasional de algún mensaje no debería plantear mayores
problemas si se diseña con una granularidad adecuada. La información perdida
puede inferirse de eventos anteriores y posteriores o incluso ignorarse.
El servidor (listado 29.5) en este caso prepara una estructura que representa la
posición de un jugador (lineas 10-14), que podría ser un jugador manejado por la
IA del juego, a continuación se crea el socket de servicio y se prepara para aceptar
paquetes de cualquier cliente en el puerto 3000. Finalmente se vincula el socket a
la dirección mediante la operación bind y se pone a la espera de recibir mensajes
mediante la operación recvfrom. Una vez recibido un mensaje, en la dirección
clientAddr tenemos la dirección del cliente, que podemos utilizar para enviarle
mensajes (en este caso mediante la operación sendto).
C29
[958] CAPÍTULO 29. NETWORKING
El cliente en este caso guarda muchas similitudes con el servidor (listado 29.6).
En primer lugar se prepara la estructura a enviar, se crea el socket, y se prepara la
dirección de destino, en este caso, la del servidor que se encuentra en el mismo host
(lineas 21-25), una vez realizada la vinculación se envía la posición y se queda a la
espera de paquetes que provienen del servidor mediante la función recvfrom.
25 serverAddr.sin_port = htons(3000);
26
27 memset( &( serverAddr.sin_zero), ’\0’, 8 );
28 length = sizeof( struct sockaddr_in );
29 bind( serviceSocket, (struct sockaddr *)&localAddr, length );
30 sendto(serviceSocket, &mypos, sizeof(struct position), 0, (struct
sockaddr *)&serverAddr, length );
31 recvfrom(serviceSocket, &enemy, sizeof(struct position), 0, (
struct sockaddr *)&serverAddr, &length );
32 printf("My enemy ID: %d is in position %f, %f, %f with orientation %
f\n",enemy.userID,enemy.x, enemy.y, enemy.z, enemy.rotation
[0][0]);
33 return 0;
34 }
A lo largo de esta sección hemos visto las funciones básicas de envío y recepción
mediante sockets TCP/IP. Aunque ya tenemos la base para construir cualquier tipo de
servidor y cliente, en la parte de servidores hace falta introducir algunas características
adicionales de cara al desarrollo de servidores eficientes y con la capacidad de atender
a varios clientes de forma simultánea.
crear un nuevo proceso por cada petición. El socket servidor recibe una petición
de conexión y crea un socket de servicio. Este socket de servicio se pasa a
un proceso creado de forma expresa para atender la petición. Este mecanismo
desde el punto de vista del diseño es muy intuitivo con la consiguiente ventaja
en mantenibilidad. De igual manera, para sistemas que sólo tienen un sólo
procesador, este sistema adolece de problemas de escalabilidad ya que el
número de cambios de contexto, para números considerables de clientes, hace
perder eficiencia a la solución.
Utilizar un mecanismo que nos permita almacenar las peticiones y notificar
cuando un cliente nos manda una petición al servidor.
La idea del select es que cuando retorna en cada uno de esos conjuntos se
encuentran los identificadores de los sockets de los cuales puedes leer, escribir o hay
alguna excepción. Con las operaciones FD_ZERO, FD_SET, FD_CLR y FD_ISSET
se limpian, añaden y eliminan descriptores y se pregunta por un descriptor concreto
en cualquiera de los conjuntos respectivamente.
Supongamos que queremos atender distintos tipos de conexiones en varios sockets,
en principio no hay un orden de llegada de los paquetes y por lo tanto, no podemos
bloquearnos en uno sólo de los sockets. La función select nos puede ayudar. Podemos
evolucionar nuestro servidor UDP desarrollado anteriormente para incluir la función
select (listado 29.7).
En el caso de send, tenemos la operación sendall que, con la misma semántica que
el send, nos permite enviar todo un buffer.
Existen otros casos en el cual la gestión de buffers puede ralentizar las prestaciones
ya que se necesitan copiar de forma continua. Por ejemplo si leemos de un archivo, la
operación read sobre un descriptor de archivo almacena la información en un buffer
interno al kernel que es copiado al buffer en espacio de usuario. Si a continuación
queremos enviar esa información por un sockets, invocaríamos la función send y
esa función copiaría ese buffer, de nuevo a un buffer interno para su procesado.
Este continuo trasiego de información ralentiza la operación si lo que queremos es
transmitir por completo el archivo. Lo ideal sería que las dos operaciones entre los
buffers internos al buffer de aplicación se convirtiera en una única operación entre
buffers internos.
La técnica que minimiza las copias innecesarias entre buffers se denomina zero-
copy y, a menudo, puede obtener una considerable mejora de las prestaciones. Es
difícil estudiar esta técnica en profundidad porque depende del sistema operativo y de
la aplicación concreta.
1 #include <arpa/inet.h>
2 uint32_t htonl(uint32_t hostlong);
3 uint16_t htons(uint16_t hostshort);
4 uint32_t ntohl(uint32_t netlong);
5 uint16_t ntohs(uint16_t netshort);
A mas alto nivel se puede usar XDR (eXternal Data Representation), un estándar
para la descripción y representación de datos utilizado por ONC (Open Network
Computing)-RPC (Remote Procedure Call) pero el cual se puede usar de forma directa
mediante las librerías adecuadas. Como veremos mas adelante, una de las principales
labores que nos ahorra el uso de middlewares es precisamente, la gestión de la
representación de datos.
1 setsockopt(fd,SOL_SOCKET,SO_REUSEADDR,&argument,sizeof(argument))
en esta llamada a setsockopt (que nos ayuda a establecer las opciones de los socket)
donde básicamente se especifica que para una dirección multicast, vamos a reutilizar
la dirección compartiendo varios socket el mismo puerto. En este caso argument es un
u_int con un valor de 1. Faltaría la segunda parte:
Objeto distribuido En un diseño orientado a objetos, el ingeniero puede decidir qué entidades del
dominio serán accesibles remotamente. Puede «particionar» su diseño, eligiendo qué
Es un objeto cuyos métodos (algu- objetos irán en cada nodo, cómo se comunicarán con los demás, cuáles serán los flujos
nos al menos) pueden ser invocados
remotamente. de información y sus tipos. En definitiva está diseñando una aplicación distribuida.
Obviamente todo eso tiene un coste, debe tener muy claro que una invocación remota
puede suponer hasta 100 veces más tiempo que una invocación local convencional.
Un videojuego en red, incluso sencillo, se puede diseñar como una aplicación
distribuida. En este capítulo veremos cómo comunicar por red las distintas partes del
juego de un modo mucho más flexible, cómodo y potente que con sockets.
Obviamente, el middleware se basa en las mismas primitivas del sistema operativo
y el subsistema de red. El middleware no puede hacer nada que no se pueda hacer
con sockets. Pero hay una gran diferencia; con el middleware lo haremos con mucho
menos esfuerzo gracias a las abstracciones y servicios que proporciona, hasta el punto
que habría muchísimas funcionalidades que serían prohibitivas en tiempo y esfuerzo
sin el middleware. El middleware encapsula técnicas de programación de sockets y
gestión de concurrencia que pueden ser realmente complejas de aprender, implemen-
tar y depurar; y que con él podemos aprovechar fácilmente. El middleware se encarga
de identificar y numerar los mensajes, comprobar duplicados, retransmisiones, com-
probaciones de conectividad, asignación de puertos, gestión del ciclo de vida de las
conexiones, despachado asíncrono y un largo etcétera.
C29
[966] CAPÍTULO 29. NETWORKING
Servidor
Es la entidad pasiva, normalmente un programa que inicializa y activa los
recursos necesarios para poner objetos a disposición de los clientes.
Objeto
Los objetos son entidades abstractas que se identifican con un identidad, que
debería ser única al menos en la aplicación. Los objetos implementan al menos
una interfaz remota definida en una especificación Slice.
2 http://www.zeroc.com
29.12. Middlewares de comunicaciones [967]
Sirviente
Es el código concreto que respalda la funcionalidad del objeto, es quién
realmente hace el trabajo.
Cliente
Es la entidad activa, la que solicita servicios de un objeto remoto mediante
invocaciones a sus métodos.
Proxy
Es un representante de un objeto remoto. El cliente en realidad interacciona (in-
voca) a un proxy, y éste se encarga con la ayuda del núcleo de comunicaciones,
de llevar el mensaje hasta el objeto remoto y después devolverle la respuesta al
cliente.
$ slice2cpp Hello.ice
Esto genera dos ficheros llamados Hello.cpp y Hello.h que deben ser
compilador para obtener las aplicaciones cliente y servidor.
29.12. Middlewares de comunicaciones [969]
Sirviente
Servidor
En la línea 9 se crea el sirviente (una instancia de la clase HelloI). Las líneas 11–
12 crea un adaptador de objetos, que es el componente encargado de multiplexar entre
los objetos alojados en el servidor. El adaptador requiere un endpoint —un punto de
conexión a la red materializado por un protocolo (TCP o UDP), un host y un puerto.
En este caso esa información se extrae de un fichero de configuración a partir de el
nombre del adaptador (HelloAdapter en este caso).
En las lineas 13–14 registramos el sirviente en el adaptador mediante el método
add() indicando para ello la identidad que tendrá el objeto (hello1). En este
ejemplo la identidad es un identificador bastante simple, pero lo recomendable es
utilizar una secuencia globalmente única. El método add() devuelve una referencia
al objeto distribuido recién creado, que llamamos proxy. La línea 17 imprime en
consola su representación textual. A partir de esa representación el cliente podrá
obtener a su vez un proxy para el objeto remoto.
La línea 18 es la activación del adaptador, que se ejecuta en otro hilo. A partir
de ese momento el servidor puede escuchar y procesar peticiones para sus objetos.
El método waitForShutown() invocado en la línea 20 bloquea el hilo principal
hasta que el comunicador sea terminado. El método shutdownOnInterrupt(),
que se invoca en la línea 19, le indica a la aplicación que termine el comunicador
al recibir la señal SIGQUIT (Control-C). Por último, las líneas 26–29 contienen la
función main() en la que se crea una instancia de la clase Server y se invoca su
método main().
29.12. Middlewares de comunicaciones [971]
Cliente
El programa acepta por línea de comandos la representación textual del proxy del
objeto remoto. A partir de ella se obtiene un objeto proxy (línea 8). Sin embargo esa
referencia es para un proxy genérico. Para poder invocar los métodos de la interfaz
Hello se requiere una referencia de tipo HelloPrx, es decir, un proxy a un objeto
remoto Hello.
Para lograrlo debemos realizar un moldeado del puntero (downcasting4 ) mediante
el método estático HelloPrx::checkedCast() (línea 9). Gracias al soporte de
introspección de los objetos remotos, Ice puede comprobar si efectivamente el objeto
remoto es del tipo al que tratamos de convertirlo. Esta comprobación no se realizaría
si empleáramos la modalidad uncheckedCast().
Una vez conseguido el proxy del tipo correcto (objeto hello) podemos invocar
el método remoto puts() (línea 12) pasando por parámetro la cadena "Hello,
World!".
Compilación
La figura 29.9 muestra el esquema de compilación del cliente y servidor a partir del
fichero de especificación de la interfaz. El ficheros marcados en amarillo corresponden
a aquellos que el programador debe escribir o completar, mientras que los ficheros
generados aparecen en naranja.
Para automatizar el proceso de compilación utilizamos un fichero Makefile, que
se muestra en el listado 29.13
4 Consiste en moldear un puntero o referencia de una clase a una de sus subclases asumiendo que
Servidor
HelloI.h
HelloI.cpp
Server.cpp
Hello.ice
slice2cpp Hello.h
translator Hello.cpp
Client.cpp
Cliente
Ejecutando el servidor
$ ./Server
!! 03/10/12 19:52:05.733 ./Server: error: ObjectAdapterI.cpp:915: Ice
::InitializationException:
initialization exception:
object adapter ‘HelloAdapter’ requires configuration
29.12. Middlewares de comunicaciones [973]
$ ./Server --Ice.Config=hello.cfg
hello1 -t:tcp -h 192.168.0.12 -p 9090:tcp -h 10.1.1.10 -p 9090
Ejecutando el cliente
Para ejecutar el cliente debemos indicar el proxy del objeto remoto en línea de
comandos, precisamente el dato que imprime el servidor. Nótese que se debe escribir
entre comillas para que sea interpretado como un único parámetro del programa:
twoway: Son las que I CE trata de realizar por defecto. La invocación twoway
implica el uso de un protocolo de transporte confiable (TCP) y es obligatorio si
el método invocado tiene un valor de retorno.
oneway: Las invocaciones oneway sólo pueden utilizarse cuando los métodos
no retornan ningún valor (void) y no tienen parámetros de salida. También
requieren de un protocolo confiable, pero en determinadas circunstancias
pueden fallar la entrega.
datagram: Igual que las oneway únicamente pueden utilizase para invocar
métodos sin valor de retorno y sin parámetros de salida. Utilizan un protocolo de
transporte no confiable (UDP). A su vez eso implica que el tamaño del mensaje
está limitado al máximo de un paquete IP (64KiB) y sufran de todas la otras
limitaciones de UDP: no hay control de orden, duplicados ni garantía de entrega.
La principal diferencia entre las invocaciones twoway con respecto a los otros tipos
es que el cliente recupera el control del flujo de ejecución tan pronto como el mensaje
de invocación es enviado, sin necesidad de esperar ningún tipo de confirmación o
respuesta desde el servidor (ver figura 29.10).
41 }
[976] CAPÍTULO 29. NETWORKING
42
43 Client app;
44 return app.main(argc, argv);
45 }
✄
Las ✂8-17 ✁corresponden con la definición de la clase callback FactorialCB. El
método response() será invocado por el núcleo de ejecución cuando el método
remoto haya terminado y el resultado esté de vuelta. También se define un método
✄
failure() que será invocado con una excepción en el caso de producirse.
En la ✂26 ✁se crea la instancia del callback, mediante una función específica para
este método: newCallback_Math_factorial() pasándole una instancia de
nuestra clase FactorialCB y sendos punteros a los métodos definidos en ella.
Esa función y todos los tipos nombrados siguiendo el patrón Callback_{interfa-
ce}_{método} son generados automáticamente por el compilador de interfaces en el
✄
espacio de nombre Example.
Por último en la ✂31 ✁aparece la invocación al método remoto pasando la instancia
del callback (factorial_cb) como segundo parámetro.
IceStorm está implementado como un servicio I CE. Los servicios son librería
dinámicas que deben ser lanzadas con el servidor de aplicaciones cuyo nombre es
IceBox. Vemos la configuración de IceBox en el siguiente listado:
Sólo se definen dos propiedades: La carga del servicio IceStorm y los endpoints
del gestor remoto de IceBox. La configuración de IceStorm a la que se hace referencia
(icestorm.config) es la siguiente:
Las líneas 1–3 especifican los endpoints para los adaptadores del TopicManager,
el objeto de administración y los publicadores de los canales. La línea 4 es el nombre
del directorio donde se almacenar la configuración del servicio (que es persistente
automáticamente). La línea 6 es el endpoint del adaptador del publicador, que se ha
incluido en este fichero por simplicidad aunque no es parte de la configuración de
IceStorm.
Una vez configurado podemos lanzar el servicio y probar el ejemplo. Lo primero
es arrancar el icebox (en background):
$ ./subscriber --Ice.Config=icestorm.config
$ ./publisher --Ice.Config=icestorm.config
$ ./subscriber --Ice.Config=icestorm.config
Event received: Hello World!
Event received: Hello World!
Event received: Hello World!
Event received: Hello World!
Event received: Hello World!
Event received: Hello World!
Event received: Hello World!
Event received: Hello World!
Event received: Hello World!
Event received: Hello World!
Este servicio puede resultar muy útil para gestionar los eventos de actualización
en juegos distribuidos. Resulta sencillo crear canales (incluso por objeto o jugador)
para informar a los interesados de sus posición o evolución. El acoplamiento mínimo
que podemos conseguir entre los participantes resulta muy conveniente porque
permite que arranquen en cualquier orden y sin importar el número de ellos, aunque
C29
También resulta muy interesante que los eventos estén modelados como invoca-
ciones. De ese modo es posible eliminar temporalmente el canal de eventos e invocar
directamente a un solo subscriptor durante las fases iniciales de modelado del proto-
colo y desarrollo de prototipos.
Sonido y Multimedia
Capítulo 30
Guillermo Simmross Wattenberg
Francisco Jurado Monroy
E
n este capítulo se discuten dos aspectos que son esenciales a la hora de afrontar
el desarrollo de un videojuego desde el punto de vista de la inmersión del
jugador. Uno de ellos está relacionado con la edición de audio, mientras que
el otro está asociado a la integración y gestión de escenas de vídeo dentro del propio
juego.
El apartado sonoro es un elemento fundamental para dotar de realismo a un
juego, y en él se aglutinan elementos esenciales como son los efectos sonoros y
elementos que acompañan a la evolución del juego, como por ejemplo la banda
sonora. En este contexto, la primera parte de esta capítulo describe la importancia
del audio en el proceso de desarrollo de videojuegos, haciendo especial hincapié en
las herramientas utilizadas, el proceso creativo, las técnicas de creación y aspectos
básicos de programación de audio.
Respecto a la integración de vídeo, en la segunda parte del capítulo se muestra la
problemática existente y la importancia de integrar escenas de vídeo dentro del propio
juego. Dicha integración contribuye a la evolución del juego y fomenta la inmersión
del jugador en el mismo.
981
[982] CAPÍTULO 30. SONIDO Y MULTIMEDIA
30.1.1. Introducción
Videojuegos, música y sonido
Tanto la música como los efectos sonoros son parte fundamental de un videojuego,
pero en ocasiones el oyente no es consciente de la capacidad que ambos tienen
para cambiar por completo la experiencia lúdica. Una buena combinación de estos
elementos aumenta enormemente la percepción que el usuario tiene de estar inmerso
en la acción que sucede en el juego. La capacidad de la música para cambiar el
estado de ánimo del oyente es clave en el papel que ejerce en un videojuego: permite
transportar al jugador al mundo en el que se desarrolla la acción, influirle en su
percepción del contenido visual y transformar sus emociones.
Dependiendo del momento del juego, de la situación y de las imágenes que Proceso de inmersión
muestre nuestro juego, la música debe ser acorde con las sensaciones que se desee La música es una herramienta única
generar en el oyente: en un juego de estrategia militar, se optará por utilizar para hacer que el jugador se impli-
instrumentos musicales que nos evoquen al ejército: cornetas, tambores. . . si el que, viva y disfrute del videojuego.
juego es de estrategia militar futurista, sería buena idea combinar los instrumentos
mencionados con otros instrumentos sintéticos que nos sugieran estar en un mundo
del futuro.
Pero la música no es el único recurso disponible que permite llevar al jugador
a otras realidades, los efectos de sonido también son una pieza fundamental en la
construcción de realidades virtuales.
La realidad cotidiana de nuestras vidas está repleta de sonidos. Todos ellos son
consecuencia de un cambio de presión en el aire producido por la vibración de una
fuente. En un videojuego se intenta que el jugador reciba un estimulo similar al de la
realidad cuando realiza una acción. Cualquier sonido real se puede transportar a un
videojuego, pero ¿y los sonidos que no existen? ¿cómo suena un dragón? y lo más
interesante ¿cómo se crean los sonidos que emitiría un dragón? ¿serían diferentes esos
sonidos dependiendo del aspecto del dragón? El trabajo del creador de audio en un
videojuego es hacer que el jugador crea lo que ve, y un buen diseño del sonido en un
videojuego ayudará de forma definitiva a conseguir este objetivo.
El artista, por tanto, debe utilizar una buena combinación de las dos herramientas
de las que dispone —música y efectos sonoros— para conseguir ambientar, divertir,
emocionar y hacer vivir al jugador la historia y la acción que propone el videojuego.
Interactividad y no linealidad
Las acciones que se llevan a cabo al jugar a un videojuego pueden ser pulsaciones
de teclas, movimientos del ratón o del joystick, movimientos de un volante o de mando
inalámbrico y hoy en día hasta movimientos corporales del propio jugador. Todas estas
acciones pueden producir una reacción en un videojuego.
Por todo lo anterior, el músico y diseñador de sonido debe cumplir una serie
de aptitudes muy especiales para ser capaz de desarrollar tareas muy diversas, tanto
técnicas como artísticas, para las que se necesitan conocimientos variados. Además
de estas aptitudes, debe ser creativo, innovador, perseverante, trabajar rápido y ser
meticuloso.
Figura 30.1: Para comenzar a crear un estudio casero sólo necesitaremos un ordenador con una buena
tarjeta de sonido. Poco a poco podremos complementarlo con instrumentos reales, diferente hardware y
nuevo software.
Secuenciad. multipistas
Muchos secuenciadores son tam-
bién multipistas en los que se pue-
den crear pistas de audio además de
pistas MIDI. Esto permite mezclar
instrumentos reales con instrumen-
tos generados en el ordenador.
C30
[986] CAPÍTULO 30. SONIDO Y MULTIMEDIA
Software
La herramienta principal con la que contamos hoy día para la creación de audio es
el software musical. Existen multitud de programas de audio en el mercado con muy
diferentes utilidades:
Figura 30.2: Existen multitud de controladores MIDI en el mercado. En la imagen, Native Instruments
Maschine (que es además un sampler y un secuenciador).
Preproducción
Producción
Una vez que el editor del juego aprueba el documento de diseño, el equipo de
desarrollo comienza su trabajo. La persona o personas encargadas de crear el audio de
un videojuego se incorpora al proyecto en las últimas fases de desarrollo (al finalizar
la fase de producción normalmente). Hasta este momento, lo más probable es que el
equipo de programadores habrá utilizado placeholders para los sonidos y su música
favorita para acompañar al videojuego.
El creador de audio tiene que enfrentarse a la dura labor de crear sonidos y música
lo suficientemente buenos como para conseguir que todo el equipo de desarrollo dé
por buenos el nuevo material (aunque la decisión final no sea del equipo de desarrollo,
pueden ejercer una gran influencia sobre el veredicto final).
En esta fase debe comprobarse tanto la validez técnica de los sonidos y música
(si se adaptan a las especificaciones) como la calidad del material entregado. Es
muy común que se cambien, repitan o se añadan sonidos y/o composiciones en este
momento, por lo que el creador de audio debe estar preparado para estas contingencias
(previéndolo en el presupuesto o creando a priori más material del entregado).
Al finalizar el proceso de producción, se determina cómo y en qué momentos
se reproducirá qué archivo de sonido y con qué procesado en caso de utilizar
procesamiento en tiempo real. Por lo general, y pese a lo que pudiera parecer en
primera instancia, este proceso puede llevar hasta la mitad del tiempo dedicado a todo
el conjunto de la producción.
dimensión a los videojuegos y otra variable con la que jugar durante el proceso de di-
seño: el sonido tridimensional puede convertirse en otro elemento protagonista para el
jugador, ya que puede permitirle posicionar cualquier fuente de sonido en un mundo
virtual (un enemigo, un objeto, etc.).
Post-producción
Figura 30.5: Algunos motores y APIs de audio vienen acompañadas de herramientas para la creación y
organización del material sonoro de un videojuego. En la imagen, FMOD Designer.
Motores y APIs
Los programadores encargados del audio pueden elegir utilizar una de las diferen-
tes herramientas software que existen en el mercado para facilitar su tarea o progra-
mar desde cero un motor de sonido. Esta decisión debe tomarse en las primeras fases
del ciclo de creación del videojuego, para que en el momento en el que el desarrolla-
dor o grupo de desarrolladores especializados en audio que llevarán a cabo esta labor
sepan a qué tipo de reto deben enfrentarse.
Desde el punto de vista del creador de contenido, esta información también
resulta relevante, ya que en multitud de ocasiones serán estas APIs las encargadas
de reproducir este contenido, y porque algunas de ellas vienen acompañadas de
herramientas que debe conocer y ser capaz de manejar.
Existe un amplio número de subsistemas de audio en el mercado escritos explíci-
tamente para satisfacer las necesidades de una aplicación multimedia o un videojuego.
Estos sistemas se incorporan en librerías que pueden asociarse al videojuego tanto es-
tática como dinámicamente en tiempo de ejecución, facilitando la incorporación de un
sistema de audio completo al programador sin conocimientos especializados en este
ámbito.
La facilidad de integración de estas librerías varía según su desarrollador y la
plataforma a la que van dirigidas, por lo que siempre existirá una cierta cantidad de
trabajo a realizar en forma de código que recubra estas librerías para unir el videojuego
con la funcionalidad que ofrecen.
Algunas de las librerías más utilizadas hoy día son OpenAL, FMOD, Miles
Sound System, ISACT, Wwise, XACT, Beatnik Audio Engine o Unreal 3 Sound
System.
Cuando se desarrollan aplicaciones que deben procesar vídeo digital ya sea para
su creación, edición o reproducción, debe tenerse en cuenta cuál es el estándar que Figura 30.10: Ogg emplea Vorbis
se está siguiendo, dado que de ello depende el formato de archivo contenedor que se para audio y Theora para video
manipulará.
Cuando se habla de formato contenedor multimedia o simplemente formato
contenedor, se está haciendo referencia a un formato de archivo que puede contener
varios tipos diferentes de datos que han sido codificados y comprimidos mediante
lo que se conocen como códecs. Los ficheros que están codificados siguiendo
un formato contenedor son conocidos como contenedores. Éstos almacenan todos
aquellos elementos necesarios para la reproducción, como el audio, el vídeo, los
subtítulos, los capítulos, etc., así como los elementos de sincronización entre ellos.
Algunos de los formatos contenedores más conocidos son AVI, MOV, MP4, Ogg o
Matroska.
Dada la complejidad que implicaría elaborar una aplicación que interprete los
ficheros contenedores así como los datos multimedia contenidos en ellos y codificados
mediante codecs, para poder manipular el vídeo de modo que pueda ser integrado
en cualquier aplicación –en nuestro caso junto a gráficos 3D–, suelen emplearse
librerías o APIs (Application Programming Interface) que nos faciliten la tarea. En
las siguientes secciones nos adentraremos en algunas de ellas.
./autogen.sh
export CXXFLAGS=-I/usr/include/OGRE/
./configure
make
sudo make install
ln -s /usr/local/lib/Plugin_TheoraVideoSystem.so \
/usr/lib/OGRE/Plugin_TheoraVideoSystem.so
material VideoMaterial {
technique {
pass {
texture_unit {
texture_source ogg_video {
filename clip.ogg
precache 16
play_mode play
}
}
}
}
}
Figura 30.11: Material que incluye textura con vídeo.
para la compilación y
para el enlazado.
30.6. Reproducción de vídeo con GStreamer [999]
gestores de protocolos
fuentes de audio y vídeo
manejadores de formatos con sus correspondientes parsers, formateadores,
muxers, demuxers, metadatos, subtítulos, etc.
codecs, tanto de codificación como de decodifiación
filtros, como conversores, mezcladores, efectos, etc.
sinks para volcar la salida de audio y vídeo resultante de todo el proceso
gst-launch
Reproductor Audio/video Servidor de Editor de
gst-inspect …
multimedia conferencia streamming vídeo
gst-editor
Framework de GStreammer
Clases base
Librería de utilidades
Pipeline Bus de mensajes
Plugins de sistema
Bindings de lenguajes
...
Gestores de Filtros:
Fuentes: Formatos: Codecs: Sinks:
protocolos: conversores,
Alsa, v4l2, avi, mp4, mp3, mpeg4, Alsa, xvideo, …
http, fichero, mezcladores,
tcp/udp, etc. ogg, etc. vorbis, etc. tcp/udp, etc.
rstp, etc. efectos
Plugins de GStreammer
Plugins de terderos
Para trabajar con la cámara web se empleará v4l2 para capturar frames de la
cámara. Un ejemplo es el siguiente comando donde se captura un frame desde
v4l2src, se procesa por ffmpegcolorspace para que no aparezca con colores “extraños”,
transforma a formato png con pngenc y se pasa a un fichero llamado picture.png con
filesink
Si lo que queremos es mostrar todos los frames que recoga la cámara web
directamente en una ventana, podemos hacerlo mediante el siguiente pilepile:
Pads. Son las conexiones de entradas y salidas de los elementos. Cada pad
gestiona datos de tipo específico, es decir, restringen el tipo de datos que
fluye a través de ellos. Son el elemento que permiten crear las pipelines entre
elementos.
Bins y Pipelines. Un bin es un contenedor de elementos. Una pipeline es un tipo
especial de bin que permite la ejecución de todos los elementos que contiene.
Un bin es un elemento, y como tal puede ser conectado a otros elementos para
construir una pipeline.
Comunicación. Como mecanismos de comunicación GStreamer proporciona:
• Buffers. Objetos para pasar datos entre elementos de la pipeline. Los
buffers siempre van desde los fuentes hasta los sinks
• Eventos. Objetos enviados entre elementos o desde la aplicación a los
elementos.
• Mensajes. Objetos enviados por los elementos al bus de la pipeline.
Las aplicaciones pueden recoger estos mensajes que suelen almacenar
información como errores, marcas, cambios de estado, etc.
• Consultas. Permiten a las aplicaciones realizar consultas como la dura-
ción o la posición de la reproducción actual.
29
30 gst_init(0, 0);
31
32 if (!g_thread_supported()){
33 g_thread_init(0);
34 }
35
36 mPlayer = gst_element_factory_make("playbin2", "play");
37
38 GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(mPlayer));
39 gst_bus_add_watch(bus, onBusMessage, getUserData(mPlayer));
40 gst_object_unref(bus);
41
42 mAppSink = gst_element_factory_make("appsink", "app_sink");
43
44 g_object_set(G_OBJECT(mAppSink), "emit-signals", true, NULL);
45 g_object_set(G_OBJECT(mAppSink), "max-buffers", 1, NULL);
46 g_object_set(G_OBJECT(mAppSink), "drop", true, NULL);
47
48 g_signal_connect(G_OBJECT(mAppSink), "new-buffer",
49 G_CALLBACK(onNewBuffer), this);
50
51 GstCaps* caps = gst_caps_new_simple("video/x-raw-rgb", 0);
52 GstElement* rgbFilter = gst_element_factory_make("capsfilter",
53 "rgb_filter");
54 g_object_set(G_OBJECT(rgbFilter), "caps", caps, NULL);
55 gst_caps_unref(caps);
56
57 GstElement* appBin = gst_bin_new("app_bin");
58
59 gst_bin_add(GST_BIN(appBin), rgbFilter);
60 GstPad* rgbSinkPad = gst_element_get_static_pad(rgbFilter,
61 "sink");
62 GstPad* ghostPad = gst_ghost_pad_new("app_bin_sink", rgbSinkPad);
63 gst_object_unref(rgbSinkPad);
64 gst_element_add_pad(appBin, ghostPad);
65
66 gst_bin_add_many(GST_BIN(appBin), mAppSink, NULL);
67 gst_element_link_many(rgbFilter, mAppSink, NULL);
68
69 g_object_set(G_OBJECT(mPlayer), "video-sink", appBin, NULL);
70 g_object_set(G_OBJECT(mPlayer), "uri", uri, NULL);
71
72 gst_element_set_state(GST_ELEMENT(mPlayer), GST_STATE_PLAYING);
73 }
✄
8. Reempazo de la ventana de salida El paso final es el de reemplazar la ventana
de salida por defecto conocida por ✄ video-sink, por nuestra aplicación ✂69 ✁ e
indicar la uri del vídeo a ejecutar ✂70 ✁
9. Comienzo de la visualización del vídeo Bastará poner el estado de nuestro
objeto playbin2 a “play”.
✄ algunos
Como ha podido verse, a lo largo del código anterior se han establecido
callbacks. El primero de ellos ha sido onBusMessage en la línea ✂39 ✁ del listado
anterior. En el listado siguiente es una posible implementación para dicho método,
el cual se encarga de mostrar el mensaje y realizar la acción que considere oportuna
según el mismo.
✄
El otro callback es onNewBuffer en la línea ✂49 ✁del mismo listado. Este método
será invocado por GStreamer cada vez que haya un nuevo frame disponible. Una
posible implementación puede verse en el listado 30.3 continuación, donde se pone
un atributo booleano llamado mNewBufferExists a verdadero cada vez que llega un
nuevo frame.
C30
[1004] CAPÍTULO 30. SONIDO Y MULTIMEDIA
30
31 TextureManager* mgr=Ogre::TextureManager::getSingletonPtr();
32
33 if (!mVideoTexture.isNull()){
34 mgr->remove(mVideoTexture->getName());
35 }
36
37 mVideoTexture = Ogre::TextureManager::getSingleton().
38 createManual(
39 "VideoTexture",
40 ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME,
41 TEX_TYPE_2D,
42 mVideoWidth, mVideoHeight,
43 0, PF_B8G8R8A8,
44 TU_DYNAMIC_WRITE_ONLY);
45
46 mVideoMaterial->getTechnique(0)->getPass(0)->
47 removeAllTextureUnitStates();
48 mVideoMaterial->getTechnique(0)->getPass(0)->
49 createTextureUnitState(mVideoTexture->getName());
50
51 float widthRatio =
52 mVideoWidth / static_cast<float>(mWindow->getWidth()) *
pixelRatio;
53 float heightRatio =
54 mVideoHeight / static_cast<float>(mWindow->getHeight());
55 float scale =
56 widthRatio > heightRatio ? widthRatio : heightRatio;
57
58 mVideoOverlay->setScale(widthRatio/scale, heightRatio/scale);
59 }
60
61 HardwarePixelBufferSharedPtr pixelBuffer =
62 mVideoTexture->getBuffer();
63 void* textureData = pixelBuffer->lock(
64 HardwareBuffer::HBL_DISCARD);
65 memcpy(textureData, GST_BUFFER_DATA(buffer),
66 GST_BUFFER_SIZE(buffer));
67 pixelBuffer->unlock();
68
69 gst_buffer_unref(buffer);
70 mNewBufferExists = false;
71 }
72 }
Interfaces de Usuario
31
Avanzadas
Francisco Jurado Monroy
Carlos González Morcillo
L
as Interfaces Naturales de Usuario (del inglés Natural User Ingerface) son
aquellas que emplean los movimientos gestuales para permitir una interacción
con el sistema sin necesidad de emplear dispositivos de entrada como el ratón,
el teclado o el joystick. Así, el cuerpo, las manos o la voz del usuario se convierten en
el mando que le permite interactuar con el sistema.
Este es el paradigma de interacción en el que se basan tecnologías como
las pantallas capacitivas tan empleadas en telefonía movil y tabletas, sistemas de
reconocimiento de voz como el implantado en algunos vehículos para permitir
“hablar” al coche y no despistarnos de la conducción, o dispositivos como Kinect de
Xbox que nos permite sumergirnos en mundos virtuales empleando nuestro cuerpo.
Este capítulo se centrará en la construcción de interfaces naturales que empleen
movimientos gestuales mediante la aplicación de técnicas de Visión por Computador
Figura 31.1: Las interfaces natura-
o Visión Artificial y el uso de dispositivos como el mando de la Wii de Nintendo
les ya no pertenecen sólo al mundo
de la ciencia ficción
y el Kinect de XBox, abordando temas como la identificación del movimiento, el
seguimiento de objetos o el reconocimiento facial, que puedan emplearse en el diseño
y desarrollo de videojuegos que sigan este paradigma de interacción.
1007
[1008] CAPÍTULO 31. INTERFACES DE USUARIO AVANZADAS
Con esto tendremos los requisitos mínimos para poder desarrollar, compilar y
ejecutar nuestras aplicaciones con OpenCV.
La distribución viene acompañada de una serie de programitas de ejemplo en
diferentes lenguajes de programación. Estos pueden resultar de mucha utilidad en
una primera toma de contacto. Si se ha realizado la instalación mediante el sistema
de paquetes, el paquete opencv-doc es el encargado de añadir dichos ejemplos. Para
poder copiarlos y compilarlos haremos:
# cp -r /usr/share/doc/opencv-doc/examples .
# cd examples
# cd c
# sh build_all.sh
31.3.1. Nomenclatura
Como nemotécnico general para la interfaz de programación en C, puede apreciar-
se cómo las funciones de OpenCV comienzan con el prefijo cv seguido de la operación
y el objeto sobre el que se aplicará. Veamos algunos ejemplos:
Ventanas
Las primitivas más interesantes y comunes en una aplicación que necesite gestión
de ventanas son:
Como puede apreciarse, ninguna de las anteriores primitivas devuelve una referen-
cia a la ventana o recibe por parámetro una referencia. Esto es debido a que HighGUI
accede a las ventanas que gestiona mediante su nombre. De esta manera, cada vez que
se desee acceder a una ventana concreta, habrá que indicar su nombre. Esto evita la
necesidad de emplear y gestionar estructuras adicionales por parte de los programa-
dores.
3 case CV_EVENT_LBUTTONDOWN:
4 if(flags & CV_EVENT_FLAG_CTRLKEY)
5 printf("Pulsado boton izquierdo con CTRL presionada\n");
6 break;
7
8 case CV_EVENT_LBUTTONUP:
9 printf("Boton izquierdo liberado\n");
10 break;
11 }
12 }
Para registrar el manejador y así poder recibir los eventos del ratón para una
ventana concreta, deberá emplearse la función cvSetMouseCallback(“window”,mou-
seHandler,&mouseParam);.
Barra de progreso
Otro elemento interesante en las interfaces construidas con HighGUI son las barras
de progreso o trackbar. Estas suelen ser muy útiles para introducir valores enteros
“al vuelo” en nuestra aplicación. Por ejemplo, para posicionar la visualización de un
vídeo en un punto determinado de la visualización o para establecer los valores de
determinados umbrales en los procesados (brillos, tonos, saturación, matices, etc.).
El manejador del evento del trackbar tiene el siguiete aspecto:
Así, para acceder a un pixel concreto para una imagen multicanal puede emplearse
el siguiente código:
Del mismo modo, para acceder a todos los pixels de una imagen, bastará con
implementar un bucle anidado, tal y como se muestra en el siguiente código de
ejemplo encargado de convertir una imagen a escala de grises:
Reservar:
• CvMemStorage* cvCreateMemStorage(int block_size = 0)
• void* cvMemStorageAlloc(CvMemStorage* storage, size_t size)
Liberar: void cvReleaseMemStorage(CvMemStorage** storage)
Vaciar: void cvClearMemStorage(CvMemStorage* storage)
Con esto se dispone de todo lo necesario para poder trabajar con memoria
dinámica en OpenCV.
Secuencias: CVSeq
Un tipo de objeto que puede ser guardado en los almacenes de memoria son las
secuencias. Estas son listas enlazadas de otras estructuras, de manera que se pueden
crear secuencias de cualquier tipo de objeto. Por su implementación, las secuencias
permiten incluso el acceso directo a cada uno de los elementos.
Algunas de las principales primitivas para su manipulación son:
✄ Aclarado
esto, puede que el código resulte bastante autodescriptivo. Así, las líneas
✂ ✁
1-2 muestran la inclusión de los ficheros con las definiciones de las funciones tanto
para OpenCV como para Highgui. ✄ Lo primero que hace la aplicación es una mínima
comprobación de parámetros ✂6-7 ✁. Si se ha introducido un parámetro, se entenderá
que es la ruta al fichero a visualizar y se creará una captura para extraer la información
del mismo mediante la llamada a cvCreateFileCapture. En caso de que no se hayan
introducido parámetros o el parámetro no sea un fichero de video, se realizará la
✄
captura desde la WebCam empleando la llamada a cvCreateCameraCapture.
A continuación, en la línea ✂9 ✁se creará una ventana llamada Window mediante
la llamada a cvNamedWindow. Como puede verse, esta función no devuelve ningún
puntero a la estructura que permita acceder a la ventana, sino que construye una
ventana con nombre. Así, cada vez que deseemos acceder a una ventana concreta,
bastará con indicar su nombre, evitando emplear estructuras adicionales.
Los frames de los ✄que consta el vídeo serán mostrados en la ventana mediante
el bucle de las líneas ✂12-20 ✁. En él, se realiza la extracción de los frames desde la
captura (fichero de video o cámara) y se muestran en la ventana. El bucle finalizará
✄
porque se haya
extraido el último frame de la captura łínea14 o se haya pulsado la tecla
de escape ✂18-19 ✁por parte del usuario. Finalmente,✄se liberan
las estructuras mediante
las correspondientes llamadas del tipo cvRelease* ✂22-23 ✁.
Como puede verse, en sólo unas pocas líneas de código se ha implementado un
reproductor de vídeo empleando OpenCV. En las siguientes secciones se mostrará
cómo sacarle algo de partido al potencial que nos ofrece la Visión por Computador.
✄
En las líneas ✂9-10 ✁se han creado dos ventanas, de manera que se puedan apreciar
los frames de entrada y de salida del ejemplo. Para almacenar las transformaciones
✄
intermedias, se crearán una serie de imágenes mediante la primitiva cvCreateImage
✂17-20 ✁, cuyo tamaño es siempre el tamaño del frame original.
La primitiva cvCreateImage será una de las que más se empleen cuando desa-
rrollemos aplicaciones con OpenCV. Esta crea la cabecera de la imagen y reserva la
memoria para los datos de la misma. Para ello toma tres parámetros: el tamaño (si-
ze) que a su vez se compone de alto y ancho, la profundidad (depth) y el número de
✄
canales (channels).
El proceso de filtrado está codificado en las líneas ✂22-24 ✁. Como puede apreciarse
son tres los filtros que se le aplican:
31.5. Introducción a los filtros [1017]
En la figura 31.4 puede verse cuál será la salida final de la ejecución de nuestra
aplicación.
Figura 31.4: Salida del ejemplo. A la izquierda la imagen antes del proceso de filtrado. A la derecha los
bordes detectados tras el proceso de filtrado.
11 CV_RETR_TREE,
12 CV_CHAIN_APPROX_SIMPLE, cvPoint(0,0) );
13 cvZero(gray);
14 if( contours )
15 cvDrawContours(gray, contours, cvScalarAll(255),
16 cvScalarAll(255), 100, 3, CV_AA, cvPoint(0,0) );
17 cvShowImage( "Contours", gray );
✄
En la línea ✂5 ✁se definen una serie de colores que serán
✄
empleados para marcar
con un rectángulo los objetos detectados. En las líneas ✂10-12 ✁, cargamos la imagen
desde un fichero, reservamos memoria para llevar a cabo el proceso de clasificación y
✄
cargamos la especificación del clasificador.
Seguidamente, en las líneas ✂15-17 ✁se limpia la memoria donde se realizará el
proceso de detección, y se pone a trabajar al clasificador sobre la imagen mediante
la llamada a la función cvHaarDetectObjects. Finalizado el proceso de detección por
parte del clasificador, se obtiene una secuencia de objetos (en nuestro caso caras)
almacenada en faces de tipo CvSeq (estructura de datos para almacenar secuencias en
OpenCV).
Bastará con recorrer dicha secuencia e ir creando rectángulos de diferentes colores
(empleando cvRectangle) sobre✄ cada una de las caras identificadas. Esto es lo que se
realiza en el bucle de las líneas ✂19-25 ✁. Finalmente se muestra la salida en una ventana
✄ espera
y a que el usuario pulse cualquier tecla antes de liberar la memoria reservada
✂27-32 ✁.
C31
[1020] CAPÍTULO 31. INTERFACES DE USUARIO AVANZADAS
Llegados a este punto, el lector entenderá gran parte del código, de modo que
nos centraremos en aquellas partes que resultan nuevas. Así, en el bucle que
✄ recoge
los frames de la captura, el clasificador es cargado sólo la primera vez ✂32-34 ✁. A
✄ para
continuación, ✄
cada frame capturado, se realizará una transformación a escala
de grises ✂36-37 ✁, se reducirá ✄
la imagen a otra más pequeña ✂ ✁ y finalmente
se ecualizará el histograma ✂43 ✁. Este último paso de ecualizar el histograma, sirve
39-42
para tratar de resaltarán detalles que pudieran haberse perdido en los procesos de
transformación a escala de grises y reducción de tamaño. Tras finalizar✄ el proceso
de identificación de rostros invocando la primitiva cvHaarDetectObjects ✂45-47 ✁, si no
se ha encontrado ninguno se muestra por pantalla ✄ el mensaje “Buscando objeto...”
empleando para ello la primitiva de cvPutText ✂39 ✁, la cual recibe la imagen donde
✄ propiamente dicho, la fuente que se empleará y que fue
se pondrá el texto, el texto
inicializada en la línea ✂25 ✁, y el color con el que se pintará. Si se ✄han identificado
rostros en la escena, mostrará el mensaje “Objetivo encontrado!” ✂53 ✁. Si el rostro
✄
está en el cuarto derecho o izquierdo de la pantalla,✄mostrará
el mensaje oportuno
✂59-64 ✁
, además de identificarlo mediante un recuadro ✂ ✁
66 .
Figura 31.6: La aplicación muestra cuándo ha identificado un rostro y si este se encuentra a la izquierda o
a la derecha.
31.7. Interacción mediante detección del color de los objetos [1023]
48 char c = cvWaitKey(5);
49 if(c==27) break;
50
51 cvReleaseImage(&imgHSV);
52 cvReleaseImage(&imgThreshed);
53 free(moments);
54 }
55
56 cvReleaseCapture(&capture);
57 cvDestroyWindow("Window");
58
59 return 0;
60 }
En él puede verse cómo, una vez seleccionado el color del objeto a detectar, el
procedimiento a llevar a cabo es el siguiente (la salida de su ejecución aparece en la
figura 31.9):
✄
1. Realizar un proceso de suavizado para eliminar el posible “ruido” en el color
✂21 ✁.
2. Convertir la imagen RGB del frame capturado a una imagen HSV (Hue,
Saturation, Value) (Matiz, ✄Saturación, Valor) de modo que sea más fácil
identificar el color a rastrear ✂23-24 ✁. La salida de este filtro es la que se muestra
en la parte izquierda de la figura 31.8.
3. Eliminar todos aquellos pixels cuyo matiz no corresponda con el del objeto a
detectar. Esto devolverá una imagen en la que serán puestos a 0 todos los pixels
✄
que no estén dentro de nuestro criterio. En definitiva, la imagen sólo contendrá
aquellos pixels cuyo color deseamos detectar ✂25-27 ✁en color blanco y el resto
todo en negro. La salida de este proceso es la que se muestra en la parte derecha
de la figura 31.8.
4. Identificar el ✄“momento”
de los objetos en movimiento, es decir, su posición en
coordenadas ✂29-34 ✁.
✄
5. Pintar una linea desde el anterior “momento” hasta el “momento” actual ✂29-43 ✁ Figura 31.7: Triángulo HSV donde
en una capa (en el código es una imagen denominada “imgScribble”). se representa el matiz (en la ruleta),
6. Unir la capa que ✄contiene
el trazo, con aquella✄que contiene la imagen capturada
la saturación y el valor (en el trián-
Figura 31.8: A la izquierda, se muestra la imagen HSV del Tux capturado con la cámara. A la derecha sólo
aquello que coincida con el valor del color de las patas y el pico (amarillo en nuestro caso)
31.8. Identificación del movimiento [1025]
Figura 31.9: Salida de la ejecución del ejemplo de detección de objeto con determinado color. Puede verse
cómo se superpone a la imagen el trazo realizado por el rastreo de un pequeño objeto amarillo fluorescente.
18 break;
19 }
Para realizar el análisis de dicho código debemos comenzar por el bucle principal.
En él puede verse cómo se captura la imagen, y si aún no existe movimiento
detectado, se crea ✄una nueva imagen cuyo origen se hará coincidir con el del primer
frame capturado ✂7-12 ✁. A continuación, se actualiza el historial de movimiento
(MHI (Motion ✄ History
Image)) en la función update_mhi y se muestra la imagen
por pantalla ✂15 ✁. La función update_mhi recibe tres parámetros: la imagen actual
procedente de la captura, la imagen resultante del movimiento y un umbral que
establece a partir de cuándo la diferencia entre dos frames se considerará movimiento.
Máscara de la silueta que contiene los pixels que no están puestos a 0 donde
ocurrió el movimiento.
Historia de imágenes del movimiento, que se actualiza por la función y debe ser
de un único canal y 32 bits.
Hora actual.
Duración máxima para el rastreo del movimiento.
41
42 cvResetImageROI( mhi );
43 cvResetImageROI( orient );
44 cvResetImageROI( mask );
45 cvResetImageROI( silh );
46
47 // check for the case of little motion
48 if( count < comp_rect.width*comp_rect.height * 0.05 )
49 continue;
50
51 // draw a clock with arrow indicating the direction
52 center = cvPoint( (comp_rect.x + comp_rect.width/2),
53 (comp_rect.y + comp_rect.height/2) );
54
55 cvCircle( dst, center, cvRound(magnitude*1.2), color, 3, CV_AA,
0 );
56 cvLine( dst, center, cvPoint( cvRound( center.x + magnitude*cos
(angle*CV_PI/180)),
57 cvRound( center.y - magnitude*sin(angle*CV_PI
/180))), color, 3, CV_AA, 0 );
58 }
Figura 31.10: Detección del movimiento capturado. En azul aquellos pixels donde se ha localizado
movimiento. En negro el fondo estático.
31.9. Comentarios finales sobre Visión por Computador [1029]
1. Elementos sensores
Tres acelerómetros para medir la fuerza ejercida en cada uno de los ejes.
C31
[1030] CAPÍTULO 31. INTERFACES DE USUARIO AVANZADAS
A través del puerto de expansión situado en la parte inferior del mando, pueden
conectarse una serie de dispositivos adicionales para permitir incrementar las posi-
bilidades de interacción que proporciona. Entre estos dispositivos caben destacar el
Nunchunk, el cuál consiste en un joystick analógico con dos botones más (común-
mente utilizado para controlar el movimiento de los personajes), la Wii Wheel, que
transforma el mando en un volante para aquellos juegos de simulación de vehículos,
o el Wii Zapper, que convierte al Wiimote+Nunchuk en una especie de “pistola”.
El objetivo de todos estos elementos es siempre el mismo: “proporcionar una in-
teracción lo más cómoda y natural posible al usuario a partir de sensores y actuadores
que obtengan información acerca del movimiento que éste está realizando”.
Interpretar esta información de forma correcta y crear los manejadores de eventos
apropiados para programar la aplicación en función de las necesidades de interacción,
corre a cargo de los programadores de dichas apliciaciones. Para podeer capturar
desde nuestras aplicaciones la información procedente de un dispositivo Wiimote, este
dispone del puerto Bluetooth. Así, podrán programarse sobre la pila del protocolo
Bluetooth las correspondientes librerías para comunicarse con el mando. En el
siguiente apartado se abordará cómo trabajar con éste tipo de librerías.
Estructura wiimote_t
Puede verse cómo quedan representados los botones, los acelerómetros, ángulos
de inclinación, fuerzas de gravedad, el altavoz, la batería, etc.
Toda aplicación que manipule el Wiimote deberá contar con un bucle de captu-
ra/envío de datos desde y hacia el mando. Este tiene el aspecto que se muestra en el
listado analizado a continuación.
✄
En él código se incluye la cabecera de la librería y en un momento dado se
✄
inicializa la estructura wiimote_t ✂5 ✁y se conecta con el mando mediante la primitiva
wiimote_connect ✂6 ✁, a la que se le pasa una referencia a la anterior estructura y la
dirección MAC del mando. Si desconocemos la dirección del mando, en GNU/Linux
podemos emplear el comando hcitool mientras pulsamos simultaneamente los botones
1 y 2 del mando.
# hcitool scan
7 exit(1);
8 }
9
10 //Establecer el modo de captura
11 while (wiimote_is_open(&wiimote)) {
12 wiimote_update(&wiimote);// sincronizar con el mando
13 if (wiimote.keys.home) { // alguna comprobación para salir
14 wiimote_disconnect(&wiimote);
15 }
16
17 //Realizar captura de datos según el modo
18 //Realizar envío de datos
19 }
Para detectar si el usuario está pulsando alguno de los botones del mando o del
Nunchuk (incluido el joystick), podemos hacerlo comprobando directamente sus va-
lores a través de los campos que se encuentran en wiimote.keys y wiimote.ext.nunchuk
de la variable de tipo wiimote_t, tal y como se muestra en el ejemplo.
Para cada uno de los ejes, el Wiimote proporciona valores recogidos por un
acelerómetro. De este modo, si ponemos el Wiimote en horizontal sobre una mesa
los valores recogidos por los acelerómetros tenderán a cero.
Es preciso tener en cuenta que los acelerómetros, como su nombre indica, miden
la “aceleración”, es decir, la variación de la velocidad con respecto del tiempo. No
miden la velocidad ni la posición. Valores altos de uno de los acelerómetros indicará
que el mando está variando muy deprisa su velocidad en el eje correspondiente. Que
el acelerómetro proporcione un valor cero, indicará que la velocidad permaneciera
constante, no que el mando esté detenido.
Además de la aceleración, resulta interesante medir los ángulos de inclinación del
mando, y en consecuencia, la dirección en la que se ha realizado un movimiento. En
el Wiimote, estos ángulos se conocen como cabeceo (Pitch) arriba y abajo en el eje x,
balanceo o alabeo (Roll) en el eje y, y guiñada o viraje a izquierda y derecha (Yaw) en
el eje z (ver figura 31.12).
Los movimientos de cabeceo (Pitch) y balanceo (Roll) pueden medirse directa-
mente por los acelerómetros, pero la guiñada (Yaw) precisa de la barra de LEDs IR
para permitir estimar el ángulo de dicho movimiento.
Yaw (guiñada)
+Z
+X
+Y
Pitch (cabeceo)
W
II-
M
ot
e
-X
-Z
-Y
Roll (balanceo)
Ejes (axis): para proporcionar la aceleración en cada uno de los ejes, es decir,
su vector aceleración.
Ángulos (tilt): para indicar el ángulo de inclinación del acelerómetro para el
balanceo, el cabeceo y el viraje.
Fuerzas (force): para representar al vector fuerza de la gravedad en sus
componentes (x, y, z).
31.12. Librerías para la manipulación del Wiimote [1035]
Para enviar eventos al mando, tales como activar y desactivar los LEDs, provocar
“zumbidos” o reproducir sonidos en el altavóz, bastará con activar el correspondiente
flag de la estructura wiimote_t, lo que accionará el actuador correspondiente en el
Wiimote.
En el siguiente código se muestra cómo provocar determinados eventos en el
mando al pulsar el correspondiente botón del mismo.
16 while (wiimote_is_open(&wiimote)) {
17 wiimote_update(&wiimote);
18 if (wiimote.keys.home) {
19 wiimote_disconnect(&wiimote);
20 }
21
22 if (wiimote.keys.up)wiimote.led.one = 1;
23 else wiimote.led.one = 0;
24
25 if (wiimote.keys.down) wiimote.led.two = 1;
26 else wiimote.led.two = 0;
27
28 if (wiimote.keys.left) wiimote.led.three = 1;
29 else wiimote.led.three = 0;
30
31 if (wiimote.keys.right) wiimote.led.four = 1;
32 else wiimote.led.four = 0;
33
34 if (wiimote.keys.a) wiimote.rumble = 1;
35 else wiimote.rumble = 0;
36 }
37
38 return 0;
39 }
Para empelar el mando como puntero sobre la pantalla de modo que se puedan
acceder a los diferentes menus de una aplicación o apuntar a un objetivo
concreto en la pantalla, solo tendrá que conocerse la posición a la que se señala.
Si se desea emplear el mando a modo de “volante” para una aplicación de
simulación automovilística, bastará procesar la variación de la fuerza sobre el
eje y y quizá implementar algunas funciones mediante los botones para frenar,
acelerar, etc.
22
23 printf ("\n");
24 }
Suponga que desea implementar un juego donde se deba lanzar una caña de
pescar o simular el golpe de un palo de golf. Deberá tomar e interpretar del
modo adecuado el “viraje” de la mano. Así, para el caso de la caña de pescar se
monitorizará la fuerza de la gravedad ejercida en el eje z para saber si está hacia
adelante y hacia atrás, siempre y cuando la inclinación en el eje y sea negativo
(la caña debe estar arriba). Además, deberá calcularse la fuerza a partir de las
componentes del vector gravedad.
Como puede verse las posibilidades son múltiples, solo es cuestión de identificar
cuáles son los datos procedentes del mando que son imprescindibles para nuestra apli-
cación, posibilitándo de este modo la construcción de interfaces de usuario naturales,
donde un elemento del mundo real externo a la aplicación, puede interaccionar con el
mundo virtual dentro del computador.
Si en ocasiones no resulta evidente el conocer qué elementos deben procesarse
y con qué valores, existen aplicaciones que pueden ayudar a testear cuáles son las
variables que intervienen en un movimiento. En la figura 31.13 puede verse el aspecto
de una de ellas: WmGui. Se trata de una aplicación para GNU/Linux donde podemos
monitorizar parámetros como los botones pulsados, la variación sobre los ejes x, y, z,
la acceleración como composición del vector gravedad, la rotación, el balanceo, etc.
Una cámara RGB que proporciona una resolución VGA (Video Graphics
Adapter) de 8 bits (640x480).
Un sensor de profuncidad monocromo de resolución VGA de 11 bist que
proporcina 211 = 2048 niveles de sensibilidad.
Un array de cuatro micrófonos.
libfreenect requiere que el usuario que accede a Kinect pertenezca a los grupos
plugdev y video. Deberá garantizarse que el usuario pertenece a estos grupos mediante
los comandos:
1 http://openkinect.org/wiki/Main_Page
C31
[1040] CAPÍTULO 31. INTERFACES DE USUARIO AVANZADAS
Tras esto, tendrá que cerrarse la sesión para volver a acceder de nuevo y hacer así
efectivos los grupos a los que se ha añadido el usuario. Con ello, podrán ejecutarse
las aplicaciones que manipulen Kinect, como las que se incluyen en el paquete de
demostración para probar la captura realizada por las cámaras de Kinect:
# freenect-glview
Con todo, lo que OpenKinect proporciona con libfreenect es un API para el acceso
a los datos procedentes de Kinect y para el control del posicionamiento del motor.
Sin embargo, procesar los datos leídos es responsabilidad de la aplicación, por lo
que es común su empleo junto a librerías como OpenCV para la identificación del
movimiento, reconocimiento de personas, etc.
Si lo que se desea es poder disponer de un API de más alto nivel, deberemos hacer
uso de algún framework, como el de OpenNI que pasaremos a describir en la siguiente
sección.
31.15. OpenNI
OpenNI (Open Natural Interaction) es una organización sin ánimo de lucro
promovida por la industria para favorecer la compatibilidad e interoperabilidad de
dispositivos, aplicaciones y middlewares que permitan la construcción de Interfaces
Naturales.
La organización ha liberado un framework para el desarrollo de aplicaciones, el
cual permite tanto la comunicación a bajo nivel con los dispositivos de video y audio
de Kinect, como el uso de soluciones de alto nivel que emplean visión por computador
para realizar seguimiento de objetos como manos, cabeza, cuerpo, etc.
El framework OpenNI se distribuyó bajo licencia GNU Lesser General Public
License (LGPL (Lesser General Public License)) hasta la versión 1.5, pasando a
distribuirse bajo Apache License desde la versión 2. Proporciona un conjunto de APIs
para diferentes plataformas con wrappers para diversos lenguajes de programación
entre los que están C, C++, Java y C#.
31.15. OpenNI [1041]
Capa de Hardware
Dispositivos y sensores Micrófonos Videocámaras Kinect
31.15.1. Instalación
Para poder emplear OpenNI, deberá descargarse el software OpenNI, el midd-
leware NiTE de PrimeSense y el driver del Sensor de la dirección http://www.
openni.org/openni-sdk/, donde pueden encontrarse los paquetes preparados
para Windows, Linux y Mac, así como el acceso a los repositorios github de los fuen-
tes para ser compilados e instalados.
Sin embargo, si se desea desarrollar aplicaciones que empleen OpenNI en otras
plataformas, una opción interesante y sencilla puede pasar por seguir el proyecto
Simple-OpenNI2 , donde se pone a nuestra disposición una versión algo reducida del
código de OpenNI con un wrapper para Processing, pero con funcionalidad sufiente
para realizar una primera inmersión en el potencial de OpenNI.
Así, desde la página de Simple-OpenNI pueden descargarse ficheros zip con el
código preparado para compilar e instalar en sistemas Linux de 64 bits, MacOSX
y Windows de 32 y 64 bits. Una vez descargado el fichero zip para la plataforma
correspondiente, bastará descomprimirlo y se dispondrá de directorios llamados
OpenNI-Bin-Dev-Linux-x64-v1.5.2.23, NITE-Bin-Dev-Linux-x64-v1.5.2.21 y Sensor-
Bin-Linux-x64-v5.1.0.41 si nos hemos descargado el código para una arquitectura de
64 bits.
En cualquiera de los casos, si se ha descargado el zip completo desde Simple-
OpenNI o si se ha descargado desde los correspondientes repositorios github, para la
instalación deberán compilarse e instalarse por separado cada uno de los anteriores
paquetes, invocando al ./install que se encuentra en cada uno de ellos:
1. OpenNI
# cd OpenNI-Bin-Dev-Linux-x64-v1.5.2.23
# sudo ./install
2. Nite
# cd NITE-Bin-Dev-Linux-x64-v1.5.2.21
# sudo ./install
3. Driver Kinect
# cd Sensor-Bin-Linux-x64-v5.1.0.41
# sudo ./install
Módulos
Los nodos de producción son componentes que encapsulan una funcionalidad muy
concreta. Estos toman unos datos de un tipo concreto en su entrada, para producir unos
datos específicos de salida como resultado de un procesamiento concreto. Esta salida
puede ser aprovechada por otros nodos de producción o por la aplicación final.
Existen nodos de producción tando de los sensores como del middleware.
Aquellos nodos de producción que proporcionan datos que pueden ser manipulados
directamente por nuestra aplicación son los conocidos como “generadores”. Ejemplos
de generadores son: el generador de imagen, que proporciona la imagen capturada
por la cámara; el generador de profundidad, que proporciona el mapa de profundidad
capturado por el sensor 3D; el generador de usuario, que proporciona datos sobre
los usuarios identificados; el generador de puntos de la mano, que proporciona
información acerca del muestreo de las manos; el generador de alertas de gestos, que
proporciona datos a cerca de los gestos identificados en los usuarios, etc.
Cadenas de producción
21 g_UserGenerator.GetSkeletonCap().SetSkeletonProfile(
22 XN_SKEL_PROFILE_ALL);
23
24 // Hacer que los generadores comiencen su trabajo
25 nRetVal = context.StartGeneratingAll();
26
27 while (TRUE){
28 // Tomamos el siguiente frame
29 nRetVal = context.WaitAndUpdateAll();
30
31 // Para cada usuario detectado por el sistema
32 XnUserID aUsers[15];
33 XnUInt16 nUsers = 15;
34 g_UserGenerator.GetUsers(aUsers, nUsers);
35 for (int i = 0; i < nUsers; ++i){
36 // Recogemos los datos deseados del esqueleto
37 if (g_UserGenerator.GetSkeletonCap().IsTracking(aUsers[i])){
38 XnSkeletonJointPosition LeftHand;
39 g_UserGenerator.GetSkeletonCap().GetSkeletonJointPosition(
40 aUsers[i], XN_SKEL_LEFT_HAND, LeftHand);
41
42 // Procesamos los datos leídos
43 if (LeftHand.position.X<0) printf("RIGHT ");
44 else printf("LEFT ");
45
46 if (LeftHand.position.Y>0) printf("UP");
47 else printf("DOWN");
48
49 printf("\n");
50 }
51 }
52 }
53
54 context.Shutdown();
55
56 return 0;
57 }
✄
En toda aplicación que manipule Kinect con el framework de OpenNI, se debe
comenzar con la inicialización de un objeto “Contexto” ✂5-6 ✁. Éste contiene todo el
✄ OpenNI. Cuando el Contexto no vaya a ser usado
estado del procesamiento usando
más, este deberá ser liberado ✂54 ✁.
Una vez que se tiene el objeto Contexto construido, se pueden crear los nodos
de producción. Para ello será preciso invocar al constructor apropiado. Así se
dispone de xn::DepthGenerator::Create() para generar datos de profundidad, xn::-
ImageGenerator::Create() para generar imagen RGB, xn::IRGenerator::Create(),
para generar datos del receptor de infrarrojos, xn::AudioGenerator::Create() para
generar datos de audio procedente de los micrófonos, xn::GestureGenerator::Create()
para generar datos de gestos, xn::SceneAnalyzer::Create() para generar datos de
la escena, xn::HandsGenerator::Create() para generar datos de las manos, xn::-
UserGenerator::Create() para generar datos del usuario, etc.
✄ En el caso que nos ocupa, en el ejemplo se ha empleado un generador de usuario
✂9 ✁, el cual producirá información asociada a cada uno de los usuarios identificados en
una escena, tales como el esqueleto o la posición de la cabeza, las manos, los pies, etc.
Tras esto se deberán establecer las opciones que sean convenientes para procesar los
datos del generador elegido. Volveremos a las opciones establecidas para el generador
de usuario en breve, pero de momento continuaremos nuestra explicación obviando
los detalles concretos de la inicialización de este.
Una vez establecidos las opciones de los generadores, deberá hacerse que estos
comiencen a producir la información de la que son ✄ responsables. Esto se hará
con la función StartGeneratingAll() del Contexto ✂25 ✁. Tras esto se podrá entrar
en el bucle principal de procesamiento de la información para recoger los datos
31.15. OpenNI [1045]
producidos por los generadores. En dicho bucle, lo primero que se hará es actualizar
✄ mediante alguna de las funciones de la
la información procedente de los generadores
familia WaitAndUpdateAll() del Contexto ✂29 ✁. A continuación, se tendrá disponible la
información proporcionada por los generadores, y que podrá recogerse con métodos
✄
Get, los cuales dependerán del generador o generadores que se estén manipulando.
En el ejemplo, lo que se hace es invocar al método GetUsers ✂34 ✁que devuelve el Id
para cada uno de los usuarios que ha reconocido el generador, así como el número de
usuarios detectados en la escena. Con esta información, para cada uno de los usuarios
identificados, se va recogiendo su esqueleto mediante el método GetSkeletonCap(),
y a partir de este, la posición en✄ la que se encuentra la mano izquierda con el
método GetSkeletonJointPosition() ✂38 ✁al que se le ha indicado qué parte del esqueleto
se desea extraer (mediante el parámetro XN_SKEL_LEFT_HAND) y dónde se
✄
almacenará la información (en la estructura LeftHand). partir de aquí, bastará con
A
procesar la información de la estructura reportada ✂42-49 ✁.
Es preciso tener en cuenta que, cuando le pedimos datos del esqueleto al
generador, es desde el punto de vista de la cámara. Es decir, la mano izquierda que
“ve Kinect” se corresponderá con mano la derecha del usuario.
Como se ha mencionado con anterioridad, para poder disponer de los datos
adecuados procedentes de los generadores, en ocasiones será preciso establecer ciertos
✄
parámetros de éstos y personalizar determinada funcionalidad. Ha llegado el momento
de abordar de nuevo este fragmento del código. Así, en el ejemplo, en las líneas ✂11-22 ✁
puede verse cómo se han registrado determinados manejadores XnCallbackHandle
que indican al generador de usuario qué acción debe realizar cuando se ha detectado
un nuevo usuario o cuando se ha perdido uno (empleando para ello el método
RegisterUserCallbacks), qué hacer cuando se ha registrado una determinada pose del
usuario (mediante RegisterToPoseCallbacks), y las acciones a realizar al comenzar y
terminar el proceso de calibración (usando RegisterCalibrationCallbacks).
Una implementación de estos manejadores o callbacks es la que se muestra en el
siguiente código:
29
30 void XN_CALLBACK_TYPE
31 Calibration_End(xn::SkeletonCapability& capability, XnUserID nId,
32 XnBool bSuccess, void* pCookie){
33 if (bSuccess){
34 printf("Usuario calibrado\n");
35 g_UserGenerator.GetSkeletonCap().StartTracking(nId);
36 } else {
37 printf("Fallo al calibrar al usuario %d\n", nId);
38 g_UserGenerator.GetPoseDetectionCap().
39 StartPoseDetection(POSE_TO_USE, nId);
40 }
41 }
Utilización de objetos físicos Ejemplo de proyección Ejemplo típico de utilización Sistema de Realidad Virtual Sistema de Realidad Virtual
para crear modelos virtuales. espacial de Realidad de la librería ARToolKit. Semi-Inmersivo empleando tipo cueva “Cave” totalmente
Aumentada “Bubble Cosmos” el sistema “Barco Baron”. inmersivo.
Diagrama Adaptado de (Milgram y Kishino 94) y Material Fotográfico de Wikipedia.
El 2009 se presenta ARhrrrr! (ver Figura 31.27), el primer juego con contenido de
alta calidad para smartphones con cámara. El teléfono utiliza la metáfora de ventana
virtual para mostrar un mapa 3D donde disparar a Zombies y facilitar la salida a los
humanos que están atrapados en él. El videojuego emplea de forma intensiva la GPU
del teléfono delegando en la tarjeta todos los cálculos salvo el tracking basado en
características naturales, que se realiza en la CPU. Así es posible distribuir el cálculo
optimizando el tiempo final de ejecución.
El videojuego de PSP Invizimals (ver Figura 31.28), creado por el estudio español Figura 31.25: AR-Tennis.
Novorama en 2009, alcanza una distribución en Europa en el primer trimestre de
2010 superior a las 350.000 copias, y más de 8 millones de copias a nivel mundial,
situándose en lo más alto del ránking de ventas. Este juego emplea marcas para
registrar la posición de la cámara empleando tracking visual.
3. Alineación 3D. La información del mundo virtual debe ser tridimensional Figura 31.28: Invizimals.
y debe estar correctamente alineada con la imagen del mundo real. Así,
estrictamente hablando las aplicaciones que superponen capas gráficas 2D sobre
la imagen del mundo real no son consideradas de Realidad Aumentada.
Siendo estrictos según la definición de Azuma, hay algunas aplicaciones que co-
mercialmente se venden como de realidad aumentada que no deberían ser consideradas
como tal, ya que el registro del mundo real no se realiza en 3D (como en el caso de
Wikitude o Layar por ejemplo).
31.16. Realidad Aumentada [1051]
31.17. ARToolKit
ARToolKit es una biblioteca de funciones para el desarrollo rápido de aplicaciones
de Realidad Aumentada. Fue escrita originalmente en C por H. Kato, y mantenida por
el HIT Lab de la Universidad de Washington, y el HIT Lab NZ de la Universidad de
Canterbury (Nueva Zelanda).
ARToolKit facilita el problema del registro de la cámara empleando métodos
de visión por computador, de forma que obtiene el posicionamiento relativo de 6
grados de libertad haciendo el seguimiento de marcadadores cuadrados en tiempo real,
incluso en dispositivos de baja capacidad de cómputo. Algunas de las características
más destacables son:
Color conversion should use x86 assembly (not working for 64bit)?
Enter : n
Do you want to create debug symbols? (y or n)
Enter : y
Build gsub libraries with texture rectangle support? (y or n)
GL_NV_texture_rectangle is supported on most NVidia graphics cards
and on ATi Radeon and better graphics cards
Enter : y
create ./Makefile
create lib/SRC/Makefile
...
create include/AR/config.h
Done.
27 glDepthFunc(GL_LEQUAL);
28
29 argConvGlpara(patt_trans, gl_para); // Convertimos la matriz de
30 glMatrixMode(GL_MODELVIEW); // la marca para ser usada
31 glLoadMatrixd(gl_para); // por OpenGL
32 // Esta ultima parte del codigo es para dibujar el objeto 3D
33 glEnable(GL_LIGHTING); glEnable(GL_LIGHT0);
34 glLightfv(GL_LIGHT0, GL_POSITION, light_position);
35 glMaterialfv(GL_FRONT, GL_AMBIENT, mat_ambient);
36 glTranslatef(0.0, 0.0, 60.0);
37 glRotatef(90.0, 1.0, 0.0, 0.0);
38 glutSolidTeapot(80.0);
39 glDiable(GL_DEPTH_TEST);
40 }
41 // ======== init =================================================
42 static void init( void ) {
43 ARParam wparam, cparam; // Parametros intrinsecos de la camara
44 int xsize, ysize; // Tamano del video de camara (pixels)
45
46 // Abrimos dispositivo de video
47 if(arVideoOpen("") < 0) exit(0);
48 if(arVideoInqSize(&xsize, &ysize) < 0) exit(0);
49
50 // Cargamos los parametros intrinsecos de la camara
51 if(arParamLoad("data/camera_para.dat", 1, &wparam) < 0)
52 print_error ("Error en carga de parametros de camara\n");
53
54 arParamChangeSize(&wparam, xsize, ysize, &cparam);
55 arInitCparam(&cparam); // Inicializamos la camara con çparam"
56
57 // Cargamos la marca que vamos a reconocer en este ejemplo
58 if((patt_id=arLoadPatt("data/simple.patt")) < 0)
59 print_error ("Error en carga de patron\n");
60
61 argInit(&cparam, 1.0, 0, 0, 0, 0); // Abrimos la ventana
62 }
63 // ======== mainLoop =============================================
64 static void mainLoop(void) {
65 ARUint8 *dataPtr;
66 ARMarkerInfo *marker_info;
67 int marker_num, j, k;
68
69 double p_width = 120.0; // Ancho del patron (marca)
70 double p_center[2] = {0.0, 0.0}; // Centro del patron (marca)
71
72 // Capturamos un frame de la camara de video
73 if((dataPtr = (ARUint8 *)arVideoGetImage()) == NULL) {
74 // Si devuelve NULL es porque no hay un nuevo frame listo
75 arUtilSleep(2); return; // Dormimos el hilo 2ms y salimos
76 }
77 argDrawMode2D();
78 argDispImage(dataPtr, 0,0); // Dibujamos lo que ve la camara
79
80 // Detectamos la marca en el frame capturado (ret -1 si error)
81 if(arDetectMarker(dataPtr, 100, &marker_info, &marker_num) < 0){
82 cleanup(); exit(0); // Si devolvio -1, salimos del programa!
83 }
84 arVideoCapNext(); // Frame pintado y analizado... A por otro!
85 // Vemos donde detecta el patron con mayor fiabilidad
86 for(j = 0, k = -1; j < marker_num; j++) {
87 if(patt_id == marker_info[j].id) {
88 if (k == -1) k = j;
89 else if(marker_info[k].cf < marker_info[j].cf) k = j;
90 }
91 }
92 if(k != -1) { // Si ha detectado el patron en algun sitio...
93 // Obtenemos transformacion entre la marca y la camara real
94 arGetTransMat(&marker_info[k], p_center, p_width, patt_trans);
95 draw(); // Dibujamos los objetos de la escena
96 }
97 argSwapBuffers(); // Cambiamos el buffer con lo que dibujado
31.18. El esperado “Hola Mundo!” [1055]
98 }
99 // ======== Main =================================================
100 int main(int argc, char **argv) {
101 glutInit(&argc, argv); // Creamos la ventana OpenGL con Glut
102 init(); // Llamada a nuestra funcion de inicio
103 arVideoCapStart(); // Creamos un hilo para captura video
104 argMainLoop( NULL, NULL, mainLoop ); // Asociamos callbacks...
105 return (0);
106 }
✄ Mundo!” se
1. Captura. Se obtiene un frame de la cámara de vídeo. En el “Hola
realiza llamando a la función arVideoGetImage en la línea ✂73 ✁.
31.18.1. Inicialización
El bloque de inicialización (función init), comienza abriendo el dispositivo de
video con arVideoOpen. Esta función admite parámetros de configuración en una
✄
cadena de caracteres.
A continuación, línea ✂48 ✁, se obtiene el tamaño de la fuente de vídeo mediante
arVideoInqSize. Esta función escribe en las direcciones de memoria reservadas
para dos enteros el ancho y alto del vídeo. En las siguientes líneas cargamos los
parámetros intrínsecos de la cámara obtenidos en la etapa de calibración. En este
ejemplo utilizaremos un fichero genérico camera_para.dat válido para la mayoría de
webcams. Sin embargo, para obtener resultados más precisos, lo ideal es trabajar con
un fichero adaptado a nuestro modelo de cámara concreto. Veremos cómo crear este
fichero específico para cada cámara posteriormente.
Así, con la llamada a arParamLoad se rellena la estructura ARParam especificada
como tercer parámetro con las características de la cámara. Estas características son
independientes de la resolución a la que esté trabajando la cámara, por lo que tenemos
✄
que instanciar los parámetros concretamente a la resolución en píxeles. Para ello, se
utiliza la llamada a arParamChangeSize (línea ✂54 ✁) especificando la resolución
concreta con la que trabajará la cámara en este ejemplo. Una vez obtenidos los
parámetros de la cámara instanciados al caso concreto de este ejemplo, se cargan en las
✄
estructuras de datos internas de ARToolKit, mediante la llamada a arInitCparam.
En la última parte cargamos el patrón asociado a la marca (línea ✂58 ✁). Finalmente
abrimos la ventana de✄OpenGL (mediante la biblioteca auxiliar Gsub de ARToolKit)
argInit en la línea ✂61 ✁, pasándole como primer parámetro la configuración de la
cámara. El segundo parámetro indica el factor de zoom (en este caso, sin zoom).
muy pronto; con mayor frecuencia que la✄ soportada por la cámara). Si esto ocurre, definir marcas personalizadas, las
simplemente dormimos el hilo 2ms (línea ✂75 ✁)y volvemos a ejecutar el mainLoop.
que ofrecen mejores resultados son
✄
las basadas en patrones sencillos,
modo 2D, línea ✂77 ✁) el buffer que
✄ (en
A continuación dibujamos en la ventana como la empleada en este ejemplo.
acabamos de recuperar de la cámara (línea ✂78 ✁).
✄ a arDetectMarker localiza las marcas en el buffer de entrada. En
La llamada
la línea ✂81 ✁el segundo parámetro de valor 100 se corresponde con el valor umbral
de binarización (a blanco y negro, ver Figura 31.35) de la imagen. El propósito
de este valor umbral (threshold) se explicará en detalle más adelante. Esta función
nos devuelve en el tercer parámetro un puntero a una lista de estructuras de tipo
ARMarkerInfo, que contienen información sobre las marcas detectadas (junto con
un grado de fiabilidad de la detección), y como cuarto parámetro el número de marcas
detectadas.
De esta forma, ARToolKit nos devuelve “posibles” posiciones para cada una
de las marcas detectas. Incluso cuando estamos trabajando con una única marca, es
común que sea detectada en diferentes posiciones (por ejemplo, si hay algo parecido
a un cuadrado negro en la escena). ¿Cómo elegimos la correcta en el caso de que
tengamos varias detecciones? ARToolKit asocia a cada percepción una probabilidad
Figura 31.35: Ejemplo de proceso
de binarización de la imagen de
entrada para la detección de marcas.
31.19. Las Entrañas de ARToolKit [1057]
de que lo percibido sea una marca, en el campo cf (confidence value). En la tabla 31.1
se muestra la descripción completa de los campos de esta estructura. Como se puede
comprobar, todos los campos de la estructura ARMarkerInfo se refieren a coordenadas
✄
2D, por lo que aún no se ha calculado la posición relativa de la marca con la cámara.
Así, en las líneas ✂86-91 ✁guardamos en la variable k el índice de la lista de marcas
detectadas aquella percepción que tenga mayor probabilidad de ser la marca (cuyo
✄
valor de fiabilidad sea mayor).
Mediante la llamada a arGetTransMat (línea ✂94 ✁) obtenemos en una matriz
la transformación relativa entre la marca y la cámara (matriz 3x4 de doubles); es
decir, obtiene la posición y rotación de la cámara con respecto de la marca detectada.
Para ello es necesario especificar el centro de la marca, y el ancho. Esta matriz será
✄
finalmente convertida al formato de matriz homogenea de 16 componentes utilizada
por OpenGL mediante la llamada a argConvGlpara en la línea ✂29 ✁.
Búsqueda de Marcas
Dibujado Objetos 3D
Registro Cámara 3D
Identificación Patrón
Posición 2D Marcas
Pos. + Rotación 3D
Fuente de vídeo de En la imagen binaria se Los objetos virtuales 3D
entrada.La imagen será 1 detectan contornos y se 2 3 4 5 6 se dibujan superpuestos a
binarizada a blanco y extraen las aristas y las la imagen real. En este
negro para buscar marcas esquinas del cuadrado. caso coincidiendo con el
cuadradas. centro de la marca.
Video: Este módulo contiene las funciones para obtener frames de vídeo de los
dispositivos soportados por el Sistema Operativo. El prototipo de las funciones
de este módulo se encuentran el fichero de cabecera video.h.
Aplicación Realidad Aumentada
AR: Este módulo principal contiene las funciones principales de tracking de
marcas, calibración y estructuras de datos requeridas por estos métodos. Los ARToolKit
ficheros de cabecera ar.h, arMulti.h (subrutinas para gestión multi-patrón) Gsub
y param.h describen las funciones asociadas a este módulo. AR Video
Gsub_Lite
Gsub y Gsub_Lite: Estos módulos contienen las funciones relacionadas con la GLUT
etapa de representación. Ambas utilizan GLUT, aunque la versión “_Lite” es Bibs.
más eficiente y no tiene dependencias con ningún sistema de ventanas concreto. OpenGL APIs Vídeo
En estos módulos se describen los ficheros de cabecera gsub.h, gsub_lite.h Driver Tarjeta 3D Driver Cámara
y gsubUtil.h. Sistema Operativo
Estos módulos están totalmente desacoplados, de forma que es posible utilizar Figura 31.36: Arquitectura de AR-
ARToolKit sin necesidad de emplear los métodos de captura de vídeo del primer ToolKit.
módulo, o sin emplear los módulos de representación Gsub o Gsub_Lite, como
veremos en la posterior integración con Ogre y OpenCV.
Xc R11 R12 R13 Tx Xm Xm
Yc R21 R22 R23 Ty Y m Ym
Z = R R32 R33 Tz × Zm = Tcm × Zm (31.1)
c 31
d 1 0 0 0 1 1 1
De este modo, conociendo la proyección de cada esquina de la marca (e) sobre las
coordenadas de pantalla (xc, yc), y las restricciones asociadas al tamaño de las marcas
y su geometría cuadrada, es posible calcular la matriz Tcm . La Figura 31.40 resume
esta transformación.
e
Ym Coordenadas
Convenio de xc de la Cámara
Xc
ARToolKit:
el eje Z “sale”
Xm desde el papel. Coordenadas (xce,yce)
en Pantalla
(xc, yc) Yc
Coordenadas
Zm
de la Marca
e
Xm Zc
yc
Ym
carlos@kurt:calib_camera2$ ./calib_camera2
Input the distance between each marker dot, in millimeters: 40
-----------
Press mouse button to grab first image,
or press right mouse button or [esc] to quit.
Hecho esto nos aparecerá una ventana con la imagen que percibe la cámara. Mo-
veremos el patrón para que todos los puntos aparezcan en la imagen y presionaremos
el botón izquierdo del ratón una vez sobre la ventana para congelar la imagen. Ahora
tenemos que definir un rectángulo que rodee cada círculo del patrón (ver Figura 31.42
empleando el siguiente orden: primero el círculo más cercano a la esquina superior
izquierda, y a continuación los de su misma fila. Luego los de la segunda fila co-
menzando por el de la izquierda y así sucesivamente. Es decir, los círculos del patrón
Figura 31.42: Marcado de círculos
deben ser recorridos en orden indicado en la Figura 31.43. del patrón.
31.19. Las Entrañas de ARToolKit [1061]
El programa marcará una pequeña cruz en el centro de cada círculo que hayamos
marcado (ver Figura 31.42), y aparecerá una línea indicando que ha sido señalada
como se muestra a continuación. Si no aparece una cruz en rojo es porque el círculo
no se ha detectado y tendrá que ser de nuevo señalado.
1 2 3 4 5 6
Grabbed image 1.
7 8 9 10 11 12 -----------
Press mouse button and drag mouse to rubber-bound features (6 x 4),
or press right mouse button or [esc] to cancel rubber-bounding & retry
13 14 15 16 17 18 grabbing.
Marked feature position 1 of 24
19 20 21 22 23 24 Marked feature position 2 of 24
Marked feature position 3 of 24
Figura 31.43: Orden de marcado ...
de los círculos del patrón de calibra- Marked feature position 24 of 24
ción. -----------
Press mouse button to save feature positions,
or press right mouse button or [esc] to discard feature positions &
retry grabbing.
Una vez que se hayan marcado los 24 puntos, se pulsa de nuevo el botón izquierdo
del ratón sobre la imagen. Esto almacenará la posición de las marcas para la primera
imagen, y descongelará la cámara, obteniendo una salida en el terminal como la
siguiente.
Precisión en calibración
### Image no.1 ###
Como es obvio, a mayor número 1, 1: 239.50, 166.00
de imágenes capturadas y marca- 2, 1: 289.00, 167.00
das, mayor precisión en el proceso ...
de calibración. Normalmente con 5 6, 4: 514.00, 253.50
ó 6 imágenes distintas suele ser su- -----------
ficiente. Press mouse button to grab next image,
or press right mouse button or [esc] to calculate distortion param.
-- loop:-50 --
F = (816.72,746.92), Center = (325.0,161.0): err = 0.843755
-- loop:-49 --
F = (816.47,747.72), Center = (325.0,162.0): err = 0.830948
...
Calibration succeeded. Enter filename to save camera parameter.
--------------------------------------
SIZE = 640, 480
Distortion factor = 375.000000 211.000000 -16.400000 0.978400 Figura 31.45: Dos imágenes resul-
770.43632 0.00000 329.00000 0.00000 tado del cálculo del centro y el fac-
0.00000 738.93605 207.00000 0.00000 tor de distorsión.
0.00000 0.00000 1.00000 0.00000
--------------------------------------
Filename: logitech_usb.dat
2 Normalización 3 Resampling 4
1 Identificación
de la marca
Comparación
con datos
identic.patt
de ficheros
de patrones
Fichero
Figura 31.46: Pasos para la identificación de patrones.
Limitaciones
Entre los factores que afectan a la detección de los patrones podemos destacar su
tamaño, complejidad, orientación relativa a la cámara y condiciones de iluminación
de la escena (ver Figura 31.47).
150
El tamaño físico de la marca afecta directamente a la facilidad de detección; a
Rotación
0
2
La orientación relativa de la marca con respecto a la cámara afecta a la calidad 30
Error (cm)
1 45
de la detección. A mayor perpendicularidad entre el eje Z de la marca y el vector look
de la cámara, la detección será peor. 0
-1
Finalmente las condiciones de iluminación afectan enormemente a la detección
de las marcas. El uso de materiales que no ofrezcan brillo especular, y que disminuyan 0 10 20 30 40 50 60
el reflejo en las marcas mejoran notablemente la detección de las marcas. Distancia (cm)
Figura 31.48: Comparativa de la trayectoria (en eje X) de 40 frames sosteniendo la marca manualmente
activando el uso del histórico de percepciones. Se puede comprobar cómo la gráfica en el caso de activar el
uso del histórico es mucho más suave.
31.20. Histórico de Percepciones [1065]
C31
[1066] CAPÍTULO 31. INTERFACES DE USUARIO AVANZADAS
44 }
45
46 // ======== cleanup ===============================================
47 static void cleanup(void) { // Libera recursos al salir ...
48 arVideoCapStop(); arVideoClose(); argCleanup();
49 free(objects); // Liberamos la memoria de la lista de objetos!
50 exit(0);
51 }
52
53 // ======== draw ==================================================
54 void draw( void ) {
55 double gl_para[16]; // Esta matriz 4x4 es la usada por OpenGL
56 GLfloat light_position[] = {100.0,-200.0,200.0,0.0};
57 int i;
58
59 argDrawMode3D(); // Cambiamos el contexto a 3D
60 argDraw3dCamera(0, 0); // Y la vista de la camara a 3D
61 glClear(GL_DEPTH_BUFFER_BIT); // Limpiamos buffer de profundidad
62 glEnable(GL_DEPTH_TEST);
63 glDepthFunc(GL_LEQUAL);
64
65 for (i=0; i<nobjects; i++) {
66 if (objects[i].visible) { // Si el objeto es visible
67 argConvGlpara(objects[i].patt_trans, gl_para);
68 glMatrixMode(GL_MODELVIEW);
69 glLoadMatrixd(gl_para); // Cargamos su matriz de transf.
70
71 // La parte de iluminacion podria ser especifica de cada
72 // objeto, aunque en este ejemplo es general para todos.
73 glEnable(GL_LIGHTING); glEnable(GL_LIGHT0);
74 glLightfv(GL_LIGHT0, GL_POSITION, light_position);
75 objects[i].drawme(); // Llamamos a su funcion de dibujar
76 }
77 }
78 glDisable(GL_DEPTH_TEST);
79 }
80
81 // ======== init ==================================================
82 static void init( void ) {
83 ARParam wparam, cparam; // Parametros intrinsecos de la camara
84 int xsize, ysize; // Tamano del video de camara (pixels)
85 double c[2] = {0.0, 0.0}; // Centro de patron (por defecto)
86
87 // Abrimos dispositivo de video
88 if(arVideoOpen("") < 0) exit(0);
89 if(arVideoInqSize(&xsize, &ysize) < 0) exit(0);
90
91 // Cargamos los parametros intrinsecos de la camara
92 if(arParamLoad("data/camera_para.dat", 1, &wparam) < 0)
93 print_error ("Error en carga de parametros de camara\n");
94
95 arParamChangeSize(&wparam, xsize, ysize, &cparam);
96 arInitCparam(&cparam); // Inicializamos la camara con çparam"
97
98 // Inicializamos la lista de objetos
99 addObject("data/simple.patt", 120.0, c, drawteapot);
100 addObject("data/identic.patt", 90.0, c, drawcube);
101
102 argInit(&cparam, 1.0, 0, 0, 0, 0); // Abrimos la ventana
103 }
104
105 // ======== mainLoop ==============================================
106 static void mainLoop(void) {
107 ARUint8 *dataPtr;
108 ARMarkerInfo *marker_info;
109 int marker_num, i, j, k;
110
111 // Capturamos un frame de la camara de video
112 if((dataPtr = (ARUint8 *)arVideoGetImage()) == NULL) {
113 // Si devuelve NULL es porque no hay un nuevo frame listo
114 arUtilSleep(2); return; // Dormimos el hilo 2ms y salimos
31.21. Utilización de varios patrones [1069]
115 }
116
117 argDrawMode2D();
118 argDispImage(dataPtr, 0,0); // Dibujamos lo que ve la camara
119
120 // Detectamos la marca en el frame capturado (ret -1 si error)
121 if(arDetectMarker(dataPtr, 100, &marker_info, &marker_num) < 0){
122 cleanup(); exit(0); // Si devolvio -1, salimos del programa!
123 }
124
125 arVideoCapNext(); // Frame pintado y analizado...
126
127 // Vemos donde detecta el patron con mayor fiabilidad
128 for (i=0; i<nobjects; i++) {
129 for(j = 0, k = -1; j < marker_num; j++) {
130 if(objects[i].id == marker_info[j].id) {
131 if (k == -1) k = j;
132 else if(marker_info[k].cf < marker_info[j].cf) k = j;
133 }
134 }
135
136 if(k != -1) { // Si ha detectado el patron en algun sitio...
137 objects[i].visible = 1;
138 arGetTransMat(&marker_info[k], objects[i].center,
139 objects[i].width, objects[i].patt_trans);
140 } else { objects[i].visible = 0; } // El objeto no es visible
141 }
142
143 draw(); // Dibujamos los objetos de la escena
144 argSwapBuffers(); // Cambiamos el buffer
145 }
✄
La estructura de datos base del ejemplo es TObject, definida en las líneas ✂2-9 ✁.
En esta estructura, el último campo drawme es un puntero a función, que deberá ser
asignado a alguna función que no reciba ni devuelva argumentos, y que se encargará
✄
de dibujar el objeto.
Mantendremos en objects (línea ✂11 ✁) una lista de objetos reconocibles en la
escena. La
✄ memoria ✄ en la función addObject
para cada objeto de la lista se reservará
(líneas ✂15-29 ✁) mediante una llamada a realloc en ✄✂21-22 ✁. Esta memoria será
liberada cuando finalice el programa en cleanup (línea ✂49 ✁).
De este modo, en la función de inicialización init, se llama a nuestra función
addObject para cada objeto que deba ser reconocido, pasándole la ruta al fichero
del patrón, el ✄ancho, centro y el nombre de la función de dibujado asociada a esa
marca (líneas ✂99-100 ✁).✄ En este
ejemplo✄ se han definido dos sencillas funciones de
dibujado de una tetera ✂31-37 ✁y un cubo ✂39-44 ✁. En el caso de que una marca no tenga
asociado el dibujado de un objeto (como veremos en los ejemplos de la sección 31.22),
simplemente podemos pasar NULL.
En el bucle principal se han incluido pocos cambios respecto de los ejemplos
anteriores. Únicamente✄en las líneas de código donde se realiza la comprobación de
las marcas detectadas ✂129-142 ✁, se utiliza la lista de objetos global y se realiza la
comparación con cada marca. Así, el bucle for externo se ✄ encarga
de recorrer todos
los objetos que pueden ser reconocidos por el programa ✂129 ✄✁, y se compara el factor
de confianza de cada objeto con el que está siendo estudiado ✄ ✂133 ✁. A continuación se
obtiene la matriz de transformación asociada al objeto en ✂139-140 ✁.
✄ ✄
La función de dibujado (llamada en ✂144 ✁y definida en ✂54-79
✄ ✁) utiliza igualmente
objetos. Para cada objeto visible ✂65-66 ✁se✄ carga
la información de la✄ lista de su matriz
de transformación ✂67-69 ✁y se llama a su función propia de dibujado ✂75 ✁.
C31
[1070] CAPÍTULO 31. INTERFACES DE USUARIO AVANZADAS
X
arGetT
ransM
at
X Y
d? X Y
B
ar
Ge A-1
tTr
a ns
Ma
t X
t
sMa
B d
Tran et
A
ArG
SRU A
Figura 31.53: Utilización de la transformación inversa a la marca de Identic A−1 para calcular la
distancia entre marcas. Primero cargamos la matriz asociada a la marca de marca de Identic, (estableciendo
implícitamente el origen del SRU ahí) y de ahí aplicamos la transformación inversa A−1 . A continuación
aplicamos la matriz B asociada a la segunda marca. La distancia al origen del SRU es la distancia entre
marcas.
Veamos a continuación un ejemplo que trabajará con las relaciones entre la cámara
y la marca. Tener claras estas transformaciones nos permitirá cambiar entre sistemas
de coordenadas y trabajar con las relaciones espaciales entre las marcas que no son
más que transformaciones entre diferentes sistemas de coordenadas. En este programa
queremos variar la intensidad de color rojo de la tetera según la distancia de una marca
auxiliar (ver Figura 31.51).
El problema nos pide calcular la distancia entre dos marcas. ARToolKit, mediante
la llamada a la función arGetTransMat nos devuelve la transformación relativa entre
la marca y la cámara. Para cada marca podemos obtener la matriz de transformación
relativa hacia la cámara. Podemos verlo como la transformación que nos posiciona la
cámara en el lugar adecuado para que, dibujando los objetos en el origen del SRU,
se muestren de forma correcta. ¿Cómo calculamos entonces la distancia entre ambas
marcas?
En el diagrama de la Figura 31.53 se resume el proceso. Con la llamada a
arGetTransMat obtenemos una transformación relativa al sistema de coordenadas
de cada cámara (que, en el caso de una única marca, podemos verlo como si fuera el
origen del SRU donde dibujaremos los objetos). En este caso, tenemos las flechas que
parten de la marca y posicionan relativamente la cámara señaladas con A y B.
Podemos calcular la transformación entre marcas empleando la inversa de una
transformación, que equivaldría a viajar en sentido contrario. Podemos imaginar
realizar la transformación contraria; partiendo del origen del SRU, viajamos hasta la
Figura 31.51: Salida del ejemplo
marca de Identic (aplicando A−1 , y de ahí aplicamos la transformación B (la que nos
de cálculo de distancias entre mar- posicionaría desde la segunda marca hasta la cámara). Así, llegamos al punto final
cas. La intensidad de la componen- (señalado con el círculo de color negro en la Figura 31.53.derecha). Su distancia al
✄
te roja del color varía en función de origen del SRU será la distancia entre marcas.
la distancia entre marcas.
✄
La codificación de esta operación es directa. En la línea ✂33 ✁del listado anterior, si
ambos objetos son visibles ✂32 ✁se calcula la inversa de la transformación a la marca
de Identic (que está en la posición 0 de la lista de objetos). Esta nueva matriz✄ m se
multiplica a continuación con la matriz de transformación de la segunda marca ✂34 ✁.
C31
[1072] CAPÍTULO 31. INTERFACES DE USUARIO AVANZADAS
✄
El VideoManager trabaja internamente con dos tipos de datos para la representa-
ción de los frames (ver líneas ✂11-12 ✁), que pueden ser convertidos entre sí fácilmente.
El IplImage ha sido descrito anteriormente en la sección. cv::Mat es una extensión
para trabajar con matrices de cualquier tamaño y tipo en C++, que permiten acceder
cómodamente a los datos de la imagen. De este modo, cuando se capture un frame, el
VideoManager actualizará los punteros anteriores de manera inmediata.
✄
El constructor de la clase (líneas ✂4-8 ✁del siguiente listado) se encarga de obtener
el dispositivo de captura e inicializarlo con las dimensiones especificadas como
parámetro al constructor. Esto es necesario, ya que las cámaras generalmente permiten
✄ En el destructor se libera el dispositivo de captura
trabajar con varias resoluciones.
creado por OpenCV (línea ✂12 ✁).
✄
✄
rectángulo 2D (coordenadas paramétricas en la línea ✂37 ✁), que añadiremos al grupo
✄ ✂41 ✁). Finalmente se añade el nodo, con el plano y la
que se dibujará primero (línea
textura, a la escena (líneas ✂43-45 ✁) empleando el puntero al SceneManager indicado
en el constructor.
La actualización de la textura asociada al plano se realiza en el método DrawCu-
rrentFrame, que se encarga de acceder a nivel de píxel y copiar el valor de la imagen
✄ por la webcam. Para ello se obtiene un puntero compartido al✄ pixel
obtenida buffer (lí-
nea ✂64 ✁), y obtiene el uso de la memoria✄con exclusión mutua (línea ✂67 ✁). Esa región
será posteriormente liberada en la línea ✂79 ✁. La actualización
✄ de la región se realiza
recorriendo el frame en los bucles de las líneas ✂70-77 ✁, y especificando el color de
✄ píxel en valores RGBA (formato de orden de componentes configurado en línea
cada
✂22 ✁).
El frame obtenido será utilizado igualmente por ARToolKit para detectar las
marcas y proporcionar la transformación relativa. Igualmente se ha definido una clase
para abstraer del uso de ARToolKit a la aplicación de Ogre. El archivo de cabecera del
ARTKDetector se muestra a continuación.
Listado 31.30: ARTKDetector.h
1 #include <AR/ar.h>
2 #include <AR/gsub.h>
3 #include <AR/param.h>
4 #include <Ogre.h>
5 #include <iostream>
6 #include "cv.h"
7
8 class ARTKDetector {
9 private:
10 ARMarkerInfo *_markerInfo;
11 int _markerNum;
12 int _thres;
13 int _pattId;
14 int _width; int _height;
15 double _pattTrans[3][4];
16 bool _detected;
17
18 int readConfiguration();
19 int readPatterns();
20 void Gl2Mat(double *gl, Ogre::Matrix4 &mat);
21
22 public:
23 ARTKDetector(int w, int h, int thres);
24 ~ARTKDetector();
25 bool detectMark(cv::Mat* frame);
26 void getPosRot(Ogre::Vector3 &pos, Ogre::Vector3 &look,
27 Ogre::Vector3 &up);
28 };
En este sencillo ejemplo únicamente se han creado dos clases para la detección de Complétame!
la marca detectMark, y para obtener los vectores de posicionamiento de la cámara en
Sería conveniente añadir nuevos
función de la última marca detectada (getPosRot). métodos a la clase ARTKDetector,
que utilice una clase que encapsule
Listado 31.31: ARTKDetector.cpp los marcadores con su ancho, alto,
1 #include "ARTKDetector.h" identificador de patrón, etc...
2
3 ARTKDetector::ARTKDetector(int w, int h, int thres){
4 _markerNum=0; _markerInfo=NULL; _thres = thres;
5 _width = w; _height = h; _detected = false;
6 readConfiguration();
7 readPatterns();
8 }
9 ARTKDetector::~ARTKDetector(){ argCleanup(); }
10 // ================================================================
31.23. Integración con OpenCV y Ogre [1077]
Esta clase está preparada para trabajar con una única marca (cargada en el método
privado readPatterns), con✄sus variables
asociadas definidas como miembros de la cla-
se ARTKDetector (líneas ✂11-16 ✁). El siguiente listado implementa el ARTKDetector.
C31
[1078] CAPÍTULO 31. INTERFACES DE USUARIO AVANZADAS
✄ una
El método getPosRot es el único que vamos a comentar (el resto son traducción
directa de la funcionalidad estudiada anteriormente). En la línea ✂65 ✁ se utiliza un
método de la clase para transformar la matriz de OpenGL a una Matrix4 de Ogre.
Ambas matrices son matrices columna!, por lo que habrá que tener cuidado a la hora
de trabajar con ellas (el campo de traslación viene definido en la fila inferior, y no en
✄
la columna de la derecha como es el convenio habitual).
Las operaciones de la línea ✂67 ✁sirven para invertir el eje Y de toda la matriz,
para cambiar el criterio del sistema de ejes de mano izquierda a mano derecha (Ogre).
Invertimos la matriz y aplicamos una rotación de 90 grados en X para tener el mismo
✄
sistema con el que hemos trabajado en sesiones anteriores del curso.
Finalmente calculamos los vectores de pos, look y up (líneas ✂71-74 ✁) que
utilizaremos en el bucle de dibujado de Ogre.
El uso de estas clases de utilidad es directa desde el FrameListener. Será ✄necesario
crear dos variables miembro del VideoManager y ARTKDetector (líneas ✂7-8 ✁). En
✄el método
frameStarted bastará con llamar a la actualización del frame (líneas
✂15-16 ✁
). La detección de la marca se realiza pasándole el puntero de tipo Mat del
VideoManager al método detectMark del Detector.
C
ada vez que creamos un nuevo proyecto con Ogre, existe un grupo de elementos
que necesitamos instanciar para crear un primer prototipo. Este conjunto de
clases y objetos se repiten con frecuencia para todos nuestros proyectos. En
este capítulo estudiaremos los fundamentos de OgreFramework, que ofrece una serie
de facilidades muy interesantes.
A.1. Introducción
OgreFramework fué creado por Philip Allgaier en 2009 y ofrece al programador
una serie de herramientas para la gestión de Ogre como:
1081
[1082] ANEXO A. INTRODUCCIÓN A OGREFRAMEWORK
Existen dos ramas del framework para Ogre, Basic y Advanced. La diferencia
entre los dos es la cantidad de recursos que manejan. Basic OgreFramework contiene
los recursos mínimos necesarios para crear una instancia del motor Ogre y que
muestre una escena como ejemplo. Y Advanced OgreFramework incluye un conjunto
completo de herramientas y clases para una gestión avanzada de nuestra aplicación.
OgreFramework está desarrollado en C++ sobre Ogre y es multiplataforma, sin
embargo, es necesario realizar algunas modificaciones en el framework para que pueda
funcionar en OSX. Una de las mayores ventajas de utilizar esta tecnología es la de
poder automatizar la carga inicial del motor y poder configurar Ogre con llamadas
simples a OgreFramework.
Inicialización de Ogre.
Bucle de renderizado personalizable.
Escena básica.
Gestión de teclado básica.
Crear capturas de pantallas (Screenshots).
Información básica de la escena (FPS, contador batch. . . ).
hg clone https://bitbucket.org/spacegaier/basicogreframework
A.2.1. Arquitectura
La rama Basic de OgreFramework contiene en exclusiva la instanciación del motor
de renderizado. Se basa en el patrón Singleton. Cada vez que queramos hacer uso de
alguna llamada al motor será necesario recuperar el puntero al Singleton del objeto. Figura A.1: Resultado del primer
Dentro de la clase OgreFramework nos encontramos con el arranque de los siguientes ejemplo con Ogre Framework.
gestores:
Todos estos gestores de la clase OgreFramework serán explicados con más detalle
en la siguiente sección, donde hablaremos de la rama ‘Advanced’. Para poder crear un
ejemplo basado en Basic OgreFramework es necesario:
Ahora veremos los pasos necesarios para rellenar de contenido nuestra escena.
Para ello crearemos nodos y escenas igual que las creamos en Ogre pero llamando al
puntero de OgreFramework, como se muestra en el siguiente listado.
Como podemos observar, para crear una entidad y un nodo, es necesario acceder
al gestor de escena mediante el puntero Singleton a OgreFramework. Se realizará
la misma acción para crear las luces de la escena y el Skybox. Esta restricción
nos ayudará a mantener separado el motor Ogre de nuestro modelo y permitirá la
integración con otros módulos de forma más homogénea para el desarrollador.
Realmente, cuando accedamos a uno de los gestores de OgreFramework como el
SceneManager, utilizaremos las mismas primitivas que utilizabamos en Ogre. Por lo
tanto, podremos usar la documentación estándar de Ogre.
Para gestionar nuestro bucle del juego necesitaremos la variable booleana m_bShutdown
la cual nos permitirá cerrar la aplicación en cualquier momento. Si se produce un error
inesperado, también saldrá del bucle principal.
En cada iteración del bucle:
Inicialización de OGRE.
Carga de recursos básicos.
Gestión de dispositivos de entrada basado en OIS.
Personalización del bucle de renderizado.
Jerarquía basada en estados.
Cargador de escenas mediante XML con DotSceneLoader.
Interfaz gráfica de usuario basada en SdkTrays.
Manipulación de materiales en código.
hg clone https://bitbucket.org/spacegaier/advancedogreframework
tiene una pila de estados activos y siempre ejecuta el estado de la cima de la pila. En
cualquier momento, se puede conectar otro estado de la pila, que será usado hasta que
sea eliminado de la misma. En este caso, el manager resume el estado que dejó en
pausa al introducir el anterior estado eliminado. En el momento en que la aplicación
no contiene ningún estado activo, el manager cierra la aplicación.
A.3.2. Arquitectura
La arquitectura de Advanced OgreFramework parece compleja pero, realmente, es
bastante fácil de entender. Esta herramienta gestiona la aplicación de Ogre como un
conjunto de estados, que se van introduciendo en una pila. Esta gestión de estados es
la que comúnmente hemos utilizado para los proyectos de Ogre.
OgreFramework
Si queremos añadir más gestores como el gestor de sonido (SDL, OpenAL, etc. . . )
debemos iniciarlos en esta clase.
[1086] ANEXO A. INTRODUCCIÓN A OGREFRAMEWORK
Para actualizar cada frame del motor Ogre, la clase AppStateManager llamará la
función updateOgre() para actualizar directamente Ogre. Este método estará vacío
para que cada estado se encargue de actualizar el motor de Ogre, pero es necesario
ubicarlo aquí como tarea central del motor.
DotSceneLoader
Esta herramienta se usa para cargar las escenas en Ogre a partir de un fichero XML.
Estos ficheros XML son procesados mediante RapidXML; DotSceneLoader crea las
estructuras necesarias para ser cargadas en el SceneManager de Ogre. Si deseamos
añadir físicas a un nodo, tendremos que añadir aquí su implementación.
Podemos ver que para crear una escena es necesario introducir 4 argumentos: el
fichero, el nombre del nodo, el SceneManager que se está gestionando en este estado
y el nodo donde queremos introducirlo. Es muy interesante poder cargar las escenas
desde un fichero XML por que podremos crearlas y destruirlas de manera dinámica,
permitiendo una mejor gestión de la memoria y del nivel del juego.
Normalmente, se pueden agrupar las escenas de un nivel por bloques e ir cargando
cada uno cuando sea necesario. Si, por ejemplo, nos encontramos con un juego
tipo endless-running, es interesante ir destruyendo las escenas que quedan detrás
del personaje ya que no vam a ser accesibles; también es interesante para ir auto-
generando la escena de forma aleatoria hacia adelante.
Cada vez que carguemos una escena, podremos recuperar una entidad como se
describe en el ejemplo; por un nombre bien conocido. Este nombre estará asignado en
el fichero XML y podremos recuperar el nodo desde el propio juego. De esta manera
podremos cambiar las propiedades o materiales de dicha entidad al vuelo como, por
ejemplo, añadir un cuerpo rígido al motor de físicas y conectarlo a la entidad.
A.3. Advanced OgreFramework [1087]
AppState
En este archivo se definen dos clases. La primera clase será la que herede el
AppStateManager que gestiona los estados, pero está definido en esta clase por
cuestiones de diseño. Esta contiene los métodos necesarios para la gestión de los
estados, estos métodos son abstractos y están definidos en la clase AppStateManager.
La segunda clase será AppState la cuál será la clase de la que hereden todos los
estados que se implementen en el juego. Esta clase contiene una serie de métodos
para mantener la consistencia entre estados y poder tratar cada uno como estado
independiente. En él se incluyen los métodos: enter, exit, pause, resume y update.
Estos métodos nos permitirán sobre todo mantener la interfaz organizada en cada
estado como, por ejemplo, el track de música que se esté escuchando en cada estado.
Cuando creamos un estado nuevo y lo agregamos a la pila de estados.
AppStateManager
Esta clase será la encargada de gestionar todos los estados. Tendrá los mecanismos
para pausar, recuperar y cambiar un estado en cualquier instante del juego. Hereda
directamente del AppStateListener e implementa sus métodos abstractos. Contiene
dos vectores:
Para cada estado existirán una serie de valores como su nombre, información y su
estado. Cada vez que se cree un estado, se llamará al método manageAppState() para
que introduzca dicha información al estado y lo inserte en la pila de estados existentes.
En esta clase se encuentra el bucle principal del juego, dentro del método start().
Dentro de este método podemos destacar el cálculo del tiempo de un frame a otro.
Aquí podremos realizar las modificaciones necesarias si necesitamos coordinar el
motor de renderizado con el motor de físicas. Para poder arreglar el problema en
el desfase de tiempo entre ambos motores se ajusta un valor delta que discretiza el
tiempo entre un frame y otro. Esta técnica nos ayudará a manejar correctamente el
tiempo en ambos motores para que se comporte de manera natural.
MenuState
GameState
Toda la lógica del juego está dentro del estado GameState. Aquí es donde
enlazaremos todos nuestros elementos de nuestro juego, como por ejemplo: el
motor de físicas, el controlador de nuestro personaje principal, módulos de inteligencia
artificial, etc. En el caso de ejemplo de OgreFramework, incluye una escena mínima
que carga una escena con DotSceneLoader y rellena de contenido la pantalla.
Para crear los elementos necesarios de la interfaz se invoca a builGUI() para in-
sertar todos los elementos necesarios del estado mediante el gestor SdkTraysManager
que explicaremos en la siguiente sección.
Para capturar los eventos de entrada por teclado, se utilizarán los métodos
keyPressed() y keyReleased() para realizar los eventos correspondientes a si se pulsa
una tecla o se ha dejado de pulsar respectivamente. En nuestro caso, tendremos una
serie de condiciones para cada tecla que se pulse, las teclas son capturadas mediante
las macros definidas en la librería OIS.
A.4. SdkTrays
Tanto los ejemplos que vienen con el SDK de Ogre (SampleBrowser), como
OgreFramework utilizan SdkTrays. Este gestor de la interfaz se construyó con el
propósito de ser sencillo, crear interfaces de una manera rápida y sin tanta complejidad
como CEGUI. El sistema de SdkTrays está basado en Ogre::Overlay por lo que nos
garantiza su portabilidad a todas las plataformas.
A.4. SdkTrays [1089]
A.4.1. Introducción
El sistema de interfaz de SdkTrays se organiza en una serie de paneles (trays), que
se localizan en nueve de posiciones de nuestra pantalla. Cada introducimos un nuevo
Ogre::Overlay en nuestra escena, debemos calcular las coordenadas (x,y) del frame
actual, añadiendo complejidad a la hora de diseñar una interfaz. Cuando creamos un
widget nuevo con SdkTrays, debemos pasarle una posición de las nueve posibles y
el tamaño. Si ya existe una un widget en esa región, el nuevo widget se colocará
justo debajo, manteniendo las proporciones de la pantalla y redimiensionando el panel
actual donde se encuentra dicho widget.
A.4.2. Requisitos
SdkTrays necesita la biblioteca OIS para la captura de datos de entrada mediante
ratón o teclado. Una vez que se han incluido estas dependencias al proyecto
simplemente es necesario incluir ‘SdkTrays.h’ en cada clase. Y también es necesario
incluir el fichero ‘SdkTrays.zip’ a nuestro directorio de recursos, normalmente en
/media/packs.
A.4.3. SdkTrayManager
Para utilizar SdkTrays, es necesario crear un SdkTrayManager. Esta es la clase a
través de la cual se van a crear y administrar todos los widgets, manipular el cursor,
cambiar la imagen de fondo, ajustar las propiedades de los paneles, mostrar diálogos,
mostrar / ocultar la barra de carga, etc. El SdkTrayManager requiere “SdkTrays.zip”,
por lo que sólo se puede crear después de cargar ese recurso. Es recomendable
asegurarse de que está utilizando el espacio de nombres OgreBites para poder incluir
las macros de localización. Estas macros permiten crear un widget en las esquinas, en
la parte central de la pantalla y en la parte central de los bordes.
A.4.4. Widgets
Existen 10 tipos de widgets básicos. Cada widget es sólo un ejemplo de
una plantilla de OverlayElement. Cada vez que se crea un widget es necesario
introducir las medidas en pixeles. Cada widget está identificado con un nombre
y se gestionará su creación y destrucción mediante SdkTrayManager. Algunos de los
widgets predefinidos que podemos crear son los siguientes:
A.4.5. SdkTrayListener
Esta clase contiene los controladores para todos los diferentes eventos que pueden
disparar los widgets. La clase SdkTrayManager es, en sí misma, un SdkTrayListener
porque responde a los eventos de los widgets. Algunos widgets dan la opción de no
disparar un evento cuando cambia su estado. Esto es útil para inicializar o restablecer
un widget, en cuyo caso no debe haber una respuesta de cualquier tipo.
[1090] ANEXO A. INTRODUCCIÓN A OGREFRAMEWORK
A.4.6. Skins
SdkTrays no está orientado a una personalización profunda de los widgets como
en CEGUI. Pero eso no significa que no podamos cambiar su apariencia del modelo
estándar. Los recursos de las imágenes que utilizan los widgets se encuentran en el
fichero SdkTrays.zip.
Si descomprimimos los recursos y modificamos el contenido, podemos persona-
lizar la apariencia de los widgets. Las imágenes que se muestran tienden a ser muy
pequeñas con formas muy concretas. Este tipo de imágenes se utiliza para reducir el
espacio necesario para cada widget y que sea reutilizable por recursos. Si queremos
modificar la apariencia de un widget es recomendable seguir las mismas formas y
cambiar sólo los colores o también podemos optar por cambiar la apariencia a co-
lores sólidos/planos. Las imágenes del paquete ‘SdkTrays.zip’ son PNG, por lo que
podemos crear transparencias si lo deseamos o sombreados.
La personalización es limitada, pero con diseño y creatividad se pueden conseguir
apariencias bastante diferentes de la original de OgreFramework.
A.4.7. OgreBites
Podemos observar como se distribuyen los widgets por regiones en la pantalla.
Estas posiciones vienen dadas por el espacio de nombres OgreBites, la cual incluye
una serie de macros que instancia los widgets en la región deseada. Observamos
además que si por ejemplo agregamos seis widgets en la parte inferior derecha, los
widgets se agrupan en un columna tipo pila. Este orden es el mismo que nosotros
agregaremos en las lineas de código y las macros para instanciar los widgets son las
siguientes:
A.5. btOgre
btOgre es un conector ligero entre Bullet–Ogre. Este conector no proporciona un
RigidBody ni CollisionShape directamente. En lugar de eso, se accede directamente
al motor correspondiente. La única acción que realiza btOgre es la de conectar los
btRigidBodies con SceneNodes. También puede convertir las mallas (mesh) de Ogre a
figuras convexas en Bullet, proporcionando un mecanismo de debug de Bullet como
observamos en la imagen y herramientas de conversión de un vector de Bullet a un
quaternio de Ogre (ver Figura A.5).
En este ejemplo (listado A.4)comprobamos como btOgre conecta el Nodo de Ogre
con la forma convexa de Bullet. Cada vez que se aplique un cambio en el mundo físico
de Bullet se verán sus consecuencias en el Nodo de Ogre. Esto permite la integración
de los dos motores y el acceso independiente a las propiedades del objeto en ambos
mundos.
Cada vez que actualicemos el bucle principal del juego será necesario hacer una
llamada a stepSimulation con el tiempo calculado entre frame y frame.
2 BtOgre::DebugDrawer m_pDebugDrawer =
3 new BtOgre::DebugDrawer(OgreFramework::getSingletonPtr()->
4 m_pSceneMgr->getRootSceneNode(), m_pDynamicsWorld);
5 m_pDynamicsWorld->setDebugDrawer(m_pDebugDrawer);
6
7 // Actualizamos el motor Bullet
8 m_pDynamicsWorld->stepSimulation(timeSinceLastFrame);
9 m_pDebugDrawer->step();
[1092] ANEXO A. INTRODUCCIÓN A OGREFRAMEWORK
A.6. Referencias
http://www.ogre3d.org/tikiwiki/Basic+Ogre+Framework
http://www.ogre3d.org/tikiwiki/Advanced+Ogre+Framework
http://bitbucket.org/spacegaier/basicogreframework
http://bitbucket.org/spacegaier/advancedogreframework
http://gafferongames.com/game-physics/fix-your-timestep/
http://ogrees.wikispaces.com/Tutorial+Framework+Avanzado+
de+Ogre
http://www.ogre3d.org/tikiwiki/SdkTrays
http://www.ogre3d.org/tikiwiki/OgreBites
http://github.com/nikki93/btogre
Vault Defense al detalle
Anexo B
David Frutos Talavera
E
n este anexo se explicarán ciertos detalles del juego Vault Defense. Detalles de
su GUI (Graphical User Interface), interfaz que usa CEGUI y con el cual, se
han creado unos botones propios, una pantalla de carga y un efecto de cámara
manchada de sangre, también se explicará al detalle el efecto de parpadeo de las luces,
usado para darles un aspecto de luz de antorcha, y por último los enemigos, unos
agentes de estado que implementan ciertos comportamientos.
Pero antes de explicar dichos detalles del juego, vamos a explicar cómo configurar
Visual C++ 2010 Express 1 para poder compilar nuestros juegos con OGRE + CEGUI
para la plataforma Windows.
1093
[1094] ANEXO B. VAULT DEFENSE AL DETALLE
-----------
-- Renderers
En este apartado del archivo podemos configurar qué proyectos queremos crear
para Visual C++. Para ello, pondremos a true la variable OGRE_RENDERER .
Con esto, el archivo estará bien configurado para nuestras necesidades, y aunque se
puede configurar mejor para que ponga las rutas bien desde el principio es mejor
aprender dónde se configura el proyecto en el programa. Ahora ejecutaremos el
archivo build_vs2008.bat , aunque es un proyecto de 2008 se podrá cargar con Visual
C++ 2010, se generará un archivo CEGUI.sln, dicho archivo será abierto con el
Visual C++. cuando lo abramos, aceptaremos la conversión del proyecto sin tocar
las opciones y ya podremos pasar a configurar las rutas.
Con el proyecto ya cargado y con la conversión ya realizada podemos pasar
a configurar las rutas. Lo primero es saber que Visual C++ mantiene 2 tipos de
configuraciones: una para modo debug y otra para modo Release, se pueden configurar
muchas más, pero no será necesario. Comprobamos si están bien las rutas, que tienen
que estarlo en el proyecto CEGUIBase, pulsaremos botón derecho/propiedades sobre
el proyecto, en el explorador de soluciones y nos saldrá una ventana de configuración
(ver figura B.1), o con el proyecto seleccionado Alt + Enter. Observamos que esta
activo el modo Release, también hará falta comprobar las rutas para el modo Debug.
Esto es lo que veremos:
1. Apartado C/C++ / general (Figura B.2). En dicho apartado se configuran los “in-
cludes” del proyecto, podemos observar que en CEGUIBase se añaden ../.
./../cegui/include; y, por otra parte, ../../../dependencies/
incluye; por eso es necesario colocar la carpeta “dependencias” en la carpeta
de CEGUI.
2. Vinculador / General (Figura B.3). Aquí es donde diremos dónde están nuestras
bibliotecas (las carpetas Lib).
2 ’http://www.cegui.org.uk/wiki/index.php/Downloads
3 ’http://www.ogre3d.org/download/sdk
B.1. Configuración en Visual 2010 Express [1095]
3. Vinculador / Entrada (Figura B.4). En este apartado se colocan los nombre de las
bibliotecas adicionales, es decir, las bibliotecas que estén en las rutas añadidas
en general y que vayamos a usar.
Plugin=RenderSystem_GL_d
B.1. Configuración en Visual 2010 Express [1097]
Plugin=Plugin_ParticleFX_d
Ya con estos 3 archivos añadidos solo falta crear una carpeta plugins y añadir los
.dlls correspondientes dentro de ella.
Con los plugins colocados pasamos a añadir los .dlls que faltan. Estos varían según
las bibliotecas usadas pero por lo general hará falta OgreMain y OIS. Acordaros de
copiar los dlls correspondientes según vuestra compilación, si estáis en modo debug
usad los que terminan en _d.
B.1. Configuración en Visual 2010 Express [1099]
44 </DimOperator>
45 </UnifiedDim>
46 </Dim>
47 <Dim type="Height"><UnifiedDim scale="1"
48 type="Height" /></Dim>
49 </Area>
50 <Image imageset="VaultDefense"
51 image="BotonSinglePlayerNormal" />
52 <VertFormat type="Stretched" />
53 <HorzFormat type="Stretched" />
54 </ImageryComponent>
55 </ImagerySection>
56
57 <ImagerySection name="hover">
58 <!-- .................... -->
59 </ImagerySection>
60
61 <ImagerySection name="pushed">
62 <!-- .................... -->
63 </ImagerySection>
64
65 <ImagerySection name="disabled">
66 <!-- .................... -->
67 </ImagerySection>
68 <StateImagery name="Normal">
69 <Layer>
70 <Section section="normal" />
71 </Layer>
72 </StateImagery>
73 <StateImagery name="Hover">
74 <Layer>
75 <Section section="hover" />
76 </Layer>
77 </StateImagery>
78 <StateImagery name="Pushed">
79 <Layer>
80 <Section section="pushed" />
81 </Layer>
82 </StateImagery>
83 <StateImagery name="PushedOff">
84 <Layer>
85 <Section section="hover" />
86 </Layer>
87 </StateImagery>
88 <StateImagery name="Disabled">
89 <Layer>
90 <Section section="disabled">
91 <Colours topLeft="FF7F7F7F" topRight="FF7F7F7F"
92 bottomLeft="FF7F7F7F" bottomRight="FF7F7F7F" />
93 </Section>
94 </Layer>
95 </StateImagery>
96 </WidgetLook>
Éstas son las marcas más usadas. Con estas marcas podemos crear unos botones
propios que actúen como nosotros queremos. En el caso del juego Vault Defense los
botones están creados por 3 zonas, una zona central donde se muestra la imagen del
botón, y 2 zonas, una a cada lado, que se usara en caso de que el botón este en estado
hover o pushed. La imagen BotonDer, BotonIzq es una imagen transparente que solo
se usa para rellenar el hueco, en los estados hover y pushed se pone una imagen distinta
dándole así un efecto curioso a los botones.
El codigo de hover, pushed y disable es similar al de normal, solo que cambia
las imagenes que carga. En hover en los bloques laterales, los que en normal son
BotonDer y BotonIzq cambian por otra imagen, en el caso del Vault Defense por
BotonDerHover y BotonIzqHover, imagenes que muestran un pico y una ballesta, que
apareceran a los lados del botón seleccionado.
Y lo último es el archivo scheme:
Definimos que imageset vamos a usar, que looknfeel usamos, y luego pasamos a
crear los botones con:
En este código podemos ver la creación de unos botones muy simples, se ha cogido
como base un archivo scheme ya creado y se ha modificado. En WindowType ponemos
el identificador del window para poder usarlo en nuestros layouts y en LookNFeel
ponemos nuestro lookNFeel modificado.
En el código es necesario cargar nuestros propios archivos:
Este método de carga no calcula el tiempo real de lo que tardan las operaciones,
es una carga por pasos. Se definen una serie de cargas, y segun se van cargando se
va pasando de paso. En resumen, se dividen los calculos en grupos y se realiza un
RenderOneFrame por cada calculo realizado, cuando se realiza el calculo se ejecuta
este código.
Esto se puede cambiar, se puede crear una barra propia y hacerle un escalado.
También se puede poner una imagen en el centro e ir cambiándola. Existen muchas
maneras, pero ésta es la más sencilla.
2 Name="UI/Fondo/Sangre10"><Property Name="Image"
3 Value="set:VaultDefenseSangre2 image:Sangre"/>
4 <Property Name="UnifiedAreaRect"
5 Value="{{0.0,0},{0.0,0},{1,0},{1,0}}"/>
6 <Property Name="BackgroundEnabled" Value="False"/>
7 <Property Name="Alpha" Value="0"/>
8 <Property Name="FrameEnabled" Value="False"/>
9 </Window>
Luego en alguna parte del juego, en el caso de Vault Defense cuando el personaje
recibe un impacto, activamos una de las sangres de manera aleatoria, mostrándola y
poniendo su alpha a 1, durante el enterFrame() vamos decrementando el alpha.
Los enemigos llevan a sus pies unos bloques pequeños ocultos. Dichos bloques se
usan para calcular colisiones entre cajas AABB.]
✄
La parte importante son las líneas ✂15-16 ✁. Dicha función comprueba la intersección
de 2 nodos y devuelve una AxixAlignedBox, si es distinto de Null la caja invisible esta
chocando con otra caja entonces ambas cajas se repelen.
Bibliografía
[1] www.boost.org.
[2] www.cegui.org.uk.
[3] www.swig.org.
[4] ISO/IEC 9241. ISO/IEC 9241-11: Ergonomic requirements for office work with
visual display terminals (VDTs) – Part 11: Guidance on usability. ISO, 1998.
[5] Advanced Micro Devices, Inc., Disponible en línea en http://support.
amd.com/us/Processor_TechDocs/31116.pdf. BIOS and Kernel
Developer’s Guide (BKDG) For AMD Family 10h Processors, Apr. 2010.
[6] T. Akenine-Moller, E. Haines, and N. Hoffman. Real-Time Rendering (3rd
Edition). AK Peters, 2008.
[7] E. Akenine-Möller, T. Haines and N. Hoffman. Real-Time Rendering. AK
Peters, Ltd., 2008.
[8] T. Akenine-Möller, E. Haines, and N. Hoffman. Real-Time Rendering. AK
Peters, 3rd edition, 2008.
[9] Andrei Alexandrescu. Modern C++ Design: Generic Programming and
Design Patterns Applied. Addison-Wesley Professional, 2001.
[10] Grenville Armitage, Mark Claypool, and Philip Branch. Networking and Online
Games. Wiley, 2006.
[11] K. Beck. Extreme Programming Explained: Embrace Change. Addison- Wesley
Professional. Addison-Wesley Professional, 1999.
1109
[1110] CAPÍTULO 31. BIBLIOGRAFÍA
[34] Randima Fernando and Mark J. Kilgard. The Cg Tutorial: The Definitive Guide
to Programmable Real-Time Graphics. Addison-Wesley Longman Publishing
Co., Inc., Boston, MA, USA, 2003.
[35] K. Flood. Game unified process (gup).
[36] S. Franklin and Graesser A. Is it an agent, or just a program?: A taxonomy
for autonomous agents. In Proceedings of the Third International Workshop
on Agent Theories, Architectures, and Languages, pages 21–35, London, UK,
1997. Springer-Verlag.
[37] E. Gamma. Design Patterns: Elements of Reusable Object-Oriented Software.
Addison-Wesley Professional, 1995.
[38] E. Gamma, R. Helm, R. Johnson, and J. Vlissided. Design Patterns. Addison
Wesley, 2008.
[39] Joseph C. Giarratano and Gary Riley. Expert Systems. PWS Publishing Co.,
Boston, MA, USA, 3rd edition, 1998.
[40] J. L. González. Jugabilidad: Caracterización de la Experiencia del Jugador en
Videojuegos. PhD thesis, Universidad de Granada, 2010.
[41] T. Granollers. MPIu+a. Una metodología que integra la ingeniería del
software, la interacción persona-ordenador y la accesibilidad en el contexto
de equipos de desarrollo multidisciplinares. PhD thesis, Universitat de Lleida,
2004.
[42] J Gregory. Game Engine Architecture. AK Peters, 2009.
[43] Khronos Group. COLLADA Format Specification.
http://www.khronos.org/collada/, 2012.
[44] H. Hoppe. Efficient implementation of progressive meshes. Computers &
Graphics, 22(1):27–36, 1998.
[45] IberOgre. Formato OGRE 3D. http://osl2.uca.es/iberogre, 2012.
[46] R. Ierusalimschy. Programming in LUA (2nd Edition). Lua.org, 2006.
[47] InfiniteCode. Quadtree Demo with source code.
http://www.infinitecode.com/?view_post=23, 2002.
[48] Intel Corporation, Disponible en línea en http://www.intel.com/
content/dam/doc/manual/. Intel 64 and IA-32 Architectures Softwa-
re Developer’s Manual. Volume 3B: System Programming Guide, Part 2, Mar.
2012.
[49] ISO/IEC. Working Draft, Standard for Programming Language C++. Docu-
ment number N3242=11-0012., Feb. 2011.
[50] D. Johnson and J. Wiles. Effective affective user interface design in games.
Ergonomics, 46(13-14):1332–1345, 2003.
[51] N.M. Josuttis. The C++ standard library: a tutorial and handbook. C++
programming languages. Addison-Wesley, 1999.
[52] G. Junker. Pro OGRE 3D Programming. Apress, 2006.
[53] G. Junker. Pro Ogre 3D Programming. Apress, 2006.
[1112] CAPÍTULO 31. BIBLIOGRAFÍA
[97] S.J. Teller and C.H. Séquin. Visibility preprocessing for interactive walkth-
roughs. In ACM SIGGRAPH Computer Graphics, volume 25, pages 61–70.
ACM, 1991.
[98] T. Theoharis, G. Papaioannou, and N. Platis. Graphics and Visualization:
Principles & Algorithms. AK Peters, 2008.
[99] José Tomás Tocino, Pablo Recio, and Manuel Palomo-Duarte. Gades Siege.
http://code.google.com/p/gsiege, 2012.
[100] T. Ulrich. Rendering massive terrains using chunked level of detail control.
SIGGRAPH Course Notes, 3(5), 2002.
[101] Wang and Niniane. Let there be clouds! Game Developer Magazine, 11:34–39,
2004.
[102] G. Weiss. Multiagent Systems: a Modern Approach to Distributed Artificial
Intelligence. The MIT Press, 1999.
[103] Wikipedia. Stratego — wikipedia, the free encyclopedia, 2014. [Online;
accessed 30-May-2014].
[104] M. Wooldridge and N.R. Jennings. Intelligent agents: Theory and practice. The
Knowledge Engineering Review, 10(2):115–152, 1995.
[105] L.A. Zadeh. Fuzzy sets. Information and control, 8(3):338–353, 1965.
[106] H. Zhang, D. Manocha, T. Hudson, and K.E. Hoff III. Visibility culling
using hierarchical occlusion maps. In Proceedings of the 24th annual confe-
rence on Computer graphics and interactive techniques, pages 77–88. ACM
Press/Addison-Wesley Publishing Co., 1997.
Este manual fue maquetado en
una máquina GNU/Linux en
Junio de 2014