02 - Scene graph

A small scene graph: assets shared between primitives and instances, frames that carry what is in them, rotations and scale, a mesh built in code, and a camera pointed with look_at.

02 - Scene graph

Data

None: everything in it is built in code.

Run

From this folder, in Python:

python scene_graph.py

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

../../build/02_scene_graph

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

Source

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

"""
02 - Scene graph

Demonstrates:
    - The asset chain: one geometry, used by two primitives with different materials
    - Building a geometry in code: a mesh from a list of vertices and a list of indices
    - Reusing one primitive in several instances, each with its own position and scale
    - Frames (new_subframe): moving or rotating a frame moves everything in it
    - Rotations: from an axis and angle (Rotation), from Euler angles, and non-uniform scale
    - Pointing a camera with look_at, which tracks its target as it moves
    - Blender's camera convention (use_blender_convention), next to the default OpenCV one
    - Naming assets, hiding an instance (set_visible), and printing the scene graph

Builds on: 01 First render
Data: none

Run:
    python scene_graph.py

Writes:
    output/02_scene_graph.png
"""

from pathlib import Path

import numpy as np

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

from huira.units import Meter as m
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


def main():
    scene = Scene()

    # Assets form a chain: a geometry is a shape, a primitive is a geometry with a material, and
    # an instance places a primitive in the scene. Each link can be shared. Here, one sphere
    # geometry makes two primitives, one gray and one rust-colored (albedos in red, green, blue):
    sphere = scene.add_ellipsoid(m(1), m(1), m(1), "unit_sphere")
    lambertian = scene.new_bsdf_lambertian()

    gray = scene.new_material(lambertian, "gray")
    gray.set_albedo_factor(SpectralBins(0.5))
    rust = scene.new_material(lambertian, "rust")
    rust.set_albedo_factor(SpectralBins([0.6, 0.3, 0.15]))

    gray_ball = scene.add_primitive(sphere, gray, "gray_ball")
    rust_ball = scene.add_primitive(sphere, rust, "rust_ball")

    # Frames group instances. A "planet" frame holds the planet, and a "moon_orbit" frame inside
    # it holds the moon. Rotating moon_orbit carries the moon around the planet, whatever the
    # moon's own position inside the frame:
    planet_frame = scene.root.new_subframe()
    planet_frame.set_position(m(0), m(0), m(0))

    planet = planet_frame.new_instance(gray_ball)
    planet.set_scale(2.0)

    moon_orbit = planet_frame.new_subframe()
    moon_orbit.set_rotation(huira.Rotation.from_local_to_parent(huira.Vec3(0, 0, 1), deg(40)))

    # The moon reuses the gray primitive at half the size, 5 m out along the orbit frame's x axis:
    moon = moon_orbit.new_instance(gray_ball)
    moon.set_position(m(5), m(0), m(0))
    moon.set_scale(0.5)

    # A rock: the rust primitive, scaled differently along each axis and turned by Euler angles
    # (applied in the order the sequence gives, about the x, y and z axes):
    rock = planet_frame.new_instance(rust_ball)
    rock.set_position(m(-3), m(-3), m(1))
    rock.set_scale(0.9, 0.5, 0.3)
    rock.set_euler_angles(deg(20), deg(0), deg(35), "XYZ")

    # Geometry can also be built in code, as a mesh: a list of vertices, each with a position and
    # a normal (and optionally texture coordinates and a color), and a list of indices into it,
    # three for each triangle. Here, a tetrahedron with flat faces: each face has its own three
    # vertices, which all take the face's normal.
    corners = np.array([(1, 1, 1), (1, -1, -1), (-1, 1, -1), (-1, -1, 1)], dtype=float)
    faces = [(0, 1, 2), (0, 3, 1), (0, 2, 3), (1, 3, 2)]
    vertices = []
    indices = []
    for face in faces:
        a, b, c = corners[list(face)]
        normal = np.cross(b - a, c - a)
        normal /= np.linalg.norm(normal)
        for corner in (a, b, c):
            indices.append(len(vertices))
            vertices.append(Vertex(position=huira.Vec3(*corner), normal=huira.Vec3(*normal)))
    tetrahedron_mesh = scene.add_mesh(indices, vertices, "tetrahedron")

    tetrahedron = planet_frame.new_instance(scene.add_primitive(tetrahedron_mesh, rust))
    tetrahedron.set_position(m(3), m(-3), m(1.5))
    tetrahedron.set_scale(0.7)

    # An instance can be hidden without removing it from the scene:
    hidden = planet_frame.new_instance(rust_ball)
    hidden.set_position(m(0), m(-4), m(0))
    hidden.set_visible(False)

    sun = scene.root.new_instance(scene.new_sun_light())
    sun.set_position(au(0.6), au(-0.6), au(0.5))

    # By default, Huira's cameras look along +z with +y down the image (OpenCV's convention).
    # use_blender_convention() makes them look along -z with +y up, as Blender's do, which helps
    # when comparing with Blender renders.
    camera_model = scene.new_camera_model("navcam")
    camera_model.use_blender_convention()
    camera_model.set_focal_length(mm(35))
    camera_model.configure_sensor_from_size((1280, 960), mm(36))
    camera_model.set_fstop(16)

    # look_at turns an instance to face a target, with "up" (here +z) toward the top of the
    # image. Given another instance, it keeps facing it as it moves.
    camera = scene.root.new_instance(camera_model)
    camera.set_position(m(4), m(-16), m(7))
    camera.look_at(planet, huira.Vec3(0, 0, 1))

    # Names make assets easy to find later, and to read in the printouts:
    assert scene.get_camera_model("navcam").valid()
    scene.print_graph()
    scene.print_contents()

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

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

    renderer = Renderer()
    renderer.set_samples_per_pixel(64)
    renderer.render(scene_view, frame_buffer)

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


