diff --git a/include/neural-graphics-primitives/testbed.h b/include/neural-graphics-primitives/testbed.h
index cb5c9dea856bf57a40085457176fa2c8d017ed8a..4490d0b77ed00a8d7d80947746d01c0b7991afbe 100644
--- a/include/neural-graphics-primitives/testbed.h
+++ b/include/neural-graphics-primitives/testbed.h
@@ -462,6 +462,8 @@ public:
 	fs::path training_data_path() const;
 	void init_window(int resw, int resh, bool hidden = false, bool second_window = false);
 	void destroy_window();
+	void init_vr();
+	void update_vr_performance_settings();
 	void apply_camera_smoothing(float elapsed_ms);
 	int find_best_training_view(int default_view);
 	bool begin_frame();
@@ -582,6 +584,7 @@ public:
 	EMeshRenderMode m_mesh_render_mode = EMeshRenderMode::VertexNormals;
 
 	uint32_t m_seed = 1337;
+
 #ifdef NGP_GUI
 	GLFWwindow* m_glfw_window = nullptr;
 	struct SecondWindow {
@@ -606,8 +609,6 @@ public:
 	OpenXRHMD::FrameInfoPtr m_vr_frame_info;
 	bool m_vr_depth_reproject = false;
 
-	void init_vr();
-	void update_vr_performance_settings();
 	void set_n_views(size_t n_views);
 
 	std::function<bool()> m_keyboard_event_callback;
diff --git a/src/python_api.cu b/src/python_api.cu
index fc95fe7b7a638e1ff69239dde456fd2fcc0c998c..951d7953c38b5b4dd827007ee8745bd70c2d300e 100644
--- a/src/python_api.cu
+++ b/src/python_api.cu
@@ -362,14 +362,15 @@ PYBIND11_MODULE(pyngp, m) {
 		.def("load_training_data", &Testbed::load_training_data, py::call_guard<py::gil_scoped_release>(), "Load training data from a given path.")
 		.def("clear_training_data", &Testbed::clear_training_data, "Clears training data to free up GPU memory.")
 		// General control
-#ifdef NGP_GUI
 		.def("init_window", &Testbed::init_window, "Init a GLFW window that shows real-time progress and a GUI. 'second_window' creates a second copy of the output in its own window.",
 			py::arg("width"),
 			py::arg("height"),
 			py::arg("hidden") = false,
 			py::arg("second_window") = false
 		)
-		.def("init_vr", &Testbed::init_vr, "Init rendering to a connected and active VR headset. Requires a GUI window to have been previously created via `init_window`.")
+		.def("destroy_window", &Testbed::destroy_window, "Destroy the window again.")
+		.def("init_vr", &Testbed::init_vr, "Init rendering to a connected and active VR headset. Requires a window to have been previously created via `init_window`.")
+#ifdef NGP_GUI
 		.def_readwrite("keyboard_event_callback", &Testbed::m_keyboard_event_callback)
 		.def("is_key_pressed", [](py::object& obj, int key) { return ImGui::IsKeyPressed(key); })
 		.def("is_key_down", [](py::object& obj, int key) { return ImGui::IsKeyDown(key); })
@@ -391,7 +392,6 @@ PYBIND11_MODULE(pyngp, m) {
 			py::arg("fps") = 30.f,
 			py::arg("shutter_fraction") = 1.0f
 		)
-		.def("destroy_window", &Testbed::destroy_window, "Destroy the window again.")
 		.def("train", &Testbed::train, py::call_guard<py::gil_scoped_release>(), "Perform a single training step with a specified batch size.")
 		.def("reset", &Testbed::reset_network, py::arg("reset_density_grid") = true, "Reset training.")
 		.def("reset_accumulation", &Testbed::reset_accumulation, "Reset rendering accumulation.",
diff --git a/src/testbed.cu b/src/testbed.cu
index 78a5164e6ca175439a5c660d1b8e0d1ad8de2440..70aa0bcc2584165796e83e70f67558c20c904ad7 100644
--- a/src/testbed.cu
+++ b/src/testbed.cu
@@ -2875,87 +2875,6 @@ void Testbed::create_second_window() {
 	glBindVertexArray(0);
 }
 
-void Testbed::init_vr() {
-	try {
-		if (!m_glfw_window) {
-			throw std::runtime_error{"`init_window` must be called before `init_vr`"};
-		}
-
-#if defined(XR_USE_PLATFORM_WIN32)
-		m_hmd = std::make_unique<OpenXRHMD>(wglGetCurrentDC(), glfwGetWGLContext(m_glfw_window));
-#elif defined(XR_USE_PLATFORM_XLIB)
-		Display* xDisplay = glfwGetX11Display();
-		GLXContext glxContext = glfwGetGLXContext(m_glfw_window);
-
-		int glxFBConfigXID = 0;
-		glXQueryContext(xDisplay, glxContext, GLX_FBCONFIG_ID, &glxFBConfigXID);
-		int attributes[3] = { GLX_FBCONFIG_ID, glxFBConfigXID, 0 };
-		int nelements = 1;
-		GLXFBConfig* pglxFBConfig = glXChooseFBConfig(xDisplay, 0, attributes, &nelements);
-		if (nelements != 1 || !pglxFBConfig) {
-			throw std::runtime_error{"init_vr(): Couldn't obtain GLXFBConfig"};
-		}
-
-		GLXFBConfig glxFBConfig = *pglxFBConfig;
-
-		XVisualInfo* visualInfo = glXGetVisualFromFBConfig(xDisplay, glxFBConfig);
-		if (!visualInfo) {
-			throw std::runtime_error{"init_vr(): Couldn't obtain XVisualInfo"};
-		}
-
-		m_hmd = std::make_unique<OpenXRHMD>(xDisplay, visualInfo->visualid, glxFBConfig, glXGetCurrentDrawable(), glxContext);
-#elif defined(XR_USE_PLATFORM_WAYLAND)
-		m_hmd = std::make_unique<OpenXRHMD>(glfwGetWaylandDisplay());
-#endif
-
-		// Enable aggressive optimizations to make the VR experience smooth.
-		update_vr_performance_settings();
-
-		// If multiple GPUs are available, shoot for 60 fps in VR.
-		// Otherwise, it wouldn't be realistic to expect more than 30.
-		m_dynamic_res_target_fps = m_devices.size() > 1 ? 60 : 30;
-		m_background_color = {0.0f, 0.0f, 0.0f, 0.0f};
-	} catch (const std::runtime_error& e) {
-		if (std::string{e.what()}.find("XR_ERROR_FORM_FACTOR_UNAVAILABLE") != std::string::npos) {
-			throw std::runtime_error{"Could not initialize VR. Ensure that SteamVR, OculusVR, or any other OpenXR-compatible runtime is running. Also set it as the active OpenXR runtime."};
-		} else {
-			throw std::runtime_error{fmt::format("Could not initialize VR: {}", e.what())};
-		}
-	}
-}
-
-void Testbed::update_vr_performance_settings() {
-	if (m_hmd) {
-		auto blend_mode = m_hmd->environment_blend_mode();
-
-		// DLSS is instrumental in getting VR to look good. Enable if possible.
-		// If the environment is blended in (such as in XR/AR applications),
-		// DLSS causes jittering at object sillhouettes (doesn't deal well with alpha),
-		// and hence stays disabled.
-		m_dlss = (blend_mode == EEnvironmentBlendMode::Opaque) && m_dlss_provider;
-
-		// Foveated rendering is similarly vital in getting high performance without losing
-		// resolution in the middle of the view.
-		m_foveated_rendering = true;
-
-		// Large minimum transmittance results in another 20-30% performance increase
-		// at the detriment of some transparent edges. Not super noticeable, though.
-		m_nerf.render_min_transmittance = 0.2f;
-
-		// Many VR runtimes perform optical flow for automatic reprojection / motion smoothing.
-		// This breaks down for solid-color background, sometimes leading to artifacts. Hence:
-		// set background color to transparent and, in spherical_checkerboard_kernel(...),
-		// blend a checkerboard. If the user desires a solid background nonetheless, they can
-		// set the background color to have an alpha value of 1.0 manually via the GUI or via Python.
-		m_render_transparency_as_checkerboard = (blend_mode == EEnvironmentBlendMode::Opaque);
-	} else {
-		m_dlss = (m_testbed_mode == ETestbedMode::Nerf) && m_dlss_provider;
-		m_foveated_rendering = false;
-		m_nerf.render_min_transmittance = 0.01f;
-		m_render_transparency_as_checkerboard = false;
-	}
-}
-
 void Testbed::set_n_views(size_t n_views) {
 	while (m_views.size() > n_views) {
 		m_views.pop_back();
@@ -3168,6 +3087,93 @@ void Testbed::destroy_window() {
 #endif //NGP_GUI
 }
 
+void Testbed::init_vr() {
+#ifndef NGP_GUI
+	throw std::runtime_error{"init_vr failed: NGP was built without GUI support"};
+#else
+	try {
+		if (!m_glfw_window) {
+			throw std::runtime_error{"`init_window` must be called before `init_vr`"};
+		}
+
+#if defined(XR_USE_PLATFORM_WIN32)
+		m_hmd = std::make_unique<OpenXRHMD>(wglGetCurrentDC(), glfwGetWGLContext(m_glfw_window));
+#elif defined(XR_USE_PLATFORM_XLIB)
+		Display* xDisplay = glfwGetX11Display();
+		GLXContext glxContext = glfwGetGLXContext(m_glfw_window);
+
+		int glxFBConfigXID = 0;
+		glXQueryContext(xDisplay, glxContext, GLX_FBCONFIG_ID, &glxFBConfigXID);
+		int attributes[3] = { GLX_FBCONFIG_ID, glxFBConfigXID, 0 };
+		int nelements = 1;
+		GLXFBConfig* pglxFBConfig = glXChooseFBConfig(xDisplay, 0, attributes, &nelements);
+		if (nelements != 1 || !pglxFBConfig) {
+			throw std::runtime_error{"init_vr(): Couldn't obtain GLXFBConfig"};
+		}
+
+		GLXFBConfig glxFBConfig = *pglxFBConfig;
+
+		XVisualInfo* visualInfo = glXGetVisualFromFBConfig(xDisplay, glxFBConfig);
+		if (!visualInfo) {
+			throw std::runtime_error{"init_vr(): Couldn't obtain XVisualInfo"};
+		}
+
+		m_hmd = std::make_unique<OpenXRHMD>(xDisplay, visualInfo->visualid, glxFBConfig, glXGetCurrentDrawable(), glxContext);
+#elif defined(XR_USE_PLATFORM_WAYLAND)
+		m_hmd = std::make_unique<OpenXRHMD>(glfwGetWaylandDisplay());
+#endif
+
+		// Enable aggressive optimizations to make the VR experience smooth.
+		update_vr_performance_settings();
+
+		// If multiple GPUs are available, shoot for 60 fps in VR.
+		// Otherwise, it wouldn't be realistic to expect more than 30.
+		m_dynamic_res_target_fps = m_devices.size() > 1 ? 60 : 30;
+		m_background_color = {0.0f, 0.0f, 0.0f, 0.0f};
+	} catch (const std::runtime_error& e) {
+		if (std::string{e.what()}.find("XR_ERROR_FORM_FACTOR_UNAVAILABLE") != std::string::npos) {
+			throw std::runtime_error{"Could not initialize VR. Ensure that SteamVR, OculusVR, or any other OpenXR-compatible runtime is running. Also set it as the active OpenXR runtime."};
+		} else {
+			throw std::runtime_error{fmt::format("Could not initialize VR: {}", e.what())};
+		}
+	}
+#endif //NGP_GUI
+}
+
+void Testbed::update_vr_performance_settings() {
+#ifdef NGP_GUI
+	if (m_hmd) {
+		auto blend_mode = m_hmd->environment_blend_mode();
+
+		// DLSS is instrumental in getting VR to look good. Enable if possible.
+		// If the environment is blended in (such as in XR/AR applications),
+		// DLSS causes jittering at object sillhouettes (doesn't deal well with alpha),
+		// and hence stays disabled.
+		m_dlss = (blend_mode == EEnvironmentBlendMode::Opaque) && m_dlss_provider;
+
+		// Foveated rendering is similarly vital in getting high performance without losing
+		// resolution in the middle of the view.
+		m_foveated_rendering = true;
+
+		// Large minimum transmittance results in another 20-30% performance increase
+		// at the detriment of some transparent edges. Not super noticeable, though.
+		m_nerf.render_min_transmittance = 0.2f;
+
+		// Many VR runtimes perform optical flow for automatic reprojection / motion smoothing.
+		// This breaks down for solid-color background, sometimes leading to artifacts. Hence:
+		// set background color to transparent and, in spherical_checkerboard_kernel(...),
+		// blend a checkerboard. If the user desires a solid background nonetheless, they can
+		// set the background color to have an alpha value of 1.0 manually via the GUI or via Python.
+		m_render_transparency_as_checkerboard = (blend_mode == EEnvironmentBlendMode::Opaque);
+	} else {
+		m_dlss = (m_testbed_mode == ETestbedMode::Nerf) && m_dlss_provider;
+		m_foveated_rendering = false;
+		m_nerf.render_min_transmittance = 0.01f;
+		m_render_transparency_as_checkerboard = false;
+	}
+#endif //NGP_GUI
+}
+
 bool Testbed::frame() {
 #ifdef NGP_GUI
 	if (m_render_window) {