The elephant in the room, Part 2: Using C++ from Rust
In The elephant in the room, Part 1: Rust interop with C++ I looked into how one can integrate Rust into an existing C++ project by writing Rust code and exposing a simple C API that can be called from C++. All you needed to do was compile your crate as a shared library and then link it in your C++ binary.
In this exciting new installment I'll instead explore the other direction: how to call functions from a C++ library inside your Rust crate. Follow me into one more 🦀-shaped hole, where we'll try to use OpenImageIO (the famous C++ library) from Rust..
A wor(l)d of advice
I'm not a Rust expert, and I'm definitely not a C++ or C expert. I'm just a Rustacean having fun. My knowledge of C++ comes mostly from hacking around in OpenFrameworks, so that should say it all.
Goals
The main goal of this post is very practical: being able to write a command line tool fully in Rust while leveraging the extensive OpenImageIO APIs in order to perform operations on image formats common inside VFX.
The main audience, thus, is folks working on CG/VFX Pipelines.
Without going into too much detail, let's say we have a C++ function that performs some operation on a series of image. For example, compositing a number of tiles 'over' each other.
Something like this:
void composite_tiles(uint8_t num_tiles, const std::string &root_dir) {
ImageBuf final_image = load_tile_at_index(0, root_dir);
for (int i = 1; i < num_tiles; i++) {
ImageBuf current_tile = load_tile_at_index(i, root_dir);
final_image = ImageBufAlgo::over(final_image, current_tile);
}
std::string final_img_path = fmt::format("{}/final.exr", root_dir);
final_image.write(final_img_path, TypeDesc::UNKNOWN);
}
Let ignore the implementation of load_tile_at_index(), since that's not the goal here.
What we want to do is be able to expose assemble_tiles() to the Rust side.
To do that, this time I'll use a different tool: Cxx! Their tutorial is easy to follow, so I recommend giving it a read: https://cxx.rs/tutorial.html.
Compared to their guide, in this article I want to document something that feels a bit more 'production' oriented and less like tutorial code.
Rust
To get started on the Rust side of things, let's create the cargo workspace using
cargo new --bin tile-assembler
Inside there we'll host the header file for the C++ code in an include dir, and the actual .cpp into the same src directory of main.rs, like this:
cd tile-assembler
mkdir include && touch include/tile-assembly.h src/tile-assembly.cpp
We'll also add CXX as a dependency, and since I'll be writing code that requires std=c++20 I'll enable the c++20 feature:
cargo add cxx --features c++20
The content of include/tile-assembly.h will be the following:
#pragma once
#include <cstdint>
#include <string>
void assemble_tiles(uint8_t num_tiles);
I'll provide the full .cpp implementation of assemble_tiles() later, for now we don't need it.
Now, let's tell CXX that we want to use assemble_tiles from our Rust code. We'll do this by writing this bit of code inside the ffi module decorated with the #[cxx::bridge] macro.
So inside src/main.rs, let's add this snippet:
#[cxx::bridge]
mod ffi {
unsafe extern "C++" {
include!("tile-assembler/include/tile-assembly.h");
fn assemble_tiles(num_tiles: u8);
}
}
We have our main() still hosting the default code, like this:
fn main() {
println!("Hello, world!");
}
If we try to run things at this stage, we should find that nothing has exploded yet:
$ cargo run
warning: function `assemble_tiles` is never used
--> src/main.rs:6:12
|
6 | fn assemble_tiles(num_tiles: u8);
| ^^^^^^^^^^^^^^
|
= note: `#[warn(dead_code)]` on by default
warning: `tile-assembler` (bin "tile-assembler") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
Running `target/debug/tile-assembler`
Hello, world!
Cool, so now lets try to actually call this function.
We'll call it from main and pass the right arguments. So let's add this to src/main.rs :
fn main() {
let num_tiles = 16;
ffi::assemble_tiles(num_tiles);
}
Let's run this again:
$ cargo run
Compiling tile-assembler v0.1.0 (/tmp/follow-along-oiio-article/tile-assembler)
error: linking with `cc` failed: exit status: 1
|
= note: "cc" "-m64" "/tmp/rustcAZEsoH/symbols.o"
[...a plethora of other very long errors...]
/tmp/follow-along-oiio-article/tile-assembler/src/main.rs:7:(.text._ZN14tile_assembler3ffi14assemble_tiles17hff8aa7cd118f5f13E+0xd): undefined reference to `cxxbridge1$assemble_tiles'
collect2: error: ld returned 1 exit status
= note: some `extern` functions couldn't be found; some native libraries may need to be installed or have their path specified
= note: use the `-l` flag to specify native libraries to link
= note: use the `cargo:rustc-link-lib` directive to specify the native libraries to link with Cargo (see https://doc.rust-lang.org/cargo/reference/build-scripts.html#rustc-link-lib)
Cool cool cool. The linking is failing, and that seems reasonable, we haven't really told CXX which C++ file to pull, so let's do that.
We'll use cxx_build for the task. We can use --build since this will only be a build time dependency:
cargo add --build cxx_build
Then, we'll create a build.rs file at the root of our crate, with this content:
fn main() {
cxx_build::bridge("src/main.rs")
.file("src/tile-assembly.cpp")
.std("c++20")
.compile("tile-assembler");
println!("cargo:rerun-if-changed=src/tile-assembly.cpp");
println!("cargo:rerun-if-changed=include/tile-assembly.h");
}
So far, nothing crazy. We told cxx how to find the .cpp file, and I've required support for C++20.
We also told cargo to re-build the project if the tile-assembly.h or tile-assembly.cpp files get updated, even if didn't update our main.rs.
Finally, we'll add some initial placeholder code for our assemble_tiles() function.
So, inside src/tile-assembly.cpp, let's add these lines:
#include <format>
#include <iostream>
#include <stdint.h>
#include "../include/tile-assembly.h"
void assemble_tiles(uint8_t num_tiles) {
std::cerr << "hello from C++!" << std::endl;
std::cerr << std::format("we'll assemble {}", num_tiles) << " tiles"
<< std::endl;
}
Again, for now the implementation is mostly a stub, we'll write something useful once we have confirmed that all the piece of the puzzle are in place.
Let's see what happens when we run it:
$ cargo run
Compiling tile-assembler v0.1.0 (/tmp/follow-along-oiio-article/tile-assembler)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.83s
Running `target/debug/tile-assembler`
hello from C++!
we'll assemble 16 tiles
Yay! We've done the first steps to successfully call a C++ function and pass an argument to it.
Getting real
Now let's try to do some actual useful work.
First, let's open up src/tile-assembly.cpp and fill it up with code that looks like it could be something you would do in production. This is the point where we'll actually use OpenImageIO and pull in the APIs that we need.
#include <format>
#include <iostream>
#include <ostream>
#include <ranges>
#include <span>
#include <stdint.h>
#include <vector>
#include "OpenImageIO/imagebuf.h"
#include "OpenImageIO/imagebufalgo.h"
#include "OpenImageIO/imageio.h"
#include "OpenImageIO/typedesc.h"
#include "../include/tile-assembly.h"
using namespace OIIO;
std::string get_current_tile_path(int index, std::string root_dir) {
// Assume that the logic here is not this simple, etc.
std::string tile_num = fmt::format("{:02}", index);
std::string tile_file_name =
fmt::format("{}/test-tiled_tile{}_.exr", root_dir, tile_num);
return tile_file_name;
}
ImageBuf load_tile_at_index(int index, std::string root_dir) {
std::string tile_path = get_current_tile_path(index, root_dir);
std::cerr << "Loading tile at " << index << fmt::format(" ({})", tile_path)
<< std::endl;
auto image = OIIO::ImageBuf(tile_path);
if (image.has_error()) {
auto error = OIIO::geterror();
std::cerr << fmt::format("Failed to read tile at '{}': {}", tile_path,
error)
<< std::endl;
// TODO: Assume that this raises an exception, or something like that
}
return image;
}
void assemble_tiles(uint8_t num_tiles, const std::string &root_dir) {
// Load the first tile
ImageBuf final_image = load_tile_at_index(0, root_dir);
// Then comp all the other ones in 'over'
for (int i = 1; i < num_tiles; i++) {
auto current_tile = load_tile_at_index(i, root_dir);
final_image = ImageBufAlgo::over(final_image, current_tile);
}
std::string final_img_path = fmt::format("{}/assembled.exr", root_dir);
final_image.write(final_img_path, TypeDesc::UNKNOWN);
// TODO: Imagine that we have better error handling
}
Cool, while this^ is not production code, it does look more like something that you can hack on.
Note that I've updated the signature to include a new argument (root_dir), so we'll update the header to match:
diff --git a/include/tile-assembly.h b/include/tile-assembly.h
index 7f53fb7..f8a7228 100644
--- a/include/tile-assembly.h
+++ b/include/tile-assembly.h
@@ -2,4 +2,4 @@
#include <cstdint>
#include <string>
-void assemble_tiles(uint8_t num_tiles);
+void assemble_tiles(uint8_t num_tiles, const std::string &root_dir);
We'll also have to update src/main.rs :
diff --git a/src/main.rs b/src/main.rs
index f772491..d1d6be0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,13 +1,24 @@
+use cxx::let_cxx_string;
+use std::path::PathBuf;
+
#[cxx::bridge]
mod ffi {
unsafe extern "C++" {
include!("tile-assembler/include/tile-assembly.h");
- fn assemble_tiles(num_tiles: u8);
+ fn assemble_tiles(num_tiles: u8, root_dir: &CxxString);
}
}
fn main() {
let num_tiles = 16;
- ffi::assemble_tiles(num_tiles);
+ let root_dir = PathBuf::from("/assume/this/is/a/valid/path/to/some/tiles");
+
+ // Using the `let_cxx_string` macro is the official way to construct a CxxString.
+ // This allows us to pass a 'std::string' to the C++ side.
+ // See: https://cxx.rs/binding/cxxstring.html
+ let_cxx_string!(root_dir = root_dir.display().to_string());
+
+ ffi::assemble_tiles(num_tiles, &root_dir);
}
If we try to cargo run this now, things won't work, because we haven't told CXX how to find OpenImageIO.
$ cargo run
Compiling tile-assembler v0.1.0 (/tmp/follow-along-oiio-article/tile-assembler)
warning: tile-assembler@0.1.0: src/tile-assembly.cpp:9:10: fatal error: OpenImageIO/imagebuf.h: No such file or directory
warning: tile-assembler@0.1.0: 9 | #include "OpenImageIO/imagebuf.h"
warning: tile-assembler@0.1.0: | ^~~~~~~~~~~~~~~~~~~~~~~~
warning: tile-assembler@0.1.0: compilation terminated.
[...redacted, for brevity...]
--- stdout
[...redacted, for brevity...]
--- stderr
[...redacted, for brevity...]
In order to successfully link against OpenImageIO, we'll need to first build it. So let's tackle that.
I'll make a dedicated directory inside the project, called vendor, where we'll temporarily host the build.
I won't go into details since you'll find better docs on https://github.com/AcademySoftwareFoundation/OpenImageIO.
Go pour yourself a cup of tea now, and if you're lucky after a while you should see that OpenImageIO finished building successfully (or at least, things worked fine on my machine™, using cmake 3.28.3)
$ mkdir vendor && cd $_
$ git clone git@github.com:AcademySoftwareFoundation/OpenImageIO.git
$ cd OpenImageIO && git checkout v3.1.7.0
$ mkdir build && cd build
$ cmake -DOpenImageIO_BUILD_MISSING_DEPS=all ..
[...]
-- Configuring done (220.7s)
-- Generating done (0.0s)
-- Build files have been written to: /tmp/follow-along-oiio-article/tile-assembler/vendor/OpenImageIO/build
$ make -j
[..redacted]
[ 98%] Built target oiiotool
[100%] Linking CXX executable ../../bin/simd_test
[100%] Built target simd_test
Now that we have a build of OIIO that we can link against, let's update the build.rs to point to it:
diff --git a/build.rs b/build.rs
index 1821fe3..af4e320 100644
--- a/build.rs
+++ b/build.rs
@@ -1,9 +1,25 @@
+use std::path::PathBuf;
+
fn main() {
+ let root_dir = std::env::var_os("CARGO_MANIFEST_DIR")
+ .map(|v| PathBuf::from(v))
+ .expect("CARGO_MANIFEST_DIR was somehow not set");
+
+ let openimageio_vendor_dir = root_dir.join("vendor").join("OpenImageIO");
+ let openimageio_lib_dir = openimageio_vendor_dir.join("build/lib");
+
+ // See:
+ // - https://doc.rust-lang.org/rustc/command-line-arguments.html#option-l-search-path
+ // - https://doc.rust-lang.org/cargo/reference/build-scripts.html#rustc-link-search
cxx_build::bridge("src/main.rs")
.file("src/tile-assembly.cpp")
.std("c++20")
+ .include(openimageio_vendor_dir.join("src/include"))
+ .include(openimageio_vendor_dir.join("build/include"))
.compile("tile-assembler");
- println!("cargo:rerun-if-changed=src/tile-assembly.cpp");
- println!("cargo:rerun-if-changed=include/tile-assembly.h");
+ println!("cargo:rerun-if-change=src/tile-assembly.cpp");
+ println!("cargo:rerun-if-change=include/tile-assembly.h");
+ println!("cargo:rustc-link-search={}", openimageio_lib_dir.display());
+ println!("cargo:rustc-link-lib=OpenImageIO",);
}
Let's try to run this again, and see what happens:
$ cargo run
warning: tile-assembler@0.1.0: In file included from /tmp/follow-along-oiio-article/tile-assembler/vendor/OpenImageIO/src/include/OpenImageIO/imageio.h:35,
warning: tile-assembler@0.1.0: from /tmp/follow-along-oiio-article/tile-assembler/vendor/OpenImageIO/src/include/OpenImageIO/imagebuf.h:17,
[..redacted, for brevity..]
warning: tile-assembler@0.1.0: | ~~~~~~~~~~~~^~~~
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
Running `target/debug/tile-assembler`
target/debug/tile-assembler: error while loading shared libraries: libOpenImageIO.so.3.1: cannot open shared object file: No such file or directory
So, it looks we can build things correctly, but then at runtime the CLI fails to load libOpenImageIO.so.3.1.
This is disappointing, but makes sense: we didn't really install OpenImageIO on this system, and its current location is not in the default search path.
We can see that via ldd too:
$ ldd target/debug/tile-assembler
linux-vdso.so.1 (0x0000714029ef4000)
libOpenImageIO.so.3.1 => not found
libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x0000714029a00000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x0000714029e06000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000714029600000)
/lib64/ld-linux-x86-64.so.2 (0x0000714029ef6000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x0000714029d1d000)
At this point it's time to put on our Pipeline cap 🎩 .
There's definitely multiple ways to solve this problem and probably (in any Pipeline that wasn't born yesterday) you might have a default location where binaries and libraries are already installed and added to the default search paths.
So what you'd do is just use that location as the installation prefix for the OpenImageIO install and call it a day.
To KISS and keep things generic, what I'll do here instead is set the RPATH for libOpenImageIO to be relative to the $ORIGIN (if you never heard of this technique, just google it, there's a lot of resources online like this one). This allows us to avoid hardcoding any specific absolute path.
To do that, we could just run a post-build script that runs patchelf (one of my favourite utilies from NixOS!), but luckily cargo gives us a built-in way to achieve that: the .cargo/config.toml file.
We'll create it via mkdir .cargo && touch .cargo/config.toml and set it to this:
[build]
rustflags = ["-C", "link-args=-Wl,-rpath=$ORIGIN/../../vendor/OpenImageIO/build/lib"]
More info on the config.toml are available here: https://doc.rust-lang.org/cargo/reference/config.html#hierarchical-structure
What this does is allow us to pass custom flags to the linker used by rustc.
In our case, we're passing the -Wl,-rpath=$ORIGIN/../some/relative/path/to/OpenImageIO/lib flag.
Finally, we can run cargo run and, if we did our math right.. everything should work just fine:
$ cargo run
[...redacted, for brevity...]
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
Running `target/debug/tile-assembler`
Loading tile at 0 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile00_.exr)
Loading tile at 1 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile01_.exr)
Loading tile at 2 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile02_.exr)
Loading tile at 3 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile03_.exr)
Loading tile at 4 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile04_.exr)
Loading tile at 5 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile05_.exr)
Loading tile at 6 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile06_.exr)
Loading tile at 7 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile07_.exr)
Loading tile at 8 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile08_.exr)
Loading tile at 9 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile09_.exr)
Loading tile at 10 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile10_.exr)
Loading tile at 11 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile11_.exr)
Loading tile at 12 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile12_.exr)
Loading tile at 13 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile13_.exr)
Loading tile at 14 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile14_.exr)
Loading tile at 15 (/home/vv/Documents/3d/houdini/tiled-render-tests/render/test-scene-v003/test-tiled_tile15_.exr)
We can confirm that all the libraries are linked correctly by looking at the output of ldd again:
$ ldd target/debug/tile-assembler
linux-vdso.so.1 (0x0000760b6a3d4000)
libOpenImageIO.so.3.1 => /tmp/follow-along-oiio-article/tile-assembler/target/debug/../../vendor/OpenImageIO/build/lib/libOpenImageIO.so.3.1 (0x0000760b69200000)
libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x0000760b68e00000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x0000760b6a2e6000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000760b68a00000)
/lib64/ld-linux-x86-64.so.2 (0x0000760b6a3d6000)
libOpenImageIO_Util.so.3.1 => /tmp/follow-along-oiio-article/tile-assembler/vendor/OpenImageIO/build/lib/libOpenImageIO_Util.so.3.1 (0x0000760b6a1b5000)
libgif.so.7 => /lib/x86_64-linux-gnu/libgif.so.7 (0x0000760b6a1aa000)
libheif.so.1 => /usr/local/lib/libheif.so.1 (0x0000760b68600000)
libpng16.so.16 => /lib/x86_64-linux-gnu/libpng16.so.16 (0x0000760b691c8000)
libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x0000760b691ac000)
libopenjp2.so.7 => /lib/x86_64-linux-gnu/libopenjp2.so.7 (0x0000760b6914b000)
libOpenEXR-3_1.so.30 => /lib/x86_64-linux-gnu/libOpenEXR-3_1.so.30 (0x0000760b68200000)
libOpenEXRCore-3_1.so.30 => /lib/x86_64-linux-gnu/libOpenEXRCore-3_1.so.30 (0x0000760b690d0000)
libtiff.so.6 => /lib/x86_64-linux-gnu/libtiff.so.6 (0x0000760b68d73000)
libwebpdemux.so.2 => /lib/x86_64-linux-gnu/libwebpdemux.so.2 (0x0000760b6a1a1000)
libwebpmux.so.3 => /lib/x86_64-linux-gnu/libwebpmux.so.3 (0x0000760b6a193000)
libfreetype.so.6 => /lib/x86_64-linux-gnu/libfreetype.so.6 (0x0000760b68ca7000)
libIex-3_1.so.30 => /lib/x86_64-linux-gnu/libIex-3_1.so.30 (0x0000760b68c24000)
libwebp.so.7 => /lib/x86_64-linux-gnu/libwebp.so.7 (0x0000760b68985000)
libImath-3_1.so.29 => /lib/x86_64-linux-gnu/libImath-3_1.so.29 (0x0000760b69082000)
libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 (0x0000760b68959000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x0000760b68517000)
libIlmThread-3_1.so.30 => /lib/x86_64-linux-gnu/libIlmThread-3_1.so.30 (0x0000760b68c1a000)
libzstd.so.1 => /lib/x86_64-linux-gnu/libzstd.so.1 (0x0000760b68146000)
liblzma.so.5 => /lib/x86_64-linux-gnu/liblzma.so.5 (0x0000760b68927000)
libLerc.so.4 => /lib/x86_64-linux-gnu/libLerc.so.4 (0x0000760b680bf000)
libjbig.so.0 => /lib/x86_64-linux-gnu/libjbig.so.0 (0x0000760b68917000)
libjpeg.so.8 => /lib/x86_64-linux-gnu/libjpeg.so.8 (0x0000760b6803c000)
libdeflate.so.0 => /lib/x86_64-linux-gnu/libdeflate.so.0 (0x0000760b68904000)
libbz2.so.1.0 => /lib/x86_64-linux-gnu/libbz2.so.1.0 (0x0000760b68028000)
libbrotlidec.so.1 => /lib/x86_64-linux-gnu/libbrotlidec.so.1 (0x0000760b688f6000)
libsharpyuv.so.0 => /lib/x86_64-linux-gnu/libsharpyuv.so.0 (0x0000760b68c12000)
libbrotlicommon.so.1 => /lib/x86_64-linux-gnu/libbrotlicommon.so.1 (0x0000760b68005000)
If you have patchelf installed, we can also easily see the rpath set to our desired value:
$ patchelf --print-rpath target/debug/tile-assembler
$ORIGIN/../../vendor/OpenImageIO/build/lib
And that'all for today, folks!
2 years ago I thought that this would have been impossible to achieve with such little effort, but it looks that the Rust ecosystem is truly something that keeps positively surprising me. Yay for Ferris! 🦀