RayLeigh + Mie + Ozone based atmospheric scatter

This is a port of this unity sky shader for godot 4+:

I thought that the default PhysicalSkyMaterial sky in godot looks worse than this, so I ported it.

It’s intended to be used with the DirectionalLight3d acting as a sun. It draws the atmosphere itself and the sun disc, that is beautifully faded into blackness at sunset.

Also I added an optional basic debanding filter that is just a noise sprinkled over the whole shader.

This is a very rough and unperformant port, that needs to be refactored more thoroughly, but it works and I’m pretty happy with how it looks 🙂

Also an important detail. The perceived color of the sun changes when it is setting (obviously) and to account for that you need to provide a script that would recolor the corresponding DirectionalLight3d.

I am doing it like so:

I have this script attached to the DirectionalLight3d that is corresponding to the sun in the sky that changes it’s Kelvin temperature depending on the elevation from the horizon:

extends DirectionalLight3D

func kelvin_to_rgb(temp_kelvin: float) -> Color:
	var temperature = temp_kelvin / 100.0

	var red: float
	var green: float
	var blue: float

	# Calculate red
	if temperature <= 66.0:
		red = 255.0
	else:
		red = temperature - 60.0
		red = 329.698727446 * pow(red, -0.1332047592)
		red = clamp(red, 0.0, 255.0)

	# Calculate green
	if temperature <= 66.0:
		green = 99.4708025861 * log(temperature) - 161.1195681661
		green = clamp(green, 0.0, 255.0)
	else:
		green = temperature - 60.0
		green = 288.1221695283 * pow(green, -0.0755148492)
		green = clamp(green, 0.0, 255.0)

	# Calculate blue
	if temperature >= 66.0:
		blue = 255.0
	elif temperature <= 19.0:
		blue = 0.0
	else:
		blue = temperature - 10.0
		blue = 138.5177312231 * log(blue) - 305.0447927307
		blue = clamp(blue, 0.0, 255.0)

	# Convert to normalized RGB (0.0 to 1.0)
	return Color(red / 255.0, green / 255.0, blue / 255.0)

func sun_dir_ray() -> Vector3:
	return global_basis.z.normalized()

func _process(delta: float) -> void:
	# Transform the light
	#rotate_y(delta * 0.05)
	#rotate_x(delta * 0.05)
	
	# Update color
	var weight: float = sun_dir_ray().dot(Vector3.UP)
	var energy = smoothstep(-0.1, -0.05, weight)
	weight = pow(clamp(weight, 0.0, 1.0), 0.5)
	var sun_color = kelvin_to_rgb(lerpf(2000, 6500, weight))
	light_color = sun_color
	light_energy = energy

It’s not the best way to do this. The best way would be to trace a scatter like in the shader to get more realistic color, but this solution is efficient and works well enough)

As for the tonemapping. My experiments show that this sky works best with ACES (at least all the screenshots are made with it).

Shader code
shader_type sky;

const float EPS = 1e-6;
const float INFINITY = 1.0 / 0.0;
const float PLANET_RADIUS = 6371000.0;
const vec3 PLANET_CENTER = vec3(0.0, -PLANET_RADIUS, 0.0);
const float ATMOSPHERE_HEIGHT = 100000.0;
const float RAYLEIGH_HEIGHT = (ATMOSPHERE_HEIGHT * 0.08);
const float MIE_HEIGHT = (ATMOSPHERE_HEIGHT * 0.012);

uniform float rayleigh_strength : hint_range(0.0, 5.0) = 1.0;
uniform float mie_strength      : hint_range(0.0, 5.0) = 1.0;
uniform float ozone_strength    : hint_range(0.0, 5.0) = 1.0;

const vec3 C_RAYLEIGH = vec3(5.802, 13.558, 33.100) * 1e-6;
const vec3 C_MIE = vec3(3.996,  3.996,  3.996) * 1e-6;
const vec3 C_OZONE = vec3(0.650,  1.881,  0.085) * 1e-6;

