Exploration of the sand shader game Journey

Beginning of a series of articles here

image

Part 4: Mirror Image


In this part, we will focus on mirror reflections, thanks to which the dunes resemble an ocean of sand.

One of the most intriguing effects of Journey 's sand rendering is how the dunes sparkle in the rays of light. This reflection is called specular . The name comes from the Latin word speculum , meaning "mirror" . Specular reflection is an “umbrella” concept that combines all types of interactions in which light is strongly reflected in one direction rather than scattered or absorbed. Thanks to specular reflections, both water and polished surfaces at a certain angle look sparkling.

In JourneyThere are three types of mirror reflections: rim lighting , ocean specular and glitter reflections , shown in the diagram below. In this part we will look at the first two types.




Before and after applying mirror reflections

Rim lighting


You may notice that at each Journey level a limited set of colors is presented. And while this creates a strong and clean aesthetics, this approach complicates the rendering of sand. Dunes are rendered only by a limited number of shades, so it is difficult for the player to understand where one ends at a long distance and the other begins.

To compensate for this, the edge of each dune has a slight radiance effect, highlighting its contours. This allows the dunes not to hide into the horizon and creates the illusion of a much wider and more complex environment.

Before starting to figure out how to implement this effect, let's expand the lighting function by adding diffuse color to it (considered by us in the previous part of the article) and a new generalized component of specular reflection.

float4 LightingJourney (SurfaceOutput s, fixed3 viewDir, UnityGI gi)
{
    // Lighting properties
    float3 L = gi.light.dir;
    float3 N = s.Normal;

    // Lighting calculation
    float3 diffuseColor	= DiffuseColor (N, L);
    float3 rimColor     = RimLighting  (N, V);

    // Combining
    float3 color = diffuseColor + rimColor;

    // Final color
    return float4(color * s.Albedo, 1);
}

In the code snippet shown above, we see that the mirror component of the rim lighting, which is called rimColor, is simply added to the original diffuse color.

High Dynamic Range and Bloom Effects
Both the diffuse component and the glow of the edges are RGB colors in the range from 0before 1. . , 1.

, , 01. , , 1. High Dynamic Range, 1«» . bloom, . .

Fresnel Reflections


The glow of the edges can be realized in many different ways. The most popular shader coding uses the well-known Fresnel reflection model .

To understand the equation underlying Fresnel reflection, it is helpful to visualize where it occurs. The diagram below shows how the dune is visible through the camera (in blue). The red arrow indicates the normal surface of the top of the dune, where it should be a mirror image. It is easy to see that all the edges of the dune have a common property: their normal (N, red) is perpendicular to the direction of view (V, of blue color).


Similar to what we did in the part about Diffuse Color, you can use the scalar product Nand Vto get a measure of their parallelism. In this caseNV equals 0, because two unit vectors are perpendicular; instead we can use1NVto obtain measures of their non-parallelism.

Direct use1NVwill not give us good results, because the reflection will be too strong. If we want to make the reflection sharper , we can just take the expression in degree. Degree of magnitude from0 before 1remains limited to one interval, but the transition between darkness and light becomes sharper.

The Fresnel reflection model states that the brightness of lightI is set as follows:

I=(1NV)powerstrength(1)


Where powerand strength- These are two parameters that can be used to control the contrast and strength of the effect. Parameterspower and strengthsometimes called specular and gloss , but the names may vary.

Equation (1) is very easy to convert to code:

float _TerrainRimPower;
float _TerrainRimStrength;
float3 _TerrainRimColor;

float3 RimLighting(float3 N, float3 V)
{
    float rim = 1.0 - saturate(dot(N, V));
    rim = saturate(pow(rim, _TerrainRimPower) * _TerrainRimStrength);
    rim = max(rim, 0); // Never negative
    return rim * _TerrainRimColor;
}

Its result is shown in the animation below.


Ocean specular


One of the most original aspects of Journey gameplay is that sometimes a player can literally “surf” the dunes. Leading engineer John Edwards explained that thatgamecompany sought to make sand more felt not solid, but liquid.

And this is not entirely wrong, because sand can be perceived as a very rough approximation of a liquid. And under certain conditions, for example, in an hourglass, he even behaves like a liquid.

