Three.js Tutorial 3 – Texturas, iluminación y transparencias.

Escrito por el 5 Abril, 2013

Seguimos con el tercer tutorial de la serie para Three.js, un framework de javascript exclusivamente dedicado a facilitarnos la vida a la hora de programar nuestros mundos 2D y 3D usando la especificación WebGL que trae el novedoso HTML5. Antes de empezar con él, me gustaría que leyeras antes los tutoriales que dediqué a WebGL que puedes encontrar en este enlace.

En esencia, lo que pretendo conseguir con cada lección de Three.js, es explicar cómo programar el mismo efecto que conseguíamos en uno o varios de los capítulos de aquél tutorial de WebGL. Así se podrán comparar códigos y comprenderemos mejor la gran ayuda que supone usar este framework de WebGL. En este tutorial uniremos lo que vimos en el tutorial 6, 7 y 8 de la serie de WebGL puro, debido a que los cambios no tienen mucha chicha.

El código para iniciar Three.JS, la escena y la cámara es el mismo, asi que en este tutorial omitiré las explicaciones sobre ellos, centrándome en los cambios. Por supuesto, en el código fuente de los ejemplos, se puede ver todo.

Usando texturas en Three.JS

Si tu navegador soporta webGL, este video muestra lo que deberías ver:

Abre este enlace para ver el ejemplo en vivo de Three.JS. Y aquí tienes la imagen GIF para descargarla y probarlo en local. Para poder verlo en acción, tendrás que usar un navegador que soporte webGL, como Mozilla Firefox o Google Chrome.

Con los cursores, podemos mover la caja en todas direcciones. Con AvPag y RePag modificaremos el zoom, y con la tecla F cambiaremos el tipo de filtrado de texturas que se está usando.

Una textura no es más que usar una imagen como “piel” de las figuras, consugiendo así que las caras de las figuras tengan más detalles de las que tendrían usando colores planos. Nada más. Asi que como habrás adivinado, las texturas están relacionadas estrechamente con los materiales. Crear una textura no es más que usar el atributo map de los materiales. Veámos cómo crear una textura básica de la caja para ponérsela a un cubo:

var cuboTextura = new THREE.ImageUtils.loadTexture("img/crate.gif");
var cuboMaterial = new THREE.MeshBasicMaterial({ map:cuboTextura, side:THREE.DoubleSide });
var cuboGeometria = new THREE.CubeGeometry(2.5, 2.5, 2.5);
var cubo = new THREE.Mesh(cuboGeometria, cuboMaterial);

Y ya está. Como veis, Three.JS nos trae utilidades para las imágenes. Entre ellas, nos interesa loadTexture, que como te podrás imaginar, cargará la imagen creando un objeto del tipo Texture. Luego solo tenemos que crear un material usando dicha textura mediante su map, y asignársela al cubo después de crearlo. Más sencillo, imposible.

Filtros de texturas

Una textura de la imagen tiene tres estados, por llamarlos de alguna forma: Que la textura a mostrar tenga el mismo tamaño que la imagen original, que la textura sea más pequeña (y quizás deformada) o que la textura sea más grande (y/o deformada). El primer caso es óptimo, la imagen se mostrará tal cual. Para el segundo caso, la textura mostrará una porción reducida de la imagen, asi que algún algoritmo debe calcular que pixeles usar y cuáles despreciar (obviamente, se perderá información). El tercer problema requerirá que la imagen se estire, asi que el algoritmo deberá inventar información para colorear los nuevos píxeles.

