Spiffy UV Parallax Effect With OpenGL

Aloha, OpenGL devs! šŸ Hereā€™s another tutorial! Iā€™m going to show you a nifty trick that game developers have long employed to bring their sidescrollers to life. Itā€™s something called the parallax effect. If youā€™d like a detailed explanation of it, thereā€™s some decent information on Wikipedia. But basically, the parallax effect is the thing that happens when objects that are closer to you (in the foreground) move faster than objects that are farther away from you (in the background). The farther away an object is, the slower it will scroll as youā€™re moving past it.

Remember this level?

Jungle Hijinx

I always thought it was cool how the different layers of trees in the background scrolled by at different rates. Itā€™s little flourishes like the parallax effect that make games greater than the sum of their pixels.

Weā€™re going to achieve our parallax scrolling by combining two of the techniques weā€™ve learned about in previous tutorials: uv mapping and animation. For those of you who havenā€™t done any of the previous tutorials yet, hereā€™s a list of all of them.

But really, you should start with the first one. At the very least, youā€™ll need to have your project folder set up like we described there, and youā€™ll need the images subfolder that we added when we started working with sprites. Once youā€™ve got the earlier tutorials out of the way, this one will make a lot more sense.

What Weā€™re Making

Hereā€™s how our parallax scene will look when weā€™re done. Only animated.

Slick Scrolling

Slick. You can thank this random PWL person for drawing the original seamless mountain layer images that weā€™re using. I found them on OpenGameArt.org, which offers a modest selection of art with permissive licenses. Thatā€™s handy!

Word Of Warning

Before we get into the code, I have an important announcement to announce. There will be two versions of this tutorial. Why? Wellā€¦ after I tested the original version (which weā€™re going to try first), I noticed that my web browserā€™s memory usage kept climbing. It seems like the animation is also pretty demanding in terms of processor power, as my laptopā€™s fans went into overdrive after only a minute or two of execution.

Iā€™ll tell you more about the reasons for the inefficiencies in the next tutorial (where Iā€™ve resolved them). For now all you need to know is that thereā€™s a better way to accomplish uv scrolling with three.js. The original version is more consistent with the way weā€™ve built things in previous tutorials, however, so I think it makes a better teaching tool. Weā€™ll stick with it for now.

Download This

Before we can get to the code, youā€™ll need to download the image files that weā€™ll be parallaxing. Click the link below to download the folder containing all six of them. Youā€™ll need to unzip the PNGs into your images folder, in the same directory with poop.png and goat.jpg and so on.

Seamless Landscape Layers Archive

Like this:

Where The Landscape Layers Go

It should be noted that Iā€™ve resized all of these images. Since this is just an example, Iā€™m not overly concerned with preserving the assetsā€™ quality. Iā€™ve scaled them down from their original resolution so that theyā€™re now 512 pixels square. Thatā€™s a power of two, which means that we can tile them using THREE.RepeatWrapping like we did last time. Of course the images werenā€™t square to begin with. Theyā€™re a lot wider than they are tall. So to make them square, I had to pad the top of each image with unused space. I looked at them in the image editor and figured out that only the bottom 288 pixels of each image are used after the resizing. Thatā€™s important because weā€™ll be using that number again in a bit. But enough talk. Letā€™s get to the code!

Code Code Code Code Code

