Interactive image grid in WebVR with A-Frame

In this week's post; I’ll share how I created an interactive image grid in WebVR, with A-Frame. I found it an engaging pattern for when you need to display a set of images in VR.

Posted in Prototype
September 25, 2017
By Noam Almosnino

You can check out the live example of the image grid here.

Click to play video


How it works

Once loaded, click the images to move in the scene. You’ll see that the camera animates to each image, and that all the images interactively face you, as you move around the scene.

The idea behind the implementation of this prototype is as follows:

  1. Dynamically add event handlers to all the images.
  2. When an image is clicked, it will update the camera’s animation position. Then is will trigger the move animation.
  3. Finaly, you’ll add a look-at component to each image, so that it faces the camera as the user moves in the scene.

Get started on Glitch

I’ve created a starter project for this tutorial on Glitch, visit it here and click the “Remix this” button to get started. The images are already in place, your job will be to make them interactive.

The first thing your going to do is create a custom component that will house your javascript for the scene.

Right after the main.js script tag, create a new script with the following code:

<script type="text/javascript">
AFRAME.registerComponent('gallery', {
  schema: {},
  init: function () {
	
  }
});
</script>

Your code will go inside the init function.

Declare the relevant variables

To make the code more readable you’ll declare some variables up front that will map to elements in your scene. Add the following code to your script:

<script type="text/javascript">
AFRAME.registerComponent('gallery', {
  schema: {},
  init: function () {
    var camera = document.querySelector('#main-camera-wrapper');
    var images = document.querySelectorAll('a-image');
    var CAMERA_Z = 3
  }
});
</script>

The last variable, CAMERA_Z will be used in each movement. During the first time the camera moves, it will zoom in from 4 to 3. After that it will stay at the constant z value. of 3

Hook up the image events

The next thing you’ll want to do is cycle through all the images, and add click event handlers to them. We already have the list of images from document.querySelectorAll.

<script type="text/javascript">
AFRAME.registerComponent('gallery', {
  schema: {},
  init: function () {
    var camera = document.querySelector('#main-camera-wrapper');
    var images = document.querySelectorAll('a-image');
    var CAMERA_Z = 3

    images.forEach(function(image) {
      image.addEventListener('click', function(e) {
      })
    })
  }
});
</script>

Grab the position

In the event listener, the next thing you want to do, is grab the image’s position, when the click for a particular image occurs, you’ll update the camera animation’s ‘to’ property with that value.

<script type="text/javascript">
AFRAME.registerComponent('gallery', {
  schema: {},
  init: function () {
    var camera = document.querySelector('#main-camera-wrapper');
    var images = document.querySelectorAll('a-image');
    var CAMERA_Z = 3

    images.forEach(function(image) {
      image.addEventListener('click', function(e) {
        var position = this.getAttribute('position')
        camera.setAttribute('animation', {
          to: {x: position.x, y: position.y, z: CAMERA_Z}
        })
      })
    })
  }
});
</script>

Trigger the camera animation

The next thing you want to do when an image is clicked, is tell the camera to animate to its position. If you look at the camera entity in your markup, you’ll notice that it has a startEvents property set to ‘imageClicked’.

<a-entity id="main-camera-wrapper" position="0.12 4.14 4.93" rotation="0 -0.35 0"
          animation="property: position; easing: easeOutQuad; startEvents: imageClicked;">
          ...

You want to trigger that ‘imageClicked’ event so that the animation starts.

Trigger the event with the emit function:

<script type="text/javascript">
AFRAME.registerComponent('gallery', {
  schema: {},
  init: function () {
    var camera = document.querySelector('#main-camera-wrapper');
    var images = document.querySelectorAll('a-image');
    var CAMERA_Z = 3

    images.forEach(function(image) {
      image.addEventListener('click', function(e) {
        var position = this.getAttribute('position')
        
        camera.setAttribute('animation', {
          to: {x: position.x, y: position.y, z: CAMERA_Z}
        })
      
        camera.emit('imageClicked')
      })
    })
  }
});
</script>

Now click the Show live button, you should see some of the movement when you click the images.

Make each image look-at you

Thanks to three.js and Kevin Ngo’s look-at component, it only takes one line of code to add the look-at functionality.

I’ve already added the relevant script in the head of the document, you just need to add in the code like this: Before the event listener for the image, you want to add the following line, it will tell the image to use the look-at component, and for it to always look at the camera:

