Glass Block
Introduction
“Hello, World! :)”
My name is Mihai, and I am a Technical Artist interested in environments, props, materials, proceduralism, shaders and automation scripts.
I always had an interest in 3D Environment Art and continued my love for it throughout university, where I studied 3D Game Art. After I graduated, I joined a Technical Art
Apprenticeship at Sumo Digital to further my knowledge, as well as expand my skills into other areas such as programming.
Now, I help with environment art creation and develop tools to aid production.
Inspiration
The idea to create a glass block shader came to me one day when I was gathering reference images for a mood board of an environment I wanted to make, based on Romanian school interiors.
Having been born in Romania, I had the opportunity to see multiple schools either built or renovated during the Socialist Republic era. Many of them featured these glass blocks which in Romanian are called “cărămidă de sticlă” or “bloc de sticlă” and which I believe were popularised by the USSR, called “steklobloky” in Russian.
The more I developed my idea, the more I realised what a large project creating the whole environment would be, so I decided to focus on one part of the mood board, which ended up being the glass blocks.
While doing more research into these glass blocks, I found out that they have also been used a lot in the mid-century modern design movement which I became increasingly interested in over the years.
I became fascinated with the way the light reflects through the glass, and how the view behind it becomes distorted. I began thinking about how this would translate into a games environment, and that’s when I started to plan my shader.
Goals & Research
Before I properly started working on the shader, I wanted to make sure that the final result would be as optimised as possible, so I needed to decide which technique would be the most efficient without compromising quality.
After some extensive research, I narrowed it down to only 2 options: Translucency, and Cubemaps. Each option comes with its own pros and cons as described below.
Translucency
Pros
- Physically more correct
You can see what is behind the glass without any tricks, and the distortion is more accurate.
- Interacts with the scene
If there are any lights behind the glass it will properly calculate refraction.
- Easier to use (from an environment artist point of view)
Since translucency shows exactly what exists behind the translucent object, all you have to do is place the object in the world and apply the translucent material to it.
Cons
- Expensive to render (very expensive)
I have used translucency in my Cavanagh’s Bar Reimagined environment project, and the performance cost was very high.
In that scene, I used it for the glass bottles behind the bar and found out that the more bottles I had, the worse the performance was, which when rendering multiple glass blocks would become a problem.
All in all, translucency scales poorly in terms of performance with many instances.
-
Depth sorting issues
Sometimes the engine struggles with translucent objects because it gets confused about which is the front and which is the back of an object or it doesn’t know which object is in front or behind, leading to some faces looking like they are clipping through others.
- Geometry
For a translucent glass block to look good and realistic it needs more geometry. It needs to be a fully modelled block.
This both increases the vertex count (not really a problem for AAA games nowadays, however it can still be a problem for other platforms such as VR and Mobile) as well as the time it takes to create new variants.
Cubemap
- Pros
-
Cheap to render (very cheap)
Compared to translucency, Cubemaps are just texture lookups and maths. The illusion of depth is based on the camera vector, a vector which can be transformed using a normal map before getting passed into the Cubemap function. This then creates the distortion effect found in the glass blocks.
-
Stable and predictable
Considering they are applied to a flat surface, Cubemaps don’t suffer from depth sorting issues like translucent objects do.
-
Geometry
Compared to translucency, Cubemaps can be applied to planes, which helps keep the vertex count low and therefore make it far more efficient for certain platforms such as VR and Mobile.
-
Customisability
Since the Cubemap material can be applied to a plane instead of a pre-modelled object, it also makes it easier to change the design of the glass block by changing the texture instead of having to remodel the mesh.
In my case, I created the textures in Substance Designer, and the design seen on the front of the glass block was made from some grayscale shapes applied to the normal map.
- Cons
-
Not Physically Accurate
To put it in simple terms, since the plane is opaque, it doesn’t allow light to pass through or reveal the world behind it, like a translucent object would. Therefore, the environment behind the plane has to be captured into a cubemap, which is then used by the shader to simulate the depth.
If the environment behind the glass is meant to be the outside world, HDRI’s could be used instead.
-
Harder to use (from an environment artist point of view)
Since the cubemap version is not physically accurate, this approach requires more time spent on creating accurate cubemaps to avoid breaking the illusion.
Conclusion
To make my decision I looked at three factors: time, predictability and stability, and performance.
Whilst both methods are somewhat time consuming for different reasons, the cubemap was clearly the better option for both other factors due to translucency’s depth sorting issues and expensive nature.
Therefore, I decided to go ahead with the cubemap version, considering that whilst I would have increased performance and stability, I would have to compromise slightly on accuracy and ease of use.
Material
I decided to use Substance Designer to procedurally generate the textures used to create the glass block shader. These textures are then used in Unreal Engine to do a multitude of things.
The glass material itself is not very complex. For the shader to work I only needed:
- A simple Grayscale Base Colour Map – used to change the colour of the glass
- A simple Normal Map – used to distort the camera vector
- An ORM Map – used for values such as Roughness, Metallic, and Ambient Occlusion
- A Height Map – used with a Bump Offset node to create the illusion of height
I started by creating two designs for the glass blocks using reference images I found online (Diamond and Pristal). I made these by tweaking some of the parameters inside the Tile Generator Grayscale node.
This is something I found helpful whilst using the cubemap technique; creating new grayscale designs is quick and easy.
Once the glass designs were ready, I used a Tile Generator Grayscale to create a 6×6 grid (representing our glass blocks), which I bevelled using a Bevel Smooth node, then passed into a Flood Fill to generate both a gradient and a random grayscale.
I used the gradient to create a subtle slant for each block, adding to the realism of the design.
I then used the random grayscale to create two masks representing the positions for the different designs.
I added a dot node called “inner_mask”, this is a simple mask representing the centre of the glass block. This ensures the designs only exist in the centre and have a thick border around them.
To create a subtle falloff on the inner edges I bevelled it slightly, on the height map it represents a smooth crevice between the border and the design.
I used blend nodes to combine everything; this gave me a grayscale image with the two designs separated by gaps for the grout.
To make my glass blocks as realistic as possible I wanted to make sure they weren’t perfectly smooth, as no glass ever is. So, I added bumpy imperfections using noise.
For the grout, I used an already existing Grunge Concrete Noise, as grout is somewhat similar to concrete.
I decided not to spend more time on the grout, as the point of the project was to create a convincing cubemap illusion, not a realistic grout texture.
The Colour Pass is fairly straightforward.
The grayscale image is passed through a Gradient Map node and then gets blended using a Perlin Noise Texture as a mask. After that, dirt and dust are added to create some variation.
Ultimately, I added roughness variation using two grunge maps on top of one another and then reused the same dirt mask from the Colour Pass to increase the roughness where dirt and dust would build up.
Finally, I packed and exported the texture maps ready for Unreal Engine. The final textures included:
- Grayscale Base Colour + Alpha Mask.
- Normal • ORMH – Ambient Occlusion, Roughness, Metallic and Height.
Shader
I made the shader in Unreal Engine 5. Although the Shader Graph handles most of the heavy lifting, a blueprint actor was also used to create some Texture Cubes.
Firstly, let’s look at the Shader Graph. To make it easier to follow along, I have separated the process into five main stages and colour-coded them. These stages are as follows:
- RED – Declare variables reused in multiple places
- GREEN – Calculate necessary cubemaps • BLUE – Tweak those cubemaps
- YELLOW – Put everything together & apply final tweaks
- CYAN – Assemble the final material
I used a standard setup to determine the UV tiling of my texture. Once it had been calculated, I plugged it into the UVs (you can plug this into any texture you want) to increase or decrease the tiling.
With this calculated, I moved on to transforming the Camera Vector using my Normal Map. To understand why, we first need to look at how an InteriorCubemap works in Unreal Engine. By default, an InteriorCubemap takes the following inputs:
- UVs (Vector2) – This is usually TexCoord.
- Tiling (Vector2) – Usually (1,1), but it can be any value.
- Randomize Rotation (Static Bool) – Irrelevant in this situation. I changed mine, so I could have a custom camera vector and depth value.
When looking inside the InteriorCubemap node, we can see that it uses two values that are not exposed as node inputs:
- Vector3 (-1, 1, 1) – This is used to define the proportions of the Cubemap.
- A Camera Vector – Transformed from World Space to Tangent Space. This Camera Vector tells the shader the direction from the surface point toward the camera. This is what allows the cubemap to shift correctly as the camera moves.
The Vector3 values control the width, height, and depth of the cubemap. Changing them can create some unusual visual effects, so I highly recommend experimenting with them to gain a better understanding of how they work.
In my case, I only wanted to adjust the depth, so I created a custom input for depth in the InteriorCubemap node.
Considering that a standard Camera Vector looks like the left image below (in tangent space), and that a Normal Map is essentially a collection of vectors stored in RGB form, I decided to try to “bake” the Normal Map into the Camera Vector to create the first and most important layer of distortion.
To do this, I also needed to expose the Camera Vector to the inputs of the InteriorCubemap node.
To distort the Camera Vector using the Normal Map, I added some height variation using a BumpOffset node. This is a simpler, cheaper version of the Parallax Occlusion Mapping. It doesn’t look as good when viewed up close, but in my case, it works perfectly fine.
I started by plugging the UV Tiling variable into the UVs of my ORMH map to keep the tiling consistent with the other texture maps.
I then used the Height Map stored in the Alpha Channel of the ORMH texture as the Height input for the BumpOffset node.
I then plugged the output of the BumpOffset node into the UVs of the Normal Map and set the Mip Level to a value of 2.
Mips are essentially a more efficient version of a blur, working by using a pre-scaled, lower-resolution version of the Normal Map. With the BumpOffset now controlling the UVs, the Normal Map looked like this.
It looked better, as it now had some height to it instead of appearing flat. However, I ended up with these artifacts, which are circled above.
To fix these, I used the Inner Mask from Substance Designer, which I packed into the Alpha Channel of my Base Colour map, to mask out the grout and the edges of the glass blocks.
This way, the Bump Offset Normal would only appear where the mask is white, while the Standard Normal would appear where the mask is black.
With the Bump Offset Normal Map ready, I used a TransformVector node to convert it from Tangent Space to World Space.
I then multiplied it by a parameter called “Distortion Intensity”, which can be adjusted in the Material Instance. After that, I added it to the Standard Camera Vector and normalised the result. Finally, I stored this vector as my new “Camera Vector”.
Next, I had to create two cubemaps using the new Camera Vector. One cubemap for the environment that exists behind the glass and another one which would represent the four walls of each glass block.
For the Camera Vector input I used the newly calculated Camera Vector which contains the normal information for the distortion.
I used a tiling value of 0.5 which makes the cubemap twice as large and combined it with a UV of TexCoord + (0.5, 0.5) to recentre it.
This results in a cubemap that appears bigger than it is as the corners do not perfectly align with the plane mesh but instead go beyond it.
The UVs can be transformed further in both X and Y by adding or subtracting different values to shift them up and down or left and right.
This effectively makes it seem like the position of the glass blocks on the wall is changing. If the glass blocks are meant to sit closer to the ceiling, we could subtract 0.5 from the Y value.
This would shift the UVs down, and therefore, make the glass blocks seem to be closer to the ceiling of our cubemap environment.
Ultimately, I set the Depth to a value of 1-x, where x is a parameter that can be adjusted in the Material Instance to make the room deeper or shallower.
With the first cubemap complete, I then moved on to the one used to create the illusion of interior walls for each glass block.
For this, I used the previously calculated UV Tiling variable as the UV input for the InteriorCubemap, along with the newly calculated Camera Vector.
I then set the Depth to 1-x, where x is the same as before, and applied a Tiling of 6, since the original texture exported from Substance Designer has a 6×6 glass block pattern.
To keep the graph tidy, I used Reroute Declaration Nodes to store these cubemaps and reuse them later, without having wires stretching across the entire graph. The two cubemaps can be seen in the image below.
Although I calculated the cubemap needed for the glass block walls, I also needed the Texture Cubes, as I mentioned before, which get sampled using the UVW coordinates from that cubemap.
These Texture Cubes include:
- Sides – A texture containing the four walls that make up the glass block
- Back – A texture which looks distorted to be used as the back of the glass block
- Mask – A black and white texture which separates the sides and the back
To capture these Texture Cubes I made a Blueprint Actor in which I built a cube enclosure made of 6 walls, pictured in the image below. I added a “SceneCaptureComponentCube”, represented by a blue camera, along with a spotlight to simulate some light from the other side.
I also made two simple materials: one translucent, with a normal map like the one exported from Substance Designer for the glass in the back, and a wall material that uses Simplex Noise to add some colour variation.
This wall material can be changed for a more realistic one if needed.
I changed the materials based on the context (Sides, Back, Mask) and captured each Texture Cube.
With the Texture Cubes complete, there was only one last thing left to do before I could put everything together.
I needed to use the “Glass Block Walls” Cubemap to sample the “Mask Texture Cube” to create a Cubemap Mask that shifts based on the camera’s position.
Next, it was time to finally put the cubemaps together. I started with the one for the environment behind the glass.
First, I used Cubemap – “Environment Behind” to control the UVs of the HDRI used for the first cubemap, which I then multiplied by “Back Texture Cube” with UVs controlled by Cubemap – “Glass Block Walls”.
This essentially added the glass distortion effect to the first cubemap, creating the illusion that the glass block has two sides: the front, being controlled by the Distorted Camera Vector that I calculated earlier, and an additional layer of distortion behind it.
Finally, I used “Mask Texture Cube” to mask out the padding on the sides.
For the glass block walls, to make them more realistic, I wanted to blend some colour from the environment behind into the wall texture. This creates the illusion of light passing through the glass.
To do this, I used the same Texture Cube from the environment behind with a MIP level of 6 to make it appear blurred and multiplied it by the Cubemap – “Glass Block Walls.”
I then multiplied the result by a “Brightness” parameter, which can be adjusted in the Material Instance. Next, I lerped it with the original wall texture for finer control and multiplied it by a uniform colour called “Wall Tint” to allow the user to change the tint of the walls.
Finally, with all components done, it was time to finish off the shader.
I started with the Base Colour by combining the “Glass Block Walls” and “Environment Behind” cubemaps using “Glass Block Walls Mask” as a mask with the help of a lerp node.
Then I added the grout using “Grout Mask”, and I finished it off by lerping the result with colour tinted version of both the glass as the grout which can be adjusted in the Material Instance.
I used the same BumpOffset setup from the Distorted Camera Vector for the Normal Map.
For Ambient Occlusion, Roughness, and Metallic I used the textures exported from Substance Designer.
Result & Performance
With everything now plugged into the Material Node, this was my final shader.
Since I mentioned earlier that the reason I chose the cubemap method over translucency was performance, I thought it would be fair to compare both head-to-head and see how they measure up.
In the image below, you can see the difference in performance using the Shader Complexity Visualizer in Unreal Engine.
The cube on the left represents the Cubemap Glass Block Shader, while the cube on the right represents the simplest translucent material, with no texture maps connected and an opacity of 0.75.
Wrapping Up
As a conclusion, try to stay away from translucency when it comes to video games.
There are many other options that work very well for different scenarios, and I would encourage you all to just try different things out in your spare time, you never know what you might accidentally find!
Thanks, everyone, for checking out my article, I appreciate it was a fairly long and wordy one, but if you found it interesting or helpful, don’t hesitate to follow me on ArtStation to keep up to date with my work.
You can find procedural systems, materials, and all sorts of cool things there! Also, huge thanks to Games Artist for taking the time to check out my work and offering me a platform to share it with the world.
Hopefully, we will see each other again in the future, “Goodbye, World! :)”