WebGL2 - Un triángulo y un cuadrado

Created at: 2023-03-12

Primer tutorial de la serie de artículos dedicados a WebGL en su segunda versión. En este artículo haremos una introducción a los conceptos básicos de este potente api gráfico, y dibujaremos un par de polígonos en la pantalla. Para aprender yo mismo, he usado la documentación oficial del grupo que hay detrás de webgl, khronos, y también he visitado algunas webs hechas por la comunidad, como los impresionantes artículos de webgl2 fundamentals. Como puedes suponer, existe una versión anterior, webGL1, o simplemente WebGL. En la versión anterior de mi blog escribí algunos pequeños tutos para la primera versión, pero he considerado más interesante reescribirlos completamente a la segunda versión, en la que veo que cambian un montón de cosas. Y como recordaba, introducirse en este mundillo sigue teniendo una curva de aprendizaje dolorosa.

Un triángulo y un cuadrado

Este es el resultado de nuestro primer código webgl2.

Aquí tienes el enlace al repositorio con todo el código de esta serie de tutoriales.

El código de este tutorial funciona perfectamente sin necesidad de tener un servidor web detrás. Así que para seguir este tutorial, recomiendo encarecídamente que te lo descargues y juegues con él. Es algo confuso a primera vista, pero espero que pronto empieces a entender cómo funciona.

Varios de los conceptos y estilo de código que voy a usar en estos tutoriales y en los que no me voy a detener o comentaré someramente, fueron explicados previamente en esta serie de artículos sobre el canvas 2D, asi que recomiendo encarecidamente que los leas antes de adentrarte en esta nueva aventura.

Qué es WebGL2

La idea principal de WebGL es proporcionar un api para renderizar gráficos usando la tarjeta gráfica de tu ordenador. HTML5 incorpora un nuevo elemento canvas, que incluye unas cuantas apis de dibujo gráfico según el contexto que uses. En artículos anteriores usamos el contexto 2d, que como decíamos, era ejecutado por la CPU del ordenador. Como sospechas, también existe un contexto webgl2 con una api gráfica diferente y muchísimo más potente, a la par que compleja, que usa la GPU de la tarjeta gráfica. Puedes ver su especificación completa en la web de mozilla.

Lo que esperaba encontrar cuando empecé a interesarme por WebGL era algún api donde pudiera fácilmente dibujar figuras geométricas complejas con pocas instrucciones, al estilo fillRect(x, y, width, height), pero en cuanto empecé a ojear la documentación, me dí de bruces contra la cruda realidad. Webgl tiene más bien un repertorio de instrucciones de dibujo gráfico a muy bajo nivel. Solo puedes dibujar puntos, líneas y triángulos, basados en las coordenadas que previamente le has proporcionado. La responsabilidad de conseguir que el resultado gráfico se parezca a lo que tenías en la cabeza es toda tuya. Esto significa que el encargado de mover y rotar objetos, o aplicar luces, sombras y toda la parafernalia gráfica que se te ocurra, eres tú, sin ayuda directa de esta API. En esta serie de tutoriales mi intención es llegar a entender más o menos cómo se consigue ésto. Si te planteas usar webgl para algún proyecto personal, si ninguna duda te recomendaría usar un framework de alto nivel como Three.js para no tener que estar reinventando la rueda constántemente, y tener todos esos problemas resueltos de una mejor forma por otros desarrolladores más inteligentes que nosotros. Pero yo sentía curiosidad por comprender cómo funciona el chiringuito, y aquí vamos.

Webgl lo componen dos trozos de código completamente diferenciados: uno que corre en la CPU, escrito en javascript, y otro que corre en la GPU, escrito en un lenguaje parecido a C llamado OpenGL Shading Language, o GLSL. Los datos declarados en javascript, como siempre, son almacenadas en la RAM. Los datos que va a usar GLSL, sin embargo, serán almacenados en la VRAM, que es la RAM de tu tarjeta gráfica.

Y ahora comienza la locura, que te obligará a cambiar mucho el chip si vienes de leer alguno de los tutoriales 2d.

