Exploración de WebGL - Gráficos 3D en la Web

Informe Final de Pasantía

Mauricio Meza Burbano

Estudiante de Ingeniería de Sistemas - Universidad Nacional de Colombia

Jean Pierre Charalambos

Profesor tutor

La siguiente página presenta el informe final de mi exploración con la API WebGL (Web Graphics Library), esta tecnología fue lanzada en el 2012 y es utilizada por una gran cantidad de páginas y aplicaciones web para dibujar gráficos 3D interactivos en el navegador sin necesidad de usar plug-ins, extensiones o programas externos. Durante mi pasantía en la empresa Newrona he utilizado esta tecnología para crear páginas web con componentes 3D haciendo uso de librerías de código abierto como Three.js y P5.js, estas librerías facilitan el desarrollo con WebGL abstrayendo las partes más complejas y verbosas de la API con componentes comunes del mundo digital 3D como luces, cámaras, materiales, escenas y muchos otros más.

Gracias a estas librerías, dentro de mi pasantía no he encontrado una necesidad inmediata de adentrarme directamente en código de WebGL o manejar Shaders en lenguaje GLSL. Sin embargo, para entender a fondo esta tecnología, su funcionamiento, la mejor manera de usarla y desarrollar nuevas herramientas que faciliten su uso, es necesario comprender el código que corre “por detrás” y explorar la manera como WebGL realiza el proceso de renderizado desde el nivel más bajo, pixel por pixel. Este acercamiento utiliza como principal guia la serie de articulos / curso WebGL Fundamentals creado por Gregg Tavares, gran parte del código que se presenta a continuación proviene de esta guia y ha sido posteriormente modificado utilizando otros libros y referencias que se presentan al final, debido a que los artículos no tienen una versión en español espero que el contenido de esta página sea una introducción rápida a los temas que se presentan en él.

El codigo que genera los ejemplos puede ser encontrado y explorado en el siguiente repositorio, es posible que los ejemplos tengan problemas cargando en dispositivos moviles. Por esto recomiendo utilizar dispositivos de escritorio como PCs y Laptops.


¿Que es WebGL?

WebGL es una librería de rasterizado que a nivel más simple está diseñada para dibujar líneas, puntos y triángulos sobre un Canvas de HTML, muchas aplicaciones incluso ignoran todas sus funcionalidades de renderización 3D y lo utilizan solamente para generar y manipular gráficos en 2D, es por esto que todas las abstracciones comunes de los software de manipulación y renderización 3D como Objetos, Cámaras, Materiales, Luces y muchos otros de estos conceptos deben desarrollarse prácticamente desde cero con las herramientas que WebGL provee, esto se realiza al desglosar cada uno de estos componentes de un ambiente 3D en sus conceptos matemáticos más básicos como Vectores, Matrices y Escalares. Al reducir la generación de gráficos a estos conceptos y operaciones del álgebra lineal, se convierte el proceso de renderización en instrucciones fácilmente computables y paralelizables de manera que puedan ser optimamente ejecutadas por la GPU (Unidad de Procesamiento de Graficos).


¿Qué es un Shader?

Los Shaders son programas que se ejecutan directamente en la GPU y son escritos en un lenguaje similar a C llamado GLSL, casi siempre se definen 2 tipos de shaders: El Vertex Shader que es ejecutado por cada vértice definido en la escena 3D, este en general se usa para tomar el vértice definido como un vector de tres dimensiones [X,Y,Z] y proyectarlo dentro del espacio 2D de la pantalla [X,Y]. Así mismo tenemos El Fragment Shader el cual se ejecuta por cada pixel en la pantalla y generalmente se define como un vector de 4 dimensiones [R,G,B,A], que asigna el color con el que se debe pintar ese pixel en específico.

Librerías como P5.js y Three.js emplean dentro de sus APIs métodos que simplifican los procesos de configuración de WebGL y autogeneran shaders a partir de inputs del usuario que presentan y modifican los ambientes 3D interactivos; sin embargo, estas librerías también cuentan con métodos que permiten utilizar shaders personalizados escritos directamente en lenguaje GLSL.


