On Rendering the Sky, Sunsets, and Planets - The Blog of Maxime Heckel
Pangram verdict · v3.3
We believe that this document is fully human-written
AI likelihood · overall
HumanArticle text · 1,625 words · 6 segments analyzed
There’s this photo that’s been sitting on my inspiration board for a while, of the space shuttle Endeavour, suspended in space in low Earth orbit at sunset. It shows Earth’s upper atmosphere as a backdrop, featuring beautiful, colorful layers ranging from dark orange to blue before fading away into the deep black of space. Not only is that gradient of color aesthetically pleasing, but the phenomenon behind those colors, atmospheric scattering, is even more of an interesting topic once you start looking into how it works and how to reproduce it.Shuttle Silhouette https://www.nasa.gov/image-article/shuttle-silhouette-2/I wanted to build my own version of this effect with shaders, rendering the sky’s distinctive blue color and realistic sunsets and sunrises directly in the browser. The goal was to get as close as I could to that photo, while also moving toward the kind of atmospheric rendering often seen in games and other shader-based media.Here’s a compilation of what came out of this month-long journey, all running in real time:I didn’t originally plan on writing about this subject, but the enthusiasm around the recent Artemis II mission, combined with my own interest in all things space, made it feel worth exploring in depth. It also felt like the perfect opportunity to build an interactive experience that could make the topic more accessible. In this write-up, we’ll see how to implement an atmospheric scattering shader post-processing effect step-by-step, starting with the implementation of the different building blocks (raymarching, Rayleigh and Mie scattering, as well as ozone absorption) to render a realistic sky dome, and then adapt the result to render it as an atmospheric shell around a planet. Finally, we'll look into Sebastian Hillaire’s LUT-based approach for a more performant result, or at least my attempt at implementing it, as this was very much the stepping outside of my comfort zone phase for this project. You may have, at some point or another, tried to slap a blue gradient background behind some of your work in an attempt to give it a more "atmospheric" look and call it a day, but quickly noticed doing so never feels quite right 1.
For a more true to life implementation, we must treat the sky and its color as the result of light interacting with air and its constituents, while taking into account several variables, such the altitude of the observer, the amount of dust, the time of day, etc, all of that in a volume. With that established, our goal for this first part is to use this as guiding principle to lay the foundation for our atmosphere shader, and get to a result that feels almost indistinguishable from a real sky, at any time of the day. Sampling Atmospheric Density Much like how we’d approach volumetric clouds or volumetric light, one easy way to sample the atmosphere is through raymarching. We can cast rays from the camera’s position into the scene and step through the transparent medium to answer the two following questions: How much light survives traveling through the atmosphere? This is the transmittance term. How much light is redirected toward the camera at each sample? Also known as scattering. To answer the first one, we need to accumulate the atmospheric density encountered along the ray to obtain what is known as the optical depth. We will model this using the Rayleigh density function, which tells us how much "air" there is at a given altitude h. This is important to take into account that the atmosphere gets thinner as altitude increases. Sampling Rayleigh density and accumulating optical depth1const float RAYLEIGH_SCALE_HEIGHT = 8.0; 2const float ATMOSPHERE_HEIGHT = 100.0; 3const float VIEW_DISTANCE = 200.0; 4const int PRIMARY_STEPS = 24;5const vec3 SUN_DIRECTION = normalize(vec3(0.0, 1.0, 1.0));67float rayleighDensity(float h) {8 return exp(-max(h, 0.0) / RAYLEIGH_SCALE_HEIGHT);9}1011void main() {12 vec2 p = vUv * 2.0 - 1.0;1314 vec3 color = vec3(0.0);15 vec3 viewDir = normalize(vec3(p.x, p.y, 1.0));16 vec3 skyDir = normalize(vec3(viewDir.x, max(viewDir.y, 0.0), viewDir.z));1718 float stepSize
= VIEW_DISTANCE / float(PRIMARY_STEPS);19 float viewOpticalDepth = 0.0;2021 for (int i = 0; i < PRIMARY_STEPS; i++) {22 float t = (float(i) + 0.5) * stepSize;23 float h = t * skyDir.y;2425 if (h < 0.0) break;26 if (h > ATMOSPHERE_HEIGHT) break;2728 float dR = rayleighDensity(h);29 viewOpticalDepth += dR * stepSize;303132 }33343536 color = ACESFilm(color);3738 fragColor = vec4(color, 1.0);39} Then, from the optical depth, we can compute the transmittance T at a given point along the ray: the fraction of light that survives while traveling through the atmosphere. T=1.0 means that there is no loss of light. T=0.0 means that the light is totally extinguished. If you’ve read my article on volumetric clouds 2, we’re using a formula that may look familiar for this: Beer's Law: Computing transmittance123float dR = rayleighDensity(h);4viewOpticalDepth += dR * stepSize;56vec3 transmittance = exp(-rayleighBeta * viewOpticalDepth);7scattering += dR * transmittance * stepSize;89 With this in place, we can now describe how light is attenuated as it travels through the atmosphere. However, density and transmittance only tell us how much light is available to scatter, not how that light is distributed toward the viewer. For that, we need to account for the angle between the incoming sunlight and the view ray, which is what the Rayleigh phase function models.
Rayleigh phase function1234const vec3 SUN_DIRECTION = normalize(vec3(0.0, 1.0, 1.0));56float rayleighPhase(float mu) {7 return 3.0 / (16.0 * PI) * (1.0 + mu * mu);8}91011void main() {1213 float phase = rayleighPhase(dot(skyDir, SUN_DIRECTION));14151617 scattering *= SUN_INTENSITY * phase * rayleighBeta;1819 float horizon = smoothstep(-0.12, 0.05, skyDir.y);20 vec3 color = mix(SPACE_COLOR, scattering, horizon);21 color = ACESFilm(color);2223 fragColor = vec4(color, 1.0);24} Putting all this together, we can have a somewhat accurate representation of how much scattered light accumulates along a given ray at any given altitude. The widget below represents the process we just described, showing you: The sample steps along a single ray The resulting pixel color obtained from this process (an approximation) Raymarch Steps20Altitude3.5 km As you can see, we’re accumulating shades of blue at lower altitude! This is mostly due to the Rayleigh scattering coefficient’s value: Red scatters very little Green a bit more Blue the most Since shorter wavelengths scatter more strongly, more blue light is redirected toward the viewer, thus resulting in the sky appearing blue during daytime. If we expand this idea into a full-on fragment shader, going from a single ray to one ray per pixel, we can render a realistic sky, as demonstrated below: UniformsCamera Pitch20.00Altitude (km)2.00 This raymarching process yields a beautiful blue sky, with a lighter white haze towards the horizon as rays travel through more atmosphere there, and deeper, darker blue colors as the altitude increases and the atmosphere gets thinner. Mie Scattering and Ozone While Rayleigh scattering alone yields a decent result, there are still additional atmospheric effects that we can take into account to make our sky rendering closer to reality: Mie Scattering, which describes the interaction of light with larger particles in the atmosphere, like dust or aerosols.
It has a density function to account for the amount of material in the medium, as well as a phase function, which, like its Rayleigh counterpart, describes how the light gets redistributed in different directions. Ozone absorption, which models how ozone absorbs part of the light passing through the upper atmosphere. This one does not scatter light; it only removes some wavelengths along the path. Its main contribution is to shift and deepen the sky’s color, especially near the horizon and during sunsets or twilight. The first one can be modeled with the following two functions: Mie density and phase function1float miePhase(float mu) {2 float gg = MIE_G * MIE_G;3 float num = 3.0 * (1.0 - gg) * (1.0 + mu * mu);4 float den = 8.0 * PI * (2.0 + gg) * pow(max(1.0 + gg - 2.0 * MIE_G * mu, 1e-4), 1.5);5 return num / den;6}78float mieDensity(float h) {9 return exp(-max(h, 0.0) / MIE_SCALE_HEIGHT);10} To get the updated scattering term that takes Mie scattering and Ozone into account, we simply add it to the current implementation of our sky shader on top of the Rayleigh density and phase function: Rayleigh, Mie, and Ozone scattering terms1float viewODR = 0.0;2float viewODM = 0.0;3float viewODO = 0.0;45vec3 sumR = vec3(0.0);6vec3 sumM = vec3(0.0);7vec3 sumO = vec3(0.0);89for (int i = 0; i < PRIMARY_STEPS; i++) {10 float t = (float(i) + 0.5) * stepSize;11 float h = uObserverAltitude + t *
skyDir.y;1213 if (h < 0.0) break;14 if (h > ATMOSPHERE_HEIGHT) break;1516 float dR = rayleighDensity(h);17 float dM = mieDensity(h);18 float dO = ozoneDensity(h);1920 viewODR += dR * stepSize;21 viewODM += dM * stepSize;22 viewODO += dO * stepSize;2324 vec3 tau = BETA_R * viewODR25 + BETA_M_EXT * viewODM26 + BETA_OZONE_ABS * viewODO;27 vec3 transmittance = exp(-tau);2829 sumR += dR * transmittance * stepSize;30 sumM += dM * transmittance * stepSize;31 sumO += dO * transmittance * stepSize;32}3334vec3 scattering = SUN_INTENSITY * (35 phaseR * BETA_R * sumR +36 phaseM * BETA_M_SCATTER * sumM +37 BETA_OZONE_SCATTER * sumO38);3940float horizon = smoothstep(-0.12, 0.05, skyDir.y);41vec3 color = mix(SPACE_COLOR, scattering, horizon);42color = ACESFilm(color);4344fragColor = vec4(color, 1.0); The widget below showcases the result of integrating both of those new terms into our sky shader: UniformsCamera Pitch20.00Altitude (km)0.00Sun Angle45.00°MieOzone As you can see, this version yields both: A more natural “sky blue” color, thanks to our ozone absorption Our sky shader without/with Ozone A hazy glow around the location of our sun, and even more so visible when the sun is close to the horizon Our sky shader without/with Mie scattering Light and Transmittance At this point, we have a decent sky fragment shader capable of rendering a natural color for any altitude and taking into account a diverse set of transmittance models (Mie, Rayleigh, and Ozone). That still leaves us with lighting to work on.