Esté código que se ejecuta en la GPU se llama shader, y en realidad son dos scripts diferenciados: El Fragment shader y el Vertex shader. A esta pareja de shaders se le llama programa. Un juego puede tener varios programas a la vez, y ir alternando entre ellos para conseguir diferentes efectos, pero para mantener las cosas sencillas nuestro primer ejemplo solo usaremos uno. De forma muy resumida, podríamos decir que el vertex shader es el código que sirve para calcular la posición (coordenada) final de cada vértice de todos los polígonos a dibujar, a partir de unos valores de entrada. El fragment shader es el encargado de determinar el color de cada píxel que hay entre los vértices que forman la figura que quieres dibujar (uno sólo para puntos, dos para líneas, y tres para triángulos). Por tanto, se ejecuta una monstruosidad de veces, como te estarás imaginando.

Javascript se encargará de crear y gestionar estructuras de datos, que en cada iteración de nuestro bucle principal del juego, se los pasará de alguna forma al programa para que este los mueva desde la RAM a la VRAM para que sean utilizados por el código GLSL de los shaders y acabar finalmente dibujando algo en la pantalla. Concretamente, hay tres formas de transferir esos valores desde javascript a GLSL, que debes conocer para usar el adecuado según tu propósito.

La primera y más importante forma son los bùferes de datos y los atributos, que están íntimamente relacionados. El búfer de datos es siempre un array de una sola dimensión que contendrá valores primitivos, típicamente números flotantes. El atributo representa un puntero a ese búfer, y será la variable que puedes usar en el código GLSL del vertex shader para acceder a los datos contenidos en su búfer. El puntero puede recorrer el búfer índice a índice, pero lo más común es que el búfer se recorra en bloques de dos, tres, o incluso cuatro índices. Podemos usar todos los búferes que queramos, como por ejemplo uno que contenga los vértices del polígono a dibujar, otro con los colores, etcétera. Es importante destacar que si declaro varios búferes (y sus respectivos atributos), el número de bloques de cada búfer debe de ser el mismo, aunque cada búfer puede tener un tamaño de bloque distinto. Por ejemplo, imagina que quiero dibujar una línea en un escenario en 3D. Necesito dos coordenadas con las tres dimensiones, x, y, z. Por ejemplo, P1 = (0, 0, 0) y P2 = (1, 2, 1). El búfer entonces sería [0, 0, 0, 1, 2, 1] con un tamaño de bloque de 3. El vertex shader del programa se iteraría (ejecutaría) dos veces, la primera con el atributo apuntando a [0, 0, 0], y en la segunda, a [1, 2, 1], con la idea de calcular la coordenada final cada vez.

La segunda forma son las variables uniformes, que son declaradas como globales. Es decir, siempre tendrán el mismo valor durante la ejecución de todas las iteraciones del programa. OJO de nuevo, una iteración del programa de webgl, no de tu bucle principal del juego escrito en javascript. Pueden ser usadas tanto en el vertex shader como en el fragment shader.

La tercera forma son las texturas, pero la dejaré para futuros tutoriales.

También es posible, y a menudo imprescindible, pasar variables desde el vertex shader al fragment shader. Este tipo de variable se llaman varying.

Visto cómo podemos enviar datos desde JS a webgl, dibujar cualquier cosa en webgl consistiría en lo siguiente:

  • Selecciono el programa que quiero usar para dibujar.
  • Declaro todos los atributos y variables uniformes que necesitan los shaders del programa escogido.
  • Creo un búfer de datos y lo relaciono con su atributo y su tamaño de bloque.
  • Asigno el valor de cada variable uniforme que necesito.
  • Le pido al webgl que dibuje todo lo que acabo de declarar, indicando el modo (punto, línea o triángulo).

