Shaders, holograms and light leaks in pure CSS

By the start of the course Full stack development in Python we tell you how to imitate shaders using pure modern CSS with a neat overlay of layers and effects. For details and demonstrations, we invite you under cat.


Maybe I’m understating a little, but WebGL is awesome. Five minutes on one of the many design award sites and you’ll see one site after another relying on canvas. Tools like threejs bring GLSL and 3D shaders to the browser that take visual effects to the next level.

It makes you wonder why let JS have all the fun. The mix-blend-mode CSS property has received widespread support, and we now have many common shading methods. Selected images and careful layering will help you create amazing effects without the need for JS.

Scroll through the image below and the sunlight has time to bloom into a warm orange before fading into a cool blue. You will also see the bokeh effect for a short time.

Oh that glitter.  Let's figure it out
Oh that glitter. Let’s figure it out

What is a CSS “shader”?

WebGL shaders are complex scripts that determine the rendering of each pixel. There is no WebGL control layer in CSS, so at its simplest level, a “shader” is an image with background layers on top of it. With careful handling of gradients, masks, nesting, and the mix-blend-mode property, you can control the interaction between the layers and the image at the very bottom. Although I took a little liberties with the name.

The example above uses multiple nested divs:

<div class="shader">
  <img src="https://habr.com/ru/company/skillfactory/blog/675862/tower.jpg" alt="Asakusa at dusk">
  <div class="shader__layer specular">
    <div class="shader__layer mask"></div>
  </div>
</div>

To align the layers with the image at the base, define the position of the nested content:

.shader {
    position: relative;
    overflow: hidden;
    backface-visibility: hidden; /* to force GPU performance. More on that later */
  }

  .shader__layer {
    background: black;
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    background-size: 100%;
  }

We figured out the base, now let’s look at the first layer of the effect – lighting.

Mirror simulation

The first thing to think about is how to move the light from light to dark across the surface of the image. Here you need a region of brightness with the highest intensity of light, which, as it scatters, fades to complete darkness. We will describe this area using a gradient:

