07 - Clouds and atmosphere

Clouds as a second, partly transparent shell over the Earth, and an atmosphere as a medium that scatters light.

07 - Clouds and atmosphere

Data

File

What it is

From

8k_earth_daymap.jpg

The Earth’s surface by day

media.huira.space

8k_earth_clouds.jpg

The Earth’s clouds

media.huira.space

Download them into this folder:

curl -fLO https://media.huira.space/data/textures/solar_system/8k_earth_daymap.jpg
curl -fLO https://media.huira.space/data/textures/solar_system/8k_earth_clouds.jpg

Licenses, and how to credit the data: Earth textures.

Run

From this folder, in Python:

python clouds_and_atmosphere.py 8k_earth_daymap.jpg 8k_earth_clouds.jpg

or in C++, built as in Running the examples:

../../build/07_clouds_and_atmosphere 8k_earth_daymap.jpg 8k_earth_clouds.jpg

Each writes its images to output/, in the folder it runs from.

Source

The complete example, in both languages. View this example on GitHub.

"""
07 - Clouds and atmosphere

Demonstrates:
    - Alpha: a texture that makes parts of a surface transparent (the clouds)
    - Layered shells: one geometry inside another, each with its own material
    - Participating media: a constant density field (absorption and scattering), an isotropic
      phase function, and a medium made of the two
    - A primitive with both a material and a medium; the null BSDF and a transmission of 1,
      for a boundary that light crosses unchanged

Builds on: 06 Materials and textures
Data: the Earth's daymap and cloud map (see README.md)

Run:
    python clouds_and_atmosphere.py 8k_earth_daymap.jpg 8k_earth_clouds.jpg

Writes:
    output/07_clouds_and_atmosphere.png

Note: volumetric rendering is still noisy; improvements are coming.
"""

import sys
from pathlib import Path

import huira
from huira.rgb import Scene, SceneView, Renderer, SpectralBins, rgb_to_spectral

from huira.units import Meter as m
from huira.units import Kilometer as km
from huira.units import Millimeter as mm
from huira.units import Degree as deg
from huira.units import AstronomicalUnit as au
from huira.units import Microsecond as us

EQUATORIAL_RADIUS_KM = 6378.137
POLAR_RADIUS_KM = 6356.752


def earth_shell(scene, altitude_km):
    """An ellipsoid the Earth's shape, altitude_km above its surface."""
    a = km(EQUATORIAL_RADIUS_KM + altitude_km)
    b = km(POLAR_RADIUS_KM + altitude_km)
    return scene.add_ellipsoid(a, a, b)


