Chrome Experiments Demo Harness

HTML5 Rocks

The Making of the Chrome Experiments Demo Harness

The Chrome Experiments demo harness is a kiosk-style web app that loops through half a dozen non-interactive demos when idle. When you move the mouse, the demo loop lets you select an interactive demo from a larger list to play with. The loop also has fancy transitions between the demos, with the transition screen showing details about the demo.

Chrome Experiments booth at I/O 2011
One of the Chrome Experiment booths at Google I/O 2011.

The goal for the demo loop was to act as an eye-catching show floor presentation at Google I/O 2011. The idea being that visitors see the demo loop, go “Wow!” and come to the Chrome Experiments booth to play around with the interactive demos. Thus getting a feel for some of the awesome things that are possible on the web today.

To fulfill that goal, the demo loop needed a bunch of great-looking demos (thankfully, the Chrome Experiments site has no lack of those) and an experience as smooth as possible. Each of the demos had to run without slowing down the other demos or the transition animation. Which was quite a challenge, given that the best-looking demos also tended to be the heaviest.

IFRAME wrangling

What I finally ended up with was a bunch of iframes controlled via a window.postMessage protocol. What postMessage lets you do is send a message from one window to another, with a JS object as payload. The receiving window needs to add an event listener for the ‘message’ event that handles the data in the event object. What postMessage allowed me to do is start the current demo without reloading it, and pause all the other demos.

The final demo sequence worked like this: First, fade out the current demo and stop it. Second, fade in the transition canvas and play the transition animation. Third, stop the transition animation and fade out the transition canvas. And finally, fade in the next demo and start playing it. As you can see, there’s only one demo playing at any given time, meaning that all the demos run as fast as possible. Well, there’s still the extra memory use to contend with, but that’s a bit difficult to get around.

Demo running in the loop
A demo running in the loop.

I also tried a couple less successful variations, such as relying on requestAnimationFrame pausing hidden demos and removing the iframe elements from the document to stop them. The problem with requestAnimationFrame was that for some reason it didn’t pause the hidden demos. And the problem with removing and re-adding iframe elements to the document was that it caused the iframe to reload itself, causing the first couple seconds of each showing of the demo be filled with loading bars and incomplete content.

The code for the iframe swapping looks something like this:

this.currentIframe = getCurrentDemoIframe();
this.currentIframe.style.display = 'block';
var self = this;
setTimeout(function() {
  if (self.previousIframe && self.previousIframe != self.currentIframe) {
    self.previousIframe.style.display = 'none';
    if (self.previousIframe.contentWindow)
      self.previousIframe.contentWindow.postMessage('pause','*');
  }
}, 0);
this.onTransitionComplete = function() {
  if (self.currentIframe.contentWindow)
    self.currentIframe.contentWindow.postMessage('pause','*');
};
this.startTransitionAnimation();

With this bit hooked up to the demo iframe's animation tick function:

var paused = false;
window.addEventListener('message', function(ev){
 paused = (ev.data == 'pause');
}, false);
var animloop = function() {
  if (!paused)
    tick();
  requestAnimFrame(animloop, canvas);
};
requestAnimFrame(animloop, canvas);

The interactive demo menu

The menu for interactive demos appears when you move the mouse during the demo loop. If you leave the mouse alone for a couple seconds, the menu fades out. The menu has a nice animated transition with the menu items rotating into view. The transition is done using CSS 3D transforms and CSS transitions. Each menu item has a CSS transition duration set to a value that depends on its x-distance from the center of the display, creating a cool staggered transition effect.

The menu styling is all HTML and CSS. The menu titles use CSS text shadows, a CSS gradient background and a 1px bottom border to create a look of debossed text on a raised background. The menu item thumbnail images are scaled to fill their 180x120 container divs by setting the image width to 180px. The menu item background is set to 80% opacity black, i.e. rgba(0,0,0,0.8), and the menu items have a CSS box-shadow set to pop them from the background.

Here's an example menu item. It fades out when you hover over it (in the real menu, the menu items fade in when you hover over the menu):

Demo Name Author Name Technologies Used

The HTML for the menu item is nothing complicated. The demo thumbnail image is inside a div to clip it.

<div id="demoMenu">
  <div>
    <div class="image">
      <img src="thumbs/thumb.png">
    </div>
    <a href="#">Demo Name</a>
    <span class="author">
      Author Name
    </span>
    <span class="info">
      Technologies Used
    </span>
  </div>
</div>

The stylesheet for the menu items is way more complex though.

#demoMenu {
  z-index: 100;
  position: absolute;
  top: 0px;
  left: 0px;
  color: white;
  text-align: center;
  width: 100%;
  height: 100%;
  vertical-align: top;
  background-color: rgba(0,0,0,0);
  -webkit-transform-origin: 50% 0;
  -webkit-transition-duration: 0.5s;
  -webkit-transition-timing-function: ease-in-out;
  -webkit-perspective: 1920px;
  -webkit-perspective-origin: 50% 0;
}

