5  Improve Cli I

In this chapter, we’ll temporarily move away from the rendering itself, and trying to improve our renderer in some way to make our developing experience better.

Language Specific Chapter

This chapter is language specific, which means we spend most time discussing Rust and its ecosystem. If you’re not using Rust, this chapter is still useful, but you need more efforts to achieve the same effects I’ve done since I use lots of 3rd parties.

You can also skip but it’s not recommended, especially Section 5.1.3.

5.1 Improvements

5.1.1 Cli Parameters

As mentioned earlier in Chapter 4, we usually use cli parameter to change the behavior of our renderer. Let’s first list all possible parameters:

  • tiling: Use tiled rendering with given tile size.
  • parallel: A flag enable rayon.
  • preview: Enable real-time preview with tev image viewer opened. See Section 5.1.3.
  • save: Accepts a path that the rendered result will be save, or don’t save if no path is given.

Above are parameters for the renderer.

  • spp: This should be positive integer, or not presented to use the value defined for the scene.
  • max-ray-depth: Also positive integer, or not presented to use the value defined for the scene.
  • diable-jittering: Flag that disable pixel jittering. This means jittering is enabled by default.
  • orthographic: Flag that enable orthographic projection for the camera. Otherwise projective projection will be used.
  • film-size: The film size, which is also the output image size.

Above are parameters for to overwrite some parameters in the scene.

  • fast: A short-hand flag that enables both parallel and tiling
  • fast-preview: A short-hand flag that enables parallel and tiling, and set spp to 4, max-ray-depth to 4 to obtains a very fast preview rendering.

Above are shorthands for convience.

All those are parameters for a command called render. So you use this cli just like hoe you use git - <renderer-executable> render [parameters...]. Later we’ll add some other commands.

I’m going to use clap for cli parameter parsing, since it’s powerful and matured.

If you haven’t learned about clap yet, refer to the book, reference or cookbook of clap. What I’m using is called the derive api of clap, which can generate a argument parser automatically for our parameter definition. The parsed parameter is:

#[derive(Parser)]
#[command(version = build::CLAP_LONG_VERSION, about, long_about = None)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Commands,
}

Note that the build::CLAP_LONG_VERSION requires the use of shadow-rs. When you use the --version(-v) option generated by clap, this information will be printed out. And the about will print out your doc comment for this struct, not just for this, struct, but almost all of them.

The use of #[command(subcommand)] means that our cpplication requires a subcommand, which is defined in the Commands struct:

#[derive(Debug, Subcommand)]
pub enum Commands {
    Render {
        #[arg(
1            short, long, name = "TILE_WIDTH,TILE_HEIGHT", value_parser = parse_non_zero_usize_pair
        )]
        tiling: Option<(usize, usize)>,
        #[arg(
            short, long, name = "FILM_WIDTH,FILM_HEIGHT", value_parser = parse_non_zero_usize_pair
        )]
        film_size: Option<(usize, usize)>,
        #[arg(short, long)]
        parallel: bool,
        #[arg(long)]
        save: Option<Option<PathBuf>>,
        #[arg(long)]
        disable_jittering: Option<bool>,
        #[arg(short, long)]
        orthographic: Option<bool>,
        #[arg(long)]
        preview: bool,
        #[arg(long)]
        spp: Option<NonZeroUsize>,
        #[arg(short = 'd', long)]
        max_ray_depth: Option<NonZeroUsize>,
        #[arg(long)]
        fast: bool,
        #[arg(long)]
        fast_preview: bool,
2        sample_scene: SampleScene,
    },
}

fn parse_non_zero_usize_pair(s: &str) -> Result<(usize, usize), String> {
    let parts: Vec<&str> = s.split(',').collect();
    if parts.len() != 2 {
        return Err("Must be exactly two positive integers.".to_owned());
    }

    let size1 = parts[0]
        .trim()
        .parse::<usize>()
        .map_err(|_| "Must be positive integer.".to_owned())?;
    let size2 = parts[1]
        .trim()
        .parse::<usize>()
        .map_err(|_| "Must be positive integer.".to_owned())?;

    if size1 > 0 && size2 > 0 {
        return Ok((size1, size2));
    }

    Err("Must be positive integer.".to_owned())
}
1
The film size and tile size should be two positive integers, like 32,32, which is a format clap doesn’t support by default, so we need a custom parser to handle this.
2
Choice of the sample scene is a positional argument instead of an option.

To make things easier, we add a new struct to organize those parameters, and seperate parameters for overwrite scene settings into another struct. Those shorthands are also eliminated.

#[derive(Debug, Clone)]
pub struct RenderingConfig {
    pub render_mode: RenderMode,
    pub preview: bool,
    pub test_scene: TestScene,
    pub save: Option<PathBuf>,
    pub scene_config: SceneConfig,
}

The conversion between Commands and RenderingConfig is quite straightforward but verbose, so I am just not showing them.