Implementacion de Shaders personalizados en Three.js (izquierda) y P5.js (derecha)

Estas funciones también permiten definir desde JavaScript las diferentes variables como Uniforms, Attributtes, Varyings y Textures a través de las cuales se pasa información a los shaders. Cada una de estas variables tiene diferentes usos que se definen como:

- Attributes: Son principalmente utilizados para pasar información al Vertex Shader sobre los objetos a renderizar, esta información es definida como "arreglos de vectores" también conocidos como Buffers, de esta manera podemos cargar por ejemplo una lista de posiciones de vértices de una figura en coordenadas [X,Y,Z].

- Uniforms: Son utilizadas para pasar variables unicas a cualquiera de los shaders, de esta manera le enviaremos el mismo valor a cada vértice o pixel del objeto, estas variables pueden ser enteros, números de punto flotante, vectores o matrices.

- Varyings: Su principal función es pasar información del Vertex Shader al Fragment Shader, al definir un Varying con el mismo nombre en ambos shaders la información definida en un shader puede ser utilizada en el otro.

- Textures: Estas variables permiten pasar información de imágenes o "arreglos de píxeles" a los shaders.

Los Shaders en su definición más simple describen el proceso de pasar la información de posición de cada vértice al vertex shader (con la variable gl_Position), y la información de color al fragment shader (con la variable gl_FragColor), ambos como un vector de dimensión 4.


Vertex y Fragment Shaders mas simples

Dibujar un Triángulo - El "Hello World" de WebGL

Para dibujar un triángulo con los shaders definidos anteriormente debemos enviar como vectores [X,Y] las coordenadas de los 3 vértices que lo forman, esto se realiza a través de un buffer de manera que el vertex shader se ejecutara por cada vertice y recibira cada par de coordenadas como atributo. Posteriormente, es necesario enviar el color con el que se pintara cada pixel dentro del triángulo, para esto se define una variable uniforme que contenga un vector de dimensión 4 [R,G,B,A], este color será enviado al fragment shader que se ejecutara una vez por cada pixel en el canvas y detectara cuales pixeles se encuentran dentro del triángulo.


Código de WebGL para dibujar un triángulo Triángulo dibujado sobre un Canvas con WebGL

Triángulo con Varios Colores - Color en el Buffer

Los Buffers no son usados solamente para definir la posición de la geometría, también son capaces de guardar cualquier tipo información que se define por cada vértice. De esta manera podríamos crear otro buffer que guarde un color diferente para cada uno de los vértices del triángulo, para que este buffer sea utilizado es necesario declarar otro atributte en el vertex shader que reciba la información de color y posteriormente la envíe al fragment shader, el proceso de pasar información entre vertex y fragment se realiza declarando una variable Varying tanto en el vertex como en el fragment shader la cual siempre tendrá el mismo valor en ambos shaders. Con estos cambios el programa dibujará un triángulo que interpola los colores definidos dependiendo de la cercanía del pixel al vértice definido con ese color.


Buffer de color y cambios en el vertex y fragment shader Triángulo con colores interpolados

Dibujar Varios Triángulos

Si queremos dibujar más de un triángulo podemos encerrar gran parte del código que dibuja el triángulo dentro de un ciclo donde la posición de los vértices y los colores son definidos de manera aleatoria, esto significa que se llama varias veces a la función drawArrays() también conocida como llamada de dibujo o draw call. Al hacer esto obtendremos una gran cantidad de triángulos en la pantalla


Código de dibujo de triángulos en un ciclo Múltiples triángulos con colores y posiciones aleatorias (cambia cada vez que se recargue la página)

Múltiples Triángulos con Buffer de Color