Copy code. Paste code. Into file. This oneā€™s called opengl_examples/uv_parallax.html.

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Spiffy UV Parallax Effect With OpenGL</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
    <style>
      body {
        color: #ffffff;
        background-color: #000000;
        margin: 0px;
        overflow: hidden;
      }
    </style>
  </head>
  <body>

    <div id="container"></div>

    <script src="js/three.js"></script>
    <script src="js/Detector.js"></script>

    <script>

      var webGlSupported = Detector.webgl;
      if (!webGlSupported) {
        Detector.addGetWebGLMessage();
      }

      // 1
      const TEXTURE_RESOLUTION = 512;
      const TEXTURE_USED_HEIGHT = 288;

      const TEXTURE_USED_PROPORTION = TEXTURE_USED_HEIGHT / TEXTURE_RESOLUTION;

      const PARALLAX_RATES = [
        0.004,
        0.009,
        0.015,
        0.022,
        0.037,
        0.049
      ];

      const TEXTURES_COUNT = PARALLAX_RATES.length;

      var textureLoader = undefined;
      var textures = undefined;
      var loadedTexturesCount = undefined;

      var container = undefined;
      var camera = undefined;
      var scene = undefined;
      var renderer = undefined;

      var clock = undefined;

      var meshes = undefined;

      initializeTextureLoader();
      loadTextures();

      function initializeTextureLoader() {
        textureLoader = new THREE.TextureLoader();
      }

      // 2
      function loadTextures() {
        textures = [];
        loadedTexturesCount = 0;

        var onProgress = function(progressEvent) {};
        var onError = function(errorEvent) {console.error("Error loading texture", errorEvent);};

        for (var textureIndex = 0; textureIndex < TEXTURES_COUNT; textureIndex++) {
          var url = "images/landscape_layer_" + textureIndex + ".png";

          var texture = textureLoader.load(url, onTextureFinishedLoading, onProgress, onError);

          texture.wrapS = THREE.RepeatWrapping;
          texture.wrapT = THREE.RepeatWrapping;

          textures.push(texture);
        }
      }

      function onTextureFinishedLoading(texture) {
        loadedTexturesCount++;
        if (loadedTexturesCount == TEXTURES_COUNT) {
          resumeSetup();
        }
      }

      function resumeSetup() {
        initializeCamera();
        initializeScene();
        initializeRenderer();

        initializeClock();
        onTick();
      }

      function initializeScene() {
        scene = new THREE.Scene();

        meshes = [];

        for (var meshIndex = TEXTURES_COUNT - 1; meshIndex >= 0; meshIndex--) {
          var geometry = getRectangleGeometry();

          var texture = textures[meshIndex];
          var material = getMaterial(texture);

          var mesh = new THREE.Mesh(geometry, material);
          mesh.position.set(0.0, 0.0, 0.0);
          scene.add(mesh);

          meshes.push(mesh);
        }

      }

      // 3
      function getRectangleGeometry() {
        var geometry = new THREE.Geometry();

        var width = 320.0;
        var halfWidth = width * 0.5;

        var height = TEXTURE_USED_PROPORTION * width;
        var halfHeight = height * 0.5;

        var bottomLeftVertex = new THREE.Vector3(-halfWidth, -halfHeight, 0.0);
        var bottomRightVertex = new THREE.Vector3(halfWidth, -halfHeight, 0.0);
        var topLeftVertex = new THREE.Vector3(-halfWidth, halfHeight, 0.0);
        var topRightVertex = new THREE.Vector3(halfWidth, halfHeight, 0.0);

        geometry.vertices.push(bottomLeftVertex);
        geometry.vertices.push(bottomRightVertex);
        geometry.vertices.push(topLeftVertex);
        geometry.vertices.push(topRightVertex);

        geometry.faces.push(new THREE.Face3(0, 1, 2));
        geometry.faces.push(new THREE.Face3(1, 2, 3));

        geometry.bottomLeftUv = new THREE.Vector3(0.0, 0.0, 0.0);
        geometry.bottomRightUv = new THREE.Vector3(0.0, 0.0, 0.0);
        geometry.topLeftUv = new THREE.Vector3(0.0, TEXTURE_USED_PROPORTION, 0.0);
        geometry.topRightUv = new THREE.Vector3(0.0, TEXTURE_USED_PROPORTION, 0.0);

        geometry.faceVertexUvs[0] = getFaceUvLayer(
          geometry.bottomLeftUv,
          geometry.bottomRightUv,
          geometry.topLeftUv,
          geometry.topRightUv
        );

        return geometry;
      }

      function getFaceUvLayer(bottomLeftUv, bottomRightUv, topLeftUv, topRightUv) {

        var faceUvLayer = [
          [
            bottomLeftUv,
            bottomRightUv,
            topLeftUv
          ],
          [
            bottomRightUv,
            topLeftUv,
            topRightUv
          ]
        ];

        return faceUvLayer;
      }

      function getMaterial(texture) {
        return new THREE.MeshBasicMaterial({
          map: texture,
          side: THREE.DoubleSide,
          transparent: true
        });
      }

      function initializeCamera() {
        var aspectRatio = window.innerWidth / window.innerHeight;
        var screenWidth = undefined;
        var screenHeight = undefined;
        if (aspectRatio > 1.0) {
          screenWidth = 320.0 * aspectRatio;
          screenHeight = 320.0;
        } else {
          screenWidth = 320.0;
          screenHeight = 320.0 / aspectRatio;
        }

        var nearPlane = 1.0;
        var farPlane = 1000.0;
        camera = new THREE.OrthographicCamera(
          -0.5 * screenWidth,
          0.5 * screenWidth,
          0.5 * screenHeight,
          -0.5 * screenHeight,

          nearPlane,
          farPlane
        );

        var distanceFromScene = 500.0;
        camera.position.set(0.0, 0.0, distanceFromScene);
      }

      function initializeRenderer() {
        renderer = new THREE.WebGLRenderer();
        renderer.setPixelRatio(window.devicePixelRatio);
        renderer.setSize(window.innerWidth, window.innerHeight);

        container = document.getElementById("container");
        container.appendChild(renderer.domElement);
      }

      function renderScene() {
        renderer.render(scene, camera);
      }

      function initializeClock() {
        clock = new THREE.Clock();
      }

      // 4
      function updateUvs(elapsedTime) {

        for (var meshIndex = 0; meshIndex < TEXTURES_COUNT; meshIndex++) {
          var mesh = meshes[meshIndex];
          var parallaxRate = PARALLAX_RATES[meshIndex];

          var repititionsCount = 1.0;
          var uOffset = (parallaxRate * elapsedTime) % repititionsCount;

          var leftU = uOffset;
          var rightU = uOffset + repititionsCount;

          var geometry = mesh.geometry;

          geometry.bottomLeftUv.setX(leftU);
          geometry.topLeftUv.setX(leftU);

          geometry.bottomRightUv.setX(rightU);
          geometry.topRightUv.setX(rightU);

          geometry.elementsNeedUpdate = true;
        }

      }

      function onTick() {
        var elapsedTime = clock.getElapsedTime();

        updateUvs(elapsedTime);

        renderScene();
        window.requestAnimationFrame(onTick);
      }

    </script>

  </body>