¿Qué hará entonces webgl? Pues bien, el vertex shader se ejecutará tantas veces como bloques tengan nuestros búferes, usando también las posibles variables uniformes que hayas declarado, con el objetivo de calcular la posición final del vértice, junto a posibles varying variables que necesitemos enviarle al vertex shader. A continuación, webgl determinará todos los píxeles que hay en el área que conforman los vértices declarados en el vertex shaders, y ejecutará el fragment shader para cada uno de ellos con el objetivo de determinar el color de dicho pixel, usando todas las posibles variables varying que le haya podido pasar el vertex shader, o globales de tipo uniform. Los más espabilados puede que se pregunten qué valor va a tener la varying cuando llegue al fragment shader, si por ejemplo se ha ejecutado el vertex shader tres veces (con el modo triángulo)... Lo veremos en el segundo tutorial, para no complicar demasiado esta primera aproximación.

Sé que te está empezando a doler la cabeza, pero espero que con el ejemplo, todo empiece a encajar mejor.

Nuestros primeros polígonos de colores

Empecemos por lo básico, el HTML:

<!DOCTYPE html>
<html lang="en" dir="ltr">
    <head>
        <title>WebGL2 01</title>
        <meta charset="utf-8">
        <script type="text/javascript" src="js/Engine.js"></script>
        <script type="text/javascript">
            'use strict'
        
            window.addEventListener('load', () => new Engine(document.querySelector("#canvas")));
        </script>
    </head>
    <body>
        <canvas id="canvas"></canvas>
    </body>
</html>

Es una estructura similar a lo que hemos hecho en otros artículos. Cargamos nuestra clase de js llamado Engine.js, declaramos un elemento canvas, y cuando la página ha terminado de ser cargada por el navegador, instancia nuestro script pasándole el elemento canvas. Este elemento, que en castellano significa lienzo, es una de las novedades de HTML5 y será la zona de la página web donde se pinten los gráficos a través de los nuevos métodos de javascript para el dibujo gráfico en 2D o 3D. Todo el código de configuración del webGL será gestionada por la clase Engine.

Éste es nuestro vertex shader:

#version 300 es

precision highp float;
in vec2 a_position;

void main() {
    gl_Position = vec4(a_position, 0, 1);
}

Y éste, nuestro fragment shader:

#version 300 es

precision highp float;
out vec4 outColor;
uniform vec4 u_color;

void main() {
    outColor = u_color;
}

En nuestro primer ejemplo, el programa no hace ningún cálculo, asi que no estamos aprovechando la potencia de la GPU. Javascript le pasará la coordenada final en el atributo a_position que el vertex shader asigna tal cual, después de transformarlo desde un tipo vec2 a otro vec4. Tiene una variable de salida gl_Position, que no ha sido previamente declarada en ningún sitio. Esto es porque es una variable reservada que obligatoriamente tenemos que calcular, y que como ya dijimos, representa la posición final del vértice en el espacio. Existen más variables reservadas que sirven para distintas cosas. Conforme las usemos en futuros tutoriales iré explicando su utilidad. Todos los atributos tienen ques ser obligatoriamente declarados como tipo in. vec2 es un vector de 2 dimensiones (x, y). Podemos adelantar con esto que el tamaño de bloque en el búfer que va a ver detrás es de 2, y serán las coordenadas en 2D de los vértices de nuestros polígonos. Sin embargo, gl_Position debe de ser de tipo vec4, un vector de 4 dimensiones (x, y, z, w). Cada valor sirve para lo siguiente:

  • x es la dimensión horizontal para indicar la posición que se va a dibujar en la ventana actual, y para que esté dentro de la zona dibujable, debe ser un valor comprendido entre -1 y +1, siendo el 0 el centro.
  • y es lo mismo, indicando la dimensión vertical.
  • z de nuevo lo mismo, indicando la profundidad o distancia a la cámara. Como en este artículo estamos centrados en 2D, seteamos un cero (es decir, está en el centro de la zona visible).
  • w es el tipo de perspectiva a aplicar. De momento vamos a olvidarlo, solo setearemos su valor a 1, que corresponde a la perspectiva ortogonal. Ya hablaremos de ésto en otro capítulo.

