How to create a wave displacement WebGL effect

How to create a wave displacement WebGL effect

Jan 1, 2021 | Read time 6 minutes

🔔 Table of contents

See the demo here

In this article, I will be doing a portfolio concept design with a displacement filter effect on the main hero image. Displacement filters are not particularly well known, I just know it from my time using PhotoShop. I will be using the PixiJS library to handle this since they have a default “DisplacementFilter” function that we can play around with.

(Note: I havent had the time to make this fully responsive - so please excuse me :))

What the heck is a displacement filter?

A displacement filter can be used to achieve some crazy warping effects. It distorts pixels from one image using another image called the displacement map. Displacement maps are usually images that are grayscale. I will be using the ‘ripple.png’ as my displacement map.

So in our case with PixiJs, when we call the displacement filter function, it takes the values from our displacement map to look up the correct values to output. As an example:

displacement filter explanation - before application displacement filter explanation - after application

HTML

The HTML for this is not too complicated - the main action is in the Javascript and PixiJS

<!doctype html>
<html>

<head>
      <meta charset="utf-8">
      <title>Create a wave displacement WebGL effect</title>
      <link rel="stylesheet" href="style.css">
      <script src="https://unpkg.com/ionicons@4.5.10-0/dist/ionicons.js"></script>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/2.0.2/TweenMax.min.js"></script>
      <link rel="preconnect" href="https://fonts.gstatic.com">
      <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300&display=swap" rel="stylesheet">
</head>
<script src="pixi.min.js"></script>

<body>

      <div class="container">

            <div class="nav">
                  <div class="search">
                        <ion-icon name="search"></ion-icon>
                  </div>
                  <div class="menu">
                        <ion-icon name="more"></ion-icon>
                  </div>
            </div>

            <div class="header">
                  <h1>Portfolio</h1>
            </div>

            <div class="media">
                  <ul>
                        <li>
                              <ion-icon name="logo-facebook"></ion-icon>
                        </li>
                        <li>
                              <ion-icon name="logo-instagram"></ion-icon>
                        </li>
                        <li>
                              <ion-icon name="logo-twitter"></ion-icon>
                        </li>
                  </ul>
            </div>

            <div class="share">
                  <ion-icon name="share"></ion-icon>
            </div>

            <div class="bottomnav">
                  <ul>
                        <li>
                              <ion-icon id="arrow-down" name="arrow-down"></ion-icon>
                        </li>
                        <li>
                              <ion-icon id="arrow-up" name="arrow-up"></ion-icon>
                        </li>
                  </ul>
            </div>

      </div>


      <script type="text/javascript" src="main.js">
      </script>
</body>

</html>

CSS

html, body {
      margin: 0;
      padding: 0;
      width: 100%;
      height: 100vh;
}

body {
    margin: 0;
    padding: 0;
}

.nav {
      position: fixed;
      width: 97%;
      height: 80px;
}

.brand, .search, .menu {
      position: absolute;
      line-height: 80px;
}

.brand {
      margin-left: 40px;
      font-family: 'Montserrat', sans-serif;
      color: #fff;
      font-size: 16px;
}

.search {
      right: 140px;
      color: #fff;
}

.menu{
      right: 40px;
      font-family: 'Montserrat', sans-serif;
      color: #fff;
      font-size: 20px;
}

.media {
      position: absolute;
      right: 0;
      top: 50%;
      transform: rotate(90deg);
}

.media ul {
      list-style: none;
}

.media ul li {
      display: inline-block;
      text-transform: uppercase;
      color: rgba(255, 255, 255, .7);
      font-family: 'Montserrat', sans-serif;
      font-size: 1.5rem;
      font-weight: 300;
      letter-spacing: 2px;
      margin-right: 40px;
}

.share {
      position: absolute;
      bottom: 30px;
      left: 40px;
      color: #fff;
      font-size: 20px;
}

.header {
      position: absolute;
      top: 45%;
      left: 50%;
      transform: translate(-50%, -50%);
      color: #fff;
      font-family: 'Montserrat', sans-serif;
      font-size: 40px;
      letter-spacing: 3px;
}