</html>

Thereā€™s a lot of stuff happening, but if you glance over it quickly youā€™ll realize most of it is recycled from previous tutorials. Weā€™re just using the old stuff in some new ways.

From The Top

The first part I wanted to point out to you is at the top, where weā€™re defining some constants.

// 1
const TEXTURE_RESOLUTION = 512;
const TEXTURE_USED_HEIGHT = 288;

const TEXTURE_USED_PROPORTION = TEXTURE_USED_HEIGHT / TEXTURE_RESOLUTION;

const PARALLAX_RATES = [
  0.004,
  0.009,
  0.015,
  0.022,
  0.037,
  0.049
];

const TEXTURES_COUNT = PARALLAX_RATES.length;

Thereā€™s that 288 number I told you would be important. Weā€™re using it to calculate TEXTURE_USED_PROPORTION. Since uv coordinates work on a scale from 0.0 to 1.0, weā€™ll be able to plug in TEXTURE_USED_PROPORTION directly as a v coordinate. In this way, we can discard the unused portion of our textures but still make use of THREE.RepeatWrapping, since our textureā€™s full dimensions are still powers of two. Sneaky. But also cool.

The other thing to notice here is the PARALLAX_RATES array. Yes, these are the scrolling speeds of the six different layers weā€™ll be animating. Each frame, each layer will shift the u coordinates mapped to its four vertices over by an amount proportional its parallax rate.

Iā€™m Doing It Wrong

Nowā€¦ if youā€™re an experienced programmer, you probably already have a decent idea of how to approach writing this program floating around in your head. No doubt that approach involves defining a class from which our scrolling layers will be instantiated. And if youā€™ve looked over the program already youā€™re probably a little disappointed in me, as there are no class definitions to be found here (or in any of my tutorialsā€™ code). Sorry! I hope I havenā€™t offended too many nerds out there.

