Monday, July 9, 2018

Dark and Stormy


This repo is availible on github: github.com/SquirrelyJones/DarkAndStormy
In this post I'll break down some of whats going on in this funky skybox shader for Unity. Some of the techniques used are Flow Mapping, Steep Parallax Mapping, and Front To Back Alpha Blending. There are plenty of resources that go over these techniques in detail and this post is more about using those techniques to make something cool than it is about thoroughly explaining each one. It should also be noted that this shader is not optimized and is structured for easier readability.

The Textures

This is the main cloud layer.
This is the flow map generated from the main cloud layer. The red and green channels are similar to a blurry normal map (openGL style) generated from a the main clouds layer height. The smaller clouds will flow outward from the thicker parts of the main clouds. The blue channel is a mask for where there may be pinches due to the flow pushing a lot of the clouds texture into a small area. This doesn't look great on clouds so we want to mask out places where this will occur.
This is the second cloud layer, it will add detail to the large clouds and be distorted by the large clouds flow map.
This is the wave distortion map. This distorts all the clouds and gives an ocean wave feel to the motion.
The wave distortion mas generated in Substance Designer using a cellular pattern with heavy anisotropic blur applied. he blur direction should be perpendicular to the direction it will scroll to give it a proper wavy look.
The last texture is the Upper color that will show through the clouds.

The Shader

