Creating a simple liquid effect on a texture using WebGL
Click on the image below to create a ripple.
If you want to test it for yourself, here is the download link with the .zip folder containing the source files. The files contained in this folder are:
Contents: - Setting it up - The main loop - The render function - The vertex shader - The fragment shader
The most important parts that make this WebGL example possible are: drawing a simple square where the texture is going to be rendered and writing a GLSL fragment shader that modifies the UV coordinates that the texture sampler uses to create the desired effect.
Setting it up:
First, we start by setting a global variable named canvas, this will be used to reference the canvas element in which we will render. Also, it is set explicitly that the canvas contents will change constantly, 60 times per second ideally, due to the fact that requestAnimationFrame is used.
canvas = document.getElementById( "gl-canvas" ); canvas.style.willChange = 'contents';
Variables that will tell the position of the mouse relative to the canvas are declared.
rect = canvas.getBoundingClientRect(); rectLeft = rect.left - window.scrollX; rectTop = rect.top + window.scrollY; rectLeftA = rectLeft; rectTopA = rectTop;
A variable named gl will be the WebGL context. A HTML Canvas element is used, if the browser does not allow the use of WebGL a message is shown. Also, the WebGL viewport is set to the same size as the Canvas.
gl = WebGLUtils.setupWebGL( canvas ); if ( !gl ) { alert( "WebGL isn't available." ); } gl.viewport( 0, 0, canvas.width, canvas.height );
A vertex and index buffer are created and loaded with the information contained in verSquareSimple and indSquareSimple.
bufVSquareSimple = gl.createBuffer(); gl.bindBuffer( gl.ARRAY_BUFFER, bufVSquareSimple ); gl.bufferData( gl.ARRAY_BUFFER,verSquareSimple, gl.STATIC_DRAW ); bufISquareSimple = gl.createBuffer(); gl.bindBuffer( gl.ELEMENT_ARRAY_BUFFER, bufISquareSimple ); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,indSquareSimple, gl.STATIC_DRAW);
Setting up the vertex shader and fragment shader to be used.
proSquareSimple = initShaders( gl, "progSquareSimpleVer", "progSquareSimpleFra" );
Getting the vertex attribute location for the attributes “vertices” and “uvs” used in the shader.
vLSquareSimple = gl.getAttribLocation( proSquareSimple, "vertices" ); gl.enableVertexAttribArray( vLSquareSimple ); cLSquareSimple = gl.getAttribLocation( proSquareSimple, "uvs" ); gl.enableVertexAttribArray( cLSquareSimple );
Getting the uniform location of the uniforms that are used in the fragment shader.
fUSquareSimple0 = gl.getUniformLocation( proSquareSimple, "pos" ); fUSquareSimple1 = gl.getUniformLocation( proSquareSimple, "time" ); fUSquareSimple2 = gl.getUniformLocation( proSquareSimple, "timeA" ); fUSquareSimple3 = gl.getUniformLocation( proSquareSimple, "propa" ); texSquareSimpleL = gl.getUniformLocation( proSquareSimple, "texture" );
We need to load the image that is going to be used as the texture on the square.
images[0].src = imaDisp[0]; images[0].onload = function() { configureTexture( images[0],0);};
The function named configureTexture creates the WebGL texture element, the image has RGBA information. A linear filter is used when the texture is rendered. Also, a variable loader counts up every time the image is loaded, this variable will be used to know if the image is loaded correctly before rendering.
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.texParameteri( gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri( gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); loader++; }
Lastly, some event listeners are set. The function scrollFunc is used to update the position of the cursor when the page is scrolled. The function mouseMoved updates the positon of the cursor when it is moved inside the Canvas. Both clickUpCanvas and clickDownCanvas are used to check clicks inside the Canvas.
document.addEventListener("scroll", scrollFunc); canvas.addEventListener("mousemove", mouseMoved); canvas.addEventListener("mouseup", clickUpCanvas); canvas.addEventListener("mousedown", clickDownCanvas);
What is happening inside the function scrollFunc is that the variables rectLeft and rectTop are updated using the current window.scrollX and window.scrollY values of the window.
function scrollFunc(event){ var wSY = window.scrollY; rectLeft = rectLeftA - window.scrollX; rectTop = rectTopA - wSY; }
The function mouseMoved is a short function that updates the values of two global variables, mouseX and mouseY, this variables are very important as they are used to know where on the Canvas the ripple is going to start from.
function mouseMoved(event){ mouseX = event.clientX - rectLeft; mouseY = event.clientY - rectTop; }
The main loop:
This function is the one that is called repeatedly to achieve animation, using requestAnimationFrame on loop. The global variable time increases by one every time loop is called, this variable is used as a timer. We check the variable loader to make sure that the texture is loaded before calling the rendering function.
function loop() { time++; if(loader===1){ render(); } requestAnimationFrame(loop); }
The render function:
In this function we do everything that is related to the WebGL context. First, the color buffer is cleared.
gl.clear(gl.COLOR_BUFFER_BIT);
Setting the program to use proSquareSimple.
gl.useProgram(proSquareSimple);
We bind the vertex buffer to then set the attribute pointers that are going to be used in this rendering, the pointers correspond to “vertices” and “uvs”, these are used in the vertex shader.
gl.bindBuffer( gl.ARRAY_BUFFER, bufVSquareSimple ); gl.vertexAttribPointer( vLSquareSimple, 3, gl.FLOAT, false, 4*(8), 0 ); gl.vertexAttribPointer( cLSquareSimple, 2, gl.FLOAT, false, 4*(8), 4*3 );
Configuring the texture.
gl.uniform1i(texSquareSimpleL, 0); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, textures[0]);
The maximum amount of ripples that can be created are 15, that is why the for loop counts to that number. The variables timeA and propa are arrays of length 15 that contain information specific to each ripple. The variable timeA is used as a timer and as the limit of the ripple, when timeA reaches 1000 the ripple stops completely. That is the amount of frames that the ripple exists for, so, 1000 frames at 60 frames per second means that a ripple is rendered for 16.67 seconds. The variable propa tells the limiting radius of each ripple, it becomes larger as time passes to create the effect of propagation from a center point.
for (var i = 0; i < 15; i++) { if(timeA[i]<1000){ timeA[i] += 0.1+(timeA[i]/250); propa[i] += 0.005; } }
Sending the uniform values for this render pass to the GPU. The variable time is common for every ripple. The variables posLi, timeA and propa are specific for each one.
gl.uniform2fv(fUSquareSimple0, posLi); gl.uniform1f(fUSquareSimple1, time/3); gl.uniform1fv(fUSquareSimple2, timeA); gl.uniform1fv(fUSquareSimple3, propa);
Finally, we bind the index buffer and render.
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, bufISquareSimple); gl.drawElements(4, indSquareSimple.length, gl.UNSIGNED_SHORT, 0);
The function clickUpCanvas gets triggered when the Canvas is clicked. The variable indexLi is the one that controls the pointer in the array. First, the array timeA is checked to see if the value is more than 50, this is done to create some time space between the creation of a new ripple and the last one. The variable posLi stores the value of the mouse cursor when a ripple is created, this is the center point from where the ripple propagates out. Both timeA and propa are initialized and indexLi counts up one to move the pointer of the arrays, also, when the value of indexLi is 15 the value is set back to 0 to restart the pointer.
function clickUpCanvas(event){ event.preventDefault(); if(timeA[indexLi]>50){ posLi[indexLi*2+0] = mouseX/512; posLi[indexLi*2+1] = 1-(mouseY/512); timeA[indexLi] = 0.01; propa[indexLi] = 0.01; indexLi++; if(indexLi===15){ indexLi = 0; } } }
The vertex shader:
The vertex shader, in this case, is a very simple and short program, it takes the vertex position and passes it as is with no modifications to gl_Positon. It also takes the attribute uvs and passes it to the fragment shader with the help of the varying register named uvsv, this information is used to sample the texture.
attribute vec4 vertices; attribute vec2 uvs; varying vec2 uvsv; void main() { gl_Position = vertices; uvsv = uvs; }
The fragment shader:
The fragment shader is in charge of the most important part of this example, in this program we use some uniform registers to recalculate the values in the texture sampler. First, we set the precision to mediump, this is the precision of the floats. The varying register uvsv is the one that has the original information that is used to calculate the texture, this is the one that is modified. Then, we declare a uniform that has the information of the texture.
precision mediump float; varying vec2 uvsv; uniform sampler2D texture;
The uniform pos is an array of length 15 that has the position of the center of the ripple, it is important to note that it is an array of vec2, meaning that is has two floats for each position in the array, this is because it has the X and Y coordinates of the point.
uniform vec2 pos[15];
Three other more uniforms are needed, time is a single float type while timeA and propa are both arrays of floats of length 15.
uniform float time; uniform float timeA[15]; uniform float propa[15];
A local float called dista is declared, uvsvA is a vec2 containing the same information as uvsv, this is done because we cannot modify the varying register uvsv as is, it needs to be precomputed first, so we use uvsvA instead, this is the variable that actually gets modified.
float dista; vec2 uvsvA = uvsv;
The for loop counts to 15 because that is the limit of ripples that can be created.
for(int i =0; i<15; i++){ if(timeA[i]<1000.0){ dista = distance(pos[i].xy, uvsvA.xy); if(dista<propa[i]){ float difX = uvsv.x-pos[i].x; float difY = uvsv.y-pos[i].y; float fact = 0.3*(dista/timeA[i])*cos(5.0*(3.1416/2.0)*(dista/0.09) - time); uvsvA.x -= fact*(difX/dista); uvsvA.y -= fact*(difY/dista); } } }
Similarly to the render function in the JavaScript file, timeA has a limit of 1000 frames, when timeA reaches that number the ripple stops being rendered.
for(int i =0; i<15; i++){ if(timeA[i]<1000.0){ dista = distance(pos[i].xy, uvsvA.xy); if(dista<propa[i]){ float difX = uvsv.x-pos[i].x; float difY = uvsv.y-pos[i].y; float fact = 0.3*(dista/timeA[i])*cos(5.0*(3.1416/2.0)*(dista/0.09) - time); uvsvA.x -= fact*(difX/dista); uvsvA.y -= fact*(difY/dista); } } }
The variable dista stores the distance between the center of the ripple, given by pos, and a point in the texture, given by uvsvA. Then, this distance is compared to the value of the uniform propa, if the distance is larger than the propagation radius defined by propa the ripple is not rendered. The variables difX and difY are the components of a vector that points from the center to the point in the texture that is sampled at the moment. The variable fact is the most important here, it describes how much the original point in the texture sampler is going to be offset. After calculating this, we subtract from the original point in uvsvA to get the new point, this new point has the direction calculated before.
for(int i =0; i<15; i++){ if(timeA[i]<1000.0){ dista = distance(pos[i].xy, uvsvA.xy); if(dista<propa[i]){ float difX = uvsv.x-pos[i].x; float difY = uvsv.y-pos[i].y; float fact = 0.3*(dista/timeA[i])*cos(5.0*(3.1416/2.0)*(dista/0.09) - time); uvsvA.x -= fact*(difX/dista); uvsvA.y -= fact*(difY/dista); } } }
Finally, we get the value color resulting from sampling the texture with the new value of uvsvA. This value is then passed to gl_FragColor.
vec4 color = texture2D( texture, uvsvA ); gl_FragColor = color;
That is all the important details that make this experiment work. I hope you now know how
to create this type of effect that takes advantage of the power that the fragment shader provides.
Also, I hope that this example gives you a better understanding of how to use WebGL in general.
Make sure to test it for yourself using the download link for the source files provided at the beginning.
I invite you to change some values in the code to see what happens, you can try to change the speed of
propagation of the ripples, maybe change the amount of ripples that can be created at once or change the
amount of time it takes for the ripples to disappear.
Keep experimenting and thanks for reading.