Simulación de Ecosistemas

En el apartado anterior vimos como crear organismos virtuales autónomos. También vimos como lograr que estos organismos se muevan de forma natural, pero hasta el momento, estos organismos no logran percibir a sus pares. Es decir, a pesar de moverse por un espacio en común, no pueden verse entre sí.

Es ahora, entonces, cuando veremos como lograr que estos seres virtuales interactuen entre sí, para ello es preciso que puedan percibirse unos a los otros. En este caso en particular, la forma en que estos agentes se manifiestan es a partir de su posición y movimiento, dado que eso es todo lo que por el momento saben hacer. Por lo tanto la forma en que ellos pueden percibir al resto es conociendo la posición de los demás.



La explosión combinatoria

La forma concreta en la que se logra que los agentes interactúen, es haciendo que cada uno de ellos compare su posición en el espacio, con la de todos los demás. Por ejemplo, si tenemos 5 agentes, el 1 deberá comparar su posición en el 2, el 3, el 4 y el 5.

Pero luego cada uno de los otras deberá hacer lo mismo:

Si el vínculo que se establece entre dos agentes no tiene dirección, es decir que, visto desde los dos individuos es lo mismo, entonces, cuando hay 4 agentes el número total de vínculos simples es 6. Pero, si en cambio, el vínculo cambia según la dirección, entonces con 4 agentes tenemos una 12 vínculos bidireccionales:

agentes 
vínculos simples
vínculos bidireccionales
2
1
2
3
3
6
4
6
12
5
10
20
6
15
30
7
21
42
8
28
56
9
36
72
10
45
90
20
190
380
30
435
870
40
780
1560
50
1225
2450
60
1770
3540
70
2415
4830
80
3160
6320
90
4005
8010
100
4950
9900

En la tabla anterior se puede ver claramente que pequeñas cantidades de agentes implican grandes cantidades de vínculos. El problema de esto es que este tipo de simulaciones son interesantes cuando la cantidad de agentes superan los cientos. Sin embargo la cantidad de interacciones que requieren cientos de agentes puede ser muy alta para seguir sosteniendo la performance de una aplicación en tiempo-real.



División del territorio

Supongamos que tenemos un escenario con 20 agentes y que queremos que estos se muevan libremente, pero cuando se cruzan con otro, lo esquiven, haciendo que salgan en la dirección opuesta. Un escenario como este necesitaría de 380 interacciones. Pero si se analiza el problema en profundidad, se observa que no tiene sentido comparar los agentes que se encuentran lejos unos de otros. El problema de este razonamiento, es que para saber cuales están lejos de los otros, es necesario hacer la comparación, después de todo, el sentido de la comparación era revisar las distancias.

Existe otra forma de resolver el problema. Esta consiste en dividir el espacio en espacios más pequeños, en los que quepan menos agentes. De esta forma, el espacio queda dividido en un número homogéneo de celdas, las cuales albergan menor cantidad de agentes. Así se puede determinar que agentes se encuentran cerca unos de otros, dado que en principio pertenecerán a la misma celda.

Por ejemplo, en el diagrama que se encuentra arriba, se pueden ver 20 agentes distribuidos en 9 celdas. Algunas celdas poseen desde 1 hasta 5 agentes, algunas no poseen ninguno. Con esta división la cantidad de interacciones se reduce a 52. Cuanto más chicas sean las celdas, menor será la cantidad de agentes que quepan dentro de cada una de estas, y por ende menor la cantidad de interacciones. Sin embargo, en casos extremos el tamaño de las celdas podría ser tan pequeño que sólo entrase un o ningun agente, por lo que estos serían incapaces de ver al resto.

En el ejemplo anterior también se puede ver que el agente 20 y el 10 (aproximadamente en el centro de la escena) se encuentra relativamente cerca, pero no interactuarán, dado que se encuentran en diferentes celdas, y sin embargo la 20 interactúa con la 3, siendo que se encuentra más lejana que la 10.



Esquivando a los otros
Ejemplo Vida 05

En el ejemplo que esta arriba, los organismos son capaces de recorrer el espacio e intentear esquivar a los otros organismos. Para esto fue necesario implementar un algoritmo que administre el territorio de la forma antes descripta. La idea del mismo, es que en cada ciclo (cada fotograma) de la ejecución, luego de mover los organismos, se los ubica a cada uno en la celda de territorio que les corresponde. Para esto se crearon un conjunto de funciones que organizan el desarrollo de este algoritmo:

void draw(){

    ...
    revisar_Territorio();
    resolver_Encuentros_Organismos();
    mover_Organismos();
    dibujar_Organismos();
    ...

}       

Hecho en Processing


En el código escrito arriba se puede observar el ciclo de funcionamiento:

1- Revisa la posición de cada organismo y los ubica en la celda de territorio que les corresponde.
2- Recorre celda por celda el territorio y en cada una de estas revisa los encuentros entre organismos.
3- Mueve los organismos en función de lo resuelto en cada encuentro.
4- Dibuja los organismos en pantalla.

Los dos últimos pasos son exactamente iguales a los vistos en el apartado anterior. De hecho el comportamiento mover() (de la clase Organismo) no ha cambiado en nada.

void mover_Organismos(){

    for(int i=0;i<cantAnimales;i++){ //se recorre cada animal y :

        animales[i].mover(); //cada uno camina hacia la
        // comida y actualiza su energia

    }

}

void dibujar_Organismos(){

    for(int i=0;i<cantAnimales;i++){ //se recorre cada animal y :

        animales[i].dibujar(); //dibuja cada animal

    }

}         

Hecho en Processing


Como se ve arriba, estas nuevas funciones lo único que hacen es recorrer el arreglo de organismos y ejecutar sus comportamientos mover( ) y dibujar( ).

La duda que puede surgir en este punto es ¿cómo es que sin cambios en el comportamiento mover( ) los organismos se comportan distintos? Esto se logra por que la desición de hacia dónde moverse se resuelve en un omportamiento, llamado resolverEncuentro( ):

void resolverEncuentro( Organismo otro ){

    if( dist( x , y , otro.x , otro.y ) < radio*4 ){
    //si se encuentra a menos de dos cuerpos
    //de distancia del otro, entonces sale en la dirección opuesta

        direccion = atan2( y-otro.y , x-otro.x );

    }

}         

Hecho en Processing