Shader "Skybox/Clouds"
{
 Properties
 {
  [NoScaleOffset] _CloudTex1 ("Clouds 1", 2D) = "white" {}
  [NoScaleOffset] _FlowTex1 ("Flow Tex 1", 2D) = "grey" {}
  _Tiling1("Tiling 1", Vector) = (1,1,0,0)

  [NoScaleOffset] _CloudTex2 ("Clouds 2", 2D) = "white" {}
  [NoScaleOffset] _Tiling2("Tiling 2", Vector) = (1,1,0,0)
  _Cloud2Amount ("Cloud 2 Amount", float) = 0.5
  _FlowSpeed ("Flow Speed", float) = 1
  _FlowAmount ("Flow Amount", float) = 1

  [NoScaleOffset] _WaveTex ("Wave", 2D) = "white" {}
  _TilingWave("Tiling Wave", Vector) = (1,1,0,0)
  _WaveAmount ("Wave Amount", float) = 0.5
  _WaveDistort ("Wave Distort", float) = 0.05

  _CloudScale ("Clouds Scale", float) = 1.0
  _CloudBias ("Clouds Bias", float) = 0.0

  [NoScaleOffset] _ColorTex ("Color Tex", 2D) = "white" {}
  _TilingColor("Tiling Color", Vector) = (1,1,0,0)
  _ColPow ("Color Power", float) = 1
  _ColFactor ("Color Factor", float) = 1

  _Color ("Color", Color) = (1.0,1.0,1.0,1)
  _Color2 ("Color2", Color) = (1.0,1.0,1.0,1)

  _CloudDensity ("Cloud Density", float) = 5.0

  _BumpOffset ("BumpOffset", float) = 0.1
  _Steps ("Steps", float) = 10

  _CloudHeight ("Cloud Height", float) = 100
  _Scale ("Scale", float) = 10

  _Speed ("Speed", float) = 1

  _LightSpread ("Light Spread PFPF", Vector) = (2.0,1.0,50.0,3.0)
 }
All the properties that can be played with.
 SubShader
 {
  Tags { "RenderType"="Opaque" }
  LOD 100

  Pass
  {
   CGPROGRAM
   #pragma vertex vert
   #pragma fragment frag
   
   #include "UnityCG.cginc"
   #define SKYBOX
   #include "FogInclude.cginc"
There is a custom include file that has a poor mans height fog and integrates directional light color. The terrain shader also uses the same fog to keep things cohesive.
   sampler2D _CloudTex1;
   sampler2D _FlowTex1;
   sampler2D _CloudTex2;
   sampler2D _WaveTex;

   float4 _Tiling1;
   float4 _Tiling2;
   float4 _TilingWave;

   float _CloudScale;
   float _CloudBias;

   float _Cloud2Amount;
   float _WaveAmount;
   float _WaveDistort;
   float _FlowSpeed;
   float _FlowAmount;

   sampler2D _ColorTex;
   float4 _TilingColor;

   float4 _Color;
   float4 _Color2;

   float _CloudDensity;

   float _BumpOffset;
   float _Steps;

   float _CloudHeight;
   float _Scale;
   float _Speed;

   float4 _LightSpread;

   float _ColPow;
   float _ColFactor;
Just declaring all the property variables to be used.
   struct v2f
   {
    float4 vertex : SV_POSITION;
    float3 worldPos : TEXCOORD0; 
   };

   
   v2f vert (appdata_full v)
   {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.worldPos = mul( unity_ObjectToWorld, v.vertex ).xyz;
    return o;
   }
The vertex shader is pretty lightweight, just need the world position for the pixel shader.
   float rand3( float3 co ){
       return frac( sin( dot( co.xyz ,float3(17.2486,32.76149, 368.71564) ) ) * 32168.47512);
   }
We'll need a random number for some noise. This will generate a random number based on a float3.
   half4 SampleClouds ( float3 uv, half3 sunTrans, half densityAdd ){

    // wave distortion
    float3 coordsWave = float3( uv.xy *_TilingWave.xy + ( _TilingWave.zw * _Speed * _Time.y ), 0.0 );
    half3 wave = tex2Dlod( _WaveTex, float4(coordsWave.xy,0,0) ).xyz;
The wave texture needs to be sampled first, it will distort the rest of the coordinates like a Gerstner Wave. In all the _Tiling parameters .xy is tiling scale and .zw is scrolling speed. All scrolling is multiplied byt the global _Speed variable for easily adjusting the overall speed of the skybox.
    // first cloud layer
    float2 coords1 = uv.xy * _Tiling1.xy + ( _Tiling1.zw * _Speed * _Time.y ) + ( wave.xy - 0.5 ) * _WaveDistort;
    half4 clouds = tex2Dlod( _CloudTex1, float4(coords1.xy,0,0) );
    half3 cloudsFlow = tex2Dlod( _FlowTex1, float4(coords1.xy,0,0) ).xyz;
Using the red and green channels of the wave texture (xy) distort the uv coordinates for the first cloud layer. Also sample the clouds flow texture with the same coordinates.
    // set up time for second clouds layer
    float speed = _FlowSpeed * _Speed * 10;
    float timeFrac1 = frac( _Time.y * speed );
    float timeFrac2 = frac( _Time.y * speed + 0.5 );
    float timeLerp  = abs( timeFrac1 * 2.0 - 1.0 );
    timeFrac1 = ( timeFrac1 - 0.5 ) * _FlowAmount;
    timeFrac2 = ( timeFrac2 - 0.5 ) * _FlowAmount;
This is a standard setup for flow mapping.

    // second cloud layer uses flow map
    float2 coords2 = coords1 * _Tiling2.xy + ( _Tiling2.zw * _Speed * _Time.y );
    half4 clouds2 = tex2Dlod( _CloudTex2, float4(coords2.xy + ( cloudsFlow.xy - 0.5 ) * timeFrac1,0,0)  );
    half4 clouds2b = tex2Dlod( _CloudTex2, float4(coords2.xy + ( cloudsFlow.xy - 0.5 ) * timeFrac2 + 0.5,0,0)  );
    clouds2 = lerp( clouds2, clouds2b, timeLerp);
    clouds += ( clouds2 - 0.5 ) * _Cloud2Amount * cloudsFlow.z;
The second cloud layer coordinates start with the first cloud layer coordinates so the second cloud layer will stay relative to the first. Sample the second cloud layer using the flow map to distort the coordinates. Then add them to the base cloud layer, masking them by the flow maps blue channel.
    // add wave to cloud height
    clouds.w += ( wave.z - 0.5 ) * _WaveAmount;
Add the wave texture blue channel to the cloud height
    // scale and bias clouds because we are adding lots of stuff together
    // and the values cound go outside 0-1 range
    clouds.w = clouds.w * _CloudScale + _CloudBias;
Since everything is just getting added together there is the possibility that the values could go outside of 0-1 range. If things look weird we can manually scale and bias the final value back into a more reasonable range.
    // overhead light color
    float3 coords4 = float3( uv.xy * _TilingColor.xy + ( _TilingColor.zw * _Speed * _Time.y ), 0.0 );
    half4 cloudColor = tex2Dlod( _ColorTex, float4(coords4.xy,0,0)  );
sample the overhead light color texture.
    // cloud color based on density
    half cloudHightMask = 1.0 - saturate( clouds.w );
    cloudHightMask = pow( cloudHightMask, _ColPow );
    clouds.xyz *= lerp( _Color2.xyz, _Color.xyz * cloudColor.xyz * _ColFactor, cloudHightMask );
Using the cloud height (the alpha channel of the clouds) lerp between the the 2 colors and multiply the overall cloud color. The power function is used to adjust the tightness of the "cracks" in the clouds that let light through.
    // subtract alpha based on height
    half cloudSub = 1.0 - uv.z;
    clouds.w = clouds.w - cloudSub * cloudSub;
subtract the uv position from the cloud height. This gives us the cloud density at the current height.
    // multiply density
    clouds.w = saturate( clouds.w * _CloudDensity );
Multiply the density by the _CloudDensity variable to control the softness of the clouds.
    // add extra density
    clouds.w = saturate( clouds.w + densityAdd );
Add any extra density if needed. This variable is passed in and is 0 except for the final pass in which it is 1
    // add Sunlight
    clouds.xyz += sunTrans * cloudHightMask;
Add in the sun gradients masked by the cloud height mask.
    // pre-multiply alpha
    clouds.xyz *= clouds.w;
The front to back alpha blending function needs the alpha to be pre-multiplied.
    return clouds;
   }
This is the main function for sampling the clouds. The pixel shader will loop over this function.
   fixed4 frag (v2f IN) : SV_Target
   {
    // generate a view direction fromt he world position of the skybox mesh
    float3 viewDir = normalize( IN.worldPos - _WorldSpaceCameraPos );

    // get the falloff to the horizon
    float viewFalloff = 1.0 - saturate( dot( viewDir, float3(0,1,0) ) );

    // Add some up vector to the horizon to pull the clouds down
    float3 traceDir = normalize( viewDir + float3(0,viewFalloff * 0.1,0) );
We can get the view direction from subtracting the camera position from the world position and normalizing the result. "traceDir" is the direction that will be used generate the cloud uvs. It is just the view direction with a little bit of "up" added at the horizon. This adds a little bit of bend to the clouds, like they are curving around the planet, and keeps them from sprawling off into infinity at the horizon and causing all kinds of artifacts.
    // Generate uvs from the world position of the sky
    float3 worldPos = _WorldSpaceCameraPos + traceDir * ( ( _CloudHeight - _WorldSpaceCameraPos.y ) / max( traceDir.y, 0.00001) );
    float3 uv = float3( worldPos.xz * 0.01 * _Scale, 0 );
Use the camera position + the trace direction to get a world position for the cloud layer. This way the clouds will react to the camera moving, just make sure not to move the camera up through the clouds, things get weird. Then make the uvs for the clouds from the world position, multiplying by the global scale variable for easy adjusting.
    // Make a spot for the sun, make it brighter at the horizon
    float lightDot = saturate( dot( _WorldSpaceLightPos0, viewDir ) * 0.5 + 0.5 );
    half3 lightTrans = _LightColor0.xyz * ( pow( lightDot,_LightSpread.x ) * _LightSpread.y + pow( lightDot,_LightSpread.z ) * _LightSpread.w );
    half3 lightTransTotal = lightTrans * pow(viewFalloff, 5 ) * 5.0 + 1.0;
Using the dot product from the first directional light direction and the view direction, get a gradient in the direction of the sun. Then use power to tighten up the gradient to your liking. This it the light from the sun that will shine through the back of the clouds. The _LightSpread parameter has the power and factor for the two sun gradients that get added together for better control.
    // Figure out how for to move through the uvs for each step of the parallax offset
    half3 uvStep = half3( traceDir.xz * _BumpOffset * ( 1.0 / traceDir.y ), 1.0 ) * ( 1.0 / _Steps );
    uv += uvStep * rand3( IN.worldPos + _SinTime.w );
Standard steep parallax uv step amount. This is how far through the uvs and the cloud height we move with each sample. Then the starting uv is jittered a bit wit a random value per pixel to keep it from looking like flat layers.
    // initialize the accumulated color with fog
    half4 accColor = FogColorDensitySky(viewDir);
    half4 clouds = 0;
    [loop]for( int j = 0; j < _Steps; j++ ){
     // if we filled the alpha then break out of the loop
     if( accColor.w >= 1.0 ) { break; }

     // add the step offset to the uv
     uv += uvStep;

     // sample the clouds at the current position
     clouds = SampleClouds(uv, lightTransTotal, 0.0 );

     // add the current cloud color with front to back blending
     accColor += clouds * ( 1.0 - accColor.w );
    }
Start by getting the fog at the starting point. This creates an early out opportunity from the loop since we don't need to sample clouds once the the accumulated color is fully opaque. Then Iterate over the clouds moving the uv with each iteration and adding the clouds to the accumulated color using front to back alpha blending.
    // one last sample to fill gaps
    uv += uvStep;
    clouds = SampleClouds(uv, lightTransTotal, 1.0 );
    accColor += clouds * ( 1.0 - accColor.w );
Once we have iterated over the entire cloud volume do one last sample without testing against the cloud height to fill in any holes from cloud values that didn't fit inside the volume.
    // return the color!
    return accColor;
   }
   ENDCG
  }
 }
}
Then return the color and we're done!

7 comments:

  1. Wow, dude. It's brilliant!!! Thanks for sharing!

    ReplyDelete
  2. This is SO GREAT!
    I wonder if Shader Graph will allow us to build shaders like this beauty...

    ReplyDelete
  3. Is there any way to use this with the HDRP in Unity? Skyboxes need to be cubemaps

    ReplyDelete
  4. This comment has been removed by the author.

    ReplyDelete
  5. Thank you so much for sharing this!!

    ReplyDelete
  6. I'm just starting with Substance Designer and feel like there are not many articles about how to create VFX textures like these awesome clouds. Would you be willing to share any directions on how to achieve results like the Cloud Layer 2 image?
    Anyway, inspirational read!

    ReplyDelete
  7. Just WOW. Beautiful!
    Thank you for this tutorial!

    ReplyDelete