<script type="text/javascript">
AFRAME.registerComponent('gallery', {
  schema: {},
  init: function () {
    var camera = document.querySelector('#main-camera-wrapper');
    var images = document.querySelectorAll('a-image');
    var CAMERA_Z = 3

    images.forEach(function(image) {
      // Set the look-at component to face the camera
      image.setAttribute('look-at', '[camera]');

      image.addEventListener('click', function(e) {
        var position = this.getAttribute('position')
        camera.setAttribute('animation', {
          to: {x: position.x, y: position.y, z: CAMERA_Z}
        })
        
        camera.emit('imageClicked')
      })
    })
  }
});
</script>

Now click the Show live button, and voila, you’ve got yourself an interactive image grid.

You’ll want to do one more thing before calling it done and showing it off to your team. Add the fuse cursor to is so that it works in VR mode (on mobile and cardboard).

Add the fuse cursor

If the user views the scene on a phone, they can point the fuse cursor at the images and the animations will start. This also works in a VR headset like Google Cardboard.

To add the fuse cursor, you’ll make it as a child of the camera entity:

<a-cursor id="fuse-cursor" fuse="true" geometry="radiusInner: 0.02; radiusOuter: 0.03; thetaLength: 360; thetaStart: 90;"
          color="#ffffff" opacity="1" repeat="1 1" shader="flat" position="0 0 -1" objects=".clickable"
          rotation="0 0 0" scale="1 1 1" visible="true"></a-cursor>

Click the Show live button, and point the cursor at the waypoints, wait about 1.5 seconds and the camera will animate. Notice the problem?

The fuse cursor is not giving you any feedback when it’s fusing. Here’s the approach I took in the code that Ottifox produces to give the user feedback:

After the cursor tag, you’ll add a primitive ring entity. The entity by default is transparent and the ring itself is not yet filled out. To make it look like a countdown, you’ll trigger a fusing event from the cursor to this element, and then it will start appearing and filling up like a countdown.

Here’s the code you want to add first, under the fuse cursor:

<a-camera id="main-camera" user-height="0"  wasd-controls="enabled: false;"
cursor="rayOrigin: mouse;" raycaster="objects: .clickable">
  <a-cursor id="fuse-cursor" fuse="true" geometry="radiusInner: 0.02; radiusOuter: 0.03; thetaLength: 360; thetaStart: 90;"
  color="#ffffff" opacity="1" repeat="1 1" shader="flat" position="0 0 -1" objects=".clickable"
  rotation="0 0 0" scale="1 1 1" visible="true"></a-cursor>
  <a-ring id="fuse-progress" radius-inner="0.02" radius-outer="0.03" theta-length="360"
  theta-start="90" color="#ef2e5f" opacity="0" repeat="1 1" shader="flat" position="0 0 -0.999"
  rotation="0 0 0" scale="1 1 1" visible="true" class="clickable" animation="delay: 0; dir: normal; dur: 1500; easing: linear; loop: 0; property: geometry.thetaLength; startEvents: fusing; to: 0; from: 360;"
  animation__1="delay: 0; dir: normal; dur: 500; easing: linear; loop: 0; property: opacity; startEvents: fusing; to: 1; from: 0;"></a-ring>
</a-camera>

Now you need to do one more thing via Javascript. When the fuse begins, you need to let the progress ring know. Update the your Javascript with the following code in the init method:

<script type="text/javascript">
AFRAME.registerComponent('image-grid', {
  schema: {},
  init: function () {
    this.fuse = document.querySelector('#fuse-cursor');
    this.fuseProgress = document.querySelector('#fuse-progress');
    var camera = document.querySelector('#main-camera-wrapper');
    var images = document.querySelectorAll('a-image');
    var CAMERA_Z = 2.5;
    var self = this;

    this.fuse.addEventListener('fusing', function (e) {
        self.fuseProgress.emit('fusing');
    });

    images.forEach(function(image) {
      image.setAttribute('look-at', '[camera]');

      image.addEventListener('click', function(e) {
        var position = this.getAttribute('position')
        camera.setAttribute('animation', {
          to: {x: position.x, y: position.y, z: CAMERA_Z}
        })
        camera.emit('imageClicked')
      })
    })
  }
});
</script>

What your saying is; when the fuse cursor begins fusing or when it hovers over a “clickable” entity, let the fuseProgress entity know that, and it will start the countdown animation.

Click Show live button now to see it in action.

Wrapping up

That’s the interactive image grid, I hope you enjoyed this pattern for displaying images in VR, if you decide to use it, I’d love to see what you’ve made.

If your interested in the final code, check it out here.

Thanks to the photographers who shared these photos on Unsplash:

  1. Jesse Williams - Subway
  2. Jonathan Pease - Chrysler Building
  3. Alec Cutter - Flatiron
  4. Leonardo Burgos - Brooklyn Birdge
  5. Anthony Delanoix - Liberty
  6. Pipe A. - Cityview

Want to know when we publish new posts?
Become a friend