La Implementación anterior no es la única manera de dibujar varios triángulos en el canvas, por ejemplo al crear un buffer mucho más grande con más vértices que definan varios triángulos podemos obtener este mismo resultado, para esto utilizaremos el ejemplo del triángulo multicolor e igualmente utilizaremos un ciclo para crear los buffers posición y color con información aleatoria, se debe tener en cuenta definir el número total de vértices a la hora de llamar la función drawArrays(). Esta Implementación nos permite dibujar varios triángulos con una sola llamada de dibujo, en el mundo de los gráficos generalmente entre menos llamadas de dibujo realice un programa, este correra más rápido y será más óptimo


Definición de arreglos que se guardan en los buffers Múltiples triángulos multicolores en posiciones aleatorias (cambia cada vez que se recargue la página)

Figura creadas a partir de Triángulos

Siguiendo esta misma lógica, si en lugar de usar valores aleatorios dibujamos triángulos con posiciones y colores definidos podemos crear formas y figuras más complejas, casi todos los gráficos 3D que se presentan en videojuegos, animaciones y toda clase de rénder son formados en sus componentes más básicos por triángulos que definen una geometría.


Letra f creada con un buffer de 6 triángulos (18 vértices) Letra f en el canvas de WebGL

Mover, Rotar y Escalar Objetos - Matrices de Transformación

Con lo anterior ya podemos dibujar un objeto en WebGL definiendo la posición de cada uno de sus vértices, pero si queremos modificarlo o transformarlo de manera que cambie su posición, tamaño o dirección tendríamos que cambiar manualmente la información de cada vértice que dibuja la figura. Por suerte el álgebra lineal nos presenta una serie de operaciones matriciales que facilitan la transformación de vectores en el espacio, y como matemáticamente cada vértice es un vector en 3 dimensiones y cada figura es un conjunto de estos vectores, podemos aplicar estas operaciones a cada vértice para modificar el objeto completo.

Las 3 operaciones de transformación comunes en los programas de manipulación 3D son Translación, Rotación y Escalado, cada una de estas operaciones se debe realizar sobre el objeto en cada uno de sus ejes (X, Y, Z) por separado. Para que las operaciones matemáticas se realicen sobre cada vértice de la figura su implementación debe realizarse directamente en el Vertex Shader.

La Translación (movimiento) se puede definir como una suma de vectores, creamos un vector que define el cambio de posición que queremos sobre cada eje [X, Y, Z], y lo sumamos a cada uno de los vértices del objeto, esto lo podemos definir fácilmente sobre el Vertex Shader al pasar este vector de translación como una uniforme y sumarlo al atributo de posición de cada vértice

El Escalado (cambio de tamaño) se puede definir como una multiplicación de vectores similar al producto punto del álgebra lineal sin la suma final, de esta manera se crea un vector que define cuanto crecerá el objeto en cada eje y lo multiplicamos sobre cada vértice del objeto.


Implementacion de Escalado y Translacion sobre el vertex shader

La Rotación es un poco más compleja debido a que es necesario definir una operación separada para girar sobre cada eje, estas son definidas de mejor manera utilizando una matriz de rotación la cual es multiplicada con el vector de posición de cada vértice, al aplicarse esta multiplicación sobre cada vértice obtendremos una rotación del objeto sobre un eje específico por el valor de un ángulo tetha. Las 3 matrices se multiplican entre si para definir una sola matriz de rotación a la cual se le pasa un solo vector (Rx, Ry, Rz) como parámetro que define el ángulo de rotación en cada eje.


Matrices de Rotación para cada eje

Las operaciones de Translación y Escalado también pueden definirse como matrices gracias a la forma como se realiza la multiplicación de un vector por una matriz.


Matrices de Translación y Escalado

De esta manera al tener las operaciones como matrices podemos finalmente multiplicar todas las 5 matrices para definir una sola Matriz de Transformación, esta recibe como parámetros el movimiento en [X,Y,Z], el escalado en [X,Y,Z] y la rotación en [X,Y,Z] realizando estas 3 transformaciones en una sola operación.