To reinforce the idea that sand may have a liquid component, Journey has added a second reflection effect, which is often found in liquid bodies. John Edwards calls it ocean specular: The idea is to get the same type of reflections that are visible on the surface of the ocean or lake at sunset (see below).


As before, we will make changes to the lighting function LightingJourneyto add a new type of specular reflection to it.

float4 LightingJourney (SurfaceOutput s, fixed3 viewDir, UnityGI gi)
{
    // Lighting properties
    float3 L = gi.light.dir;
    float3 N = s.Normal;
    float3 V = viewDir;

    // Lighting calculation
    float3 diffuseColor	= DiffuseColor  (N, L);
    float3 rimColor     = RimLighting   (N, V);
    float3 oceanColor   = OceanSpecular (N, L, V);

    // Combining
    float3 specularColor = saturate(max(rimColor, oceanColor));
    float3 color = diffuseColor + specularColor;

    // Final color
    return float4(color * s.Albedo, 1);
}

Why do we take a maximum of two reflection components?
, rim lighting ocean specular. , , -. , .

, .

Mirror reflections on water are often realized using Blinn-Fong reflection , which is a low-cost solution for shiny materials. It was first described by James F. Blinn in 1977 (article: " Models of Light Reflection for Computer Synthesized Pictures ") as an approximation of an earlier shading technique developed by Bui Tyong Fong in 1973 (article: " Illumination for Computer Generated Pictures ") .

When using Blinnu-Phong shading, luminosityI surfaces is given by the following equation:

I=(NH)powerstrength(2)


Where

H=V+LV+L(3)


The denominator of equation (3) divides the vector V+Lon its length. This ensures thatH has a length 1. The equivalent shader function to perform this operation is this normalize. From a geometric point of view,H represents the vector between Vand L, and therefore is called a half vector .



Why is H between V and L?
, , HVL.

, . VLLVVL.

, , V+LL+V, . , :


, , . , ( V+L) . , ( ).


, V+LVL, 1. , 1, ( ).

A more detailed description of Blinn-Fong reflection can be found in the Physically Based Rendering and Lighting Models tutorial . Below is a simple implementation of it in the shader code.

float _OceanSpecularPower;
float _OceanSpecularStrength;
float3 _OceanSpecularColor;

float3 OceanSpecular (float3 N, float3 L, float3 V)
{
    // Blinn-Phong
    float3 H = normalize(V + L); // Half direction
    float NdotH = max(0, dot(N, H));
    float specular = pow(NdotH, _OceanSpecularPower) * _OceanSpecularStrength;
    return specular * _OceanSpecularColor;
}

The animation presents a comparison of the traditional diffuse shadowing according to Lambert and the mirror one according to Blinn-Fong:


Part 5: brilliant reflection


In this part, we will recreate the brilliant reflections that are usually visible on the sand dunes.

Shortly after publishing my series of articles, Julian Oberbek and Paul Nadelek made their own attempt to recreate a scene inspired by the Journey game in Unity. The tweet below shows how they perfected brilliant reflections to provide greater temporal integrity. Read more about their implementation in an article on IndieBurg Mip Map Folding .


In the previous part of the course, we revealed the implementation of two mirror functions in Journey sand rendering : rim lighting and ocean specular . In this part, I will explain how to implement the last version of specular reflection: glitter .


If you've ever been to the desert, you probably noticed how sand is actually shiny. As discussed in the sand normals part, each grain of sand can potentially reflect light in a random direction. Due to the nature of random numbers, part of these reflected rays will fall into the camera. Because of this, random dots of sand will appear very bright. This luster is very sensitive to movement, since the slightest shift will prevent the reflected rays from entering the camera.

In other games, such as Astroneer and Slime Rancher, brilliant reflections were used for sand and caves.



Gloss: before and after applying the effect.

It's easier to evaluate these gloss characteristics in a larger image:



Without a doubt, the glitter effect on real dunes depends entirely on the fact that some grains of sand randomly reflect light into our eyes. Strictly speaking, this is exactly what we have already modeled in the second part of the course devoted to sand normals, when we modeled a random distribution of normals. So why do we need another effect for this?

The answer may not be too obvious. Let's imagine that we are trying to recreate the gloss effect only with the help of normals. Even if all the normals are directed to the camera, the sand will still not shine, because the normals can only reflect the amount of light that is available in the scene. That is, at best, we will reflect only 100% of the light (if the sand is completely white).