El fragment shader es igual de básico. Nuestro código javascript le pasará el color mediante una variable uniforme llamada u_color, asi que GSLS tampoco tiene que calcular nada, se limita a devolver la variable que representa el color del pixel tal cual. Como curiosidad, en el fragment shader no existen variables reservadas; sin embargo, solo podemos tener una variable out de tipo vec4 en el código, que representará el color del píxel a dibujar.

La primera línea de ambos shaders, #version 300 es, no es un comentario. Es la forma de decirle a la GPU que queremos usar una de las versiones más recientes de GLSL, necesaria para webgl2.

El lenguaje GLSL es harto complicado y queda fuera del alcance de mis tutoriales. Intentaré poner siempre el script más sencillo posible que cumpla con el objetivo de tutorial, pero para sacar realmente provecho de la potencia de la GPU habría que mover toda la lógica de cálculo posible a los shaders y exprimir al máximo las posibilidades que nos ofrece. Si acaso estás suficientemente loco, aquí tienes la especificación de la versión actual.

'use strict'
class Engine {
    #ctx;
    
    constructor(canvas) {
        const vertexShaderSource = `#version 300 es
            precision highp float;
            in vec2 a_position;

            void main() {
                gl_Position = vec4(a_position, 0, 1);
            }
        `;
        const fragmentShaderSource = `#version 300 es
            precision highp float;
            out vec4 outColor;
            uniform vec4 u_color;

            void main() {
                outColor = u_color;
            }
        `;
        this.#ctx = canvas.getContext('webgl2');
        if (!this.#ctx) {
            throw 'Your browser does not support webgl2';
        }
        canvas.width = 400;
        canvas.height = 400;
        this.#init(vertexShaderSource, fragmentShaderSource);
    }

    // ...
}

Empecemos por el constructor. Para declarar los shaders, estoy utilizando plantillas, que es una forma de declarar strings con múltiples líneas. Cuando el shader tenga un tamaño importante, probablemente lo mueva a un fichero externo, pero de momento vamos a ir tirando así. El constructor recibe el canvas, y le asignamos un tamaño cuadrado, después veremos por qué. A continuación llamamos al método init para que cree el programa y lo dibuje todo en pantalla:

'use strict'
class Engine {
    // ...

    #init(vertexShaderSource, fragmentShaderSource) {
        const vertexShader = this.#createShader(this.#ctx.VERTEX_SHADER, vertexShaderSource);
        const fragmentShader = this.#createShader(this.#ctx.FRAGMENT_SHADER, fragmentShaderSource);
        const program = this.#createProgram(vertexShader, fragmentShader);
        this.#ctx.linkProgram(program);
        this.#ctx.useProgram(program);
        this.#drawAll(program);
        this.#ctx.deleteProgram(program);
    }

    #createShader(type, source) {
        const shader = this.#ctx.createShader(type);
        this.#ctx.shaderSource(shader, source);
        this.#ctx.compileShader(shader);
        if (!this.#ctx.getShaderParameter(shader, this.#ctx.COMPILE_STATUS)) {
            console.error(this.#ctx.getShaderInfoLog(shader));
            throw 'Error in your shader';
        }
        
        return shader;
    }

    #createProgram(vertexShader, fragmentShader) {
        const program = this.#ctx.createProgram();
        this.#ctx.attachShader(program, vertexShader);
        this.#ctx.attachShader(program, fragmentShader);
        return program;
    }

    //...
}

Los shaders los creamos con createShader indicando su tipo, que puede ser VERTEX_SHADER o FRAGMENT_SHADER, asignamos el código fuente con shaderSource y los compilamos con compileShader. El programa lo creamos con el método del contexto createProgram, con attachShader añadimos cada shader, y terminamos enviándolo a la GPU mediante linkProgram. y asignándo un cursor al programa a usar la próxima vez que usemos una instrucción que intente dibujar algo con useProgram. Por completitud, porque realmente en nuestra demo no hace falta, he usado deleteProgram para sacarlo de la GPU. Esto sería útil en aplicaciones complejas con muchos programas y donde te interesa ir cambiándolos según el estado de tu juego. Por ejemplo, para dibujar el menú principal te interesa usar un programa simple, pero cuando la partida comience, te interesa cargar un programa mucho más complejo.