Hay varios algoritmos para reescalar la imagen. Los más usados son tres:

  • Filtro Nearest: En el caso de agrandar la imagen, el algoritmo elegirá el píxel más cercano para colorear los nuevos píxeles. En el caso de minimizar, simplemente descartará al azar una ristra de píxeles de cada X filas o Y columnas (depende del porcentaje que deba reducirse). El algoritmo es el más rápido, pero por contra es el que peor resultados da con texturas con muchos detalles, mostrando un horrible efecto de aliasing. Para ver el efecto de pixelación, abra el ejemplo en vivo, y acerca la caja (tecla AvPag, avanzar página) hasta casi chocar con la cámara. Verá esos cuadraditos.
  • Filtro Linear: El algoritmo del filtro Linear funciona mejor en las ampliaciones porque utilizará una interpolación lineal entre los píxeles de la imagen de la textura original que más cerca se encuentren (es decir, una suave degradación entre dos colores) Por ejemplo, un píxel que se encuentre entre uno blanco y otro negro, se dibujará gris. Ésto hace que la imagen ampliada sea más agradable a la vista, aunque en imagenes muy ampliadas puede notarse un cierto efecto borroso. Puedes ver ésto abriendo el ejemplo en vivo y acercando la cámara, y después pulsar la tecla F para cambiar al filtrado lineal. Pero para ser justos, no es posible hacer ampliaciones perfectas, ningún algoritmo puede inventar detalles que no están en la imagen original.
  • Filtro Mipmaps: Sólo sirve para reducciones. El filtrado mipmap soluciona el problema generando para cada textura una serie de imágenes auxiliares (llamadas niveles mip) a la mitad, a un cuarto, a un octavo, y así sucesivamente, de su tamaño original hasta llegar a una imagen de 1×1. El conjunto de todos esos niveles se llama mipmap. Cada nivel mip es una versión suavemente reducida del anterior nivel por el filtrado lineal. Cuando se debe realizar una reducción, el algoritmo elige a los dos niveles mip más cercanos, por encima y por debajo, y hace una interpolación lineal de ambos seleccionando el pixel intermedio más cercano al tamaño final de la imagen. Así evitamos en gran medida el aliasing.

En Three.JS, estos filtros se llaman THREE.NearestFilter, THREE.LinearFilter y THREE.LinearMipMapNearestFilter. Enseguida veremos cómo usarlos, pero antes veámos cómo detectar las pulsaciones de teclas, para modificar el estado de nuestra escena 3D. Como curiosidad, si cuando creas un material no defines ningún filtro, se usará el MIPMAP para el minimizado y el LINEAR para el estirado por defecto, que es el que mejor resultado da generalmente.

Eventos de teclado

Si eres un veterano de mi blog, verás que he dedicado muchos tutoriales a hablar de cómo detectar las pulsaciones de teclas, asi que describiré rápidamente lo que necesitamos para poner en marcha el ejemplo de arriba. Si te queda alguna duda, busca en mis tutoriales, como éste de space invaders, para saber más.

var TECLA = { AVPAG:false, REPAG:false, ARRIBA:false, ABAJO:false, IZQUIERDA:false, DERECHA:false, F:false };

En esta variable-objeto de javascript, guardaremos qué teclas están pulsadas en un momento dado, asignándoles un booleano a cada una. Por supuesto, inicialmente a false porque no habrá ninguna tecla pulsada.

function webGLStart() {
    log = document.getElementById("log");

    iniciarEscena();
    ultimoTiempo=Date.now();

    document.onkeydown=teclaPulsada;
    document.onkeyup=teclaSoltada;

    animarEscena();
}

En la función que se carga en el onload del body, la que lo inicia todo, meteremos un par de detectores de los eventos onkeydown y onkeyup que nos dirán cuándo se aprieta y cuándo se suelta una tecla en concreto, usando un par de funciones que definiremos a continuación:

function teclaPulsada(e)
{
    switch (e.keyCode)
    {
        case 33: //Av página
            TECLA.AVPAG=true;
            break;
        case 34: // Re página
            TECLA.REPAG=true;
            break;
        case 37: // Izquierda
            TECLA.IZQUIERDA=true;
            break;
        case 39: // Derecha
            TECLA.DERECHA=true;
            break;
        case 38: // Arriba
            TECLA.ARRIBA=true;
            break;
        case 40: // Abajo
            TECLA.ABAJO=true;
            break;
    }

}
function teclaSoltada(e)
{
    switch (e.keyCode)
    {
        case 33: //Av página
            TECLA.AVPAG=false;
            break;
        case 34: // Re página
            TECLA.REPAG=false;
            break;
        case 37: // Izquierda
            TECLA.IZQUIERDA=false;
            break;
        case 39: // Derecha
            TECLA.DERECHA=false;
            break;
        case 38: // Arriba
            TECLA.ARRIBA=false;
            break;
        case 40: // Abajo
            TECLA.ABAJO=false;
            break;
        case 70: // F
            TECLA.F=true;
            break;
    }
}

Lo que hacen es mediante el e.keyCode podemos saber el código numérico asignado a cada tecla (no confundir con el código ASCII). Asi que usando el objeto TECLA anterior, actualizamos su estado según se haya pulsado o soltado la tecla correspondiente. La tecla F tiene una particularidad. No podemos usar onkeydown para true, y onkeyup para false, ya que entonces seguro que no nos dará tiempo a pulsar y soltar F tan rápidamente que coincida su valor TRUE con una sola vuelta del bucle animar, para que sólo cambie una vez el tipo de filtro. Por otor lado, el onkeydown se ejecutará un montón de veces mientras la tecla esté pulsada. El keyup sólo se ejecuta una vez, cuando la suelte. Así que usaré ese evento para poner a TRUE la tecla F, y será el bucle de animar el que la ponga a FALSE de nuevo. Así garantizo que cuando pulse (mejor dicho, cuando suelte) la tecla F sólo se cambie una vez el tipo de filtrado de textura.

Y ya está, en la función de animación de la escena, podemos usar el objeto TECLA para saber si alguna tecla en concreto está pulsada.

¿Cómo se establecen los filtros de la textura? Pues bien, el objeto textura, que nosotros llamamos cuboTextura tiene dos atributos: minFilter y magFilter. El primero sirve para indicar el algoritmo de minimizado de la textura, y el segundo el de estirado.

Inicialmente, la textura, una vez cargada, le pondremos los filstros NEAREST a ambas opciones, ya que es el primer tipo de filtro que queremos probar:

cuboTextura.minFilter = THREE.NearestFilter;
cuboTextura.magFilter = THREE.NearestFilter;

Lo que nos queda por hacer en la función animarEscena, esa que se llama en cada ciclo para dibujar el escenario, es leer las teclas pulsadas y mover las posiciones y rotaciones de las figuras y la cámara, y de actualizar las propiedades de los filtros de la textura.

function animarEscena(){
    var delta=(Date.now()-ultimoTiempo)/1000;
    if (delta>0)
    {
        if (TECLA.ARRIBA) cubo.velocidadX-=2*delta;
        if (TECLA.ABAJO) cubo.velocidadX+=2*delta;
        if (TECLA.IZQUIERDA) cubo.velocidadY-=2*delta;
        if (TECLA.DERECHA) cubo.velocidadY+=2*delta;

        if (TECLA.REPAG) cubo.position.z-=10*delta;
        if (TECLA.AVPAG) cubo.position.z+=10*delta;

        if (TECLA.F)
        {
            filtroActivo=(filtroActivo+1)%3;
            switch (filtroActivo)
            {
                case 0:
                    cuboTextura.minFilter = THREE.NearestFilter;
                    cuboTextura.magFilter = THREE.NearestFilter;
                    log.innerHTML="Filtros Min: Nearest, Mag: Nearest";
                    break;
                case 1:
                    cuboTextura.minFilter = THREE.LinearFilter;
                    cuboTextura.magFilter = THREE.LinearFilter;
                    log.innerHTML="Filtros Min: Linear, Mag: Linear";
                    break;
                case 2:
                    cuboTextura.minFilter = THREE.LinearMipMapNearestFilter;
                    cuboTextura.magFilter = THREE.LinearFilter;
                    log.innerHTML="Filtros Min: MipMap, Mag: Linear";
                    break;
            }
            cuboTextura.needsUpdate = true;
            TECLA.F = false;
        }

        cubo.rotation.x += cubo.velocidadX * delta;
        cubo.rotation.y += cubo.velocidadY * delta;

        renderEscena();
    }
    ultimoTiempo=Date.now();
    requestAnimationFrame(animarEscena);
}

