From our sponsor: Meco is a distraction-free space for reading and discovering newsletters, separate from the inbox.
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.
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.
Tiny break: 📬 Want to stay up to date with frontend and trends in web design? Subscribe and get our Collective newsletter twice a tweek.
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:
// "attribute", "varying" and "uniform" variables are values passed down from
// other parts of the code: the program, the vertex shader, etc.
varying vec2 position;
uniform sampler2D texture;
void main(){
// Get the color of the pixel at the current position
vec4 color=texture2D(texture,position);
gl_FragColor=color;
}
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:
...
float distortion=position.y*0.2;
vec4 color=texture2D(texture,vec2(position.x+distortion,position.y));
Now, to get a simple but interesting distortion, we could vary the position based on a sine wave.
Here we will add to the x position a sine curve based on the y position.
...
// Since the position usually goes from 0 to 1, we have to multiply the result
// of the sine by a small value so to not make the effect too harsh.
// For the same reason, we have to multiply the value inside the sine function
// by a large value if we want a higher frequency of the curves.
float frequency=100.0;
float amplitude=0.003;
float distortion=sin(position.y*frequency)*amplitude;
vec4 color=texture2D(texture,vec2(position.x+distortion, position.y));
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
:
(function draw(){
// do something every frame
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:
var fps=60; // target frame rate
var frameDuration=1000/fps; // how long, in milliseconds, a regular frame should take to be drawn
var time=0; // time value, to be sent to shaders, for example
var lastTime=0; // when was the last frame drawn
(function draw(elapsed){
// how long ago has the last frame been rendered
var delta=elapsed-lastTime;
lastTime=elapsed;
// how much of a frame did the last frame take
var step=delta/frameDuration;
// add it to the time counter
time+=step;
// now for example we can compensate the speed of an animation
ball.x += 20*step;
requestAnimationFrame(draw);
}(0));
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:
...
(function draw(elapsed){
...
// get the location of the "time" variable in the shader
var location=gl.getUniformLocation(program,"time");
// send the time value
gl.uniform1f(location,time);
...
})
… and in the shader:
...
float speed=0.03;
float distortion=sin(position.y*frequency+time*speed)*amplitude;
vec4 color=texture2D(texture,vec2(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.
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.
...
// if our map is grayscale, we only need to get the value of one channel
// (in this case, red) to get the brightness
float map=texture2D(map,position).r;
vec4 color=texture2D(texture,vec2(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.
...
document.addEventListener('mousemove',function(event){
var location=gl.getUniformLocation(program,"mouse");
// send the mouse position as a vec2
gl.uniform2f(location,event.clientX/canvas.width,event.clientY/canvas.height);
})
...
vec2 parallax=mouse*0.005;
vec2 distortedPosition=vec2(position.x+distortion*map, position.y);
vec4 color=texture2D(texture,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.
In the code, we refer to each one of them by their respective channels:
...
// get the current map pixel
vec4 maps=texture2D(mapsTexture,pos);
// get each map value
float depthMap=maps.r;
float distortionMap=maps.g;
...
vec2 distortedPosition=vec2(position.x+distortion*distortionMap, position.y);
vec4 color=texture2D(texture,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
:
var canvas=document.createElement('canvas');
loadSVG('file.svg',canvas);
function loadSVG(file,canvas){
var svg=new Image();
svg.addEventListener('load',function(){
var ctx=canvas.getContext('2d');
canvas.width=svg.width;
canvas.height=svg.height;
ctx.drawImage(svg,0,0);
})
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.
var title=document.querySelector('canvas');
var bounds=title.getBoundingClientRect();
var location=gl.getUniformLocation(program,"contentPosition");
gl.uniform2f(location,bounds.left,bounds.top);
var location=gl.getUniformLocation(program,"contentSize");
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:
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!
This is really amazing.
Web are getting awesome and more awesome each days. Thanks for sharing this awesome trick, Lucas. Might be useful when most users migrated to modern browsers.
There’s a problem, tho. The only demos that work are Water and Wok Heat. Others gave me this error:
Error in program linking: C:\fakepath(102,39-107): error X3005: '_wave': identifier represents a variable, not a function Warning: D3D shader compilation failed with default flags. (ps_4_0) Retrying with skip validation C:\fakepath(102,39-107): error X3005: '_webgl_974aaeadebe6aa6f': identifier represents a variable, not a function Warning: D3D shader compilation failed with skip validation flags. (ps_4_0) Retrying with skip optimization C:\fakepath(102,39-107): error X3005: '_webgl_974aaeadebe6aa6f': identifier represents a variable, not a function Warning: D3D shader compilation failed with skip optimization flags. (ps_4_0) Failed to create D3D shaders.
I’m using Chrome 50 on Win 7.
Me too, Desert and Jet Heat demos doesn’t work with same errors
Same here, both on latest Chrome and latest Firefox (on Win 10)
same here
Thanks for reporting – should work now 🙂
wow ! amazing. Tnq
“The demos use some modern web technology that might not work in older browsers.” So my Chrome v50 is old… o_O ?
It works in my Chrome 50
Thanks guys, we are working on a fix.
Lindo, parabéns pelo maravilhoso trabalho!
Wow ! Nice effects !
I noticed that the javascript sources are compiled. There is a reason ?
It would be easier to understand how it works with scripts (js, shader).
Awesome!! really awesome
Not work in Opera
(nobody cares)
The first thing that came into my head when I saw this: “This should be a thing of Lucas Bebber!”. Said and done! Awesome work!
Awesome!! really awesome
Works well on Version 49.0.2623.112 m Chrome.
upgraded to Version 50.0.2661.94 m and still works perfectly, thanks for the tutorial!
Dang ~~ The desert & water demos look like video footage! Well done all; good write-up too!
All examples were ridiculously cool, good work!
que demais! parabéns e obrigado pelo ótimo artigo!
amazing job
well done
Wow. This is probably the best experiment I can see this year.
Beautiful example/ showcase!
Just AMAZING !! Very nice job !
Another magic from Codrops! Beautiful!
Really impressive! amazing work
I like water Demo bcs I can use it for my future web site. Thanks Codrops!
First of all this is a great idea!
On mobile though parallax range is too high especially in horizontal view. Tested on Chrome in Marshamllow and Firefox. The horns and legs look really weird. You could attempt to read orientation or do lock orientation. Not as widely supported as WebGL, but you can always try 🙂
This is seriously the sexiest use of modern web technologies I’ve ever seen!
My only issue is that when I tilt my phone, the parallax is reverse from what is expected. Example: if I tilt the left side of my phone downward, I should be able to see more of the animal’s back legs. Instead, I see less.
WELL DONE!
W O W! Fantastic work!!!
The masking effect left me stunned… Applause for you!
Marco
Hello,
I’m a big fun of tympanus, but heyyy why this is not working in IE
Because IE is a piece of shit
Because… Nobody use internet explorer anymore?
Because you’re the only one using IE in this whole entire universe and Codrops won’t waste a single second creating millions of lines of code so that you can use a disgusting piece of ancient software that is Internet Explorer.
Superb, thanks for sharing!
Cool… Awesome
Lucas Bebber your design is really incredible. I voted in awwwards. We want to your new projects.
I have a problem once I have downloaded the source code.
this is the text issue:
Uncaught SecurityError: Failed to execute 'texImage2D' on 'WebGLRenderingContext': Tainted canvases may not be loaded.
Open the project in netbeans. 😀
A more intelligent answer ?
How to resolve this problem ? thanks
I am straight up flabbergasted. This is such awesome work.
Got any github or uncompiled source available yet?
You can find the Github link in the end of the article.
Congratulation, awesome demo again.
But am I mistaken, or does the Jet demo show the suction part of the engine – where the air is not heated yet…
Wow, I find many thing I need. Cool and interesting work. Just want to find out more about shades and parallax effect.
It’s really AMAZING!!! SO COOL!!!
Realy good work, but doesn’t work on the latest IE
When I downloaded it on my machine it doesn’t work on local machine, any idea why ?
Simply beautiful, impressive moods. Thank you for sharing the code! Imho, a rare example of elaborate and classy interactive design, not just technology overkill and fx-mania. Inspiring work, thanks again!
Its really awesome.
HI!. About mask:
”
// if our map is grayscale, we only need to get the value of one channel
// (in this case, red) to get the brightness
float map=texture2D(map,position).r;
vec4 color=texture2D(texture,vec2(position.x+distortion*map, position.y));
”
I understand it, but how to send mask into shader?
mask = new Image()
gl.uniform1f(gl.getUniformLocation(program, ‘mask’), mask)
isn’t working.
Thanks
This is super cool Lucas. Great Job!! How can I increase the water effect for images that doesn’t have much noise on it? Thanks
Such an amazing work, very cool. Nice job Lucas. Please, how can I increase the the water effect on images that doesn’t have so much noise like that of the example? Thanks
This site is something else, this effect is just phenomenon.
Hi, Thanks for the great work! can you upload the source code to the github?
I’m blown away by this. Started taking courses on Pluralsight on WebGL because of this demo. Thanks for the detailed write up. Really helps make sense of your approach.
amaizing work
Awsome
Wow.. Just wow.
Here is the Github sources: https://github.com/lbebber/HeatDistortionEffect
Awesome effect! I have a problem, how can I achieve only the depth effect on a texture? I can’t really understand how do you use the texture map on your js file, can you explain that please?
Thanks!