But we need something else. If we want the pixel to appear so bright that the light spreads onto the pixels adjacent to it, then the color should be larger1. This happened because in Unity, when the bloom filter is applied to the camera using the post-processing effect , the colors are brighter1spread to neighboring pixels and produce a halo that creates the feeling that some pixels are glowing. This is the foundation of HDR rendering .

So no, overlaying normals cannot be used in a simple way to create shiny surfaces. Therefore, this effect is more convenient to implement as a separate process.

Theory of Microfaces


To approach the situation more formally, we need to perceive the dunes as consisting of microscopic mirrors, each of which has a random direction. This approach is called the microfacet theory , where each of these tiny mirrors is called microfacet . The mathematical foundation of most modern shading models is based on the theory of micro faces, including the Standard shader model from Unity .

The first step is to divide the surface of the dune into micro faces and determine the orientation of each of them. As already mentioned, we did something similar in the part of the tutorial on the normals of sand, where the UV position of the 3D model of the dune was used to sample a random texture. The same approach can be used here to attach a random orientation to each micro facet. The size of each microface will depend on the scale of the texture and on its level of mip-texturing . Our task is to recreate a certain aesthetics, and not the desire for photorealism; this approach will be good enough for us.

After sampling a random texture, we can associate a random direction with each sand grain / micro facet of the dune. Let's call himG. It indicates the direction of brightness , that is, the direction of the normal of the grains of sand we are looking at. A ray of light falling on a grain of sand will be reflected, taking into account the fact that the micro facet is an ideal mirror oriented in the directionG. The resulting reflected light ray should enter the camera.R (see below).


Here we can again use the scalar product Rand Vto obtain measures of their parallelism.

One approach is exponentiationRV, as explained in the previous (fourth) part of the article. If you try to do so, we will see that the result is very different from what is in Journey . Brilliant reflections should be rare and very bright. The easiest way will be to consider only those brilliant reflections for whichRV is below a certain threshold value.

Implementation


We can easily implement the gloss effect described above using a function reflectin Cg, which makes it very easy to calculateR.

sampler2D_float _GlitterTex;
float _GlitterThreshold;
float3 _GlitterColor;

float3 GlitterSpecular (float2 uv, float3 N, float3 L, float3 V)
{
    // Random glitter direction
    float3 G = normalize(tex2D(_GlitterTex, uv).rgb * 2 - 1); // [0,1]->[-1,+1]

    // Light that reflects on the glitter and hits the eye
    float3 R = reflect(L, G);
    float RdotV = max(0, dot(R, V));
	
    // Only the strong ones (= small RdotV)
    if (RdotV > _GlitterThreshold)
        return 0;
	
    return (1 - RdotV) * _GlitterColor;
}

Strictly speaking, if Gcompletely by chance then Rwill also be completely random. It may seem that use is reflectoptional. And although this is true for a static frame, but what happens if the light source moves? This can be either due to the movement of the sun itself, or because of a point source of light tied to the player. In both cases, the sand will lose temporal integrity between the current and subsequent frames, due to which the glitter effect will appear in random places. However, using the function reflectprovides much more stable rendering.

The results are shown below:


As we recall from the very first part of the tutorial, the gloss component is added to the final color.

#pragma surface surf Journey fullforwardshadows

float4 LightingJourney (SurfaceOutput s, fixed3 viewDir, UnityGI gi)
{
    float3 diffuseColor = DiffuseColor    ();
    float3 rimColor     = RimLighting     ();
    float3 oceanColor   = OceanSpecular   ();
    float3 glitterColor = GlitterSpecular ();

    float3 specularColor = saturate(max(rimColor, oceanColor));
    float3 color = diffuseColor + specularColor + glitterColor;
	
    return float4(color * s.Albedo, 1);
}

There is a high probability that some pixels will eventually get more color 1, which will lead to the bloom effect. That is what we need. The effect is also added on top of the already existing specular reflection (discussed in the previous part of the article), so shiny grains of sand can even be found where the dunes are well lit.

There are many ways to improve this technique. It all depends on the outcome that you want to achieve. In Astroneer and Slime Rancher , for example, this effect is only used at night. This can be achieved by reducing the strength of the gloss effect depending on the direction of sunlight.

