3.4. Gráficos y animación

Hasta ahora hemos visto la creación de aplicaciones con una interfaz gráfica a partir de una serie de componentes definidos en la API de AWT y de Swing (ventanas, botones, campos de texto, etc).

En este punto veremos como dibujar nuestros propios gráficos directamente en pantalla. Para ello Java nos proporciona acceso al contexto gráfico del área donde vayamos a dibujar, permitiéndonos a través de éste modificar los pixels de este área, dibujar una serie de figuras geométricas, así como volcar imágenes en ella.

3.4.1. Gráficos en AWT

Para dibujar gráficos en un área de la pantalla, AWT nos ofrece un objeto con el contexto gráfico de dicha área, perteneciente a la clase Graphics. Este objeto nos ofrece una serie de métodos que nos permiten dibujar distintos elementos en pantalla. Más adelante veremos con detalle los métodos más importantes.

Este objeto Graphics nos lo deberá proporcionar AWT en el momento en que vayamos a dibujar, ya que no podremos obtenerlo por nuestra cuenta de ninguna otra forma.

Para dibujar en pantalla cualquier componente AWT, estos componentes proporcionan dos métodos: paint(Graphics g) y update(Graphics g).

Estos métodos serán invocados por AWT cuando necesite dibujar su contenido en pantalla. Por ejemplo, cuando la ventana se muestre por primera vez, AWT invocará al método paint(Graphics g) de todos los componentes AWT que contenga la aplicación, de forma que estos se dibujen en pantalla. Cuando una aplicación minimizada se maximice, o una aplicación que estaba total o parcialmente tapada por otra pase a primer plano de nuevo, también podrá ser necesario invocar dicho método para volver a dibujar los componentes de la ventana.

Cada componente AWT definirá su propio método paint(Graphics g) para dibujar su contenido. Por ejemplo, un Button dibujará en pantalla la forma del botón y el texto. Si creamos una subclase de estos componentes y sobrescribimos su método paint(Graphics g) con un método propio, dentro de él podremos utilizar el objeto de contexto gráfico Graphics para dibujar en el área de dicho componente.

La mayoría de los componentes tienen ya definido su propio comportamiento y apariencia, por lo que no deberemos sobrescribir este método ya que el componente dejaría de funcionar correctamente. Sin embargo, hay un componente diseñado para que el usuario pueda utilizarlo como área de dibujo, este es el caso del Canvas. Este componente simplemente comprende un área vacía de la pantalla, en la que nosotros podremos dibujar nuestros propios gráficos sobrescribiendo su método paint(Graphics g). Sobrescribiremos el método de la siguiente forma:

public class MiCanvas extends Canvas {
	public void paint(Graphics g) {
		// Dibujamos en el área del canvas
		// usando el objeto g proporcionado
	}
}

Con esto en la clase MiCanvas hemos creado un componente propio en el que nosotros controlamos lo que se dibuja en pantalla. Podremos añadir este componente a nuestra aplicación de la misma forma que añadimos cualquier otro componente AWT:

MiCanvas mc = new MiCanvas();
panel.add(mc);

Hemos de recordar que no podemos controlar cuando se invoca el método paint(Graphics g), esté método será invocado por AWT en el momento en el que el SO necesite que la ventana sea redibujada. En él simplemente definimos como se dibujará el contenido de nuestro componente en pantalla, y AWT ya se encargará de invocarlo cuando sea necesario.

3.4.2 Contexto gráfico: Graphics

El objeto Graphics nos permitirá acceder al contexto gráfico de un determinado componente y a través de él dibujar en su área en pantalla. Vamos a ver ahora como dibujar utilizando dicho objeto.

3.4.2.1 Atributos

El contexto gráfico tendrá asociado el color del lápiz que usamos en cada momento para dibujar, así como la fuente de texto que se utilizará en el caso de que dibujemos una cadena de texto. Para consultar o modificar el color o la fuente asociadas al contexto gráfico se proporcionan los siguientes métodos:

Una vez establecidos estos atributos en el contexto gráfico, podremos dibujar en él una serie de elementos utilizando una serie de métodos de Graphics. Estos métodos comienzan por drawXXXX para dibujar el contorno de una determinada forma, o fillXXXX para dibujar dicha forma con relleno.

El sistema de coordenadas del área en pantalla tendrá la coordenada (0,0) en su esquina superior izquierda, y las coordenadas serán positivas hacia la derecha (coordenada x) y hacia abajo (coordenada y), tal como se muestra a continuación:

Figura 1. Coordenadas del área de dibujo

3.4.2.2 Figuras

El contexto gráfico nos ofrece una serie de métodos para dibujar en él las principales primitivas geométricas básicas:

Por ejemplo, el siguiente canvas aparecerá con un dibujo de un círculo rojo y un cuadrado verde:

public class MiCanvas extends Canvas {
	public void paint(Graphics g) {
		g.setColor(Color.red);
		g.fillOval(10,10,50,50);
		g.setColor(Color.green);
		g.fillRect(60,60,50,50);
	}
}

3.4.2.3 Texto

A parte de dibujar figuras geométricas también podremos dibujar cadenas de texto. Para ello se proporciona el método drawString(String s, int x, int y) que dibuja la cadena s en las coordenadas (x,y). Este punto corresponderá al inicio de la cadena, en la línea de base del texto como se muestra a continuación:

Figura 2. Línea de base del texto