uniform float atmosphere_density: hint_range(0.0, 5.0) = 1.0;
uniform float exposure = 10.0;

uniform float sun_disc_feather: hint_range(0.0, 0.5) = 0.1;
uniform float sundisc_intensity = 10.0;

uniform bool use_debanding = true;

#define saturate(a) clamp(a, 0.0, 1.0)

vec2 sphere_intersection (vec3 rayStart, vec3 rayDir, vec3 sphereCenter, float sphereRadius)
{
	rayStart -= sphereCenter;
	float a = dot(rayDir, rayDir);
	float b = 2.0 * dot(rayStart, rayDir);
	float c = dot(rayStart, rayStart) - (sphereRadius * sphereRadius);
	float d = b * b - 4.0 * a * c;
	if (d < 0.0)
	{
		return vec2(-1.0);
	}
	else
	{
		d = sqrt(d);
		return vec2(-b - d, -b + d) / (2.0 * a);
	}
}

vec2 planet_intersection (vec3 rayStart, vec3 rayDir)
{
	return sphere_intersection(rayStart, rayDir, PLANET_CENTER, PLANET_RADIUS);
}

vec2 atmosphere_intersection (vec3 rayStart, vec3 rayDir)
{
	return sphere_intersection(rayStart, rayDir, PLANET_CENTER, PLANET_RADIUS + ATMOSPHERE_HEIGHT);
}

float phase_rayleigh (float costh)
{
	return 3.0 * (1.0 + costh*costh) / (16.0 * PI);
}

float phase_mie (float costh, float g)
{
	g = min(g, 0.9381);
	float k = 1.55*g - 0.55*g*g*g;
	float kcosth = k*costh;
	return (1.0 - k*k) / ((4.0 * PI) * (1.0-kcosth) * (1.0-kcosth));
}

float atmosphere_height (vec3 position_world_space) {
	return distance(position_world_space, PLANET_CENTER) - PLANET_RADIUS;
}
float density_rayleigh (float h) {
	return exp(-max(0.0, h / RAYLEIGH_HEIGHT));
}
float density_mie (float h) {
	return exp(-max(0.0, h / MIE_HEIGHT));
}
float density_ozone (float h) {
	return max(0.0, 1.0 - abs(h - 25000.0) / 15000.0);
}
vec3 density_atmosphere (float h) {
	return vec3(density_rayleigh(h), density_mie(h), density_ozone(h));
}

vec3 integrate_optical_depth (vec3 rayStart, vec3 rayDir)
{
	vec2 intersection = atmosphere_intersection(rayStart, rayDir);
	float  rayLength    = intersection.y;

	int    sampleCount  = 8;
	float  stepSize     = rayLength / float(sampleCount);
	
	vec3 opticalDepth = vec3(0.0);

	for (int i = 0; i < sampleCount; i++)
	{
		vec3 localPosition = rayStart + rayDir * (float(i) + 0.5) * stepSize;
		float localHeight  = atmosphere_height(localPosition);
		vec3 localDensity  = density_atmosphere(localHeight);

		opticalDepth += localDensity * stepSize;
	}

	return opticalDepth;
}

vec3 absorb (vec3 opticalDepth) {
	return exp(-(
		opticalDepth.x * C_RAYLEIGH * rayleigh_strength + 
		opticalDepth.y * C_MIE * mie_strength * 1.1 + 
		opticalDepth.z * C_OZONE * ozone_strength
	) * atmosphere_density);
}