For example, the value max(dot(L, fixed3(0,1,0),0))is1when the sun falls from above, and is equal to zero when it is beyond the horizon. But you can create your own system, the appearance of which depends on your preferences.

Why is reflection not used in Blinn-Fong reflection?
ocean specular, , -.

, 3D- , , . , reflect . - RVNH, .

Part 6: waves


In the last part of the article, we will recreate typical sand waves resulting from the interaction of dunes and wind.




Waves on the surface of the dunes: before and after It would be

theoretically logical to put this part after the part about the normals of sand. I left it in the end because it is the most difficult of the effects of the tutorial. Part of this complexity is due to the way the normal maps are stored and processed by a surface shader that performs many additional steps.

Normal Maps


In the previous (fifth) part, we explored a method for producing heterogeneous sand. In the part devoted to sand normals, a very popular normal mapping technique was used to change the way light interacts with the geometry surface . It is often used in 3D graphics to create the illusion that the object has more complex geometry, and is usually used to make curved surfaces smoother (see below).


To achieve this effect, each pixel is mapped to the direction of the normal , indicating its orientation. And it is used to calculate lighting instead of the true orientation of the mesh.

By reading the directions of the normals from an apparently random texture, we were able to simulate the graininess. Despite the physical inaccuracy, she still looks believable.

Waves in the sand


However, sand dunes show another feature that cannot be ignored: waves. On each dune there are smaller dunes, appearing due to the influence of the wind and held together by the friction of individual grains of sand.

These waves are very visible and visible on most dunes. In the photograph shown below, taken in Oman, it is seen that the near part of the dune has a pronounced wavy pattern.


These waves vary significantly depending not only on the shape of the dune, but also on the composition, direction and speed of the wind. Most dunes with a sharp peak have waves on one side only (see below).


The effect presented in the tutorial is designed for smoother dunes with waves on both sides. This is not always physically accurate, but realistic enough to be believable, and is a good first step toward more complex implementations.

Realization of the waves


There are many ways to implement waves. The least expensive is to simply draw them on a texture, but in the tutorial we want to achieve something else. The reason is simple: the waves are not “flat” and must correctly interact with the light. If you simply draw them, it will be impossible to achieve a realistic effect when the camera (or the sun) moves.

Another way to add waves is to change the geometry of the dune model. But increasing the complexity of the model is not recommended, because it greatly affects the overall performance.

As we saw in the part about sand normals, you can get around this problem using normal maps . In fact, they are drawn on the surface like traditional textures, but are used in lighting calculations to simulate more complex geometry.

That is, the task has turned into another: how to create these normal maps. Manual rendering will be too time consuming. In addition, each time you change the dunes, you will need to redraw the waves again. This will significantly slow down the process of creating resources, which many technical artists seek to avoid.

A much more effective and optimal solution would be to add waves in a procedural manner. This means that the normal directions of the dunes change based on local geometry in order to take into account not only sand grains, but also waves.

Since the waves need to be simulated on a 3D surface, it will be more logical to implement a change in the direction of the normals for each pixel. It’s easier to use a seamless normal map with a wave pattern. This map will then be combined with the existing normal map previously used for sand.

Normal Maps


Until this moment, we met with three different normals :

  • Geometry Normal : the orientation of each face of the 3D model, which is stored directly at the vertices;
  • Sand normal : calculated using noise texture;
  • Wave Normal : The new effect discussed in this part.

The example below, taken from the Unity Surface Shader examples page , demonstrates a standard way to rewrite the normal of a 3D model. This requires changing the value o.Normal, which is usually done after sampling the texture (most often called the normal map ).

  Shader "Example/Diffuse Bump" {
    Properties {
      _MainTex ("Texture", 2D) = "white" {}
      _BumpMap ("Bumpmap", 2D) = "bump" {}
    }
    SubShader {
      Tags { "RenderType" = "Opaque" }
      CGPROGRAM
      #pragma surface surf Lambert
      struct Input {
        float2 uv_MainTex;
        float2 uv_BumpMap;
      };
      sampler2D _MainTex;
      sampler2D _BumpMap;
      void surf (Input IN, inout SurfaceOutput o) {
        o.Albedo = tex2D (_MainTex, IN.uv_MainTex).rgb;
        o.Normal = UnpackNormal (tex2D (_BumpMap, IN.uv_BumpMap));
      }
      ENDCG
    } 
    Fallback "Diffuse"
  }