Con esto dibujaremos un texto en pantalla, pero podemos querer hacer por ejemplo que el texto sea sensible a pulsaciones del ratón sobre él. Lo único que nos proporciona AWT es la posición del ratón dentro del área, por lo que para saber si está sobre el texto tendremos que saber que región ocupa el texto. Aquí es donde encontramos el problema, según la fuente, la cadena escrita, y el contexto donde la escribamos, el texto puede tener distintas dimensiones, y nosotros sólo conocemos las coordenadas de comienzo. La solución a esto la proporciona el objeto FontMetrics, que podemos obtener llamando al método getFontMetrics(Font f) del contexto gráfico o del componente AWT (ambos tipos de objetos contienen este método). Este objeto nos dará información sobre las métricas de dicha fuente en este contexto. De este objeto podemos sacar la siguiente información:

Con estas medidas podremos conocer exactamente los límites de una cadena de texto, tal como se muestra a continuación:

Figura 3. Métricas del texto

3.4.2.4 Imágenes

Por último, un método importante es el que nos permite volcar una imagen al área de dibujo. Las imágenes en Java se encapsulan en mediante la clase Image. Podemos o bien crearnos una imagen vacia para dibujar nosotros en ella, o cargar imágenes desde ficheros. Para cargar una imagen de un fichero en caso de un Applet, simplemente deberemos llamar al método getImage(URL url) de la clase Applet que nos devolverá el objeto Image con la imagen cargada, que podrá estar en formato GIF, JPG o PNG. En el caso de una aplicación deberemos seguir los siguientes pasos:

Obtener la implementación del toolkit de AWT a partir de cualquier componente AWT de nuestra aplicación. Si tenemos una ventana con un canvas, tanto la ventana como el canvas nos servirían dado que ambos son componentes AWT. Sobre el componente llamaremos al método getToolkit() para obtener el objeto Toolkit:

Canvas mc = new MiCanvas();
Toolkit toolkit = mc.getToolkit();

También podemos obtener el toolkit por defecto si no podemos acceder a ningún componente AWT:

Toolkit toolkit = Toolkit.getDefaultToolkit();

Utilizar el método createImage(String filename) del Toolkit para cargar la imagen del fichero de nombre filename:

Image img = toolkit.createImage("foto.jpg");

Una vez obtenida la imagen podremos dibujarla en el contexto gráfico con drawImage(Image img, int x, int y, ImageObserver obs). Con esto dibujaremos la imagen img en las coordenadas (x,y) del área. Además necesitamos proporcionar el objeto ImageObserver que se utilizará para comunicarle cuando la imagen está cargada del todo, y por lo tanto puede mostrarla por pantalla. Cualquier componente AWT capaz de mostrar imagenes implementará la interfaz ImageObserver, por lo que podremos utilizarlo en la llamada a dicho método. Por ejemplo, si dibujamos la imagen en un Canvas, utilizaremos el mismo Canvas como ImageObserver ya que es el componente en el que vamos a observar la imagen:

public class MiCanvas extends Canvas {
	public void paint(Graphics g) {
		Toolkit toolkit = getToolkit();
		Image img = toolkit.createImage("foto.jpg");

		g.drawImage(img, 0, 0, this);
	}
}

Si no necesitamos utilizar un ImageObserver podemos especificar null en este parámetro. Esto será útil cuando trabajemos con imágenes que sabemos que ya están cargadas, y cuando no tengamos la referencia al componente donde se va a dibujar la imagen.

3.4.2.5 Otros métodos

Otros métodos útiles del contexto gráfico son:

3.4.3 Animaciones

Hasta ahora hemos visto como dibujar gráficos en pantalla, pero lo único que hacemos es definir un método que se encargue de dibujar el contenido del componente, y ese método será invocado cuando el sistema necesite dibujar la ventana.

Sin embargo puede interesarnos cambiar dinámicamente los gráficos de nuestro componente. Para ello deberemos indicar el momento en el que queremos que se redibujen los gráficos, ya que el sistema por si solo sólo llamará a paint(Graphics g) cuando sea necesario volver a dibujar la ventana porque su contenido se ha perdido, pero no lo llamará cuando hayamos realizado cambios.

3.4.3.1 Redibujado del área

Para forzar que se redibuje el área del componente, deberemos llamar al método repaint() del componente (del canvas por ejemplo). Con eso estamos solicitando al sistema que se repinte el componente, pero no lo repinta en el mismo momento en el que se llama. El sistema introducirá esta solicitud en la cola de ventanas de debe repintar, y cuando tenga tiempo repintará su contenido.

MiCanvas mc = new MiCanvas();
...
mc.repaint();

En este caso para repintar el componente no llamará a su método paint(Graphics g), sino al método update(Graphics g). Este es el método que se encarga de actualizar los gráficos. Su implementación por defecto consiste en borrar los gráficos actuales del área del componente, y llamar a paint(Graphics g) para pintarlo de nuevo.

Imaginemos que estamos moviendo un rectángulo por pantalla. El rectángulo irá cambiando de posición, y en cada momento lo dibujaremos en la posición en la que se encuentre. Pero si no borramos el contenido de la pantalla en el instante anterior, el rectángulo aparecerá en todos los lugares donde ha estado en instantes anteriores produciendo este efecto indeseable de dejar rastro. Por ello el método update(Graphics g) vacía todo el área del componente antes de invocar a paint(Graphics g) para que dibuje los gráficos en el instante actual.

Ejemplo de efecto flicker

Sin embargo, al estar vaciando y volviendo a dibujar en el componente, veremos en éste un efecto parpadeo (flicker). Por ello, si queremos que nuestra aplicación no muestre este aspecto de aplicación amateur, deberemos sobrescribir el método update(Graphics g) para que su única función sea llamar a paint(Graphics g) sin vaciar previamente el área:

public class MiCanvas {
	public void update(Graphics g) {
		paint(g);
	}

