clicktorelease

Loading...
My past self, on twitter. What a boob...

How to make clouds with CSS 3D

A tutorial on how to use CSS 3D Transforms to create sprite-based 3D-like clouds like these ones.

Introduction

This tutorial will try to guide you through the steps to create a 3D-like, billboard-based clouds. There are a few advanced topics, mainly how 3D transformations via CSS properties work. If you want to find more information, this is a nice place to begin.

The tutorial is divided into sections, each with a different step to understand and follow the process, with HTML, CSS and Javascript blocks. Each step is based on the previous one, and has a link to test the code. The code in the tutorial is a simplified version of the demos, but the main differences are documented on every section.

  1. Creating a world and a camera
  2. Adding objects to our world
  3. Adding layers to our objects
  4. Making the 3D effect work
  5. Final words

1. Creating a world and a camera

First, we need two div elements: viewport and world. All the rest of the elements will be dynamically created.

Viewport covers the whole screen and acts as the camera plane. Since in CSS 3D Transforms there is no camera per se, think of it as a static sheet of glass through which you see a world that changes orientation relative to you. We'll position all our world objects (or scene) inside it, and that's what will be transformed around.

World is a div that we are going to use to anchor all our 3D elements. Transforming (rotating, translating or scaling) world will transform all our elements. For brevity and from here on, I'm using non-prefixed CSS properties. Use the vendor prefix (-webkit, -moz, -o, -ms, etc.) where appropriate.

This is all the markup we'll need:

<div id="viewport" >
	<div id="world" ></div>
</div>

These next are our two CSS definitions. It's very important to center the div that contains our scene (world in our case) in the viewport, or the scene will be rendered with an offset! Remember that you are still rotating an element that is positioned inside the document, exactly like any other 2D element.

#viewport {
	bottom: 0;
	left: 0;
	overflow: hidden;
	perspective: 400; 
	position: absolute;
	right: 0;
	top: 0;
}

#world {
	height: 512px;
	left: 50%;
	margin-left: -256px;
	margin-top: -256px;
	position: absolute;
	top: 50%;
	transform-style: preserve-3d;
	width: 512px;
}

Now a bit of code. We initialise our objects, hook to the mousemove event and define updateView():

/*
	Defining our variables
	world and viewport are DOM elements,
	worldXAngle and worldYAngle are floats that hold the world rotations,
	d is an int that defines the distance of the world from the camera 
*/
var world = document.getElementById( 'world' ),
	viewport = document.getElementById( 'viewport' ),
	worldXAngle = 0,
	worldYAngle = 0,
	d = 0;

/*
	Event listener to transform mouse position into angles 
	from -180 to 180 degress, both vertically and horizontally
*/
window.addEventListener( 'mousemove', function( e ) {
	worldYAngle = -( .5 - ( e.clientX / window.innerWidth ) ) * 180;
	worldXAngle = ( .5 - ( e.clientY / window.innerHeight ) ) * 180;
	updateView();
} );

/*
	Changes the transform property of world to be
	translated in the Z axis by d pixels,
	rotated in the X axis by worldXAngle degrees and
	rotated in the Y axis by worldYAngle degrees.
*/
function updateView() {
	world.style.transform = 'translateZ( ' + d + 'px ) \
		rotateX( ' + worldXAngle + 'deg) \
		rotateY( ' + worldYAngle + 'deg)';
}

World has red colour, viewport has a CSS background to simulate the sky, and there's mousewheel event listener to modify the distance of the camera. Move the mouse and notice how the red div changes orientation.

Here's the code running for this first step.

2. Adding objects to our world

Now we start adding real 3D content. We add some new div which are positioned in the space, relatively to world. It's esentially adding several absolute-positioned div as children of world, but using translate in 3 dimensions instead of left and top. They are centered in the middle of world by default. The width and height don't really matter, since these new elements are containers for the actual cloud layers. For commodity, it's better to center them (by setting margin-left and margin-top to negative half of width and height).

.cloudBase {
	height: 20px;
	left: 256px;
	margin-left: -10px;
	margin-top: -10px;
	position: absolute;
	top: 256px;
	width: 20px;
}

We add generate() and createCloud() functions to populate world. Note that random_{var} are not real variables but placeholder names for the real code, which should return a random number between the specified range.

/*
	objects is an array of cloud bases
	layers is an array of cloud layers
*/
var objects = [],
	layers = [];

/*
	Clears the DOM of previous clouds bases 
	and generates a new set of cloud bases
*/
function generate() {
	objects = [];
	layers = [];
	if ( world.hasChildNodes() ) {
		while ( world.childNodes.length >= 1 ) {
			world.removeChild( world.firstChild );       
		} 
	}
	for( var j = 0; j <; 5; j++ ) {
		objects.push( createCloud() );
	}
}