I said something to this effect in a previous tutorial, but allow me to present my rationalization. Basically, I want to keep the focus of these tutorials on OpenGL, not on JavaScript. If I saw any possibility that Iā€™d be reusing this code, then Iā€™d definitely want to be building up a library of classes that all the tutorials share. The problem with that approach is that it makes it really hard to figure out which version of which class goes with which tutorial. I know it smells bad, but at least this way all of the code that goes with each tutorial is contained in its own file. Itā€™s a compromise. I hope youā€™ll find it in your heart to forgive me someday.

Onward!

Letā€™sā€¦ change the subject. Textures! Before we only had to worry about one of them. But this time we have to load all six. Iā€™m taking care of this in part // 2.

// 2
function loadTextures() {
  textures = [];
  loadedTexturesCount = 0;

  var onProgress = function(progressEvent) {};
  var onError = function(errorEvent) {console.error("Error loading texture", errorEvent);};

  for (var textureIndex = 0; textureIndex < TEXTURES_COUNT; textureIndex++) {
    var url = "images/landscape_layer_" + textureIndex + ".png";

    var texture = textureLoader.load(url, onTextureFinishedLoading, onProgress, onError);

    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;

    textures.push(texture);
  }
}

function onTextureFinishedLoading(texture) {
  loadedTexturesCount++;
  if (loadedTexturesCount == TEXTURES_COUNT) {
    resumeSetup();
  }
}

Donā€™t forget that three.js does its loading asynchronously. So basically loadTextures starts all six textures loading at the same time. After that, nothing else happens in the main program. It just sits there and waits while three.js is loading the textures behind the scenes. Theyā€™re small files, so theyā€™ll usually finish almost instantly. But technically they could finish loading at any time and in any order. How will we know when all six of them are finished?

Thatā€™s where onTextureFinishedLoading comes in. Weā€™ve passed it into our textureLoaderā€™s load call, so it will run each time that one of the textures is ready. Each time it gets called, we increment the loadedTexturesCount and check to see if weā€™ve reached TEXTURES_COUNTā€“that is, if weā€™ve counted all the way up to 6 yet. If not, we keep waiting. But when we finally load the last of the textures, we call resumeSetup to finish initializing our scene.

Honestly it wouldnā€™t be a big deal if we hadnā€™t gone through all this trouble. We could have just finished initializing the scene and even started animating and rendering it, and nothing bad wouldā€™ve happened. If three.js doesnā€™t have all the data for a Texture when you try to render it, it simply skips that Mesh and moves on to the next one. Still, a partially loaded scene doesnā€™t look great. With games itā€™s always a good idea to preload as many resources as possible. You want to provide the smoothest possible experience for players by taking care of as much heavy computation as you reasonably can before they even start playing. Sure, weā€™re not writing a real game here, but we might as well get ourselves into that mindset, right?

Rectangles Upon Rectangles

I wanted to point out a few little things here in part // 3.

// 3
function getRectangleGeometry() {
  var geometry = new THREE.Geometry();

  var width = 320.0;
  var halfWidth = width * 0.5;

  var height = TEXTURE_USED_PROPORTION * width;
  var halfHeight = height * 0.5;

  var bottomLeftVertex = new THREE.Vector3(-halfWidth, -halfHeight, 0.0);
  var bottomRightVertex = new THREE.Vector3(halfWidth, -halfHeight, 0.0);
  var topLeftVertex = new THREE.Vector3(-halfWidth, halfHeight, 0.0);
  var topRightVertex = new THREE.Vector3(halfWidth, halfHeight, 0.0);

  geometry.vertices.push(bottomLeftVertex);
  geometry.vertices.push(bottomRightVertex);
  geometry.vertices.push(topLeftVertex);
  geometry.vertices.push(topRightVertex);

  geometry.faces.push(new THREE.Face3(0, 1, 2));
  geometry.faces.push(new THREE.Face3(1, 2, 3));

  geometry.bottomLeftUv = new THREE.Vector3(0.0, 0.0, 0.0);
  geometry.bottomRightUv = new THREE.Vector3(0.0, 0.0, 0.0);
  geometry.topLeftUv = new THREE.Vector3(0.0, TEXTURE_USED_PROPORTION, 0.0);
  geometry.topRightUv = new THREE.Vector3(0.0, TEXTURE_USED_PROPORTION, 0.0);

  geometry.faceVertexUvs[0] = getFaceUvLayer(
    geometry.bottomLeftUv,
    geometry.bottomRightUv,
    geometry.topLeftUv,
    geometry.topRightUv
  );

  return geometry;
}