def main():
    if len(sys.argv) != 3:
        sys.exit(
            "Usage: python clouds_and_atmosphere.py <daymap> <cloud_map>\n"
            "README.md says where to get them."
        )
    daymap_path, cloud_map_path = Path(sys.argv[1]), Path(sys.argv[2])

    scene = Scene()

    # The surface: the daymap, on a Lambertian BSDF. (Example 06 builds the full material.)
    surface = scene.new_material(scene.new_bsdf_lambertian())
    daymap_srgb = huira.read_image(daymap_path)
    daymap = huira.srgb_to_linear(daymap_srgb)  # JPEG textures are in sRGB color space
    surface.set_albedo_image(scene.add_texture(rgb_to_spectral(daymap.image)))
    scene.root.new_instance(scene.add_primitive(earth_shell(scene, 0), surface))

    # The clouds are a second shell, 6 km up, with a white Lambertian material. Its alpha image
    # (the cloud map, read as one channel) says how opaque each point is: where it is 0, light
    # passes through to the surface below.
    cloud_material = scene.new_material(scene.new_bsdf_lambertian())
    cloud_map = huira.read_image_mono(cloud_map_path)
    cloud_material.set_alpha_image(scene.add_texture(cloud_map.image))
    scene.root.new_instance(scene.add_primitive(earth_shell(scene, 6), cloud_material))

    # The atmosphere is a medium: a volume that absorbs and scatters light along the paths
    # through it. A medium is a density field (how much it absorbs and scatters, per meter, in
    # each spectral bin) with a phase function (which way scattered light goes). This one is
    # uniform, scattering blue more than red, and scatters equally in all directions.
    absorption = SpectralBins(0.0)  # per meter
    scattering = SpectralBins([0.5e-6, 1.0e-6, 2.0e-6])  # per meter (red, green, blue)
    density = scene.new_constant_density_field(absorption, scattering)
    phase_function = scene.new_isotropic_phase_function()
    air = scene.new_medium(density, phase_function)

    # The medium fills a shell 100 km up. Its surface has the null BSDF, which reflects nothing,
    # and a transmission of 1, so light crosses the boundary unchanged and only the medium
    # inside acts on it.
    boundary = scene.new_material(scene.new_bsdf_null())
    boundary.set_transmission(SpectralBins(1.0))
    scene.root.new_instance(scene.add_primitive(earth_shell(scene, 100), boundary, air))

    sun = scene.root.new_instance(scene.new_sun_light())
    sun.set_position(au(0.866), au(0.5), m(0))

    camera_model = scene.new_camera_model()
    camera_model.use_blender_convention()
    camera_model.set_focal_length(mm(25))
    camera_model.configure_sensor_from_size((1080, 1080), mm(6))
    camera_model.set_fstop(5)
    camera_model.set_sensor_bit_depth(12)
    camera_model.set_sensor_conversion_gain(1)
    camera_model.set_sensor_quantum_efficiency(0.8)
    camera_model.set_sensor_full_well_capacity(20000)
    camera_model.enable_sensor_noise(False)

    camera = scene.root.new_instance(camera_model)
    camera.set_position(km(100000), m(0), m(0))
    camera.set_euler_angles(deg(90), deg(0), deg(90))

    time = huira.Time("2026-01-01T00:00:00")
    exposure = huira.Interval.from_start(time, us(25))
    scene_view = SceneView(scene, exposure, camera)

    frame_buffer = camera_model.make_frame_buffer()
    frame_buffer.enable_sensor_response()

    # Light that scatters in the atmosphere needs bounces to reach the camera, and volumes need
    # more samples than surfaces to settle.
    renderer = Renderer()
    renderer.set_samples_per_pixel(256)
    renderer.set_max_bounces(4)

    camera_model.precompute()
    renderer.render(scene_view, frame_buffer)

    output_dir = Path("output")
    output_dir.mkdir(exist_ok=True)
    huira.write_png(
        output_dir / "07_clouds_and_atmosphere.png",
        huira.linear_to_srgb(frame_buffer.sensor_response),
    )


if __name__ == "__main__":
    main()
// 07 - Clouds and atmosphere
//
// Demonstrates:
//   - Alpha: a texture that makes parts of a surface transparent (the clouds)
//   - Layered shells: one geometry inside another, each with its own material
//   - Participating media: a constant density field (absorption and scattering), an isotropic
//     phase function, and a medium made of the two
//   - A primitive with both a material and a medium; the null BSDF and a transmission factor,
//     for a boundary that light crosses unchanged
//
// Builds on: 06 Materials and textures
// Data: the Earth's daymap and cloud map (see README.md)
//
// Run:
//   ./07_clouds_and_atmosphere 8k_earth_daymap.jpg 8k_earth_clouds.jpg
//
// Writes:
//   output/07_clouds_and_atmosphere.png
//
// Note: volumetric rendering is still noisy; improvements are coming.

#include <filesystem>
#include <iostream>
#include <utility>

#include "huira/huira.hpp"

namespace fs = std::filesystem;

using namespace huira::units::literals;

using TSpectral = huira::RGB;

constexpr double EQUATORIAL_RADIUS_KM = 6378.137;
constexpr double POLAR_RADIUS_KM = 6356.752;

// An ellipsoid the Earth's shape, altitude_km above its surface.
static huira::EllipsoidHandle<TSpectral> earth_shell(huira::Scene<TSpectral>& scene,
                                                     double altitude_km)
{
    const huira::units::Kilometer a(EQUATORIAL_RADIUS_KM + altitude_km);
    const huira::units::Kilometer b(POLAR_RADIUS_KM + altitude_km);
    return scene.add_ellipsoid(a, a, b);
}