So now we can extract most part of our main to another function that accepts a RenderingConfig, instead, the main function parses the cli parameters and start rendering:

fn main() {
    // Init the logger for outputs
    env_logger::init();

    let cli = Cli::parse();
    match cli.command {
1        r @ Commands::Render { .. } => rendering(r.to_rendering_config()),
    }
}
1
This weird @ bound value to r if matched.

And the rendering function is still similiar, just apply those parameters accordingly. The benefit of seperating parameters for the scene makes it easy to pass them to the scene loader, i.e., currently the TestScene::build function.

Try to add more useful argument by your own!

5.1.2 Progress Report

Now we’ve already have a simple progress, but it works badly - the console quickly scrolls, and it’s hard to see useful informations. It’s not even a bar! We’re going to use indicatif to add some nice progress bars.

Fisrt, instead of directly initialize the logger, we create the context of indicatif’s multiple progress bar, and init logger with it. This would be passed through our applicaiton so that whenever a progress bar is needed, the same progress context is used to support correct multiple progress bars. We also set the deault log level to info if RUST_LOG environment variable is not set.

int main() {
    // Init the logger for outputs
    let progress_bar = MultiProgress::new();

    // recreate the logger
    let log_env = env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info");
    let logger = env_logger::builder().parse_env(log_env).build();
    LogWrapper::new(progress_bar.clone(), logger)
        .try_init()
        .unwrap();
    
    // ...
}

Then at the place where we update the film, we can also update this progress bar accordingly.

pub fn render(scene: Scene, render_mode: RenderMode, progress_bar: MultiProgress) -> Scene {
    // ...

1    let tile_progress = render_mode.tiled.map(|(w, h)| {
        progress_bar.add(
            ProgressBar::new(((x / w) * (y / h)) as u64).with_style(
                ProgressStyle::default_bar()
                    .tick_strings(&["#", "##-", "-"])
                    .template(
                        "[{elapsed}] Tiles [{wide_bar:.green/cyan}] {percent}% ({pos}/{len}) <eta:{eta}>",
                    )
                    .unwrap_or(ProgressStyle::default_bar()).progress_chars("##-"),
            ),
        )
    });
    let pixel_progress = progress_bar.add(
        ProgressBar::new((x * y) as u64).with_style(
            ProgressStyle::default_bar()
                .template(
                    "[{elapsed}] Pixel [{wide_bar:.green/cyan}] {percent}% ({pos}/{len}) <eta:{eta}>",
                )
                .unwrap_or(ProgressStyle::default_bar())
                .progress_chars("##-"),
        ),
    );

    while !handle.is_finished() {
        if let Ok(message) = receiver.try_recv() {
            match message {
                RenderMessage::Pixel { position, color } => {
                    film[position] = color;
                    pixel_progress.inc(1);
                }
                RenderMessage::Tile { .. } => {
                    if let Some(tile_progress) = &tile_progress {
                        tile_progress.inc(1);
                    }
                }
            }
        }
    }

    handle.join().unwrap();

2    let time_elapsed = pixel_progress.elapsed();
    pixel_progress.finish();
    progress_bar.remove(&pixel_progress);
    if let Some(tile_progress) = &tile_progress {
        tile_progress.finish();
        progress_bar.remove(tile_progress)
    }

    // ...
}
1
The tiling is not always enabled so we need to wrap it in Option. Also, we hope that tile progress is showed over the pixel progress, so if it’s used, we add it first.
2
We save the elapsed time before the progress bar is removed, and later we can report it through logger with indicatif::HumanDuration.

5.1.3 Real-time Preview

tev protocal

An (almost) real-time previewer is very useful for renderer development. If you use other languages other than C++ or Rust and want to use tev also, you need to implement the protocol by your own. However, since tev’s protocol is not documented, though it’s not complex, reading my explanation and implementation (though using 3rd party protocol implementation) is still very useful, especially if you’re not familiar with multi-dimensional array.

We’re not going to implement the preview ui manually. Instead, the powerful tev image viewer could be controlled using a TCP connection, and that’s also the way PBRT has chosen.

We implement two preview mode. If the tiled-rendering is enabled, we update per tile. If not, we update the whole image per 1.5 second. This is to avoid the cost of updating the image for every pixel. The control protocol tev accepts has already been implemented in Rust, in the crate tev_client, so it’s easy to have this feature.

We’ll encapsulate the logic of previewing in a Previewer struct.

pub struct Previewer {
    pub tev: TevClient,
    pub film_size: (usize, usize),
    pub last_update: Instant,
    pub tile_mode: bool,
}

Tev supports 6 operations through TCP control. While tev_client implemented only 5 of them, we’re only going to use two of them, i.e., CreateImage and UpdateImage.

Here’s the explanation of CreateImage copied from tev's README file:

Creates a blank image with a specified name, size, and set of channels. If an image with the specified name already exists, it is overwritten.