Como detalle importante, vé acostumbrándote a la idea de marcar cosas como elemento actual. El api está plagado de métodos que sirven para marcar con un cursor un programa, un búfer, un atributo, etcétera, como elemento de referencia, y todas las operaciones posteriores que necesiten ese tipo elemento para funcionar, recurrirán al último indicado por el cursor. Supongo que la idea que hay detrás es optimizar la velocidad de ejecución. En el código anterior, ese elemento es el programa, cuyo cursor fue asignado con useProgram, y que será usado por las siguientes instrucciones de dibujo.

'use strict'
class Engine {
    // ...

    #drawAll(program) {
        this.#ctx.viewport(0, 0, this.#ctx.canvas.width, this.#ctx.canvas.height);
        const positionAttr = this.#ctx.getAttribLocation(program, 'a_position');
        const colorUniform = this.#ctx.getUniformLocation(program, 'u_color');
        this.#clear();
        this.#drawTriangle(positionAttr, colorUniform);
        this.#drawRectangle(positionAttr, colorUniform);
    }

    //...
}
Cuadrado y triángulo

Este es el método que se encargará de ir dibujándo todos los polígonos en pantalla. En la primera línea usamos viewport, para decir a la GPU el tamaño de la cuadrícula en la que tiene que dibujar. Para mantener las cosas fáciles, y no entrar ni en perspectivas, ni en proyecciones, ni demás locuras varias, vamos a tirar por la configuración mínima. Si recuerdas los tutoriales 2d, el vértice superior izquierdo del canvas era la coordenada (0, 0). La x crecía hacia la derecha, y la y hacia abajo, y el tamaño de la pantalla de dibujo visible era exactamente igual al tamaño del canvas, en píxeles. Con webgl2 y con esta configuración mínima, las cosas son distintas. Para webgl, la ventana de dibujo es un cuadrado donde el orígen de coordenadas (0, 0) corresponde exactamente al centro de dicho cuadrado. La x crece a la derecha, como siempre, pero la y crece hacia arriba. Las medidas de ese cuadrado no son píxeles, son unidades, y además tienen un rango muy pequeño. El rango visible va, en las dos coordenadas, desde -1 a +1. Por tanto, el vértice superior izquierdo es el (-1, 1), el vértice superior derecho es (1, 1), el vértice inferior izquierdo es (-1, -1), y el vértice inferior derecho (1, -1). Para dibujar algo en el canvas, antes tenemos que decirle dónde hacerlo, mediante viewport. En este ejemplo, usaremos toda su superficie.

Una pregunta razonable es... si internamente webgl dibuja en un área definida por un cuadrado de -1 a +1, ¿cómo se adapta la superficie del canvas, en este caso un cuadrado de 400X400 definido en el constructor? Pues bien, en todos los casos, webgl se encargará de transformar esa coordenada interna de dibujado en la coordenada final en píxeles, usando una sencilla regla de tres. La coordenada (0, 0) interna, que como dijimos, corresponde al centro, es la coordenada (200, 200) del canvas.

¿Y qué pasa si definimos el viewport como un rectángulo, por ejemplo, de 600 x 400? Pues exactamente igual, pero usando el nuevo tamaño como referencia. La coordenada central ahora estará en (300, 200), y todo lo que dibujemos, tendrá aspecto de estar deformado en el eje horizontal... salvo que corrijamos el problema, como veremos en futuros tutoriales.

Para de momento seguir simplificando el tema, volveremos al canvas cuadrado. La imagen de la izquierda es un ejemplo de cómo sería el eje de coordenadas interno que usa webgl, mostrando también en azul los veŕtices del cuadrado que queremos hacer, y en rojo, los vértices del triángulo.

Después declaramos las estructuras de datos que necesitan nuestros shaders, como son el atributo a_position, mediante el método getAttribLocation, y la variable uniforme u_color con getUniformLocation, y que usaremos para pasarle al fragment shader el color con el que dibujar cada figura.

