Last year I created a demo showing how CSS 3D transforms could be used to create 3D environments. The demo was a technical showcase of what could be achieved with CSS at the time but I wanted to see how far I could push things, so over the past few months I’ve been working on a new version with more complex models, realistic lighting, shadows and collision detection. This post documents how I did it and the techniques I used.
View the demo
Creating 3D objects
The geometry of a 3D object is stored as a collection of points (or vertices), each having an x , y and z property that defines its position in 3D space. A rectangle for example would be defined by four vertices, one for each corner. Each corner can be individually manipulated allowing the rectangle to be pulled into different shapes by moving its vertices along the x, y and z axis. The 3D engine will use this vertex data and some math to render 3D objects on your 2D screen.
With CSS transforms this is turned on its head. We can’t define arbitrary shapes using a set of points. Instead, we have to work with HTML elements, which are always rectangular and have two dimensional properties such as top , left , width and height to determine their position and size. In many ways this makes dealing with 3D easier as there’s no complex math to deal with — just apply a CSS transform to rotate an element around an axis and you’re done!
Creating objects from rectangles seems limiting at first but you can do a surprising amount with them, especially when you start playing with PNG alpha channels. In the image below you can see the barrel top and wheel objects appear rounded despite being made up of rectangles.
An example of 3D objects built entirely from rectangular
elements
All objects are created in JavaScript using a small set functions for creating primitive geometry. The simplest object that can be created is a plane, which is basically a
element. Planes can be added to assemblies, (a wrapper
element) allowing the entire object to be rotated and moved as a single entity. A tube is an assembly containing planes rotated around an axis and a barrel is a tube with a top plane and another for the bottom.
The following example shows this in practice – have a look at the “JS” tab:
Lighting
Lighting was by the biggest challenge in this project. I won’t lie, the math nearly broke me, but it was worth the effort because lighting brings an incredible sense of depth and atmosphere an otherwise flat and lifeless environment.
A screenshot of an unlit room
As I mentioned earlier, an object in your average 3D engine is defined by a series of vertices. To calculate lighting these vertices are used to compute a “normal” which can be used to determine how much light will hit the centre point of a surface. This poses a problem when creating 3D objects with HTML elements because this vertex data doesn’t exist. So the first challenge was to write a set of functions to calculate the four vertices (one for each corner) for an element that had been transformed with CSS so that lighting could be calculated. Once that was figured out I began to experiment with different ways to light objects. In my first experiment, I used multiple background-image s to simulate light hitting a surface by combining a linear-gradient with an image. The effect uses a gradient that begins and ends with the same rgba value, producing a solid block of colour. Varying the value of the alpha channel allows the underlying image to bleed through the colour block creating the illusion of shading.
Example of using a gradient to shade a texture
To achieve the second darkest effect in the above image I apply the following styles to an element:
element { background: linear-gradient(rgba(0,0,0,.8), rgba(0,0,0,.8)), url("texture.png"); }
In practice, these styles are not predefined in a stylesheet, they are calculated dynamically and applied directly to the elements style property using JavaScript.
This technique is referred to as flat shading. It’s an effective method of shading, however it does result in the entire surface having the same detail. For example, if I created a 3D wall that extended into the distance, it would be shaded identically along its entire length. I wanted something that looked more realistic.
A second stab at lighting
To simulate real lighting, surfaces need to darken as they extend beyond the range of a light source, and if multiple lights hit the same surface it should shade accordingly.
To flat shade a surface I only had to calculate the light hitting the centre point, but now I need to sample the light at various points on the surface so I can determine how light or dark each point should be. The math required to create this lighting data is identical to that used for flat shading.
Initially, I tried producing a radial-gradient from the lighting data to use in place of the linear-gradient in my earlier attempt. The results were more realistic but multiple light sources were still a problem because layering multiple gradients on top of each other causes the underlying texture to get progressively darker. If CSS supported image compositing and blending modes (they are coming) it may have been possible to make radial gradients work.
The solution was to use a