diff --git a/include/neural-graphics-primitives/camera_path.h b/include/neural-graphics-primitives/camera_path.h
index 492cd8385a6998bd5a7c45cf9078abd7289857d1..856087f823cab1599c827177aa8406e6c12775fb 100644
--- a/include/neural-graphics-primitives/camera_path.h
+++ b/include/neural-graphics-primitives/camera_path.h
@@ -126,8 +126,8 @@ struct CameraPath {
 		return spline(t-floorf(t), get_keyframe(t1-1), get_keyframe(t1), get_keyframe(t1+1), get_keyframe(t1+2));
 	}
 
-	void save(const std::string& filepath_string);
-	void load(const std::string& filepath_string, const Eigen::Matrix<float, 3, 4> &first_xform);
+	void save(const fs::path& path);
+	void load(const fs::path& path, const Eigen::Matrix<float, 3, 4> &first_xform);
 
 #ifdef NGP_GUI
 	ImGuizmo::MODE m_gizmo_mode = ImGuizmo::LOCAL;
diff --git a/include/neural-graphics-primitives/common.h b/include/neural-graphics-primitives/common.h
index 6e2b1e4b3c6c23603f032f5b077ca8b1be6ad881..8d3f6068b8462f29fedaa4837f3596d5187d1993 100644
--- a/include/neural-graphics-primitives/common.h
+++ b/include/neural-graphics-primitives/common.h
@@ -63,10 +63,20 @@
 
 NGP_NAMESPACE_BEGIN
 
+namespace fs = filesystem;
+
 bool is_wsl();
 
-filesystem::path get_executable_dir();
-filesystem::path get_root_dir();
+fs::path get_executable_dir();
+fs::path get_root_dir();
+
+#ifdef _WIN32
+std::string utf16_to_utf8(const std::wstring& utf16);
+std::wstring utf8_to_utf16(const std::string& utf16);
+std::wstring native_string(const fs::path& path);
+#else
+std::string native_string(const fs::path& path);
+#endif
 
 bool ends_with(const std::string& str, const std::string& ending);
 bool ends_with_case_insensitive(const std::string& str, const std::string& ending);
@@ -333,4 +343,12 @@ private:
 	std::chrono::time_point<std::chrono::steady_clock> m_creation_time;
 };
 
+uint8_t* load_stbi(const fs::path& path, int* width, int* height, int* comp, int req_comp);
+float* load_stbi_float(const fs::path& path, int* width, int* height, int* comp, int req_comp);
+uint16_t* load_stbi_16(const fs::path& path, int* width, int* height, int* comp, int req_comp);
+bool is_hdr_stbi(const fs::path& path);
+int write_stbi(const fs::path& path, int width, int height, int comp, const uint8_t* pixels, int quality = 100);
+
+FILE* native_fopen(const fs::path& path, const char* mode);
+
 NGP_NAMESPACE_END
diff --git a/include/neural-graphics-primitives/common_device.cuh b/include/neural-graphics-primitives/common_device.cuh
index 8cf8c42f570ea4fabb678b0f999dae9c0616d149..389fc3bdab7aff5925f14c5cfe88d8aebbd94e39 100644
--- a/include/neural-graphics-primitives/common_device.cuh
+++ b/include/neural-graphics-primitives/common_device.cuh
@@ -756,7 +756,7 @@ inline NGP_HOST_DEVICE float read_depth(Eigen::Vector2f pos, const Eigen::Vector
 
 Eigen::Matrix<float, 3, 4> log_space_lerp(const Eigen::Matrix<float, 3, 4>& begin, const Eigen::Matrix<float, 3, 4>& end, float t);
 
-tcnn::GPUMemory<float> load_exr(const std::string& filename, int& width, int& height);
-tcnn::GPUMemory<float> load_stbi(const std::string& filename, int& width, int& height);
+tcnn::GPUMemory<float> load_exr_gpu(const fs::path& path, int* width, int* height);
+tcnn::GPUMemory<float> load_stbi_gpu(const fs::path& path, int* width, int* height);
 
 NGP_NAMESPACE_END
diff --git a/include/neural-graphics-primitives/marching_cubes.h b/include/neural-graphics-primitives/marching_cubes.h
index d09511b7cc0e8074b7194984a0f7f67798f54624..ceb7481a89bda254808af848992123a92379219d 100644
--- a/include/neural-graphics-primitives/marching_cubes.h
+++ b/include/neural-graphics-primitives/marching_cubes.h
@@ -46,7 +46,7 @@ void save_mesh(
 	tcnn::GPUMemory<Eigen::Vector3f>& normals,
 	tcnn::GPUMemory<Eigen::Vector3f>& colors,
 	tcnn::GPUMemory<uint32_t>& indices,
-	const char* outputname,
+	const fs::path& path,
 	bool unwrap_it,
 	float nerf_scale,
 	Eigen::Vector3f nerf_offset
@@ -70,10 +70,10 @@ uint32_t compile_shader(bool pixel, const char* code);
 bool check_shader(uint32_t handle, const char* desc, bool program);
 #endif
 
-void save_density_grid_to_png(const tcnn::GPUMemory<float>& density, const char* filename, Eigen::Vector3i res3d, float thresh, bool swap_y_z = true, float density_range = 4.f);
+void save_density_grid_to_png(const tcnn::GPUMemory<float>& density, const fs::path& path, Eigen::Vector3i res3d, float thresh, bool swap_y_z = true, float density_range = 4.f);
 
-void save_rgba_grid_to_png_sequence(const tcnn::GPUMemory<Eigen::Array4f>& rgba, const char *path, Eigen::Vector3i res3d, bool swap_y_z = true);
+void save_rgba_grid_to_png_sequence(const tcnn::GPUMemory<Eigen::Array4f>& rgba, const fs::path& path, Eigen::Vector3i res3d, bool swap_y_z = true);
 
-void save_rgba_grid_to_raw_file(const tcnn::GPUMemory<Eigen::Array4f>& rgba, const char* path, Eigen::Vector3i res3d, bool swap_y_z, int cascade);
+void save_rgba_grid_to_raw_file(const tcnn::GPUMemory<Eigen::Array4f>& rgba, const fs::path& path, Eigen::Vector3i res3d, bool swap_y_z, int cascade);
 
 NGP_NAMESPACE_END
diff --git a/include/neural-graphics-primitives/nerf_loader.h b/include/neural-graphics-primitives/nerf_loader.h
index 426d99bce841b7faa64995a41dbb611245867522..61ae9eedf34d441d59ecb1fa1faaf9f51355804f 100644
--- a/include/neural-graphics-primitives/nerf_loader.h
+++ b/include/neural-graphics-primitives/nerf_loader.h
@@ -180,7 +180,7 @@ struct NerfDataset {
 	}
 };
 
-NerfDataset load_nerf(const std::vector<filesystem::path>& jsonpaths, float sharpen_amount = 0.f);
+NerfDataset load_nerf(const std::vector<fs::path>& jsonpaths, float sharpen_amount = 0.f);
 NerfDataset create_empty_nerf_dataset(size_t n_images, int aabb_scale = 1, bool is_hdr = false);
 
 NGP_NAMESPACE_END
diff --git a/include/neural-graphics-primitives/render_buffer.h b/include/neural-graphics-primitives/render_buffer.h
index 73466ff3008d78863dea9a075240b5907d2d8552..2e29f72a6fd896c815645fa81273bbf20e219ff2 100644
--- a/include/neural-graphics-primitives/render_buffer.h
+++ b/include/neural-graphics-primitives/render_buffer.h
@@ -105,7 +105,7 @@ public:
 
 	bool is_8bit() { return m_is_8bit; }
 
-	void load(const char* fname);
+	void load(const fs::path& path);
 
 	void load(const float* data, Eigen::Vector2i new_size, int n_channels);
 
diff --git a/include/neural-graphics-primitives/testbed.h b/include/neural-graphics-primitives/testbed.h
index c766a84a19521949b58e2030113c7aa0d52eab06..e6db007b6ce0c39437b4ac51ff1820566c368ed3 100644
--- a/include/neural-graphics-primitives/testbed.h
+++ b/include/neural-graphics-primitives/testbed.h
@@ -31,8 +31,6 @@
 
 #include <json/json.hpp>
 
-#include <filesystem/path.h>
-
 #ifdef NGP_PYTHON
 #  include <pybind11/pybind11.h>
 #  include <pybind11/numpy.h>
@@ -64,13 +62,14 @@ public:
 	EIGEN_MAKE_ALIGNED_OPERATOR_NEW
 	Testbed(ETestbedMode mode = ETestbedMode::None);
 	~Testbed();
-	Testbed(ETestbedMode mode, const std::string& data_path) : Testbed(mode) { load_training_data(data_path); }
-	Testbed(ETestbedMode mode, const std::string& data_path, const std::string& network_config_path) : Testbed(mode, data_path) { reload_network_from_file(network_config_path); }
-	Testbed(ETestbedMode mode, const std::string& data_path, const nlohmann::json& network_config) : Testbed(mode, data_path) { reload_network_from_json(network_config); }
+
+	Testbed(ETestbedMode mode, const fs::path& data_path) : Testbed(mode) { load_training_data(data_path); }
+	Testbed(ETestbedMode mode, const fs::path& data_path, const fs::path& network_config_path) : Testbed(mode, data_path) { reload_network_from_file(network_config_path); }
+	Testbed(ETestbedMode mode, const fs::path& data_path, const nlohmann::json& network_config) : Testbed(mode, data_path) { reload_network_from_json(network_config); }
 
 	bool clear_tmp_dir();
 	void update_imgui_paths();
-	void load_training_data(const std::string& data_path);
+	void load_training_data(const fs::path& path);
 	void reload_training_data();
 	void clear_training_data();
 
@@ -274,7 +273,7 @@ public:
 	);
 	void train_volume(size_t target_batch_size, bool get_loss_scalar, cudaStream_t stream);
 	void training_prep_volume(uint32_t batch_size, cudaStream_t stream) {}