We used the exact same technique to replace the normal of geometry with the normal of sand.

What is UnpackNormal?
. ( 1), , . X, Y Z R, G B .

1+1. 01. , «» «» . (normal packing) (normal unpacking). :

R=X2+12G=Y2+12B=Z2+12(1)


:

X=2R1Y=2G1Z=2B1(2)


Unity (2), UnpackNormal. .

Normal Map Technical Details polycount.

Steepness of the dunes


However, the first difficulty is related to the fact that the waveform changes depending on the sharpness of the dunes. Low, flat dunes have small waves; on steep dunes, wave patterns are more distinct. This means that you need to consider the steepness of the dune.

The easiest way around this problem is to create two different normal maps, respectively, for flat and steep dunes. The basic idea is to mix between the two normal maps based on the steepness of the dune.


Normal Map for the Steep Dune


Normal Map for a Flat Dune

Normal maps and blue channel
, .

, . , (X Y) (Z) .

length(N)=1X2+Y2+Z2=1(3)


:

X2+Y2+Z2=1X2+Y2+Z2=12Z2=1X2Y2Z=1X2Y2(4)


UnpackNormal Unity. Shader API .

inline fixed3 UnpackNormal(fixed4 packednormal)
{
#if defined(SHADER_API_GLES)  defined(SHADER_API_MOBILE)
    return packednormal.xyz * 2 - 1;
#else
    fixed3 normal;
    normal.xy = packednormal.wy * 2 - 1;
    normal.z = sqrt(1 - normal.x*normal.x - normal.y * normal.y);
    return normal;
#endif
}

Unity DXT5nm, (packednormal.xy) - (packednormal.wy).

Normal Map Compression polycount.

Why do normal maps seem lilac?
, , .

, . , [0,0,1].

[0,0,1][0.5,0.5,1], RGB.

; , [0,0,1].

, «». , B 1+1, 0+1. .

The steepness can be calculated using a scalar product , which is often used in shader coding to calculate the degree of “parallelism” of two directions. In this case, we take the direction of the normal of the geometry (shown below in blue) and compare it with a vector pointing to the sky (shown below in yellow). The scalar product of these two vectors returns values ​​close to1when the vectors are almost parallel (flat dunes), and close to 0when the angle between them is 90 degrees (steep dunes).


However, we are faced with the first problem - two vectors are involved in this operation. The normal vector obtained from the function surfusing o.Normalis expressed in tangent space . This means that the coordinate system used to encode the normal direction is relative to the local surface geometry (see below). We briefly touched on this topic in the part about the normal sand.


A vector pointing to the sky is expressed in world space . To get the correct scalar product, both vectors must be expressed in the same coordinate system. This means that we need to transform one of them so that both are expressed in one space.

Fortunately, Unity comes to the rescue with a function WorldNormalVectorthat allows us to transform the normal vector from tangent space to world space . To use this feature, we need to change the structure Input, so that it was included float3 worldNormaland INTERNAL_DATA:

struct Input
{
    ...

    float3 worldNormal;
    INTERNAL_DATA
};

This is explained in an article from the Unity Writing Surface Shaders documentation that says:

INTERNAL_DATA — , o.Normal.

, WorldNormalVector (IN, o.Normal).

Often this becomes the main source of problems when writing surface shaders. In fact, the value o.Normalthat is available in the function surf, varies depending on how you're using. If you are only reading it, o.Normalcontains the normal vector of the current pixel in world space . If you change its value, it o.Normalis in tangent space .

If you record in o.Normal, but you still need access to the normal in world space (as in our case), then you can use it WorldNormalVector (IN, o.Normal). However, for this you need to make a small change to the structure shown above Input.

What is INTERNAL_DATA?
INTERNAL_DATA Unity.

, , WorldNormalVector. , , . ( ).

, 3D- 3×3. , , (tangent to world matrix), Unity TtoW.

INTERNAL_DATA TtoW Input. , «Show generated code» :


, INTERNAL_DATA — , TtoW:

#define INTERNAL_DATA
    half3 internalSurfaceTtoW0;
    half3 internalSurfaceTtoW1;
    half3 internalSurfaceTtoW2;