It’s pretty clear, right? The only problem is why we need to pass the set of channels? Well, tev is designed to support a large range of image formats and hopes to be universal that can handle any image layout, so we need pass the channels used in the image and tev will then create image for that image. What we’re using is RGB channels. The process of connecting to tev and create a blank image for us is encapsulated in a function:

impl Previewer {
    pub fn create(film_size: (usize, usize), tile_mode: bool) -> Option<Self> {
        if let Ok(tev) = Self::connect_to_tev(film_size) {
            Some(Self {
                tev,
                film_size,
                last_update: Instant::now(),
                tile_mode,
            })
        } else {
            log::warn!("Failed to open preview. Please check whether `tev` is opened and listening default port.");
            None
        }
    }
}

And the explanation of UpdateImage in tev's README file is:

Updates the pixels in a rectangular region.

This explanation is not as good as the above one (in my mind). The key point is that this operation update a rectangle region, which is just what we need.

In tev_client this operation corresponds to PacketUpdateImage:

pub struct PacketUpdateImage<'a, S: AsRef<str> + 'a> {
    pub image_name: &'a str,
    pub grab_focus: bool,
1    pub channel_names: &'a [S],
2    pub channel_offsets: &'a [u64],
    pub channel_strides: &'a [u64],
3    pub x: u32,
    pub y: u32,
    pub width: u32,
    pub height: u32,
    pub data: &'a [f32],
}
1
Usually this channel name should be same as the channel name we used to difine the image. However, it seems that tev can update only part channels of the area, but I haven’t give it a try. If you’re interested in it, try it and welcome to told me to modify this note.
2
Whether the channel_names is same as channel_names in PacketCreateImage or not, the size of channel_offsets and channel_strides should be same as the size of channel_names, and data at same places are corresponding to each other. They’re actually obvious to experienced users, but not to new graphic programmers like me, so to prevent the case you also don’t understand, I’ll explain them immediately.
3
x and y is image top left corner, and width and height is the size of the rectangle we’re going to update.

These three parameters, channel_names, channel_offsets and channel_strides, is used to determine how tev explains the data we send to it, i.e., the layout of the image. For channel channel_name[i], tev will try to find the ith data at channel_offsets[i] + i * channel_strides[i]. For example, if there’s \(c\) pixels in total you store RGB color values as: [r, g, b, r, g, b, ...], the array should meet the size \(c \times 3\), and you can immediately see that channel_offsets should be [0, 1, 2] and channel_strides is [3, 3, 3]. But if you store by channels, e.g., [r, r, .., g, g, .., b, b, ..], the channel_offsets will be [0, 0, 0] and channel_strides is [1, 2, 3]. We’ll be using the former way. So our implementation of update_tile is:

impl Previewer {
    // ...

    pub fn update_tile(
        &mut self,
        (x, y): (usize, usize),
        (w, h): (usize, usize),
        film: &Film,
    ) -> io::Result<()> {
        self.tev.send(PacketUpdateImage {
            image_name: "Gleam Preview",
            grab_focus: false,
            channel_names: &["R", "G", "B"],
            channel_offsets: &[0, 1, 2],
            channel_strides: &[3, 3, 3],
            x: x as u32,
            y: y as u32,
            width: w as u32,
            height: h as u32,
            data: &film
                .slice(s![x..(x + w), y..(y + h)])
                .columns()
                .into_iter()
                .flat_map(|c| {
                    c.iter()
                        .flat_map(|c| [c.red, c.green, c.blue])
                        .collect_vec()
                })
1                .collect_vec(),
        })
    }
}
1
This huge conversion is to transform a rectangle subview of the Film into a Vec<f32>, which can be coerced into &[f32] when we reference it. The layout of our image is stated above, and the flat_map can transform a iterator of iterator into a iterator of the element type of the inner iterator. So to flatten the whole rectangle, we need to flatten by rows of the rectangle since tec uses a row-major indexes to fetch the data. But why we uses .columns()? That’s a problem from ndarray. It uses a ‘big endian’ layout to store the data, so the rows in our mind is actually store as columns!
flat_map VS map

Add a figure explain the difference between flat_map and map here.

Other update functions can be implemented easily using update_tile. And the timed_update is just a wrapper of update_whole with a time step:

impl Previewer {
    // ...

    pub fn timed_update(&mut self, film: &Film) -> io::Result<()> {
1        if !self.tile_mode && self.last_update.elapsed().as_secs_f32() >= 1.5 {
            // Update the whole film
            self.last_update = Instant::now();
            self.update_whole(film)?;
        }

        Ok(())
    }

    // ...
}
1
The time_update will actually update the whole image only if tiled-rendering is disabled, to avoid the unnecessary cost. This way you can just call the time_update function without checking the tiled rendering outside.

Add the preview logic to where we also update the progress bar and now you can have the (almost) real-time preview.

Error handling!

Our argument passing and error handling is a huge mess! And it’ll be worse in later chapters. However, don’t worry. Since the target of Part I is to build just an MVP, we’ll re-organize and improve our codes at the end of Part I.