Y a continuación, limpiamos el canvas y le decimos que dibuje el triángulo y el cuadrado pasándole los punteros hacia los atributos y variables uniformes que cada polígono necesita usar.

'use strict'
class Engine {
    // ...

    #clear() {
        this.#ctx.clearColor(0, 0, 0, 1);
        this.#ctx.clear(this.#ctx.COLOR_BUFFER_BIT);
    }
}

La acción de limpiar el canvas no tiene mucho misterio. Nos basta con indicar el color de fondo mediante clearColor. Este método requiere de cuatro parámetros normalizados (es decir, valores entre 0 a 1). A poco que tengas nociones de teoría de color sabrás que esos valores se corresponden al formato estándar RGBA, rojo, verde, azul, alfa (transparencia). Y con clear, le decimos a webgl que lo limpie todo usando el color de fondo. Este método acepta ciertos flags. Nosotros estamos indicando COLOR_BUFFER_BIT, es decir, todos los colores almacenados en el búfer interno. Pero también aceptaría otra constante DEPTH_BUFFER_BIT, para decirle que vacíe el búfer de profundidad, que no estamos usando en este artículo. Como nota, si queremos vaciar los dos, se haría empalmándolos con un OR binario COLOR_BUFFER_BIT | DEPTH_BUFFER_BIT, un recurso muy típico para enviar combinaciones de muchas opciones distintas flags usando solo un número entero, aprovechando que si cada opción es un número entero distinto (obligatoriamente el 1 o una potencia de 2), con dicho número final y otra operación binaria es muy fácil saber si cada una de las opciones está activada o no ((SELECTED_FLAGS & SOME_FLAG) == SOME_FLAG).

'use strict'
class Engine {
    // ...

    #drawTriangle(positionAttr, colorUniform) {
        const blockSize = 2;
        const coords = new Float32Array([
            0, 0,
            0, 0.5,
            0.7, 0
        ]);
        const totalBlocks = coords.length / blockSize;
        const color = [1, 0, 0, 1];
        this.#ctx.enableVertexAttribArray(positionAttr);
        this.#ctx.bindBuffer(this.#ctx.ARRAY_BUFFER, this.#ctx.createBuffer());
        this.#ctx.bufferData(this.#ctx.ARRAY_BUFFER, coords, this.#ctx.STATIC_DRAW);
        this.#ctx.vertexAttribPointer(positionAttr, blockSize, this.#ctx.FLOAT, false, 0, 0);
        this.#ctx.uniform4fv(colorUniform, color);
        this.#ctx.drawArrays(this.#ctx.TRIANGLES, 0, totalBlocks);
        this.#ctx.disableVertexAttribArray(positionAttr);
    }

}

Y por fin, y en todo su esplendor, el código necesario para dibujar un triángulo rojo en pantalla. Para empezar, tenemos que indicar qué atributos queremos enviar al programa. En nuestro ejemplo, será positionAttr que seŕa el puntero al búfer de datos con los vértices del triángulo a dibujar. Por completitud también lo liberamos después, con disableVertexAttribArray, pero en nuestro ejemplo, con un solo programa que no vamos a cambiar, no es realmente necesario. Además, activo y desactivo dicho atributo en cada método que se va a encargar de dibujar cada polígono, pero realmente no es necesario tampoco hacerlo aquí, bastaría con activarlas justo después de asignar el programa, una sola vez.

A continuación creamos el búfer donde almacenaremos las coordenadas. Insisto de nuevo en que el tamaño visible de nuestra ventana webgl va de -1, -1 a 1, 1, y las coordenadas del triángulo están basadas en ello. No vamos a usar el eje Z de momento (el vertex shader lo pondrá siempre a cero), por tanto, los vértices 2D a declarar son: (0, 0), (0, 0.5) y (0.7, 0). Si estás intentando dibujarlo mentalmente, espero que te hayas dado cuenta de que sigue el sentido horario, es decir, en el sentido en el que giran las agujas de un reloj. En este ejemplo da un poco igual, pero te recomiendo que siempre declares los vértices de todos los polígonos siguiendo este orden, para que cuando tengas cálculos matemáticos complejos (relacionados con texturas, luces y/o sombras), el resultado sea el esperado cuando se use el mismo programa para dibujar cada figura.