int main(int argc, char** argv)
{
    if (argc != 3) {
        std::cerr << "Usage: 07_clouds_and_atmosphere <daymap> <cloud_map>\n"
                     "README.md says where to get them.\n";
        return 1;
    }
    const fs::path daymap_path = argv[1];
    const fs::path cloud_map_path = argv[2];

    huira::Scene<TSpectral> scene;

    // The surface: the daymap, on a Lambertian BSDF. (Example 06 builds the full material.)
    auto surface = scene.new_material(scene.new_bsdf_lambertian());
    auto daymap_srgb = huira::read_image(daymap_path);
    auto daymap = huira::srgb_to_linear(daymap_srgb); // JPEG textures are in sRGB color space
    surface.set_albedo_image(scene.add_texture(huira::rgb_to_spectral<TSpectral>(daymap.image)));
    scene.root.new_instance(scene.add_primitive(earth_shell(scene, 0.0), surface));

    // The clouds are a second shell, 6 km up, with a white Lambertian material. Its alpha image
    // (the cloud map, read as one channel) says how opaque each point is: where it is 0, light
    // passes through to the surface below.
    auto cloud_material = scene.new_material(scene.new_bsdf_lambertian());
    auto cloud_map = huira::read_image_mono(cloud_map_path);
    cloud_material.set_alpha_image(scene.add_texture(std::move(cloud_map.image)));
    scene.root.new_instance(scene.add_primitive(earth_shell(scene, 6.0), cloud_material));

    // The atmosphere is a medium: a volume that absorbs and scatters light along the paths
    // through it. A medium is a density field (how much it absorbs and scatters, per meter, in
    // each spectral bin) with a phase function (which way scattered light goes). This one is
    // uniform, scattering blue more than red, and scatters equally in all directions.
    const TSpectral absorption{0.f};                       // per meter
    const TSpectral scattering{0.5e-6f, 1.0e-6f, 2.0e-6f}; // per meter (red, green, blue)
    auto density = scene.new_constant_density_field(absorption, scattering);
    auto phase_function = scene.new_isotropic_phase_function();
    auto air = scene.new_medium(density, phase_function);

    // The medium fills a shell 100 km up. Its surface has the null BSDF, which reflects nothing,
    // and a transmission of 1, so light crosses the boundary unchanged and only the medium
    // inside acts on it.
    auto boundary = scene.new_material(scene.new_bsdf_null());
    boundary.set_transmission(TSpectral{1.f});
    scene.root.new_instance(scene.add_primitive(earth_shell(scene, 100.0), boundary, air));

    auto sun = scene.root.new_instance(scene.new_sun_light());
    sun.set_position(0.866_au, 0.5_au, 0_m);

    auto camera_model = scene.new_camera_model();
    camera_model.use_blender_convention();
    camera_model.set_focal_length(25_mm);
    camera_model.configure_sensor_from_size({1080, 1080}, 6_mm);
    camera_model.set_fstop(5);
    camera_model.set_sensor_bit_depth(12);
    camera_model.set_sensor_conversion_gain(1.f);
    camera_model.set_sensor_quantum_efficiency(0.8);
    camera_model.set_sensor_full_well_capacity(20000.f);
    camera_model.enable_sensor_noise(false);

    auto camera = scene.root.new_instance(camera_model);
    camera.set_position(100000_Km, 0_m, 0_m);
    camera.set_euler_angles(90_deg, 0_deg, 90_deg);

    huira::Time time("2026-01-01T00:00:00");
    huira::Interval exposure = huira::Interval::from_start(time, 25_us);
    huira::SceneView<TSpectral> scene_view(scene, exposure, camera);

    auto frame_buffer = camera_model.make_frame_buffer();
    frame_buffer.enable_sensor_response();

    // Light that scatters in the atmosphere needs bounces to reach the camera, and volumes need
    // more samples than surfaces to settle.
    huira::Renderer<TSpectral> renderer;
    renderer.set_samples_per_pixel(256);
    renderer.set_max_bounces(4);

    camera_model.precompute();
    renderer.render(scene_view, frame_buffer);

    fs::create_directories("output");
    huira::write_image_png("output/07_clouds_and_atmosphere.png",
                           huira::linear_to_srgb(frame_buffer.sensor_response()));
}