vec3 integrate_scattering (vec3 rayStart, vec3 rayDir, float rayLength, vec3 lightDir, vec3 lightColor, out vec3 transmittance)
{
	float  rayHeight = atmosphere_height(rayStart);
	float  sampleDistributionExponent = 1.0 + saturate(1.0 - rayHeight / ATMOSPHERE_HEIGHT) * 8.0; // Slightly arbitrary max exponent of 9

	vec2 intersection = atmosphere_intersection(rayStart, rayDir);
	rayLength = min(rayLength, intersection.y);
	if (intersection.x > 0.0)
	{
		rayStart += rayDir * intersection.x;
		rayLength -= intersection.x;
	}

	float  costh    = dot(rayDir, lightDir);
	float  phaseR   = phase_rayleigh(costh);
	float  phaseM   = phase_mie(costh, 0.85);

	int    sampleCount  = 64;

	vec3 opticalDepth = vec3(0.0);
	vec3 rayleigh     = vec3(0.0);
	vec3 mie          = vec3(0.0);

	float  prevRayTime  = 0.0;

	for (int i = 0; i < sampleCount; i++)
	{
		float  rayTime = pow(float(i) / float(sampleCount), sampleDistributionExponent) * rayLength;
		float  stepSize = (rayTime - prevRayTime);

		vec3 localPosition = rayStart + rayDir * rayTime;
		float  localHeight   = atmosphere_height(localPosition);
		vec3 localDensity  = density_atmosphere(localHeight);

		opticalDepth += localDensity * stepSize;

		vec3 viewTransmittance = absorb(opticalDepth);

		vec3 opticalDepthlight  = integrate_optical_depth(localPosition, lightDir);
		vec3 lightTransmittance = absorb(opticalDepthlight);

		rayleigh += viewTransmittance * lightTransmittance * phaseR * localDensity.x * stepSize;
		mie      += viewTransmittance * lightTransmittance * phaseM * localDensity.y * stepSize;

		prevRayTime = rayTime;
	}

	transmittance = absorb(opticalDepth);

	return (rayleigh * C_RAYLEIGH * rayleigh_strength + mie * C_MIE * mie_strength) * lightColor * exposure;
}

float sun_disc(vec3 eyedir, vec3 sundir, float theta_r) {
	float cos_angle = dot(eyedir, sundir);
	float cos_inner = cos(theta_r * (1.0 - sun_disc_feather));
	float cos_outer = cos(theta_r * (1.0 + sun_disc_feather));
	return smoothstep(cos_outer, cos_inner, cos_angle);
}

float rand(vec2 co){
    return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
}

void sky() {
	vec3 transmittance;
	vec3 radiance = integrate_scattering(vec3(0.0), EYEDIR, INFINITY, LIGHT0_DIRECTION, vec3(1.0, 0.996, 0.98), transmittance);
	
	float sun_mask = sun_disc(EYEDIR, LIGHT0_DIRECTION, LIGHT0_SIZE * 0.5);
	vec3 sundisc = vec3(sun_mask) * transmittance * sundisc_intensity;
	
	COLOR = radiance + sundisc;
	
	if (use_debanding) {
		COLOR += (rand(SKY_COORDS) - 0.5) * 0.001;
	}
}
Tags
atmosphere, atmospheric scattering, mie, rayleigh, sky, Sun
The shader code and all code snippets in this post are under CC0 license and can be used freely without the author's permission. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

More from Ansol

guest

6 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
KingGD
KingGD
6 months ago

You’re great! Thanks soooo much!

jaccurate
jaccurate
5 months ago

I absolutely love this shader. However, you were not kidding when you said it is not performant. I was able to get significant(about 200%) FPS increase when adding a global sample count parameter and lowering that. It does alter the look of the sky, but it can be adjusted as needed.

code

shader_type sky;

const float EPS = 1e-6;
const float INFINITY = 1.0 / 0.0;
const float PLANET_RADIUS = 6371000.0;
const vec3 PLANET_CENTER = vec3(0.0, -PLANET_RADIUS, 0.0);
const float ATMOSPHERE_HEIGHT = 100000.0;
const float RAYLEIGH_HEIGHT = (ATMOSPHERE_HEIGHT * 0.08);
const float MIE_HEIGHT = (ATMOSPHERE_HEIGHT * 0.012);

