HTML and CSS Madness [translation]
or Create 3D worlds using HTML, CSS and JS
Last year, I made a demo that shows how you can use CSS 3D transforms to create 3D space. The demo was a technical demonstration of what CSS could accomplish at the time, but I wanted to see how far I could go, so the last few months I have been working on a new version with even more complex models, realistic lighting, shadows and collision detection. This post documents how I did it and what techniques I applied.
Demo Demo2
Create 3D objects
In modern 3D engines, objects are stored as a set of points (or vectors), each has an X, Y, and Z value for declaring its position in 3D space. A square, for example, will be determined by 4 vectors, one for each corner. Each of the vectors can be manipulated individually, moving it along the X, Y and Z axes, thereby allowing the square to stretch into different shapes. The 3D engine visualizer will use these vectors and lots of smart math to draw a 3D object on your 2D screen.
With CSS transforms, it's the other way around. We cannot set arbitrary shapes as a set of points; our hands are tied with HTML elements that are constantly square and have two-dimensional properties, such as top, left, width and height to indicate their positions and sizes. But in many ways, this makes working with 3D much easier, since in this case there is no complicated mathematics - you just need to apply CSS transformation to rotate the element around the axes and you're done!
Creating objects from squares may seem like a limited method at first, but you can create an amazing amount of them, especially when you start playing with PNG alpha channels. In the figure below you can observe how the top of the barrel and the wheel appear round, despite the fact that they are made of squares.
example of 3D objects created entirely from square elements
All objects are created using JavaScript, using a set of methods to create primitive geometry. The simplest object that can be created is a plane, which is essentially ordinary
element. Planes can be added to sets, wrap
above them allows the entire object to rotate and move as one entity. A pipe is a set of planes rotated around its axes, and a barrel is a pipe with a plane on top and a plane below.
This example shows what is said in practice, take a look at the JS tab.
Light was the most difficult challenge in this project. I won’t lie, mathematics almost broke me, but it was worth it because the light brought an incredible sense of depth and atmosphere into a flat and lifeless space.
As I already said, an object in a regular 3D engine is defined by a series of vectors. To calculate the light, these vectors are used to calculate the "normal" which can be used to determine the amount of light that is reflected from the center point of the surface of the object.
This poses a problem when you create 3D objects using HTML elements because these vectors do not exist. So the first hurdle is writing a set of methods for calculating four vectors (one for each corner) for an element that has been transformed using CSS, which can be used to calculate light. As soon as I determined this, I immediately began experimenting with various ways to illuminate objects.
In my first experiment, I used several background images to simulate the light falling on the surface, by combining a linear-gradient with the picture. The effect uses a gradient that starts and ends with the same rgba value, producing a solid color block. Changing the alpha channel value allows the image below to appear through the color block to create the illusion of shading.
To achieve the darkest effect on the image above, I applied the following styles:
In practice, these styles are not declared in advance, they are calculated dynamically and applied directly to the element's style attribute using JavaScript.
This technique is called flat shading. This is an effective shading method, but its result is that the entire surface has the same detail. For example, if I create a 3D wall that moves away at a certain distance, it will be shaded the same throughout its length. I wanted to do something more realistic.
To simulate real lighting, surfaces must be darkened away from the light source, and if we have several such sources falling on the surface, it should be shaded accordingly.
For a flat shaded surface, I only needed to calculate the light incident on the center point, but now I need to measure the light at different points on the surface to determine how much light or shade each of the points should be. Mathematics required the creation of this light information in the same way as in the case of plane shading. I tried to create a radial-gradient from light information to use it in place of linear-gradientof my previous attempt. The results were more realistic, but multiple light sources were still a problem, as layering several gradients on top of one another gradually obscures the underlying textures. If CSS supported image blending and blending modes (blending is on the way), it would be possible to make radial gradients work.
The solution was to useelement for programmatically generating a new texture that can be used as a light map. Using the calculated light information, I was able to draw sets of black pixels, each changing the alpha channel based on the amount of light that should fall on the surface at a given point.
In the end, I used the canvas.toDataURL () method to encode the image, which I used instead of linear-gradient of my first experiment. Repeating this process for each surface, I reproduced the effect of realistic lighting for the entire space of the experiment.
Calculating and drawing such textures is hard work. The ceiling and basement floor, both have a size of 1000x2000 pixels, creating a texture to cover this entire area is not very practical, so I measure the light only every 12 pixels, which produces a light map 12 times smaller than the surface that it covers.
Setting background-size: 100% causes the browser to scale the texture using bilinear (or similar) filtering, so the light map covers all the surface we need. The scaling effect creates a result that is almost completely identical to the light map generated for each pixel.
An example of a style rule for the background, which is used to set the light map and surface, looks something like this:
What creates a lighted surface:
Using canvas for lighting allows you to implement shadow casting. The logic of shadow casting is quite simple. Ordering the surfaces by the criterion of their distance from the light source allowed me not only to create a light map for the surface, but also to determine whether the previous surface was illuminated by the current light source. If it was necessary, I could set the corresponding pixel on the lightmap to be in shadow. This technique allows a single image to be used for both lighting and shading.
A height map is used to determine collisions - the image below uses color to display the height of objects on it. White color indicates the deepest, and black the highest possible position that the player can reach. As the player moves around the level, I convert his position to 2D coordinates and use them to check the color on the height map. If the color is lighter than the last position of the player, it falls; if the color is slightly darker, the player can climb or bounce onto an object. If the color is much darker, the player stops, I use this to set walls and obstacles. This image is hand-drawn, but it looks the same as it was created dynamically.
Well, a full game will be the logical next step - it will be interesting to see how scalable these techniques are. So far, I have started working on a CSS3 renderer prototype for the delightful Three.js which uses the same techniques for rendering geometry and light created by this 3D engine.
primary source
This example shows what is said in practice, take a look at the JS tab.
Shine
Light was the most difficult challenge in this project. I won’t lie, mathematics almost broke me, but it was worth it because the light brought an incredible sense of depth and atmosphere into a flat and lifeless space.
screenshot of a room without lighting
As I already said, an object in a regular 3D engine is defined by a series of vectors. To calculate the light, these vectors are used to calculate the "normal" which can be used to determine the amount of light that is reflected from the center point of the surface of the object.
This poses a problem when you create 3D objects using HTML elements because these vectors do not exist. So the first hurdle is writing a set of methods for calculating four vectors (one for each corner) for an element that has been transformed using CSS, which can be used to calculate light. As soon as I determined this, I immediately began experimenting with various ways to illuminate objects.
In my first experiment, I used several background images to simulate the light falling on the surface, by combining a linear-gradient with the picture. The effect uses a gradient that starts and ends with the same rgba value, producing a solid color block. Changing the alpha channel value allows the image below to appear through the color block to create the illusion of shading.
Gradient shading example
To achieve the darkest effect on the image above, I applied the following styles:
element {
background: linear-gradient(rgba(0,0,0,.8), rgba(0,0,0,.8)), url("texture.png");
}
In practice, these styles are not declared in advance, they are calculated dynamically and applied directly to the element's style attribute using JavaScript.
This technique is called flat shading. This is an effective shading method, but its result is that the entire surface has the same detail. For example, if I create a 3D wall that moves away at a certain distance, it will be shaded the same throughout its length. I wanted to do something more realistic.
The second storm of lighting
To simulate real lighting, surfaces must be darkened away from the light source, and if we have several such sources falling on the surface, it should be shaded accordingly.
For a flat shaded surface, I only needed to calculate the light incident on the center point, but now I need to measure the light at different points on the surface to determine how much light or shade each of the points should be. Mathematics required the creation of this light information in the same way as in the case of plane shading. I tried to create a radial-gradient from light information to use it in place of linear-gradientof my previous attempt. The results were more realistic, but multiple light sources were still a problem, as layering several gradients on top of one another gradually obscures the underlying textures. If CSS supported image blending and blending modes (blending is on the way), it would be possible to make radial gradients work.
The solution was to useelement for programmatically generating a new texture that can be used as a light map. Using the calculated light information, I was able to draw sets of black pixels, each changing the alpha channel based on the amount of light that should fall on the surface at a given point.
In the end, I used the canvas.toDataURL () method to encode the image, which I used instead of linear-gradient of my first experiment. Repeating this process for each surface, I reproduced the effect of realistic lighting for the entire space of the experiment.
Calculating and drawing such textures is hard work. The ceiling and basement floor, both have a size of 1000x2000 pixels, creating a texture to cover this entire area is not very practical, so I measure the light only every 12 pixels, which produces a light map 12 times smaller than the surface that it covers.
Setting background-size: 100% causes the browser to scale the texture using bilinear (or similar) filtering, so the light map covers all the surface we need. The scaling effect creates a result that is almost completely identical to the light map generated for each pixel.
An example of a style rule for the background, which is used to set the light map and surface, looks something like this:
element {
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACoAAAAyCAYAAAAqRkmtAAACiUlEQVRoQ9VZa0vEQAy8+/8/yPcbFVFEEREREcS/4eNSmpKmmU12t/XYD6W3beGmk8kk2a5Xq9XR5vjeHD+B47d/xjrTtdSxud3dl+d+OTmt+yvDmX4c9n8eAbtVoAeCyRRYBknMb4XRfRVyC6wEqYFK0Cj0HG4OfSr8HG56ZhR6AhoJO2u4JPxInxK4BDYCyYu9YOglk/xbsymvy8RhpjlrNEArqWRCrWlBQEsYRWC98EfAmlm/W8FoBKzWp2aT11KbZugJ6NKMzpJMFlANPJX1Wpdyjdj0NKozv9PoTjD0pRYVDb2b9VGgVtZHNKrLpuelEzb5DRhoRKel1alGox1wGfpmgFIYPbCIUa+MRiuTpdMJox7QrSdTM/bUlOFzZ7SERqM+6pbQZpqSZtq8ZhrnZkaRZoa7ZsblZjYgjgM1vmYClYOd1+LBnpRM9iQItLTFQ/0o6vLhXH9aCTTaOWnAehp1K9NZP4rUbufo2UnP9ajV82b6Tg70FudiZtKtHmrtoiOIpU9PpzD0Fwoo2n6sbZo9gJJZcwq9DGi0JpFyhjuoU7pxtSCjtXP9aDfv2gFaOoJofaKst5JJ+ukwM91kMirtyMp0a5MsOtyZicSobxMaLd3K0dZksZlt+HcZoUdsImZTY0g20PtA6OeypohFQR99MCqTDnnqA0NKp7My+ggYjerz34A+LQRUsolCrnWaNPznSqCoeyo1e/bV0T4+LV4KgCJwJAPNZI41WfV+MPzXAFDLluY2e83kqDoR2jcD6ByJhJoRrUtea31OgL4roCU9aORDWMRDIav0Fh890JR3lnz/1GVU1nsGlJX1n4UaRQkV6ZpQ+Uwm05ej0TkSycp8i2Hoo3+utUtvDhk9pwAAAABJRU5ErkJggg==") 0 0 / 100% 100%, url("texture.png") 0 0 / auto auto;
}
What creates a lighted surface:
image of a small resolution scaled and superimposed on the texture of a light map
Shadow overlay
Using canvas for lighting allows you to implement shadow casting. The logic of shadow casting is quite simple. Ordering the surfaces by the criterion of their distance from the light source allowed me not only to create a light map for the surface, but also to determine whether the previous surface was illuminated by the current light source. If it was necessary, I could set the corresponding pixel on the lightmap to be in shadow. This technique allows a single image to be used for both lighting and shading.
screenshot of the resulting room with lighting and shadows
Collision detection
A height map is used to determine collisions - the image below uses color to display the height of objects on it. White color indicates the deepest, and black the highest possible position that the player can reach. As the player moves around the level, I convert his position to 2D coordinates and use them to check the color on the height map. If the color is lighter than the last position of the player, it falls; if the color is slightly darker, the player can climb or bounce onto an object. If the color is much darker, the player stops, I use this to set walls and obstacles. This image is hand-drawn, but it looks the same as it was created dynamically.
height map image and its relation to levels
What's next?
Well, a full game will be the logical next step - it will be interesting to see how scalable these techniques are. So far, I have started working on a CSS3 renderer prototype for the delightful Three.js which uses the same techniques for rendering geometry and light created by this 3D engine.
primary source