#demoMenu > div {
  vertical-align: top;
  display: inline-block;
  font-size: 13px;
  width: 180px;
  height: 208px;
  padding: 0px;
  margin: 10px;
  background-color: rgba(0,0,0,0.8);
  -webkit-box-shadow: 0px 2px 5px rgba(0,0,0,0.75);
  -webkit-transform-origin: 50% 50%;
  -webkit-transition-duration: 0.5s;
  -webkit-transition-timing-function: ease-in-out;
}

#demoMenu .image {
  width: 180px;
  height: 120px;
  overflow: hidden;
  cursor: pointer;
}

#demoMenu .image img {
  width: 180px;
}

#demoMenu a {
  color: black;
  display: block;
  font-size: 14px;
  font-weight: bold;
  text-decoration: none;
  padding-top: 4px;
  padding-bottom: 4px;
  margin-bottom: 4px;
  background-color: #30dfff;
  border-bottom: 1px solid black;
  text-shadow: 0px 1px 1px rgba(255,235,215,0.5);
  background: #ffa84c;
  background: -moz-linear-gradient(top, #ffa84c 0%, #df5b0d 100%);
  background: -o-linear-gradient(top, #ffa84c 0%,#ff7b0d 100%);
  background: -webkit-gradient(
    linear,
    left top,
    left bottom,
    color-stop(0%,#ffa84c),
    color-stop(100%,#df5b0d)
  );
}

.author {
  display: block;
  font-size: 14px;
}

.info {
  margin-top: 4px;
  display: block;
  color: #ddd;
  font-size: 12px;
  margin-left: 4px;
  margin-right: 4px;
}

And here's the bit of hover magic used to demo the menu item fade effect.

#demoMenu > div:hover {
  opacity: 0;
  -webkit-transform: rotateX(90deg);
}

The menu itself has a 80% opacity white background, and it’s faded in using a CSS transition for the background-color property. The menu title is using the text-transform property to make the text uppercase and letter-spacing to space the letters far apart. I’m also using an eyeballed margin-left to center the letter “A” in the “PICK A DEMO”-text.

The interactive demo menu
The demo menu is all HTML.

When you select a demo, the demo loop script creates a new iframe for it and appends it to the demo container. There are fade animations for the iframe container, done using a CSS transition on the opacity property. The iframe is popped out from the background with a large-radius drop shadow. For the close-button I used the UNICODE math operator “⊗”.

Selected demo running
The selected demo running in an inset iframe.

The close-button onclick handler removes the demo iframe from the DOM to stop the demo from playing. It also removes the close button itself, and fades out the iframe container with a CSS transition on the opacity property.

Here's a live version:

The iframe HTML is very simple:

<div id="demoFrameContainer">
  <iframe class="demoFrame"></iframe>
  <div id="demoClose">⊗</div>
</div>

All the fancy stuff is in the CSS:

#demoFrameContainer {
  z-index: 100;
  position: absolute;
  top: 0px;
  left: 0px;
  width: 100%;
  height: 100%;
  background-color: rgba(0,0,0,0.8);
  color: white;
  -webkit-transition-duration: 0.8s;
  opacity: 1;
}

.demoFrame {
  border: 0px;
  -webkit-box-shadow: 0px 0px 20px #000;
  position: absolute;
  top: 30px;
  left: 40px;
  width: 520px;
  height: 180px;
  background-color: #444;
  overflow:hidden;
  -webkit-transition-property: opacity;
  -webkit-transition-duration: 1s;
  -webkit-transition-timing-function: ease-in-out;
}

#demoClose {
  width: 40px;
  font-size: 40px;
  position: absolute;
  text-align: center;
  right: 0px;
  top: 19px;
  cursor: pointer;
}

Transition animation

Demo transition screen
The transition screen displays the name and the author of the demo.

The transition animation is done in WebGL using my home-grown Magi library (I wrote it for doing stuff like this.) The timing of the animation is driven by a bunch of if-statements switching over the time since the start of the animation, so no timelines or anything fancy this time, sorry.

The transition animation consists of four distinct parts:

  • Demo info elements flying in and fading out
  • Rainbow ribbons flying in and out
  • Camera zoom on fade out
  • The “Chrome Experiments”-text fading in and out

The author image and demo info texts are moved in by a simple tween. Each of the elements has a frame handler that moves it closer to its target position. The move start times are staggered to create a more interesting motion. To fade out the elements, I’m multiplying the opacity of each of the element pixels with an opacity uniform in the fragment shader.

Here's a tween function if you're wondering how to make one. It defaults to a sine curve ease-in-out tween:

Tween = {};

Tween.linear = function(t) { return t; };
Tween.easeInOut = function(t) { return 0.5-0.5*Math.cos(Math.PI*2*t); };
Tween.easeIn = function(t) { return 1-Math.cos(Math.PI*t); };
Tween.easeOut = function(t) { return Math.sin(Math.PI*t); };