h1 span {
      -webkit-text-stroke: 1px #fff;
      color: rgba(0,0,0,0);
}

.bottomnav {
      position: fixed;
      width: 15%;
      height: 80px;
      bottom: -40px;
      background: #fff;
      left: 70%;
      transform: translate(-50%, -50%);
}

.bottomnav ul {
      margin: 0;
      padding: 0;
      list-style: none;
      line-height: 80px;
      float: right;
}

.bottomnav ul li {
      padding-right: 50px;
      display: inline-block;
}

Javascript with PixiJs

In the JavaScript I am doing the following things:

  • Initializing PixiJs and add the app to the document body

  • Create a bunch of textures that we want to use. In this case, the textures are my Evangelion background images :)

  • Load the displacement map (ripple.png) to PixiJs.

  • Continually loop and update the filter and texture. Everytime we loop, we also calculate the mouse movement velocity so that we can add a scalling effect to the filter.

            var app = new PIXI.Application(window.innerWidth, window.innerHeight);
            document.body.appendChild(app.view);

            // create a texture from an image path
            var textureA = PIXI.Texture.fromImage('a.jpg');

            // create a second texture
            var textureB = PIXI.Texture.fromImage('b.jpg');
            var textureC = PIXI.Texture.fromImage('c.jpg');
            var textureD = PIXI.Texture.fromImage('d.jpg');

            app.stage.interactive = true;
            var posX, displacementSprite, displacementFilter, bg, vx;
            var container = new PIXI.Container();
            app.stage.addChild(container);

            PIXI.loader.add("ripple.png").load(setup);

            function setup() {
                  posX = app.renderer.width / 2;
                  displacementSprite = new PIXI.Sprite(PIXI.loader.resources["ripple.png"].texture);
                  displacementFilter = new PIXI.filters.DisplacementFilter(displacementSprite);
                  displacementSprite.anchor.set(0.5);
                  displacementSprite.x = app.renderer.width / 2;
                  displacementSprite.y = app.renderer.height / 2;
                  vx = displacementSprite.x;


                  app.stage.addChild(displacementSprite);
                  container.filters = [displacementFilter];
                  displacementFilter.scale.x = 0;
                  displacementFilter.scale.y = 0;
                  bg = new PIXI.Sprite(textureA);
                  bg.height = app.renderer.height;
                  bg.width = 700
                  container.addChild(bg);
                  app.stage.on('mousemove', onPointerMove).on('touchmove', onPointerMove);
                  loop();
            }

            var arrowDown = document.getElementById('arrow-down');
            var clickCount = 0;
            arrowDown.addEventListener('click', function () {
                  if (clickCount == 0) {
                        bg.texture = textureB

                  } else if (clickCount == 1) {
                        bg.texture = textureC

                  } else if (clickCount == 2) {
                        bg.texture = textureD

                  } else if (clickCount == 3) {
                        bg.texture = textureA;
                        clickCount = 0;
                        return;

                  }
                  clickCount++;

            })

            function onPointerMove(eventData) {
                  posX = eventData.data.global.x;
            }


            function loop() {
                  requestAnimationFrame(loop);
                  vx += (posX - displacementSprite.x) * 0.045;
                  displacementSprite.x = vx;
                  var disp = Math.floor(posX - displacementSprite.x);
                  if (disp < 0) disp = -disp;
                  var fs = map(disp, 0, 500, 0, 120);
                  disp = map(disp, 0, 500, 0.1, 0.6);
                  displacementSprite.scale.x = disp;
                  displacementFilter.scale.x = fs;
            }

            map = function (n, start1, stop1, start2, stop2) {
                  var newval = (n - start1) / (stop1 - start1) * (stop2 - start2) + start2;
                  return newval;
            };

            // GSAP animate the header
            TweenMax.from(".header h1", 2, {
                  delay: 1,
                  y: 20,
                  opacity: 0,
                  ease: Expo.easeInOut
            });

👋 About the Author

G'day! I am Huy a software engineer based in Australia. I have been creating design-centered software for the last 10 years both professionally and as a passion.

My aim to share what I have learnt with you! (and to help me remember 😅)

Follow along on Twitter , GitHub and YouTube