	public void paint(Graphics g) {
		// Aqui dibujamos el contenido del componente
	}
}

Pero ahora nos encontramos con otros problemas. Al no borrar la pantalla los objetos pueden dejar rastro. Además puede que queramos dibujar varios componentes en pantalla, y si los dibujamos uno detrás de otro puede producirse el efecto poco deseable de ver como se va construyendo la imagen. Para evitar que esto ocurra y conseguir unas animaciones limpias, utilizaremos la técnica del doble buffer.

Ejemplo de efecto rastro

3.4.3.2 Técnica del doble buffer

La técnica del doble buffer consiste en dibujar todos los elementos que queremos mostrar en una imagen en memoria, y una vez se ha dibujado todo, volcarlo a pantalla como una unidad. De esta forma, mientras se va dibujando la imagen, como no se hace directamente en pantalla no veremos efectos de parpadeo al borrar el contenido anterior, ni veremos como se va creando la imagen, en pantalla se mostrará la imagen cuando esté completa.

Ejemplo de doble buffer

Para utilizar esta técnica lo primero que deberemos hacer es crearnos el denominado back buffer que será el buffer en memoria donde dibujamos la imagen. Para implementarlo en Java utilizaremos una imagen (objeto Image) que tendremos en memoria, y sobre la que dibujaremos el contenido que queramos mostrar. Deberemos crear una imagen del mismo tamaño del componente en el que vamos a dibujar. Para crear una imagen en blanco podemos usar el método createImage(int width, int height) que se encuentra en cualquier componente AWT (Component), como por ejemplo nuestro canvas, y crea una imagen vacia con las dimensiones especificadas. Tendremos que crearla con las dimensiones del componente:

Image backbuffer = createImage(getWidth(), getHeight());

Al igual que cada componente tiene un contexto gráfico asociado en el que podemos dibujar, una imagen en memoria también tendrá su contexto gráfico. Mientras el contexto gráfico de los componentes hace referencia a la pantalla, el de una imagen hará referencia a un espacio de memoria en el que se almacena la imagen, pero la forma de dibujar en ambos se realizar de la misma forma a través de la misma interfaz (objeto Graphics). Para obtener el contexto gráfico de una imagen utilizaremos el método getGraphics() de la misma:

Graphics offScreen = backbuffer.getGraphics();

Una vez obtenido este contexto gráfico, dibujaremos todo lo que queremos mostrar en él, en lugar de hacerlo en pantalla. Una vez hemos dibujado todo el contenido en este contexto gráfico, deberemos volcar la imagen a pantalla para que ésta se haga visible:

g.drawImage(backbuffer, 0, 0, this);

La imagen conviene crearla una única vez, ya que la animación puede redibujar frecuentemente, y si cada vez que lo hacemos creamos un nuevo objeto imagen estaremos malgastando memoria inutilmente. Es buena práctica de programación en Java instanciar nuevos objetos las mínimas veces posibles, intentando reutilizar los que ya tenemos.

Podemos ver como quedaría nuestra clase ahora:

public MiCanvas extends Canvas {

	// Backbuffer
	Image backbuffer = null;

	// Ancho y alto del backbuffer
	int width, height;

	// Coordenadas del rectangulo dibujado
	int x, y;

	public void update(Graphics g) {
		paint(g);
	}

	public void paint(Graphics g) {
		// Solo creamos la imagen la primera vez 
		// o si el componente ha cambiado de tamaño
		if( backbuffer == null || 
			width != getWidth() || 
			height != getHeight() )
		{
			width = getWidth();
			height = getHeight();
			backbuffer = createImage(width, height);
		}

		Graphics offScreen = backbuffer.getGraphics();

		// Vaciamos el área de dibujo

		offScreen.clearRect(0,0,getWidth(), getHeight());

		// Dibujamos el contenido en offScreen
		offScreen.setColor(Color.red);
		offScreen.fillRect(x,y,50,50);

		// Volcamos el back buffer a pantalla
		g.drawImage(backbuffer,0,0,this);
	}
}

En ese ejemplo se dibuja un rectángulo rojo en la posición (x,y) de la pantalla que podrá ser variable, tal como veremos a continuación añadiendo a este ejemplo métodos para realizar la animación.

3.4.3.3 Código para la animación

Si queremos hacer una animación tendremos que ir cambiando ciertas propiedades de los objetos de la imagen (por ejemplo su posición) y solicitar que se redibuje tras cada cambio. El bucle para la animación podría ser el siguiente:

public class MiCanvas extends Canvas {
	...
	public void anima() {
		// El rectangulo comienza en (10,10)
		x = 10;
		y = 10;

		while(x < 100) {
			x++;
			repaint();

			try {
				Thread.currentThread().sleep(100);
			} catch(InterruptedException e) {}
		}
	}
}

Con este código de ejemplo veremos una animación en la que el rectángulo que dibujamos partirá de la posición (10,10) y cada 100ms se moverá un pixel hacia la derecha, hasta llegar a la coordenada (100,10).

Para ello sólo tendremos que invocar el método anima() de nuestro componente. Pero es importante no invocar este método directamente desde cualquier respuesta a un evento, como puede ser la pulsación de un boton:

public void actionPerformed(ActionEvent e) {
	// ¡¡¡ No hay que hacer esto !!!
	mc.anima();
}

¿Por qué no debemos hacer esto? Parece dificil ver que esto pueda causar algún problema, pero debemos pensar que Java tiene un hilo en el que se tratan los eventos que se van produciendo, y este mismo hilo será el que se encargue de repintar la pantalla cuando se lo solicitemos. Sin embargo, si desde este hilo llamamos al método que realiza la animación y éste no devuelve el control hasta que la animación no ha terminado, al no continuar el hilo no podrá repintar el contenido de la pantalla, por mucho que se lo pidamos dentro de nuestra función de animación. Esto producirá el efecto de no ver la animación mientras se está realizando, sólo se actualizará la pantalla una vez haya terminado la animación, por lo tanto se producirá un salto desde el estado inicial hasta el estado final.