Tween.tween = function(a, b, t, tweenFunc, dst) {
  if (!tweenFunc)
    tweenFunc = this.easeInOut;
  if (!dst)
    dst = [];
  var f = tweenFunc(t);
  var fc = 1-f;
  for (var i=0; i<a.length; i++) {
    dst[i] = a[i]*fc + b[i]*f;
  }
  return dst;
}

The ribbons are cubic Bézier curves swept with a 4-sided polygon over 500 points. To fly in the ribbon, I control the vertice count drawn by the ribbon drawArrays-call. For example, to draw just the beginning of the ribbon, you'd draw only 24 verts. To draw up to the middle of the ribbon, you'd draw 24*250 verts. For flying the ribbon out, I control the starting offset of the drawArrays call so that it only draws the end of the ribbon.

If you're not familiar with how Bézier curves work, the idea is that you recursively interpolate between the curve control points using a parameter (called t) that ranges between 0 and 1, with 0 mapping to the first control point and1 to the last control point.

Look at this Wikipedia image. It's the best explanation I've seen thus far:

Animated interpolation of a cubic Bézier curve.

To find the point that corresponds to the parameter value t on a Bézier curve, you first interpolate the point at t between each of the subsequent control points. If you end up with more than one point, you use the points as new control points, and interpolate the points at t between them. Repeat until you have only one point left. That point is the Bézier curve point at t.

Here's a code snippet to evaluate a cubic Bézier curve:

Bezier = {};

Bezier.cubicCoord = function(a, b, c, d, t) {
  var a3 = a*3, b3 = b*3, c3 = c*3;
  return a + t*(b3 - a3 + t*(a3-2*b3+c3 + t*(b3-a-c3+d)));
};

Bezier.cubicPoint3 = function(a,b,c,d, t, dest) {
  if (dest == null)
    dest = vec3();
  dest[0] = this.cubicCoord(a[0], b[0], c[0], d[0], t);
  dest[1] = this.cubicCoord(a[1], b[1], c[1], d[1], t);
  dest[2] = this.cubicCoord(a[2], b[2], c[2], d[2], t);
  return dest;
};

Bezier.cubicPoint3v = function(p, t, dest) {
  this.cubicPoint3(p[0], p[1], p[2], p[3], t, dest);
};

To sweep a polygon along the Bezier curve, I'm evaluating 500 points from the curve and moving the polygon to each of them. Then I connect the points of the polygons to create the sweep geometry.

SweepGeo = {};

SweepGeo.translatePoly = function(polygon, offset) {
  var a = [];
  for (var i=0; i<polygon.length; i++) {
    var p = polygon[i];
    a.push(vec3(p[0]+offset[0], p[1]+offset[1], p[2]+offset[2]));
  }
  return a;
};

SweepGeo.createFromBezier = function(path, polygon, count) {
  var triangles = new Float32Array(polygon.length*3*count);
  var triIndex = -1;
  var prev = Bezier.cubicPoint3v(path, 0);
  var prevPoly = this.translatePoly(polygon, prev);

  // go through the path
  for (var i=1; i<count; i++) {
    var t = i/(count-1);
    var next = Bezier.cubicPoint3v(path, t);
    var nextPoly = this.translatePoly(polygon, next);

    // add the triangles connecting prevPoly and nextPoly
    for (var i=0; i<polygon.length; i++) {
      var j = i > polygon.length-1 ? 0 : i;

      // /|
      triangles[++triIndex] = prevPoly[i][0];
        triangles[++triIndex] = prevPoly[i][1];
        triangles[++triIndex] = prevPoly[i][2];
      triangles[++triIndex] = nextPoly[j][0];
        triangles[++triIndex] = nextPoly[j][1];
        triangles[++triIndex] = nextPoly[j][2];
      triangles[++triIndex] = nextPoly[i][0];
        triangles[++triIndex] = nextPoly[i][1];
        triangles[++triIndex] = nextPoly[i][2];

      // |‾
      triangles[++triIndex] = prevPoly[i][0];
        triangles[++triIndex] = prevPoly[i][1];
        triangles[++triIndex] = prevPoly[i][2];
      triangles[++triIndex] = prevPoly[j][0];
        triangles[++triIndex] = prevPoly[j][1];
        triangles[++triIndex] = prevPoly[j][2];
      triangles[++triIndex] = nextPoly[j][0];
        triangles[++triIndex] = nextPoly[j][1];
        triangles[++triIndex] = nextPoly[j][2];
    }
    prev = next;
    prevPoly = nextPoly;
  }
  return triangles;
};

Here's a live version with a visualization of the ribbon wireframe. Drag to rotate, scroll to zoom.

The camera zoom uses a similar tween as the demo info elements. The camera moves towards the target point while the canvas element is faded out using a CSS opacity transition.

The “Chrome Experiments”-text is an HTML element faded in and out with CSS transitions. It transitions over its opacity- and right-properties, with ease-in-out for the transition-timing-function. Very simple to do. In fact, it probably would’ve been faster to do the WebGL animations using a JS implementation of CSS transitions. Something to try next time, eh.

Comments

0