WebGL Tutorial 11 – Esferas, matrices de rotación y eventos de ratón

Escrito por el 18 noviembre, 2011

Bienvenido al undécimo tutorial de la serie Aprende webGL. Es una traducción no literal de su respectivo tutorial en Learning webGL. Ésta lección va a ser la primera que no está basada en algún tutorial de OpenGL de NeHe. Aquí veremos cómo mapear una esfera con una textura (es decir, utilizar una sola textura para pintar todas las caras del objeto) con luz direccional. Además haremos que podamos girar alrededor de la esfera utilizando el ratón.

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

Abre este enlace para ver el ejemplo en vivo de webGL, 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. La escena representa una esfera con la textura de la luna, iluminada con una luz direccional que viene desde arriba a la derecha. Si pincha y arrastra desde cualquier lugar del CANVAS, podremos hacer girar la cámara sobre la luna en esa dirección. Y como en un tutorial anterior, puedes cambiar los parámetros que definen la luz ambiental y la luz direccional.

Más información acerca de cómo funciona todo, abajo.

Pero antes, la advertencia habitual: Estas lecciones están pensadas para personas con unos razonables conocimientos de programación previa, pero sin experiencia en gráficos 3D; el objetivo que buscamos es que podáis poner en marcha en poco tiempo vuestras propias páginas web con 3D, con una buena comprensión de lo que está ocurriendo en el código en cada momento. Si no has leído los tutoriales anteriores, deberías hacerlo, pues aquí sólo comentaremos el código que necesitamos para programar las novedades específicas para esta lección. En particular, en esta lección partiremos del código que usamos en el tutorial 7.

Y ahora, pongamonos manos  a la masa. Como viene siendo costumbre, empezaremos viendo la función webGlStart. Ésta vez no veremos nada del HTML, porque es idéntico al del tutorial 7.

  /*function webGLStart() {
    canvas = document.getElementById("lesson11-canvas");
    initGL(canvas);
    initShaders();
    initBuffers();
    initTexture();

    gl.clearColor(0.0, 0.0, 0.0, 1.0);
    gl.enable(gl.DEPTH_TEST);*/


    canvas.onmousedown = handleMouseDown;
    document.onmouseup = handleMouseUp;
    document.onmousemove = handleMouseMove;

    /*tick();
  }*/

Esas tres líneas nuevas son las que detectarán los eventos de ratón, y llamarán a funciones nuestras, que harán que la luna gire sobre su centro. Fíjate de que el evento de presionar el botón derecho del ratón se controla sólo dentro del canvas, pero cuando se suelta o cuando se mueve el puntero del ratón lo detectamos para toda la superficie de la página web. Ésto quiere decir que podremos orbitar sobre la luna si pinchamos dentro del canvas, pero podremos girarla arrastrando el ratón incluso hasta fuera del canvas. Así evitamos el irritante efecto de que se pare el giro si sin querer sacamos el puntero del ratón fuera del canvas mientras arrastramos.

Ahora veámos la función tick, que en esta lección sólo se encargará  de llamarse a sí misma cuando la tarjeta gráfica necesite dibujar el siguiente fotograma, y llamar a drawScene para que dibuje la escena. Ya no tiene necesidad de manejar los eventos de teclado, ni llamar a la función que se encargue de animar la escena, pues no hay animaciones independientes.

  function tick() {
    requestAnimFrame(tick);
    drawScene();
  }