Vertex Shader con matriz de transformacion completa Figura despues de aplicar Translacion, Rotacion y Escalado

Figuras en 3D - Proyección Ortográfica

Por el momento solo hemos definido figuras y objetos utilizando vectores en 2 dimensiones, si queremos renderizar objetos en 3 dimensiones debemos definir la posición de sus vértices en 3 dimensiones [X,Y,Z]


Buffer de letra F en 3D creada con 96 vértices (32 triángulos)

Al pasar la información de los vértices en 3 dimensiones WebGL automáticamente procesa la información en el valor Z y proyecta el objeto con una vista ortográfica (sin perspectiva). Este proceso de rasterización internamente utiliza una matriz ortográfica que toma el vector en 3 dimensiones de cada vértice y lo convierte en un vector en 2 dimensiones que define su posición en la pantalla.

Letra F en 3D con proyección ortográfica

Perspectiva - Proyección con Matriz de Perspectiva

Si deseamos tener perspectiva se debe usar otra matriz que tome esta proyección y la modifique para presentar los objetos lejanos de manera más pequeña y los cercanos de manera más grande, esta matriz crea el tan conocido Frustum que recibe como parámetros el campo de visión (FOV) y los valores cercano y lejano para definir la pirámide de visión de la cámara perspectiva.


Matriz que define el Frustum de cámara Perspectiva, y diferencias entre proyección ortografica y perspectiva

Una vez definida esta matriz se pasa al vertex shader como una variable uniforme, de esta manera se multiplica junto con la matriz de transformación y el vector de posición para generar la proyección deseada.

Letra F en 3D con proyección perspectiva

Movimiento de Cámara - Matriz de Vista

Finalmente, para completar la configuración por defecto que la mayoría de ambientes 3D utilizan, se debe definir una cámara que sea capaz de moverse alrededor de la escena. Una de las sorpresas más grandes que me lleve haciendo esta investigación fue el hecho de que para WebGL no existe el concepto de cámara, la librería siempre renderizara él “cubo unitario” estático que tiene en frente y la única manera de “mover la cámara” es moviendo toda la geometría alrededor de ella y modificándola de manera que quede dentro de este cubo unitario. Por suerte no hay necesidad de definir nuevas matrices, el movimiento de la cámara se puede simular simplemente generando una matriz de movimiento que a través de los parámetros de translación defina la posición en la que se quiere colocar la cámara y también a través de los parámetros de rotación defina hacia donde está mirando la cámara, una vez tenemos esta matriz calculamos su inversa la cual se conoce como la matriz de Vista, esta matriz se la aplicamos a todos los vértices de la escena al multiplicarla con el resto de matrices en el Vertex Shader.


Vertex Shader final con las 3 matrices de Tranformación, Perspectiva y Vista Varias Fs en 3D renderizadas con la cámara en una posición superior

Si queremos una cámara que simplemente tenga libre movimiento, podemos definir una matriz de movimiento que la mueva a cualquier punto pasando directamente los parámetros (Tx,Ty,Tz), para que posteriormente la haga girar alrededor de su eje pasando directamente los parámetros de rotación (Rx,Ry,Rz).


Matriz de Vista generada a partir de 1 matriz de translación y 3 matrices de rotación

Pero si queremos generar una aproximación al método de navegación Orbit/Zoom/Pan podemos definir inicialmente la Orbita como una rotación solamente en los ejes X y Y para luego generar un Zoom a partir de un movimiento en el eje Z y finalizar con el Pan como un movimiento del punto central de pivote con una translación libre.


Matriz de Vista generada a partir de 2 matrices de translación y 2 matrices de rotación

Objetos en Movimiento - Animación con WebGL

Una vez tenemos la capacidad de transformar el objeto y la escena podemos animar estas transformaciones para visualizarlas de mejor manera, las animaciones en javascript requieren utilizar el metodo requestAnimationFrame() el cual nos permite ejecutar una función cada vez que el navegador dibuje un nuevo frame en la pantalla, así al definir una función que realice el dibujo de la escena podemos sucesivamente renderizarla múltiples veces por segundo con pequeños cambios en la posición del objeto o la cámara para dar la ilusión de movimiento.

