Creando un efecto líquido simple en una malla usando WebGL
Utilice el click izquierdo para crear una ondulación. Utilice el click derecho (y manténgalo presionado) para mover la cámara.
Si quiere probarlo, aquí está el link de descarga de la carpeta .zip que contiene los archivos que hacen funcionar este ejemplo. Los archivos contenidos en esta carpeta son:
Contenido: - Entendiendo la malla - Configuración - Funciones de rotación vectorial - El loop principal - La función rotCameraUpdate - La función projectedPointer - La función render - El shader de vértices - El shader de fragmentos
Las partes más importantes que hacen que este ejemplo de WebGL sea posible son: una malla que tenga suficientes vértices para hacer que este efecto sea visible y un shader de vértices que está a cargo de modificar tanto la coordenada Y de los vértices como su color.
Entendiendo la malla.
Usted puede probarlo directamente descargando los archivos, el archivo llamado meshSimple.obj es
el que tiene la información de la malla. Puede abrir este archivo en cualquier software que pueda
leer archivos OBJ.
Esta malla es un cuadrado formado por muchos cuadrados más pequeños, cuantos más cuadrados tenga,
mejor se ve el efecto, pero requiere más potencia del procesador, por otro lado, si tiene menos
cuadrados, el requisito de rendimiento disminuye, pero el efecto pierde calidad. Todo se reduce a
la cantidad de vértices, ya que, en este ejemplo, son los que están siendo modificados cada vez
que se dibuja un fotograma.
Configuración:
Se define una variable global denominada canvas, esta se usa para hacer referencia al elemento Canvas en el cual dibujaremos. Además, se establece explícitamente que el contenido del Canvas cambiará constantemente, 60 FPS idealmente, esto porque requestAnimationFrame se utiliza.
canvas = document.getElementById( "gl-canvas" ); canvas.style.willChange = 'contents';
Se declaran las variables que indicarán la posición del mouse con respecto al Canvas.
rect = canvas.getBoundingClientRect(); rectLeft = rect.left - window.scrollX; rectTop = rect.top + window.scrollY; rectLeftA = rectLeft; rectTopA = rectTop;
Una variable llamada gl será el contexto de WebGL. Se utiliza un elemento Canvas de HTML, si el navegador no permite el uso de WebGL, se muestra un mensaje. El viewport de WebGL se configura para que tenga el mismo tamaño que el Canvas. El clearColor del buffer de color se establece explícitamente, este es el color de fondo predeterminado del Canvas. Además, habilitamos el DEPTH_TEST para asegurar un buen efecto visual.
gl = WebGLUtils.setupWebGL( canvas ); if ( !gl ) { alert( "WebGL isn't available. WebGL no esta disponible" ); } gl.viewport( 0, 0, canvas.width, canvas.height ); gl.clearColor( 0.95, 0.95, 0.95, 1.0 ); gl.enable(gl.DEPTH_TEST);
Un buffer de vértices y uno de fragmentos son creados, estos se cargan con la información contenida en verMeshSimple e indMeshSimple.
bufVMeshSimple = gl.createBuffer(); gl.bindBuffer( gl.ARRAY_BUFFER, bufVMeshSimple ); gl.bufferData( gl.ARRAY_BUFFER,verMeshSimple, gl.STATIC_DRAW ); bufIMeshSimple = gl.createBuffer(); gl.bindBuffer( gl.ELEMENT_ARRAY_BUFFER, bufIMeshSimple ); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,indMeshSimple, gl.STATIC_DRAW);
Configurando el shader de vértices y de fragmentos que se van a utilizar.
proMeshSimple = initShaders( gl, "progMeshSimpleVer", "progMeshSimpleFra" );
Se obtiene el puntero de los atributos "vértices" y "uvs" para ser utilizados en el shader de vértices.
vLMeshSimple = gl.getAttribLocation( proMeshSimple, "vertices" ); gl.enableVertexAttribArray( vLMeshSimple ); cLMeshSimple = gl.getAttribLocation( proMeshSimple, "uvs" ); gl.enableVertexAttribArray( cLMeshSimple );
Se obtienen los punteros de los uniformes que se utilizan en los shaders. Hay 7 uniformes utilizados en el shader de vértices, uno de ellos es la matriz que va a permitir tener una vista en perspectiva al dibujar. En el shader de fragmentos sólo hay 2 uniformes utilizados, uno de ellos es la textura de la malla.
vUMeshSimple0 = gl.getUniformLocation( proMeshSimple, "sc" ); vUMeshSimple1 = gl.getUniformLocation( proMeshSimple, "t" ); vUMeshSimple2 = gl.getUniformLocation( proMeshSimple, "p" ); vUMeshSimple3 = gl.getUniformLocation( proMeshSimple, "time" ); vUMeshSimple4 = gl.getUniformLocation( proMeshSimple, "timeA" ); vUMeshSimple5 = gl.getUniformLocation( proMeshSimple, "propa" ); mCommonMeshSimpleL = gl.getUniformLocation( proMeshSimple, "mvpMat" ); fUMeshSimple0 = gl.getUniformLocation( proMeshSimple, "point" ); texMeshSimpleL = gl.getUniformLocation( proMeshSimple, "texture" );
Necesitamos cargar la imagen que va a ser utilizada como textura en la malla.
images[0].src = imaDisp[0]; images[0].onload = function() { configureTexture( images[0],0);};
La función llamada configureTexture crea el elemento de textura de WebGL que se va a utilizar luego, la imagen tiene información en RGBA. Mipmapping se utiliza para filtrar la textura, esto mejora la forma en que la textura se ve cuando es vista desde diferentes ángulos. Además, una variable llamada loader cuenta cada vez que se carga la imagen, esta variable se utilizará para saber si la imagen se ha cargado correctamente antes de utilizarla.
function configureTexture(image, index) { textures[index] = gl.createTexture(); gl.bindTexture( gl.TEXTURE_2D, textures[index] ); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image ); gl.generateMipmap( gl.TEXTURE_2D ); gl.texParameteri( gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.texParameteri( gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR ); loader++; }
Se configuran algunos “event listeners”. La función scrollFunc se utiliza para actualizar la posición del cursor cuando se desplaza la página. La función mouseMoved actualiza la posición del cursor cuando se mueve dentro del Canvas. Tanto clickUpCanvas como clickDownCanvas se utilizan para comprobar los clicks dentro del Canvas.
document.addEventListener("scroll", scrollFunc); canvas.addEventListener("mousemove", mouseMoved); canvas.addEventListener("mouseup", clickUpCanvas); canvas.addEventListener("mousedown", clickDownCanvas);
Lo que sucede dentro de la función scrollFunc es que las variables rectLeft y rectTop se actualizan utilizando los valores actuales window.scrollX y window.scrollY de la página.
function scrollFunc(event){ rectLeft = rectLeftA - window.scrollX; rectTop = rectTopA - window.scrollY; }
La función mouseMoved es una función corta que actualiza los valores de dos variables globales: mouseX y mouseY, estas variables son muy importantes ya que se utilizan para saber de dónde va a partir la ondulación.
function mouseMoved(event){ mouseX = event.clientX - rectLeft; mouseY = event.clientY - rectTop; }
La última cosa que necesita configurarse es una matriz de perspectiva, esta va a ser utilizada para calcular la matriz MVP.
var near = 0.1; var far = 1000; var fovy = 45.0; var aspect = 700 / 512; pMatrix = perspective(fovy, aspect, near, far); pMatrix0T = [pMatrix[0][0], pMatrix[1][0], pMatrix[2][0], pMatrix[3][0], pMatrix[0][1], pMatrix[1][1], pMatrix[2][1], pMatrix[3][1], pMatrix[0][2], pMatrix[1][2], pMatrix[2][2], pMatrix[3][2], pMatrix[0][3], pMatrix[1][3], pMatrix[2][3], pMatrix[3][3]];
Funciones de rotación vectorial.
Las funciones rotateX y rotateY se utilizan repetidamente en todo el código para girar un vector específico, rotateX gira el vector con respecto al eje X y rotateY con respecto al eje Y. Estas funciones reciben tanto el vector como el ángulo de rotación y devuelven el vector girado.
function rotateX(x, y, z, theta, math) { theta = theta * 0.0174533; var c = math.cos(theta); var s = math.sin(theta); return [x, y*c + z*s, -y*s + z*c]; } function rotateY(x, y, z, theta, math){ theta = -theta * 0.0174533; var c = math.cos(theta); var s = math.sin(theta); return [x*c - z*s, y, x*s + z*c]; }
El loop principal:
Esta función se llama repetidamente para lograr el efecto de animación. La variable global time aumenta cada vez que esta función es llamada, esta variable se utiliza como temporizador. La función rotCameraUpdate se utiliza para modificar el ángulo de visión de la cámara cuando un usuario presiona el botón derecho del mouse y arrastra el cursor sobre el Canvas. La función projectedPointer calcula la posición del cursor en el espacio 3D en función de su posición en el espacio 2D. La función mvpMatrixUpdate calcula la matriz MVP. Si la variable loader es igual a 1, lo que significa que la textura se cargó correctamente, se llama a la función render.
function loop() { time++; rotCameraUpdate(); projectedPointer(); mvpMatrixUpdate(); if(loader===1){ render(); } requestAnimationFrame(loop); }
La función rotCameraUpdate:
Esta función actualiza tanto rotCameraX como rotCameraY. Para que estos valores cambien el valor de clickRight debe ser igual a true, lo que significa que se ha presionado el botón derecho del mouse.
function rotCameraUpdate(){ if(clickRight){ rotCameraX = rotCameraXAD+0.5*(mouseY-mouseYA); rotCameraY = rotCameraYAD-0.5*(mouseX-mouseXA); if(rotCameraX>89){ rotCameraX=89; } if(rotCameraX<10){ rotCameraX=10; } } }
La función projectedPointer:
Esta función tiene un trabajo muy importante que es tomar la posición del cursor desde un espacio 2D (dado por mouseX y mouseY) y traducirlo al equivalente proyectado en espacio 3D, esos valores se almacenan en las variables posPointerX y posPointerZ.
function projectedPointer(){ var degToRad = Math.PI / 180; var posMouseX = ((mouseX - 350)/350) * eyeToCenter; var posMouseY = ((mouseY - 256)/256) * eyeToCenter / (700 / 512); var anguloPorAhora = Math.atan2(posMouseX , cameraToCenter); var thetazp = Math.atan2(posMouseY , cameraToCenter); var thetaz = -rotCameraX * degToRad; var zzz = ((Math.tan(thetazp) * cameraToCenter) / (Math.tan(thetaz) - Math.tan(thetazp))); posPointerX = posMouseX + (zzz * Math.tan(anguloPorAhora)); posPointerZ = -zzz / Math.cos(thetaz); var ammm = Math.sqrt(at[0]*at[0] + at[2]*at[2]); var rotCamYGrad = rotCameraY * degToRad; var c = Math.cos(rotCamYGrad); var s = Math.sin(rotCamYGrad); var vPunt = rotateY(posPointerX + at[0]*c - at[2]*s, 0, posPointerZ + at[0]*s + at[2]*c, rotCameraY, Math); posPointerX = vPunt[0]; posPointerZ = vPunt[2]; }
La función render:
En esta función hacemos todo lo relacionado con el contexto de WebGL. Para iniciar el dibujado, se borran el buffer de color y el buffer de profundidad.
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
Configurando el programa para utilizar proMeshSimple.
gl.useProgram(proMeshSimple);
Vinculamos el buffer de vértices para luego establecer los punteros de atributos que van a ser utilizados en este dibujado, los punteros corresponden a "vertices" y "uvs", estos se usan en el shader de vértices.
gl.bindBuffer( gl.ARRAY_BUFFER, bufVMeshSimple ); gl.vertexAttribPointer( vLMeshSimple, 3, 5126, false, 4*(8), 0 ); gl.vertexAttribPointer( cLMeshSimple, 2, 5126, false, 4*(8), 4*3 );
Se establece un uniforme que contiene una matriz, esta matriz se utilizará en el shader de vértices para crear la perspectiva de la escena.
gl.uniformMatrix4fv( mCommonMeshSimpleL, false, mvpMatrix );
Configurando la textura.
gl.uniform1i(texMeshSimpleL, 0); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, textures[0]);
Sólo se pueden crear 15 ondulaciones a la vez, el bucle “for” cuenta hasta ese número. Las variables timeA0, timeA y propa son arreglos de longitud 15 que contienen información específica de cada ondulación. La variable timeA0 se utiliza para añadir un desplazamiento al sinusoide que se utiliza en el shader de vértices. La variable timeA se utiliza como un temporizador y como el límite de la ondulación, cuando el timeA llega a 1000, la ondulación se detiene por completo. Esa es la cantidad de fotogramas por los que la ondulación existe, 1000 fotogramas a 60 fotogramas por segundo significa que cada ondulación se dibuja durante 16.67 segundos. El valor de propa es el límite del radio de cada ondulación, se hace más grande a medida que pasa el tiempo para crear el efecto de propagación desde un punto central.
for (var i = 0; i < 15; i++) { if(timeA[i]<1000){ timeA0[i] += 0.31416; timeA[i] += 0.1+(timeA[i]/250); propa[i] += 0.01818; } }
Estableciendo los uniformes que van a ser utilizados en el shader vértices.
gl.uniform3fv(vUMeshSimple0, [1,1,1]); gl.uniform3fv(vUMeshSimple1, [0,0,0]); gl.uniform2fv(vUMeshSimple2, posLi); gl.uniform1fv(vUMeshSimple3, timeA0); gl.uniform1fv(vUMeshSimple4, timeA); gl.uniform1fv(vUMeshSimple5, propa);
Estableciendo el uniforme que será utilizado en el shader de fragmentos.
gl.uniform2fv(fUMeshSimple0, [posPointerX, posPointerZ]);
Lo último que se necesita es vincular el buffer de índices y dibujar la escena.
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, bufIMeshSimple); gl.drawElements(4, indMeshSimple.length, gl.UNSIGNED_SHORT, 0);
El shader de vértices:
Hay algunas cosas que hacer antes de comenzar la función principal en el shader de vértices. En primer lugar, los atributos de la posición de los vértices y las coordenadas para el muestreo de la textura se toman de vertices y uvs, respectivamente. La matriz mvpMat se utiliza para la perspectiva de la escena. Se necesitan otros uniformes, sc es la escala, y t es la traslación de la malla, en este ejemplo realmente no se usan para mucho. La variable p es un arreglo de vec2 que tiene los valores X y Z del centro de la ondulación. El registro variable llamado uvsv se utiliza simplemente para pasar la información interpolada de las coordenadas del muestreo de la textura al shader de fragmentos. La variable yv se utiliza para modificar el color en el shader de fragmentos y pos se utiliza para conocer la posición de los vértices en el shader de fragmentos.
attribute vec4 vertices; attribute vec2 uvs; uniform mat4 mvpMat; uniform vec3 sc; uniform vec3 t; uniform vec2 p[15]; uniform float timeA0[15]; uniform float timeA[15]; uniform float propa[15]; varying vec2 uvsv; varying float yv; varying vec2 pos;
Lo primero que se hace en la función principal es pasar el valor de vertices a la verticesa, verticesa es la variable que será modificada. La variable dista almacena la distancia entre el centro de la ondulación y cada uno de los vértices. La variable fact es muy importante, esta es la variable utilizada para saber por cuánto debe modificarse la coordenada Y de cada vértice. El valor de cada ondulación se suma para crear el valor final de verticesa.y.
vec4 verticesa = vertices; float dista; float fact = 0.0; for(int i =0; i<15; i++){ if(timeA[i]<1000.0){ dista = distance(p[i].xy, verticesa.xz); if(dista<propa[i]){ fact = 0.3*(dista/timeA[i])*cos(11.0*(3.1416/2.0)*dista - timeA0[i]); verticesa.y += fact; } } }
En esta parte enviamos la posición interpolada de los vértices en el plano XZ, esto se hace con la ayuda del registro variable pos. El valor de yv se utilizará para modificar el color de los vértices con respecto a su coordenada Y.
pos = verticesa.xz; yv = verticesa.y*3.0;
Estos dos son simplemente una matriz de escala y una matriz de traslación.
mat4 scale = mat4( sc.x, 0.0, 0.0, 0.0, 0.0, sc.y, 0.0, 0.0, 0.0, 0.0, sc.z, 0.0, 0.0, 0.0, 0.0, 1.0 ); mat4 trans = mat4( 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, t.x, t.y, t.z, 1.0 );
Lo último que hay que hacer es obtener la posición final de los vértices, se hacen algunas multiplicaciones matriciales para obtener este valor. La última multiplicación con mvpMat sirve para obtener el efecto de perspectiva. Después de esto, el valor puede ser enviado a gl_Position. Además, el valor interpolado de uvs se envía al shader de fragmentos a través de uvsv.
mat4 mFinal = trans*scale; verticesa = mFinal*verticesa; gl_Position = mvpMat*verticesa; uvsv = uvs;
El shader de fragmentos:
Nada demasiado complicado sucede en el shader de fragmentos. Este recibe la textura y el valor de uvsv interpolado para calcular el color final, este color es entonces modificado por el valor de yv, esto es para dar más énfasis a las ondulaciones, si un vértice está más alto, lo que significa que su coordenada Y es mayor, el color es más claro, si el vértice está más bajo se vuelve más oscuro. La uniforme llamada point nos da la posición del cursor en el plano XZ, esto se utiliza para dibujar un círculo en la parte superior de la malla lo cual hace más visible donde será el centro de la ondulación. Después de todo esto, el color final se pasa a gl_FragColor.
precision mediump float; varying vec2 uvsv; uniform sampler2D texture; varying float yv; varying vec2 pos; uniform vec2 point; void main() { vec4 color = texture2D( texture, uvsv ); color.xyz += yv; float distoA = distance(point.xy, pos.xy); if(distoA<0.05){ color.xyz += (0.05-distoA)/0.05; } gl_FragColor = color; }
Esos son todos los detalles importantes que hacen que este experimento funcione.
Espero que ahora sepa cómo crear este tipo de efecto que aprovecha el poder del
shader de vértices. También, espero que este ejemplo le dé una mejor comprensión
de cómo usar WebGL en general.
Asegúrese de probarlo por sí mismo usando el enlace de descarga para los archivos proporcionado
al principio. Le invito a cambiar algunos valores en el código para ver qué sucede,
intente sustituir la textura por una propia, puede intentar aumentar la velocidad de
propagación de las ondulaciones o intente que las ondulaciones sean más marcadas
cambiando el factor que modifica la coordenada Y de los vértices.
Siga experimentando y gracias por leer.