Skybox tutorial part 1
Recently I've shown my skybox, which has a new shader and code to calculate the positions of the sun and moon. People quite liked the result, and as such I thought it was time to also write a tutorial on this. It will be split in a series of at least 2 parts, where this part focuses on the shader for the sky colours, as well as the sun, moon, and stars. In part 2 we'll do the calculations for the sun and moon position as function of date and geographical location.
This tutorial was made with Unity 2020.3.26f1 and the Universal Render Pipeline (URP), and the shader is written in HLSL. Note that the general concept can be applied to any shader environment.
Acknowledgements
For this part the main acknowledgement would be NASA's Scientific Visualization Studio, for their publicly available texture maps of the moon and stars.Contents
1. Introduction
Before we start doing anything in Unity I'd first like to talk about the general approach of this skybox, as it is not a physically based model. Instead we'll be trying to approach the look of the sky using basic inputs, such as the sun's position.
In this shader we're going to colour the sky by sampling 3 different colour gradients depending on the position and strength of the sun. In addition we'll draw the sun, moon and stars, and also take solar and lunar eclipses into account. In order to this we need 4 main directions as input. The sun direction, the moon direction, the view direction from the camera and the zenith direction, defined as directly above the observer which corresponds to the y-direction in Unity.
In order to give you a better idea I've drawn these four directions below. This is a 2D slice of the world, where the vertical axis corresponds to the y-axis in Unity, and the horizontal axis corresponds to the x-z plane in Unity. The symbols correspond to the following: H = horizon plane, Z = zenith direction, S = sun direction, M = moon direction, and V = view direction. Note that all directions point from the centre outwards, this is a convention we'll be following, where we define our directions to point from the camera/origin towards the object in question, e.g. the sun direction points towards the sun not from the sun.
These direction are in turn used to calculate 4 angles, each between two of these directions.
- sunView: angle between the sun and view direction,
- sunZenith: angle between the sun and the Zenith (the y-axis),
- viewZenith: angle between the view direction and Zenith,
- sunMoon: angle between the sun and the moon.
The sunView angle can be used to change the colour directly around the sun, e.g. when the sun sets there should be an orange glow around the sun. The sunZenith can be used to measure how far the sun is above (or below) the horizon. The viewZenith similarly measures how far we're looking above (or below) the horizon. Finally, the sunMoon angle is useful to determine when a solar (or lunar) eclipse occurs.
2. Basic setup
Start by creating a new scene, it should only have a main camera and a directional light. Rename the directional light to Sun and create a new empty gameObject called Moon. These objects will be used to set the direction of the sun and moon respectively. Also add a plane or some other objects so that you have a reference of where the horizon is.
2.1 Skybox controller
Despite the fact that this tutorial is all about the skybox shader, we do still need a small script to pass the sun and moon direction to the shader. Create a new empty gameObject, call it SkyboxController, and also create a new C# script SkyboxController.cs and add it to the gameObject. The script is very simple and shown below in the code block.
using UnityEngine;
[ExecuteAlways]
public class SkyboxController : MonoBehaviour
{
[SerializeField] Transform _Sun = default;
[SerializeField] Transform _Moon = default;
void LateUpdate()
{
// Directions are defined to point towards the object
// Sun
Shader.SetGlobalVector("_SunDir", -_Sun.transform.forward);
// Moon
Shader.SetGlobalVector("_MoonDir", -_Moon.transform.forward);
}
}
We have two serialized fields that will contain the transform of the sun and moon. In order to get the sun and moon's direction data to the shader we set two global shader variables by calling
The
Don't forget to set the actual transform fields in the inspector.
Note that because we define our directions to point towards the object we need to pass the negative forward directions of our transforms.
2.2 HLSL shader
In this tutorial we'll be writing an HLSL shader consisting of a single pass, with a small vertex stage and large fragment stage. Start by creating a new unlit shader Skybox.shader, and replace Unity's standard code by the code block below.
Shader "KelvinvanHoorn/Skybox"
{
Properties
{
}
SubShader
{
Tags { "Queue"="Background" "RenderType"="Background" "PreviewType"="Skybox" }
Cull Off ZWrite Off
Pass
{
HLSLPROGRAM
#pragma vertex Vertex
#pragma fragment Fragment
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct Attributes
{
float4 posOS : POSITION;
};
struct v2f
{
float4 posCS : SV_POSITION;
float3 viewDirWS : TEXCOORD0;
};
v2f Vertex(Attributes IN)
{
v2f OUT = (v2f)0;
VertexPositionInputs vertexInput = GetVertexPositionInputs(IN.posOS.xyz);
OUT.posCS = vertexInput.positionCS;
OUT.viewDirWS = vertexInput.positionWS;
return OUT;
}
float3 _SunDir, _MoonDir;
float4 Fragment (v2f IN) : SV_TARGET
{
float3 viewDir = normalize(IN.viewDirWS);
float3 col = saturate(float3(step(0.9,dot(_SunDir, viewDir)), step(0.9,dot(_MoonDir, viewDir)), 0));
return float4(col, 1);
}
ENDHLSL
}
}
}
This is the basic shader we'll build upon in the following sections. I'll briefly go over its contents, starting with the tags. Because this will be a skybox shader we set the
The
As far as library files go we'll only include
The
In the
In between the
Finally, in the
In order to see something create a material from this shader, and set it as your skybox material. The skybox material can be set in the environment tab from the Lighting window, if it is not open you can find it in the top-bar under Window Rendering Lighting. The sky should now be black, with a bright red and green spot that correspond to the positions of the sun and moon (it is yellow when they overlap). You can test this by rotating the Sun and Moon gameObjects from the previous section.
3. The sun and sky
In this section we'll go over calculating our 4 main angles, using the main angles to both sample and adjust the colour gradients for the sky, and drawing the sun.
3.1 Main angles
As a quick reminder the 4 main angles are: the sunView, sunZentih, viewZentih and sunMoon angle.
While I've been talking about angles all this time we're actually only going to calculate the dot product between the different directions. Mathematically this corresponds to the cosine of the angle between the directions (if both vectors are normalized). For our use case this is fine, though you should note that the cosine of an angle isn't linear (it flattens near 0° and 180°).
We already have the sun, moon and view direction in our shader and the zenith direction is simply \((0, 1, 0)\), so we can calculate all 4 dot products. Add the following highlighted lines to our shader in the
float3 viewDir = normalize(IN.viewDirWS);
// Main angles
float sunViewDot = dot(_SunDir, viewDir);
float sunZenithDot = _SunDir.y;
float viewZenithDot = viewDir.y;
float sunMoonDot = dot(_SunDir, _MoonDir);
Note that we've simplified the calculation of
The dot products range from -1 to 1, with -1 being parallel but opposed vectors, 0 perpendicular vectors and 1 parallel vectors. While this is a useful range for some calculations, for others it is better to remap it to a 0, 1 range. As such add the following 2 lines just below the previous calculations.
float sunViewDot01 = (sunViewDot + 1.0) * 0.5;
float sunZenithDot01 = (sunZenithDot + 1.0) * 0.5;
These calculations haven't changed the look of the skybox yet, but form the foundation for most subsequent calculations.
3.2 Colours of the sky
The colours of the sky will be determined by sampling 3 gradient textures, adjusting them depending on our main angles, and adding them together.
Add all 3 gradient textures by adding the highlighted lines from the code block below. We add them to the properties, where the
Properties
{
[NoScaleOffset] _SunZenithGrad ("Sun-Zenith gradient", 2D) = "white" {}
[NoScaleOffset] _ViewZenithGrad ("View-Zenith gradient", 2D) = "white" {}
[NoScaleOffset] _SunViewGrad ("Sun-View gradient", 2D) = "white" {}
}
...
v2f Vertex(Attributes IN){}
TEXTURE2D(_SunZenithGrad); SAMPLER(sampler_SunZenithGrad);
TEXTURE2D(_ViewZenithGrad); SAMPLER(sampler_ViewZenithGrad);
TEXTURE2D(_SunViewGrad); SAMPLER(sampler_SunViewGrad);
float3 _SunDir, _MoonDir;
For those curious, the TEXTURE2D and SAMPLER macros form an abstraction layer between the shader and the different rendering APIs, such as D3D11, Metal and Vulkan. You can find these macros through the Project window in the Core RP Library package under Shaderlibrary/API.
3.2.1 Main sky colour
Now let us sample the first gradient and output its colour. The sunZenith gradient is the simplest and represents the general sky colour depending on how high the sun is in the sky. This is represented within our shader by the float
// Sky colours
float3 sunZenithColor = SAMPLE_TEXTURE2D(_SunZenithGrad, sampler_SunZenithGrad, float2(sunZenithDot01, 0.5)).rgb;
float3 skyColor = sunZenithColor;
float3 col = skyColor;
I've used the following small gradient texture as my sunZenith gradient, where if the sun is directly below us it is nearly black and transitions to a brighter blue as the sun rises. You can download it, by right-clicking and save image, and set it as your texture or create one that better suits your needs.
For the gradient texture import settings I've turned off generate mip maps, set the filter mode to clamp, and set compression quality to high.
3.2.2 Horizon haze
The next gradient is the viewZenith gradient, which represents the difference in colour near the horizon (usually a slight haze). First thing to note is that the sample coordinate for the texture is still
Add the following two lines and change
float3 viewZenithColor = SAMPLE_TEXTURE2D(_ViewZenithGrad, sampler_ViewZenithGrad, float2(sunZenithDot01, 0.5)).rgb;
float vzMask = pow(saturate(1.0 - viewZenithDot), 4);
float3 skyColor = sunZenithColor + vzMask * viewZenithColor;
For the gradient texture I've used the following, where you should note that near the middle the texture becomes orange before transitioning towards a pale blue. This represents the colour of the horizon during a sunset or sunrise. You can once again download it and use it as your gradient.
Note: you might see some extensive banding in the colours of the sky. I won't go into the details of this, but you can greatly reduce it by selecting your camera gameObject and turning on Post Processing and Dithering.
3.2.3 Sun bloom
The final of the gradient textures is the sunView gradient, which represents the brighter colour you see surrounding the sun. Similarly to the previous texture we use
Add the following lines and once again update
float3 sunViewColor = SAMPLE_TEXTURE2D(_SunViewGrad, sampler_SunViewGrad, float2(sunZenithDot01, 0.5)).rgb;
float svMask = pow(saturate(sunViewDot), 4);
float3 skyColor = sunZenithColor + vzMask * viewZenithColor + svMask * sunViewColor;
For the gradient texture I've used the following, where most of the action occurs at the centre during sunrise/sunset. You can once again either download this texture or create one that suits your needs.
3.3 The sun
While we can see the bloom around where the sun should be, we currently don't actually see the sun. Let's fix that by making a mask for where the sun should be drawn. Let's encapsulate the sun mask in its own function called
float GetSunMask(float sunViewDot, float sunRadius)
{
float stepRadius = 1 - sunRadius * sunRadius;
return step(stepRadius, sunViewDot);
}
We'll control the sun's radius with a float property set between 0 and 1, and we'll set the colour to the main light's colour (
Properties
{
_SunRadius ("Sun radius", Range(0,1)) = 0.05
}
float3 _SunDir, _MoonDir;
float _SunRadius;
float4 Fragment (v2f IN) : SV_TARGET
{
// The sun
float sunMask = GetSunMask(sunViewDot, _SunRadius);
float3 sunColor = _MainLightColor.rgb * sunMask;
float3 col = skyColor + sunColor;
return float4(col, 1);
}
In order to give the sun a more blurred and sun-like edge we're going to add some post-processing to the scene, a bloom effect. Add a new gameObject to the scene and call it something like PostProcessing. Add a Volume component and make a new profile by clicking New. Now add the Bloom override. Enable the threshold and intensity and set them to 0.9 and 0.5 respectively. Make sure that your camera has post-processing enabled and you should now see the effect of bloom around the sun.
You can now control the colour and intensity of the sun through the light options on the Sun gameObject. If you don't want it linked to that you can instead create some properties for the shader to control the colour and intensity. I've kept the default values, but you can of course tweak them to your hearts desire.
4. The Moon
The sun seems a bit lonely in the sky, so lets add the moon as well. The method for the moon will be quite different, as we need more information for texture sampling and lighting. In order to do this we'll do some basic ray-tracing of a sphere in the sky and use that for our moon.
4.1 Ray-tracing mask
The first step is just getting the ray-traced sphere information and creating a mask from it. I'll not go into the specifics of ray-tracing and instead use this function from Inigo Quilez (with small modifications). You can also check my other tutorial on line intersections to learn more about ray-tracing. Add the following lines before the
// From Inigo Quilez, https://www.iquilezles.org/www/articles/intersectors/intersectors.htm
float sphIntersect(float3 rayDir, float3 spherePos, float radius)
{
float3 oc = -spherePos;
float b = dot(oc, rayDir);
float c = dot(oc, oc) - radius * radius;
float h = b * b - c;
if(h < 0.0) return -1.0;
h = sqrt(h);
return -b - h;
}
The function has 3 arguments, the ray direction (the view direction), the sphere's position (the moon direction) and the radius (the moon radius). In return it outputs the distance from the ray origin (our camera) to the intersection with the sphere, and if there is no intersection it returns -1. We can then create a mask by checking if the intersection is greater than -1 or not.
Now for the implementation we need a new property, the moon's radius. Add the following property and declare it by adding/changing the highlighted lines.
Properties
{
_SunRadius ("Sun radius", Range(0,1)) = 0.05
_MoonRadius ("Moon radius", Range(0,1)) = 0.05
}
float3 _SunDir, _MoonDir;
float _SunRadius, _MoonRadius;
To show the moon on the screen we need to call
// The moon
float moonIntersect = sphIntersect(viewDir, _MoonDir, _MoonRadius);
float moonMask = moonIntersect > -1 ? 1 : 0;
float3 moonColor = moonMask;
float3 col = skyColor + sunColor + moonColor;
Note that in the picture above the moon and sun have a similar size, while the moon radius is 0.07 and the sun radius is 0.05. Unfortunately their radius variables do not scale the same.
4.2 Moon lighting
For the lighting we're going to do some simple NdotL lighting, except that we first need to calculate a normal. Luckily, that isn't that difficult as we have a ray-traced sphere as moon. We can take the difference between the moon direction and the intersection point, and normalize it. This procedure is illustrated below, where the moon's size is greatly exaggerated.
Let's add this normal calculation in the
// The moon
float moonIntersect = sphIntersect(viewDir, _MoonDir, _MoonRadius);
float moonMask = moonIntersect > -1 ? 1 : 0;
float3 moonNormal = normalize(_MoonDir - viewDir * moonIntersect);
We now have the normal, but for our NdotL lighting we also need the light direction. The light direction would be the direction of the sun's rays away from the moon. It is away from the sun because we want our surface to be lit if the dot product between the normal and light direction is 1.
In mathematical terms it can be expressed as \( d_l = norm(d_m r_m - d_s r_s) \). Here \(d_l\) is the light direction, \(d_m\) the moon direction, \(r_m\) the moon's distance to us, \(d_s\) the sun direction, \(r_s\) the sun's distance to us, and \(norm()\) a normalization function. This might look complicated, however, we can use the fact that the sun is much further away from us than the moon (about 400 times further). Using this fact we can roughly approximate the light direction by saying the sun's distance is so big that the moon's distance is neglectable, i.e. \(d_l = norm(d_m r_m - d_s r_s) ≈ norm(-d_s r_s) = -d_s\).
We can now calculate our NdotL using the
float3 moonNormal = normalize(_MoonDir - viewDir * moonIntersect);
float moonNdotL = saturate(dot(moonNormal, -_SunDir));
float3 moonColor = moonMask * moonNdotL;
Let's also add an exposure property, so that we can control how bright the moon is. Add the following highlighted lines, and add the exposure to
Properties
{
_MoonExposure ("Moon exposure", Range(-16, 16)) = 0
}
float _SunRadius, _MoonRadius;
float _MoonExposure;
float4 Fragment (v2f IN) : SV_TARGET
{
float3 moonColor = moonMask * moonNdotL * exp2(_MoonExposure);
}
4.3 Moon texture
For a stylised look a textureless moon might suffice, but more often than not you do want some texture for it. You could create your own, but we're going to use the publicly available color map from NASA's Scientific Visualization Studio. You can download the picture below or download it directly, and in higher resolutions, from NASA's site here.
We'll be sampling this texture as a cube map, so be sure to set the Texture Shape to Cube in the import settings. A benefit of sampling it as a cube map is that we can reuse the normals we've calculated as a 3 component UV for the cube map texture, which we'll refer to as UVW.
Adding the property and declaration of a cube map is very similar to that of textures, only a slight change in macros. Add the following highlighted lines.
Properties
{
[NoScaleOffset] _MoonCubeMap ("Moon cube map", Cube) = "black" {}
}
TEXTURE2D(_SunViewGrad); SAMPLER(sampler_SunViewGrad);
TEXTURECUBE(_MoonCubeMap); SAMPLER(sampler_MoonCubeMap);
Let's make a new function to read our color map, with as input the moon's normal. Add the following
float3 GetMoonTexture(float3 normal)
{
float3 uvw = normal;
return SAMPLE_TEXTURECUBE(_MoonCubeMap, sampler_MoonCubeMap, uvw).rgb;
}
Now to see the texture add or change the following lines in the Fragment function, and don't forget to set our texture on the material in the inspector.
float3 moonTexture = GetMoonTexture(moonNormal);
float3 moonColor = moonMask * moonNdotL * exp2(_MoonExposure) * moonTexture;
Okay, we have the moon texture, so this section is done right? Well... if you change the moon's direction you'll notice that the texture changes, which is not how the real moon behaves (we only see 1 side). The texture changes because our UVW is the normal in world-space, meaning that it changes with the moon's rotation in the sky. What we want as UVW is the normal in the moon's local space, moon-space. In order to get that we need a space transformation matrix. We'll construct it in our C# script, SkyboxController.cs, from section 2.1. The transformation matrix consists of the 3 basis vectors that make up the local space, which are the forward, right and up vectors. Add the highlighted line to the SkyboxController.cs script, where we set a global shader matrix called
void LateUpdate()
{
// Directions are defined to point towards the object
// Sun
Shader.SetGlobalVector("_SunDir", -_Sun.transform.forward);
// Moon
Shader.SetGlobalVector("_MoonDir", -_Moon.transform.forward);
Shader.SetGlobalMatrix("_MoonSpaceMatrix", new Matrix4x4(-_Moon.transform.forward, -_Moon.transform.up, -_Moon.transform.right, Vector4.zero).transpose);
}
Note that our transformation matrix only has 3 vectors, not 4. We can get away with this because we only need to rotate the moon normal, not translate it.
Now we can access this matrix by declaring it in our shader. We then need to multiply this matrix with the moon's normal in world-space to get the normal in moon-space. Add the following highlighted lines to implement this.
float _MoonExposure;
float4x4 _MoonSpaceMatrix;
float3 GetMoonTexture(float3 normal)
{
float3 uvw = mul(_MoonSpaceMatrix, float4(normal,0)).xyz;
return SAMPLE_TEXTURECUBE(_MoonCubeMap, sampler_MoonCubeMap, uvw).rgb;
}
Now the moon's texture should appear stationary when you change the moon's direction. There is one final alteration I'd like to make, because we're not seeing the correct part of the moon texture. In order to correct this I've constructed an additional 3x3 rotation matrix, the values of which I got through some trial and error. All it does is rotate the normal such that the correct part is shown to us, you can think of it as a texture offset. Add the highlighted lines to get our final moon texture.
float3 GetMoonTexture(float3 normal)
{
float3 uvw = mul(_MoonSpaceMatrix, float4(normal,0)).xyz;
float3x3 correctionMatrix = float3x3(0, -0.2588190451, -0.9659258263,
0.08715574275, 0.9622501869, -0.2578341605,
0.9961946981, -0.08418598283, 0.02255756611);
uvw = mul(correctionMatrix, uvw);
return SAMPLE_TEXTURECUBE(_MoonCubeMap, sampler_MoonCubeMap, uvw).rgb;
}
5. The stars
For the stars we're also going to sample a cube map, however this time we're using the view direction as UVW coordinates and we need more rotation matrices. One as a tilt, because the stars position depends on your latitude, and a spinning rotation, which rotates the stars around the tilted axis.
The texture we'll be using again comes from NASA's Scientific Visualization Studio, and can be found here. It is the star map at the top of the page, where I'm using the 8192x4096 x-exr version. Don't forget to set the Texture Shape to Cube in the import settings in Unity, and also set the Max Size appropriately (8192 in this case).
Let's start by simply displaying the star map, by using the view direction directly as UVW. Add the cube map to the properties, declare it, and sample it in the
Properties{
[NoScaleOffset] _StarCubeMap ("Star cube map", Cube) = "black" {}
}
TEXTURECUBE(_MoonCubeMap); SAMPLER(sampler_MoonCubeMap);
TEXTURECUBE(_StarCubeMap); SAMPLER(sampler_StarCubeMap);
float4 Fragment (v2f IN) : SV_TARGET
{
// The stars
float3 starUVW = viewDir;
float3 starColor = SAMPLE_TEXTURECUBE(_StarCubeMap, sampler_StarCubeMap, starUVW).rgb;
float3 col = skyColor + sunColor + moonColor + starColor;
return float4(col, 1);
}
That was easy, but looking at the result you might see a few problems, one of them being that the stars are drawn on top of the moon (and also sun). To fix this we multiply the starColor by 1 minus the moon and sun masks.
float3 starColor = SAMPLE_TEXTURECUBE(_StarCubeMap, sampler_StarCubeMap, starUVW).rgb;
starColor *= (1 - sunMask) * (1 - moonMask);
Another thing is that the stars look rather blurry, even though we're using an 8K texture. In order to make the stars sharper we'll be biasing the mip-level. This would make the texture values flicker more with camera movement, which normally would be bad but actually is appropriate for stars. Change the cube map sampling macro to
float3 starColor = SAMPLE_TEXTURECUBE_BIAS(_StarCubeMap, sampler_StarCubeMap, starUVW, -1).rgb;
Alternatively you could just disable mip-mapping altogether for the texture, however, in some cases you might need it. One example would be when using a second camera that renders at a lower resolution, without mip-mapping it would flicker too much and become distracting.
Let's also add an exposure and power value for the star map, so that we have more control over the absolute and relative brightness of the stars. Add 2 new properties, declare them, take
Properties
{
_StarExposure ("Star exposure", Range(-16, 16)) = 0
_StarPower ("Star power", Range(1,5)) = 1
}
float _MoonExposure, _StarExposure;
float _StarPower;
float4 Fragment (v2f IN) : SV_TARGET
{
float3 starColor = SAMPLE_TEXTURECUBE_BIAS(_StarCubeMap, sampler_StarCubeMap, starUVW, -1).rgb;
starColor = pow(abs(starColor), _StarPower);
starColor *= (1 - sunMask) * (1 - moonMask) * exp2(_StarExposure);
}
We're almost there but there is still one problem, the stars are still bright and visible during the day. In order to fix this we'll add another factor to
float starStrength = (1 - sunViewDot01) * (saturate(-sunZenithDot));
starColor *= (1 - sunMask) * (1 - moonMask) * exp2(_StarExposure) * starStrength;
5.1 Star rotations
As you might know the night sky differs depending on where you are in the world and what time it is. To implement this we'll first tilt the star map depending on a latitude that we provide and then rotate the map around that tilted axis depending on time.
In order to do these rotations we need a new function that can rotate a direction, given a rotation axis and rotation amount. I won't go over the mathetmatical details of this function, instead we'll be using this
// Construct a rotation matrix that rotates around a particular axis by angle
// From: https://gist.github.com/keijiro/ee439d5e7388f3aafc5296005c8c3f33
float3x3 AngleAxis3x3(float angle, float3 axis)
{
float c, s;
sincos(angle, s, c);
float t = 1 - c;
float x = axis.x;
float y = axis.y;
float z = axis.z;
return float3x3(
t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c
);
}
Let's encapsulate the rotations we're going to do in a new function called
// Rotate the view direction, tilt with latitude, spin with time
float3 GetStarUVW(float3 viewDir, float latitude, float localSiderealTime)
{
// tilt = 0 at the north pole, where latitude = 90 degrees
float tilt = PI * (latitude - 90) / 180;
float3x3 tiltRotation = AngleAxis3x3(tilt, float3(1,0,0));
// 0.75 is a texture offset for lST = 0 equals noon
float spin = (0.75-localSiderealTime) * 2 * PI;
float3x3 spinRotation = AngleAxis3x3(spin, float3(0, 1, 0));
// The order of rotation is important
float3x3 fullRotation = mul(spinRotation, tiltRotation);
return mul(fullRotation, viewDir);
}
You might be wondering, what is local sidereal time? Sidereal time is the time of day as measured from distant stars instead of the sun, i.e. 1 sidereal day is 1 full rotation of the stars. This value will be between 0 and 1, where 0 refers to the time when the sun is the highest in the sky (noon). The local part refers to that were using the local time zone, instead of greenwich time. You can read more about sidereal time on the wiki page.
Another interesting thing to note is that we subtract time, this is because the time represents the rotation of the earth, but in our scene the earth is stationary and thus the stars should rotate in the opposite direction.
Now we just need to set
Properties
{
_StarLatitude ("Star latitude", Range(-90, 90)) = 0
_StarSpeed ("Star speed", Float) = 0.001
}
float _StarPower;
float _StarLatitude, _StarSpeed;
float4 Fragment (v2f IN) : SV_TARGET
{
// The stars
float3 starUVW = GetStarUVW(viewDir, _StarLatitude, _Time.y * _StarSpeed % 1);
}
5.2 Constellations
The star map is technically finished, however, when looking for a star map I found that NASA also has a constellation map and it might be fun to add it to our sky. It is also really easy to add with what we already have. Download the constellation map from the picture below or from NASA directly, it is the "Constellation figures in celestial coordinates." at 4096x2048 resolution. Don't forget to set the import setting's Texture Shape to Cube.
Now add the map as property, as well as a colour for the constellation lines. We can reuse the
Properties
{
[NoScaleOffset] _ConstellationCubeMap ("Constellation cube map", Cube) = "blank" {}
_ConstellationColor ("Constellation color", Color) = (0,0.3,0.6,1)
}
TEXTURECUBE(_StarCubeMap); SAMPLER(sampler_StarCubeMap);
TEXTURECUBE(_ConstellationCubeMap); SAMPLER(sampler_ConstellationCubeMap);
float _StarLatitude, _StarSpeed;
float3 _ConstellationColor;
float4 Fragment (v2f IN) : SV_TARGET
{
// The constellations
float3 constColor = SAMPLE_TEXTURECUBE(_ConstellationCubeMap, sampler_ConstellationCubeMap, starUVW).rgb * _ConstellationColor;
constColor *= (1 - sunMask) * (1 - moonMask) * starStrength;
float3 col = skyColor + sunColor + moonColor + starColor + constColor;
}
6. Eclipses
We're getting close to the end of this tutorial as there are only 2 phenomena left that we'll implement, the solar and lunar eclipse. A solar eclipse occurs when the moon is in front of the sun, and a lunar eclipse occurs when the earth casts it shadow onto the moon, i.e. the the sun is at the opposite direction of the moon. Of these 2 the solar eclipse is easier to implement so let's start with that.
6.1 Solar eclipse
Currently the sun and moon are both drawn, even if they're on top of each other. To fix this we simply use the inverse (one minus) of the moon's mask to mask out the sun if the moon is in front. Add the following lines to the
// Solar eclipse
sunColor *= (1 - moonMask);
I've never seen a total solar eclipse in person and finding reliable images of the sky during one is quite hard, but it seems that the sky becomes darker and the area around the sun seems brighter as a result. In order to implement this we'll use
// Solar eclipse
float solarEclipse01 = smoothstep(1 - _SunRadius * _SunRadius, 1.0, sunMoonDot);
skyColor *= lerp(1, 0.4, solarEclipse01);
sunColor *= (1 - moonMask) * lerp(1, 16, solarEclipse01);
Notice that we use a similar calculation to the sun's mask, except we use a
This is only one way of changing the sky during a solar eclipse, you could of course expand on this using more colours or colour gradients and sample those as function of
6.2 Lunar eclipse
The lunar eclipse will work similar to the solar eclipse, we'll mask out the moon and calculate a
Unfortunately we can't reuse
// Lunar eclipse
float lunarEclipseMask = 1 - step(1 - _SunRadius * _SunRadius, -sunViewDot);
moonColor *= lunarEclipseMask;
The mask needs to be one minus the step because we want the moon to be visible unless it is in the mask. Technically speaking we should be defining an earth radius and use that for the mask, because the mask should represent the earth's shadow. However, for our purposes it works fine to use the sun's radius instead.
If you've seen a lunar eclipses you'll know that it turns slightly red once it gets fully eclipsed. Let's implement that as well. In order to do this we need to know when the moon is nearly fully eclipsed. We do this by using a
// Lunar eclipse
float lunarEclipseMask = 1 - step(1 - _SunRadius * _SunRadius, -sunViewDot);
float lunarEclipse01 = smoothstep(1 - _SunRadius * _SunRadius * 0.05, 1.0, -sunMoonDot);
moonColor *= lerp(lunarEclipseMask, float3(0.3,0.05,0), lunarEclipse01);
You can of course change the red moon color or even add a property for it to control from the inspector.
7. Conclusion
This the end of the tutorial, we've made a skybox shader where the colour of the sky is determined by the height of the sun and several colour gradients. The moon is shown, lit depending on the sun's position and has a texture. The stars are drawn, rotate in the sky, and represent the sky at a chosen latitude. A constellation map is drawn over the stars, so that you can find out where Orion is. And finally we've made some simple calculations to simulate a solar and lunar eclipse.
There is of course a lot more customisation that you could add, such as more colour options and other parameters. However, that is mostly up to your personal preferences or outside demands. I hope you've at least learned some new things and had fun making this, I sure did. In the next part of this tutorial series we'll make a C# script to control the position of the sun, moon and stars, such that they correspond to their real-world position in the sky given your geographical position and the date.
If you want to buy me a coffee (or tea because I don't drink that much coffee) you can do so using my ko-fi page. You can also check out my twitter for more shader work and other things that might not make it to the site.