El siguiente cambio pertinente está en drawScene. El primer trozo hace lo de siempre, configurar el canvas, limpiarlo, crear la perspectiva de la línea de visión y a continuación, hacer lo mismo que hacíamos en el tutorial 7 para recuperar la información de la iluminación de los campos del formulario de la página:

  function drawScene() {
    gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    mat4.perspective(45, gl.viewportWidth / gl.viewportHeight, 0.1, 100.0, pMatrix);

    var lighting = document.getElementById("lighting").checked;
    gl.uniform1i(shaderProgram.useLightingUniform, lighting);
    if (lighting) {
      gl.uniform3f(
        shaderProgram.ambientColorUniform,
        parseFloat(document.getElementById("ambientR").value),
        parseFloat(document.getElementById("ambientG").value),
        parseFloat(document.getElementById("ambientB").value)
      );

      var lightingDirection = [
        parseFloat(document.getElementById("lightDirectionX").value),
        parseFloat(document.getElementById("lightDirectionY").value),
        parseFloat(document.getElementById("lightDirectionZ").value)
      ];
      var adjustedLD = vec3.create();
      vec3.normalize(lightingDirection, adjustedLD);
      vec3.scale(adjustedLD, -1);
      gl.uniform3fv(shaderProgram.lightingDirectionUniform, adjustedLD);

      gl.uniform3f(
        shaderProgram.directionalColorUniform,
        parseFloat(document.getElementById("directionalR").value),
        parseFloat(document.getElementById("directionalG").value),
        parseFloat(document.getElementById("directionalB").value)
      );
    }

Después colocamos la posición actual en el lugar que corresponde al centro de la luna:

    mat4.identity(mvMatrix);

    mat4.translate(mvMatrix, [0, 0, -6]);

…y aquí viene el primer cambio que os va a dejar perplejos. Por razones que explicaré más tarde, estamos almacenando la rotación actual de la luna en una matriz moonRotationMatrix, matriz que comienza como matriz de identidad y que luego, cuando el usuario pinche y arrastre sobre la luna, irá reflejando dichas manipulaciones. Por lo tanto, antes de dibujar la luna, tenemos que aplicar la matriz de rotaciones a la matriz modelo-vista, lo cuál se realiza como una multiplicación con mat4.multiply:

    mat4.multiply(mvMatrix, moonRotationMatrix);

Y después, sólo queda dibujar la luna. Este código es bastante estándar, y os debería de ser muy familiar. Sólo estamos definiendo la textura a utilizar, cargamos el buffer con las posiciones de los vértices de la esdera, cargamos el buffer de las coordenas de la textura en cada vértice, cargamos el buffer de los vectores normales de cada vértice para la iluminación, y por último cargamos el buffer de los índices que definían qué vértices hay que dibujar juntos para formar una cara, y pintámos la esfera utilizando triángulos. Todo ello idéntico a lo que hacíamos en el tutorial 7.

    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, moonTexture);
    gl.uniform1i(shaderProgram.samplerUniform, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, moonVertexPositionBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, moonVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, moonVertexTextureCoordBuffer);
    gl.vertexAttribPointer(shaderProgram.textureCoordAttribute, moonVertexTextureCoordBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, moonVertexNormalBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexNormalAttribute, moonVertexNormalBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, moonVertexIndexBuffer);
    setMatrixUniforms();
    gl.drawElements(gl.TRIANGLES, moonVertexIndexBuffer.numItems, gl.UNSIGNED_SHORT, 0);
  }

Asi que la pregunta interesante es ¿cómo podemos crear la posición de cada vértice de una esfera para poder dibujarla mediante triángulos?¿Cómo podemos asignarle las coordenadas de la textura a cada vértice con los valores correctos para que toda la esfera se pinte con una sola textura cuadrada que cubra todas sus caras? ¿Cómo definimos los vectores normales? Muy bien, de todo esto se encargará nuestra función initBuffers.