Por lo tanto, si queremos hacer una animación lo mejor será crear un hilo independiente que se encargue de realizar dicha animación, y de esta manera el hilo de procesamiento de eventos pueda continuar realizando sus tareas mientras se ejecuta la animación:

public class MiCanvas extends Canvas implements Runnable {
	...
	public void run() {
		anima();
	}
}

Ahora si que podremos invocar la animación desde el código de los eventos creando un hilo independiente que se encargue de ella:

public void actionPerformed(ActionEvent e) {
	// Así si que funciona
	Thread t = new Thread(mc);
	t.start();
}

3.4.3.4 Modo XOR

Si no queremos tener que sobrescribir la pantalla entera cada vez que se actualiza, podemos utilizar otra técnica que se basa en el modo XOR de dibujo para evitar que los objetos dejen rastro al ser modificados.

Lo que haremos en este caso es activar el modo XOR mientras estemos modificando un objeto. Cada vez que realicemos un cambio en el objeto (movimiento, cambio de tamaño o forma), lo que haremos será dibujar de nuevo el objeto en su posición anterior, de forma que al estar activado el modo XOR se borrará el objeto, y a continuación lo dibujamos en su nueva posición. Este modo es muy utilizado en programas de dibujo, mientras estamos dibujando figuras en pantalla.

3.4.4 API de Java 2D

Java 2D es una nueva API introducida a partir de JDK 1.2, que extiende AWT para proporcionar un extenso conjunto de funcionalidades para trabajar con gráficos 2D, texto e imágenes. Aporta una serie de formas primitivas básicas con las que podremos trabajar.

Además de mostrar gráficos por pantalla, es capaz de sacarlos a través de la impresora, utilizando un modelo uniforme de render para ambos casos.

El mecanismo de render es el mismo que vimos en AWT para versiones anteriores de JDK, cuando el sistema necesita redibujar un componente, se invoca a su método paint o update. Sin embargo, lo que se proporciona es un objeto Graphics2D, que extiende a Graphics proporcionando acceso a las nuevas funcionalidades de Java 2D.

Además, todos aquellos componentes de Swing derivados de JComponent implementan internamente el doble buffer, por lo que no tendremos que ocuparnos de hacerlo nosotros. Simplemente deberemos rellenar el código del método paint, borrando el contenido del contexto gráfico que se nos proporciona, si fuese necesario, y dibujando los gráficos. Es importante hacer notar que no todos los componentes de Swing derivan de JComponent, como es el caso de JFrame por ejemplo, por lo que en este caso no implementará internamente el doble buffer. Para saber si un componente deriva de éste, simplemente tendremos que ir a la documentación de la API de Java y en la página de dicho componente veremos toda la jerarquía de sus ascendientes.

Lo que deberemos hacer (siempre que trabajemos con la versión 1.2 de JDK o posteriores) será hacer una conversión cast del objeto Graphics proporcionado a un objeto Graphics2D. Esto es así porque a los métodos paint y update se les estará proporcionando en realidad un objeto Graphics2D, aunque la referencia a él sea de tipo Graphics por cuestión de mantener la compatibilidad con versiones anteriores. Si queremos utilizar las funcionalidades mejoradas que ofrece la nueva API de Java 2D, deberemos obtener el objeto Graphics2D de la siguiente forma:

public void paint(Graphics g) {
	Graphics2D g2 = (Graphics2D)g;
}

Este objeto nos permitirá realizar un mayor número de operaciones en el contexto gráfico pudiendo así obtener de forma sencilla unos gráficos mejorados.

En este nuevo contexto gráfico tendremos los siguiente atributos:

Se proporcionan una seríe de métodos setXXXX y getXXXX para obtener y modificar los atributos anteriores.

Para dibujar figuras podemos utilizar el siguiente método:

g2.draw(Shape figura);

La información de las figuras estará encapsulada en clases derivadas de Shape (podemos encontrar rectángulos, líneas, curvas, etc). Deberemos instanciar la figura adecuada y dibujarla en el contexto gráfico mediante el método anterior.

Figura 8. Tipos de figuras

3.4.5 Modelo de imagen

A lo largo de la evolución de Java han aparecido distintos modelos para trabajar con imágenes. A continuación estudiaremos cada modelo viendo como se trabaja con ellos y sus pros y sus contras.

3.4.5.1 Modelo productor/consumidor (push)

Este es el primer modelo que apareció. Entonces Java estaba orientado hacia la creación de Applets, que son aplicaciones Java que nuestro navegador descarga de la red y ejecuta en nuestra máquina como un objeto incrustado en un documento (página web). En este caso las imágenes serán recursos que deban descargarse del servidor, lo cuál puede llevar un tiempo, incluso puede ocurrir que no se consiga descargar la imagen.

Por ello, este modelo no fuerza a que se complete la carga de la imagen. Es más, el consumidor de la imagen no controlará cuando la imagen llega a él, simplemente puede esperar a que el productor vaya empujando los datos hacia él conforme se vayan cargando, por esto se dice que es un modelo push.

En este modelo no existe la idea de tener una imagen persistente almacenada en memoria. Una imagen definida en la clase Image no contiene los datos de los pixels de la imagen, sino que simplemente contiene una referencia a un productor que será el objeto que tendremos que utilizar para obtener estos datos. El productor se define en la clase ImageProducer, y podremos tener distintos tipos de productores según donde se encuentre la imagen que queramos utilizar. Cuando cargamos una imagen de la red, el productor se encargará de descargar los datos de la imagen de la red.