Para esto crearemos una función que a través de las matrices presentadas anteriormente genere dinámicamente los cambios según el paso del tiempo, una vez se realicen las transformaciones haremos una llamada de dibujo con el metodo drawArrays() y finalmente realizaremos una petición al navegador para que vuelva a ejecutar la función de animación en el siguiente frame con el metodo requestAnimationFrame().


Función de animación con cambios matriz de transformación y vista Animación de la escena con cambios en la rotación del objeto y el zoom de la cámara

Múltiples Objetos Animados

Anteriormente hemos visto que para dibujar varios objetos dentro del mismo entorno podemos definir todos los vértices de todos los objetos dentro del mismo buffer de geometría, o también podemos usar múltiples llamados de dibujo con el mismo buffer aplicando cambios en la matriz de transformacion de cada uno de los objetos. Sin embargo, para WebGL no existe ninguna diferencia entre estos objetos, la librería no guarda ninguna referencia con respecto a su posición, buffer de geometría, color o cualquier otra información de cada objeto, por esta razón para fácilmente dibujar y manipular varios objetos es necesario definir una variable que almacene en formato JSON la información de cada uno de los objetos 3D en el entorno, información como el buffer que define su geometría, color, numero de vértices, posición, textura y cualquier otra propiedad que pueda ser únicamente de ese objeto.


Declaración de cada Objeto 3D y sus propiedades con información definida y aleatoria.

De esta manera, cada objeto que renderizamos podrá ser fácilmente referenciado e identificado en cada llamada de dibujo, para dibujarlos simplemente guardamos todos los objetos en un mismo arreglo y los dibujamos iterativamente en un ciclo, cada iteración entonces define los buffers y uniformes de cada objeto por separado y utiliza un solo draw call por cada objeto.


Objetos renderizados en un ciclo con sus atributos y uniformes propios Varias Fs en 3D animadas en un mismo entorno

Múltiples Tipos de Objetos Animados

Lo anterior nos permite definir la información de cada objeto por separado, esto no solo nos ayuda a dibujar múltiples objetos en diferentes posiciones, también podemos dibujar objetos con diferentes geometrías, colores y cualquier otra propiedad única que guardemos de estos.


Declaramos y utilizamos diferentes Buffers de Geometria y Color para los Objetos Varios tipos de Objetos 3D animados en un mismo entorno

Geometría con Texturas

Si en lugar de colores planos deseamos usar imágenes mapeadas sobre los objetos, es necesario definirlas y enviarlas a los shaders utilizando un tipo de variable especial llamada Texture, esta variable puede recibir cualquier tipo de "Arreglo de Píxeles" ya sea una imagen cargada o generada en código.


Creación de una variable de textura

A continuación para proyectar la textura sobre la geometría de un objeto es necesario definir las coordenadas de cada vertice sobre la imagen como un vector en 2 dimensiones [U,V], este vector define la posición de un vértice sobre la imagen y esta información en conjunto es conocida como las coordenadas UV del objeto, estas pueden ser definidas dentro de un buffer que se pasara luego al fragment shader.


Buffer con las coordenadas UV del objeto

Finalmente, la información de la textura y de las coordenadas UV son empleadas para mapear la imagen encima de la geometría dentro del Fragment Shader.


Fragment shader que recibe la textura y la mapea sobre el objeto usando las coordenadas UV Objeto 3D con textura mapeada sobre su geometría

Renderización a una Textura con FrameBuffer