No tiene ninguna complicación. Los cursores modificarán la velocidad del giro del cubo en los dos ejes, usando el tiempo delta para conseguir movimientos suaves, el AvPg y el RePag moverán el cubo en el eje Z, y la tecla F irá alternando entre los tres filtros de texturas que estamos probando. Notar que el filtro MipMap sólo se usa para minFilter, como explicamos antes, y que a diferencia de las figuras, cuando modificamos los parámetros de la textura, hay que indicarle a Three.JS que la textura ha sido modificada y que los datos internos de Three.JS deben actualizarse, mediante la propiedad needsUpdate de cuboTextura.

Y con ésto, ya somos capaces de usar todas las texturas que queramos en nuestras figuras.

Iluminación de ambiente y direccional

En esta primera aproximación a la iluminación, veremos dos tipos de luces: Iluminación de ambiente e iluminación direccional.

La iluminación de ambiente es la luz que iluminará toda las caras que haya en la escena, estén donde estén. No importa, siempre serán iluminadas. Asi pues es recomendable ponerla débil, sólo para que aquellas zonas que no reciben ninguna luz, no se vean totalmente negras.

La iluminación direccional son como los rayos del sol. Los rayos de luz incidirán en nuestra escena según el vector dirección que le indiquemos, y cuando lo hagas, sobre el escenario caerán infinitos rayos de luz, paralelos entre ellos, que “golpearán” la escena en todas partes, aunque siempre con el ángulo que le indicamos. Esto hará que algunas caras se iluminen y otras se oscurezcan.

Ojo, aún no trabajaremos con sombras, asi que la luz puede atravesar las figuras e iluminar a otras figuras que están detrás. Serán las sombra que arroje una figura la que oscurezca a la otra figura, pero eso lo veremos en otro capítulo.

Si tu navegador soporta webGL, este video muestra lo que deberías ver:

Abre este enlace para ver el ejemplo en vivo de Three.JS

¿Cómo funciona la luz? Necesitamos hacer tres cosas: Crear una fuente de luz, meterla en la escena, y usar un tipo de material al que le afecte la luz. Veámos el código del ejemplo:

var luzAmbiente = new THREE.AmbientLight(0x000000);
escena.add(luzAmbiente);

var luzDireccional = new THREE.DirectionalLight(0x000000,1);
luzDireccional.position.set(1,1,1).normalize();
escena.add(luzDireccional);

Primero creamos la luz de ambiente con AmbientLight. Sólo necesita un parámetro, el color de la luz, en formato hexadecimal. Incialmente ponemos el negro, porque cuando se ejecute el código, se leerá el input:text que hemos puesto en el HTML para definir su valor real.

A continuación creamos la luz direccional con DirectionalLight. Ésta acepta dos parámetros. El color en hexadecimal, y su intensidad (entre 0 y 1). También acepta un tercer parámetro, la distancia, pero nosotros no la necesitamos para el ejemplo. Como particularidad, la dirección de la luz se define cambiando su atributo position, aunque quizás lo correcto hubiera sido haber creado un atributo “direction”. Su color real y su position lo definiremos en tiempo real, según también los valores introducidos en los input:text correspondientes. El position, además, debe ser un vector normalizado (su módulo, o longitud, tiene que ser 1). Asi que usamos el método normalize suyo que hace este trabajo por nosotros.

Ahora sólo nos queda hablar del material al que le afecta la luz. Anteriormente usamos materiales tipo MeshBasicMaterial, que ignora la luz. Ahora usaremos el material MeshLambertMaterial, que usará el modelo de luz Lambert como su nombre indica. Hay otros tipos de materiales que también son afectados por la luz, como MeshPhongMaterial, más preciso.