half3x3, half3.

WorldNormalVector, , ( ) TtoW:

#define WorldNormalVector(data,normal)
    fixed3
    (
        dot(data.internalSurfaceTtoW0, normal),
        dot(data.internalSurfaceTtoW1, normal),
        dot(data.internalSurfaceTtoW2, normal)
    )

mul, TtoW , .

, :

[ToW1,1ToW1,2ToW1,3ToW2,1ToW2,2ToW2,3ToW3,1ToW3,2ToW3,3][N1N2N3]=[[ToW1,1ToW1,2ToW1,3][N1N2N3][ToW2,1ToW2,2ToW2,3][N1N2N3][ToW3,1ToW3,2ToW3,3][N1N2N3]]


LearnOpenGL.

Implementation


A snippet of the code below converts the normal from tangent to world space and calculates the slope relative to the up direction .

// Calculates normal in world space
float3 N_WORLD = WorldNormalVector(IN, o.Normal);
float3 UP_WORLD = float3(0, 1, 0);

// Calculates "steepness"
// => 0: steep (90 degrees surface)
//  => 1: shallow (flat surface)
float steepness = saturate(dot(N_WORLD, UP_WORLD));

Now that we have calculated the steepness of the dune, we can use it to mix the two normal maps. Both normal maps are sampled, both flat and cool (in the code below they are called _ShallowTexand _SteepTex). Then they are mixed based on the value steepness:

float2 uv = W.xz;

// [0,1]->[-1,+1]
float3 shallow = UnpackNormal(tex2D(_ShallowTex, TRANSFORM_TEX(uv, _ShallowTex)));
float3 steep   = UnpackNormal(tex2D(_SteepTex,   TRANSFORM_TEX(uv, _SteepTex  )));

// Steepness normal
float3 S = normalerp(steep, shallow, steepness);

As stated in the part about sand normals, it is quite difficult to combine normal maps correctly, and this cannot be done with lerp. In this case slerp, it is more correct to use , but instead, a less expensive function called normalerp.

Wave mixing


If we use the code shown above, the results may disappoint us. This is because the dunes have very little steepness, which leads to too much mixing of the two normal textures. To fix this, we can apply a non-linear transformation to the steepness, which will increase the sharpness of the mixture:

// Steepness to blending
steepness = pow(steepness, _SteepnessSharpnessPower);

When mixing two textures, it is often used to control their sharpness and contrast pow. We learned how and why it works in my Physically Based Rendering tutorial .

Below we see two gradients. The top shows the colors from black to white, linearly interpolated along the X axis with c = uv.x. At the bottom, the same gradient is represented with c = pow(uv.x*1.5)*3.0:



It is easy to notice, which powallows you to create a much sharper transition between black and white. When we work with textures, this reduces their overlap, creating sharper edges.

Direction of the dunes


Everything that we did earlier works perfectly. But we need to solve another last problem. Waves vary with steepness , but not with direction . As mentioned above, waves are usually not symmetrical due to the fact that the wind mainly blows in one direction.

To make the waves even more realistic, we need to add two more normal maps (see table below). They can be mixed depending on the parallelism of the dune of the X axis or Z axis.

CoolFlat
Xcool xflat x
Zcool zflat z

Here we need to implement the calculation of the parallelism of the dune relative to the Z axis. This can be done similarly to the calculation of the steepness, but float3 UP_WORLD = float3(0, 1, 0);can be used instead float3 Z_WORLD = float3(0, 0, 1);.

This last step I will leave to you. If you have any problems, then at the end of this tutorial there is a link to download the full Unity package.

Conclusion


This is the last part of a series of tutorials on rendering sand from Journey.

The following shows how far we have been able to advance in this series:



Before and after

I want to thank you for reading this rather long tutorial to the end. I hope you enjoyed exploring and re-creating this shader.

Acknowledgments


The Journey video game was developed by Thatgamecompany and published by Sony Computer Entertainment . It is available for PC ( Epic Store ) and PS4 ( PS Store ).

3D dune models, backgrounds, and lighting options were created by Jiadi Deng .

A 3D model of the Journey character was found on the FacePunch forum (now closed).

Unity Package


If you want to recreate this effect, then the full Unity package is available for download on Patreon . It has everything you need, from shaders to 3D models.

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


All Articles