Las texturas definidas en WebGL pueden ser imágenes cargadas como elementos de HTML (como en el anterior ejemplo) o también pueden ser generadas dinámicamente dentro de JavaScript, este tipo de texturas dinámicas nos permiten calcular información de la escena para reutilizarla dentro de esta misma lo cual puede ayudar con el desarrollo de diferentes tipos de funcionalidades. Un ejemplo de esto es la capacidad de renderizar una escena dentro de una textura que posteriormente puede ser mapeada sobre un objeto, para hacer esto declaramos inicialmente una textura cuyo valor será vacío por el momento.


Textura vacia donde se renderizara la escena

A continuación crearemos un Frame Buffer, este buffer es un objeto utilizado para definir donde se renderiza una escena, por defecto WebGL utiliza un frame buffer que renderiza sobre el Canvas y al crear otro frame buffer configurado a una textura evitamos que se renderice sobre el Canvas y nos aseguramos de que el render se realice sobre esta textura.


Frame Buffer configurado con la textura anterior

Finalmente, debemos renderizar la escena 2 veces, la primera se realizará sobre el render buffer declarado por nosotros y utilizando como textura de la geometría una imagen cargada anteriormente. Y posteriormente renderizamos una segunda vez declarando el frame buffer vacio lo cual renderizara por defecto al canvas y utilizando como textura de la geometría la imagen generada por el render anterior.


Renderizacion de la escena 2 veces sobre Buffer y Canvas Escena de 3 cubos con textura del render de 3 cubos con textura de imagen

Selección de Objetos con el Mouse

Para finalizar utilizaremos los conceptos aprendidos anteriormente para desarrollar una funcionalidad de picking o selección de objetos con el mouse, esta funcionalidad es muy importante para la interaccion del usuario con los espacios tridimensionales renderizados y su implementación sintetiza una gran parte de las ideas exploradas.

La implementación utiliza una técnica que consiste en renderizar la escena 2 veces, el primer rénder se realiza sobre él canvas con la configuración del fragment shader que se desea presentar al usuario, una segunda renderización se hace sobre una textura invisible la cual utiliza un fragment shader muy simple que pinta cada objeto de un color específico, este color está predefinido según el ID del objeto por lo que cada objeto se pinta con un color diferente. Si rastreamos la posición del mouse y leemos el color del pixel sobre el que se encuentra actualmente el puntero, podemos usar la información de color para obtener el ID del objeto y a partir de esto referenciarlo en la lista de objetos de la escena.

Para desarrollar esto necesitamos definir 2 programas de WebGL, uno con la configuración necesaria para renderizar la escena y otro con un fragment shader muy simple que reciba un color como uniforme de manera que pinte todo el objeto de este color.


Fragment Shader que pinta el objeto de un color

Cada objeto que agregamos usara su posición en la lista para crear un color único a partir de una serie de pequeñas operaciones de corrimiento de bits, esto nos permitirá agregar una gran cantidad de objetos con colores únicos mientras al mismo tiempo esta operación puede ser fácilmente invertida para obtener la posición original en la lista que referencia al objeto. Para visualizar la selección también agregaremos una propiedad llamada multiplicador de color, este se pasara al fragment shader visible por el usuario y multiplicara el valor del color a dibujar para cambiar su apariencia.


Definición del color único como propiedad del objeto

Para renderizar la escena con esta información primero definimos una textura que asignaremos a un FrameBuffer, igualmente implementaremos un RenderBuffer que nos permite agregar al FrameBuffer la información del mapa de profundidad generado por WebGL, esto evitara errores y sobrelapamientos de profundidad en la renderización de los objetos para siempre seleccionar el primer objeto que debería ser visible.


Textura, Render Buffer y Frame Buffer para renderizar la escena fuera del canvas

Al igual que en el ejemplo anterior renderizaremos la escena 2 veces, una hacia el Buffer de textura con la configuración de los shaders del picker, y otra hacia el canvas con la configuración de los shaders de presentación al usuario. Dentro del ciclo de dibujo nos aseguraremos de utilizar los atributos y uniformes correctos según los shaders que se estén utilizando.


Renderización con ambas configuraciones



Selección de uniformes y atributos según el shader