Como nuestro ejemplo tiene un checkbox para desactivar la iluminación, lo que vamos a hacer es crear dos tipos de materiales, y asignárselos al cubo según esté marcado o no el checkbox. Los materiales se crean igual, sólo es cuestión de usar el nombre de la clase que queremos usar:

var materialLuz = new THREE.MeshLambertMaterial({ map:cuboTextura, side:THREE.DoubleSide });
var materialSinLuz= new THREE.MeshBasicMaterial({ map:cuboTextura, side:THREE.DoubleSide });

Luego, en la función que se llama en el onload del body, obtenemos un puntero a los input:text y al checkbox, además de iniciar la escena, detectar los eventos de teclado y animarla por primera vez. Comento lo que no ha cambiado para resaltar lo nuevo:

function webGLStart() {
    chkIluminacion=document.getElementById("lighting");
    txtDireccionX=document.getElementById("lightDirectionX");
    txtDireccionY=document.getElementById("lightDirectionY");
    txtDireccionZ=document.getElementById("lightDirectionZ");
    txtDireccionR=document.getElementById("directionalR");
    txtDireccionG=document.getElementById("directionalG");
    txtDireccionB=document.getElementById("directionalB");

    txtAmbienteR=document.getElementById("ambientR");
    txtAmbienteG=document.getElementById("ambientG");
    txtAmbienteB=document.getElementById("ambientB");

    /*iniciarEscena();
    ultimoTiempo=Date.now();

    document.onkeydown=teclaPulsada;
    document.onkeyup=teclaSoltada;

    animarEscena();*/

}

Y por último, animar escena, que es igual que el anterior, mas el control de la luz. He comentado lo que no nos interesa, para destacar lo nuevo:

function animarEscena(){
    /*var delta=(Date.now()-ultimoTiempo)/1000;
    if (delta>0)
    {
        if (TECLA.ARRIBA) cubo.velocidadX-=2*delta;
        if (TECLA.ABAJO) cubo.velocidadX+=2*delta;
        if (TECLA.IZQUIERDA) cubo.velocidadY-=2*delta;
        if (TECLA.DERECHA) cubo.velocidadY+=2*delta;

        if (TECLA.REPAG) cubo.position.z-=10*delta;
        if (TECLA.AVPAG) cubo.position.z+=10*delta;*/


        if (chkIluminacion.checked)
        {
            cubo.material = materialLuz;

            luzAmbiente.color.setRGB(parseFloat(txtAmbienteR.value),parseFloat(txtAmbienteG.value),parseFloat(txtAmbienteB.value));

            luzDireccional.position.set(parseFloat(txtDireccionX.value),parseFloat(txtDireccionY.value),parseFloat(txtDireccionZ.value)).normalize();
            luzDireccional.color.setRGB(parseFloat(txtDireccionR.value),parseFloat(txtDireccionG.value),parseFloat(txtDireccionB.value));
        }
        else
        {
            cubo.material = materialSinLuz;
        }
        cubo.material.needsUpdate = true;

        /*cubo.rotation.x += cubo.velocidadX * delta;
        cubo.rotation.y += cubo.velocidadY * delta;

        renderEscena();
    }
    ultimoTiempo=Date.now();
    requestAnimationFrame(animarEscena);*/

}

Con un simple IF comprobamos si está marcado o no el checkbox de la iluminación. Si lo está, usamos el material de Lambert, y leemos los input:text donde están definidos los colores y la dirección de las luces. Notar que para no tener que meter el código hexadecimal a mano, Three.JS nos proporciona un método para el atributo color, setRGB, que convertirá un color en formato RGB decimal (de 0 a 1 por cada componente del color) a hexadecimal. Por último, como hemos cambiado las propiedades del material, necesitamos decirle al cubo que lo acutalice para que tenga en cuenta las modificaciones, con needsUpdate sobre el material.

