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.
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 enablerayon
.preview
: Enable real-time preview withtev
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 bothparallel
andtiling
fast-preview
: A short-hand flag that enablesparallel
andtiling
, and setspp
to4
,max-ray-depth
to4
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 formatclap
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 aschannel_names
inPacketCreateImage
or not, the size ofchannel_offsets
andchannel_strides
should be same as the size ofchannel_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
andy
is image top left corner, andwidth
andheight
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 aVec<f32>
, which can be coerced into&[f32]
when we reference it. The layout of our image is stated above, and theflat_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 sincetec
uses a row-major indexes to fetch the data. But why we uses.columns()
? That’s a problem fromndarray
. 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 thetime_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.
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.