El productor irá produciendo los datos de la imagen, y se los irá suministrando a un consumidor, que es un objeto definido en ImageConsumer, y que será el objeto interesado en los datos de la imagen para utilizarlos. Normalmente, cuando mostramos una imagen, AWT es el consumidor que utilizará los datos que produce el productor para mostrarlos por pantalla.

En resumen, cuando en AWT dibujamos una imagen hacemos lo siguiente:

  1. Llamamos a drawImage proporcionando un objeto Image.
    g.drawImage(img,0,0,obs);
  2. Será el mismo AWT el que se comporte como consumidor de dicha imagen.
  3. Obtendrá el productor asociado a la imagen que le hemos proporcionado. Para ello utilizará el método getSource del objeto imagen.
    ImageProducer prod = img.getSource();
  4. En el objeto productor, se registrará el consumidor de la imagen. Para ello llamará al método addConsumer del productor de la imagen, registrando como consumidor el objeto consumidor que AWT utilice para mostrar la imagen.
    prod.addConsumer(ImageConsumer consumidor);
  5. El productor, al recibir los datos de la imagen se los proporcionará a todos los consumidores que tenga registrados. Para ello llama el método setPixels de cada consumidor proporcionando la información de los pixels obtenidos.
    consumidor.setPixels( ... );
  6. El consumidor (AWT en este caso) podrá dibujar los pixels conforme le llegan, o bien hacer un backup temporal de estos datos para mostrarlos cuando haya recibido la imagen completa.

Como deciamos anteriormente, es el productor el que llamará a un método del consumidor cuando le lleguen datos, por lo que el consumidor no puede hacer más que esperar a que llamen a ese método suyo para que se le proporcionen los datos de la imagen, no puede obtenerlos más que cuando el productor los empuje hacia él.

Figura 9. Modelo productor/consumidor

Aquí vemos que el consumidor sólo recibe datos, pero además necesitará saber cuando ha terminado de recibir la imagen para poderla mostrar entera. Para ello tiene el método imageComplete al que llamará el productor una vez finalizada la producción.

Control de la producción de la imagen

Si de forma externa queremos conocer el proceso de carga de la imagen, podemos usar o bien la clase MediaTracker, o la interfaz ImageObserver. ImageObserver nos permitirá conocer con más detalle en todo momento el estado de la producción de la imagen, pero para las necesidades que podamos tener en nuestras aplicaciones con la información que ofrece MediaTracker será suficiente.

Cuando dibujamos una imagen en AWT, hemos visto anteriormente que debemos proporcionar un objeto que cumpla la interfaz ImageObserver. Esta interfaz define el método imageUpdate, al que el consumidor (AWT) irá llamando conforme se carga la imagen. Como ImageObserver nosotros deberemos indicar el componente AWT donde estamos dibujando la imagen, ya que el comportamiento por defecto de este observador será llamar a repaint cuando la imagen se haya completado, para que ésta sea dibujada por completo en el área del componente.

La utilización de un MediaTracker, nos permitirá controlar si un conjunto de recursos (imágenes) han sido cargados por completo. Nos permite también esperar hasta que esto ocurra, lo cuál será util si no queremos que nuestra aplicación continue su ejecución hasta que no se hayan cargado todas las imágenes necesarias. Mientras tanto, será buena idea mostrar un mensajedel tipo "Cargando datos, espere por favor ...". La utilización de un MediaTracker para esperar hasta que se haya cargado una imagen se hará como se muestra a continuación:

// Carga la imagen suponiendo que se trata de un Applet
Image img = getImage(getCodeBase(), "foto.jpg");	

// this hace referencia al componente AWT donde se cargan las imágenes 
MediaTracker tracker = new MediaTracker(this); 	

// Añade la imagen img bajo el identificador 0.
// Pueden añadirse multiples imágenes bajo un mismo identificador
tracker.addImage(img,0);	

// Bloquea el código hasta que se hayan cargado todas las imágenes
// con identificador 0
tracker.waitForId(0);	

Filtrado de imágenes

Este modelo nos permite construir cadenas de productores, cada uno de los cuales puede tomar como entrada la salida del anterior productor de la cadena, y de esta forma realizar distintos filtrados a la imagen en cada etapa.

Figura 10. Filtrado de imágenes

Tenemos para ello un tipo de productor denominado FilteredImageSource, que tomará los datos generados por otro productor, los procesará de alguna forma, y los suministrará a los consumidores que haya registrados a la espera de datos. Al construir este objeto deberemos proporcionar el productor que produce la imagen que vamos a tomar como entrada, y el filtro que vamos a aplicarle a dicha imagen.

img = getImage("foto.jpg");
ImageProducer productor = img.getSource();

ImageFilter filtro = ... // Construye filtro a aplicar

FilteredImageSource fis = new FilteredImageSource(productor, filtro);

Image resultado = createImage(fis);

Los filtros se definen en la clase ImageFilter, y son un tipo de consumidores de imágenes (implementan ImageConsumer). En realidad, contienen un consumidor como uno de sus campos, y lo que harán será reenviar las llamadas a sus métodos al consumidor interno. Cuando se contruya la imagen, el filtro la procesará y enviará los datos procesados a su consumidor interno. Será entonces de este consumidor interno del que el productor FilteredImageSource obtendrá los datos a producir para el siguiente elemento de la cadena.