El búfer contendrá los vértices del triángulo que quiero dibujar, en 2D, asi que sé guardará un array de 6 posiciones. Cada bloque será un vértice, que sé que tiene un tamaño de 2. El array debe se una instancia de algún array tipado, Float32Array en nuestro ejemplo. Con createBuffer creamos lo creamos, con bindBuffer lo marcamos como el búfer actual (misma idea que expliqué acerca del programa), por lo que será el búfer que se use en las instrucciones posteriores.

Con bufferData mandamos a la memoria de la GPU nuestro array de vértices. Este método necesita que le digas el tipo de búfer, y además necesita que le indiques algunos flags de optimización que quieres que se ejecuten. Aquí puedes ver los tipos de búferes que trae webgl2, pero los dos que más simples son ARRAY_BUFFER y ELEMENT_ARRAY_BUFFER.

El primero sirve para enviar datos, como coordenadas, colores, texturas, coordenadas de posibles fuentes de luz, o en resumen, cualquier información necesaria para calcular las posiciones finales de los vértices de tu polígono y su color. Como comenté anteriormente, el tamaño de bloque no importa, pero todos los búferes de este tipo que mandes tienen que tener el mismo número de bloques. En cada iteración del vertex shader, tendrás un atributo con el valor del bloque de cada búfer para que puedas trabajar con ellos y determinar la coordenada a dibujar. El segundo tipo, ELEMENT_ARRAY_BUFFER, sirve para modificar el orden en el que webgl leerá los bloques, y sólo puede ser definido unna única vez. Como su nombre indica, contendrá la posición del bloque a leer en cada iteración del vertex shader, en lugar de hacerlo secuencialmente como hace webgl por defecto. ¿Para qué sirve esto? Pues a menudo vamos a tener bloques con los mismos valores. Por ejemplo, en polígonos 3D con muchas caras, todos los vértices van a estar repetidos en varias de las caras que lo forman. Si usamos un búfer de índices, solo lo enviaríamos una sola vez. En futuros tutoriales sacaramos provecho de él.

En cuanto STATIC_DRAW, es el tipo de optimización mas simple. Con él estamos diciendo a webgl que guarde el búfer en memoria para que si ejecutamos de nuevo el programa, no haga falta enviárselo de nuevo. En este tutorial es irrelevante, porque solo dibujaremos el escenario una sola vez.

Depués indicamos con vertexAttribPointer el tipo de relación que existe entre el atributo y el búfer actual, con un tamaño del bloque de 2 (debe ser un valor entre 1 y 4), y el tipo de dato que contiene cada ítem del array ,FLOAT. Los tres parámetros restantes sirven para forzar el normalizado del dato, y el stride y el offset a usar, pero por sencillez usemos los valores por defecto false, 0 y 0 y dejémoslos por el momento como brujería.

A continuación hacemos que la variable uniform apunte al color con el que queremos dibujar el triángulo. Si recuerdas, esta variable se usaba en el fragment shader, y era de tipo vec4, asi que para enviarla a la memoria de la GPU usaremos uniform4fv. Por supuesto, existen métodos distintos para tipos distintos. En cuanto al color en sí, supongo que te habrás dado cuenta de que tenemos otra vez el formato RGBA, y que corresponde a un rojo sin transparencia.

Y por fín, la instrucción que le dice a webgl que dibuje en pantalla todo lo que tenga en memoria, drawArrays. Este método necesita que indicas el modo de rellenar los huecos. Estamos indicando el modo TRIANGLES, pues con webgl, todos los polígonos SIEMPRE se dibujarán con triángulos. Este modo servirá para determinar todos los píxeles afectados por lo que se está dibujando, y por tanto, llamar al fragment shader de nuestro programa por cada uno de esos píxeles. En el ejemplo, todos los píxeles que queden dentro del triángulo que acabamos de definir. También necesitamos indicar el offset y la cantidad de bloques a dibujar. Queremos dibujarlos todos, así que el offset es 0, y el número de total de bloques, que en mi caso es dibujar un triángulo, es de 3, o de forma mas genérica, coords.length / blockSize.