One thing to take note of is the way weā€™re calculating the height of the meshes. I decided I wanted our scrolling layers to take up 320.0 units, which is the entire width of the guaranteed visible area. To make sure our imageā€™s proportions donā€™t get stretched out or squashed down at all, I just multiplied this value by TEXTURE_USED_PROPORTION to figure out how many units tall the scrolling layers should be.

Another thing youā€™ll want to look at is the initial uv coordinates Iā€™m providing. I left all four of the u coordinates at 0.0 for now because weā€™re going to update them before anything gets rendered. But the v coordinates are interesting. The bottom two are at 0.0, but the top two are at TEXTURE_USED_PROPORTION. Thatā€™s how weā€™re cutting out the blank area from the top of each texture. Weā€™re mapping only the bottom (the part with the image in it) onto our meshes.

Again I must apologize, as Iā€™m using a bit of a JavaScript hack here to make it easier to hold onto our uv coordinates. Iā€™m saving a reference to each uv point in our geometry object. If I were doing things the right way, bottomLeftUv, bottomRightUv, topLeftUv, and topRightUv would all be properties of my parallaxing layer class. I just wanted to point that out. ā˜ļø

Keep Scrolling, Scrolling, Scrollingā€¦

Part // 4 is the real fun part.

// 4
function updateUvs(elapsedTime) {

  for (var meshIndex = 0; meshIndex < TEXTURES_COUNT; meshIndex++) {
    var mesh = meshes[meshIndex];
    var parallaxRate = PARALLAX_RATES[meshIndex];

    var repititionsCount = 1.0;
    var uOffset = (parallaxRate * elapsedTime) % repititionsCount;

    var leftU = uOffset;
    var rightU = uOffset + repititionsCount;

    var geometry = mesh.geometry;

    geometry.bottomLeftUv.setX(leftU);
    geometry.topLeftUv.setX(leftU);

    geometry.bottomRightUv.setX(rightU);
    geometry.topRightUv.setX(rightU);

    geometry.elementsNeedUpdate = true;
  }

}

Iā€™m iterating through all the meshes. I pull each oneā€™s parallaxRate out of the array we created at the beginning. And then I do some reasonably simple calculations to find leftU and rightU. The value of elapsedTime increases slightly each time updateUvs gets called, but the % (modulo) operator converts this constant upward climb into an endless cycle. We apply that cycle to our horizontal texture coordinates to produce our perpetual parade of clouds, mountains, and hillsides.

One bit of trickery thatā€™s slightly obscured here is the way that our updates to the four corners of our texture get translated into the six points that make up the two triangles in each meshā€™s faceUvLayer layer array. Wellā€¦ since bottomLeftUv, topLeftUv, bottomRightUv, and topRightUv are objects, not primitives, JavaScript passes them around by reference. That means when we added these four Vector3 objects to our faceUvLayer array, they didnā€™t get copied. Thereā€™s still just one copy of each point. We actually stored references to each Vector3 in the faceUvLayer arrayā€¦ which means if we update the u coordinates in one place, weā€™re really updating the values everywhere theyā€™re referenced simultaneously. Presto.

Cliffhanger

This one was pretty cool. I hope you enjoyed watching the landscape scroll by, and I also hope I didnā€™t melt your processor. Like I said, Iā€™m going to show you how to make a much more efficient version in the next tutorial. This is the part where I usually tell you to meddle with the code to make sure you understand how it works. But honestly, itā€™s probably better to hold off for now. Try Part Two first, and then youā€™ll be able to conduct your experiments in a more forgiving environment. Iā€™ll bet you canā€™t wait!

Get Yer Downloadables Here

Click here to run the finished product!

And hereā€™s a link to a downloadable version of the source code: uv_parallax.html. Note that youā€™ll need to run it from inside the folder structure that we set up in the first tutorial, and youā€™ll also need to unzip the six images from the Seamless Landscape Layers Archive into your images subfolder.

Next Tutorial (UV Parallax Part 2)

More Efficient Spiffy UV Parallax Effect With OpenGL

rss facebook twitter github youtube mail spotify instagram linkedin google pinterest medium vimeo