Cuando creemos nuestro propio filtro, deberemos crear una subclase de ImageFilter y leer los pixels que se nos entreguen llamando a nuestro método setPixels y almacenarlos en un buffer interno. Una vez se llame a nuestro método imageComplete, podremos procesar la imagen que hemos almacenado, y enviar el resultado al consumidor interno (campo consumer) utilizando sus métodos setPixels e imageComplete.

Acceso a los pixels de la imagen

Antes comentabamos que con este modelo no exista la idea de una imagen persistente, la imagen en memoria no mantiene almacenados sus pixels, sin embargo, existen métodos para convertir un array de memoria en un productor de imágenes, o bien capturar la salida del productor guardándola en un array en memoria.

Para poder utilizar un array de pixels que tengamos en memoria como una imagen, podemos definir un productor que utilice dicho array para producir la imagen. Este tipo de productor es MemoryImageSource (derivado de ImageProducer).

int ancho = 100;
int alto = 100;
int [] pixels = new int[ancho*alto];

for(int i=0;i<alto;i++) {
    for(int j=0;j<ancho;j++) {
        int rojo = ...  // Valor de 0 a 255 indicando el nivel de rojo
        int verde = ... // Valor de 0 a 255 indicando el nivel de verde
        int azul = ...  // Valor de 0 a 255 indicando el nivel de azul

        pixels[i*ancho + j] = (255 << 24) | 
                              (rojo << 16) | (verde << 8) | azul;
    }
}

Image img = createImage(
       new MemoryImageSource(ancho, alto, pixels, 0, ancho));

Si lo que queremos es capturar los pixels de una imagen con la que ya contamos (por ejemplo cargada de un fichero), lo que haremos será utilizar un consumidor que nos proporcione el array de pixels de la imagen consumida. Este consumidor es PixelGrabber (derivado de ImageConsumer). Proporcionaremos a este objeto la imagen a consumir, de la cual queramos extraer los pixels, y una vez finalizada la producción de la imagen nos devolverá el array de pixels que haya producido.

int ancho = 100;
int alto = 100;
int [] pixels = new int[ancho*alto];

// Capturamos la región de la imagen img que comienza en (x,y) 
// con dimensiones de ancho x alto

PixelGrabber pg = 
	new PixelGrabber(img, x, y, ancho, alto, pixels, 0, ancho);

try {
    pg.grabPixels();
} catch (InterruptedException e) {
    System.err.println("Interrumpido durante la captura");
    return;
}
if ((pg.getStatus() & ImageObserver.ABORT) != 0) {
    System.err.println("Carga de la imagen abortada");
    return;
}

for(int i=0;i<alto;i++) {
	for(int j=0;j<ancho;j++) {
		int pix_rgb = pixels[i*ancho + j];

		int alpha = (pix_rgb >> 24) & 0xFF;
		int rojo = (pix_rgb >> 16) & 0xFF;
		int verde = (pix_rgb >> 8) & 0xFF;
		int azul = pix_rgb & 0xFF;

		// Ahora podemos utilizar estos datos del pixel leido
	}
}

Como vemos, este modelo está pensado para aplicaciones que cargan imágenes de la red simplemente para mostrarlas sin realizar ninguna operación compleja con ellas. Por lo tanto, el modelo carece de capacidad para escribir código de alto rendimiento para el procesamiento de imagenes.

Si lo único que necesitamos es cargar y mostrar imágenes en nuestra aplicación, no hará falta que entendamos el funcionamiento interno del modelo productor/consumidor.

3.4.5.2 Modo inmediato

Este nuevo modo pretende eliminar las restricciones impuestas por el sistema anterior. Se incorpora junto a la API de Java 2D introducida a partir de JDK 1.2.

Aquí ya aparece la imagen persistente en memoria, con la que tenemos los datos de la imagen almacenados en el objeto de la imagen, y no dependemos de un productor para obtener dichos datos. La clase que se utiliza en este caso para la encapsulación de las imágenes es BufferedImage, y ésta si que contiene los datos sobre la matriz de pixels de la imagen, denominado raster. En ella se mantiene la compatibilidad con el modelo productor/consumidor, por lo que podremos utilizar este tipo de imágenes en cualquier situación en la que usabamos las anteriores.

Figura 11. Modelo de modo inmediato

En el esquema vemos que la imagen contiene información del raster, encapsulada en la clase Raster, y del modelo de color utilizado en la representación de dicho raster encapsulada en ColorModel.

El raster se compondrá de las clases SampleModel y DataBuffer. Los datos de los pixels de la imagen están contenidos en DataBuffer. El objeto SampleModel indicará la organización de los datos dentro del buffer. Por ejemplo, podemos tener una imagen almacenada por bandas, en la que encontramos primero los datos de la banda roja, después todos los de la verde y por último los de la azul, o bien almacenar empaquetados en cada pixel los componentes rojo, verde y azul.

Por lo tanto, el SampleModel contiene la información sobre como están almacenados los pixels en el buffer de datos, de forma que el desarrollador pueda acceder a ellos directamente a bajo nivel. Sin embargo, normalmente no necesitaremos trabajar directamente con estos datos, ya que el Raster proporciona métodos para el acceso a los pixels de la imagen de forma sencilla.

Con esto tenemos almacenado en el raster un valor para cada pixel de la imagen. Ahora necesitamos el objeto ColorModel para saber a que color corresponde cada valor, es decir, que valores de rojo, verde, azul y alpha corresponden a ese pixel. Para ello ColorModel contiene un objeto ColosSpace, que encapsula la información del espacio de color que estemos usando. El espacio de color más común será el RGB donde los valores de las bandas de rojo, verde y azul se mueven en los intervalos de 0 a 1. Utilizando ya esta información, la clase BufferedImage nos ofrece métodos para acceder directamente a los valores RGB de los pixels de la imagen. Para obtener este valor podemos usar el método:

int valor_rgb = buf_img.getPixel(x,y);

De esta forma podemos leer la imagen pixel a pixel. También podemos encontrar una variante de este método que nos permite obtener un array de pixels, lo cuál será bastante más rápido ya que con una única llamada al método, obtendremos toda la información necesaria para trabajar con la imagen. La codificación de estos valores RGB es la siguiente (en hexadecimal):

0xAARRGGBB

Donde AA es el valor alpha (para transparencia y otros efectos, normalmente lo pondremos con valor FF), RR el rojo, GG el valor de verde, y BB el de azul.

Como vemos, con este modelo es muy sencillo acceder al contenido de la imagen, sin necesitar conocer los aspectos internos de la implementación de BufferedImage que hemos descrito anteriormente.

La imágenes en el modo inmediato, concretamente la clase BufferedImage que es la implementación básica en este modo, implementan la interfaz RenderedImage. En el caso de BufferedImage se implementa un subtipo de dicha interfaz denominado WritableRenderedImage, ya que podemos modificar el valor de los pixels de dicha imagen (con la interfaz anterior sólo se permitiría el acceso a los pixels). Decimos que la imagen es renderizada (rendered) porque tiene una resolución fija, es decir, contiene un array de pixels (raster) que se volcará tal cual a pantalla, con las dimensiones que tenga. Por otro lado, una imagen rendirizable (renderable), no proporciona directamente un array de pixels, ya que es independiente del contexto (de la resolución). Deberemos renderizarla con unas determinadas dimensiones, y entonces se creará una imagen renderizada con un raster de pixels con las dimensiones que hayamos indicado.

Como hemos comentado anteriormente, la implementación de BufferedImage es renderizada, por lo que no vamos a entrar a estudiar con más profundidad las imagenes renderizables. Si queremos cambiar el tamaño (resolución) de una imagen BufferedImage, podremos utilizar un filtro que realice dicha operación.

Filtrados de imágenes

Podemos utilizar una serie de filtros para procesar las imágenes (BufferedImage). Para ello se proporcionan una serie de objetos operadores (filtros), que tomarán como entrada una imagen origen y una imagen destino. Accederá al contenido de la imagen origen, lo procesará, y escribirá el resultado en la imagen destino.

Los operadores de imágenes puedes trabajar a dos niveles:

Para aplicar el filtro, lo único que tendremos que hacer será instanciar el operador adecuado, y realizar el filtrado de la imagen de la siguiente forma:

BufferedImage origen = ... // Imagen origen
BufferedImage destino = ... // Imagen destino

BufferedImageOp filtro = ... // Instanciamos el filtro adecuado

filtro.filter(origen,destino);

Podemos forzar a que se cree la imagen destino adecuada para realizar la transformación en ella especificando null como imagen destino:

BufferedImage origen = ... // Imagen origen

BufferedImageOp filtro = ... // Instanciamos el filtro adecuado

BufferedImage destino = filtro.filter(origen,null);

Si trabajamos con rasters, lo haremos de forma similar:

BufferedImage origen = ... // Imagen origen
BufferedImage destino = ... // Imagen destino

Raster r_origen = origen.getData();
WritableRaster r_destino = destino.getRaster();

RasterImageOp filtro = ... // Instanciamos el filtro adecuado

filtro.filter(r_origen,r_destino);

Igual que ocurría con BufferedImageOp, especificando como destino null forzaremos a que se cree automáticamente un raster destino donde escribir el resultado.

3.4.5.3 Modelo pipeline (pull)

Este último modelo está diseñado para la realización de aplicaciones en las que necesitemos realizar operaciones de alto rendimiento con las imágenes, en las que realicemos un tratamiento avanzado de las imágenes. En este caso ya no se trabaja con productores y consumidores (es un modelo pull).

No se proporciona en la distribución básica de JDK, sino que hay que obtenerlo como una extensión. Esta extensión es JAI (Java Advanced Imaging), de la cual hablaremos con más detalle en el último tema.

3.4.6 Modo a pantalla completa

A partir de JDK 1.4.0 podremos utilizar un modo de gráficos a pantalla completa. Con este modo tendremos acceso exclusivo y directo al dispositivo de la pantalla.

En AWT hemos visto anteriormente que era el sistema el que se encargaba de mandar eventos para repintar el contenido de nuestra ventana. Esto tiene que hacerse así ya que el dispositivo gráfico (la pantalla) se comparte con el sistema operativo y el resto de aplicaciones en ejecución, por lo que es el mismo sistema el que nos tiene que indicar cuando podemos redibujar.

Sin embargo, al tener ahora un acceso exclusivo a la pantalla, podremos dibujar en ella en cualquier momento. Esto es lo que se conoce como render activo, frente al render pasivo que hemos visto anteriormente.

Para utilizar este modo exclusivo a pantalla completa lo primero que deberemos hacer es obtener el dispositivo gráfico (GraphicsDevice) que vamos a utilizar. Lo obtendremos a partir del entorno gráfico (GraphicsEnvironment) de nuestra máquina:

GraphicsEnvironmen ge = 
	GraphicsEnvironment.getLocalGraphicsEnvironment();
GraphicsDevice gd = ge.getDefaultScreenDevice();

Podemos tener varios dispositivos gráficos en nuestra máquina, que podrán ser obtenidos también a través del entorno gráfico. Con el método visto anteriormente obtendremos el dispositivo primario que es el que querremos utilizar normalmente, ya que lo normal será disponer de un solo dispositivo gráfico.

Una vez tenemos el dispositivo gráfico podemos podremos comprobar si soporta el modo a pantalla completa:

boolean isFullScreenSupported()

Una vez hemos comprobado que soporta dicho modo podremos pasar a pantalla completa indicando que ventana (Window) de nuestra aplicación vamos a mostrar en la pantalla:

void setFullScreenWindow(Window w)

Del objeto de dispositivo gráfico podremos obtener la lista de modos soportados por dicho dispositivo (resolución y profundidad de color), y seleccionar cualquiera de estos modos siempre que la operación esté permitida en nuestro sistema. Obtenemos el modo gráfico actual con:

DisplayMode dm = gd.getDisplayMode();

Para obtener todos los modos soportados haremos lo siguiente:

DisplayMode [] dms = gd.getDisplayModes();

Los datos sobre los modos gráficos se encapsulan en la clase DisplayMode. Una vez elegido el modo que queremos utilizar podemos seleccionarlo con:

gd.setDisplayMode(dm);

Antes hemos hablado del concepto de render activo. Para utilizar este tipo de render tendremos que obtener el objeto de contexto gráfico asociado al componente donde vamos a dibujar (la ventana en este caso):

Graphics g = getGraphics();

Como vemos, una vez hemos terminado de dibujar en la pantalla, debemos llamar a dispose para liberar los recursos que estuviese utilizando.

Podemos usar tanto el modo activo como el modo pasivo cuando trabajemos a imagen completa. Sin embargo el modo activo nos dará un mayor control y podremos utilizar técnicas avanzada como el intercambio (flipping) de páginas de memoria de video.

Antes hemos visto como implementar el doble buffer. Pero ahora que tenemos acceso directo al dispositivo gráfico, podremos usar una técnica más rápida para implementarlo. Existen dos técnicas:

BLT: Leido como blit (blitting), que significa BLock Transfer. Es una técnica similar a la que hemos descrito anteriormente. El backbuffer es un área en memoria, y cuando queremos volcar este contenido a pantalla copiaremos el contenido de dicho área a la memoria del dispositivo gráfico.

FLIP: Se refiere al intercambio (flipping) de páginas de memoria de video. Normalmente la tarjeta gráfica tendrá varias páginas de memoria (doble buffer o triple buffer normalmente). En un momento dado el dispositivo gráfico estará mostrando el contenido de una de estas páginas. Lo que haremos será utilizar la página que no se esté mostrando como backbuffer. Una vez hayamos terminado de dibujar, haremos que esa página pase a ser el buffer de pantalla mostrándose así su contenido sin tener que hacer ninguna transferencia de datos, y la otra página pasará a ser el backbuffer donde dibujaremos a continuación. Esta técnica, puesto que la página contiene todo el contenido de la pantalla, no podremos utilizarla si estamos compartiendo la pantalla con otras aplicaciones, ya que afectariamos a todas ellas. Es por esto que esta técnica solo podemos utilizarla al trabajar a pantalla completa.

Con el modo a pantalla completa podremos utilizar cualquiera de las dos técnicas anteriores. Para ello tenemos las clases BufferStrategies y BufferCapabilities que implementan dichas técnicas.

3.4.7 Sonido y música. Java Sound

Hemos visto como incluir gráficos y animaciones en nuestras aplicaciones y applets. Con Java además podremos añadir sonido y música de forma sencilla.

En las primeras versiones de JDK los applets permitían cargar clips de audio, con lo que podiamos reproducir música y sonido en la web. Los tipos de ficheros y formatos reconocidos entonces eran bastante limitados (MIDI, WAV, AU).

A partir de Java 2, se incorpora la API de Java Sound, que nos permite además de tratar con una mayor número de formatos, incorporar sonido tanto a applets como aplicaciones y trabajar con la reproducción del sonido a un nivel más bajo, lo cual nos dará mayor flexibilidad para incluir todo tipo de efectos de sonido en nuestras aplicaciones.

Nos referiremos a las músicas y a los efectos de sonido como clips de audio. Estos clips de audio estarán encapsulados en la clase AudioClip. Para cargar un clip de audio, los applets siempre han incorporado el siguiente método:

AudioClip clip = getAudioClip(url);

Para ello deberemos estar dentro un applet, ya que dicho método pertenece a la clase Applet y para usarlo debe haber un objeto Applet instanciado.

Si estamos dentro de una aplicación, no tendremos acceso a este método. Por ello, a partir de JDK 1.2 se incorpora a la clase Applet el siguiente método estático:

AudioClip clip = Applet.newAudioClip(url);

Al ser estático no hará falta haber instanciado un applet para poder usarlo, por lo que lo podremos utilizar desde cualquier aplicación Java. El problema que tenemos en este caso es que debemos proporcionar una URL, cuando lo más seguro es que queramos cargar un fichero del disco local. Podemos obtener una url para esto de la siguiente forma:

URL url = new URL("file:" + System.getProperty("user.dir") 
                          + "/sonido.wav";

Utilizamos la propiedad del sistema user.dir para obtener el directorio actual, y con él podemos construir un URL para el acceso a ficheros locales.

Una vez obtenido el clip de audio, podremos reproducirlo.

clip.play();

Reproduce el clip una sola vez.

clip.loop();

Reproduce el clip ciclicamente. Util en el caso de que queramos tener una música de fondo que no pare de sonar.

clip.stop();

Detiene la reproducción del clip de audio. Sobretodo útil para músicas, y cuando hayamos establecido que se reproduzca ciclicamente.

En la API Java Sound incorporada en las últimas versiones de Java, tenemos bastante más clases que nos permitirán controlar la secuenciación, el mezclado, etc. Estas clases se incluyen en el paquete javax.sound y subpaquetes.