Animated Heat Distortion Effects with WebGL

A tutorial on how to use fragment shaders in WebGL to create an animated heat haze distortion effect on images and text.










Today we want to show you how to create some realistic looking heat haze distortion effects using fragment shaders in WebGL. The idea is to add a subtle animated distortion to a static image and some text.

Attention: The demos use some modern web technology that might not work in older browsers.

Please note that the current ZIP file is the compiled build of this project; source files can be found in the GitHub repo. See the link below.

Overview

For this article, we’ll pick apart one of the demos to give you an overview of how it works.

This effect uses WebGL. We’ve already seen WebGL being used in some experiments posted here on Codrops. Here you can see an introduction on how it works. For a more in-depth explanation, take a look at the WebGL Fundamentals page, look into Learning WebGL, or start right away with three.js or pixi.js.

The important thing to have in mind for this effect is how fragment shaders, or pixel shaders, work: it runs a function for every pixel of the area that is being processed – in our case, the entire canvas – and returns a color, which will be set for the said pixel. In order to be able to do what we want with the shader, we can send information to it, such as the current pixel position, images (as textures), mouse position, etc.

Let’s look into a few aspects of the demo to get an idea of how we can use it.

Distortion

The heart of this effect is the heat haze distortion. First let’s have a look at how we can draw a regular image, and then we’ll look into how we can distort it.

This is how we can get the color of a pixel in a texture in the same position as the pixel being processed currently by the shader:


<span class="hljs-comment">// "attribute", "varying" and "uniform" variables are values passed down from</span>
<span class="hljs-comment">// other parts of the code: the program, the vertex shader, etc.</span>
<span class="hljs-keyword">varying</span> <span class="hljs-keyword">vec2</span> position;
<span class="hljs-keyword">uniform</span> <span class="hljs-keyword">sampler2D</span> <span class="hljs-built_in">texture</span>;

<span class="hljs-keyword">void</span> main(){
  <span class="hljs-comment">// Get the color of the pixel at the current position</span>
  <span class="hljs-keyword">vec4</span> color=<span class="hljs-built_in">texture2D</span>(<span class="hljs-built_in">texture</span>,position);

  <span class="hljs-built_in">gl_FragColor</span>=color;
}

distortion-normal

Instead of simply getting the pixel from the current position though, we can apply some transform the position value to get the pixel from a different position. For example:

...
<span class="hljs-keyword">float</span> distortion=position.y*<span class="hljs-number">0.2</span>;
<span class="hljs-keyword">vec4</span> color=<span class="hljs-built_in">texture2D</span>(<span class="hljs-built_in">texture</span>,<span class="hljs-keyword">vec2</span>(position.x+distortion,position.y));

distortion-slant

Now, to get a simple but interesting distortion, we could vary the position based on a sine wave.

sine

Here we will add to the x position a sine curve based on the y position.

...
<span class="hljs-comment">// Since the position usually goes from 0 to 1, we have to multiply the result</span>
<span class="hljs-comment">// of the sine by a small value so to not make the effect too harsh.</span>
<span class="hljs-comment">// For the same reason, we have to multiply the value inside the sine function</span>
<span class="hljs-comment">// by a large value if we want a higher frequency of the curves.</span>
<span class="hljs-keyword">float</span> frequency=<span class="hljs-number">100.0</span>;
<span class="hljs-keyword">float</span> amplitude=<span class="hljs-number">0.003</span>;
<span class="hljs-keyword">float</span> distortion=<span class="hljs-built_in">sin</span>(position.y*frequency)*amplitude;
<span class="hljs-keyword">vec4</span> color=<span class="hljs-built_in">texture2D</span>(<span class="hljs-built_in">texture</span>,<span class="hljs-keyword">vec2</span>(position.x+distortion, position.y));

distortion-sine

To animate it, we may do the following: send to the shader a value that increments every frame, and use that value in the sine function.
To send a value every frame, we can use the JS function requestAnimationFrame:

(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">draw</span>()</span>{
  <span class="hljs-comment">// do something every frame</span>
  requestAnimationFrame(draw);
}())

