From our sponsor: Meco is a distraction-free space for reading and discovering newsletters, separate from the inbox.
In this tutorial we’re going to build a water-like effect with a bit of basic math, a canvas, and postprocessing. No fluid simulation, GPGPU, or any of that complicated stuff. We’re going to draw pretty circles in a canvas, and distort the scene with the result.
We recommend that you get familiar with the basics of Three.js because we’ll omit some of the setup. But don’t worry, most of the tutorial will deal with good old JavaScript and the canvas API. Feel free to chime in if you don’t feel too confident on the Three.js parts.
The effect is divided into two main parts:
- Capturing and drawing the ripples to a canvas
- Displacing the rendered scene with postprocessing
Let’s start with updating and drawing the ripples since that’s what constitutes the core of the effect.
Making the ripples
The first idea that comes to mind is to use the current mouse position as a uniform and then simply displace the scene and call it a day. But that would mean only having one ripple that always remains at the mouse’s position. We want something more interesting, so we want many independent ripples moving at different positions. For that we’ll need to keep track of each one of them.
We’re going to create a WaterTexture
class to manage everything related to the ripples:
- Capture every mouse movement as a new ripple in an array.
- Draw the ripples to a canvas
- Erase the ripples when their lifespan is over
- Move the ripples using their initial momentum
For now, let’s begin coding by creating our main App
class.
import { WaterTexture } from './WaterTexture';
class App{
constructor(){
this.waterTexture = new WaterTexture({ debug: true });
this.tick = this.tick.bind(this);
this.init();
}
init(){
this.tick();
}
tick(){
this.waterTexture.update();
requestAnimationFrame(this.tick);
}
}
const myApp = new App();
Let’s create our ripple manager WaterTexture
with a teeny-tiny 64px canvas.
export class WaterTexture{
constructor(options) {
this.size = 64;
this.radius = this.size * 0.1;
this.width = this.height = this.size;
if (options.debug) {
this.width = window.innerWidth;
this.height = window.innerHeight;
this.radius = this.width * 0.05;
}
this.initTexture();
if(options.debug) document.body.append(this.canvas);
}
// Initialize our canvas
initTexture() {
this.canvas = document.createElement("canvas");
this.canvas.id = "WaterTexture";
this.canvas.width = this.width;
this.canvas.height = this.height;
this.ctx = this.canvas.getContext("2d");
this.clear();
}
clear() {
this.ctx.fillStyle = "black";
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
update(){}
}
Note that for development purposes there is a debug option to mount the canvas to the DOM and give it a bigger size. In the end result we won’t be using this option.
Now we can go ahead and start adding some of the logic to make our ripples work:
- On
constructor()
addthis.points
array to keep all our ripplesthis.radius
for the max-radius of a ripplethis.maxAge
for the max-age of a ripple
-
On
Update()
,- clear the canvas
- sing happy birthday to each ripple, and remove those older than
this.maxAge
- draw each ripple
- Create
AddPoint()
, which is going to take a normalized position and add a new point to the array.
class WaterTexture(){
constructor(){
this.size = 64;
this.radius = this.size * 0.1;
this.points = [];
this.maxAge = 64;
...
}
...
addPoint(point){
this.points.push({ x: point.x, y: point.y, age: 0 });
}
update(){
this.clear();
this.points.forEach(point => {
point.age += 1;
if(point.age > this.maxAge){
this.points.splice(i, 1);
}
})
this.points.forEach(point => {
this.drawPoint(point);
})
}
}
Note that AddPoint()
receives normalized values, from 0 to 1. If the canvas happens to resize, we can use the normalized points to draw using the correct size.
Let’s create drawPoint(point)
to start drawing the ripples: Convert the normalized point coordinates into canvas coordinates. Then, draw a happy little circle:
class WaterTexture(){
...
drawPoint(point) {
// Convert normalized position into canvas coordinates
let pos = {
x: point.x * this.width,
y: point.y * this.height
}
const radius = this.radius;
this.ctx.beginPath();
this.ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
this.ctx.fill();
}
}
For our ripples to have a strong push at the center and a weak force at the edges, we’ll make our circle a Radial Gradient, which looses transparency as it moves to the edges.
Radial Gradients create a dithering-like effect when a lot of them overlap. It looks stylish but not as smooth as what we want it to look like.
To make our ripples smooth, we’ll use the circle’s shadow instead of using the circle itself. Shadows give us the gradient-like result without the dithering-like effect. The difference is in the way shadows are painted to the canvas.
Since we only want to see the shadow and not the flat-colored circle, we’ll give the shadow a high offset. And we’ll move the circle in the opposite direction.
As the ripple gets older, we’ll reduce it’s opacity until it disappears:
export class WaterTexture(){
...
drawPoint(point) {
...
const ctx = this.ctx;
// Lower the opacity as it gets older
let intensity = 1.;
intensity = 1. - point.age / this.maxAge;
let color = "255,255,255";
let offset = this.width * 5.;
// 1. Give the shadow a high offset.
ctx.shadowOffsetX = offset;
ctx.shadowOffsetY = offset;
ctx.shadowBlur = radius * 1;
ctx.shadowColor = `rgba(${color},${0.2 * intensity})`;
this.ctx.beginPath();
this.ctx.fillStyle = "rgba(255,0,0,1)";
// 2. Move the circle to the other direction of the offset
this.ctx.arc(pos.x - offset, pos.y - offset, radius, 0, Math.PI * 2);
this.ctx.fill();
}
}
To introduce interactivity, we’ll add the mousemove
event listener to app class and send the normalized mouse position to WaterTexture
.
import { WaterTexture } from './WaterTexture';
class App {
...
init(){
window.addEventListener('mousemove', this.onMouseMove.bind(this));
this.tick();
}
onMouseMove(ev){
const point = {
x: ev.clientX/ window.innerWidth,
y: ev.clientY/ window.innerHeight,
}
this.waterTexture.addPoint(point);
}
}
Great, now we’ve created a disappearing trail of ripples. Now, let’s give them some momentum!
Momentum
To give momentum to a ripple, we need its direction and force. Whenever we create a new ripple, we’ll compare its position with the last ripple. Then we’ll calculate its unit vector and force.
On every update, we’ll update the ripples’ positions with their unit vector and position. And as they get older we’ll move them slower and slower until they retire or go live on a farm. Whatever happens first.
export lass WaterTexture{
...
constructor(){
...
this.last = null;
}
addPoint(point){
let force = 0;
let vx = 0;
let vy = 0;
const last = this.last;
if(last){
const relativeX = point.x - last.x;
const relativeY = point.y - last.y;
// Distance formula
const distanceSquared = relativeX * relativeX + relativeY * relativeY;
const distance = Math.sqrt(distanceSquared);
// Calculate Unit Vector
vx = relativeX / distance;
vy = relativeY / distance;
force = Math.min(distanceSquared * 10000,1.);
}
this.last = {
x: point.x,
y: point.y
}
this.points.push({ x: point.x, y: point.y, age: 0, force, vx, vy });
}
update(){
this.clear();
let agePart = 1. / this.maxAge;
this.points.forEach((point,i) => {
let slowAsOlder = (1.- point.age / this.maxAge)
let force = point.force * agePart * slowAsOlder;
point.x += point.vx * force;
point.y += point.vy * force;
point.age += 1;
if(point.age > this.maxAge){
this.points.splice(i, 1);
}
})
this.points.forEach(point => {
this.drawPoint(point);
})
}
}
Note that instead of using the last ripple in the array, we use a dedicated this.last
. This way, our ripples always have a point of reference to calculate their force and unit vector.
Let’s fine-tune the intensity with some easings. Instead of just decreasing until it’s removed, we’ll make it increase at the start and then decrease:
const easeOutSine = (t, b, c, d) => {
return c * Math.sin((t / d) * (Math.PI / 2)) + b;
};
const easeOutQuad = (t, b, c, d) => {
t /= d;
return -c * t * (t - 2) + b;
};
export class WaterTexture(){
drawPoint(point){
...
let intensity = 1.;
if (point.age < this.maxAge * 0.3) {
intensity = easeOutSine(point.age / (this.maxAge * 0.3), 0, 1, 1);
} else {
intensity = easeOutQuad(
1 - (point.age - this.maxAge * 0.3) / (this.maxAge * 0.7),
0,
1,
1
);
}
intensity *= point.force;
...
}
}
Now we’re finished with creating and updating the ripples. It’s looking amazing.
But how do we use what we have painted to the canvas to distort our final scene?
Canvas as a texture
Let’s use the canvas as a texture, hence the name WaterTexture
. We are going to draw our ripples on the canvas, and use it as a texture in a postprocessing shader.
First, let’s make a texture using our canvas and refresh/update that texture at the end of every update:
import * as THREE from 'three'
class WaterTexture(){
initTexture(){
...
this.texture = new THREE.Texture(this.canvas);
}
update(){
...
this.texture.needsUpdate = true;
}
}
By creating a texture of our canvas, we can sample our canvas like we would with any other texture. But how is this useful to us? Our ripples are just white spots on the canvas.
In the distortion shader, we’re going to need the direction and intensity of the distortion for each pixel. If you recall, we already have the direction and force of each ripple. But how do we communicate that to the shader?
Encoding data in the color channels
Instead of thinking of the canvas as a place where we draw happy little clouds, we are going to think about the canvas’ color channels as places to store our data and read them later on our vertex shader.
In the Red and Green channels, we’ll store the unit vector of the ripple. In the Blue channel, we’ll store the intensity of the ripple.
Since RGB channels range from 0 to 255, we need to send our data that range to normalize it. So, we’ll transform the unit vector range (-1 to 1) and the intensity range (0 to 1) into 0 to 255.
class WaterEffect {
drawPoint(point){
...
// Insert data to color channels
// RG = Unit vector
let red = ((point.vx + 1) / 2) * 255;
let green = ((point.vy + 1) / 2) * 255;
// B = Unit vector
let blue = intensity * 255;
let color = `${red}, ${green}, ${blue}`;
let offset = this.size * 5;
ctx.shadowOffsetX = offset;
ctx.shadowOffsetY = offset;
ctx.shadowBlur = radius * 1;
ctx.shadowColor = `rgba(${color},${0.2 * intensity})`;
this.ctx.beginPath();
this.ctx.fillStyle = "rgba(255,0,0,1)";
this.ctx.arc(pos.x - offset, pos.y - offset, radius, 0, Math.PI * 2);
this.ctx.fill();
}
}
Note: Remember how we painted the canvas black? When our shader reads that pixel, it’s going to apply a distortion of 0, only distorting where our ripples are painting.
Look at the pretty color our beautiful data gives the ripples now!
With that, we’re finished with the ripples. Next, we’ll create our scene and apply the distortion to the result.
Creating a basic Three.js scene
For this effect, it doesn’t matter what we render. So, we’ll only have a single plane to showcase the effect. But feel free to create an awesome-looking scene and share it with us in the comments!
Since we’re done with WaterTexture
, don’t forget to turn the debug option to false.
import * as THREE from "three";
import { WaterTexture } from './WaterTexture';
class App {
constructor(){
this.waterTexture = new WaterTexture({ debug: false });
this.renderer = new THREE.WebGLRenderer({
antialias: false
});
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
document.body.append(this.renderer.domElement);
this.camera = new THREE.PerspectiveCamera(
45,
window.innerWidth / window.innerHeight,
0.1,
10000
);
this.camera.position.z = 50;
this.touchTexture = new TouchTexture();
this.tick = this.tick.bind(this);
this.onMouseMove = this.onMouseMove.bind(this);
this.init();
}
addPlane(){
let geometry = new THREE.PlaneBufferGeometry(5,5,1,1);
let material = new THREE.MeshNormalMaterial();
let mesh = new THREE.Mesh(geometry, material);
window.addEventListener("mousemove", this.onMouseMove);
this.scene.add(mesh);
}
init(){
this.addPlane();
this.tick();
}
render(){
this.renderer.render(this.scene, this.camera);
}
tick(){
this.render();
this.waterTexture.update();
requrestAnimationFrame(this.tick);
}
}
Applying the distortion to the rendered scene
We are going to use postprocessing to apply the water-like effect to our render.
Postprocessing allows you to add effects or filters after (post) your scene is rendered (processing). Like any kind of image effect or filter you might see on snapchat or Instagram, there is a lot of cool stuff you can do with postprocessing.
For our case, we’ll render our scene normally with a RenderPass
, and apply the effect on top of it with a custom EffectPass
.
Let’s render our scene with postprocessing’s EffectComposer
instead of the Three.js renderer
.
Note that EffectComposer
works by going through its passes
on each render. It doesn’t render anything unless it has a pass for it. We need to add the render of our scene using a RenderPass
:
import { EffectComposer, RenderPass } from 'postprocessing'
class App{
constructor(){
...
this.composer = new EffectComposer(this.renderer);
this.clock = new THREE.Clock();
...
}
initComposer(){
const renderPass = new RenderPass(this.scene, this.camera);
this.composer.addPass(renderPass);
}
init(){
this.initComposer();
...
}
render(){
this.composer.render(this.clock.getDelta());
}
}
Things should look about the same. But now we start adding custom postprocessing effects.
We are going to create the WaterEffect
class that extends postprocessing’s Effect
. It is going to receive the canvas texture in the constructor and make it a uniform in its fragment shader.
In the fragment shader, we’ll distort the UVs using postprocessing’s function mainUv
using our canvas texture. Postprocessing is then going to take these UVs and sample our regular scene distorted.
Although we’ll only use postprocessing’s mainUv
function, there are a lot of interesting functions you can use. I recommend you check out the wiki for more information!
Since we already have the unit vector and intensity, we only need to multiply them together. But since the texture values are normalized we need to convert our unit vector from a range of 1 to 0, into a range of -1 to 0:
import * as THREE from "three";
import { Effect } from "postprocessing";
export class WaterEffect extends Effect {
constructor(texture) {
super("WaterEffect", fragment, {
uniforms: new Map([["uTexture", new THREE.Uniform(texture)]])
});
}
}
export default WaterEffect;
const fragment = `
uniform sampler2D uTexture;
#define PI 3.14159265359
void mainUv(inout vec2 uv) {
vec4 tex = texture2D(uTexture, uv);
// Convert normalized values into regular unit vector
float vx = -(tex.r *2. - 1.);
float vy = -(tex.g *2. - 1.);
// Normalized intensity works just fine for intensity
float intensity = tex.b;
float maxAmplitude = 0.2;
uv.x += vx * intensity * maxAmplitude;
uv.y += vy * intensity * maxAmplitude;
}
`;
We’ll then instantiate WaterEffect
with our canvas texture and add it as an EffectPass after our RenderPass. Then we’ll make sure our composer only renders the last effect to the screen:
import { WaterEffect } from './WaterEffect'
import { EffectPass } from 'postprocessing'
class App{
...
initComposer() {
const renderPass = new RenderPass(this.scene, this.camera);
this.waterEffect = new WaterEffect( this.touchTexture.texture);
const waterPass = new EffectPass(this.camera, this.waterEffect);
renderPass.renderToScreen = false;
waterPass.renderToScreen = true;
this.composer.addPass(renderPass);
this.composer.addPass(waterPass);
}
}
And here we have the final result!
An awesome and fun effect to play with!
Conclusion
Through this article, we’ve created ripples, encoded their data into the color channels and used it in a postprocessing effect to distort our render.
That’s a lot of complicated-sounding words! Great work, pat yourself on the back or reach out on Twitter and I’ll do it for you 🙂
But there’s still a lot more to explore:
- Drawing the ripples with a hollow circle
- Giving the ripples an actual radial-gradient
- Expanding the ripples as they get older
- Or using the canvas as a texture technique to create interactive particles as in Bruno’s article.
We hope you enjoyed this tutorial and had a fun time making ripples. If you have any questions, don’t hesitate to comment below or on Twitter!
Stunning work, i appreciate that! Thanks for the experience share