Andrea Riccardi discussed how you can create a convincing freezing effect on the screen, to give the player the feel that he may freeze to death as time passes.
Andrea Riccardi allowed us to repost his article on an outstanding freezing screen shader which he created with the help of Ludovico Antonicelli. The artist is constantly publishing breakdowns of shaders, so make sure to check out his personal blog.
Hello there, and welcome to another Technical Challenge of the Week! This one was inspired by a post-process effect used in a bachelor’s project I supervised during the past months. You can find out more about the project here on Facebook, and a more in-depth technical art analysis here on artstation, written by my great friend Ludovico Antonicelli.
So, back to us. The aim of this challenge was to get a convincing freezing effect on the screen, to give the player the feel that he may freeze to death as time passes. Here you can find the solution we came up with in our Unreal project. To get this feel, we needed a couple features:
- The effect should be modulable, meaning that a single parameter, animated via script, should control the freezing progression
- The effect should start from the borders and go toward the center as the freeze progress
- The effect should of course look as realistic as possible
The first thing I did was to look for references, especially in games, to both try to get hints on how the effect was achieved, and have a means of comparison to evaluate my progress. Surprisingly, though, I haven’t found any game with this kind of effect. I researched quite a lot, especially Crysis and Crysis Warhead, which I remember had frozen parts, but I wasn’t able to find any reference for this specific effect. If you know of any game with a nice freezing screen effect, please comment below and let me know!
As usual, the main goal of the challenge was the performance, so complex effects like refraction and physical accurate sub-surface scattering were out of discussion. Also, because of the texture being applied directly onto the screen, and considering the very high frequency of the ice cristal details, the texture dimension must remain of a considerable size: all the needed information must then be packed into one single texture, to avoid bandwidth problems.
To achieve our freezing screen effect, we’ll surely need:
- a normal map, to distort the frame;
- a sort of height map, or density map if you prefer, which tells us where the ice is thick and where is thin, in order to blend the ice color accordingly;
- a map to distort the gradient, to give the impression that the ice veins “run” toward the center in a pseudo random, noisy way, rather than linearly;
Now, normal maps in tangent space are usually encoded in an rgb texture, with the b channel equal to 1.0 (or 255, if you like integer representation). So, if we drop the blue channel, which we won’t use, and place the density map in it instead, and the gradient map in the alpha, we can pack all the info we need in just one single texture! That sounds like the way to go.
NOTE: a well produced texture is essential for this effect to work as intended. You’ll need very nice normals, a convincing density map and an artist-authored gradient map to get a realistic effect
Back to the code now. The first thing we should do in our shader is to extract the info from our texture, keeping in mind that the normals are compressed and stored as [0..1] values, but they really are in a [-1..1] range, so expand them back to their original range right after the texture sampling with the old value * 2.0 – 1.0 trick.
Right after, we want to modulate them using the gradient, so the rest of the calculation can be written without having to worry about how to adjust the values. So we could get the gradient more or less like this
float gradient = pow(opacity, _Steepness – (_Amount * _Steepness)) * _Amount;
and then multiply both the density and the opacity by it. The normal, instead, should be modulated by the opacity, in order to make it follow the ice veins instead of the simple linear gradient.
The next step is to compute some colors! The first we’ll need is the light_color, a.k.a. the frame we just rendered. To fake the ice-caused distortion of the light, we’ll offset the uv based on the normal we sampled from the texture, modulated by a parameter to allow strength tuning. The last color we need is the ice_color, computed as the density times the _Colorparameter.
It’s time to get to the juicy stuff. To get the feel of the ice actually interacting with light, we’ll compute a diffuse lighting factor, the plain old NdotL diffuse term. But, since we’ve no actual light to compute, we’ll use a fake light source. To spice thing up, instead of using a fixed direction, we’ll use the expanded uv as the .xy components of the vector, to get a sphere-like direction for our fake light, like this
float2 light_source = (i.uv * 2.0 – 1.0) * 0.2; float NdotL = saturate(dot(float3(normal, 1.0), normalize(float3(light_source, 1.0))));
Last, but absolutely not least, the subsurface scattering. As I mentioned before, physically correct subsurface scatering is extremely costly to compute, but I found a fantastic presentation by Colin Barre-Brisebois, held at GDC 2011, conveniently titled Approximating Translucency for a Fast, Cheap and Convincing Subsurface-Scattering Look. In this awesome slideshow, he presents a very smart technique to fake subsurface scattering, lowering dramatically the costs while mantaining a convincing overall result.
The idea sounds more or less like this: approximate the light transportation inside the shape using a local thickness map, distort and attenuate the result using a view-dependant factor. It all comes down to four lines of code, as taken from slide 21:
half3 vLTLight = vLight + vNormal * fLTDistortion; half3 fLTDot = pow(saturate(dot(vEye, -vLTLight)), iLTPower)*fLTScale; half3 fLT = fLightAttenuation * (fLTDot + fLTAmbient) * fLTThickness; outColor.rgb+= cDIffuseAlbedo * cLightDiffuse * fLT;
In our case, we already computed the dot product with our fake light source, so the actual work is reduced to
half sss_dot = pow(NdotL, _SSSPower) * _SSSScale; //changing _SSSLightAttenuation values in order to preserve visual impact during lerping _SSSLightAttenuation = lerp(8.0, 1.5, _Amount); //compute light contribution as the dot + light color from behind modulated by the ice thickness mask (a.k.a. the opacity) half3 sss_light_contribution = _SSSLightAttenuation * (sss_dot + light_color.rgb) * opacity; //colorize the light contribution by the ice color sss_light_contribution *= ice_color;
Once we’ve done this, the last thing we need to do is to lerp between the light_color from the frame below and the sss_light_contribution already computed, based on the opacity mask. Since the opacity mask is modulated by the gradient, the icy effect will progress from borders toward the center as the _Amount parameter grows to 1.
Andrea Riccardi
The article was originally published here.