Tutorial WebGL 13 – Iluminación por fragmento y múltiples “programas”

Escrito por el 23 noviembre, 2011

Bienvenido al decimotercer tutorial de la serie Aprende webGL. Es una traducción no literal de su respectivo tutorial en Learning webGL. En ella, veremos cómo simular una iluminación por fragmento, lo que le supone a la tarjeta gráfica una carga de trabajo adicional importante, pero consigue resultados mucho más realistas. También veremos cómo cambiar los shaders en mitad de una ejecución, mediante el uso de un poco de código para sustituir el programa shader por otro cuando nos interese.

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 una y aquí otra de las texturas utilizadas, para descargarlas 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. Se ve la luna y una caja gigante orbitando en el espacio (se verán blancos hasta que la textura se cargue completamente). Ambos están iluminados por un punto de luz situado entre ellos, pero ésta vez se incluye un checbox para activar o no la iluminacion por fragmento (o por píxeles) y el uso de texturas, para que sea fácil ver la diferencia entre un tipo de iluminación u otro. Si quieres puedes cambiar la posición de dicha luz, su color, etc, mediante el formulario que hay debajo del canvas.

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 12.

Vamos a comenzar describiendo exactamente por qué merece la pena hacer que el procesador gráfico trabaje más por usar iluminación por fragmento. Quizás recuerdes la imagen de la izquierda, que usamos en el tutorial 7 (la primera elcción en donde hablábamos de iluminación). Como debes saber, el brillo de una superficie se determina por el ángulo entre la normal y los rayos incidentes que llegan desde la fuente de luz. Ahora, hasta el momento, nuestra iluminación se ha calculado siempre en el vertex shader mediante la combinación del vector normal asociado a cada vértice con la dirección de la iluminación, que dependía del tipo de luz que estábamos usando. Con ésto, el vertex shader le proporcionaba al fragment shader una variable varying con un peso (o intensidad) de la luz que tiene cada vértice, y luego el fragment shader utilizaba la interpolación lineal para ir repartiendo gradualmente los pesos de la luz de cada vértice para cada uno de los píxeles que se encuentran entre esos vértices de la cara que estamos dibujando. Volviendo a la imagen, vemos que el punto B será muy brillante porque recibe los rayos casi de forma paralela a su vector normal, pero A brillará sensiblemente menos, porque los rayos llegan con un ángulo mucho mayor. Así que la parte de abajo de dicha cara tendrá un brillo mayor, y conforme nos vamos acercando a su parte superior, el brillo se irá apagando gradualmente hasta alcancanzar el nivel de brillo del vértice A. Es un efecto de luz que queda realista.

Pero ahora imagina que la luz está un poco más arriba, más o menos a la altura del centro de la cara, como pasa en la imagen de la derecha. Como antes, el brillo de la luz será mínimo en los puntos A y C, ya que la luz les llega con el ángulo más grande posible. Como estamos calculando el brillo de una cara sólo en los vértices, el resultado que se consigue con la interpolación lineal es que toda esa cara tenga el mismo brillo que existe en A y C, lo cual es tremendamente falso, ya que como podemos suponer, el punto B debería brillar mucho más que A y C, ya que los rayos de luz inciden casi completamente paralelos a su vector normal. Por lo tanto, para usar la iluminación por fragmentos es evidente que tenemos que realizar cálculos por separado para todos y cada uno de los fragmentos (píxeles) que componen las caras de los objetos.

Calcular la iluminación de cada fragmento significa que para cada uno necesitamos obtener su ubicación (para determinar la dirección con la que nos llega la luz) y su vector normal; podemos obtener éstos datos pasándoselos desde el vertex shader. Ambos estarán interpolados linealmente, pero como las normales de todos los vértices son iguales, las normales obtenidas en cada fragmento tienen un resultado correcto. Piensa que todos los vértices y píxeles de una cara tienen el mismo vector normal. En cuanto a la posición, la interpolación lineal da también un valor exacto.