Y vualá, escena iluminada. Ahora te recomiendo que leas la documentación de Three.JS y juegues con los otros tipos de luces, para ver sus diferentes efectos. Así es como realmente se aprende.

Transparencias

Usar materiales transparentes es sencillísimo. Sólo es modificar un par de atributos que tienen los materiales: transparent a true, y opacity a la candidad de alpha (o transparencia) que queramos. 0 es totalmente transparente, y 1 es totalmente opaco.

Veámos el ejemplo:

Abre este enlace para ver el ejemplo en vivo de Three.JS Y aquí tienes la imagen GIF para descargarla y probarlo en local.

Los únicos cambios que hay que hacer son los que he puesto arriba. Creo que todos los materiales aceptan transparencias. Todo el código es exáctamente el mismo. Sólo hemos añadido una línea en el webGLStart, para obtener el checkbox de si usar transparencias o no. El único cambio está en el animarEscena. Igual que antes, dependiendo de si la luz está activada o no, usamos un material u otro. Y ahora también comprobaremos si el checbox de las transparencias está marcado o no, para poner unos valores a los atributos transparent y opacity de los materiales acordes a las circunstancias:

function animarEscena(){
    /*var delta=(Date.now()-ultimoTiempo)/1000;
    if (delta>0)
    {
        if (TECLA.ARRIBA) cubo.velocidadX-=2*delta;
        if (TECLA.ABAJO) cubo.velocidadX+=2*delta;
        if (TECLA.IZQUIERDA) cubo.velocidadY-=2*delta;
        if (TECLA.DERECHA) cubo.velocidadY+=2*delta;

        if (TECLA.REPAG) cubo.position.z-=10*delta;
        if (TECLA.AVPAG) cubo.position.z+=10*delta;

        if (chkIluminacion.checked)
        {
            cubo.material = materialLuz;

            luzAmbiente.color.setRGB(parseFloat(txtAmbienteR.value),parseFloat(txtAmbienteG.value),parseFloat(txtAmbienteB.value));

            luzDireccional.position.set(parseFloat(txtDireccionX.value),parseFloat(txtDireccionY.value),parseFloat(txtDireccionZ.value)).normalize();
            luzDireccional.color.setRGB(parseFloat(txtDireccionR.value),parseFloat(txtDireccionG.value),parseFloat(txtDireccionB.value));
        }
        else
        {
            cubo.material = materialSinLuz;
        }*/

        if (chkBlending.checked)
        {
            cubo.material.transparent = true;
            cubo.material.opacity = parseFloat(txtBlending.value);
        }
        else
        {
            cubo.material.transparent = false;
            cubo.material.opacity = 1.0;
        }
        cubo.material.needsUpdate = true;

        /*cubo.rotation.x += cubo.velocidadX * delta;
        cubo.rotation.y += cubo.velocidadY * delta;

        renderEscena();
    }
    ultimoTiempo=Date.now();
    requestAnimationFrame(animarEscena);*/

}

Si el checkbox está activado, ponemos el transparent a true, y la opacidad a lo que haya en el input:text correspondiente. Si no lo está, transparent a false y opacity a 1. Más sencillo imposible.

Pero no todo es de color de rosas. Misteriosamente, al hacer ésto, la cara derecha del cubo sigue siendo totalmente opaca. Supongo que es un bug que será corregido en una futura versión de Three.JS, pero por si acaso seguiré investigando si es que acaso soy yo el que está cometiendo el error.

Etiquetas: , , , ,

Comentarios (3)

  • Muchas gracias por el trabajo, se agredece 🙂

  • Muchas gracias por tus tutoriales, son de gran ayuda

  • Exelente el tutorial como todos, son realmente de gran ayuda para lograr que más gente se interese en WebGL y que esto llegue a crecer a tal punto que podamos crear realmente aplicaciones y juegos todo desde la web con un gran nivel profesional.

¿Tienes algo que decir?

Gestionado con Wordpress y Stripes Theme Entradas (RSS) | Comentarios (RSS)