-	void load_volume(const filesystem::path& data_path);
+	void load_volume(const fs::path& data_path);
 
 	void render_sdf(
 		const distance_fun_t& distance_function,
@@ -291,9 +290,9 @@ public:
 	void render_image(CudaRenderBuffer& render_buffer, cudaStream_t stream);
 	void render_frame(const Eigen::Matrix<float, 3, 4>& camera_matrix0, const Eigen::Matrix<float, 3, 4>& camera_matrix1, const Eigen::Vector4f& nerf_rolling_shutter, CudaRenderBuffer& render_buffer, bool to_srgb = true) ;
 	void visualize_nerf_cameras(ImDrawList* list, const Eigen::Matrix<float, 4, 4>& world2proj);
-	filesystem::path find_network_config(const filesystem::path& network_config_path);
-	nlohmann::json load_network_config(const filesystem::path& network_config_path);
-	void reload_network_from_file(const std::string& network_config_path = "");
+	fs::path find_network_config(const fs::path& network_config_path);
+	nlohmann::json load_network_config(const fs::path& network_config_path);
+	void reload_network_from_file(const fs::path& path = "");
 	void reload_network_from_json(const nlohmann::json& json, const std::string& config_base_path=""); // config_base_path is needed so that if the passed in json uses the 'parent' feature, we know where to look... be sure to use a filename, or if a directory, end with a trailing slash
 	void reset_accumulation(bool due_to_camera_movement = false, bool immediate_redraw = true);
 	void redraw_next_frame() {
@@ -303,9 +302,9 @@ public:
 	static ELossType string_to_loss_type(const std::string& str);
 	void reset_network(bool clear_density_grid = true);
 	void create_empty_nerf_dataset(size_t n_images, int aabb_scale = 1, bool is_hdr = false);
-	void load_nerf(const filesystem::path& data_path);
+	void load_nerf(const fs::path& data_path);
 	void load_nerf_post();
-	void load_mesh(const filesystem::path& data_path);
+	void load_mesh(const fs::path& data_path);
 	void set_exposure(float exposure) { m_exposure = exposure; }
 	void set_max_level(float maxlevel);
 	void set_min_level(float minlevel);
@@ -314,7 +313,7 @@ public:
 	void translate_camera(const Eigen::Vector3f& rel);
 	void mouse_drag(const Eigen::Vector2f& rel, int button);
 	void mouse_wheel(Eigen::Vector2f m, float delta);
-	void load_file(const std::string& file);
+	void load_file(const fs::path& path);
 	void set_nerf_camera_matrix(const Eigen::Matrix<float, 3, 4>& cam);
 	Eigen::Vector3f look_at() const;
 	void set_look_at(const Eigen::Vector3f& pos);
@@ -385,7 +384,6 @@ public:
 #ifdef NGP_PYTHON
 	pybind11::dict compute_marching_cubes_mesh(Eigen::Vector3i res3d = Eigen::Vector3i::Constant(128), BoundingBox aabb = BoundingBox{Eigen::Vector3f::Zero(), Eigen::Vector3f::Ones()}, float thresh=2.5f);
 	pybind11::array_t<float> render_to_cpu(int width, int height, int spp, bool linear, float start_t, float end_t, float fps, float shutter_fraction);
-	pybind11::array_t<float> render_with_rolling_shutter_to_cpu(const Eigen::Matrix<float, 3, 4>& camera_transform_start, const Eigen::Matrix<float, 3, 4>& camera_transform_end, const Eigen::Vector4f& rolling_shutter, int width, int height, int spp, bool linear);
 	pybind11::array_t<float> screenshot(bool linear) const;
 	void override_sdf_training_data(pybind11::array_t<float> points, pybind11::array_t<float> distances);
 #endif
@@ -393,7 +391,7 @@ public:
 	double calculate_iou(uint32_t n_samples=128*1024*1024, float scale_existing_results_factor=0.0, bool blocking=true, bool force_use_octree = true);
 	void draw_visualizations(ImDrawList* list, const Eigen::Matrix<float, 3, 4>& camera_matrix);
 	void train_and_render(bool skip_rendering);
-	filesystem::path training_data_path() const;
+	fs::path training_data_path() const;
 	void init_window(int resw, int resh, bool hidden = false, bool second_window = false);
 	void destroy_window();
 	void apply_camera_smoothing(float elapsed_ms);
@@ -403,22 +401,22 @@ public:
 	void draw_gui();
 	bool frame();
 	bool want_repl();
-	void load_image(const filesystem::path& data_path);
-	void load_exr_image(const filesystem::path& data_path);
-	void load_stbi_image(const filesystem::path& data_path);
-	void load_binary_image(const filesystem::path& data_path);
+	void load_image(const fs::path& data_path);
+	void load_exr_image(const fs::path& data_path);
+	void load_stbi_image(const fs::path& data_path);
+	void load_binary_image(const fs::path& data_path);
 	uint32_t n_dimensions_to_visualize() const;
 	float fov() const ;
 	void set_fov(float val) ;
 	Eigen::Vector2f fov_xy() const ;
 	void set_fov_xy(const Eigen::Vector2f& val);
-	void save_snapshot(const std::string& filepath_string, bool include_optimizer_state, bool compress);
-	void load_snapshot(const std::string& filepath_string);
+	void save_snapshot(const fs::path& path, bool include_optimizer_state, bool compress);
+	void load_snapshot(const fs::path& path);
 	CameraKeyframe copy_camera_to_keyframe() const;
 	void set_camera_from_keyframe(const CameraKeyframe& k);
 	void set_camera_from_time(float t);
 	void update_loss_graph();
-	void load_camera_path(const std::string& filepath_string);
+	void load_camera_path(const fs::path& path);
 	bool loop_animation();
 	void set_loop_animation(bool value);
 
@@ -640,7 +638,7 @@ public:
 #endif
 
 			void reset_camera_extrinsics();
-			void export_camera_extrinsics(const std::string& filename, bool export_extrinsics_in_quat_format = true);
+			void export_camera_extrinsics(const fs::path& path, bool export_extrinsics_in_quat_format = true);
 		} training = {};
 
 		tcnn::GPUMemory<float> density_grid; // NERF_GRIDSIZE()^3 grid of EMA smoothed densities from the network
@@ -865,8 +863,8 @@ public:
 	bool m_train_encoding = true;
 	bool m_train_network = true;
 
-	filesystem::path m_data_path;
-	filesystem::path m_network_config_path = "base.json";
+	fs::path m_data_path;
+	fs::path m_network_config_path = "base.json";
 
 	nlohmann::json m_network_config;
 
diff --git a/include/neural-graphics-primitives/tinyexr_wrapper.h b/include/neural-graphics-primitives/tinyexr_wrapper.h
index 05adf882694c64305c0104a706bec17bdea79c92..db690368bdce1bad34ec09f51472614a45b29bff 100644
--- a/include/neural-graphics-primitives/tinyexr_wrapper.h
+++ b/include/neural-graphics-primitives/tinyexr_wrapper.h
@@ -20,8 +20,8 @@
 
 NGP_NAMESPACE_BEGIN
 
-void save_exr(const float* data, int width, int height, int nChannels, int channelStride, const char* outfilename);
-void load_exr(float** data, int* width, int* height, const char* filename);
-__half* load_exr_to_gpu(int* width, int* height, const char* filename, bool fix_premult);
+void save_exr(const float* data, int width, int height, int nChannels, int channelStride, const fs::path& path);
+void load_exr(float** data, int* width, int* height, const fs::path& path);
+__half* load_exr_to_gpu(int* width, int* height, const fs::path& path, bool fix_premult);
 
 NGP_NAMESPACE_END
diff --git a/include/neural-graphics-primitives/tinyobj_loader_wrapper.h b/include/neural-graphics-primitives/tinyobj_loader_wrapper.h
index 187617b804afe3c0da7af6a86e412f505d2debc7..cbd823263aca7d13ec5b3fc3e1cf5ac35ef3724c 100644
--- a/include/neural-graphics-primitives/tinyobj_loader_wrapper.h
+++ b/include/neural-graphics-primitives/tinyobj_loader_wrapper.h
@@ -23,6 +23,6 @@
 
 NGP_NAMESPACE_BEGIN
 
-std::vector<Eigen::Vector3f> load_obj(const std::string& filename);
+std::vector<Eigen::Vector3f> load_obj(const fs::path& path);
 
 NGP_NAMESPACE_END
diff --git a/src/camera_path.cu b/src/camera_path.cu
index 9c23a71633d71378f1346ca344ea9a11318f994a..5f3a3ff00d29b3ae936df685f2297b95ef5307c3 100644
--- a/src/camera_path.cu
+++ b/src/camera_path.cu
@@ -107,20 +107,20 @@ void from_json(bool is_first, const json& j, CameraKeyframe& p, const CameraKeyf
 }
 
 
-void CameraPath::save(const std::string& filepath_string) {
+void CameraPath::save(const fs::path& path) {
 	json j = {
 		{"loop", loop},
 		{"time", play_time},
 		{"path", keyframes},
 	};
-	std::ofstream f(filepath_string);
+	std::ofstream f(native_string(path));
 	f << j;
 }
 
-void CameraPath::load(const std::string& filepath_string, const Eigen::Matrix<float, 3, 4>& first_xform) {
-	std::ifstream f(filepath_string);
+void CameraPath::load(const fs::path& path, const Eigen::Matrix<float, 3, 4>& first_xform) {
+	std::ifstream f{native_string(path)};
 	if (!f) {
-		throw std::runtime_error{fmt::format("Camera path {} does not exist.", filepath_string)};
+		throw std::runtime_error{fmt::format("Camera path {} does not exist.", path.str())};
 	}
 
 	json j;
diff --git a/src/common.cu b/src/common.cu
index a0b43891de843397f8f7aa86d560a79fc9033d5e..b6c2ec6abdd91ad0f6c027d01ad97b9c1a5bb0cf 100644
--- a/src/common.cu
+++ b/src/common.cu
@@ -18,13 +18,39 @@
 
 #include <filesystem/path.h>
 
-#ifndef _WIN32
+#define STB_IMAGE_IMPLEMENTATION
+#define STB_IMAGE_WRITE_IMPLEMENTATION
+
+#ifdef __NVCC__
+#  ifdef __NVCC_DIAG_PRAGMA_SUPPORT__
+#    pragma nv_diag_suppress 550
+#  else
+#    pragma diag_suppress 550
+#  endif
+#endif
+#include <stb_image/stb_image.h>
+#include <stb_image/stb_image_write.h>
+#ifdef __NVCC__
+#  ifdef __NVCC_DIAG_PRAGMA_SUPPORT__
+#    pragma nv_diag_default 550
+#  else
+#    pragma diag_default 550
+#  endif
+#endif
+
+#ifdef _WIN32
+#  include <windows.h>
+#else
 #  include <unistd.h>
 #  include <linux/limits.h>
 #endif
 
+#undef min
+#undef max
+#undef near
+#undef far
+
 using namespace tcnn;
-namespace fs = filesystem;
 
 NGP_NAMESPACE_BEGIN
 
@@ -37,34 +63,61 @@ bool is_wsl() {
 		return false;
 	}
 
-	std::ifstream f{path.str()};
+	std::ifstream f{native_string(path)};
 	std::string content((std::istreambuf_iterator<char>(f)), (std::istreambuf_iterator<char>()));
 	return content.find("microsoft") != std::string::npos;
 #endif
 }
 
+#ifdef _WIN32
+std::string utf16_to_utf8(const std::wstring& utf16) {
+	std::string utf8;
+	if (!utf16.empty()) {
+		int size = WideCharToMultiByte(CP_UTF8, 0, &utf16[0], (int)utf16.size(), NULL, 0, NULL, NULL);
+		utf8.resize(size, 0);
+		WideCharToMultiByte(CP_UTF8, 0, &utf16[0], (int)utf16.size(), &utf8[0], size, NULL, NULL);
+	}
+	return utf8;
+}
+
+std::wstring utf8_to_utf16(const std::string& utf8) {
+	std::wstring utf16;
+	if (!utf8.empty()) {
+		int size = MultiByteToWideChar(CP_UTF8, 0, &utf8[0], (int)utf8.size(), NULL, 0);
+		utf16.resize(size, 0);
+		MultiByteToWideChar(CP_UTF8, 0, &utf8[0], (int)utf8.size(), &utf16[0], size);
+	}
+	return utf16;
+}
+
+std::wstring native_string(const fs::path& path) { return path.wstr(); }
+#else
+std::string native_string(const fs::path& path) { return path.str(); }
+#endif
+
 fs::path get_executable_dir() {
 #ifdef _WIN32
-	WCHAR path[MAX_PATH];
-	if (GetModuleFileNameW(NULL, path, MAX_PATH) == 0) {
+	WCHAR path[1024];
+	if (GetModuleFileNameW(NULL, path, 1024) == 0) {
 		return ".";
 	}
+	return fs::path{std::wstring{path}}.parent_path();
 #else
 	char path[PATH_MAX];
 	ssize_t count = readlink("/proc/self/exe", path, PATH_MAX);
 	if (count == -1) {
 		return ".";
 	}
+	return fs::path{std::string{path}}.parent_path();
 #endif
-	return fs::path{path}.parent_path();
 }
 
-filesystem::path get_root_dir() {
+fs::path get_root_dir() {
 	auto executable_dir = get_executable_dir();
-	fs::path exists_in_root_dir = "./scripts";
+	fs::path exists_in_root_dir = "scripts";
 	for (const auto& candidate : {
-		exists_in_root_dir,
-		fs::path{"../"}/exists_in_root_dir,
+		fs::path{"."}/exists_in_root_dir,
+		fs::path{".."}/exists_in_root_dir,
 		executable_dir/exists_in_root_dir,
 		executable_dir/".."/exists_in_root_dir,
 	}) {
@@ -130,4 +183,69 @@ std::string to_string(ETestbedMode mode) {
 	}
 }
 
+static const stbi_io_callbacks istream_stbi_callbacks = {
+	// Read
+	[](void* context, char* data, int size) {
+		auto stream = reinterpret_cast<std::istream*>(context);
+		stream->read(data, size);
+		return (int)stream->gcount();
+	},
+	// Seek
+	[](void* context, int size) {
+		reinterpret_cast<std::istream*>(context)->seekg(size, std::ios_base::cur);
+	},
+	// EOF
+	[](void* context) {
+		return (int)!!(*reinterpret_cast<std::istream*>(context));
+	},
+};
+
+void istream_stbi_write_func(void* context, void* data, int size) {
+	reinterpret_cast<std::ostream*>(context)->write(reinterpret_cast<char*>(data), size);
+}
+
+uint8_t* load_stbi(const fs::path& path, int* width, int* height, int* comp, int req_comp) {
+	std::ifstream f{native_string(path), std::ios::in | std::ios::binary};
+	return stbi_load_from_callbacks(&istream_stbi_callbacks, &f, width, height, comp, req_comp);
+}
+
+float* load_stbi_float(const fs::path& path, int* width, int* height, int* comp, int req_comp) {
+	std::ifstream f{native_string(path), std::ios::in | std::ios::binary};
+	return stbi_loadf_from_callbacks(&istream_stbi_callbacks, &f, width, height, comp, req_comp);
+}
+
+uint16_t* load_stbi_16(const fs::path& path, int* width, int* height, int* comp, int req_comp) {
+	std::ifstream f{native_string(path), std::ios::in | std::ios::binary};
+	return stbi_load_16_from_callbacks(&istream_stbi_callbacks, &f, width, height, comp, req_comp);
+}
+
+bool is_hdr_stbi(const fs::path& path) {
+	std::ifstream f{native_string(path), std::ios::in | std::ios::binary};
+	return stbi_is_hdr_from_callbacks(&istream_stbi_callbacks, &f);
+}
+
+int write_stbi(const fs::path& path, int width, int height, int comp, const uint8_t* pixels, int quality) {
+	std::ofstream f{native_string(path), std::ios::out | std::ios::binary};
+
+	if (equals_case_insensitive(path.extension(), "jpg") || equals_case_insensitive(path.extension(), "jpeg")) {
+		return stbi_write_jpg_to_func(istream_stbi_write_func, &f, width, height, comp, pixels, quality);
+	} else if (equals_case_insensitive(path.extension(), "png")) {
+		return stbi_write_png_to_func(istream_stbi_write_func, &f, width, height, comp, pixels, width * comp);
+	} else if (equals_case_insensitive(path.extension(), "tga")) {
+		return stbi_write_tga_to_func(istream_stbi_write_func, &f, width, height, comp, pixels);
+	} else if (equals_case_insensitive(path.extension(), "bmp")) {
+		return stbi_write_bmp_to_func(istream_stbi_write_func, &f, width, height, comp, pixels);
+	} else {
+		throw std::runtime_error{fmt::format("write_stbi: unknown image extension '{}'", path.extension())};
+	}
+}
+
+FILE* native_fopen(const fs::path& path, const char* mode) {
+#ifdef _WIN32
+	return _wfopen(path.wstr().c_str(), utf8_to_utf16(mode).c_str());
+#else
+	return fopen(path.str().c_str(), mode);
+#endif
+}
+
 NGP_NAMESPACE_END
diff --git a/src/common_device.cu b/src/common_device.cu
index 5ae268348fcace47ade376acfcc6d9929feb61f9..add7bb6e4fba273f2c27ea557e01230e4817a574 100644
--- a/src/common_device.cu
+++ b/src/common_device.cu
@@ -36,25 +36,25 @@ Matrix<float, 3, 4> log_space_lerp(const Matrix<float, 3, 4>& begin, const Matri
 	return ((log_space_a_to_b * t).exp() * A).block<3,4>(0,0);
 }
 
-GPUMemory<float> load_exr(const std::string& filename, int& width, int& height) {
+GPUMemory<float> load_exr_gpu(const fs::path& path, int* width, int* height) {
 	float* out; // width * height * RGBA
-	load_exr(&out, &width, &height, filename.c_str());
+	load_exr(&out, width, height, path.str().c_str());
 	ScopeGuard mem_guard{[&]() { free(out); }};
 
-	GPUMemory<float> result(width * height * 4);
+	GPUMemory<float> result((*width) * (*height) * 4);
 	result.copy_from_host(out);
 	return result;
 }
 
-GPUMemory<float> load_stbi(const std::string& filename, int& width, int& height) {
-	bool is_hdr = stbi_is_hdr(filename.c_str());
+GPUMemory<float> load_stbi_gpu(const fs::path& path, int* width, int* height) {
+	bool is_hdr = is_hdr_stbi(path);
 
 	void* data; // width * height * RGBA
 	int comp;
 	if (is_hdr) {
-		data = stbi_loadf(filename.c_str(), &width, &height, &comp, 4);
+		data = load_stbi_float(path, width, height, &comp, 4);
 	} else {
-		data = stbi_load(filename.c_str(), &width, &height, &comp, 4);
+		data = load_stbi(path, width, height, &comp, 4);
 	}
 
 	if (!data) {
@@ -63,17 +63,17 @@ GPUMemory<float> load_stbi(const std::string& filename, int& width, int& height)
 
 	ScopeGuard mem_guard{[&]() { stbi_image_free(data); }};
 
-	if (width == 0 || height == 0) {
+	if (*width == 0 || *height == 0) {
 		throw std::runtime_error{"Image has zero pixels."};
 	}
 
-	GPUMemory<float> result(width * height * 4);
+	GPUMemory<float> result((*width) * (*height) * 4);
 	if (is_hdr) {
 		result.copy_from_host((float*)data);
 	} else {
-		GPUMemory<uint8_t> bytes(width * height * 4);
+		GPUMemory<uint8_t> bytes((*width) * (*height) * 4);
 		bytes.copy_from_host((uint8_t*)data);
-		linear_kernel(from_rgba32<float>, 0, nullptr, width * height, bytes.data(), result.data(), false, false, 0);
+		linear_kernel(from_rgba32<float>, 0, nullptr, (*width) * (*height), bytes.data(), result.data(), false, false, 0);
 	}
 
 	return result;
diff --git a/src/dlss.cu b/src/dlss.cu
index 995f1c7df6b7961b1517fb6fd15cf12772edf6c7..d81df9390f895cd0f4f47039e6db9a849081d805 100644
--- a/src/dlss.cu
+++ b/src/dlss.cu
@@ -52,7 +52,6 @@ static_assert(false, "DLSS can only be compiled when both Vulkan and GUI support
 
 using namespace Eigen;
 using namespace tcnn;
-namespace fs = filesystem;
 
 NGP_NAMESPACE_BEGIN
 
diff --git a/src/main.cu b/src/main.cu
index 078014fab9632fb5faeeb7fa97553c1efd5bf58e..8c30e833de4c89b30b115cf73f276b6168f10e72 100644
--- a/src/main.cu
+++ b/src/main.cu
@@ -24,9 +24,10 @@ using namespace args;
 using namespace ngp;
 using namespace std;
 using namespace tcnn;
-namespace fs = ::filesystem;
 
-int main(int argc, char** argv) {
+NGP_NAMESPACE_BEGIN
+
+int main_func(const std::vector<std::string>& arguments) {
 	ArgumentParser parser{
 		"Instant Neural Graphics Primitives\n"
 		"Version " NGP_VERSION,
@@ -112,7 +113,13 @@ int main(int argc, char** argv) {
 	// Parse command line arguments and react to parsing
 	// errors using exceptions.
 	try {
-		parser.ParseCLI(argc, argv);
+		if (arguments.empty()) {
+			tlog::error() << "Number of arguments must be bigger than 0.";
+			return -3;
+		}
+
+		parser.Prog(arguments.front());
+		parser.ParseArgs(begin(arguments) + 1, end(arguments));
 	} catch (const Help&) {
 		cout << parser;
 		return 0;
@@ -131,47 +138,67 @@ int main(int argc, char** argv) {
 		return 0;
 	}
 
-	try {
-		if (mode_flag) {
-			tlog::warning() << "The '--mode' argument is no longer in use. It has no effect. The mode is automatically chosen based on the scene.";
-		}
+	if (mode_flag) {
+		tlog::warning() << "The '--mode' argument is no longer in use. It has no effect. The mode is automatically chosen based on the scene.";
+	}
 
-		Testbed testbed;
+	Testbed testbed;
 
-		for (auto file : get(files)) {
-			testbed.load_file(file);
-		}
+	for (auto file : get(files)) {
+		testbed.load_file(file);
+	}
 
-		if (scene_flag) {
-			testbed.load_training_data(get(scene_flag));
-		}
+	if (scene_flag) {
+		testbed.load_training_data(get(scene_flag));
+	}
 
-		if (snapshot_flag) {
-			testbed.load_snapshot(get(snapshot_flag));
-		} else if (network_config_flag) {
-			testbed.reload_network_from_file(get(network_config_flag));
-		}
+	if (snapshot_flag) {
+		testbed.load_snapshot(get(snapshot_flag));
+	} else if (network_config_flag) {
+		testbed.reload_network_from_file(get(network_config_flag));
+	}
 
-		testbed.m_train = !no_train_flag;
+	testbed.m_train = !no_train_flag;
 
 #ifdef NGP_GUI
-		bool gui = !no_gui_flag;
+	bool gui = !no_gui_flag;
 #else
-		bool gui = false;
+	bool gui = false;
 #endif
 
-		if (gui) {
-			testbed.init_window(width_flag ? get(width_flag) : 1920, height_flag ? get(height_flag) : 1080);
+	if (gui) {
+		testbed.init_window(width_flag ? get(width_flag) : 1920, height_flag ? get(height_flag) : 1080);
+	}
+
+	// Render/training loop
+	while (testbed.frame()) {
+		if (!gui) {
+			tlog::info() << "iteration=" << testbed.m_training_step << " loss=" << testbed.m_loss_scalar.val();
 		}
+	}
+}
+
+NGP_NAMESPACE_END
 
-		// Render/training loop
-		while (testbed.frame()) {
-			if (!gui) {
-				tlog::info() << "iteration=" << testbed.m_training_step << " loss=" << testbed.m_loss_scalar.val();
-			}
+#ifdef _WIN32
+int wmain(int argc, wchar_t* argv[]) {
+	SetConsoleOutputCP(CP_UTF8);
+#else
+int main(int argc, char* argv[]) {
+#endif
+	try {
+		std::vector<std::string> arguments;
+		for (int i = 0; i < argc; ++i) {
+#ifdef _WIN32
+			arguments.emplace_back(ngp::utf16_to_utf8(argv[i]));
+#else
+			arguments.emplace_back(argv[i]);
+#endif
 		}
+
+		return ngp::main_func(arguments);
 	} catch (const exception& e) {
-		tlog::error() << "Uncaught exception: " << e.what();
+		tlog::error() << fmt::format("Uncaught exception: {}", e.what());
 		return 1;
 	}
 }
diff --git a/src/marching_cubes.cu b/src/marching_cubes.cu
index 7bc2d28c3e201a5e131737c38c8a24cd663c62b0..28c28585ab86bd8e8104319ecde973a325fae723 100644
--- a/src/marching_cubes.cu
+++ b/src/marching_cubes.cu
@@ -21,9 +21,6 @@
 #include <tiny-cuda-nn/gpu_memory.h>
 #include <filesystem/path.h>
 
-#define STB_IMAGE_WRITE_IMPLEMENTATION
-
-#include <stb_image/stb_image_write.h>
 #include <stdarg.h>
 
 #ifdef NGP_GUI
@@ -40,7 +37,6 @@
 
 using namespace Eigen;
 using namespace tcnn;
-namespace fs = filesystem;
 
 NGP_NAMESPACE_BEGIN
 
@@ -819,7 +815,7 @@ void save_mesh(
 	GPUMemory<Vector3f>& normals,
 	GPUMemory<Vector3f>& colors,
 	GPUMemory<uint32_t>& indices,
-	const char* outputname,
+	const fs::path& path,
 	bool unwrap_it,
 	float nerf_scale,
 	Vector3f nerf_offset
@@ -862,16 +858,17 @@ void save_mesh(
 				tex[x*3+y*3*texw+2]=b;
 			}
 		}
-		stbi_write_tga(fs::path(outputname).with_extension(".tga").str().c_str(), texw, texh, 3, tex);
+
+		write_stbi(path.with_extension(".tga"), texw, texh, 3, tex);
 		free(tex);
 	}
 
-	FILE* f = fopen(outputname, "wb");
+	FILE* f = native_fopen(path, "wb");
 	if (!f) {
-		throw std::runtime_error{"Failed to open " + std::string(outputname) + " for writing."};
+		throw std::runtime_error{fmt::format("Failed to open '{}' for writing", path.str())};
 	}
 
-	if (fs::path(outputname).extension() == "ply") {
+	if (equals_case_insensitive(path.extension(), "ply")) {
 		// ply file
 		fprintf(f,
 			"ply\n"
@@ -958,7 +955,7 @@ void save_mesh(
 	fclose(f);
 }
 
-void save_density_grid_to_png(const GPUMemory<float>& density, const char* filename, Vector3i res3d, float thresh, bool swap_y_z, float density_range) {
+void save_density_grid_to_png(const GPUMemory<float>& density, const fs::path& path, Vector3i res3d, float thresh, bool swap_y_z, float density_range) {
 	float density_scale = 128.f / density_range; // map from -density_range to density_range into 0-255
 	std::vector<float> density_cpu;
 	density_cpu.resize(density.size());
@@ -1025,9 +1022,9 @@ void save_density_grid_to_png(const GPUMemory<float>& density, const char* filen
 		}
 	}
 
-	stbi_write_png(filename, w, h, 1, pngpixels, w);
+	write_stbi(path, w, h, 1, pngpixels);
 
-	tlog::success() << "Wrote density PNG to " << filename;
+	tlog::success() << "Wrote density PNG to " << path.str();
 	tlog::info()
 		<< "  #lattice points=" << N
 		<< " #zero-x voxels=" << num_voxels << " (" << ((num_voxels*100.0)/N) << "%%)"
@@ -1039,7 +1036,7 @@ void save_density_grid_to_png(const GPUMemory<float>& density, const char* filen
 // Distinct from `save_density_grid_to_png` not just in that is writes RGBA, but also
 // in that it writes a sequence of PNGs rather than a single large PNG.
 // TODO: make both methods configurable to do either single PNG or PNG sequence.
-void save_rgba_grid_to_png_sequence(const GPUMemory<Array4f>& rgba, const char* path, Vector3i res3d, bool swap_y_z) {
+void save_rgba_grid_to_png_sequence(const GPUMemory<Array4f>& rgba, const fs::path& path, Vector3i res3d, bool swap_y_z) {
 	std::vector<Array4f> rgba_cpu;
 	rgba_cpu.resize(rgba.size());
 	rgba.copy_to_host(rgba_cpu);
@@ -1066,10 +1063,8 @@ void save_rgba_grid_to_png_sequence(const GPUMemory<Array4f>& rgba, const char*
 				*dst++ = (uint8_t)tcnn::clamp(rgba_cpu[i].w() * 255.f, 0.f, 255.f);
 			}
 		}
-		// write slice
-		char filename[256];
-		snprintf(filename, sizeof(filename), "%s/%04d_%dx%d.png", path, z, w, h);
-		stbi_write_png(filename, w, h, 4, pngpixels, w*4);
+
+		write_stbi(path / fmt::format("{:04d}_{}x{}.png", z, w, h), w, h, 4, pngpixels);
 		free(pngpixels);
 
 		progress.update(++n_saved);
@@ -1077,7 +1072,7 @@ void save_rgba_grid_to_png_sequence(const GPUMemory<Array4f>& rgba, const char*
 	tlog::success() << "Wrote RGBA PNG sequence to " << path;
 }
 
-void save_rgba_grid_to_raw_file(const GPUMemory<Array4f>& rgba, const char* path, Vector3i res3d, bool swap_y_z, int cascade) {
+void save_rgba_grid_to_raw_file(const GPUMemory<Array4f>& rgba, const fs::path& path, Vector3i res3d, bool swap_y_z, int cascade) {
 	std::vector<Array4f> rgba_cpu;
 	rgba_cpu.resize(rgba.size());
 	rgba.copy_to_host(rgba_cpu);
@@ -1089,9 +1084,9 @@ void save_rgba_grid_to_raw_file(const GPUMemory<Array4f>& rgba, const char* path
 	uint32_t w = res3d.x();
 	uint32_t h = res3d.y();
 	uint32_t d = res3d.z();
-	char filename[256];
-	snprintf(filename, sizeof(filename), "%s/%dx%dx%d_%d.bin", path, w, h, d, cascade);
-	FILE *f=fopen(filename,"wb");
+
+	auto actual_path = path / fmt::format("{}x{}x{}_{}.bin", w, h, d, cascade);
+	FILE* f = native_fopen(actual_path, "wb");
 	if (!f)
 		return ;
 	const static float zero[4]={0.f,0.f,0.f,0.f};
@@ -1111,7 +1106,7 @@ void save_rgba_grid_to_raw_file(const GPUMemory<Array4f>& rgba, const char* path
 		}
 	}
 	fclose(f);
-	tlog::success() << "Wrote RGBA raw file to " << filename;
+	tlog::success() << "Wrote RGBA raw file to " << actual_path.str();
 }
 
 NGP_NAMESPACE_END
diff --git a/src/nerf_loader.cu b/src/nerf_loader.cu
index c3b7a3a6d4ca2a89940eec12a3dbcf379d0aa3a2..3fd76ca003e845e183bdd3b914e3af12bc21c9c1 100644
--- a/src/nerf_loader.cu
+++ b/src/nerf_loader.cu
@@ -23,6 +23,8 @@
 
 #include <filesystem/path.h>
 
+#include <stb_image/stb_image.h>
+
 #define _USE_MATH_DEFINES
 #include <cmath>
 #include <cstdlib>
@@ -31,28 +33,9 @@
 #include <string>
 #include <vector>
 
-#define STB_IMAGE_IMPLEMENTATION
-
-#ifdef __NVCC__
-#  ifdef __NVCC_DIAG_PRAGMA_SUPPORT__
-#    pragma nv_diag_suppress 550
-#  else
-#    pragma diag_suppress 550
-#  endif
-#endif
-#include <stb_image/stb_image.h>
-#ifdef __NVCC__
-#  ifdef __NVCC_DIAG_PRAGMA_SUPPORT__
-#    pragma nv_diag_default 550
-#  else
-#    pragma diag_default 550
-#  endif
-#endif
-
 using namespace tcnn;
 using namespace std::literals;
 using namespace Eigen;
-namespace fs = filesystem;
 
 NGP_NAMESPACE_BEGIN
 
@@ -284,7 +267,7 @@ bool read_focal_length(const nlohmann::json &json, Vector2f &focal_length, const
 	return true;
 }
 
-NerfDataset load_nerf(const std::vector<filesystem::path>& jsonpaths, float sharpen_amount) {
+NerfDataset load_nerf(const std::vector<fs::path>& jsonpaths, float sharpen_amount) {
 	if (jsonpaths.empty()) {
 		throw std::runtime_error{"Cannot load NeRF data from an empty set of paths."};
 	}
@@ -293,7 +276,7 @@ NerfDataset load_nerf(const std::vector<filesystem::path>& jsonpaths, float shar
 
 	NerfDataset result{};
 
-	std::ifstream f{jsonpaths.front().str()};
+	std::ifstream f{native_string(jsonpaths.front())};
 	nlohmann::json transforms = nlohmann::json::parse(f, nullptr, true, true);
 
 	ThreadPool pool;
@@ -322,7 +305,7 @@ NerfDataset load_nerf(const std::vector<filesystem::path>& jsonpaths, float shar
 	std::transform(
 		jsonpaths.begin(), jsonpaths.end(),
 		std::back_inserter(jsons), [](const auto& path) {
-			return nlohmann::json::parse(std::ifstream{path.str()}, nullptr, true, true);
+			return nlohmann::json::parse(std::ifstream{native_string(path)}, nullptr, true, true);
 		}
 	);
 
@@ -546,10 +529,10 @@ NerfDataset load_nerf(const std::vector<filesystem::path>& jsonpaths, float shar
 			}
 
 			if (equals_case_insensitive(envmap_path.extension(), "exr")) {
-				result.envmap_data = load_exr(envmap_path.str(), result.envmap_resolution.x(), result.envmap_resolution.y());
+				result.envmap_data = load_exr_gpu(envmap_path, &result.envmap_resolution.x(), &result.envmap_resolution.y());
 				result.is_hdr = true;
 			} else {
-				result.envmap_data = load_stbi(envmap_path.str(), result.envmap_resolution.x(), result.envmap_resolution.y());
+				result.envmap_data = load_stbi_gpu(envmap_path, &result.envmap_resolution.x(), &result.envmap_resolution.y());
 			}
 		}
 
@@ -580,7 +563,7 @@ NerfDataset load_nerf(const std::vector<filesystem::path>& jsonpaths, float shar
 				result.is_hdr = true;
 			} else {
 				dst.image_data_on_gpu = false;
-				uint8_t* img = stbi_load(path.str().c_str(), &dst.res.x(), &dst.res.y(), &comp, 4);
+				uint8_t* img = load_stbi(path, &dst.res.x(), &dst.res.y(), &comp, 4);
 				if (!img) {
 					throw std::runtime_error{"Could not open image file: "s + std::string{stbi_failure_reason()}};
 				}
@@ -588,7 +571,7 @@ NerfDataset load_nerf(const std::vector<filesystem::path>& jsonpaths, float shar
 				fs::path alphapath = resolve_path(base_path, fmt::format("{}.alpha.{}", frame["file_path"], path.extension()));
 				if (alphapath.exists()) {
 					int wa = 0, ha = 0;
-					uint8_t* alpha_img = stbi_load(alphapath.str().c_str(), &wa, &ha, &comp, 4);
+					uint8_t* alpha_img = load_stbi(alphapath, &wa, &ha, &comp, 4);
 					if (!alpha_img) {
 						throw std::runtime_error{"Could not load alpha image "s + alphapath.str()};
 					}
@@ -607,7 +590,7 @@ NerfDataset load_nerf(const std::vector<filesystem::path>& jsonpaths, float shar
 				fs::path maskpath = path.parent_path() / fmt::format("dynamic_mask_{}.png", path.basename());
 				if (maskpath.exists()) {
 					int wa = 0, ha = 0;
-					uint8_t* mask_img = stbi_load(maskpath.str().c_str(), &wa, &ha, &comp, 4);
+					uint8_t* mask_img = load_stbi(maskpath, &wa, &ha, &comp, 4);
 					if (!mask_img) {
 						throw std::runtime_error{fmt::format("Dynamic mask {} could not be loaded.", maskpath.str())};
 					}
@@ -637,7 +620,7 @@ NerfDataset load_nerf(const std::vector<filesystem::path>& jsonpaths, float shar
 				fs::path depthpath = resolve_path(base_path, frame["depth_path"]);
 				if (depthpath.exists()) {
 					int wa = 0, ha = 0;
-					dst.depth_pixels = stbi_load_16(depthpath.str().c_str(), &wa, &ha, &comp, 1);
+					dst.depth_pixels = load_stbi_16(depthpath, &wa, &ha, &comp, 1);
 					if (!dst.depth_pixels) {
 						throw std::runtime_error{fmt::format("Could not load depth image '{}'.", depthpath.str())};
 					}
@@ -653,7 +636,7 @@ NerfDataset load_nerf(const std::vector<filesystem::path>& jsonpaths, float shar
 				uint32_t n_pixels = dst.res.prod();
 				dst.rays = (Ray*)malloc(n_pixels * sizeof(Ray));
 
-				std::ifstream rays_file{rayspath.str(), std::ios::binary};
+				std::ifstream rays_file{native_string(rayspath), std::ios::binary};
 				rays_file.read((char*)dst.rays, n_pixels * sizeof(Ray));
 
 				std::streampos fsize = 0;
diff --git a/src/python_api.cu b/src/python_api.cu
index f69fc8cce6ef98d7c8480c184f061ac117b452bc..f69056f3f46a65aff0b7f18e7cec7a5fa56f239b 100644
--- a/src/python_api.cu
+++ b/src/python_api.cu
@@ -326,11 +326,18 @@ PYBIND11_MODULE(pyngp, m) {
 		.def_readwrite("max", &BoundingBox::max)
 		;
 
+	py::class_<fs::path>(m, "path")
+		.def(py::init<>())
+		.def(py::init<const std::string&>())
+		;
+
+	py::implicitly_convertible<std::string, fs::path>();
+
 	py::class_<Testbed> testbed(m, "Testbed");
 	testbed
 		.def(py::init<ETestbedMode>(), py::arg("mode") = ETestbedMode::None)
-		.def(py::init<ETestbedMode, const std::string&, const std::string&>())
-		.def(py::init<ETestbedMode, const std::string&, const json&>())
+		.def(py::init<ETestbedMode, const fs::path&, const fs::path&>())
+		.def(py::init<ETestbedMode, const fs::path&, const json&>())
 		.def_readonly("mode", &Testbed::m_testbed_mode)
 		.def("create_empty_nerf_dataset", &Testbed::create_empty_nerf_dataset, "Allocate memory for a nerf dataset with a given size", py::arg("n_images"), py::arg("aabb_scale")=1, py::arg("is_hdr")=false)
 		.def("load_training_data", &Testbed::load_training_data, py::call_guard<py::gil_scoped_release>(), "Load training data from a given path.")
@@ -387,8 +394,8 @@ PYBIND11_MODULE(pyngp, m) {
 		.def("n_encoding_params", &Testbed::n_encoding_params, "Number of trainable parameters in the encoding")
 		.def("save_snapshot", &Testbed::save_snapshot, py::arg("path"), py::arg("include_optimizer_state")=false, py::arg("compress")=true, "Save a snapshot of the currently trained model. Optionally compressed (only when saving '.ingp' files).")
 		.def("load_snapshot", &Testbed::load_snapshot, py::arg("path"), "Load a previously saved snapshot")
-		.def("load_camera_path", &Testbed::load_camera_path, "Load a camera path", py::arg("path"))
-		.def("load_file", &Testbed::load_file, "Load a file and automatically determine how to handle it. Can be a snapshot, dataset, network config, or camera path.", py::arg("path"))
+		.def("load_camera_path", &Testbed::load_camera_path, py::arg("path"), "Load a camera path")
+		.def("load_file", &Testbed::load_file, py::arg("path"), "Load a file and automatically determine how to handle it. Can be a snapshot, dataset, network config, or camera path.")
 		.def_property("loop_animation", &Testbed::loop_animation, &Testbed::set_loop_animation)
 		.def("compute_and_save_png_slices", &Testbed::compute_and_save_png_slices,
 			py::arg("filename"),
diff --git a/src/render_buffer.cu b/src/render_buffer.cu
index e70aae04172219c0be70591d5d78d21fd27254d0..c6d06e250c5200c96c8455b8de2db5a375a3ff0b 100644
--- a/src/render_buffer.cu
+++ b/src/render_buffer.cu
@@ -35,7 +35,6 @@
 
 using namespace Eigen;
 using namespace tcnn;
-namespace fs = filesystem;
 
 NGP_NAMESPACE_BEGIN
 
@@ -117,10 +116,10 @@ void GLTexture::blit_from_cuda_mapping() {
 	glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, m_size.x(), m_size.y(), 0, GL_RGBA, GL_FLOAT, data_cpu);
 }
 
-void GLTexture::load(const char* fname) {
+void GLTexture::load(const fs::path& path) {
 	uint8_t* out; // width * height * RGBA
-	int comp,width,height;
-	out = stbi_load(fname, &width, &height, &comp, 4);
+	int comp, width, height;
+	out = load_stbi(path, &width, &height, &comp, 4);
 	if (!out) {
 		throw std::runtime_error{std::string{stbi_failure_reason()}};
 	}
@@ -161,7 +160,7 @@ void GLTexture::resize(const Vector2i& new_size, int n_channels, bool is_8bit) {
 		case 2: m_internal_format = is_8bit ? GL_RG8   : GL_RG32F;   m_format = GL_RG;   break;
 		case 3: m_internal_format = is_8bit ? GL_RGB8  : GL_RGB32F;  m_format = GL_RGB;  break;
 		case 4: m_internal_format = is_8bit ? GL_RGBA8 : GL_RGBA32F; m_format = GL_RGBA; break;
-		default: tlog::error() << "Unsupported number of channels: " << n_channels;
+		default: throw std::runtime_error{fmt::format("GLTexture: unsupported number of channels {}", n_channels)};
 	}
 	m_is_8bit = is_8bit;
 	m_size = new_size;
diff --git a/src/testbed.cu b/src/testbed.cu
index dac713813c63d592f6ea3945bd42c06590761275..23735633885daba0ad7550ee7edfe59a93c851de 100644
--- a/src/testbed.cu
+++ b/src/testbed.cu
@@ -38,9 +38,6 @@
 #include <filesystem/directory.h>
 #include <filesystem/path.h>
 
-#include <stb_image/stb_image.h>
-#include <stb_image/stb_image_write.h>
-
 #include <zstr.hpp>
 
 #include <fstream>
@@ -73,30 +70,30 @@
 using namespace Eigen;
 using namespace std::literals::chrono_literals;
 using namespace tcnn;
-namespace fs = filesystem;
+
+NGP_NAMESPACE_BEGIN
 
 int do_system(const std::string& cmd) {
 #ifdef _WIN32
 	tlog::info() << "> " << cmd;
+	return _wsystem(utf8_to_utf16(cmd).c_str());
 #else
 	tlog::info() << "$ " << cmd;
-#endif
 	return system(cmd.c_str());
+#endif
 }
 
-NGP_NAMESPACE_BEGIN
-
 std::atomic<size_t> g_total_n_bytes_allocated{0};
 
-json merge_parent_network_config(const json& child, const fs::path& child_filename) {
+json merge_parent_network_config(const json& child, const fs::path& child_path) {
 	if (!child.contains("parent")) {
 		return child;
 	}
-	fs::path parent_filename = child_filename.parent_path() / std::string(child["parent"]);
-	tlog::info() << "Loading parent network config from: " << parent_filename.str();
-	std::ifstream f{parent_filename.str()};
+	fs::path parent_path = child_path.parent_path() / std::string(child["parent"]);
+	tlog::info() << "Loading parent network config from: " << parent_path.str();
+	std::ifstream f{native_string(parent_path)};
 	json parent = json::parse(f, nullptr, true, true);
-	parent = merge_parent_network_config(parent, parent_filename);
+	parent = merge_parent_network_config(parent, parent_path);
 	parent.merge_patch(child);
 	return parent;
 }
@@ -127,27 +124,26 @@ void Testbed::update_imgui_paths() {
 	snprintf(m_imgui.video_path, sizeof(m_imgui.video_path), "%s", get_filename_in_data_path_with_suffix(m_data_path, m_network_config_path, "_video.mp4").c_str());
 }
 
-void Testbed::load_training_data(const std::string& data_path_str) {
-	fs::path data_path = data_path_str;
-	if (!data_path.exists()) {
-		throw std::runtime_error{fmt::format("Data path '{}' does not exist.", data_path.str())};
+void Testbed::load_training_data(const fs::path& path) {
+	if (!path.exists()) {
+		throw std::runtime_error{fmt::format("Data path '{}' does not exist.", path.str())};
 	}
 
 	// Automatically determine the mode from the first scene that's loaded
-	ETestbedMode scene_mode = mode_from_scene(data_path.str());
+	ETestbedMode scene_mode = mode_from_scene(path.str());
 	if (scene_mode == ETestbedMode::None) {
-		throw std::runtime_error{fmt::format("Unknown scene format for path '{}'.", data_path.str())};
+		throw std::runtime_error{fmt::format("Unknown scene format for path '{}'.", path.str())};
 	}
 
 	set_mode(scene_mode);
 
-	m_data_path = data_path;
+	m_data_path = path;
 
 	switch (m_testbed_mode) {
-		case ETestbedMode::Nerf:   load_nerf(data_path); break;
-		case ETestbedMode::Sdf:    load_mesh(data_path); break;
-		case ETestbedMode::Image:  load_image(data_path); break;
-		case ETestbedMode::Volume: load_volume(data_path); break;
+		case ETestbedMode::Nerf:   load_nerf(path); break;
+		case ETestbedMode::Sdf:    load_mesh(path); break;
+		case ETestbedMode::Image:  load_image(path); break;
+		case ETestbedMode::Volume: load_volume(path); break;
 		default: throw std::runtime_error{"Invalid testbed mode."};
 	}
 
@@ -226,17 +222,17 @@ json Testbed::load_network_config(const fs::path& network_config_path) {
 
 	json result;
 	if (is_snapshot) {
+		std::ifstream f{native_string(network_config_path), std::ios::in | std::ios::binary};
 		if (equals_case_insensitive(network_config_path.extension(), "ingp")) {
 			// zstr::ifstream applies zlib compression.
-			zstr::ifstream f{network_config_path.str(), std::ios::in | std::ios::binary};
-			result = json::from_msgpack(f);
+			zstr::istream zf{f};
+			result = json::from_msgpack(zf);
 		} else {
-			std::ifstream f{network_config_path.str(), std::ios::in | std::ios::binary};
 			result = json::from_msgpack(f);
 		}
 		// we assume parent pointers are already resolved in snapshots.
 	} else if (equals_case_insensitive(network_config_path.extension(), "json")) {
-		std::ifstream f{network_config_path.str()};
+		std::ifstream f{native_string(network_config_path)};
 		result = json::parse(f, nullptr, true, true);
 		result = merge_parent_network_config(result, network_config_path);
 	}
@@ -244,15 +240,15 @@ json Testbed::load_network_config(const fs::path& network_config_path) {
 	return result;
 }
 
-void Testbed::reload_network_from_file(const std::string& network_config_path_string) {
-	if (!network_config_path_string.empty()) {
-		fs::path candidate = find_network_config(network_config_path_string);
+void Testbed::reload_network_from_file(const fs::path& path) {
+	if (!path.empty()) {
+		fs::path candidate = find_network_config(path);
 		if (candidate.exists() || !m_network_config_path.exists()) {
 			// Store the path _argument_ in the member variable. E.g. for the base config,
 			// it'll store `base.json`, even though the loaded config will be
 			// config/<mode>/base.json. This has the benefit of switching to the
 			// appropriate config when switching modes.
-			m_network_config_path = network_config_path_string;
+			m_network_config_path = path;
 		}
 	}
 
@@ -285,46 +281,46 @@ void Testbed::reload_network_from_json(const json& json, const std::string& conf
 	reset_network();
 }
 
-void Testbed::load_file(const std::string& file_path) {
-	if (!fs::path{file_path}.exists()) {
+void Testbed::load_file(const fs::path& path) {
+	if (!path.exists()) {
 		// If the path doesn't exist, but a network config can be resolved, load that.
-		if (ends_with_case_insensitive(file_path, ".json") && find_network_config(file_path).exists()) {
-			reload_network_from_file(file_path);
+		if (equals_case_insensitive(path.extension(), "json") && find_network_config(path).exists()) {
+			reload_network_from_file(path);
 			return;
 		}
 
-		tlog::error() << "File '" << file_path << "' does not exist.";
+		tlog::error() << "File '" << path.str() << "' does not exist.";
 		return;
 	}
 
-	if (ends_with_case_insensitive(file_path, ".ingp") || ends_with_case_insensitive(file_path, ".msgpack")) {
-		load_snapshot(file_path);
+	if (equals_case_insensitive(path.extension(), "ingp") || equals_case_insensitive(path.extension(), "msgpack")) {
+		load_snapshot(path);
 		return;
 	}
 
 	// If we get a json file, we need to parse it to determine its purpose.
-	if (ends_with_case_insensitive(file_path, ".json")) {
+	if (equals_case_insensitive(path.extension(), "json")) {
 		json file;
 		{
-			std::ifstream f{file_path};
+			std::ifstream f{native_string(path)};
 			file = json::parse(f, nullptr, true, true);
 		}
 
 		// Snapshot in json format... inefficient, but technically supported.
 		if (file.contains("snapshot")) {
-			load_snapshot(file_path);
+			load_snapshot(path);
 			return;
 		}
 
 		// Regular network config
 		if (file.contains("parent") || file.contains("network") || file.contains("encoding") || file.contains("loss") || file.contains("optimizer")) {
-			reload_network_from_file(file_path);
+			reload_network_from_file(path);
 			return;
 		}
 
 		// Camera path
 		if (file.contains("path")) {
-			load_camera_path(file_path);
+			load_camera_path(path);
 			return;
 		}
 	}
@@ -332,7 +328,7 @@ void Testbed::load_file(const std::string& file_path) {
 	// If the dragged file isn't any of the above, assume that it's training data
 	try {
 		bool was_training_data_available = m_training_data_available;
-		load_training_data(file_path);
+		load_training_data(path);
 
 		if (!was_training_data_available) {
 			// If we previously didn't have any training data and only now dragged
@@ -1984,7 +1980,7 @@ void Testbed::prepare_next_camera_path_frame() {
 		m_render_futures.emplace_back(m_thread_pool.enqueue_task([image_data=std::move(image_data), frame_idx=m_camera_path.render_frame_idx++, res, tmp_dir] {
 			std::vector<uint8_t> cpu_image_data(image_data.size());
 			CUDA_CHECK_THROW(cudaMemcpy(cpu_image_data.data(), image_data.data(), image_data.bytes(), cudaMemcpyDeviceToHost));
-			stbi_write_jpg(fmt::format("{}/{:06d}.jpg", tmp_dir.str(), frame_idx).c_str(), res.x(), res.y(), 3, cpu_image_data.data(), 100);
+			write_stbi(tmp_dir / fmt::format("{:06d}.jpg", frame_idx), res.x(), res.y(), 3, cpu_image_data.data(), 100);
 		}));
 
 		reset_accumulation(true);
@@ -2025,7 +2021,7 @@ void Testbed::prepare_next_camera_path_frame() {
 #endif
 
 			auto ffmpeg_command = fmt::format(
-				"{} -loglevel error -y -framerate {} -i tmp/%06d.jpg -c:v libx264 -preset slow -crf {} -pix_fmt yuv420p {}",
+				"{} -loglevel error -y -framerate {} -i tmp/%06d.jpg -c:v libx264 -preset slow -crf {} -pix_fmt yuv420p \"{}\"",
 				ffmpeg.str(),
 				m_camera_path.render_settings.fps,
 				// Quality goes from 0 to 10. This conversion to CRF means a quality of 10
@@ -3499,8 +3495,7 @@ void Testbed::gather_histograms() {
 // Increment this number when making a change to the snapshot format
 static const size_t SNAPSHOT_FORMAT_VERSION = 1;
 
-void Testbed::save_snapshot(const std::string& filepath_string, bool include_optimizer_state, bool compress) {
-	fs::path filepath = filepath_string;
+void Testbed::save_snapshot(const fs::path& path, bool include_optimizer_state, bool compress) {
 	m_network_config["snapshot"] = m_trainer->serialize(include_optimizer_state);
 
 	auto& snapshot = m_network_config["snapshot"];
@@ -3533,23 +3528,23 @@ void Testbed::save_snapshot(const std::string& filepath_string, bool include_opt
 		snapshot["nerf"]["dataset"] = m_nerf.training.dataset;
 	}
 
-	m_network_config_path = filepath;
+	m_network_config_path = path;
+	std::ofstream f{native_string(m_network_config_path), std::ios::out | std::ios::binary};
 	if (equals_case_insensitive(m_network_config_path.extension(), "ingp")) {
 		// zstr::ofstream applies zlib compression.
-		zstr::ofstream f{m_network_config_path.str(), std::ios::out | std::ios::binary, compress ? Z_DEFAULT_COMPRESSION : Z_NO_COMPRESSION};
-		json::to_msgpack(m_network_config, f);
+		zstr::ostream zf{f, zstr::default_buff_size, compress ? Z_DEFAULT_COMPRESSION : Z_NO_COMPRESSION};
+		json::to_msgpack(m_network_config, zf);
 	} else {
-		std::ofstream f{m_network_config_path.str(), std::ios::out | std::ios::binary};
 		json::to_msgpack(m_network_config, f);
 	}
 
-	tlog::success() << "Saved snapshot '" << filepath_string << "'";
+	tlog::success() << "Saved snapshot '" << path.str() << "'";
 }
 
-void Testbed::load_snapshot(const std::string& filepath_string) {
-	auto config = load_network_config(filepath_string);
+void Testbed::load_snapshot(const fs::path& path) {
+	auto config = load_network_config(path);
 	if (!config.contains("snapshot")) {
-		throw std::runtime_error{fmt::format("File '{}' does not contain a snapshot.", filepath_string)};
+		throw std::runtime_error{fmt::format("File '{}' does not contain a snapshot.", path.str())};
 	}
 
 	const auto& snapshot = config["snapshot"];
@@ -3610,7 +3605,7 @@ void Testbed::load_snapshot(const std::string& filepath_string) {
 	if (snapshot.contains("render_aabb_to_local")) from_json(snapshot.at("render_aabb_to_local"), m_render_aabb_to_local);
 	m_render_aabb = snapshot.value("render_aabb", m_render_aabb);
 
-	m_network_config_path = filepath_string;
+	m_network_config_path = path;
 	m_network_config = std::move(config);
 
 	reset_network(false);
@@ -3621,8 +3616,8 @@ void Testbed::load_snapshot(const std::string& filepath_string) {
 	m_trainer->deserialize(m_network_config["snapshot"]);
 }
 
-void Testbed::load_camera_path(const std::string& filepath_string) {
-	m_camera_path.load(filepath_string, Matrix<float, 3, 4>::Identity());
+void Testbed::load_camera_path(const fs::path& path) {
+	m_camera_path.load(path, Matrix<float, 3, 4>::Identity());
 }
 
 bool Testbed::loop_animation() {
diff --git a/src/testbed_image.cu b/src/testbed_image.cu
index 0b8313f72ec97f6fdfd41e272a5b1c0e9183be98..5be8aa74bf6f11b99d70b8663835956b66137fd0 100644
--- a/src/testbed_image.cu
+++ b/src/testbed_image.cu
@@ -17,6 +17,7 @@
 #include <neural-graphics-primitives/random_val.cuh>
 #include <neural-graphics-primitives/render_buffer.h>
 #include <neural-graphics-primitives/testbed.h>
+#include <neural-graphics-primitives/tinyexr_wrapper.h>
 
 #include <tiny-cuda-nn/gpu_matrix.h>
 #include <tiny-cuda-nn/network_with_input_encoding.h>
@@ -27,7 +28,6 @@
 
 using namespace Eigen;
 using namespace tcnn;
-namespace fs = filesystem;
 
 NGP_NAMESPACE_BEGIN
 
@@ -382,7 +382,7 @@ void Testbed::load_exr_image(const fs::path& data_path) {
 	tlog::info() << "Loading EXR image from " << data_path;
 
 	// First step: load an image that we'd like to learn
-	GPUMemory<float> image = load_exr(data_path.str(), m_image.resolution.x(), m_image.resolution.y());
+	GPUMemory<float> image = load_exr_gpu(data_path, &m_image.resolution.x(), &m_image.resolution.y());
 	m_image.data.resize(image.size() * sizeof(float));
 	CUDA_CHECK_THROW(cudaMemcpy(m_image.data.data(), image.data(), image.size() * sizeof(float), cudaMemcpyDeviceToDevice));
 
@@ -397,7 +397,7 @@ void Testbed::load_stbi_image(const fs::path& data_path) {
 	tlog::info() << "Loading STBI image from " << data_path;
 
 	// First step: load an image that we'd like to learn
-	GPUMemory<float> image = load_stbi(data_path.str(), m_image.resolution.x(), m_image.resolution.y());
+	GPUMemory<float> image = load_stbi_gpu(data_path, &m_image.resolution.x(), &m_image.resolution.y());
 	m_image.data.resize(image.size() * sizeof(float));
 	CUDA_CHECK_THROW(cudaMemcpy(m_image.data.data(), image.data(), image.size() * sizeof(float), cudaMemcpyDeviceToDevice));
 
@@ -412,7 +412,7 @@ void Testbed::load_binary_image(const fs::path& data_path) {
 
 	tlog::info() << "Loading binary image from " << data_path;
 
-	std::ifstream f(data_path.str(), std::ios::in | std::ios::binary);
+	std::ifstream f{native_string(data_path), std::ios::in | std::ios::binary};
 	f.read(reinterpret_cast<char*>(&m_image.resolution.y()), sizeof(int));
 	f.read(reinterpret_cast<char*>(&m_image.resolution.x()), sizeof(int));
 
diff --git a/src/testbed_nerf.cu b/src/testbed_nerf.cu
index c020237f3bbff01494d0035d611a6d06013711d9..33a663d4894ad43422b55af9fa2b8d97eec7dd4c 100644
--- a/src/testbed_nerf.cu
+++ b/src/testbed_nerf.cu
@@ -40,7 +40,6 @@
 
 using namespace Eigen;
 using namespace tcnn;
-namespace fs = filesystem;
 
 NGP_NAMESPACE_BEGIN
 
@@ -2462,8 +2461,8 @@ void Testbed::Nerf::Training::reset_camera_extrinsics() {
 	}
 }
 
-void Testbed::Nerf::Training::export_camera_extrinsics(const std::string& filename, bool export_extrinsics_in_quat_format) {
-	tlog::info() << "Saving a total of " << n_images_for_training << " poses to " << filename;
+void Testbed::Nerf::Training::export_camera_extrinsics(const fs::path& path, bool export_extrinsics_in_quat_format) {
+	tlog::info() << "Saving a total of " << n_images_for_training << " poses to " << path.str();
 	nlohmann::json trajectory;
 	for(int i = 0; i < n_images_for_training; ++i) {
 		nlohmann::json frame{{"id", i}};
@@ -2495,7 +2494,8 @@ void Testbed::Nerf::Training::export_camera_extrinsics(const std::string& filena
 
 		trajectory.emplace_back(frame);
 	}
-	std::ofstream file(filename);
+
+	std::ofstream file{native_string(path)};
 	file << std::setw(2) << trajectory << std::endl;
 }
 
diff --git a/src/testbed_sdf.cu b/src/testbed_sdf.cu
index 2fcfc5ef4ffbd2692c3d483688781ecbe54e1b72..aced131525d5198518e14ed2a97ac75e180f6a6d 100644
--- a/src/testbed_sdf.cu
+++ b/src/testbed_sdf.cu
@@ -32,7 +32,6 @@
 
 using namespace Eigen;
 using namespace tcnn;
-namespace fs = filesystem;
 
 NGP_NAMESPACE_BEGIN
 
@@ -1018,23 +1017,23 @@ void Testbed::render_sdf(
 	}
 }
 
-std::vector<Vector3f> load_stl(const std::string& filename) {
+std::vector<Vector3f> load_stl(const fs::path& path) {
 	std::vector<Vector3f> vertices;
 
-	std::ifstream f{filename, std::ios::in | std::ios::binary};
+	std::ifstream f{native_string(path), std::ios::in | std::ios::binary};
 	if (!f) {
-		throw std::runtime_error{fmt::format("Mesh file '{}' not found", filename)};
+		throw std::runtime_error{fmt::format("Mesh file '{}' not found", path.str())};
 	}
 
 	uint32_t buf[21] = {};
 	f.read((char*)buf, 4 * 21);
 	if (f.gcount() < 4 * 21) {
-		throw std::runtime_error{fmt::format("Mesh file '{}' too small for STL header", filename)};
+		throw std::runtime_error{fmt::format("Mesh file '{}' too small for STL header", path.str())};
 	}
 
 	uint32_t nfaces = buf[20];
 	if (memcmp(buf, "solid", 5) == 0 || buf[20] == 0) {
-		throw std::runtime_error{fmt::format("ASCII STL file '{}' not supported", filename)};
+		throw std::runtime_error{fmt::format("ASCII STL file '{}' not supported", path.str())};
 	}
 
 	vertices.reserve(nfaces * 3);
diff --git a/src/testbed_volume.cu b/src/testbed_volume.cu
index 168b666fb6b063f26a81d7d9dc9a84d9515f7581..10306cb0ca6e0dbca0f59f32923c9c84df79b6a8 100644
--- a/src/testbed_volume.cu
+++ b/src/testbed_volume.cu
@@ -33,7 +33,6 @@
 
 using namespace Eigen;
 using namespace tcnn;
-namespace fs = filesystem;
 
 NGP_NAMESPACE_BEGIN
 
@@ -555,7 +554,7 @@ void Testbed::load_volume(const fs::path& data_path) {
 		throw std::runtime_error{data_path.str() + " does not exist."};
 	}
 	tlog::info() << "Loading NanoVDB file from " << data_path;
-	std::ifstream f(data_path.str(), std::ios::in | std::ios::binary);
+	std::ifstream f{native_string(data_path), std::ios::in | std::ios::binary};
 	NanoVDBFileHeader header;
 	NanoVDBMetaData metadata;
 	f.read(reinterpret_cast<char*>(&header), sizeof(header));
diff --git a/src/tinyexr_wrapper.cu b/src/tinyexr_wrapper.cu
index ea1608c9e59984462075ccb4f7fa3c2a4430c8d7..00e451a81c43e7278f75398caf3323870b37f364 100644
--- a/src/tinyexr_wrapper.cu
+++ b/src/tinyexr_wrapper.cu
@@ -54,7 +54,7 @@ __global__ void interleave_and_cast_kernel(const uint32_t num_pixels, bool has_a
 	*((uint64_t*)&out[i*4]) = *((uint64_t*)&rgba_out[0]);
 }
 
-void save_exr(const float* data, int width, int height, int n_channels, int channel_stride, const char* outfilename) {
+void save_exr(const float* data, int width, int height, int n_channels, int channel_stride, const fs::path& path) {
 	EXRHeader header;
 	InitEXRHeader(&header);
 
@@ -103,23 +103,43 @@ void save_exr(const float* data, int width, int height, int n_channels, int chan
 	}
 
 	const char* err = NULL; // or nullptr in C++11 or later.
-	int ret = SaveEXRImageToFile(&image, &header, outfilename, &err);
-	if (ret != TINYEXR_SUCCESS) {
+	uint8_t* buffer;
+	size_t n_bytes = SaveEXRImageToMemory(&image, &header, &buffer, &err);
+	if (n_bytes == 0) {
 		std::string error_message = std::string("Failed to save EXR image: ") + err;
 		FreeEXRErrorMessage(err); // free's buffer for an error message
 		throw std::runtime_error(error_message);
 	}
-	tlog::info() << "Saved exr file: " << outfilename;
+
+	{
+		std::ofstream f{native_string(path), std::ios::out | std::ios::binary};
+		f.write((char*)buffer, n_bytes);
+	}
+
+	tlog::info() << "Saved exr file: " << path.str();
 
 	free(header.channels);
 	free(header.pixel_types);
 	free(header.requested_pixel_types);
+	free(buffer);
 }
 
-void load_exr(float** data, int* width, int* height, const char* filename) {
-	const char* err = nullptr;
+void load_exr(float** data, int* width, int* height, const fs::path& path) {
+	std::vector<uint8_t> buffer;
+
+	{
+		std::ifstream f{native_string(path), std::ios::in | std::ios::binary | std::ios::ate};
+		size_t size = f.tellg();
+		f.seekg(0, std::ios::beg);
 
-	int ret = LoadEXR(data, width, height, filename, &err);
+		buffer.resize(size);
+		if (!f.read((char*)buffer.data(), size)) {
+			throw std::runtime_error("Failed to open EXR file");
+		}
+	}
+
+	const char* err = nullptr;
+	int ret = LoadEXRFromMemory(data, width, height, buffer.data(), buffer.size(), &err);
 
 	if (ret != TINYEXR_SUCCESS) {
 		if (err) {
@@ -132,11 +152,24 @@ void load_exr(float** data, int* width, int* height, const char* filename) {
 	}
 }
 
-__half* load_exr_to_gpu(int* width, int* height, const char* filename, bool fix_premult) {
+__half* load_exr_to_gpu(int* width, int* height, const fs::path& path, bool fix_premult) {
+	std::vector<uint8_t> buffer;
+
+	{
+		std::ifstream f{native_string(path), std::ios::in | std::ios::binary | std::ios::ate};
+		size_t size = f.tellg();
+		f.seekg(0, std::ios::beg);
+
+		buffer.resize(size);
+		if (!f.read((char*)buffer.data(), size)) {
+			throw std::runtime_error("Failed to open EXR file");
+		}
+	}
+
 	// 1. Read EXR version.
 	EXRVersion exr_version;
 
-	int ret = ParseEXRVersionFromFile(&exr_version, filename);
+	int ret = ParseEXRVersionFromMemory(&exr_version, buffer.data(), buffer.size());
 	if (ret != 0) {
 		std::string error_message = std::string("Failed to parse EXR image version");
 		throw std::runtime_error(error_message);
@@ -151,7 +184,7 @@ __half* load_exr_to_gpu(int* width, int* height, const char* filename, bool fix_
 	InitEXRHeader(&exr_header);
 
 	const char* err = NULL; // or `nullptr` in C++11 or later.
-	ret = ParseEXRHeaderFromFile(&exr_header, &exr_version, filename, &err);
+	ret = ParseEXRHeaderFromMemory(&exr_header, &exr_version, buffer.data(), buffer.size(), &err);
 	if (ret != 0) {
 		std::string error_message = std::string("Failed to parse EXR image header: ") + err;
 		FreeEXRErrorMessage(err); // free's buffer for an error message
@@ -170,7 +203,7 @@ __half* load_exr_to_gpu(int* width, int* height, const char* filename, bool fix_
 	EXRImage exr_image;
 	InitEXRImage(&exr_image);
 
-	ret = LoadEXRImageFromFile(&exr_image, &exr_header, filename, &err);
+	ret = LoadEXRImageFromMemory(&exr_image, &exr_header, buffer.data(), buffer.size(), &err);
 	if (ret != 0) {
 		std::string error_message = std::string("Failed to load EXR image: ") + err;
 		FreeEXRHeader(&exr_header);
diff --git a/src/tinyobj_loader_wrapper.cpp b/src/tinyobj_loader_wrapper.cpp
index 4ad738460f2ce9031be5f60d487fc49ef6b57088..19da0ef8f926f31f814875dc4b570970ed6ee233 100644
--- a/src/tinyobj_loader_wrapper.cpp
+++ b/src/tinyobj_loader_wrapper.cpp
@@ -29,7 +29,7 @@ using namespace Eigen;
 
 NGP_NAMESPACE_BEGIN
 
-std::vector<Vector3f> load_obj(const std::string& filename) {
+std::vector<Vector3f> load_obj(const fs::path& path) {
 	tinyobj::attrib_t attrib;
 	std::vector<tinyobj::shape_t> shapes;
 	std::vector<tinyobj::material_t> materials;
@@ -37,19 +37,20 @@ std::vector<Vector3f> load_obj(const std::string& filename) {
 	std::string warn;
 	std::string err;
 
-	bool ret = tinyobj::LoadObj(&attrib, &shapes, &materials, &warn, &err, filename.c_str());
+	std::ifstream f{native_string(path), std::ios::in | std::ios::binary};
+	bool ret = tinyobj::LoadObj(&attrib, &shapes, &materials, &warn, &err, &f);
 
 	if (!warn.empty()) {
-		tlog::warning() << warn << " while loading '" << filename << "'";
+		tlog::warning() << warn << " while loading '" << path.str() << "'";
 	}
 
 	if (!err.empty()) {
-		throw std::runtime_error{fmt::format("Error loading '{}': {}", filename, err)};
+		throw std::runtime_error{fmt::format("Error loading '{}': {}", path.str(), err)};
 	}
 
 	std::vector<Vector3f> result;
 
-	tlog::success() << "Loaded mesh \"" << filename << "\" file with " << shapes.size() << " shapes.";
+	tlog::success() << "Loaded mesh \"" << path.str() << "\" file with " << shapes.size() << " shapes.";
 
 	// Loop over shapes
 	for (size_t s = 0; s < shapes.size(); s++) {
@@ -59,7 +60,7 @@ std::vector<Vector3f> load_obj(const std::string& filename) {
 			size_t fv = size_t(shapes[s].mesh.num_face_vertices[f]);
 
 			if (shapes[s].mesh.num_face_vertices[f] != 3) {
-				tlog::warning() << "Non-triangle face found in " << filename;
+				tlog::warning() << "Non-triangle face found in " << path.str();
 				index_offset += fv;
 				continue;
 			}