/*
	Creates a single cloud base: a div in world
	that is translated randomly into world space.
	Each axis goes from -256 to 256 pixels.
*/
function createCloud() {

	var div = document.createElement( 'div'  );
	div.className = 'cloudBase';
	var t = 'translateX( ' + random_x + 'px ) \
		translateY( ' + random_y + 'px ) \
		translateZ( ' + random_z + 'px )';
	div.style.transform = t;
	world.appendChild( div );
	
	return div;
}

The cloud bases are slightly pink squares. There's a p variable to make it easier to change viewport.style.perspective. Try changing this value and notice how it behaves like the FOV of our camera. The higher this value goes, the more orthographic the view goes. Again, note that random_{var} are not variables.

Here's the code running for this second step.

3. Adding layers to our objects

Now things start getting interesting. We add several absolute-positioned .cloudLayer div elements to each .cloudBase. These will hold our cloud textures.

.cloudLayer {
	height: 256px;
	left: 50%;
	margin-left: -128px;
	margin-top: -128px;
	position: absolute;
	top: 50%;
	width: 256px;
}

The old createCloud() changes a bit to add a random number of cloudLayers.

/*
	Creates a single cloud base and adds several cloud layers.
	Each cloud layer has random position ( x, y, z ), rotation (a)
	and rotation speed (s). layers[] keeps track of those divs.
*/
function createCloud() {

	var div = document.createElement( 'div'  );
	div.className = 'cloudBase';
	var t = 'translateX( ' + random_x + 'px ) \
		translateY( ' + random_y + 'px ) \
		translateZ( ' + random_z + 'px )';
	div.style.transform = t;
	world.appendChild( div );
	
	for( var j = 0; j < 5 + Math.round( Math.random() * 10 ); j++ ) {
		var cloud = document.createElement( 'div' );
		cloud.className = 'cloudLayer';
		
		cloud.data = { 
			x: random_x,
			y: random_y,
			z: random_z,
			a: random_a,
			s: random_s
		};
		var t = 'translateX( ' + random_x + 'px ) \
			translateY( ' + random_y + 'px ) \
			translateZ( ' + random_z + 'px ) \
			rotateZ( ' + random_a + 'deg ) \
			scale( ' + random_s + ' )';
		cloud.style.transform = t;
		
		div.appendChild( cloud );
		layers.push( cloud );
	}
	
	return div;
}

Here's the code running the third step.

The cloud layers are blue and have a white border, to make them very visible. Move the mouse to notice how each layer is positioned and rotated.

4. Making the 3D effect work

This is where the magic happens. We have layers[] containing a reference for every single layer sprite in our world, and we have worldXangle and worldYAngle, which are the rotation part of the transformation applied to our world.

If we apply the opposite rotation to each and every layer, we are effectively re-aligning them to the viewport: we have a billboard. Since we've rotated the world first in X and then in Y, we need to rotate each layer the opposite way: first Y and then X. The order of transformations is very important. If you don't apply them in the correct order your elements will be incorrectly oriented!

/*
	Iterate layers[], update the rotation and apply the
	inverse transformation currently applied to the world.
	Notice the order in which rotations are applied.
*/
function update (){
		
	for( var j = 0; j < layers.length; j++ ) {
		var layer = layers[ j ];
		layer.data.a += layer.data.speed;
		var t = 'translateX( ' + layer.data.x + 'px ) \
			translateY( ' + layer.data.y + 'px ) \
			translateZ( ' + layer.data.z + 'px ) \
			rotateY( ' + ( - worldYAngle ) + 'deg ) \
			rotateX( ' + ( - worldXAngle ) + 'deg ) \
			scale( ' + layer.data.s + ')';
		layer.style.transform = t;
	}
	
	requestAnimationFrame( update );
	
}

Move the mouse around and you'll see that the cloud layers (blue) are now billboards (they always face the camera/viewport), while world and each cloud base are still 3D-projected.

Here's the code running the last step.

5. Final words

For the final effect, just remove the debug colours and change the cloudLayer div for an img with a cloud texture. The textures should be PNG with alpha channel to get the effect right.

Here's the code running with textures. Here's the final version.

Of course, you can use any texture or set of textures you want: smoke puffs, plasma clouds, green leaves, flying toasters... Just change the background-image that a specific kind of cloud layer uses. Mixing different textures in different proportions gives interesting results.

Adding elements in random order is fine, but you can also create ordered structures, like trees, duck-shaped clouds or complex explosions. Try following a 3D curve and create solid trails of clouds. Create a multiplayer game to guess the shape of a 3D cloud. The possibilities are endless!

I hope it's been an interesting tutorial and not too hard to follow.
Feel free to use the code for your project, or to drop me a line at the(dot)spite(at)gmail(dot)com!