📜 ⬆️ ⬇️

HTML and CSS madness [translation]

or Create 3D worlds with HTML, CSS and JS


image
Last year, I made a demo that shows how CSS 3D transforms can be used to create a 3D space. The demo was a technical demonstration of what can be achieved with CSS at the time, but I wanted to see how far I could go, so for the past 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 used.

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 the declaration of its position in 3D space. A square, for example, will be defined 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 visualizer will use these vectors and a lot of smart math to draw a 3D object on your 2D screen.
With CSS transformations, the opposite is true. We cannot set arbitrary shapes with a set of points, our hands are bound by HTML elements that are constantly square and have two-dimensional properties, such as top, left, width and height to denote 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 the 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 picture below, you can observe how the upper part of the barrel and the wheel seem round, despite the fact that they are made of squares.

image
')
example of 3D objects created entirely from square <div> elements


All objects are created using JavaScript, using a set of methods for creating primitive geometry. The simplest object that can be created is the plane, which is essentially a regular <div> element. Planes can be added to sets, a <div> wrapper above them allows the entire object to rotate and move as a single entity. A pipe is a set of planes turned around the axes and a barrel is a pipe with a plane at the top and a plane at the bottom.

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, math 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.

image
screenshot of a room without lighting


As I 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 object's surface.
This formulates the problem when creating 3D objects using HTML elements, because these vectors do not exist. So the first obstacle is to write a set of methods for calculating four vectors (one for each angle) for an element that was transformed with 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 incident on the surface, by combining a linear-gradient with a picture. The effect uses a gradient that begins and ends with the same rgba value, producing a solid color block. Changing the alpha channel value allows the underlying image to shine through the color block to create the illusion of shading.

image
gradient example for shading textures


To achieve the most dark 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"); } 

Practically, these styles are not declared in advance, they are calculated dynamically and are applied directly to the style attribute of the element using JavaScript.
This technique is called flat shading. This is an effective shading method, but its result - the entire surface has the same detail. For example, if I create a 3D wall that moves away a certain distance, it will be shaded equally throughout its length. I wanted to do something more realistic.

Second lighting assault


To simulate real lighting, the surface must be darkened when 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 falling on the center point, but now I need to measure the light at different points on the surface to determine how much each point should be illuminated or shaded. Mathematics required the creation of this light information in the same way as in the case of flat shading. I tried to create a radial-gradient of light information to use in place of the linear-gradient of my previous attempt. The results were more realistic, but multiple light sources were still a problem, as the layering of several gradients on top of one another gradually obscures the underlying textures. If CSS supported combining images and blending modes (they are blending on the way), there would be an opportunity to make radial gradients work.
The solution was to use the <canvas> element to programmatically generate 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 one 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 that I used instead of the linear-gradient of my first experiment. Repeating this process, for each surface, I reproduced the effect of realistic illumination for the entire experiment space.
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 it covers.
Background-size setting : 100% causes the browser to scale the texture using bilinear (or similar) filtering, so the light map covers the entire 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 a background that is used to define a light map and a surface looks 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; } / png; base64, iVBORw0KGgoAAAANSUhEUgAAACoAAAAyCAYAAAAqRkmtAAACiUlEQVRoQ9VZa0vEQAy8 + / 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; } / xjrTtdSxud3dl + d + OTmt + yvDmX4c9n8eAbtVoAeCyRRYBknMb4XRfRVyC6wEqYFK0Cj0HG4OfSr8HG56ZhR6AhoJO2u4JPxInxK4BDYCyYu9YOglk / xbsymvy8RhpjlrNEArqWRCrWlBQEsYRWC98EfAmlm / W8FoBKzWp2aT11KbZugJ6NKMzpJMFlANPJX1Wpdyjdj0NKozv9PoTjD0pRYVDb2b9VGgVtZHNKrLpuelEzb5DRhoRKel1alGox1wGfpmgFIYPbCIUa + MRiuTpdMJox7QrSdTM / bUlOFzZ7SERqM + 6pbQZpqSZtq8ZhrnZkaRZoa7ZsblZjYgjgM1vmYClYOd1 + LBnpRM9iQItLTFQ / 0o6vLhXH9aCTTaOWnAehp1K9NZP4rUbufo2UnP9ajV82b6Tg70FudiZtKtHmrtoiOIpU9PpzD0Fwoo2n6sbZo9gJJZcwq9DGi0JpFyhjuoU7pxtSCjtXP9aDfv2gFaOoJofaKst5JJ + ukwM91kMirtyMp0a5MsOtyZicSobxMaLd3K0dZksZlt + HcZoUdsImZTY0g20PtA6OeypohFQR99MCqTDnnqA0NKp7My 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; } + MPzXAFDLluY2e83kqDoR2jcD6ByJhJoRrUtea31OgL4roCU9aORDWMRDIav0Fh890JR3lnz / 1GVU1nsGlJX1n4UaRQkV6ZpQ + Uwm05ej0TkSycp8i2Hoo3 + utUtvDhk9pwAAAABJRU5ErkJggg == ") 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 an illuminated surface:

image
image scaled and superimposed on the texture of the light map of small resolution


Shadow overlay


Using canvas for lighting allows you to overlay shadows. The logic of shadow overlay is quite simple. Arranging 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 light map to be in the shadow. This technique allows a single image to be used both for lighting and shading.

image
screenshot of the resulting room with lighting and shadows


Collision detection


A height map is used to define collisions — the image below uses color to display the height of objects on it. White color represents the deepest, and black is the highest possible position that a player can reach. As the player moves through the level, I will 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 rise or jump on the object. If the color is much darker, the player stops, I use it to set the walls and obstacles. This image is drawn by hand, but it looks the same as the one created dynamically.

image
the image of the height map and its relation to the levels


What's next?


Well, a full-fledged game will be a logical next step - it will be interesting to see how scalable these techniques are. So far, I've started working on a prototype CSS3 renderer for the amazing Three.js that uses the same techniques for rendering geometry and light created by this 3D engine.

original source

Source: https://habr.com/ru/post/169077/


All Articles