uniform float rayleigh_strength : hint_range(0.0, 5.0) = 1.0;
uniform float mie_strength      : hint_range(0.0, 5.0) = 1.0;
uniform float ozone_strength    : hint_range(0.0, 5.0) = 1.0;

const vec3 C_RAYLEIGH = vec3(5.802, 13.558, 33.100) * 1e-6;
const vec3 C_MIE = vec3(3.996,  3.996,  3.996) * 1e-6;
const vec3 C_OZONE = vec3(0.650,  1.881,  0.085) * 1e-6;

uniform float atmosphere_density: hint_range(0.0, 5.0) = 1.0;
uniform float exposure = 10.0;

uniform float sun_disc_feather: hint_range(0.0, 0.5) = 0.1;
uniform float sundisc_intensity = 10.0;

uniform bool use_debanding = true;
uniform int sample_count = 8;

#define saturate(a) clamp(a, 0.0, 1.0)

vec2 sphere_intersection (vec3 rayStart, vec3 rayDir, vec3 sphereCenter, float sphereRadius)
{
    rayStart -= sphereCenter;
    float a = dot(rayDir, rayDir);
    float b = 2.0 * dot(rayStart, rayDir);
    float c = dot(rayStart, rayStart) – (sphereRadius * sphereRadius);
    float d = b * b – 4.0 * a * c;
    if (d < 0.0)
    {
        return vec2(-1.0);
    }
    else
    {
        d = sqrt(d);
        return vec2(-b – d, -b + d) / (2.0 * a);
    }
}

vec2 planet_intersection (vec3 rayStart, vec3 rayDir)
{
    return sphere_intersection(rayStart, rayDir, PLANET_CENTER, PLANET_RADIUS);
}

vec2 atmosphere_intersection (vec3 rayStart, vec3 rayDir)
{
    return sphere_intersection(rayStart, rayDir, PLANET_CENTER, PLANET_RADIUS + ATMOSPHERE_HEIGHT);
}

float phase_rayleigh (float costh)
{
    return 3.0 * (1.0 + costh*costh) / (16.0 * PI);
}

float phase_mie (float costh, float g)
{
    g = min(g, 0.9381);
    float k = 1.55*g – 0.55*g*g*g;
    float kcosth = k*costh;
    return (1.0 – k*k) / ((4.0 * PI) * (1.0-kcosth) * (1.0-kcosth));
}

float atmosphere_height (vec3 position_world_space) {
    return distance(position_world_space, PLANET_CENTER) – PLANET_RADIUS;
}
float density_rayleigh (float h) {
    return exp(-max(0.0, h / RAYLEIGH_HEIGHT));
}
float density_mie (float h) {
    return exp(-max(0.0, h / MIE_HEIGHT));
}
float density_ozone (float h) {
    return max(0.0, 1.0 – abs(h – 25000.0) / 15000.0);
}
vec3 density_atmosphere (float h) {
    return vec3(density_rayleigh(h), density_mie(h), density_ozone(h));
}

vec3 integrate_optical_depth (vec3 rayStart, vec3 rayDir)
{
    vec2 intersection = atmosphere_intersection(rayStart, rayDir);
    float  rayLength    = intersection.y;

    
    float  stepSize     = rayLength / float(sample_count);
    
    vec3 opticalDepth = vec3(0.0);

    for (int i = 0; i < sample_count; i++)
    {
        vec3 localPosition = rayStart + rayDir * (float(i) + 0.5) * stepSize;
        float localHeight  = atmosphere_height(localPosition);
        vec3 localDensity  = density_atmosphere(localHeight);

        opticalDepth += localDensity * stepSize;
    }

    return opticalDepth;
}