Para finalizar, antes de renderizar el picker obtendremos la posición del mouse en el canvas, leeremos el color del pixel debajo del puntero y finalmente calcularemos la referencia al objeto señalado a partir del color. Esto nos permitirá hacer un pequeño cambio de color cuando se está "seleccionando" el objeto.


Interacción entre mouse y canvas

Con todos estos cambios podemos presentar una escena renderizada con múltiples objetos, si pasamos el mouse sobre cada uno de estos podemos observar como cambian de color.

Selección de objetos con el mouse

Optimización del Picking

Todos estos cálculos realizados para implementar la funcionalidad de picking se ejecutan en cada frame (aprox. 20-30 veces por segundo) y por esta razón es muy importante optimizar todas las funciones de la mejor manera posible. Una manera de lograr esto es limitando la renderización de la escena sobre el frame buffer a solamente el pixel que se encuentra debajo del mouse, así no estamos renderizando la escena completa 2 veces cada frame y al mismo tiempo obtenemos la información requerida (color del pixel debajo del mouse).

Para realizar esto utilizaremos una matriz conocida como Matriz de Frustum, esta nos permitirá declarar el frustum de perspectiva utilizando los parámetros arriba, abajo, izquierda y derecha del plano cercano en lugar de utilizar el FOV (field of view), con esta podremos definir un plano cercano de solamente un pixel.


Matriz de Frustum

Si queremos obtener un frustum de un solo pixel, primero debemos obtener los valores requeridos para crear una perspectiva igual a la que hemos estado usando anteriormente pero con los parámetros que nos pide la matriz de frustum, para esto utilizaremos una función que calculara estos valores a partir de las variables cercano(near), lejano(far), relación de aspecto(aspect) y campo de visión(fov) que hemos declarado anteriormente. Esta función solo será llamada una vez por fuera del ciclo de dibujo, en este caso se ejecutará justo después de calcular la matriz de perspectiva.


Parámetros de la Matriz de Frustum con Near, Far, Aspect y FOV

Una vez tengamos estos valores los utilizaremos en conjunto con la posición del mouse para calcular los parámetros arriba, abajo, izquierda y derecha requeridos para encerrar el pixel debajo del puntero. Esta función la ejecutaremos en cada llamada de dibujo, justo antes de renderizar sobre el frame buffer y tanto la renderización como la misma textura del frame buffer serán configuradas para solo consistir de un pixel.


Definición de la matriz de frustum la cual se pasa como parámetro a la función de dibujo

Con esta configuración ya no es necesario determinar la posición del mouse al buscar el color del pixel que debemos identificar, solamente debemos leer el color del primer y único pixel en el frame buffer justo antes de renderizar la escena para presentar al usuario.


configuración para leer el color del pixel.

Con estos cambios el picking seguirá funcionando de la misma manera, pero esta vez será mucho más óptimo.

Comparacion con otros metodos de Picking

Existen varios métodos de implementar picking según las necesidades y complejidad del software, por ejemplo en Three.js la selección de objetos se puede implementar usando la funcionalidad de Ray Casting, esta permite emitir un rayo desde la cámara que pasa por la posición del mouse y automáticamente detecta los objetos intersectados por el rayo en un orden especifico, de esta manera se puede calcular el objeto debajo del mouse y también se puede extender esta funcionalidad para otros usos según sea necesario. En la página de P5.js existe un ejemplo que hace una aproximación de esta técnica dentro de un entorno generado por planos, esta utiliza una función matemática que detecta el punto de colisión entre el rayo dirigido por el mouse y cualquiera de los planos. Calculando funciones similares para cada primitiva se podría crear una funcionalidad similar a la que implementa Three.js dentro de P5; sin embargo, la técnica del rénder con colores únicos es mucho más simple y se adaptaría más al estilo de librería que P5.js trata de ser.


Ilustración Raycasting - tomado de la publicación