Now let’s step aside for a moment. One thing we have to keep in mind for animations in general: the frequency of updates, that is, frames per second, is often inconsistent and unpredictable. The device might hang for a moment, the device might be a bit slow – or, as sometimes is the case when one tries to run old games on modern hardware, far too fast – etc. So, a good way to compensate for that is to check how long it has been since the last frame has been drawn and take that into account when we draw the next frame. For example:


<span class="hljs-keyword">var</span> fps=<span class="hljs-number">60</span>; <span class="hljs-comment">// target frame rate</span>
<span class="hljs-keyword">var</span> frameDuration=<span class="hljs-number">1000</span>/fps; <span class="hljs-comment">// how long, in milliseconds, a regular frame should take to be drawn</span>
<span class="hljs-keyword">var</span> time=<span class="hljs-number">0</span>; <span class="hljs-comment">// time value, to be sent to shaders, for example</span>
<span class="hljs-keyword">var</span> lastTime=<span class="hljs-number">0</span>; <span class="hljs-comment">// when was the last frame drawn</span>
(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">draw</span>(<span class="hljs-params">elapsed</span>)</span>{
  <span class="hljs-comment">// how long ago has the last frame been rendered</span>
  <span class="hljs-keyword">var</span> delta=elapsed-lastTime;
  lastTime=elapsed;

  <span class="hljs-comment">// how much of a frame did the last frame take</span>
  <span class="hljs-keyword">var</span> step=delta/frameDuration;
  <span class="hljs-comment">// add it to the time counter</span>
  time+=step;

  <span class="hljs-comment">// now for example we can compensate the speed of an animation</span>
  ball.x += <span class="hljs-number">20</span>*step;

  requestAnimationFrame(draw);
}(<span class="hljs-number">0</span>));

So now we can send the time value to our shader every frame and use it to animate the sine wave.

In the JS file:

...
(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">draw</span>(<span class="hljs-params">elapsed</span>)</span>{
  ...

  <span class="hljs-comment">// get the location of the "time" variable in the shader</span>
  <span class="hljs-keyword">var</span> location=gl.getUniformLocation(program,<span class="hljs-string">"time"</span>);
  <span class="hljs-comment">// send the time value</span>
  gl.uniform1f(location,time);

  ...
})

… and in the shader:

...
<span class="hljs-keyword">float</span> speed=<span class="hljs-number">0.03</span>;
<span class="hljs-keyword">float</span> distortion=<span class="hljs-built_in">sin</span>(position.y*frequency+time*speed)*amplitude;
<span class="hljs-keyword">vec4</span> color=<span class="hljs-built_in">texture2D</span>(<span class="hljs-built_in">texture</span>,<span class="hljs-keyword">vec2</span>(position.x+distortion, position.y));

Notice though that the distortion is being applied to the entire image. One way to do so only on certain areas is to use another texture as a map, and paint areas brighter or darker proportionally to how strong or weak we want the distortion to be.

distortion-map

Note that the edges are blurry – this is to attenuate the effect and keep from distorting things we don’t want to.

Then, we can multiply the amount of the distortion by the brightness of the current pixel of the map.

...
<span class="hljs-comment">// if our map is grayscale, we only need to get the value of one channel</span>
<span class="hljs-comment">// (in this case, red) to get the brightness</span>
<span class="hljs-keyword">float</span> map=<span class="hljs-built_in">texture2D</span>(map,position).r;
<span class="hljs-keyword">vec4</span> color=<span class="hljs-built_in">texture2D</span>(<span class="hljs-built_in">texture</span>,<span class="hljs-keyword">vec2</span>(position.x+distortion*map, position.y));

Depth

The depth/parallax effect works much the same way – get a color value from a slightly different position based on a map and some values. In this case, the values are the mouse x and y position.

...
<span class="hljs-built_in">document</span>.addEventListener(<span class="hljs-string">'mousemove'</span>,<span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">event</span>)</span>{
  <span class="hljs-keyword">var</span> location=gl.getUniformLocation(program,<span class="hljs-string">"mouse"</span>);
  <span class="hljs-comment">// send the mouse position as a vec2</span>
  gl.uniform2f(location,event.clientX/canvas.width,event.clientY/canvas.height);
})
...
<span class="hljs-keyword">vec2</span> parallax=mouse*<span class="hljs-number">0.005</span>;
<span class="hljs-keyword">vec2</span> distortedPosition=<span class="hljs-keyword">vec2</span>(position.x+distortion*map, position.y);
<span class="hljs-keyword">vec4</span> color=<span class="hljs-built_in">texture2D</span>(<span class="hljs-built_in">texture</span>,distortedPosition+parallax);