vec3 absorb (vec3 opticalDepth) {
    return exp(-(
        opticalDepth.x * C_RAYLEIGH * rayleigh_strength + 
        opticalDepth.y * C_MIE * mie_strength * 1.1 + 
        opticalDepth.z * C_OZONE * ozone_strength
    ) * atmosphere_density);
}

vec3 integrate_scattering (vec3 rayStart, vec3 rayDir, float rayLength, vec3 lightDir, vec3 lightColor, out vec3 transmittance)
{
    float  rayHeight = atmosphere_height(rayStart);
    float  sampleDistributionExponent = 1.0 + saturate(1.0 – rayHeight / ATMOSPHERE_HEIGHT) * 8.0; // Slightly arbitrary max exponent of 9

    vec2 intersection = atmosphere_intersection(rayStart, rayDir);
    rayLength = min(rayLength, intersection.y);
    if (intersection.x > 0.0)
    {
        rayStart += rayDir * intersection.x;
        rayLength -= intersection.x;
    }

    float  costh    = dot(rayDir, lightDir);
    float  phaseR   = phase_rayleigh(costh);
    float  phaseM   = phase_mie(costh, 0.85);

    int reducedSampleCount  = sample_count / 2;

    vec3 opticalDepth = vec3(0.0);
    vec3 rayleigh     = vec3(0.0);
    vec3 mie          = vec3(0.0);

    float  prevRayTime  = 0.0;

    
    for (int i = 0; i < reducedSampleCount; i++)
    {
        float  rayTime = pow(float(i) / float(reducedSampleCount), sampleDistributionExponent) * rayLength;
        float  stepSize = (rayTime – prevRayTime);

        vec3 localPosition = rayStart + rayDir * rayTime;
        float  localHeight   = atmosphere_height(localPosition);
        vec3 localDensity  = density_atmosphere(localHeight);

        opticalDepth += localDensity * stepSize;

        vec3 viewTransmittance = absorb(opticalDepth);

        vec3 opticalDepthlight  = integrate_optical_depth(localPosition, lightDir);
        vec3 lightTransmittance = absorb(opticalDepthlight);

        rayleigh += viewTransmittance * lightTransmittance * phaseR * localDensity.x * stepSize;
        mie      += viewTransmittance * lightTransmittance * phaseM * localDensity.y * stepSize;

        prevRayTime = rayTime;
    }

    transmittance = absorb(opticalDepth);

    return (rayleigh * C_RAYLEIGH * rayleigh_strength + mie * C_MIE * mie_strength) * lightColor * exposure;
}

float sun_disc(vec3 eyedir, vec3 sundir, float theta_r) {
    float cos_angle = dot(eyedir, sundir);
    float cos_inner = cos(theta_r * (1.0 – sun_disc_feather));
    float cos_outer = cos(theta_r * (1.0 + sun_disc_feather));
    return smoothstep(cos_outer, cos_inner, cos_angle);
}

float rand(vec2 co){
    return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
}

void sky() {
    vec3 transmittance;
    vec3 radiance = integrate_scattering(vec3(0.0), EYEDIR, INFINITY, LIGHT0_DIRECTION, vec3(1.0, 0.996, 0.98), transmittance);
    
    float sun_mask = sun_disc(EYEDIR, LIGHT0_DIRECTION, LIGHT0_SIZE * 0.5);
    vec3 sundisc = vec3(sun_mask) * transmittance * sundisc_intensity;
    
    COLOR = radiance + sundisc;
    
    if (use_debanding) {
        COLOR += (rand(SKY_COORDS) – 0.5) * 0.001;
    }
}

Last edited 5 months ago by jaccurate
jaccurate
jaccurate
5 months ago
Reply to  Ansol

can’t wait to see the new version 😀

jaccurate
jaccurate
5 months ago

another optimization I’d recommend would be to move the directional light 3D _process() code to a signal, rather than running every frame. I modified my own code for it to update in the editor and emit a custom signal when I update the sun’s position(for a day/night cycle)