Como siempre comenzamos con la definición de las variables globales que contendrán los búferes, y definimos unas nuevas variables locales que nos servirán para construir la esfera, que son el número de bandas de latitud y longitud a usar, y el tamaño del radio de la esfera. En una aplicación profesional, ésta información no estaría almacenada de esta forma, si no que habrían creado un objeto “esfera” que contendría toda su información relevante, pero yo para hacerlo simple he optado por traicionar todos los consejos de la programación orientada a objetos.

  var moonVertexPositionBuffer;
  var moonVertexNormalBuffer;
  var moonVertexTextureCoordBuffer;
  var moonVertexIndexBuffer;
  function initBuffers() {
    var latitudeBands = 30;
    var longitudeBands = 30;
    var radius = 2;

Posiblemente te preguntes, ¿qué son las bandas de longitud y latitud? A fin de elaborar un conjunto de triángulos situados de tal forma que se aproximen lo más posible a la forma de una esfera, tenemos que dividirla por zonas. Hay muchas maneras inteligentes de hacerlo, pero lo haré con una técnica que usa la geometría que aprendimos en el instituto, que (a) obtiene un buen resultado, y (b) que cualquiera puede entender sin que le explote la cabeza. Se basa en una de las demos de la página web de Khronos, equipo que originalmente desarrolló webGL, y funciona así:

Comenzemos por la definición de la terminología que usaremos: las líneas de latitud (imagen de la izquierda) son las que, en un globo terráqueo, te dicen cuánto de lejos estás del norte o del sur, es decir, las líneas paralelas al ecuador que cortan en rodajas al globo, como si fuera un tomate. Es evidente que las rodajas que están más cerca del centro, cortadas con el mismo espesor, serían más grandes que las que tiene por encima (hasta llegar a la rodaja central).

Las líneas de longitud (imagen de la derecha), serían como los gajos de una naranja, y dividen la esfera en segmentos.

Ahora, para dibujar nuestra esfera, imagina que hemos trazado las líneas de latitud y longitud a todo su alrededor.  Obteniendo un montón de cuadrados deformados. Lo que queremos obtener en la coordenada exacta en donde las líneas se cruzan, que serán las posiciones de los vértices. Además dibujaremos cada cuadrado (formado por dos líneas adyacentes de latitud y otras dos de longitud) mediante dos triángulos, partiéndolo por una diagonal, como ya hicimos antes. Con lo que tendríamos una esfera dividida en triángulos como la que tenemos en la imagen, que supongo que habrá ayudado a más de uno. La siguiente pregunta es, ¿cómo podemos calcular los puntos donde se cruzan las líneas de latitud y longitud? Vamos a suponer que la esfera tiene un radio de una unidad, y que la cortamos por la mitad pero verticalmente, para poder situar su centro en un plano con los ejes X e Y, como en la imagen de abajo a la derecha.Obviamente la forma del corte es un círculo, y las líneas de latitud son las que vemos que atraviesan ese círculo.En esta imagen tenemos 10 bandas de latitud, y lo que estamos buscando son las coordenadas que están sobre la tercera banda. El ángulo que hay entre el eje Y y la banda lo llamaremos θ. Sabiendo un poco de trigonometría básica, y recordando de que el radio de la esfera es uno, sabemos que la coordenada Y es el coseno de θ, y su coordenada X es el seno de θ.

Ahora vamos a generalizar este concepto para resolver todos los puntos equivalentes en todas las líneas de latitud. Queremos que cada rebanada tenga la misma longitud de “piel” (la parte curvada exterior), es decir, queremos dividir medio círculo en partes iguales sobre las que trazar las latitudes. Sabemos (o deberíamos) que un semicírculo tiene 180º, o lo que es lo mismo, π radianes, asi que si queremos poner 10 rebanadas de latitud, los ángulos θ correspondientes son 0, π/10, 2π/10, 3π/10… hasta 10π/10.

Ahora, todos los puntos para las latitudes, cualquiera que sea su longitud, tienen la misma fórmula para obtener la coordenada Y. Asi que teniendo en cuenta el cálculo que hicimos arriba, podemos decir que todos los puntos alrededor de la esfera (de radio 1) en la latitud n y con 10 líneas de latitud, tendrá la coordenada Y con el valor cos(nπ/10).

Así que con ésto podemos resolver todas las coordenadas Y de la esfera. ¿Pero qué pasa con X y Z? Bueno, si sabemos que la coordenada Y es cos(nπ/10), intuímos que la coordenada X sería sen(nπ/10). Echémos un vistazo a la imagen de la izquierda, donde ésta vez hemos partido la esfera por su ecuador, para poder situarla en un eje de coordenadas X y Z. Como podemos ver, la coordenada X de la línea de longitud donde Z vale 0 equivale a sen(nπ/10). Llamaremos k a éste valor. Si dividimos el círculo con 10 líneas de longitud (las cuales serían como líneas de diámetro), y sabiendo que hay 2π radianes en un círculo, los valores del ángulo φ que utilizaremos para definir las longitudes tendría los valores 0, 2π/10, 4π/10, y así sucesívamente. Y una vez más, por simple trigonometría, sabemos que nuestra coordenada X equivale a k*cos(φ), y nuestra coordenada Z, k*sen(φ).

Por lo tanto, y para generalizar del todo, una esfera con radio r, con m bandas de latitud y n bandas de longitud, podemos generar los valores de X, Y y Z tomando un rango de valores para θ dividiendo π en m partes, y teniendo un rango de valores para φ dividiendo 2π en n partes. Finalmente, calculamos:

  • x = r * sen(θ) * cos(φ)
  • y = r * cos(θ)
  • z = r * sen(θ) * sen(φ)

Con ésto obtenemos los vértices. Ahora, ¿qué pasa con los otros valores que necesitamos para cada punto, como las normales y las coordenadas de la textura? Las normales son algo muy sencillo de calcular. Recordemos que una normal es un vector con una longitud de uno que indica la dirección a la que apunta una línea perpendicular que sale de una cara. Para una esfera con radio de una unidad, es un vector que va de su centro hasta la superficie, es decir, los senos y cosenos del ángulo que queremos hallar para un punto en cuestión, datos que además ya hemos calculado para obtener las coordenadas de los vértices. Así que la forma más sencilla es obtener X, Y y Z sin tener en cuenta el radio de la esfera, usándolo sólo al llenar el buffer de las posiciones de los vértices.

Las coordenadas de la textura son aún más fácil de establecer. Podemos esperar que una imagen para pintar encima de una esfera tenga forma rectangular. Podemos asumir con seguridad de que esa textura se estirará por su parte superior e inferior, siguiendo la proyección de Mercator. Ésto significa que podemos dividir la textura de izquierda a derecha mediante líneas de longitud, y de arriba a abajo mediante las líneas de latitud.

Y así es como creamos una esfera con una textura que la cubre completamente, y que puede ser iluminada. Espero que ahora el código javascript que viene a continuación sea un poco más fácil de entender. Recorremos todos los segmentos latitudinales, donde en cada uno de ellos obtenemos los segmentos longitudinales, y generamos las normales, las coordenadas de la textura, y las posiciones de los vértices. La única rareza es que los bucles terminan cuando el índice es mayor que el número de líneas longitudinales o latitudinales, es decir, usamos “<=” en vez de “<”. Ésto lo hemos hecho así para que la última colección de vértices superponga  a los de la primera, cerrando así la esfera al completo.

    var vertexPositionData = [];
    var normalData = [];
    var textureCoordData = [];
    for (var latNumber = 0; latNumber <= latitudeBands; latNumber++) {
      var theta = latNumber * Math.PI / latitudeBands;
      var sinTheta = Math.sin(theta);
      var cosTheta = Math.cos(theta);

      for (var longNumber = 0; longNumber <= longitudeBands; longNumber++) {
        var phi = longNumber * 2 * Math.PI / longitudeBands;
        var sinPhi = Math.sin(phi);
        var cosPhi = Math.cos(phi);

        var x = cosPhi * sinTheta;
        var y = cosTheta;
        var z = sinPhi * sinTheta;
        var u = 1 - (longNumber / longitudeBands);
        var v = 1 - (latNumber / latitudeBands);

        normalData.push(x);
        normalData.push(y);
        normalData.push(z);
        textureCoordData.push(u);
        textureCoordData.push(v);
        vertexPositionData.push(radius * x);
        vertexPositionData.push(radius * y);
        vertexPositionData.push(radius * z);
      }
    }

Ahora que tenemos los vértices, y la información asociada a cada uno de ellos, nos falta unirlas de alguna forma para dibujar los triángulos, y ésto lo hacíamos usando un índice de elemento, que contendrá secuencias de seis valores que representan un cuadrado expresado mediante un par de triángulos. Aquí está el código:

    var indexData = [];
    for (var latNumber = 0; latNumber < latitudeBands; latNumber++) {
      for (var longNumber = 0; longNumber < longitudeBands; longNumber++) {
        var first = (latNumber * (longitudeBands + 1)) + longNumber;
        var second = first + longitudeBands + 1;
        indexData.push(first);
        indexData.push(second);
        indexData.push(first + 1);

        indexData.push(second);
        indexData.push(second + 1);
        indexData.push(first + 1);
      }
    }

Ésto es realmente muy fácil de entender. Hacemos un doble bucle que recorra los índices de los vértices, sabiendo cómo ha sido llenado el buffer de las posiciones de los vértices.  Y sabemos que, suponiendo que el número de lineas latitudinales y longitudinales sea 10, los vértices de 0 a 9 estarían en la latitud que vale 0, los que van del 10 al 19 estarían en la 1, etc, así hasta los 100 vértices. Y para formar los triángulos que definen cada cuadrado nos fijamos en la imagen de la derecha. Calculamos la posición del primer cruce, y con ella obtenemos de forma inmediata los índices de los otros tres puntos. Así los vamos metiendo en el buffer de índices. Y con ésto, acabamos de programar la parte más difícil.

Inmediatamente por encima de la función initBuffers, tenemos las tres funciones que controlan los eventos de ratón. Empecemos por ver cuidadosamente lo que estamos intentando hacer. Queremos que el espectador de la escena pueda ser capaz de girar sobre la luna pinchando sobre alguna parte del canvas y arrastrando. Ingenuamente podríamos pensar que ésto podríamos hacerlo parecido a como lo hicimos con la caja, es decir, con tres variables que representan los giros sobre los ejes X, Y y Z, ajustando cada uno de ellos según el movimiento del ratón. Por ejemplo, si lo arrastramos hacia arriba o hacia abajo, ajustaríamos la rotación sobre X, y si es de lado a lado, sobre la variable que controle el eje Y. El problema de hacer las cosas de esta forma es que cuando estás rotando un objeto sobre varios ejes, y haces después varias rotaciones más, el orden con el que hay que realizar los giros para obtener un buen resultado es muy importante. Digamos que el espectador gira la luna 90º sobre el eje Y, y a continuación, arrastra el ratón de arriba a abajo. Si hacemos girar la luna sobre su eje X, como parece lógico, lo que se conseguirá es que la luna gire alrededor de lo que ahora es el eje Z (ya que la primera rotación también afectó a los ejes). Y el resultado será muy extraño. El problema, empeora más cuando el espectador gira en dos ejes a la vez, por ejemplo, 10º sobre el eje X y 23º sobre el eje Y…  Se podría programar algo como “dado el stado actual de rotación, si el usuario arrastra hacia abajo, ajusta los tres valores de la rotación adecuadamente” una vez por cada giro que se haga en cada eje. Pero una forma más sencilla de manejar éste problema sería mantener algún tipo de registro de cada giro que el espectador ha aplicado en la luna, para luego repetirlos cada vez quees dibujada. Ésto puede parecer una manera aún más costosa de realizar los giros, a no ser que recordemos que ya tenemos una forma perfecta de representar multitud de transformaciones (rotaciones, en este caso) secuenciales sobre objetos geométricos en el espacio en una sola operación: Usar una matriz.

Mantenemos una matriz para almacenar el estado de la rotación actual de la luna, moonRotationMatrix. Cuando el usuario arrastra el ratón, se obtiene una secuencia de eventos que se producen al mover el ratón, y en cada uno de ellos tenemos que calcular cuántos grados de rotación sobre X e Y actuales se ha producido, mediante la cantidad de arrastre que se haya hecho. A continuación, calculamos una matriz temporal que representa las dos últimas rotaciones, y multiplicamos el moonRotationMatrix por ella (ojo, el orden con el que se multiplican dos matrices importa, primero hay que poner la matriz con la nueva rotación, y después, la matriz por la que multiplicar; el tercer parámetro sirve para decir dónde guardar el resultado, pues si utilizamos el multiplicador de dos parámetros, por defecto se guarda el resultado en el primero de ellos). Y así, tenemos este código:

  var mouseDown = false;
  var lastMouseX = null;
  var lastMouseY = null;

  var moonRotationMatrix = mat4.create();
  mat4.identity(moonRotationMatrix);

  function handleMouseDown(event) {
    mouseDown = true;
    lastMouseX = event.clientX;
    lastMouseY = event.clientY;
  }

  function handleMouseUp(event) {
    mouseDown = false;
  }

  function handleMouseMove(event) {
    if (!mouseDown) {
      return;
    }
    var newX = event.clientX;
    var newY = event.clientY;

    var deltaX = newX - lastMouseX;
    var newRotationMatrix = mat4.create();
    mat4.identity(newRotationMatrix);
    mat4.rotate(newRotationMatrix, degToRad(deltaX / 10), [0, 1, 0]);

    var deltaY = newY - lastMouseY;
    mat4.rotate(newRotationMatrix, degToRad(deltaY / 10), [1, 0, 0]);

    mat4.multiply(newRotationMatrix, moonRotationMatrix, moonRotationMatrix);

    lastMouseX = newX
    lastMouseY = newY;
  }

Y con ésto, se acabaron los cambios relevantes del código. El resto de diferencias, sólo corresponden a cambiar el nombre de las variables, para reflejar el nuevo objeto a modelar, y poco más.

¡Y ésto fue todo! Ahora ya sabeis dibujar esferas mediante triángulos usando un algoritmo relativamente sencillo, pero eficaz. También hemos aprendido a usar los tres eventos del ratón, para que los espectadores puedan manipular los objetos en 3D, y un poco sobre el uso de matrices para representar el estado actual de rotación de un objeto en una escena.

Agradecimientos: Naturalmente agradezco a Giles Thomas, creador de learning webGL, ya que esta serie de tutoriales son traduciones casi literales de sus respectivas lecciones en inglés, que son de lo mejorcito que podemos encontrar para introducirnos en esta muy novedosa tecnología web. Él, a su vez, agradece la ayuda de terceras personas, que por cortesía, voy a reproducir a continuación.

El mapeado de la textura para la luna viene de la página JPL de la NASA. El código para generar la esfera está basado en la versión anterior (ha sido sustituído por el uso de una librería de webgl lamada J3DI) de esta demo de la página de Krhonos, del equipo desarrollador de webGL. ¡Muchas gracias a ambos!

Etiquetas: , ,

Comentarios (2)

  • Wow!!

    Complejísimos cálculos… tendré que repasar mucho más de lo que pensaba. No obstante, he podido lograr generar dentro de mi clase Forma, tanto esferas como cubos, pirámides y planos, mezclando los métodos de mapeado de textura.

    Muchas Gracias nuevamente por estos tutoriales. Buenísimos.
    Saludos.

  • superbien, queria preguntarte, omo hago o donde edito o donde se p escribe ese codigo, me refiero a que programa o como hago para programar eso? me refiero si hau una plataforma como visual, eclipse o algo asi donde uno entra y crea el codigo

¿Tienes algo que decir?

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