Acerca de los modos de relleno, tenemos estas opciones:

  • POINTS: Dibujará un punto usando sólo un vértice.
  • LINES: Dibujará una línea nueva por cada par de vértices del búfer. Es decir, si tenemos vértices A, B, C y D, dibujará dos líneas A-B y C-D.
  • LINE_STRIP: Dibujará una línea uniendo todos los vértices del búfer. Con el ejemplo anterior, una sola línea que recorre A-B-C-D.
  • LINE_LOOP: Igual que el anterior, pero también enlazará el último vértice con el primero.
  • TRIANGLES: Dibujará un triángulo nuevo por cada tres vértices que contenga el búfer.
  • TRIANGLE_STRIP: Dibujará una cadena de triángulos, siendo el último vértice del triángulo que se está dibujando, el primer vértice del siguiente triángulo.
  • TRIANGLE_FAN: Dibujará otra cadena de triángulos, pero esta vez el primer vértice definido será siempre el primer vértice de todos los triángulos, por lo que tendría un aspecto de estar dibujando un ventilador, de ahí lo de fan.

Todos los modos ejecutan el vertex shader la misma cantidad de veces, que corresponde al total de bloques declarados en los búferes. Vuelvo a insistir, lo que hace el vertex shader es usar toda esa información, para entre otras cosas, determinar la posición final del vértice a dibujar. Lo que cambia de forma drástica, es las veces que ejecuta el fragment shader. En el caso de points, se ejecutará las mismas veces que el fragment shader, es decir, una por bloque. Cualquiera de los modos de línea, ejecutará tantas veces como píxeles forman la línea definida, y los de triángulos, como dije anteriormente, tropecientas mil veces por cada píxel que esté dentro del área del triángulo recién definido.

'use strict'
class Engine {
    // ...

    #drawRectangle(positionAttr, colorUniform) {
        const blockSize = 2;
        const coords = new Float32Array([
            -0.8, -0.8,
            -0.8, -0.3,
            -0.3, -0.3,
            -0.8, -0.8,
            -0.3, -0.8
        ]);
        const totalBlocks = coords.length / blockSize;
        const color = [1, 1, 0, 1];
        this.#ctx.enableVertexAttribArray(positionAttr);
        this.#ctx.bindBuffer(this.#ctx.ARRAY_BUFFER, this.#ctx.createBuffer());
        this.#ctx.bufferData(this.#ctx.ARRAY_BUFFER, coords, this.#ctx.STATIC_DRAW);
        this.#ctx.vertexAttribPointer(positionAttr, blockSize, this.#ctx.FLOAT, false, 0, 0);
        this.#ctx.uniform4fv(colorUniform, color);
        this.#ctx.drawArrays(this.#ctx.TRIANGLE_STRIP, 0, totalBlocks);
        this.#ctx.disableVertexAttribArray(positionAttr);
    }
}

Y a continuación, hacemos exactamente lo mismo para dibujar el cuadrado.

Acabo de explicar que webgl solo puede dibujar puntos, líneas o triángulos. ¿Cómo dibujamos entonces un cuadrado? Fácil, mediante dos triángulos con uno de los lados en contacto directo, que corresponderá con la diagonal. Sabiendo que con TRIANGLE_STRIP el último vértice del triángulo anterior será el primero del siguiente, declaro mi array de coordenadas de forma lógica y vualá, un cuadrado amarillo aparecerá por arte de magia.

Y ésto es todo de momento, te recomiendo encarecidamente que te descargues el ejemplo, toquetees el código del tutorial y que intentes conseguir diferentes resultados... toca los vértices, cambia el color de las figuras, prueba a añadir más vértices, juega con los modos de dibujado... ¡A ver qué te sale!