More Efficient Spiffy UV Parallax Effect With OpenGL

‘Sup, OpenGL Developers. Welcome back. This is a continuation of the previous tutorial, in which we made a cool parallax scrolling landscape thing. It looked like this, except it was animated.

Scrolling Landscape

This time we’re going to be making the exact same thing. The only difference is that this version of it will run much more efficiently. The original version sort of made my laptop’s fans spin up like a jet engine. That’s not okay.

If you’re just joining us, you’ll probably want to go back and try the rest of my tutorials on OpenGL for 2D game development first. Here’s the running list of them:

Specifically, you’ll need to have your project folder set up like we did in the very first tutorial.

Don’t Forget

Before the new version will run on your computer, you’ll have to download the seamless mountain layer images from the original version and extract all six PNGs into your images folder. A big thanks to the artist and to OpenGameArt.org for providing the original artwork.

Seamless Landscape Layers Archive

Where The Landscape Layers Go

And Now The Conclusion

Are you ready? Hold on. Here we go!

Copy and paste the code, like always. Call this file opengl_examples/uv_parallax_buffer_geometry.html.

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Spiffy UV Parallax Effect (With BufferGeometry) 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();
      }

      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();
      }

      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 = getRectangleBufferGeometry();

          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);
        }

      }

      // 1
      function getRectangleBufferGeometry() {
        var geometry = new THREE.BufferGeometry();

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

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

        var positions = [
          -halfWidth, -halfHeight, 0.0,
          halfWidth, -halfHeight, 0.0,
          -halfWidth, halfHeight, 0.0,
          halfWidth, halfHeight, 0.0
        ];

        var uvs = [
          0.0, 0.0,
          0.0, 0.0,
          0.0, TEXTURE_USED_PROPORTION,
          0.0, TEXTURE_USED_PROPORTION
        ]

        var indices = [
          0, 1, 2,
          1, 2, 3
        ];

        var positionsTypedArray = new Float32Array(positions);
        var uvsTypedArray = new Float32Array(uvs);
        var indicesTypedArray = new Uint32Array(indices);

        var vector3Size = 3;
        var positionAttribute = new THREE.BufferAttribute(positionsTypedArray, vector3Size);
        geometry.addAttribute("position", positionAttribute);

        var vector2Size = 2;
        var uvAttribute = new THREE.BufferAttribute(uvsTypedArray, vector2Size);
        geometry.addAttribute("uv", uvAttribute);

        var indexSize = 1;
        var indexAttribute = new THREE.BufferAttribute(indicesTypedArray, indexSize);
        geometry.setIndex(indexAttribute);

        return geometry;
      }

      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();
      }

      // 2
      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 uvAttribute = mesh.geometry.getAttribute("uv");

          uvAttribute.setX(0, leftU);
          uvAttribute.setX(1, rightU);
          uvAttribute.setX(2, leftU);
          uvAttribute.setX(3, rightU);

          uvAttribute.needsUpdate = true;
        }

      }

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

        updateUvs(elapsedTime);

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

    </script>

  </body>
</html>

Looks The Same

I’ll save you the trouble of sifting through the code to find what’s new. Almost nothing has changed from the previous tutorial. The only differences have to do with this three.js class called BufferGeometry.

The Faulty Component

I figured out what was wrong last time that made memory usage continually climb and probably fried your CPU (sorry!). It turns out that in three.js, there are two different ways to build your Meshes. You can pass in an instance of THREE.Geometry, like we have in all the previous tutorials, or you can pass in an instance of this THREE.BufferGeometry class instead.

What’s the difference? Well–according to the docs, the BufferGeometry does the same thing, just more efficiently. Fine. But it might help us understand OpenGL better if we also understood why it’s more efficient.

OpenGL Likes To Do Things In Batches

The answer gets a little technical, but I promise you can handle it. I’m sure I’ve said before that OpenGL is a constant balancing act between the CPU and GPU. In general, the CPU is responsible for laying out the scene. It calculates where all of your scene’s triangles should go and what attributes each vertex should have. Once it’s done, it hands off instructions to render those vertices to the GPU. The thing is that the GPU is super picky about the format of those instructions.

The GPU is unbelievably fast, and it can convert gargantuan quantities of vertex data into pixels, but it’s exceptionally bad at switching contexts. It likes to process everything in batches of data with identical settings. The size of the batches doesn’t matter so much–they can be huge without posing much of a problem–but each time your GPU has to switch settings, it slows down a lot. Therefore it’s best if you can minimize the number of batches you hand it each frame.

Where Do Batches Come From?

Behind the scenes, three.js is doing a ton of work on your CPU to make your animation a reality. Each frame it’s going through all of your Meshes, figuring out where each point of each triangle should end up, and dropping the data representing those points into arrays. These arrays will get grouped together to become the batches that get shipped off to the GPU for rendering. However, OpenGL (in this case WebGL, since we’re doing this in a web browser) can’t accept just any old JavaScript array.

You Take The High-Level And I’ll Take The Low-Level

JavaScript works in a pretty high-level format, where each element of an array could be an integer or a floating-point value or a string or an object or null or an iguana. Just interpreting each element’s type takes a ton of extra processing, and that’s the kind of work that GPUs are simply incapable of doing. Therefore OpenGL mandates that every element of the arrays you pass to the GPU must be of the same type–typically all floats or all ints. They all have to take up the same number of bytes, and they all must be housed in a contiguous chunk of memory.