Otra técnica muy utilizada es el método de bounding-box (cubo envolvente) el cual busca generar una geometría alrededor del objeto que pueda hacer más fácil detectar la selección, dependiendo de la implementación puede ser una geometría 3D que envuelve el volumen del objeto sobre la escena o una geometría 2D que encierra el área del objeto sobre la pantalla. En el caso 3D se puede hacer uso del Ray Casting para detectar la colisión del rayo con la geometría envolvente (generalmente un cubo o una esfera) lo cual será mucho más fácil de calcular, en el caso 2D simplemente debemos detectar si el mouse en la pantalla se encuentra adentro o afuera de la geometría envolvente sobre el canvas (generalmente un circulo o un cuadrado). La librería treegl para el motor gráfico de P5.js presenta un acercamiento a esta técnica utilizando el método de Boundig Box 2D directamente en JavaScript.

En el siguiente link podemos ver una comparación de ambos métodos dentro de una escena parecida para demostrar que su funcionamiento es muy similar; sin embargo, se pueden notar ciertas diferencias dependiendo de la implementación. Por ejemplo en WebGL al utilizar el Render Buffer con la información de profundidad solamente seleccionaremos los objetos que estén al frente de la pantalla, con la implementación de bounding box 2D no existe una revisión de profundidad y por tanto podemos seleccionar varios objetos que se encuentran intersectados e incluso son seleccionados los objetos que se encuentran detrás de la selección.


La implemntación con WebGL no permite seleccionar varios objetos, la implementación de bounding-box 2D si lo permite.

Aqui tenemos 2 implementaciones del Picking con Colores en WebGL y Picking con Boundig Box en Javascript donde instanciamos una gran cantidad de objetos y medimos el frame rate de cada escena, se puede apreciar que el picking con colores tiene una ejecución mas optima. Aun así la perdida de rendimiento solamente se da con una gran cantidad de objetos y no es tan pronunciada, por lo que una implementación de bounding box podría ser útil según la funcionalidad de interacción que se desea desarrollar.


Comparación de rendimiento entre WebGL (44 FPS) y Javascript (24 FPS) con mil objetos.

Conclusiones

Aunque utilizar WebGL directamente es un proceso arduo, complejo y difícil, fue muy necesario para entender de manera completa todo el proceso de renderizado que las librerías de JavaScript tratan de esconder, solo así es posible entender el código fuente de estos proyectos con el propósito de desarrollar aportes o librerías que complementen sus funcionalidades actuales. Así mismo, el uso de shaders personalizados dentro de estas librerías puede ser una gran herramienta para generar efectos complejos que son difíciles de implementar con sus funcionalidades por defecto, para este propósito el acercamiento a código de WebGL nativo me ayudo a comprender muchos conceptos sobre el uso de Shaders que no tenía muy claro anteriormente.

A pesar de que la mayoría de los motores gráficos para WebGL intentan cumplir propósitos similares y pueden lograr resultados similares, difieren mucho en el estilo del código y los métodos que emplean para implementar ciertas funcionalidades según las necesidades de comportamiento, optimización, facilidad de uso, etc. De esta manera, aunque tenga un grado de dificultad mucho mayor, al adentrarnos con WebGL dentro del pipeline de renderización podemos manipular el proceso de dibujo de una manera muy personalizada y lograr los resultados más óptimos y correctos para las funcionalidades que queremos implementar.

Bibliografia

- Gregg Tavares. 2015. WebGL Fundamentals. https://webglfundamentals.org/

- Edward Angel and Dave Shreiner. 2014. An Introduction to WebGL Programming. SIGGRAPH University (Siggraph 2014). https://www.youtube.com/watch?v=tgVLb6fOVVc

- Bruno Simon. 2020. Three.js Journey. https://threejs-journey.com/

- Kouichi Matsuda and Rodger Lea. 2013. WebGL Programming Guide: Interactive 3D Graphics Programming with WebGL. https://sites.google.com/site/webglbook/

- Wayne Brown. 2015. Learn WebGL. http://learnwebgl.brown37.net/index.html