.specular {
  background-image: linear-gradient(180deg, black 20%, #3c5e6d 35%, #f4310e, #f58308 80%, black);
}

Imagine that you are looking at a shiny surface. Light that reflects back is a specular reflection. The place and time of appearance of this reflection depends on the light source and the viewing angle – the highlight moves with you. The gradient looks great but is a bit static and the effect needs movement.

Luckily vintage. property CSS level 1 can help: background-attachment: fixed means that when you scroll the page, the gradient will remain anchored to the browser’s viewport. Not only does this bring some much-needed movement to the shader, it also means we can very crudely simulate a change in view angle without any JavaScript.

.specular {
  background-attachment: fixed;
  background-image: linear-gradient(180deg, black 20%, #3c5e6d 35%, #f4310e, #f58308 80%, black);
}

Wonderful! And now apply lighting to the main image.

Know Blending Modes

The name mix-blend-mode means blending the colors of each pixel in the same layer with the pixel below it. Like GLSL, CSS has long list of options. To create a suitable effect means to know the combination of colors that will give it exactly. But what do blend modes actually do? Before painstaking work on the shader, let’s briefly consider the modes that we will use.

Below you see images for examples. Left – the top layer that will be superimposed on the main image on the right.

Let’s look at mixing with multiplication – multiply. mode takes the color of each pixel in the current layer and multiplies it by the color of the pixel directly below it. This means that the dark colors of the current layer obscure the colors of the bottom layer.

The screen value takes the inverses of two pixels and multiplies them before inverting the result. This may seem complicated, but you can think of the operation as the opposite of multiplication: the darker colors become transparent, and only the lighter colors are visible on the bottom layer.

The color-dodge and color-burn modes are like multiply and screen in overdrive. Both modes mathematically divide the color of a pixel on the main layer by the color on the current layer.

A value of color-dodge means that the midtones and highlights are washed out and the dark tones are not affected at all; color-burn boosts shadows and midtones closer to darker ones, while lighter tones are not affected.

Below, the left example is with the color-dodge property, and the right example is with the color-burn property.

Layer composition

It might seem like the next step is to add a blend mode to our gradient, place it on top of the main image, and we’re done. It will definitely work, but the effect will not reach the desired quality.

Blending the gradient with the base layer means uneven lighting. In the real world – except for chrome surfaces – this happens infrequently. To make the effect really impressive, you need to control the areas where the light falls and where it does not. To mask the gradient for this simulation, you can use a predominantly dark image – a specular map.

You might be wondering how this is possible in CSS if the blend mode only affects the pixels of the layer directly below it, and you can only set one mode. Here comes the finest hour of the HTML structure:

<div class="shader">
  <img src="https://habr.com/ru/company/skillfactory/blog/675862/tower.jpg" alt="Asakusa at dusk">
  <div class="shader__layer specular">
    <div class="shader__layer mask"></div>
  </div>
</div>

It is possible to nest layers one within another and work from the outside, applying additional mix-blend-modes to each wrapper layer, which will allow you to add another blend mode to the result of the previous one. This approach is known as composition.

We try. With a suitable dark background-image, set mix-blend-mode: multiply to the .mask layer and discard the parts of the gradient where there shouldn’t be gaps.

.mask {
  mix-blend-mode: multiply;
  background-image: url(/tower_spec.jpg);
}

.specular {
  background-attachment: fixed;
  background-image: linear-gradient(180deg, black 20%, #3c5e6d 35%, #f4310e, #f58308 80%, black);
}

We have a specular map and can apply the final lighting effect to the base image. You need to use a blending mode that ignores black and dark tones. This means that for the .specular layer we have to set blend-mode: screen or mix-blend-mode: color-dodge. Anyone will work, but I want the highlights to turn into nice sunlight, so we’ll use color-dodge.

And let’s see:

.specular {
  mix-blend-mode: color-dodge;
  background-attachment: fixed;
  background-image: linear-gradient(180deg, black 20%, #3c5e6d 35%, #f4310e, #f58308 80%, black);
}

final shader

The effect is over! Here is the code:

<div class="shader">
  <img src="https://habr.com/ru/company/skillfactory/blog/675862/tower.jpg" alt="Asakusa at dusk">
  <div class="shader__layer specular">
    <div class="shader__layer mask"></div>
  </div>
</div>

<style>
  .shader {
    position: relative;
    overflow: hidden;
    backface-visibility: hidden; /* to force GPU performance */
  }

  .shader__layer {
    background: black;
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    background-size: 100%;
    background-position: center;
  }

  .specular {
    mix-blend-mode: color-dodge;
    background-attachment: fixed;
    background-image: linear-gradient(180deg, black 20%, #3c5e6d 35%, #f4310e, #f58308 80%, black);
  }

  .mask {
    mix-blend-mode: multiply;
    background-image: url(/tower_spec.jpg);
  }
</style>

Let’s look at the finished effect again, but this time with the ability to isolate each shader layer. Change view mode via dropdown list [в оригинальной статье]to see the effect step by step and get a better idea of ​​how the layers create the final image.

Move on

In the example above, the main image is greyscale with scratches and bokeh. as a mask. This is a great way to add something interesting to an image, but the shader layers can be whatever you want. Let’s see other examples:

Northern lights

Here, repeating the background gradient and decreasing background-size-y causes the light effect to move across the screen faster. When masked with a specular map, this creates the illusion of aurora borealis ripples across the main image. The color-dodge blurry highlights are creating the wrong effect, so change the .specular layer to mix-blend-mode: screen to keep the glow crisp.

light leak

So far, all examples have used a grayscale image, but a full-color specular map can bring new effects. Below, the masks are created from an inverted and blurred version of the main image with a blue-red overlay. When it all adds up with a hot red-orange gradient, the result is a blend that looks a bit like light leaking from vintage film cameras.

Hologram

Layering inside the mask opens up even more perspectives. What happens if you add another layer with background-attachment: fixed?

In the last example, the .mask layer has an SVG background image and another black to white gradient at the opposite angle of the .specular layer’s specular gradient. Setting the nested mask layer to color-burn skews the definition of the SVG, resulting in a nice two-sided hologram. CSS is amazing.

Results

At the time of writing, browsers still require significant resources for blending effects. In effects harder with multiple layers in the composition you will see a real performance hit. Add animations and any transitions to the overlay and browsers will crash, especially in Safari.

With a little tweak, I was able to get a little performance boost with backface-visibility: hidden, but the first impulse was a hack – forced GPU rendering with a robust transform: translateZ(0);. Unfortunately, this transformation revealed a feature that also needs to be known.

Due to the dependency on background-attachment: fixed , CSS transforms on a shader can cause weird side effects. In Chrome, this generally works, but depending on the transforms, the gradients may appear misaligned. Firefox just ignores the fixed position and your gradients are completely static. I’m sure there are ways around this, but they will probably tempt your spirit to use JavaScript.

Of course, we cannot achieve GLSL level, but for simpler effects, this approach is a great alternative to adding JS dependencies to the project. As beautiful as the effects are, I think today it’s pretty much a coincidence: you don’t have to do it just because you can.

Until CSS filters and blend modes get faster, or until browsers link filters and GLSL directly, it’s best to be subtle and restrained.

And we will help you upgrade your skills or master a profession that is relevant at any time from the very beginning:

Choose another in-demand profession.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *