Diseña experiencias en Realidad Virtual haciendo uso de interacción y movimiento.
En este tutorial, crearemos un entorno virtual en el cual podremos ver un pequeño sistema planetario desde un punto de vista fijo, y luego seleccionar y seguir cada uno de los planetas a medida que giran alrededor de la estrella central. Para implementar esta escena interactiva, tendremos que aplicar una serie de técnicas más avanzadas que hace posible que el usuario del bosquejo RV seleccionar un objeto en la escena y hacer que la posición de la cámara en RV cambie de posición dinámicamente en cada cuadro.
Iremos paso a paso, comenzando por crear los objetos en la escena, luego añadiéndoles movimiento y finalmente implementando la selección de objetos y el posicionamiento de la cámara.
Nuestro sistema solar tendrá un sol central y solo dos los planetas orbitando alrededor del sol. Todos estos elementos son esferas, que podemos almacenar en objetos de tipo PShape para optimizar el rendimiento de nuestro bosquejo evitando que las esferas se vuelvan a recrear en cada cuadro. Como queremos que estas esferas aparezcan como planetas, no podemos simplemente pintarlas con un color de relleno sólido. La forma más fácil de dar a nuestros planetas una apariencia realista es texturizarlos con imágenes que generadas a partir de fotografías reales de planetas, o con un algoritmo generativo que reproduzca las masas de tierra y otras características planetarias al ser vistas desde el espacio. Más específicamente, necesitamos imágenes de las superficies de los planetas representadas como proyecciones cilíndricas equidistantes para que puedan usarse para envolver nuestra esferas correctamente. Utilizaremos las siguientes tres imágenes para el sol y los dos planetas:
Existen una gran candidad de recursos en línea para obtener más imágenes como estas, por ejemplo este ofrece imágenes de proyección cilíndricas equidistantes para todos los planetas de nuestro sistema solar.
Empezemos por agregar el sol a nuestra escena RV:
import processing.vr.*;
PShape sol;
void setup() {
fullScreen(VR);
cameraUp();
noStroke();
sphereDetail(40);
sol = createShape(SPHERE, 100);
sol.setTexture(loadImage("sol.jpg"));
}
void draw() {
background(0);
shape(sol);
}
En la función setup()
configuramos el motor de renderizado RV y con cameraUp()
nos aseguramos de que la escena ya esté centrada en el centro de la pantalla con el eje Y apuntando arriba, que es la configuración estándar para trabajar en realidad virtual. También deshabilitamos el trazado de bordes de los objetos y establecemos un detalle de esfera que sea lo suficientemente alto para que las caras individuales de las mismas no sean reconocibles. Establecemos una imágen como la textura de un objecto PShape con la llamada setTexture()
, que también debe hacerse durante setup para evitar ralentizar el bosquejo.
La ejecución de este bosquejo debería darnos el sol centrado en la escena de RV:
Del mismo modo, podemos agregar los planetas a la escena, aplicando algunas desplazamientos iniciales para que no se superpongan con el sol:
import processing.vr.*;
PShape sol;
PShape planet1;
PShape planet2;
void setup() {
fullScreen(VR);
cameraUp();
noStroke();
sphereDetail(40);
sol = createShape(SPHERE, 100);
sol.setTexture(loadImage("sol.jpg"));
planet1 = createShape(SPHERE, 20);
planet1.setTexture(loadImage("mercury.jpg"));
planet2 = createShape(SPHERE, 50);
planet2.setTexture(loadImage("earthlike.jpg"));
}
void draw() {
background(0);
shape(sol);
pushMatrix();
translate(300, 0, 0);
shape(planet1);
popMatrix();
pushMatrix();
translate(600, 0, 0);
shape(planet2);
popMatrix();
}
Este bosquejo representa un sistema estático con un sol y dos planetas alrededor del mismo:
¡Ahora es el momento de agregar movimiento! El sol puede permanecer inmóvil en nuestra escena, pero las plantas deben girar alrededor del sol. Implementamos las rotaciones orbitales de los planetas utilizando la función rotateY()
y agregando un par de variables para guardar del ángulo de rotación de cada planeta e incrementarlo en cada cuadro:
import processing.vr.*;
PShape sol;
PShape planet1;
PShape planet2;
float orbitalAngle1;
float orbitalAngle2;
void setup() {
fullScreen(VR);
cameraUp();
noStroke();
sphereDetail(40);
sol = createShape(SPHERE, 100);
sol.setTexture(loadImage("sol.jpg"));
planet1 = createShape(SPHERE, 20);
planet1.setTexture(loadImage("mercury.jpg"));
planet2 = createShape(SPHERE, 50);
planet2.setTexture(loadImage("earthlike.jpg"));
}
void calculate() {
orbitalAngle1 += 0.002;
orbitalAngle2 += 0.001;
}
void draw() {
background(0);
shape(sol);
pushMatrix();
rotateY(orbitalAngle1);
translate(300, 0, 0);
shape(planet1);
popMatrix();
pushMatrix();
rotateY(orbitalAngle2);
translate(600, 0, 0);
shape(planet2);
popMatrix();
}
En los bosquejos de RV es muy importante tener en cuenta otra función además de setup()
y draw()
, que es calculate()
. Esta función esta pensada para contener todo el código que realiza los cálculos no visuales que deben ocurrir antes de dibujar un nuevo cuadro en la escena. En los bosquejos de Processing normales, alcanza con colocar este código directamente dentro de la función draw()
, pero esto no es una buena idea en un bosquejo de RV, ya que en ese caso, draw()
es llamada dos veces por cuadro, una vez por cada ojo. La función calculate()
, en cambio, es llamada una sola vez justo antes de draw()
. En este caso particular, lo unico que hacemos en calculate es actualizar los dos ángulos orbitales.
Para aumentar el realismo de nuestro sistema planetario, también podemos incorporar la rotación de los planetas alrededor de sus propios ejes. Para hacer eso, necesitamos agregar dos nuevas variables para guardar los ángulos adicionales, y las llamadas rotateY() correspondientes después de los desplazamientos (para que las rotaciones ocurran alrededor de los ejes de las esferas y no el origen de las coordenadas):
...
float rotationAngle1;
float rotationAngle2;
...
void calculate() {
orbitalAngle1 += 0.002;
orbitalAngle2 += 0.001;
rotationAngle1 += 0.02;
rotationAngle2 += 0.02;
}
void draw() {
background(0);
shape(sol);
pushMatrix();
rotateY(orbitalAngle1);
translate(300, 0, 0);
rotateY(rotationAngle1);
shape(planet1);
popMatrix();
pushMatrix();
rotateY(orbitalAngle2);
translate(600, 0, 0);
rotateY(rotationAngle2);
shape(planet2);
popMatrix();
}
¡Nuestro mini-sistema solar está casi listo! Un aspecto que no es muy realista en este momento es el fondo negro. Necesitamos un campo de estrellas lo suficientemente convincente que rodee nuestra escena. Una manera sencilla de implementarlo podría ser colocar una esfera lo suficientemente grande que contenga el sol y los planetas, y texturizarla con una imagen de proyección cilíndrica equidisrtante del cielo nocturno. Por ejemplo, esta página contiene materiales muy detallados sobre proyecciones astronómicas, incluidas imágenes adecuadas para texturizar una esfera con el mapa celeste.
PShape stars;
...
void setup() {
...
stars = createShape(SPHERE, 1000);
stars.setTexture(loadImage("startfield.jpg"));
...
}
...
void draw() {
background(0);
shape(stars);
shape(sol);
...
}
Con esta última incorporación, deberíamos obtener un sistema solar bastante convincente con planetas en rotación alrededor del sol con un campo estelar de 360 grados como fondo:
Como un último (e importante) detalle podemos habilitar luces para que los planetas estén sombreados correctamente dependiendo de su orientación hacia el sol. Veamos el efecto de incorporar un único punto de luz centrado en el origen de las coordenadas, que es precisamente la ubicación del sol. Para lograr esto, solo necesitamos una línea de código adicional:
...
shape(sol);
pointLight(255, 255, 255, 0, 0, 0);
pushMatrix();
...
Observemos que la llamada pointLight()
ocurre después de dibujar el objeto PShape del sol, por lo cual el mismo no es afectada por su propia luz. Los resultados son bastante buenos:
Hasta este punto en el tutorial, los usuarios de nuestro bosquejo no tienen la posibilidad de moverse en realidad virtual. En muchos casos, una posición fija en RV es suficiente, pero en este ejemplo en particular podríamos hacer que nuestro sistema planetario sea mucho más inmersivo si los usuarios pudieran navegar y mirar la escena desde diferentes puntos de vista. Hay muchas formas de implementar movimiento en realidad virtual, una opción es permitir que los usuarios seleccionen un planeta con su mirada y luego se transporten al planeta seleccionado cuando efectuan un toque con el gafas de RV.
De esta manera, hay dos pasos en nuestra interacción. El primero consiste en proveer los usuarios una indicación de qué planeta están mirando, y el segundo, efectuar la selección al hacer el toque mientras se mira al planeta.
Processing para Android incluye una serie de funciones para realizar la selección de objetos utilizando "ray-casting" o "proyección de rayos" en RV. En un algoritmo de proyección de rayos, se extiende una línea hacia adelante desde la posición de los ojos del espectador, y se seleccionan los objetos que se cruzan con esa línea. Existen muchos algoritmos de proyección de rayos según el tipo de objetos que uno necesita seleccionar, y Processing incluye la selección de proyección de rayos para cajas y esferas con las funciones interesectsBox()
e interesectsSphere()
. Aunque esto parezca limitado, objetos más complejos siempre pueden ser contendios en una caja o esfera delimitadora, y el cálculo de la intersección con esas geometrías de delimitación suele ser lo suficientemente preciso para la mayoría de las aplicaciones que requiren selección de objetos en RV.
Tanto interesectsBox()
como interesectsSphere()
funcionan de la misma manera: es necesario aplicar todas las transformaciones para posicionar la caja o esfera en la ubicación deseada, y luego llamar a interesectsBox/Sphere con el tamaño del objeto como argumento para obtener el resultado de la intersección. El siguiente código demuestra el uso de esta técnica para resaltar la esfera correspondiente al planeta 1 si el usuario lo está mirando:
pushMatrix();
rotateY(orbitalAngle1);
translate(300, 0, 0);
rotateY(rotationAngle1);
if (intersectsSphere(20, 0, 0)) {
planet1.setTint(color(255, 0, 0));
} else {
planet1.setTint(color(255));
}
shape(planet1);
popMatrix();
La llamada a interesectsSphere()
tiene tres argumentos, el primero es el radio de la esfera que se está evaluando y los otros dos son las coordenadas de la pantalla del punto de inicio del rayo. El valor (0, 0) denota el centro de la pantalla y que corresponde con la posición de ojos del usuario, pero se puede usar cualquier otro valor (x, y) en caso de que el rayo comience desde un punto diferente en la pantalla.
Movimiento en RV hace necesario que se establezca la posición del ojo del usuario de acuerdo a la interacción, animaciones, etc. La biblioteca de RV en Processing incluye una clase llamada VRCamera
que ayuda a implementar dichos cambios en la posición del punto de vista del usuario. La función setPosition()
de VRCamera toma una posición (x, y, z) en coordenadas globales, y coloca el punto de vista exactamente en esa ubicación en RV.
Para usar una VRCamera, solo necesitamos agregar la variable al bosquejo y crearla en setup()
:
void setup() {
fullScreen(VR);
cameraUp();
cam = new VRCamera(this);
cam.setNear(10);
cam.setFar(1500);
...
VRCamera también nos permite establecer los planos cercanos y lejanos, que determinan qué tan cerca o lejos se puede ver en el espacio virtual.
En el bosquejo del sistema solar que discutimos antes, los planetas se mueven a lo largo de sus órbitas alrededor del sol y también giran alrededor de sus propios ejes. Por lo tanto, si quisiéramos colocar la cámara RV en una ubicación fija con respecto a uno de los planetas, necesitaríamos calcular las coordenadas globales de esa ubicación. Para hacer eso, podemos construir una matriz de transformación que encapsule todas las transformaciones en el mismo orden en que se aplican al dibujar el planeta (rotación alrededor del sol/desplazamiento hasta la órbita/rotación alrededor del eje del planeta). Esto lo podemos hacer como sigue:
PMatrix3D mat = new PMatrix3D();
float cx, cy, cz;
mat.rotateY(orbitalAngle1);
mat.translate(300, 0, 0);
mat.rotateY(rotationAngle1);
mat.translate(-2 * 20, 0, 0);
cx = mat.multX(0, 0, 0);
cy = mat.multY(0, 0, 0);
cz = mat.multZ(0, 0, 0);
...
cam.setPosition(cx, cy, cz);
Aplicamos la transformación en la posición (0, 0, 0), que es la ubicación predeterminada de la esfera antes de cualquier transformación subsiguiente. De esta manera, el punto (cx, cy, cz) contiene la ubicación final de la esfera del planeta 1.
Podemos combinar este código de transformación con la selección de proyección de rayos de esfera que discutimos anteriormente, de manera tal que la transformación se aplica sólamente cuando el espectador selecciona la esfera deseada. Nuevamente, el código de transformación que produce (cx, cy, cz) debe colocarse en calculate() para evitar una evaluación duplicada en draw(). Al final, el bosquejo completo se se implementaría de la siguiente manera:
import processing.vr.*;
VRCamera cam;
PShape stars;
PShape sol;
PShape planet1;
PShape planet2;
float orbitalAngle1;
float orbitalAngle2;
float rotationAngle1;
float rotationAngle2;
int followPlanet;
PMatrix3D mat = new PMatrix3D();
float cx, cy, cz;
void setup() {
fullScreen(VR);
cameraUp();
cam = new VRCamera(this);
cam.setNear(10);
cam.setFar(1500);
noStroke();
sphereDetail(40);
stars = createShape(SPHERE, 1000);
stars.setTexture(loadImage("startfield.jpg"));
sol = createShape(SPHERE, 100);
sol.setTexture(loadImage("sol.jpg"));
planet1 = createShape(SPHERE, 20);
planet1.setTexture(loadImage("mercury.jpg"));
planet2 = createShape(SPHERE, 50);
planet2.setTexture(loadImage("earthlike.jpg"));
}
void calculate() {
orbitalAngle1 += 0.002;
orbitalAngle2 += 0.001;
rotationAngle1 += 0.02;
rotationAngle2 += 0.02;
if (0 < followPlanet) {
float d;
float r;
float oa;
float ra;
if (followPlanet == 1) {
d = 300;
r = 20;
oa = orbitalAngle1;
ra = rotationAngle1;
} else {
d = 600;
r = 50;
oa = orbitalAngle2;
ra = rotationAngle2;
}
mat.reset();
mat.rotateY(oa);
mat.translate(d, 0, 0);
mat.rotateY(ra);
mat.translate(-2 * r, 0, 0);
cx = mat.multX(0, 0, 0);
cy = mat.multY(0, 0, 0);
cz = mat.multZ(0, 0, 0);
}
}
void draw() {
background(0);
shape(stars);
if (0 < followPlanet) cam.setPosition(cx, cy, cz);
shape(sol);
if (intersectsSphere(100, 0, 0) && mousePressed) followPlanet = 0;
pointLight(255, 255, 255, 0, 0, 0);
pushMatrix();
rotateY(orbitalAngle1);
translate(300, 0, 0);
rotateY(rotationAngle1);
if (followPlanet != 1 && intersectsSphere(2 * 20, 0, 0)) {
planet1.setTint(color(255, 0, 0));
if (mousePressed) followPlanet = 1;
} else {
planet1.setTint(color(255));
}
shape(planet1);
popMatrix();
pushMatrix();
rotateY(orbitalAngle2);
translate(600, 0, 0);
rotateY(rotationAngle2);
if (followPlanet != 2 && intersectsSphere(2 * 50, 0, 0)) {
planet2.setTint(color(255, 0, 0));
if (mousePressed) followPlanet = 2;
} else {
planet2.setTint(color(255));
}
shape(planet2);
popMatrix();
}
La lógica en la función draw()
es la siguiente: si el planeta aún no está seleccionado, Processing determinará si el rayo del visión desde el ojo del usuario se cruza con la esfera, y si es así, la tiñe de rojo y configura VRCamera para seguir al planeta estableciendo su posición en (cx, cy, cz). Si el usuario selecciona el sol, la posición de la cámara retorna a la posición fija inicial.
El código del bosquejo completo está disponible aquí.