This is where TypedArrays come in. Since interfaces like WebGL (and other standards that need to access raw data) can’t process JavaScript’s high-level arrays, JavaScript invented these TypedArray objects. They’re basically a high-level handle to a low-level, contiguous sequence of bytes in memory. Of course, they’re pretty restrictive and don’t mix well with JavaScript’s other high-level objects, so you won’t want to use them if you don’t have to. But–they’re already in the format your GPU needs. How convenient!

Circling Back

Now we’re back to three.js and the Geometry versus BufferGeometry comparison. So far we’ve used Geometry objects exclusively. Geometry objects keep their data in easily manipulated high-level JavaScript structures, like regular arrays and Vector3 objects. Of course, since WebGL accepts only TypedArrays, that means that every time a Geometry’s properties get updated three.js must convert its high-level data into low-level data. With BufferGeometry, on the other hand, all of the data is kept in TypedArrays to begin with. There’s no need to do any conversion before passing it into the GPU, and that saves a whole lot of time and memory.

So How Do We Make A BufferGeometry?

I’m glad you asked. Finally, let’s look at part // 1!

// 1
function getRectangleBufferGeometry() {
  var geometry = new THREE.BufferGeometry();

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

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

  var positions = [
    -halfWidth, -halfHeight, 0.0,
    halfWidth, -halfHeight, 0.0,
    -halfWidth, halfHeight, 0.0,
    halfWidth, halfHeight, 0.0
  ];

  var uvs = [
    0.0, 0.0,
    0.0, 0.0,
    0.0, TEXTURE_USED_PROPORTION,
    0.0, TEXTURE_USED_PROPORTION
  ]

  var indices = [
    0, 1, 2,
    1, 2, 3
  ];

  var positionsTypedArray = new Float32Array(positions);
  var uvsTypedArray = new Float32Array(uvs);
  var indicesTypedArray = new Uint32Array(indices);

  var vector3Size = 3;
  var positionAttribute = new THREE.BufferAttribute(positionsTypedArray, vector3Size);
  geometry.addAttribute("position", positionAttribute);

  var vector2Size = 2;
  var uvAttribute = new THREE.BufferAttribute(uvsTypedArray, vector2Size);
  geometry.addAttribute("uv", uvAttribute);

  var indexSize = 1;
  var indexAttribute = new THREE.BufferAttribute(indicesTypedArray, indexSize);
  geometry.setIndex(indexAttribute);

  return geometry;
}

On the first line, we’re instantiating a BufferGeometry object rather than a plain old Geometry object. Naturally. The calculations dealing with width and height are the same, but instead of placing the coordinates into Vector3 objects, this time we’re throwing all of them into the same flat array. Note that this is not a TypedArray–we’re not to that part yet. It’s just a plain old JavaScript array. We’re doing this with our uv coordinates too.

And then we hit this indices array. Geometry objects used Face3 objects to define which vertices made up which triangles. We now know, however, that those high-level Face3s must ultimately be converted into low-level arrays before they can be passed to OpenGL. With BufferGeometry, we don’t even bother with Face3s. Instead, we just keep an array of integers. Every triad of integers represents the three indices that make up another triangle. Nothing fancy.

And at long last we’ve arrived at the TypedArray declarations. Actually, we’re creating Float32Array and Uint32Array versions of the three regular JavaScript arrays we just created. Once we have the low-level versions of the arrays, we pass them into THREE.BufferAttribute objects to tell three.js how their data is organized. Finally, we add the BufferAttributes to our BufferGeometry object and return it. That wasn’t too scary, was it?

We Also Updated updateUvs

Here’s the other part we changed:

// 2
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 uvAttribute = mesh.geometry.getAttribute("uv");

    uvAttribute.setX(0, leftU);
    uvAttribute.setX(1, rightU);
    uvAttribute.setX(2, leftU);
    uvAttribute.setX(3, rightU);

    uvAttribute.needsUpdate = true;
  }

}

The first half of the function is exactly the same. The rest is completely different, however. Since we’re using BufferGeometry now, we have a different interface for updating our vertices’ attributes. Before, we would change the coordinates of Vector3 objects, but since all of our vertices’ data is already in TypedArrays now, we don’t have any Vector3 objects to manipulate. Fortunately, three.js makes it reasonably easy to alter values within the TypedArrays using the BufferAttribute objects we added to our BufferGeometry. We simply pass in the index (0 through 3) of the vertex whose x (actually, u) value we want to change, and the BufferAttribute object does the rest.

Efficiency Achieved

That’s all we had to change! If you open up your web browser and run it, your animation should look just like it did before. Only this time your six meshes are backed by BufferGeometry objects, so the process of rendering them is much more straightforward. Your machine should be able to handle them with no trouble.

Now that you no longer have to worry about your motherboard melting, this is a good time to do some tinkering. Try playing around with the scrolling speeds of the various layers. See if you can get them to scroll backwards. Better yet, see if you can remap the meshes’ corners so that they scroll vertically. Hey, why not? Now that we know how BufferGeometry works, we’ll be using it a lot more in future examples.

Downloadables For Great Justice!!

Click here to run the finished product!

And here’s a link to a downloadable version of the source code: uv_parallax_buffer_geometry.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

Wiggly Bézier Curves With OpenGL

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