Por lo tanto, ésto explica por qué el cubo se ve de forma más realista con la iluminación por fragmento. Pero hay otro beneficio, y es que también mejora el aspecto de las superficies planas que intentan aproximarse a superficies curvas, como pasa en la esfera. Si las normales son diferentes, la interpolación lineal sirve para obtener las normales intermedias, consiguiendo un mejor efecto de superficie curva, consiguiendo lo que se conoce como sombreado de Phong. En la imagen que acompaña a dicho artículo se puede ver claramente la diferencia entre los métodos de iluminación por vértice y por fragmento. En nuesto ejemplo en vivo se puede observar la diferencia en los bordes de la luz sobre la esfera, que con la iluminación por vértice forma una silueta irregular, y con la iluminación por fragmento redondea.

De acuerdo, con ésto ya hemos visto toda la teoría. Vamos a empezar a tocar el código. Los shaders se encuentran en la parte superior del archivo, por lo que comenzaremos con ellos. Debido a que este ejemplo permite elegir entre utilizar la iluminación por fragmento o no utilizarla, en cuyo caso se utilizaría la iluminación por vértices que vimos en el tutorial anterior, tenemos que crear dos vertex shader y dos fragment shader, uno de ellos para cada algoritmo de iluminación. Se podría haber hecho que nuestros viejos shaders hicieran las dos cosas, pero el código se hubiera complicado bastante haciéndolo más difícil de leer.

La forma en la que cambian entre ellos la explicaremos más adelante; de momento basta con señalar que los distinguimos mediante el uso de diferentes ID’s de sus respectivas etiquetas <script>. Los primeros dos, son los mismos shaders que usamos en la lección anterior, por lo que sólo mostramos las etiquetas que muestran los ID’s que les hemos puesto, para poder ser leídas en el javascript:

<script id="per-vertex-lighting-fs" type="x-shader/x-fragment">

<script id="per-vertex-lighting-vs" type="x-shader/x-vertex">

A continuación, tenemos el nuevo fragment shader de la iluminación por fragmento:

<script id="per-fragment-lighting-fs" type="x-shader/x-fragment">
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vTextureCoord;
  varying vec3 vTransformedNormal;
  varying vec4 vPosition;

  uniform bool uUseLighting;
  uniform bool uUseTextures;

  uniform vec3 uAmbientColor;

  uniform vec3 uPointLightingLocation;
  uniform vec3 uPointLightingColor;

  uniform sampler2D uSampler;

  void main(void) {
    vec3 lightWeighting;
    if (!uUseLighting) {
      lightWeighting = vec3(1.0, 1.0, 1.0);
    } else {
      vec3 lightDirection = normalize(uPointLightingLocation - vPosition.xyz);

      float directionalLightWeighting = max(dot(normalize(vTransformedNormal), lightDirection), 0.0);
      lightWeighting = uAmbientColor + uPointLightingColor * directionalLightWeighting;
    }

    vec4 fragmentColor;
    if (uUseTextures) {
      fragmentColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
    } else {
      fragmentColor = vec4(1.0, 1.0, 1.0, 1.0);
    }
    gl_FragColor = vec4(fragmentColor.rgb * lightWeighting, fragmentColor.a);
  }
</script>

Se puede ver que tiene un aspecto muy similar a los vertex shader que hemos estado usando hasta ahora; está haciendo los mismos cálculos para determinar la dirección de la luz y combinarlo luego con la normal para obtener la cantidad de luz ponderada para ese fragmento. Las diferencias consisten en que los valores de entrada provienen de variables varying, en vez de como atributos asociados al vértice, y que dicho peso del color obtenido es usado de inmedianto, pues ya no tenemos que “empujarla” al otro shader. Es también digno de mencion la necesidad de normalizar el vector normal que se obtenido de la implícita interpolación lineal (normalización que como recordarás, consiste poner su módulo, o longitud, a uno), pues esta operación automática lo suele desnormalizar.

Y ya que casi todo el trabajo pesado lo hace el nuevo fragment shader, su vertex shader correspondiente es muy sencillo:

<script id="per-fragment-lighting-vs" type="x-shader/x-vertex">
  attribute vec3 aVertexPosition;
  attribute vec3 aVertexNormal;
  attribute vec2 aTextureCoord;

  uniform mat4 uMVMatrix;
  uniform mat4 uPMatrix;
  uniform mat3 uNMatrix;

  varying vec2 vTextureCoord;
  varying vec3 vTransformedNormal;
  varying vec4 vPosition;

  void main(void) {
    vPosition = uMVMatrix * vec4(aVertexPosition, 1.0);
    gl_Position = uPMatrix * vPosition;
    vTextureCoord = aTextureCoord;
    vTransformedNormal = uNMatrix * aVertexNormal;
  }
</script>

Sólo resolvemos la posición del vértice después de aplicar la matriz de modelo-vista y multiplicar las normales por la matriz normal, pero esta vez acumulando los resultados en variables varying para que sean mandadas al fragment shader.

Y eso es todo para los shaders. El resto del código te será bastante familiar a lo que vimos en lecciones anteriores, pero con una excepción. Hasta ahora, sólo habíamos utilizado un vertex shader y un fragment shader, que componen un programa shader. Y dicho programa shader, es lo que utiliza la tarjeta gráfica para realizar las operaciones. Ahora, tenemos un nuevo programa shader con su vertex y fragment shader correspondiente, que resuelven la iluminación por fragmento. Sin embargo, la tarjeta gráfica sólo puede tener en memoria un programa shader a la vez. ¿Qué hacemos? Pues sencillamente, según esté marcada o no la casilla de iluminación por fragmento en el formulario de la página, cargamos en la tarjeta uno u otro programa shader.

Para haecrlo, nuestra función initShader tiene ahora este aspecto:

  var currentProgram;
  var perVertexProgram;
  var perFragmentProgram;
  function initShaders() {
    perVertexProgram = createProgram("per-vertex-lighting-fs", "per-vertex-lighting-vs");
    perFragmentProgram = createProgram("per-fragment-lighting-fs", "per-fragment-lighting-vs");
  }

Así que ahora tenemos dos programas shaders independientes en variables globales, uno para la iluminación por vértices y otro para la iluminación por fragmento. También tenemos una variable global currentProgram, que es un puntero que apunta al programa shader que actualmente tenemos en uso. La nueva función createProgram simplemente contiene todo el código que ahora le falta initShaders, pero parametrizado para que devuelva un objeto programa con todos los atributos inicializados. Es un cambio tan sencillo que no voy a incluirlo aquí, basta con mirarlo en el código fuente.

A continuación, en drawScene elegimos uno u otro programa shader con el que dibujar la escena para ser cargado en la tarjeta gráfica, dependiendo de si el checkbox está marcado o no:

  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 perFragmentLighting = document.getElementById("per-fragment").checked;
    if (perFragmentLighting) {
      currentProgram = perFragmentProgram;
    } else {
      currentProgram = perVertexProgram;
    }
    gl.useProgram(currentProgram);

Como también podemos elegir si usar o no la iluminación, tenemos que indicárselo al programa actual:

    var lighting = document.getElementById("lighting").checked;
    gl.uniform1i(currentProgram.useLightingUniform, lighting);

Así que básicamente lo que estamos haciendo es dar la posibilidad de cambiar de programa justo antes de comenzar a pintar un frame cualquiera de la escena. Quizás te esté preguntando, ¿se puede cambiar de programa shader dentro de drawScena, para dibujar una misma escena? La respuesta es que sí, se puede. En este ejemplo no lo hemos necesitado, pero es perfectamente posible e incluso puede ser muy útil dependiendo de lo que se quiera hacer. Imagina que tal vez tengas cientos de cuadrados y esferas en tu escena. Como las esferas consiguen un buen aspecto con la iluminación por vértice, iluminas las esferas con ese algoritmo, que es más rápido. Y luego dibujes los cubos, pero utilizando el programa shader con la iluminación por fragmentos. Así conseguiríamos mejorar bastante el rendimiento; nuestra escena se dibujaría más rápido que calculandolo tódo con la iluminación por fragmento, y apenas habría diferencias en el aspecto final.

¡Y hemos acabado la lección! Ahora ya sabes cómo utilizar varios programas shader, y cómo programar una artesanal iluminación por fragmento, también conocida como iluminación por píxel.

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.

Como antes, 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 (1)

  • Están geniales tus tutoriales, gracias

¿Tienes algo que decir?

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