if __name__ == "__main__":
    main()
// 02 - Scene graph
//
// Demonstrates:
//   - The asset chain: one geometry, used by two primitives with different materials
//   - Building a geometry in code: a mesh from a list of vertices and a list of indices
//   - Reusing one primitive in several instances, each with its own position and scale
//   - Frames (new_subframe): moving or rotating a frame moves everything in it
//   - Rotations: from an axis and angle (Rotation), from Euler angles, and non-uniform scale
//   - Pointing a camera with look_at, which tracks its target as it moves
//   - Blender's camera convention (use_blender_convention), next to the default OpenCV one
//   - Naming assets, hiding an instance (set_visible), and printing the scene graph
//
// Builds on: 01 First render
// Data: none
//
// Run:
//   ./02_scene_graph
//
// Writes:
//   output/02_scene_graph.png

#include <array>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <iostream>

#include "huira/huira.hpp"

namespace fs = std::filesystem;

using namespace huira::units::literals;

using TSpectral = huira::RGB;

int main()
{
    huira::Scene<TSpectral> scene;

    // Assets form a chain: a geometry is a shape, a primitive is a geometry with a material, and
    // an instance places a primitive in the scene. Each link can be shared. Here, one sphere
    // geometry makes two primitives, one gray and one rust-colored (albedos in red, green, blue):
    auto sphere = scene.add_ellipsoid(1_m, 1_m, 1_m, "unit_sphere");
    auto lambertian = scene.new_bsdf_lambertian();

    auto gray = scene.new_material(lambertian, "gray");
    gray.set_albedo_factor(TSpectral{0.5f});
    auto rust = scene.new_material(lambertian, "rust");
    rust.set_albedo_factor(TSpectral{0.6f, 0.3f, 0.15f});

    auto gray_ball = scene.add_primitive(sphere, gray, "gray_ball");
    auto rust_ball = scene.add_primitive(sphere, rust, "rust_ball");

    // Frames group instances. A "planet" frame holds the planet, and a "moon_orbit" frame inside
    // it holds the moon. Rotating moon_orbit carries the moon around the planet, whatever the
    // moon's own position inside the frame:
    auto planet_frame = scene.root.new_subframe();
    planet_frame.set_position(0_m, 0_m, 0_m);

    auto planet = planet_frame.new_instance(gray_ball);
    planet.set_scale(2.0);

    auto moon_orbit = planet_frame.new_subframe();
    moon_orbit.set_rotation(
        huira::Rotation<double>::from_local_to_parent(huira::Vec3<double>{0.0, 0.0, 1.0}, 40_deg));

    // The moon reuses the gray primitive at half the size, 5 m out along the orbit frame's x axis:
    auto moon = moon_orbit.new_instance(gray_ball);
    moon.set_position(5_m, 0_m, 0_m);
    moon.set_scale(0.5);

    // A rock: the rust primitive, scaled differently along each axis and turned by Euler angles
    // (applied in the order the sequence gives, about the x, y and z axes):
    auto rock = planet_frame.new_instance(rust_ball);
    rock.set_position(-3_m, -3_m, 1_m);
    rock.set_scale(0.9, 0.5, 0.3);
    rock.set_euler_angles(20_deg, 0_deg, 35_deg, "XYZ");

    // Geometry can also be built in code, as a mesh: a list of vertices, each with a position and
    // a normal (and optionally texture coordinates and a color), and a list of indices into it,
    // three for each triangle. Here, a tetrahedron with flat faces: each face has its own three
    // vertices, which all take the face's normal.
    const std::array<huira::Vec3<float>, 4> corners = {{
        {1.f, 1.f, 1.f},
        {1.f, -1.f, -1.f},
        {-1.f, 1.f, -1.f},
        {-1.f, -1.f, 1.f},
    }};
    const std::array<std::array<std::size_t, 3>, 4> faces = {{
        {0, 1, 2},
        {0, 3, 1},
        {0, 2, 3},
        {1, 3, 2},
    }};
    huira::VertexBuffer<TSpectral> vertices;
    huira::IndexBuffer indices;
    for (const auto& face : faces) {
        const huira::Vec3<float>& a = corners[face[0]];
        const huira::Vec3<float>& b = corners[face[1]];
        const huira::Vec3<float>& c = corners[face[2]];
        const huira::Vec3<float> normal = glm::normalize(glm::cross(b - a, c - a));
        for (const huira::Vec3<float>& corner : {a, b, c}) {
            indices.push_back(static_cast<std::uint32_t>(vertices.size()));
            huira::Vertex<TSpectral> vertex;
            vertex.position = corner;
            vertex.normal = normal;
            vertices.push_back(vertex);
        }
    }
    auto tetrahedron_mesh = scene.add_mesh(indices, vertices, "tetrahedron");

    auto tetrahedron = planet_frame.new_instance(scene.add_primitive(tetrahedron_mesh, rust));
    tetrahedron.set_position(3_m, -3_m, 1.5_m);
    tetrahedron.set_scale(0.7);

    // An instance can be hidden without removing it from the scene:
    auto hidden = planet_frame.new_instance(rust_ball);
    hidden.set_position(0_m, -4_m, 0_m);
    hidden.set_visible(false);

    auto sun = scene.root.new_instance(scene.new_sun_light());
    sun.set_position(0.6_au, -0.6_au, 0.5_au);

    // By default, Huira's cameras look along +z with +y down the image (OpenCV's convention).
    // use_blender_convention() makes them look along -z with +y up, as Blender's do, which helps
    // when comparing with Blender renders.
    auto camera_model = scene.new_camera_model("navcam");
    camera_model.use_blender_convention();
    camera_model.set_focal_length(35_mm);
    camera_model.configure_sensor_from_size({1280, 960}, 36_mm);
    camera_model.set_fstop(16);

    // look_at turns an instance to face a target, with "up" (here +z) toward the top of the
    // image. Given another instance, it keeps facing it as it moves.
    auto camera = scene.root.new_instance(camera_model);
    camera.set_position(4_m, -16_m, 7_m);
    camera.look_at(planet, huira::Vec3<double>{0.0, 0.0, 1.0});

    // Names make assets easy to find later, and to read in the printouts:
    if (!scene.get_camera_model("navcam").valid()) {
        std::cerr << "navcam not found\n";
        return 1;
    }
    scene.print_graph();
    scene.print_contents();

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

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

    huira::Renderer<TSpectral> renderer;
    renderer.set_samples_per_pixel(64);
    renderer.render(scene_view, frame_buffer);

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