El comportamiento resolverEncuentro( ) recibe un objeto Organismo como parámetro (llamado otro). Dado que otro es también un organismo, posee los mismos datos que este objeto, es decir: x, y, dirección, velocidad, dx, dy, radio,etc. Entonces utiliza los datos de posición (x e y) para compararlos con los propios y así estimar la distancia del otro: dist( x , y , otro.x , otro.y ) < radio*4 (la función dist(x1,y1,x2,y2) calcula la distancia entre dos puntos ). Si esta condición se cumple, entonces toma la dirección contraria: direccion = atan2( y-otro.y , x-otro.x ) ( la operación atan2(y2-y1,x2-x1) devuelve el ángulo descripto por dos puntos (la pendiente).



Administrando el territorio

Si bien la clase Organismo tiene un comportamiento para resolver el encuentro con otro. Es necesario ejecutar esta acción desde fuera, presentándole los diferentes otros a cada organismo. Como vimos al principio hacer esto entre todos los organismos en forma indiscriminada puede generar problemas por la explosión combinatoria. Por eso es necesario administrar el territorio según el criterio que antes describimos. Para ello desarrollamos una clase Territorio que a su vez está conformada por una matriz de objetos de tipo T_Lugar. La función de los objetos T_Lugar es registrar los organismos de cada celda en que se divide el territorio. Para ello posee tres arreglos, dos para las posiciones de los organismos (x[ ] e y[ ] ) y otro para registrar los identificadores de estos ( id[ ] el número de índice en el arreglo de organismos).

class T_Lugar{

    int cantidad;
    int limite;
    int fila,col;
    float x[], y[];
    int id[];

    T_Lugar( int col_ , int fila_ ){

        fila = fila_;
        col = col_;
        cantidad = 0;
        limite = 100;
        x = new float[limite];
        y = new float[limite];
        id = new int[limite];

    }

    void agregar( float x_ , float y_ , int id_ ){

        if( cantidad < limite-1 ){

            x[ cantidad ] = x_;
            y[ cantidad ] = y_;
            id[ cantidad ] = id_;
            cantidad ++;

        }

    }

}       

Hecho en Processing


Las variables fila y col sirven para almacenar la posición de la celda en el territorio. Sólo es útil para hacerle posteriores consultas a la celda.

El comportamiento agregar( float x_ , float y_ , int id_ ), que es el que nos interesa, permite agregar un nuevo organismo a esta celda. Por cada organismo que se agrega, se carga en los arreglos (x[ ], y[ ] e id[ ] ) y luego se incrementa la variable cantidad.

A su vez el objeto territorio se encarga de verificar en cuál celda está el organismo:

class Territorio{

    float ancho;
    float alto;
    int filas;
    int col;
    int modH,modV;
    T_Lugar lugares[][];

    Territorio( float anchoPantalla , float altoPantalla , int filas_ , int col_ ){
    	//inicializa el objeto definiendo la matriz de celdas

        ancho = anchoPantalla;
        alto = altoPantalla;
        filas = filas_;
        col = col_;
        modH = int(ancho/col);
        modV = int(alto/filas);
        lugares = new T_Lugar[ col ][ filas ];
        for(int i=0;i < col;i++){

            for(int j=0;j<filas;j++){

                lugares[i][j] = new T_Lugar(i,j);

            }

        }

    }
    void ubicar( float x , float y , int id ){
    //este comportamiento ubica a cada objeto en su celda

        if( x>0 && x<ancho && y>0 && y<alto){

            int cualX = int(x/modH);
            //define el lugar horizontal en el que //cae el objeto
            int cualY = int(y/modV);
            //define el lugar vertical en el que cae //el objeto
            cualX = (cualX >= col ? col-1 : cualX);
            cualY = (cualY >= filas ? filas-1 : cualY);
            //agrega el objeto en la celda elegida
            lugares[ cualX ][ cualY ].agregar( x , y , id );

        }

    }
    ...
	

Hecho en Processing


Como se observa arriba, el constructor de la clase Territorio recibe como parámetros las dimensiones de la pantalla (la escena) y la cantidad de filas y columnas de la matriz de celdas en las que se divide el territorio. En función de estos parámetros, el constructor calcula las variables modH y modV, las cuales describen las dimensiones (en píxels) de cada celda.

El comportamiento ubicar( float x , float y , int id ) se encarga de recibir la posición e identificación de cada organismo, para calcular en cúal celda está ubicado, esto lo hace con las operaciones:
int cualX = int(x/modH)

int cualY = int(y/modV)

Luego le envía la información a la celda seleccionada para que esta lo agregue a su lista:
lugares[ cualX ][ cualY ].agregar( x , y , id )
.

Las funciones que comandan estas acciones desde la estructura principal son revisar_Territorio( ) y
resolver_Encuentros_Organismos( ). La primera es bastante sencilla:

void revisar_Territorio(){

    miTerritorio = new Territorio( width , height , celdas , celdas);
    for(int i=0;i<cantAnimales;i++){ //se recorre cada animal y :

        miTerritorio.ubicar( animales[i].x , animales[i].y , i );

    }

}
         

Hecho en Processing


Inicializa el territorio, es decir, vacía todas las celdas ejecutando el contructor:
miTerritorio = new Territorio( width , height , celdas , celdas)
Luego, recorre todos los organismos, ejecutando la acción ubicar( ), para que el territorio los ubique en la celda que les corresponde.

La función resolver_Encuentros_Organismos( ) es algo más compleja:

void resolver_Encuentros_Organismos(){

    int limiteEncuentros = 20;
    for( int i=0 ; i<miTerritorio.col ; i++ ){
    //recorre una por una las

        for( int j=0 ; j<miTerritorio.filas ; j++ ){
        //celdas del territorio

            T_Lugar esteLugar = miTerritorio.lugar( i , j );
            //toma cada lugar del territorio

            for( int k=0 ; k < esteLugar.cantidad-1 && k<limiteEncuentros ; k++ ){
            // toma uno por uno los objetos de este lugar

                int id1 = esteLugar.id[k];
                // recupera el id

                for( int l=k+1 ;l < esteLugar.cantidad && l<limiteEncuentros ; l++ ){
                // toma otro objeto del lugar

                    int id2 = esteLugar.id[l];
                    // recupera el id

                    animales[id1].resolverEncuentro( animales[id2] );
                    // enfrenta al organismo
                    // 1 con el 2

                    animales[id2].resolverEncuentro( animales[id1] );
                    // enfrenta al organismo
                    // 2 con el 1

                }

            }

        }

    }

}
        	
        

Hecho en Processing


Los dos primeros ciclos for (los que corresponden a las variable i y j) se encargan de recorrer el territorio celda por celda. La instrucción T_Lugar esteLugar = miTerritorio.lugar( i , j ) carga en la variable esteLugar la celda correspondiente a la posición( i y j). Luego, el ciclo for correspondiente a la variable k se encarga de recorrer uno a uno los organismos de esa celda.

En este ciclo, la condición k<limiteEncuentros sirve para que la cantidad de encuentros no superen un límite preestablecido. Esto es dado que podría suceder el hipotético caso de que todos los organismos estén en una única celda de todo el territorio, en cuyo caso estaríamos con el mismo problema que al principio. Frente a esta posible situación, no nos queda más remedio que limitar la cantidad de encuentros.

El cuerto ciclo, el correspondiente a la variable l, se encarga de seleccionar un nuevo organismos para enfrentar al ya seleccionado, por eso el recorrido del ciclo se hace desde l=k+1. La combinación de recorrido de los dos ciclos for (el de k y l) asegura que se recorre todos los casos de combinación (en tanto no se llegue al límite de encuentros), sin nunca llegar hacer que k y l sean iguales:
for( int k=0 ; k < esteLugar.cantidad-1 && k<limiteEncuentros ; k++ ){
for( int l=k+1 ; l < esteLugar.cantidad && l<limiteEncuentros ; l++ ){

Por último, se les pide a los organismos seleccionados que enfrenten a su pareja:
animales[ id1 ].resolverEncuentro( animales[ id2 ] )
animales[ id2 ].resolverEncuentro( animales[ id1 ] )