Now we just need a depth map. This will probably be a different map than the one we used before. So, instead of loading two textures, one for each map, we can add both maps on the same image file, each in a separate channel – that is, one in the red channel and one in the green channel. This way we can save loading time and system memory.

both-maps

In the code, we refer to each one of them by their respective channels:

...
<span class="hljs-comment">// get the current map pixel</span>
<span class="hljs-keyword">vec4</span> maps=<span class="hljs-built_in">texture2D</span>(mapsTexture,pos);

<span class="hljs-comment">// get each map value</span>
<span class="hljs-keyword">float</span> depthMap=maps.r;
<span class="hljs-keyword">float</span> distortionMap=maps.g;
...

<span class="hljs-keyword">vec2</span> distortedPosition=<span class="hljs-keyword">vec2</span>(position.x+distortion*distortionMap, position.y);
<span class="hljs-keyword">vec4</span> color=<span class="hljs-built_in">texture2D</span>(<span class="hljs-built_in">texture</span>,distortedPosition+parallax*depthMap);

Keep in mind though that this is a quick and dirty way to do the depth effect. As such, it should be kept subtle, otherwise artifacts will soon be very apparent.

Content

So far, we’ve seen that we can use images and do pretty much anything we like with them. So what about adding text to it?

We can’t draw HTML content to a canvas object – neither in WebGL nor in its 2d context. Creating text on canvas is tricky, and while loading a bitmap containing the text is possible, it has its problems: limited resolution, file size, harder to author, etc.

A solution is to use SVG – we can draw externally loaded SVG files to a canvas object, and then use that canvas as a texture. SVG files are easier to maintain, are light when compared to bitmaps and are resolution independent, and could even possibly be generated by the server on the fly.

This is a quick way to load an SVG and paint it on a canvas:

  <span class="hljs-keyword">var</span> canvas=<span class="hljs-built_in">document</span>.createElement(<span class="hljs-string">'canvas'</span>);
  loadSVG(<span class="hljs-string">'file.svg'</span>,canvas);

  <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">loadSVG</span>(<span class="hljs-params">file,canvas</span>)</span>{
    <span class="hljs-keyword">var</span> svg=<span class="hljs-keyword">new</span> Image();
    svg.addEventListener(<span class="hljs-string">'load'</span>,<span class="hljs-function"><span class="hljs-keyword">function</span>()</span>{
      <span class="hljs-keyword">var</span> ctx=canvas.getContext(<span class="hljs-string">'2d'</span>);
      canvas.width=svg.width;
      canvas.height=svg.height;
      ctx.drawImage(svg,<span class="hljs-number">0</span>,<span class="hljs-number">0</span>);
    })
    svg.src=file;
  }

Now we can use that canvas just like any other texture.

A trick to facilitate the positioning of the texture we just created into the WebGL container is to create and position the canvas just like any other element – that is, with HTML and CSS – and get its final position with getBoundingClientRect, and then send it to the shader.

<span class="hljs-keyword">var</span> title=<span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'canvas'</span>);
<span class="hljs-keyword">var</span> bounds=title.getBoundingClientRect();
<span class="hljs-keyword">var</span> location=gl.getUniformLocation(program,<span class="hljs-string">"contentPosition"</span>);
gl.uniform2f(location,bounds.left,bounds.top);
<span class="hljs-keyword">var</span> location=gl.getUniformLocation(program,<span class="hljs-string">"contentSize"</span>);
gl.uniform2f(location,bounds.width,bounds.height);

Then, when drawing the text, we can use yet another map to determine if anything goes over the text:

TextAntilope

And that’s our final result. We’ve take this first example and explained it in detail, but there are many more possibilities, including distortion effects for water, like you can see in the last demo.

And that’s it! Hope you enjoyed this effect and find it inspiring!

Tagged with:

Stay up to date with the latest web design and development news and relevant updates from Codrops.

Feedback 67

Comments are closed.
  1. Very impressed. Any chance you could make a plugin to make it easier to reproduce? That would be amazing!!