From 61eaa814dae0bf8cb9d57b6050a6c65d48431140 Mon Sep 17 00:00:00 2001
From: Sameer Sheorey <41028320+ssheorey@users.noreply.github.com>
Date: Thu, 13 Aug 2026 15:01:54 -0700
Subject: [PATCH 01/11] Add Windows MSIX packaging for Open3DViewer
Adds AppxManifest, store icon assets, and a build_msix.ps1 packaging script under cpp/apps/Open3DViewer/WindowsMSIX/, wires MSIX packaging into the Open3DViewer CMake target, and adds a Windows CI workflow step to build the MSIX package.
---
.github/workflows/windows.yml | 24 +++++
cpp/apps/CMakeLists.txt | 5 ++
cpp/apps/Open3DViewer/Open3DViewer.xml | 10 +++
.../Open3DViewer/WindowsMSIX/AppxManifest.xml | 85 ++++++++++++++++++
.../WindowsMSIX/Assets/Square150x150Logo.png | Bin 0 -> 8506 bytes
.../WindowsMSIX/Assets/Square44x44Logo.png | Bin 0 -> 1720 bytes
.../WindowsMSIX/Assets/StoreLogo.png | Bin 0 -> 1936 bytes
.../Open3DViewer/WindowsMSIX/build_msix.ps1 | 57 ++++++++++++
8 files changed, 181 insertions(+)
create mode 100644 cpp/apps/Open3DViewer/WindowsMSIX/AppxManifest.xml
create mode 100644 cpp/apps/Open3DViewer/WindowsMSIX/Assets/Square150x150Logo.png
create mode 100644 cpp/apps/Open3DViewer/WindowsMSIX/Assets/Square44x44Logo.png
create mode 100644 cpp/apps/Open3DViewer/WindowsMSIX/Assets/StoreLogo.png
create mode 100644 cpp/apps/Open3DViewer/WindowsMSIX/build_msix.ps1
diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml
index b680f0a191a..70c7de2a96c 100755
--- a/.github/workflows/windows.yml
+++ b/.github/workflows/windows.yml
@@ -296,6 +296,30 @@ jobs:
path: ${{ github.workspace }}/${{ env.VIEWER_ZIP_NAME }}
if-no-files-found: error
+ - name: Setup WinApp CLI
+ if: ${{ matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.BUILD_CUDA_MODULE == 'OFF' && matrix.CONFIG == 'Release' }}
+ uses: microsoft/setup-WinAppCli@v0.1
+
+ - name: Build MSIX
+ if: ${{ matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.BUILD_CUDA_MODULE == 'OFF' && matrix.CONFIG == 'Release' }}
+ run: |
+ $ErrorActionPreference = 'Stop'
+ & "${{ env.SRC_DIR }}\cpp\apps\Open3DViewer\WindowsMSIX\build_msix.ps1" `
+ -SrcDir "${{ env.SRC_DIR }}" `
+ -InstallDir "C:\Program Files\Open3D\bin\Open3D" `
+ -CMakeCachePath "C:/Open3D/build/CMakeCache.txt" `
+ -OutDir "C:\Open3D"
+
+ - name: Upload MSIX
+ if: ${{ matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.BUILD_CUDA_MODULE == 'OFF' && matrix.CONFIG == 'Release' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: open3d-app-windows-msix-amd64
+ path: |
+ C:\Open3D\*.msix
+ C:\Open3D\Open3D.cer
+ if-no-files-found: error
+
- name: Update devel release with viewer
if: ${{ github.ref == 'refs/heads/main' && matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.device == 'cpu' && matrix.CONFIG == 'Release' }}
env:
diff --git a/cpp/apps/CMakeLists.txt b/cpp/apps/CMakeLists.txt
index 107aae62150..f2c4cce7853 100644
--- a/cpp/apps/CMakeLists.txt
+++ b/cpp/apps/CMakeLists.txt
@@ -131,6 +131,11 @@ macro(open3d_add_app_common SRC_DIR APP_NAME TARGET_NAME)
elseif (WIN32)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/../${APP_NAME}")
add_executable(${TARGET_NAME} ${SOURCE_FILES} ${HEADER_FILES})
+ # Suppress the console window while keeping the standard main() entry point (no WinMain).
+ set_target_properties(${TARGET_NAME} PROPERTIES WIN32_EXECUTABLE TRUE)
+ if (MSVC)
+ target_link_options(${TARGET_NAME} PRIVATE "/ENTRY:mainCRTStartup")
+ endif()
else()
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/../${APP_NAME}")
add_executable(${TARGET_NAME} ${SOURCE_FILES} ${HEADER_FILES})
diff --git a/cpp/apps/Open3DViewer/Open3DViewer.xml b/cpp/apps/Open3DViewer/Open3DViewer.xml
index 8300b67da16..6b789ca76ca 100644
--- a/cpp/apps/Open3DViewer/Open3DViewer.xml
+++ b/cpp/apps/Open3DViewer/Open3DViewer.xml
@@ -83,4 +83,14 @@
+
+ 3D Gaussian Splat
+
+
+
+
+ 3D Gaussian Splat (compressed)
+
+
+
diff --git a/cpp/apps/Open3DViewer/WindowsMSIX/AppxManifest.xml b/cpp/apps/Open3DViewer/WindowsMSIX/AppxManifest.xml
new file mode 100644
index 00000000000..af0132c3346
--- /dev/null
+++ b/cpp/apps/Open3DViewer/WindowsMSIX/AppxManifest.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+ Open3D
+ Open3D
+ Assets\StoreLogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Assets\Square44x44Logo.png
+ Open3D 3D file
+
+ .ply
+ .stl
+ .obj
+ .off
+ .fbx
+ .glb
+ .gltf
+ .pcd
+ .pts
+ .xyz
+ .splat
+ .spz
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cpp/apps/Open3DViewer/WindowsMSIX/Assets/Square150x150Logo.png b/cpp/apps/Open3DViewer/WindowsMSIX/Assets/Square150x150Logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..f218d5b4f32f22472f5951513bf6a177b13808cc
GIT binary patch
literal 8506
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
+param(
+ [Parameter(Mandatory = $true)][string]$SrcDir,
+ [Parameter(Mandatory = $true)][string]$InstallDir,
+ [Parameter(Mandatory = $true)][string]$CMakeCachePath,
+ [Parameter(Mandatory = $true)][string]$OutDir
+)
+
+$ErrorActionPreference = 'Stop'
+
+# Build a four-part MSIX version from the Open3D version in CMakeCache.
+# MSIX requires Major.Minor.Patch.Build (all numeric).
+$RAW_VER = (Select-String -Path $CMakeCachePath `
+ -Pattern "OPEN3D_VERSION_FULL:STRING=").Line.Split('=')[1].Trim()
+$MSIX_VERSION = ($RAW_VER -replace '[^0-9.]', '').TrimEnd('.') + '.0'
+$OPEN3D_VERSION = $RAW_VER -replace '[^0-9.]', ''
+
+# Stage: start from the already-installed viewer folder and add manifest + assets.
+$STAGING = Join-Path $OutDir "msix-staging"
+New-Item -ItemType Directory -Force -Path $STAGING | Out-Null
+Copy-Item -Recurse (Join-Path $InstallDir "*") $STAGING
+$SRC_MSIX = Join-Path $SrcDir "cpp\apps\Open3DViewer\WindowsMSIX"
+Copy-Item (Join-Path $SRC_MSIX "AppxManifest.xml") $STAGING
+Copy-Item -Recurse (Join-Path $SRC_MSIX "Assets") $STAGING
+
+# Substitute version placeholder in the staged manifest.
+$manifestPath = Join-Path $STAGING "AppxManifest.xml"
+(Get-Content $manifestPath) -replace '@OPEN3D_MSIX_VERSION@', $MSIX_VERSION |
+ Set-Content $manifestPath
+
+# Generate self-signed cert whose subject matches Publisher="CN=Open3D".
+# Export .cer (public key only) so users can install it to trust the package.
+winapp cert generate `
+ --manifest $manifestPath `
+ --output (Join-Path $OutDir "Open3D.pfx") `
+ --export-cer `
+ --if-exists overwrite
+
+# Pack and sign the MSIX.
+$MSIX_NAME = "Open3DViewer-$OPEN3D_VERSION-x64.msix"
+winapp pack $STAGING `
+ --output (Join-Path $OutDir $MSIX_NAME) `
+ --cert (Join-Path $OutDir "Open3D.pfx")
+
+echo "MSIX_NAME=$MSIX_NAME" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
From 9a78ca1f09275d9b4c36a70f5635a7813c9acce5 Mon Sep 17 00:00:00 2001
From: Sameer Sheorey <41028320+ssheorey@users.noreply.github.com>
Date: Thu, 13 Aug 2026 15:02:12 -0700
Subject: [PATCH 02/11] Fix Windows crash on Gaussian Splat GL/Vulkan interop
with hybrid GPUs
On hybrid-GPU systems (e.g. Intel integrated + discrete), Vulkan's discrete-GPU-preferred device selection could pick a different physical adapter than the one the OS bound the OpenGL context to, causing GL_EXT_memory_object cross-adapter texture import to silently fail with GL_OUT_OF_MEMORY and crash later with STATUS_FAIL_FAST_EXCEPTION in glTextureStorageMem2DEXT.
Fix: create the shared OpenGL context before Vulkan initialization, query its DXGI adapter LUID (Windows) or GL_DEVICE_UUID_EXT (other platforms) via GaussianSplatOpenGLContext::GetAdapterId(), and thread it into GaussianSplatVulkanInteropContext::SelectPhysicalDevice() so Vulkan device selection prefers the physical device matching the GL context's adapter, falling back to best-effort scoring if no match is found.
Also fixes an unrelated HANDLE-to-int truncation bug in AllocateExportableImage, sets GL_DEDICATED_MEMORY_OBJECT_EXT and GL_TEXTURE_TILING_EXT before importing Vulkan-allocated memory into GL (required since allocations use dedicated allocation and optimal tiling), and disables MSAA before render target setup in the geometry pass.
---
.../rendering/filament/FilamentEngine.cpp | 58 +++++++-----
.../GaussianSplatOpenGLContext.cpp | 81 +++++++++++++++++
.../GaussianSplatOpenGLContext.h | 16 ++++
.../GaussianSplatVulkanBackend.cpp | 6 +-
.../GaussianSplatVulkanInteropContext.cpp | 91 ++++++++++++++++---
.../GaussianSplatVulkanInteropContext.h | 30 ++++--
6 files changed, 239 insertions(+), 43 deletions(-)
diff --git a/cpp/open3d/visualization/rendering/filament/FilamentEngine.cpp b/cpp/open3d/visualization/rendering/filament/FilamentEngine.cpp
index 1d0e2ffa847..b4c77f0be57 100644
--- a/cpp/open3d/visualization/rendering/filament/FilamentEngine.cpp
+++ b/cpp/open3d/visualization/rendering/filament/FilamentEngine.cpp
@@ -22,6 +22,7 @@
#endif // _MSC_VER
#include // recursive includes needs this, std::size_t especially
+#include
#include "open3d/utility/FileSystem.h"
#include "open3d/visualization/rendering/filament/FilamentResourceManager.h"
@@ -126,32 +127,18 @@ EngineInstance::EngineInstance() {
backend = filament::backend::Backend::OPENGL;
}
- // Initialise the Vulkan interop context BEFORE the GL context so that
- // Vulkan device memory allocations and exported FDs are ready for the
- // GL EXT_memory_object import calls made during PrepareOutputTextures().
- // Failure is non-fatal: the Vulkan backend will fall back gracefully.
- {
- auto& vk_ctx = GaussianSplatVulkanInteropContext::GetInstance();
- if (!vk_ctx.IsValid()) {
- if (!vk_ctx.Initialize()) {
- utility::LogWarning(
- "EngineInstance: Vulkan interop context init failed: "
- "{}",
- vk_ctx.GetLastError());
- }
- }
- }
-
- // On Linux (X11/XWayland via GLX) and Windows (WGL), create our compute
- // GL context BEFORE the Filament engine so we can pass it as the
- // sharedGLContext. Filament then creates its own context sharing our GL
- // namespace, enabling zero-copy texture import() between the two
- // contexts. This must happen before Engine::create() because GLX/WGL
+ // Create our compute GL context BEFORE the Vulkan interop context so we
+ // can read the GPU adapter's GL_DEVICE_UUID_EXT and select a *matching*
+ // Vulkan physical device. GL_EXT_memory_object cross-adapter import is
+ // not supported: if Vulkan picks a different GPU than the GL context is
+ // bound to (e.g. multi-GPU laptops), the memory import silently fails
+ // (GL_OUT_OF_MEMORY) even though the Vulkan-side export succeeds.
+ // This must also happen before Engine::create() because GLX/WGL context
// sharing can only be established at context creation time.
+ auto& gl_ctx = GaussianSplatOpenGLContext::GetInstance();
if ((backend == filament::backend::Backend::OPENGL ||
backend == filament::backend::Backend::DEFAULT) &&
!shared_context_) {
- auto& gl_ctx = GaussianSplatOpenGLContext::GetInstance();
if (!gl_ctx.IsValid()) {
gl_ctx.InitializeStandalone();
}
@@ -163,6 +150,33 @@ EngineInstance::EngineInstance() {
shared_context_);
}
}
+
+ // Initialise the Vulkan interop context, preferring the physical device
+ // whose adapter id matches the GL context's adapter (see above). Failure
+ // is non-fatal: the Vulkan backend will fall back gracefully.
+ {
+ auto& vk_ctx = GaussianSplatVulkanInteropContext::GetInstance();
+ if (!vk_ctx.IsValid()) {
+ std::uint8_t gl_adapter_id[16];
+ std::size_t gl_adapter_id_size = 0;
+ const bool have_id =
+ gl_ctx.IsValid() &&
+ gl_ctx.GetAdapterId(gl_adapter_id, gl_adapter_id_size);
+ if (!vk_ctx.Initialize(have_id ? gl_adapter_id : nullptr,
+ have_id ? gl_adapter_id_size : 0)) {
+ utility::LogWarning(
+ "EngineInstance: Vulkan interop context init failed: "
+ "{}",
+ vk_ctx.GetLastError());
+ }
+ }
+ // GL context is already current at this point (created above), so
+ // the interop extension probe can run now.
+ if (vk_ctx.IsValid() && gl_ctx.IsValid() &&
+ !vk_ctx.AreGLExtensionsReady()) {
+ vk_ctx.ProbeGLExtensions();
+ }
+ }
#endif
filament::Engine::Config fmcfg;
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp
index a1256f4a12d..5a4f6bb1505 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp
@@ -20,6 +20,7 @@
#if !defined(__APPLE__)
+#include
#include
#include
@@ -41,6 +42,11 @@
#endif
#include
+#if defined(_WIN32)
+#include
+#pragma comment(lib, "dxgi.lib")
+#endif
+
#include "open3d/utility/Logging.h"
namespace open3d {
@@ -171,6 +177,81 @@ void* GaussianSplatOpenGLContext::GetNativeContext() const {
#endif
}
+bool GaussianSplatOpenGLContext::GetAdapterId(std::uint8_t out_id[16],
+ std::size_t& out_size) const {
+ if (!initialized_ || !glfw_window_) {
+ return false;
+ }
+#if defined(_WIN32)
+ // Find the DXGI adapter driving the monitor this (hidden) window is
+ // associated with, and return its 8-byte LUID. This is used to match
+ // against Vulkan's VkPhysicalDeviceIDProperties::deviceLUID so the
+ // Vulkan device selected for GL_EXT_memory_object interop is guaranteed
+ // to be the *same* physical GPU as this GL context — required because
+ // cross-adapter memory import silently fails (GL_OUT_OF_MEMORY).
+ HWND hwnd = glfwGetWin32Window(static_cast(glfw_window_));
+ if (!hwnd) return false;
+ HMONITOR mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY);
+
+ IDXGIFactory1* factory = nullptr;
+ if (FAILED(CreateDXGIFactory1(__uuidof(IDXGIFactory1),
+ reinterpret_cast(&factory))) ||
+ !factory) {
+ return false;
+ }
+
+ bool found = false;
+ LUID luid{};
+ for (UINT i = 0;; ++i) {
+ IDXGIAdapter1* adapter = nullptr;
+ if (factory->EnumAdapters1(i, &adapter) == DXGI_ERROR_NOT_FOUND) {
+ break;
+ }
+ if (!adapter) continue;
+ for (UINT j = 0;; ++j) {
+ IDXGIOutput* output = nullptr;
+ if (adapter->EnumOutputs(j, &output) == DXGI_ERROR_NOT_FOUND) {
+ break;
+ }
+ if (!output) continue;
+ DXGI_OUTPUT_DESC odesc{};
+ if (SUCCEEDED(output->GetDesc(&odesc)) &&
+ odesc.Monitor == mon) {
+ DXGI_ADAPTER_DESC1 adesc{};
+ if (SUCCEEDED(adapter->GetDesc1(&adesc))) {
+ luid = adesc.AdapterLuid;
+ found = true;
+ }
+ }
+ output->Release();
+ if (found) break;
+ }
+ adapter->Release();
+ if (found) break;
+ }
+ factory->Release();
+
+ if (!found) return false;
+ std::memcpy(out_id, &luid, sizeof(luid));
+ out_size = sizeof(luid);
+ return true;
+#else
+ if (GLEW_EXT_memory_object == 0) {
+ return false;
+ }
+ GLint num_uuids = 0;
+ glGetIntegerv(GL_NUM_DEVICE_UUIDS_EXT, &num_uuids);
+ if (num_uuids < 1) {
+ return false;
+ }
+ glGetUnsignedBytei_vEXT(GL_DEVICE_UUID_EXT, 0,
+ reinterpret_cast(out_id));
+ if (glGetError() != GL_NO_ERROR) return false;
+ out_size = 16;
+ return true;
+#endif
+}
+
void GaussianSplatOpenGLContext::Shutdown() {
if (!initialized_) {
return;
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.h b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.h
index b324e76781f..65e142e1140 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.h
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.h
@@ -15,6 +15,9 @@
#pragma once
+#include
+#include
+
#if !defined(__APPLE__)
namespace open3d {
@@ -50,6 +53,19 @@ class GaussianSplatOpenGLContext {
/// Windows -> HGLRC
void* GetNativeContext() const;
+ /// Queries an identifier for the GPU adapter this context is bound to,
+ /// so callers (Vulkan interop) can select a *matching* physical device.
+ /// GL_EXT_memory_object cross-adapter import silently fails, so this
+ /// match is required on multi-GPU (hybrid graphics) systems.
+ /// Windows -> DXGI adapter LUID (8 bytes), via the monitor the
+ /// context's window is associated with. More reliable than
+ /// GL_DEVICE_UUID_EXT, which some drivers advertise but do
+ /// not actually implement (observed: Intel Iris Xe/Arc
+ /// hybrid driver returns GL_INVALID_OPERATION for it).
+ /// Other platforms -> GL_DEVICE_UUID_EXT (16 bytes).
+ /// Returns false (leaving out_id untouched) if unavailable.
+ bool GetAdapterId(std::uint8_t out_id[16], std::size_t& out_size) const;
+
/// Destroys the context and associated resources.
void Shutdown();
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanBackend.cpp b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanBackend.cpp
index c2703d3e658..2bf70bedeb5 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanBackend.cpp
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanBackend.cpp
@@ -179,12 +179,14 @@ class GaussianSplatVulkanBackend final : public GaussianSplatRenderer::Backend {
targets.render_target =
resource_mgr.CreateRenderTarget(view_color, targets.depth);
}
- view.SetRenderTarget(targets.render_target);
-
+ // Disable MSAA before binding the render target: Filament validates
+ // MSAA/sampleable-depth compatibility inside SetRenderTarget() (see #7495).
auto* native = view.GetNativeView();
auto msaa = native->getMultiSampleAntiAliasingOptions();
msaa.enabled = false;
native->setMultiSampleAntiAliasingOptions(msaa);
+
+ view.SetRenderTarget(targets.render_target);
view.SetPostProcessing(false);
return static_cast(targets.render_target);
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp
index 2a327943d6e..e7061496a7a 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp
@@ -169,7 +169,8 @@ GaussianSplatVulkanInteropContext::~GaussianSplatVulkanInteropContext() {
// Initialize / Shutdown
// ---------------------------------------------------------------------------
-bool GaussianSplatVulkanInteropContext::Initialize() {
+bool GaussianSplatVulkanInteropContext::Initialize(
+ const std::uint8_t* gl_adapter_id, std::size_t gl_adapter_id_size) {
if (initialized_) return true;
// Initialize the global dynamic dispatcher with vkGetInstanceProcAddr
@@ -192,7 +193,7 @@ bool GaussianSplatVulkanInteropContext::Initialize() {
// function pointers (required for physical device enumeration etc.).
VULKAN_HPP_DEFAULT_DISPATCHER.init(static_cast(*instance_));
- if (!SelectPhysicalDevice()) return false;
+ if (!SelectPhysicalDevice(gl_adapter_id, gl_adapter_id_size)) return false;
if (!CreateLogicalDevice()) return false;
// After device creation, update the dispatcher with device-level
@@ -314,7 +315,8 @@ bool GaussianSplatVulkanInteropContext::CreateInstance() {
// Physical device selection
// ---------------------------------------------------------------------------
-bool GaussianSplatVulkanInteropContext::SelectPhysicalDevice() {
+bool GaussianSplatVulkanInteropContext::SelectPhysicalDevice(
+ const std::uint8_t* gl_adapter_id, std::size_t gl_adapter_id_size) {
auto devices = instance_.enumeratePhysicalDevices();
if (devices.empty()) {
last_error_ = "No Vulkan-capable devices found";
@@ -322,16 +324,61 @@ bool GaussianSplatVulkanInteropContext::SelectPhysicalDevice() {
return false;
}
+ // GL_EXT_memory_object interop requires the Vulkan device and the GL
+ // context to be the *same* physical GPU adapter: cross-adapter import
+ // is not supported and fails silently (GL_OUT_OF_MEMORY) even though
+ // the Vulkan-side export succeeds. When the GL adapter id is known
+ // (8-byte DXGI LUID on Windows, 16-byte GL_DEVICE_UUID_EXT elsewhere),
+ // restrict candidates to the matching device first.
+ const bool have_id = gl_adapter_id != nullptr &&
+ (gl_adapter_id_size == 8 || gl_adapter_id_size == 16);
+
int best_score = -1;
std::size_t best_idx = devices.size();
for (std::size_t i = 0; i < devices.size(); ++i) {
- const int score = ScoreDevice(devices[i]);
+ int score = ScoreDevice(devices[i]);
+ if (score < 0) continue;
+ if (have_id) {
+ const auto id_props =
+ devices[i].getProperties2();
+ const auto& id = id_props.get();
+ bool matches = false;
+ if (gl_adapter_id_size == 8) {
+ matches = id.deviceLUIDValid &&
+ std::memcmp(id.deviceLUID.data(), gl_adapter_id,
+ 8) == 0;
+ } else {
+ matches = std::memcmp(id.deviceUUID.data(), gl_adapter_id,
+ 16) == 0;
+ }
+ if (!matches) continue; // not the GL-bound adapter; skip
+ score += 100; // guarantee a match wins over any non-match
+ }
if (score > best_score) {
best_score = score;
best_idx = i;
}
}
+ if (best_idx == devices.size() && have_id) {
+ // No device matched the GL adapter. Falling back to best-effort
+ // scoring risks a repeat of the cross-adapter import failure, but a
+ // working (if unshared) device is still better than none for
+ // callers that only need compute, so fall back with a warning.
+ utility::LogWarning(
+ "GaussianSplat Vulkan: no physical device matched the GL "
+ "context's adapter; GL/Vulkan interop may fail. Falling "
+ "back to best-effort device scoring.");
+ for (std::size_t i = 0; i < devices.size(); ++i) {
+ const int score = ScoreDevice(devices[i]);
+ if (score > best_score) {
+ best_score = score;
+ best_idx = i;
+ }
+ }
+ }
+
if (best_idx == devices.size()) {
last_error_ =
"No suitable Vulkan device found with required interop "
@@ -474,7 +521,7 @@ bool GaussianSplatVulkanInteropContext::AllocateExportableImage(
VkImageUsageFlags usage,
VkImage& out_image,
VkDeviceMemory& out_memory,
- int& out_fd) const {
+ intptr_t& out_fd) const {
const vk::Format format = static_cast(vk_format);
const vk::ImageUsageFlags vk_usage =
static_cast(usage);
@@ -576,10 +623,12 @@ bool GaussianSplatVulkanInteropContext::AllocateExportableImage(
dev.destroyImage(image);
return false;
}
- out_fd = static_cast(reinterpret_cast(win32_handle));
+ // Stored as intptr_t (not int) to avoid truncating/sign-extending the
+ // 64-bit HANDLE, which previously corrupted the value passed to GL.
+ out_fd = reinterpret_cast(win32_handle);
// NOTE: on Windows, HANDLE values are not real file descriptors and should
// be closed with CloseHandle() after being imported into GL. However, the
- // current design passes them as int (out_fd) to ImportFDIntoGL, which will
+ // current design passes them as out_fd to ImportFDIntoGL, which will
// pass them to glImportMemoryWin32HandleEXT. GL takes ownership of the
// HANDLE import, so we cannot close it here. The HANDLE lifetime is managed
// by GL and Vulkan.
@@ -602,7 +651,7 @@ bool GaussianSplatVulkanInteropContext::AllocateExportableImage(
}
bool GaussianSplatVulkanInteropContext::ImportFDIntoGL(
- int fd,
+ intptr_t fd,
std::uint32_t width,
std::uint32_t height,
VkDeviceSize memory_size,
@@ -617,9 +666,20 @@ bool GaussianSplatVulkanInteropContext::ImportFDIntoGL(
return false;
}
+ // AllocateExportableImage() always allocates via
+ // vk::MemoryDedicatedAllocateInfo, so GL must be told this memory object
+ // is dedicated to a single resource before the handle/fd is imported.
+ // Omitting this causes some Windows GL drivers to silently fail the
+ // import (GL_OUT_OF_MEMORY) and crash on the subsequent storage call.
+ {
+ const GLint dedicated = GL_TRUE;
+ glMemoryObjectParameterivEXT(out_gl_memory_object,
+ GL_DEDICATED_MEMORY_OBJECT_EXT,
+ &dedicated);
+ }
+
#if defined(_WIN32)
- const HANDLE win32_handle =
- reinterpret_cast(static_cast(fd));
+ const HANDLE win32_handle = reinterpret_cast(fd);
glImportMemoryWin32HandleEXT(out_gl_memory_object,
static_cast(memory_size),
GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, win32_handle);
@@ -630,7 +690,7 @@ bool GaussianSplatVulkanInteropContext::ImportFDIntoGL(
#else
glImportMemoryFdEXT(out_gl_memory_object,
static_cast(memory_size),
- GL_HANDLE_TYPE_OPAQUE_FD_EXT, fd);
+ GL_HANDLE_TYPE_OPAQUE_FD_EXT, static_cast(fd));
// FD ownership is transferred to GL; do not close it.
#endif
@@ -643,6 +703,13 @@ bool GaussianSplatVulkanInteropContext::ImportFDIntoGL(
return false;
}
+ // The imported Vulkan image was allocated with optimal (driver-opaque)
+ // tiling; GL defaults imported textures to linear tiling, so this must be
+ // set explicitly or glTextureStorageMem2DEXT operates on a mismatched
+ // memory layout (crashes some Windows drivers instead of erroring).
+ glTextureParameteri(out_gl_texture, GL_TEXTURE_TILING_EXT,
+ GL_OPTIMAL_TILING_EXT);
+
// Allocate GL texture storage bound to the imported memory.
GLenum gl_internal_format = 0;
switch (format) {
@@ -704,7 +771,7 @@ SharedImageDesc GaussianSplatVulkanInteropContext::CreateSharedImage(
break;
}
- int export_fd = -1;
+ intptr_t export_fd = -1;
{
// Probe memory requirements using a temporary image (no exportable
// memory yet) to determine the actual allocation size for GL import.
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.h b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.h
index e7927feae41..81d4a4f6639 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.h
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.h
@@ -32,6 +32,7 @@
#if !defined(__APPLE__)
+#include
#include
#include
@@ -110,10 +111,22 @@ class GaussianSplatVulkanInteropContext {
/// Load Vulkan via BlueVK, select a physical device with external-memory
/// extension support, and create a compute queue.
- /// Must be called BEFORE
- /// GaussianSplatOpenGLContext::InitializeStandalone(). Returns false on
- /// failure; call GetLastError() for a diagnostic string.
- bool Initialize();
+ ///
+ /// `gl_adapter_id`/`gl_adapter_id_size`, if `gl_adapter_id` is non-null,
+ /// identify the GPU adapter the GL interop context is bound to (see
+ /// GaussianSplatOpenGLContext::GetAdapterId()): an 8-byte DXGI LUID on
+ /// Windows or a 16-byte GL_DEVICE_UUID_EXT elsewhere. When provided,
+ /// only physical devices whose VkPhysicalDeviceIDProperties deviceLUID
+ /// (8 bytes) or deviceUUID (16 bytes) matches are eligible:
+ /// GL_EXT_memory_object import requires the Vulkan device and the GL
+ /// context to be on the *same* GPU adapter, otherwise the import
+ /// silently fails (GL_OUT_OF_MEMORY) even though the Vulkan-side export
+ /// succeeds. If null, or no Vulkan device matches, falls back to the
+ /// old best-effort scoring (may pick a mismatched adapter on multi-GPU
+ /// systems).
+ /// Returns false on failure; call GetLastError() for a diagnostic string.
+ bool Initialize(const std::uint8_t* gl_adapter_id = nullptr,
+ std::size_t gl_adapter_id_size = 0);
/// Release all Vulkan resources and invalidate the context.
void Shutdown();
@@ -209,7 +222,8 @@ class GaussianSplatVulkanInteropContext {
// --- Internal helpers -------------------------------------------------
bool CreateInstance();
- bool SelectPhysicalDevice();
+ bool SelectPhysicalDevice(const std::uint8_t* gl_adapter_id,
+ std::size_t gl_adapter_id_size);
bool CreateLogicalDevice();
/// Allocate a VkImage with a dedicated exportable memory allocation and
@@ -220,11 +234,13 @@ class GaussianSplatVulkanInteropContext {
VkImageUsageFlags usage,
VkImage& out_image,
VkDeviceMemory& out_memory,
- int& out_fd) const;
+ // intptr_t (not int): must hold a 64-bit
+ // Windows HANDLE without truncation.
+ intptr_t& out_fd) const;
/// Import a Vulkan FD into an OpenGL memory-object and create a GL
/// texture backed by that memory object.
- bool ImportFDIntoGL(int fd,
+ bool ImportFDIntoGL(intptr_t fd,
std::uint32_t width,
std::uint32_t height,
VkDeviceSize memory_size,
From ff19e0eb058451fc39053c45b5e7fcc020c65682 Mon Sep 17 00:00:00 2001
From: Sameer Sheorey <41028320+ssheorey@users.noreply.github.com>
Date: Thu, 13 Aug 2026 22:45:20 -0700
Subject: [PATCH 03/11] Fix Gaussian Splat GPU adapter interop
---
cpp/open3d/visualization/CMakeLists.txt | 1 +
cpp/open3d/visualization/app/Viewer.cpp | 2 +
.../rendering/GpuAdapterSelection.cpp | 292 ++++++++++++++++++
.../rendering/GpuAdapterSelection.h | 101 ++++++
.../rendering/filament/FilamentEngine.cpp | 90 ++++--
.../GaussianSplatOpenGLContext.cpp | 80 -----
.../GaussianSplatOpenGLContext.h | 15 +-
.../GaussianSplatPassRunner.cpp | 11 +-
.../GaussianSplatVulkanInteropContext.cpp | 117 ++++---
.../GaussianSplatVulkanInteropContext.h | 26 +-
10 files changed, 527 insertions(+), 208 deletions(-)
create mode 100644 cpp/open3d/visualization/rendering/GpuAdapterSelection.cpp
create mode 100644 cpp/open3d/visualization/rendering/GpuAdapterSelection.h
diff --git a/cpp/open3d/visualization/CMakeLists.txt b/cpp/open3d/visualization/CMakeLists.txt
index aa23190770a..a49870d7149 100644
--- a/cpp/open3d/visualization/CMakeLists.txt
+++ b/cpp/open3d/visualization/CMakeLists.txt
@@ -110,6 +110,7 @@ if (BUILD_GUI)
else()
target_sources(visualization_impl PRIVATE
rendering/gaussian_splat/ComputeGPUVulkan.cpp
+ rendering/GpuAdapterSelection.cpp
rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp
rendering/gaussian_splat/GaussianSplatVulkanBackend.cpp
rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp
diff --git a/cpp/open3d/visualization/app/Viewer.cpp b/cpp/open3d/visualization/app/Viewer.cpp
index c4b965bbb9c..84684b68c00 100644
--- a/cpp/open3d/visualization/app/Viewer.cpp
+++ b/cpp/open3d/visualization/app/Viewer.cpp
@@ -24,6 +24,8 @@ static const int width = 1280;
static const int height = 960;
void RunViewer(int argc, const char *argv[]) {
+ utility::Logger::GetInstance().SetVerbosityLevel(
+ utility::VerbosityLevel::Debug);
std::function print_fcn =
utility::Logger::GetInstance().GetPrintFunction();
utility::Logger::GetInstance().ResetPrintFunction();
diff --git a/cpp/open3d/visualization/rendering/GpuAdapterSelection.cpp b/cpp/open3d/visualization/rendering/GpuAdapterSelection.cpp
new file mode 100644
index 00000000000..870717ef132
--- /dev/null
+++ b/cpp/open3d/visualization/rendering/GpuAdapterSelection.cpp
@@ -0,0 +1,292 @@
+// ----------------------------------------------------------------------------
+// - Open3D: www.open3d.org -
+// ----------------------------------------------------------------------------
+// Copyright (c) 2018-2024 www.open3d.org
+// SPDX-License-Identifier: MIT
+// ----------------------------------------------------------------------------
+
+#if defined(_WIN32)
+#ifndef VK_USE_PLATFORM_WIN32_KHR
+#define VK_USE_PLATFORM_WIN32_KHR 1
+#endif
+#ifndef WIN32_LEAN_AND_MEAN
+#define WIN32_LEAN_AND_MEAN
+#endif
+#ifndef NOMINMAX
+#define NOMINMAX
+#endif
+#include
+
+#include
+#pragma comment(lib, "dxgi.lib")
+#endif
+
+#include "open3d/visualization/rendering/GpuAdapterSelection.h"
+
+#if !defined(__APPLE__)
+
+#ifndef VK_NO_PROTOTYPES
+#define VK_NO_PROTOTYPES
+#endif
+#include
+
+#define GLFW_INCLUDE_NONE
+#include
+#if defined(_WIN32)
+#define GLFW_EXPOSE_NATIVE_WIN32
+#include
+#endif
+
+#include
+
+#include
+#include
+#include
+
+#include
+
+#include "open3d/utility/Logging.h"
+
+namespace open3d {
+namespace visualization {
+namespace rendering {
+
+#if defined(_WIN32)
+
+namespace {
+
+/// Finds the DXGI adapter identified by `info` and returns the desktop rect
+/// of its first output, if it drives one at all.
+bool FindMonitorRectForAdapter(const GpuAdapterInfo& info, RECT* out_rect) {
+ IDXGIFactory1* factory = nullptr;
+ if (FAILED(CreateDXGIFactory1(__uuidof(IDXGIFactory1),
+ reinterpret_cast(&factory))) ||
+ !factory) {
+ return false;
+ }
+
+ bool found = false;
+ for (UINT i = 0;; ++i) {
+ IDXGIAdapter1* adapter = nullptr;
+ if (factory->EnumAdapters1(i, &adapter) == DXGI_ERROR_NOT_FOUND) {
+ break;
+ }
+ if (!adapter) continue;
+ DXGI_ADAPTER_DESC1 adesc{};
+ if (SUCCEEDED(adapter->GetDesc1(&adesc)) &&
+ std::memcmp(&adesc.AdapterLuid, info.luid,
+ sizeof(info.luid)) == 0) {
+ IDXGIOutput* output = nullptr;
+ if (adapter->EnumOutputs(0, &output) != DXGI_ERROR_NOT_FOUND &&
+ output) {
+ DXGI_OUTPUT_DESC odesc{};
+ if (SUCCEEDED(output->GetDesc(&odesc))) {
+ *out_rect = odesc.DesktopCoordinates;
+ found = true;
+ }
+ output->Release();
+ }
+ }
+ adapter->Release();
+ if (found) break;
+ }
+ factory->Release();
+ return found;
+}
+
+} // namespace
+
+GpuAdapterInfo GetAdapterInfo(VkPhysicalDevice physical_device) {
+ GpuAdapterInfo info;
+ vk::PhysicalDevice pd(physical_device);
+ const auto chain =
+ pd.getProperties2();
+ info.device_name = chain.get()
+ .properties.deviceName.data();
+ const auto& id = chain.get();
+ if (id.deviceLUIDValid) {
+ std::memcpy(info.luid, id.deviceLUID.data(), sizeof(info.luid));
+ info.valid = true;
+ }
+ return info;
+}
+
+bool SteerNextGLContextToAdapter(const GpuAdapterInfo& info) {
+ if (!info.valid) return false;
+
+ RECT rect{};
+ if (!FindMonitorRectForAdapter(info, &rect)) {
+ utility::LogWarning(
+ "GpuAdapterSelection: could not find a monitor driven by "
+ "adapter '{}'; GL context will use its default adapter.",
+ info.device_name);
+ return false;
+ }
+
+ // GLFW binds a WGL context to whatever adapter drives the monitor the
+ // window is created on, so positioning the (still hidden) window on
+ // that monitor before glfwCreateWindow() steers the context there.
+ glfwWindowHint(GLFW_POSITION_X, rect.left);
+ glfwWindowHint(GLFW_POSITION_Y, rect.top);
+ return true;
+}
+
+GpuAdapterInfo GetAdapterInfoForWindow(void* glfw_window) {
+ if (!glfw_window) return GpuAdapterInfo();
+ HWND hwnd = glfwGetWin32Window(static_cast(glfw_window));
+ if (!hwnd) return GpuAdapterInfo();
+ HMONITOR monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY);
+
+ IDXGIFactory1* factory = nullptr;
+ if (FAILED(CreateDXGIFactory1(__uuidof(IDXGIFactory1),
+ reinterpret_cast(&factory))) ||
+ !factory) {
+ return GpuAdapterInfo();
+ }
+
+ GpuAdapterInfo info;
+ for (UINT i = 0;; ++i) {
+ IDXGIAdapter1* adapter = nullptr;
+ if (factory->EnumAdapters1(i, &adapter) == DXGI_ERROR_NOT_FOUND) {
+ break;
+ }
+ if (!adapter) continue;
+ bool found = false;
+ for (UINT j = 0;; ++j) {
+ IDXGIOutput* output = nullptr;
+ if (adapter->EnumOutputs(j, &output) == DXGI_ERROR_NOT_FOUND) {
+ break;
+ }
+ if (!output) continue;
+ DXGI_OUTPUT_DESC output_desc{};
+ if (SUCCEEDED(output->GetDesc(&output_desc)) &&
+ output_desc.Monitor == monitor) {
+ DXGI_ADAPTER_DESC1 adapter_desc{};
+ if (SUCCEEDED(adapter->GetDesc1(&adapter_desc))) {
+ std::memcpy(info.luid, &adapter_desc.AdapterLuid,
+ sizeof(info.luid));
+ const std::wstring name(adapter_desc.Description);
+ info.device_name.assign(name.begin(), name.end());
+ info.valid = true;
+ found = true;
+ }
+ }
+ output->Release();
+ if (found) break;
+ }
+ adapter->Release();
+ if (found) break;
+ }
+ factory->Release();
+ return info;
+}
+
+#else // !_WIN32
+
+GpuAdapterInfo GetAdapterInfo(VkPhysicalDevice physical_device) {
+ GpuAdapterInfo info;
+ vk::PhysicalDevice pd(physical_device);
+ const auto base_props = pd.getProperties();
+ info.device_name = base_props.deviceName.data();
+
+ bool has_pci_ext = false;
+ for (const auto& ext : pd.enumerateDeviceExtensionProperties()) {
+ if (std::strcmp(ext.extensionName,
+ VK_EXT_PCI_BUS_INFO_EXTENSION_NAME) == 0) {
+ has_pci_ext = true;
+ break;
+ }
+ }
+ if (!has_pci_ext) return info; // info.valid stays false
+
+ const auto chain =
+ pd.getProperties2();
+ const auto& pci =
+ chain.get();
+ info.pci_domain = pci.pciDomain;
+ info.pci_bus = pci.pciBus;
+ info.pci_device = pci.pciDevice;
+ info.pci_function = pci.pciFunction;
+
+ const auto driver_chain =
+ pd.getProperties2();
+ info.is_nvidia = driver_chain.get()
+ .driverID == vk::DriverId::eNvidiaProprietary;
+ info.valid = true;
+ return info;
+}
+
+bool SteerNextGLContextToAdapter(const GpuAdapterInfo& info) {
+ if (!info.valid) return false;
+
+ const std::string pci_id = fmt::format(
+ "pci-{:04x}_{:02x}_{:02x}_{:01x}", info.pci_domain,
+ info.pci_bus, info.pci_device, info.pci_function);
+
+ // EXPERIMENTAL: no portable GLX API exists to select a specific GPU, so
+ // this relies on the Mesa/NVIDIA PRIME-offload env var convention, which
+ // only takes effect if set before the first GL/GLX context is created
+ // in this process.
+ setenv("DRI_PRIME", pci_id, 1);
+ if (info.is_nvidia) {
+ setenv("__NV_PRIME_RENDER_OFFLOAD", "1", 1);
+ setenv("__GLX_VENDOR_LIBRARY_NAME", "nvidia", 1);
+ }
+ utility::LogDebug(
+ "GpuAdapterSelection: best-effort steering next GL context to "
+ "'{}' ({}) via DRI_PRIME{}. This is experimental; verify with "
+ "GetCurrentGLAdapterUUID().",
+ info.device_name, pci_id, info.is_nvidia ? "/NVIDIA PRIME" : "");
+ return true;
+}
+
+#endif // _WIN32
+
+#if !defined(_WIN32)
+GpuAdapterInfo GetAdapterInfoForWindow(void* /*glfw_window*/) {
+ // No portable GLX/EGL API exists to query the GPU adapter backing an
+ // existing context, so verifying SteerNextGLContextToAdapter() actually
+ // took effect is not currently supported on this platform.
+ return GpuAdapterInfo();
+}
+
+#endif
+
+bool SameAdapter(const GpuAdapterInfo& a, const GpuAdapterInfo& b) {
+ if (!a.valid || !b.valid) return false;
+#if defined(_WIN32)
+ return std::memcmp(a.luid, b.luid, sizeof(a.luid)) == 0;
+#else
+ return a.pci_domain == b.pci_domain && a.pci_bus == b.pci_bus &&
+ a.pci_device == b.pci_device && a.pci_function == b.pci_function;
+#endif
+}
+
+std::string GetCurrentGLAdapterUUID() {
+ if (!GLEW_EXT_memory_object) return {};
+ GLint num_uuids = 0;
+ glGetIntegerv(GL_NUM_DEVICE_UUIDS_EXT, &num_uuids);
+ if (num_uuids < 1) return {};
+ GLubyte uuid[16] = {};
+ glGetUnsignedBytei_vEXT(GL_DEVICE_UUID_EXT, 0, uuid);
+ if (glGetError() != GL_NO_ERROR) return {};
+ return std::string(reinterpret_cast(uuid), sizeof(uuid));
+}
+
+std::string HexEncode(const std::string& raw_bytes) {
+ std::string out;
+ out.reserve(raw_bytes.size() * 2);
+ for (unsigned char b : raw_bytes) {
+ fmt::format_to(std::back_inserter(out), "{:02x}", b);
+ }
+ return out;
+}
+
+} // namespace rendering
+} // namespace visualization
+} // namespace open3d
+
+#endif // !defined(__APPLE__)
diff --git a/cpp/open3d/visualization/rendering/GpuAdapterSelection.h b/cpp/open3d/visualization/rendering/GpuAdapterSelection.h
new file mode 100644
index 00000000000..387ac1736ce
--- /dev/null
+++ b/cpp/open3d/visualization/rendering/GpuAdapterSelection.h
@@ -0,0 +1,101 @@
+// ----------------------------------------------------------------------------
+// - Open3D: www.open3d.org -
+// ----------------------------------------------------------------------------
+// Copyright (c) 2018-2024 www.open3d.org
+// SPDX-License-Identifier: MIT
+// ----------------------------------------------------------------------------
+
+// General-purpose (non-Gaussian-Splat-specific) helpers for keeping a Vulkan
+// device and an OpenGL context on the *same* physical GPU adapter. Needed
+// because GL_EXT_memory_object cross-adapter texture import silently fails
+// (GL_OUT_OF_MEMORY) on multi-GPU (hybrid graphics) systems.
+//
+// Architecture: Vulkan selects its physical device first (see
+// GaussianSplatVulkanInteropContext), then GetAdapterInfo() + the
+// SteerNextGLContextToAdapter() helpers below let the *following* OpenGL
+// context creation be steered onto that same adapter.
+
+#pragma once
+
+#include
+#include
+
+#if !defined(__APPLE__)
+
+typedef struct VkPhysicalDevice_T* VkPhysicalDevice;
+
+namespace open3d {
+namespace visualization {
+namespace rendering {
+
+/// Identifies the physical GPU adapter backing a Vulkan device.
+struct GpuAdapterInfo {
+ bool valid = false;
+#if defined(_WIN32)
+ std::uint8_t luid[8] = {};
+#else
+ std::uint32_t pci_domain = 0;
+ std::uint32_t pci_bus = 0;
+ std::uint32_t pci_device = 0;
+ std::uint32_t pci_function = 0;
+ bool is_nvidia = false; ///< driverID == eNvidiaProprietary
+#endif
+ std::string device_name; ///< For logging only.
+};
+
+/// Extracts the adapter identity from a Vulkan physical device: the 8-byte
+/// DXGI LUID on Windows (via VkPhysicalDeviceIDProperties), or the PCI bus
+/// address on other platforms (via the optional VK_EXT_pci_bus_info device
+/// extension). Returns GpuAdapterInfo::valid == false if unavailable.
+GpuAdapterInfo GetAdapterInfo(VkPhysicalDevice physical_device);
+
+/// Best-effort: steers the *next* OpenGL context created in this process
+/// onto the physical GPU described by `info`. No-op (returns false) if
+/// `info.valid` is false.
+/// Windows: sets GLFW window-position hints so the next glfwCreateWindow()
+/// lands on the monitor driven by the matching DXGI adapter,
+/// causing its WGL context to bind to that adapter. Must be
+/// called before glfwCreateWindow().
+/// Other platforms: EXPERIMENTAL. Sets Mesa/NVIDIA PRIME-offload
+/// environment variables (DRI_PRIME / __NV_PRIME_RENDER_OFFLOAD)
+/// matching the PCI bus address. No portable GLX API exists to
+/// force adapter selection, so this only has a chance of taking
+/// effect if called before *any* GL context has been created in
+/// this process (Mesa/GLVND cache the driver choice on first
+/// load), and success is not guaranteed. Verify after the fact
+/// with GetCurrentGLAdapterUUID().
+bool SteerNextGLContextToAdapter(const GpuAdapterInfo& info);
+
+/// Reverse lookup: identifies the physical GPU adapter actually backing an
+/// already-created OpenGL context, given its GLFW window handle. Used to
+/// verify SteerNextGLContextToAdapter() actually took effect, since it can
+/// silently fail (e.g. the target adapter drives no monitor at all, which
+/// happens on some hybrid-graphics laptops where the discrete GPU is
+/// render-only).
+/// Windows: looks up the DXGI adapter driving the monitor the window is
+/// on. Always succeeds if the window has a monitor, regardless
+/// of whether that adapter was the intended steering target.
+/// Other platforms: not implemented (returns GpuAdapterInfo::valid ==
+/// false) — no portable way to query the GPU behind an existing
+/// GLX context.
+GpuAdapterInfo GetAdapterInfoForWindow(void* glfw_window);
+
+/// True if `a` and `b` identify the same physical GPU adapter. False if
+/// either is invalid.
+bool SameAdapter(const GpuAdapterInfo& a, const GpuAdapterInfo& b);
+
+/// Diagnostic-only: returns the current GL context's GL_DEVICE_UUID_EXT (16
+/// raw bytes), or an empty string if unavailable/unsupported. Requires a
+/// current GL context with GLEW already initialized. Never used to gate
+/// device-selection behavior: some drivers advertise this extension but
+/// fail the query (observed: Intel Iris Xe/Arc hybrid Windows driver).
+std::string GetCurrentGLAdapterUUID();
+
+/// Hex-encodes raw bytes for log messages (e.g. GetCurrentGLAdapterUUID()).
+std::string HexEncode(const std::string& raw_bytes);
+
+} // namespace rendering
+} // namespace visualization
+} // namespace open3d
+
+#endif // !defined(__APPLE__)
diff --git a/cpp/open3d/visualization/rendering/filament/FilamentEngine.cpp b/cpp/open3d/visualization/rendering/filament/FilamentEngine.cpp
index b4c77f0be57..38b49053558 100644
--- a/cpp/open3d/visualization/rendering/filament/FilamentEngine.cpp
+++ b/cpp/open3d/visualization/rendering/filament/FilamentEngine.cpp
@@ -27,6 +27,7 @@
#include "open3d/utility/FileSystem.h"
#include "open3d/visualization/rendering/filament/FilamentResourceManager.h"
#if !defined(__APPLE__)
+#include "open3d/visualization/rendering/GpuAdapterSelection.h"
#include "open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.h"
#include "open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.h"
#endif
@@ -127,20 +128,58 @@ EngineInstance::EngineInstance() {
backend = filament::backend::Backend::OPENGL;
}
- // Create our compute GL context BEFORE the Vulkan interop context so we
- // can read the GPU adapter's GL_DEVICE_UUID_EXT and select a *matching*
- // Vulkan physical device. GL_EXT_memory_object cross-adapter import is
- // not supported: if Vulkan picks a different GPU than the GL context is
- // bound to (e.g. multi-GPU laptops), the memory import silently fails
- // (GL_OUT_OF_MEMORY) even though the Vulkan-side export succeeds.
- // This must also happen before Engine::create() because GLX/WGL context
- // sharing can only be established at context creation time.
+ // Vulkan selects its physical device first (discrete-GPU-preferred,
+ // emulated/software devices down-weighted — see ScoreDevice()), honoring
+ // any loader-level device reordering (e.g. VK_LOADER_DEVICE_SELECT on
+ // Linux) for free. The compute GL context is then steered onto that same
+ // adapter before creation: GL_EXT_memory_object cross-adapter import is
+ // not supported and silently fails (GL_OUT_OF_MEMORY) on multi-GPU
+ // (hybrid graphics) systems if Vulkan and GL end up on different GPUs.
+ auto& vk_ctx = GaussianSplatVulkanInteropContext::GetInstance();
+ if (!vk_ctx.IsValid() && !vk_ctx.Initialize()) {
+ utility::LogWarning(
+ "EngineInstance: Vulkan interop context init failed: {}",
+ vk_ctx.GetLastError());
+ }
+
auto& gl_ctx = GaussianSplatOpenGLContext::GetInstance();
if ((backend == filament::backend::Backend::OPENGL ||
backend == filament::backend::Backend::DEFAULT) &&
!shared_context_) {
if (!gl_ctx.IsValid()) {
+ GpuAdapterInfo vk_adapter_info;
+ if (vk_ctx.IsValid()) {
+ vk_adapter_info = GetAdapterInfo(vk_ctx.GetPhysicalDevice());
+ SteerNextGLContextToAdapter(vk_adapter_info);
+ }
gl_ctx.InitializeStandalone();
+
+ // Safety net only: GaussianSplatVulkanInteropContext::Initialize()
+ // already avoids picking a monitor-less adapter when it can, so
+ // steering above should normally succeed. This still guards
+ // against rarer cases (e.g. the window manager not honoring the
+ // position hint) by making Vulkan follow GL if they still
+ // disagree — guaranteeing the two share a GPU (required for
+ // GL_EXT_memory_object import) matters more than which GPU is
+ // used.
+ if (gl_ctx.IsValid() && vk_adapter_info.valid) {
+ const GpuAdapterInfo gl_actual = GetAdapterInfoForWindow(
+ gl_ctx.GetNativeWindowHandle());
+ if (gl_actual.valid &&
+ !SameAdapter(gl_actual, vk_adapter_info)) {
+ utility::LogWarning(
+ "EngineInstance: GL landed on adapter '{}' but "
+ "Vulkan selected '{}'; reinitializing Vulkan to "
+ "match GL so compute interop works correctly.",
+ gl_actual.device_name, vk_adapter_info.device_name);
+ vk_ctx.Shutdown();
+ if (!vk_ctx.Initialize(&gl_actual)) {
+ utility::LogWarning(
+ "EngineInstance: Vulkan reinit to match GL "
+ "adapter failed: {}", vk_ctx.GetLastError());
+ }
+ }
+ }
}
if (gl_ctx.IsValid()) {
shared_context_ = gl_ctx.GetNativeContext();
@@ -151,32 +190,21 @@ EngineInstance::EngineInstance() {
}
}
- // Initialise the Vulkan interop context, preferring the physical device
- // whose adapter id matches the GL context's adapter (see above). Failure
- // is non-fatal: the Vulkan backend will fall back gracefully.
- {
- auto& vk_ctx = GaussianSplatVulkanInteropContext::GetInstance();
- if (!vk_ctx.IsValid()) {
- std::uint8_t gl_adapter_id[16];
- std::size_t gl_adapter_id_size = 0;
- const bool have_id =
- gl_ctx.IsValid() &&
- gl_ctx.GetAdapterId(gl_adapter_id, gl_adapter_id_size);
- if (!vk_ctx.Initialize(have_id ? gl_adapter_id : nullptr,
- have_id ? gl_adapter_id_size : 0)) {
- utility::LogWarning(
- "EngineInstance: Vulkan interop context init failed: "
- "{}",
- vk_ctx.GetLastError());
- }
- }
- // GL context is already current at this point (created above), so
- // the interop extension probe can run now.
- if (vk_ctx.IsValid() && gl_ctx.IsValid() &&
- !vk_ctx.AreGLExtensionsReady()) {
+ if (vk_ctx.IsValid() && gl_ctx.IsValid() &&
+ !vk_ctx.AreGLExtensionsReady()) {
+ if (gl_ctx.MakeCurrent()) {
vk_ctx.ProbeGLExtensions();
+ gl_ctx.ReleaseCurrent();
}
}
+ // Diagnostic-only verification that GL and Vulkan ended up on the same
+ // adapter; never gates behavior (some drivers fail this query, see
+ // GetCurrentGLAdapterUUID()'s doc comment).
+ if (gl_ctx.IsValid() && gl_ctx.MakeCurrent()) {
+ utility::LogDebug("EngineInstance: GL adapter UUID = {}",
+ HexEncode(GetCurrentGLAdapterUUID()));
+ gl_ctx.ReleaseCurrent();
+ }
#endif
filament::Engine::Config fmcfg;
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp
index 5a4f6bb1505..22a0c97e722 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp
@@ -42,11 +42,6 @@
#endif
#include
-#if defined(_WIN32)
-#include
-#pragma comment(lib, "dxgi.lib")
-#endif
-
#include "open3d/utility/Logging.h"
namespace open3d {
@@ -177,81 +172,6 @@ void* GaussianSplatOpenGLContext::GetNativeContext() const {
#endif
}
-bool GaussianSplatOpenGLContext::GetAdapterId(std::uint8_t out_id[16],
- std::size_t& out_size) const {
- if (!initialized_ || !glfw_window_) {
- return false;
- }
-#if defined(_WIN32)
- // Find the DXGI adapter driving the monitor this (hidden) window is
- // associated with, and return its 8-byte LUID. This is used to match
- // against Vulkan's VkPhysicalDeviceIDProperties::deviceLUID so the
- // Vulkan device selected for GL_EXT_memory_object interop is guaranteed
- // to be the *same* physical GPU as this GL context — required because
- // cross-adapter memory import silently fails (GL_OUT_OF_MEMORY).
- HWND hwnd = glfwGetWin32Window(static_cast(glfw_window_));
- if (!hwnd) return false;
- HMONITOR mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY);
-
- IDXGIFactory1* factory = nullptr;
- if (FAILED(CreateDXGIFactory1(__uuidof(IDXGIFactory1),
- reinterpret_cast(&factory))) ||
- !factory) {
- return false;
- }
-
- bool found = false;
- LUID luid{};
- for (UINT i = 0;; ++i) {
- IDXGIAdapter1* adapter = nullptr;
- if (factory->EnumAdapters1(i, &adapter) == DXGI_ERROR_NOT_FOUND) {
- break;
- }
- if (!adapter) continue;
- for (UINT j = 0;; ++j) {
- IDXGIOutput* output = nullptr;
- if (adapter->EnumOutputs(j, &output) == DXGI_ERROR_NOT_FOUND) {
- break;
- }
- if (!output) continue;
- DXGI_OUTPUT_DESC odesc{};
- if (SUCCEEDED(output->GetDesc(&odesc)) &&
- odesc.Monitor == mon) {
- DXGI_ADAPTER_DESC1 adesc{};
- if (SUCCEEDED(adapter->GetDesc1(&adesc))) {
- luid = adesc.AdapterLuid;
- found = true;
- }
- }
- output->Release();
- if (found) break;
- }
- adapter->Release();
- if (found) break;
- }
- factory->Release();
-
- if (!found) return false;
- std::memcpy(out_id, &luid, sizeof(luid));
- out_size = sizeof(luid);
- return true;
-#else
- if (GLEW_EXT_memory_object == 0) {
- return false;
- }
- GLint num_uuids = 0;
- glGetIntegerv(GL_NUM_DEVICE_UUIDS_EXT, &num_uuids);
- if (num_uuids < 1) {
- return false;
- }
- glGetUnsignedBytei_vEXT(GL_DEVICE_UUID_EXT, 0,
- reinterpret_cast(out_id));
- if (glGetError() != GL_NO_ERROR) return false;
- out_size = 16;
- return true;
-#endif
-}
-
void GaussianSplatOpenGLContext::Shutdown() {
if (!initialized_) {
return;
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.h b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.h
index 65e142e1140..d34ba8e719e 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.h
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.h
@@ -53,18 +53,9 @@ class GaussianSplatOpenGLContext {
/// Windows -> HGLRC
void* GetNativeContext() const;
- /// Queries an identifier for the GPU adapter this context is bound to,
- /// so callers (Vulkan interop) can select a *matching* physical device.
- /// GL_EXT_memory_object cross-adapter import silently fails, so this
- /// match is required on multi-GPU (hybrid graphics) systems.
- /// Windows -> DXGI adapter LUID (8 bytes), via the monitor the
- /// context's window is associated with. More reliable than
- /// GL_DEVICE_UUID_EXT, which some drivers advertise but do
- /// not actually implement (observed: Intel Iris Xe/Arc
- /// hybrid driver returns GL_INVALID_OPERATION for it).
- /// Other platforms -> GL_DEVICE_UUID_EXT (16 bytes).
- /// Returns false (leaving out_id untouched) if unavailable.
- bool GetAdapterId(std::uint8_t out_id[16], std::size_t& out_size) const;
+ /// Returns the underlying GLFWwindow* (as void*), for adapter-identity
+ /// lookups (see GpuAdapterSelection.h's GetAdapterInfoForWindow()).
+ void* GetNativeWindowHandle() const { return glfw_window_; }
/// Destroys the context and associated resources.
void Shutdown();
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatPassRunner.cpp b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatPassRunner.cpp
index c209fc695b3..d10b6f4cb0d 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatPassRunner.cpp
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatPassRunner.cpp
@@ -190,9 +190,14 @@ bool RunGaussianGeometryPasses(
vs.dc_opacity_buf,
attrs.dc_opacity.size() * sizeof(std::uint32_t),
"gs.dc_opacity");
- vs.sh_buf = ctx.ResizeBuffer(
- vs.sh_buf, attrs.sh_coefficients.size() * sizeof(std::uint32_t),
- "gs.sh_coeffs");
+ // Always keep this buffer non-empty: the project shader statically
+ // references binding 5 even when guarded by "if (sh_degree >= 1u)",
+ // so a size-0 (i.e. unallocated) buffer leaves the descriptor unwritten
+ // and unbound, which some drivers (e.g. Intel Arc) fault on even
+ // though it's never read at that degree (VUID-vkCmdDispatch-None-08114).
+ const std::size_t sh_bytes = std::max(
+ 1, attrs.sh_coefficients.size()) * sizeof(std::uint32_t);
+ vs.sh_buf = ctx.ResizeBuffer(vs.sh_buf, sh_bytes, "gs.sh_coeffs");
}
// GPU-only intermediate buffers: private storage for better cache
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp
index e7061496a7a..a4a6df38158 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp
@@ -41,8 +41,11 @@ VULKAN_HPP_DEFAULT_DISPATCH_LOADER_DYNAMIC_STORAGE
#include
#include
+#include
#include
+#include
+
#include "open3d/utility/Logging.h"
// Initialize VMA in this single translation unit.
@@ -113,11 +116,15 @@ bool CheckExtensions(const std::vector& available,
return true;
}
+/// Sentinel returned by ScoreDevice() for devices that must never be picked
+/// (no compute queue, missing required extensions, or API version too low).
+/// Distinct from the emulated-device penalty below, which keeps the device
+/// eligible as a last resort when nothing better is available.
+constexpr int kDeviceRejected = std::numeric_limits::min();
+
/// Score a physical device for interop suitability. Higher is better.
-/// +2 : discrete GPU
-/// +1 : integrated GPU
-/// 0 : any compute-capable device
-/// -∞ : no compute queue or missing required extensions → reject
+/// Native discrete > native integrated > other native > D3D12 translation > CPU.
+/// kDeviceRejected : no compute queue or missing required extensions/version
int ScoreDevice(const vk::raii::PhysicalDevice& dev) {
// Check for a compute queue.
const auto qfams = dev.getQueueFamilyProperties();
@@ -128,7 +135,7 @@ int ScoreDevice(const vk::raii::PhysicalDevice& dev) {
break;
}
}
- if (!has_compute) return -1;
+ if (!has_compute) return kDeviceRejected;
// Check device extensions.
const auto exts = dev.enumerateDeviceExtensionProperties();
@@ -137,16 +144,33 @@ int ScoreDevice(const vk::raii::PhysicalDevice& dev) {
const bool ok =
CheckExtensions(exts, kRequiredDeviceExtensions,
std::size(kRequiredDeviceExtensions), missing);
- if (!ok) return -1;
+ if (!ok) return kDeviceRejected;
- // Shaders are compiled for Vulkan 1.3 (SPIR-V 1.6); reject older devices.
const auto props = dev.getProperties();
- if (props.apiVersion < VK_API_VERSION_1_3) return -1;
- // Score device type.
- if (props.deviceType == vk::PhysicalDeviceType::eDiscreteGpu) return 2;
- if (props.deviceType == vk::PhysicalDeviceType::eIntegratedGpu) return 1;
- return 0;
+ // Shaders are compiled for Vulkan 1.3 (SPIR-V 1.6); reject older devices.
+ if (props.apiVersion < VK_API_VERSION_1_3) return kDeviceRejected;
+
+ const std::string renderer(props.deviceName.data());
+ const bool software = renderer.find("llvmpipe") != std::string::npos ||
+ renderer.find("SwiftShader") != std::string::npos ||
+ renderer.find("WARP") != std::string::npos ||
+ renderer.find("Basic Render Driver") !=
+ std::string::npos;
+ if (software) return 0;
+
+ int score = 10;
+ if (props.deviceType == vk::PhysicalDeviceType::eDiscreteGpu) {
+ score = 30;
+ } else if (props.deviceType == vk::PhysicalDeviceType::eIntegratedGpu) {
+ score = 20;
+ }
+ // Penalize emulated Vulkan-on-D3D12 devices (e.g. WSL2)
+ if (renderer.find("D3D12") != std::string::npos ||
+ renderer.find("Dozen") != std::string::npos) {
+ --score;
+ }
+ return score;
}
} // namespace
@@ -170,7 +194,7 @@ GaussianSplatVulkanInteropContext::~GaussianSplatVulkanInteropContext() {
// ---------------------------------------------------------------------------
bool GaussianSplatVulkanInteropContext::Initialize(
- const std::uint8_t* gl_adapter_id, std::size_t gl_adapter_id_size) {
+ const GpuAdapterInfo* required_adapter) {
if (initialized_) return true;
// Initialize the global dynamic dispatcher with vkGetInstanceProcAddr
@@ -193,7 +217,8 @@ bool GaussianSplatVulkanInteropContext::Initialize(
// function pointers (required for physical device enumeration etc.).
VULKAN_HPP_DEFAULT_DISPATCHER.init(static_cast(*instance_));
- if (!SelectPhysicalDevice(gl_adapter_id, gl_adapter_id_size)) return false;
+ if (!SelectPhysicalDevice(required_adapter)) return false;
+
if (!CreateLogicalDevice()) return false;
// After device creation, update the dispatcher with device-level
@@ -316,7 +341,7 @@ bool GaussianSplatVulkanInteropContext::CreateInstance() {
// ---------------------------------------------------------------------------
bool GaussianSplatVulkanInteropContext::SelectPhysicalDevice(
- const std::uint8_t* gl_adapter_id, std::size_t gl_adapter_id_size) {
+ const GpuAdapterInfo* required_adapter) {
auto devices = instance_.enumeratePhysicalDevices();
if (devices.empty()) {
last_error_ = "No Vulkan-capable devices found";
@@ -324,36 +349,16 @@ bool GaussianSplatVulkanInteropContext::SelectPhysicalDevice(
return false;
}
- // GL_EXT_memory_object interop requires the Vulkan device and the GL
- // context to be the *same* physical GPU adapter: cross-adapter import
- // is not supported and fails silently (GL_OUT_OF_MEMORY) even though
- // the Vulkan-side export succeeds. When the GL adapter id is known
- // (8-byte DXGI LUID on Windows, 16-byte GL_DEVICE_UUID_EXT elsewhere),
- // restrict candidates to the matching device first.
- const bool have_id = gl_adapter_id != nullptr &&
- (gl_adapter_id_size == 8 || gl_adapter_id_size == 16);
-
- int best_score = -1;
+ int best_score = kDeviceRejected;
std::size_t best_idx = devices.size();
for (std::size_t i = 0; i < devices.size(); ++i) {
- int score = ScoreDevice(devices[i]);
- if (score < 0) continue;
- if (have_id) {
- const auto id_props =
- devices[i].getProperties2();
- const auto& id = id_props.get();
- bool matches = false;
- if (gl_adapter_id_size == 8) {
- matches = id.deviceLUIDValid &&
- std::memcmp(id.deviceLUID.data(), gl_adapter_id,
- 8) == 0;
- } else {
- matches = std::memcmp(id.deviceUUID.data(), gl_adapter_id,
- 16) == 0;
- }
- if (!matches) continue; // not the GL-bound adapter; skip
- score += 100; // guarantee a match wins over any non-match
+ const int score = ScoreDevice(devices[i]);
+ if (score == kDeviceRejected) continue;
+ if (required_adapter && required_adapter->valid &&
+ !SameAdapter(GetAdapterInfo(static_cast(
+ *devices[i])),
+ *required_adapter)) {
+ continue; // not the required adapter; skip
}
if (score > best_score) {
best_score = score;
@@ -361,29 +366,13 @@ bool GaussianSplatVulkanInteropContext::SelectPhysicalDevice(
}
}
- if (best_idx == devices.size() && have_id) {
- // No device matched the GL adapter. Falling back to best-effort
- // scoring risks a repeat of the cross-adapter import failure, but a
- // working (if unshared) device is still better than none for
- // callers that only need compute, so fall back with a warning.
- utility::LogWarning(
- "GaussianSplat Vulkan: no physical device matched the GL "
- "context's adapter; GL/Vulkan interop may fail. Falling "
- "back to best-effort device scoring.");
- for (std::size_t i = 0; i < devices.size(); ++i) {
- const int score = ScoreDevice(devices[i]);
- if (score > best_score) {
- best_score = score;
- best_idx = i;
- }
- }
- }
-
if (best_idx == devices.size()) {
last_error_ =
- "No suitable Vulkan device found with required interop "
- "extensions. Required "
- "extensions: " VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME;
+ required_adapter && required_adapter->valid
+ ? "No Vulkan device matched the required adapter '" +
+ required_adapter->device_name + "'"
+ : "No suitable Vulkan device found with required "
+ "interop extensions. Required extensions: " VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME;
utility::LogWarning("GaussianSplat Vulkan: {}", last_error_);
return false;
}
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.h b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.h
index 81d4a4f6639..5346b814301 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.h
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.h
@@ -49,6 +49,8 @@
#endif
#include
+#include "open3d/visualization/rendering/GpuAdapterSelection.h"
+
namespace open3d {
namespace visualization {
namespace rendering {
@@ -110,23 +112,12 @@ class GaussianSplatVulkanInteropContext {
static GaussianSplatVulkanInteropContext& GetInstance();
/// Load Vulkan via BlueVK, select a physical device with external-memory
- /// extension support, and create a compute queue.
- ///
- /// `gl_adapter_id`/`gl_adapter_id_size`, if `gl_adapter_id` is non-null,
- /// identify the GPU adapter the GL interop context is bound to (see
- /// GaussianSplatOpenGLContext::GetAdapterId()): an 8-byte DXGI LUID on
- /// Windows or a 16-byte GL_DEVICE_UUID_EXT elsewhere. When provided,
- /// only physical devices whose VkPhysicalDeviceIDProperties deviceLUID
- /// (8 bytes) or deviceUUID (16 bytes) matches are eligible:
- /// GL_EXT_memory_object import requires the Vulkan device and the GL
- /// context to be on the *same* GPU adapter, otherwise the import
- /// silently fails (GL_OUT_OF_MEMORY) even though the Vulkan-side export
- /// succeeds. If null, or no Vulkan device matches, falls back to the
- /// old best-effort scoring (may pick a mismatched adapter on multi-GPU
- /// systems).
+ /// extension support, and create a compute queue. If `required_adapter`
+ /// is valid, only that physical GPU is selected; otherwise native discrete
+ /// GPUs are preferred over integrated, translation, and software devices.
+ /// Vulkan and OpenGL must use the same adapter for memory-object interop.
/// Returns false on failure; call GetLastError() for a diagnostic string.
- bool Initialize(const std::uint8_t* gl_adapter_id = nullptr,
- std::size_t gl_adapter_id_size = 0);
+ bool Initialize(const GpuAdapterInfo* required_adapter = nullptr);
/// Release all Vulkan resources and invalidate the context.
void Shutdown();
@@ -222,8 +213,7 @@ class GaussianSplatVulkanInteropContext {
// --- Internal helpers -------------------------------------------------
bool CreateInstance();
- bool SelectPhysicalDevice(const std::uint8_t* gl_adapter_id,
- std::size_t gl_adapter_id_size);
+ bool SelectPhysicalDevice(const GpuAdapterInfo* required_adapter);
bool CreateLogicalDevice();
/// Allocate a VkImage with a dedicated exportable memory allocation and
From d1515e8bb2a004bb0ec2f3307d4e15ca4625a44f Mon Sep 17 00:00:00 2001
From: Sameer Sheorey <41028320+ssheorey@users.noreply.github.com>
Date: Fri, 14 Aug 2026 16:22:29 -0700
Subject: [PATCH 04/11] Package Open3D Viewer as signed MSIX
---
.github/workflows/windows.yml | 49 ++++++------------
cpp/apps/CMakeLists.txt | 14 +++++
.../Open3DViewer/WindowsMSIX/AppxManifest.xml | 14 ++---
.../Open3DViewer/WindowsMSIX/build_msix.ps1 | 37 +++++++------
.../visualization/gui/GLFWWindowSystem.cpp | 4 ++
cpp/open3d/visualization/gui/Native.h | 5 ++
cpp/open3d/visualization/gui/NativeWin32.cpp | 21 ++++++++
.../gui/Resources}/Square150x150Logo.png | Bin
.../gui/Resources}/Square44x44Logo.png | Bin
.../gui/Resources}/StoreLogo.png | Bin
.../visualizer/GuiVisualizer.cpp | 2 +-
11 files changed, 85 insertions(+), 61 deletions(-)
rename cpp/{apps/Open3DViewer/WindowsMSIX/Assets => open3d/visualization/gui/Resources}/Square150x150Logo.png (100%)
rename cpp/{apps/Open3DViewer/WindowsMSIX/Assets => open3d/visualization/gui/Resources}/Square44x44Logo.png (100%)
rename cpp/{apps/Open3DViewer/WindowsMSIX/Assets => open3d/visualization/gui/Resources}/StoreLogo.png (100%)
diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml
index 70c7de2a96c..a26c8d28f02 100755
--- a/.github/workflows/windows.yml
+++ b/.github/workflows/windows.yml
@@ -272,52 +272,33 @@ jobs:
$ErrorActionPreference = 'Stop'
cmake --build . --parallel ${{ env.NPROC }} --config ${{ matrix.CONFIG }} `
--target Open3DViewer
- cmake --build . --parallel ${{ env.NPROC }} --config ${{ matrix.CONFIG }} `
- --target INSTALL
- $cmakeCachePath = Join-Path $env:BUILD_DIR "CMakeCache.txt"
- $Env:OPEN3D_VERSION_FULL = (Select-String -Path $cmakeCachePath -Pattern "OPEN3D_VERSION_FULL").Line.Split('=')[1]
- $open3dAppPath = Join-Path $env:INSTALL_DIR "bin\Open3D"
- Compress-Archive -Path $open3dAppPath -DestinationPath `
- "$Env:GITHUB_WORKSPACE/open3d-$Env:OPEN3D_VERSION_FULL-app-windows-amd64.zip"
- echo "VIEWER_ZIP_NAME=open3d-$Env:OPEN3D_VERSION_FULL-app-windows-amd64.zip" | Out-File -FilePath `
- $Env:GITHUB_ENV -Encoding utf8 -Append
-
- - name: Generate viewer attestation
- if: ${{ github.ref == 'refs/heads/main' && matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.device == 'cpu' && matrix.CONFIG == 'Release' }}
- uses: actions/attest@v4
- with:
- subject-path: ${{ github.workspace }}/${{ env.VIEWER_ZIP_NAME }}
-
- - name: Upload Viewer
- if: ${{ matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.device == 'cpu' && matrix.CONFIG == 'Release' }}
- uses: actions/upload-artifact@v4
- with:
- name: open3d-app-windows-amd64
- path: ${{ github.workspace }}/${{ env.VIEWER_ZIP_NAME }}
- if-no-files-found: error
- name: Setup WinApp CLI
- if: ${{ matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.BUILD_CUDA_MODULE == 'OFF' && matrix.CONFIG == 'Release' }}
+ if: ${{ matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.device == 'cpu' && matrix.CONFIG == 'Release' }}
uses: microsoft/setup-WinAppCli@v0.1
- name: Build MSIX
- if: ${{ matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.BUILD_CUDA_MODULE == 'OFF' && matrix.CONFIG == 'Release' }}
+ working-directory: ${{ env.BUILD_DIR }}
+ if: ${{ matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.device == 'cpu' && matrix.CONFIG == 'Release' }}
run: |
$ErrorActionPreference = 'Stop'
- & "${{ env.SRC_DIR }}\cpp\apps\Open3DViewer\WindowsMSIX\build_msix.ps1" `
- -SrcDir "${{ env.SRC_DIR }}" `
- -InstallDir "C:\Program Files\Open3D\bin\Open3D" `
- -CMakeCachePath "C:/Open3D/build/CMakeCache.txt" `
- -OutDir "C:\Open3D"
+ cmake --build . --parallel ${{ env.NPROC }} --config ${{ matrix.CONFIG }} `
+ --target Open3DViewerMSIX
+
+ - name: Generate MSIX attestation
+ if: ${{ github.ref == 'refs/heads/main' && matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.device == 'cpu' && matrix.CONFIG == 'Release' }}
+ uses: actions/attest@v4
+ with:
+ subject-path: ${{ env.MSIX_PATH }}
- name: Upload MSIX
- if: ${{ matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.BUILD_CUDA_MODULE == 'OFF' && matrix.CONFIG == 'Release' }}
+ if: ${{ matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.device == 'cpu' && matrix.CONFIG == 'Release' }}
uses: actions/upload-artifact@v4
with:
name: open3d-app-windows-msix-amd64
path: |
- C:\Open3D\*.msix
- C:\Open3D\Open3D.cer
+ ${{ env.MSIX_PATH }}
+ ${{ env.BUILD_DIR }}\msix-package\Open3D.cer
if-no-files-found: error
- name: Update devel release with viewer
@@ -325,7 +306,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
- bash .github/workflows/update_release.sh "${{ env.VIEWER_ZIP_NAME }}"
+ bash .github/workflows/update_release.sh "${{ env.MSIX_PATH }}" "${{ env.BUILD_DIR }}\msix-package\Open3D.cer"
- name: Run C++ unit tests
if: ${{ matrix.device != 'cuda' }}
diff --git a/cpp/apps/CMakeLists.txt b/cpp/apps/CMakeLists.txt
index f2c4cce7853..c2276cf338f 100644
--- a/cpp/apps/CMakeLists.txt
+++ b/cpp/apps/CMakeLists.txt
@@ -55,6 +55,8 @@ macro(open3d_add_app_gui SRC_DIR APP_NAME TARGET_NAME)
COMMAND ${CMAKE_COMMAND} -E copy_directory "${GUI_RESOURCE_DIR}" "${APP_DIR}/${RESOURCE_DIR_NAME}"
)
if (UNIX)
+ install(FILES "$"
+ DESTINATION "${CMAKE_INSTALL_PREFIX}/bin/${APP_NAME}")
install(DIRECTORY "${APP_DIR}"
DESTINATION "${CMAKE_INSTALL_PREFIX}/bin"
USE_SOURCE_PERMISSIONS)
@@ -160,6 +162,18 @@ endmacro()
if (BUILD_GUI)
open3d_add_app_common(Open3DViewer Open3D Open3DViewer)
open3d_add_app_gui(Open3DViewer Open3D Open3DViewer)
+ if (WIN32)
+ add_custom_target(Open3DViewerMSIX
+ COMMAND "${CMAKE_COMMAND}" --install "${CMAKE_BINARY_DIR}"
+ --config "$"
+ COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass
+ -File "${PROJECT_SOURCE_DIR}/cpp/apps/Open3DViewer/WindowsMSIX/build_msix.ps1"
+ -InstallDir "${CMAKE_INSTALL_PREFIX}/bin/Open3D"
+ -Version "${PROJECT_VERSION}"
+ -OutDir "${CMAKE_BINARY_DIR}/msix-package"
+ VERBATIM)
+ add_dependencies(Open3DViewerMSIX Open3DViewer)
+ endif()
endif()
open3d_add_app_common(OfflineReconstruction OfflineReconstruction OfflineReconstruction)
diff --git a/cpp/apps/Open3DViewer/WindowsMSIX/AppxManifest.xml b/cpp/apps/Open3DViewer/WindowsMSIX/AppxManifest.xml
index af0132c3346..2940e2a2131 100644
--- a/cpp/apps/Open3DViewer/WindowsMSIX/AppxManifest.xml
+++ b/cpp/apps/Open3DViewer/WindowsMSIX/AppxManifest.xml
@@ -6,13 +6,13 @@
- Checked in as source; only the Version attribute is substituted at pack time
in CI (placeholder @OPEN3D_MSIX_VERSION@ → four-part version, e.g. 0.18.0.0).
- Publisher "CN=Open3D" is matched by the self-signed dev cert generated in CI
- via: winapp cert generate --manifest AppxManifest.xml
+ using the manifest publisher value
- runFullTrust is the only required capability for a packaged Win32 desktop app.
- File associations are derived from cpp/apps/Open3DViewer/Open3DViewer.xml.
The viewer receives the opened file path as argv[1] (standard packaged app
full-trust activation behaviour).
- - Assets/ PNGs are checked in (generated once from icon.ico with
- winapp manifest update-assets; regenerate and commit on icon changes).
+ - MSIX PNGs are kept with the shared GUI resources and installed under
+ resources/ alongside the viewer's other runtime resources.
-->
Open3D
Open3D
- Assets\StoreLogo.png
+ resources\StoreLogo.png
@@ -48,15 +48,15 @@
- Assets\Square44x44Logo.png
+ resources\Square44x44Logo.png
Open3D 3D file
.ply
diff --git a/cpp/apps/Open3DViewer/WindowsMSIX/build_msix.ps1 b/cpp/apps/Open3DViewer/WindowsMSIX/build_msix.ps1
index 6be89d86cc3..ee532f1a196 100644
--- a/cpp/apps/Open3DViewer/WindowsMSIX/build_msix.ps1
+++ b/cpp/apps/Open3DViewer/WindowsMSIX/build_msix.ps1
@@ -2,38 +2,32 @@
.SYNOPSIS
Stages, signs, and packs the Open3D Viewer as a side-load MSIX.
-.PARAMETER SrcDir
- Open3D repository root (contains cpp/apps/Open3DViewer/WindowsMSIX).
.PARAMETER InstallDir
Directory the viewer was installed to (contains Open3D.exe + resources/).
-.PARAMETER CMakeCachePath
- Path to CMakeCache.txt, used to read OPEN3D_VERSION_FULL.
+.PARAMETER Version
+ Three-part Open3D version from CMake.
.PARAMETER OutDir
Directory to write the staged files, cert, and final .msix/.cer into.
#>
param(
- [Parameter(Mandatory = $true)][string]$SrcDir,
[Parameter(Mandatory = $true)][string]$InstallDir,
- [Parameter(Mandatory = $true)][string]$CMakeCachePath,
+ [Parameter(Mandatory = $true)][string]$Version,
[Parameter(Mandatory = $true)][string]$OutDir
)
$ErrorActionPreference = 'Stop'
-# Build a four-part MSIX version from the Open3D version in CMakeCache.
-# MSIX requires Major.Minor.Patch.Build (all numeric).
-$RAW_VER = (Select-String -Path $CMakeCachePath `
- -Pattern "OPEN3D_VERSION_FULL:STRING=").Line.Split('=')[1].Trim()
-$MSIX_VERSION = ($RAW_VER -replace '[^0-9.]', '').TrimEnd('.') + '.0'
-$OPEN3D_VERSION = $RAW_VER -replace '[^0-9.]', ''
+$OPEN3D_VERSION = $Version
+$MSIX_VERSION = "$Version.0"
-# Stage: start from the already-installed viewer folder and add manifest + assets.
+# Stage the already-installed viewer and add the manifest.
$STAGING = Join-Path $OutDir "msix-staging"
+Remove-Item -Recurse -Force $STAGING -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $STAGING | Out-Null
-Copy-Item -Recurse (Join-Path $InstallDir "*") $STAGING
-$SRC_MSIX = Join-Path $SrcDir "cpp\apps\Open3DViewer\WindowsMSIX"
-Copy-Item (Join-Path $SRC_MSIX "AppxManifest.xml") $STAGING
-Copy-Item -Recurse (Join-Path $SRC_MSIX "Assets") $STAGING
+Copy-Item (Join-Path $InstallDir "Open3D.exe") $STAGING
+Copy-Item (Join-Path $InstallDir "tbb*.dll") $STAGING
+Copy-Item -Recurse (Join-Path $InstallDir "resources") $STAGING
+Copy-Item (Join-Path $PSScriptRoot "AppxManifest.xml") $STAGING
# Substitute version placeholder in the staged manifest.
$manifestPath = Join-Path $STAGING "AppxManifest.xml"
@@ -50,8 +44,13 @@ winapp cert generate `
# Pack and sign the MSIX.
$MSIX_NAME = "Open3DViewer-$OPEN3D_VERSION-x64.msix"
+$MSIX_PATH = Join-Path $OutDir $MSIX_NAME
winapp pack $STAGING `
- --output (Join-Path $OutDir $MSIX_NAME) `
+ --output $MSIX_PATH `
--cert (Join-Path $OutDir "Open3D.pfx")
-echo "MSIX_NAME=$MSIX_NAME" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
+# If running in GitHub Actions, export the MSIX name and path to the environment.
+if ($env:GITHUB_ENV) {
+ echo "MSIX_NAME=$MSIX_NAME" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
+ echo "MSIX_PATH=$MSIX_PATH" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
+}
diff --git a/cpp/open3d/visualization/gui/GLFWWindowSystem.cpp b/cpp/open3d/visualization/gui/GLFWWindowSystem.cpp
index 23f24921387..bd3dcc944c4 100644
--- a/cpp/open3d/visualization/gui/GLFWWindowSystem.cpp
+++ b/cpp/open3d/visualization/gui/GLFWWindowSystem.cpp
@@ -172,6 +172,10 @@ GLFWWindowSystem::OSWindow GLFWWindowSystem::CreateOSWindow(Window* o3d_window,
auto* glfw_window = glfwCreateWindow(width, height, title, NULL, NULL);
+#if defined(_WIN32)
+ SetNativeWindowIcon(glfw_window);
+#endif
+
glfwSetWindowUserPointer(glfw_window, o3d_window);
glfwSetWindowSizeCallback(glfw_window, ResizeCallback);
glfwSetWindowPosCallback(glfw_window, WindowMovedCallback);
diff --git a/cpp/open3d/visualization/gui/Native.h b/cpp/open3d/visualization/gui/Native.h
index 6089723342d..4cb48db9729 100644
--- a/cpp/open3d/visualization/gui/Native.h
+++ b/cpp/open3d/visualization/gui/Native.h
@@ -19,6 +19,11 @@ namespace visualization {
namespace gui {
void* GetNativeDrawable(GLFWwindow* glfw_window);
+// GLFW uses the generic Windows application icon unless the native window is
+// assigned the icon embedded in the executable.
+#ifdef _WIN32
+void SetNativeWindowIcon(GLFWwindow* glfw_window);
+#endif // _WIN32
// Note that Windows cannot post an expose event so it must draw immediately.
// Therefore this function cannot be called while drawing.
void PostNativeExposeEvent(GLFWwindow* glfw_window);
diff --git a/cpp/open3d/visualization/gui/NativeWin32.cpp b/cpp/open3d/visualization/gui/NativeWin32.cpp
index 42c2f48b1e0..c3ddd2813f5 100644
--- a/cpp/open3d/visualization/gui/NativeWin32.cpp
+++ b/cpp/open3d/visualization/gui/NativeWin32.cpp
@@ -20,6 +20,27 @@ void* GetNativeDrawable(GLFWwindow* glfw_window) {
return glfwGetWin32Window(glfw_window);
}
+void SetNativeWindowIcon(GLFWwindow* glfw_window) {
+ HWND window = glfwGetWin32Window(glfw_window);
+ HINSTANCE instance = GetModuleHandle(nullptr);
+ HICON small_icon = static_cast(LoadImage(
+ instance, "IDI_ICON1", IMAGE_ICON, GetSystemMetrics(SM_CXSMICON),
+ GetSystemMetrics(SM_CYSMICON), 0));
+ HICON large_icon = static_cast(LoadImage(
+ instance, "IDI_ICON1", IMAGE_ICON, GetSystemMetrics(SM_CXICON),
+ GetSystemMetrics(SM_CYICON), 0));
+ if (small_icon && large_icon) {
+ SendMessage(window, WM_SETICON, ICON_SMALL,
+ reinterpret_cast(small_icon));
+ SendMessage(window, WM_SETICON, ICON_BIG,
+ reinterpret_cast(large_icon));
+ SetClassLongPtr(window, GCLP_HICON,
+ reinterpret_cast(large_icon));
+ SetClassLongPtr(window, GCLP_HICONSM,
+ reinterpret_cast(small_icon));
+ }
+}
+
void PostNativeExposeEvent(GLFWwindow* glfw_window) {
InvalidateRect(glfwGetWin32Window(glfw_window), NULL, FALSE);
// InvalidateRect() does not actually post an event to the message queue.
diff --git a/cpp/apps/Open3DViewer/WindowsMSIX/Assets/Square150x150Logo.png b/cpp/open3d/visualization/gui/Resources/Square150x150Logo.png
similarity index 100%
rename from cpp/apps/Open3DViewer/WindowsMSIX/Assets/Square150x150Logo.png
rename to cpp/open3d/visualization/gui/Resources/Square150x150Logo.png
diff --git a/cpp/apps/Open3DViewer/WindowsMSIX/Assets/Square44x44Logo.png b/cpp/open3d/visualization/gui/Resources/Square44x44Logo.png
similarity index 100%
rename from cpp/apps/Open3DViewer/WindowsMSIX/Assets/Square44x44Logo.png
rename to cpp/open3d/visualization/gui/Resources/Square44x44Logo.png
diff --git a/cpp/apps/Open3DViewer/WindowsMSIX/Assets/StoreLogo.png b/cpp/open3d/visualization/gui/Resources/StoreLogo.png
similarity index 100%
rename from cpp/apps/Open3DViewer/WindowsMSIX/Assets/StoreLogo.png
rename to cpp/open3d/visualization/gui/Resources/StoreLogo.png
diff --git a/cpp/open3d/visualization/visualizer/GuiVisualizer.cpp b/cpp/open3d/visualization/visualizer/GuiVisualizer.cpp
index ac1495d663b..f33c7bc637b 100644
--- a/cpp/open3d/visualization/visualizer/GuiVisualizer.cpp
+++ b/cpp/open3d/visualization/visualizer/GuiVisualizer.cpp
@@ -1312,7 +1312,7 @@ void GuiVisualizer::OnMenuItemSelected(gui::Menu::ItemId item_id) {
"Point cloud files (.xyz, .xyzn, .xyzrgb, .ply, "
".pcd, .pts)");
dlg->AddFilter(".ply .splat .spz",
- "Gaussian Splat files (.ply, .splat,.spz)");
+ "Gaussian Splat files (.ply, .splat, .spz)");
dlg->AddFilter(".ply", "Polygon files (.ply)");
dlg->AddFilter(".stl", "Stereolithography files (.stl)");
dlg->AddFilter(".fbx", "Autodesk Filmbox files (.fbx)");
From 250089cd5572112be95805bd3b6179bff83c2e16 Mon Sep 17 00:00:00 2001
From: Sameer Sheorey <41028320+ssheorey@users.noreply.github.com>
Date: Fri, 14 Aug 2026 22:42:43 -0700
Subject: [PATCH 05/11] Fix cross-platform CI build failures
---
cpp/apps/CMakeLists.txt | 3 +++
cpp/open3d/visualization/rendering/GpuAdapterSelection.cpp | 2 +-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/cpp/apps/CMakeLists.txt b/cpp/apps/CMakeLists.txt
index c2276cf338f..838f7f9921f 100644
--- a/cpp/apps/CMakeLists.txt
+++ b/cpp/apps/CMakeLists.txt
@@ -137,6 +137,9 @@ macro(open3d_add_app_common SRC_DIR APP_NAME TARGET_NAME)
set_target_properties(${TARGET_NAME} PROPERTIES WIN32_EXECUTABLE TRUE)
if (MSVC)
target_link_options(${TARGET_NAME} PRIVATE "/ENTRY:mainCRTStartup")
+ elseif (CMAKE_CXX_COMPILER_ID STREQUAL "IntelLLVM")
+ target_link_options(${TARGET_NAME} PRIVATE
+ "-Xlinker" "/ENTRY:mainCRTStartup")
endif()
else()
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/../${APP_NAME}")
diff --git a/cpp/open3d/visualization/rendering/GpuAdapterSelection.cpp b/cpp/open3d/visualization/rendering/GpuAdapterSelection.cpp
index 870717ef132..98ef22ff429 100644
--- a/cpp/open3d/visualization/rendering/GpuAdapterSelection.cpp
+++ b/cpp/open3d/visualization/rendering/GpuAdapterSelection.cpp
@@ -230,7 +230,7 @@ bool SteerNextGLContextToAdapter(const GpuAdapterInfo& info) {
// this relies on the Mesa/NVIDIA PRIME-offload env var convention, which
// only takes effect if set before the first GL/GLX context is created
// in this process.
- setenv("DRI_PRIME", pci_id, 1);
+ setenv("DRI_PRIME", pci_id.c_str(), 1);
if (info.is_nvidia) {
setenv("__NV_PRIME_RENDER_OFFLOAD", "1", 1);
setenv("__GLX_VENDOR_LIBRARY_NAME", "nvidia", 1);
From 91e1e3d92f9bca0ccd997e0c995618715d0ebf8a Mon Sep 17 00:00:00 2001
From: Sameer Sheorey <41028320+ssheorey@users.noreply.github.com>
Date: Fri, 14 Aug 2026 23:39:22 -0700
Subject: [PATCH 06/11] formatting. IntelLLVM support. msix not in main-devel -
only for windows store, since msix canot be installed easily without trusted
CA signature.msix + public key cer is available in github artifact for
testing.
---
.github/workflows/windows.yml | 39 ++++++++++++-------
cpp/apps/CMakeLists.txt | 2 +-
.../Open3DViewer/WindowsMSIX/build_msix.ps1 | 26 ++++++++-----
cpp/open3d/visualization/gui/NativeWin32.cpp | 20 +++++-----
.../rendering/GpuAdapterSelection.cpp | 31 +++++++--------
.../rendering/filament/FilamentEngine.cpp | 7 ++--
.../GaussianSplatPassRunner.cpp | 8 ++--
.../GaussianSplatVulkanBackend.cpp | 3 +-
.../GaussianSplatVulkanInteropContext.cpp | 22 ++++++-----
cpp/pybind/t/geometry/pointcloud.cpp | 7 ++--
10 files changed, 93 insertions(+), 72 deletions(-)
diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml
index a26c8d28f02..359cf153865 100755
--- a/.github/workflows/windows.yml
+++ b/.github/workflows/windows.yml
@@ -272,6 +272,30 @@ jobs:
$ErrorActionPreference = 'Stop'
cmake --build . --parallel ${{ env.NPROC }} --config ${{ matrix.CONFIG }} `
--target Open3DViewer
+ cmake --build . --parallel ${{ env.NPROC }} --config ${{ matrix.CONFIG }} `
+ --target install
+ $version = (Select-String -Path "${{ env.BUILD_DIR }}/CMakeCache.txt" `
+ -Pattern "OPEN3D_VERSION_FULL").Line.Split('=')[1]
+ $viewerZip = "${{ env.BUILD_DIR }}/open3d-$version-app-windows-amd64.zip"
+ Compress-Archive -Path "${{ env.INSTALL_DIR }}/bin/Open3D" `
+ -DestinationPath $viewerZip
+ "VIEWER_ZIP_PATH=$viewerZip" | Out-File -FilePath $Env:GITHUB_ENV `
+ -Encoding utf8 -Append
+
+ - name: Upload Viewer
+ if: ${{ matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.device == 'cpu' && matrix.CONFIG == 'Release' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: open3d-app-windows-amd64
+ path: ${{ env.VIEWER_ZIP_PATH }}
+ if-no-files-found: error
+
+ - name: Update devel release with viewer
+ if: ${{ github.ref == 'refs/heads/main' && matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.device == 'cpu' && matrix.CONFIG == 'Release' }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ bash .github/workflows/update_release.sh "${{ env.VIEWER_ZIP_PATH }}"
- name: Setup WinApp CLI
if: ${{ matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.device == 'cpu' && matrix.CONFIG == 'Release' }}
@@ -285,13 +309,7 @@ jobs:
cmake --build . --parallel ${{ env.NPROC }} --config ${{ matrix.CONFIG }} `
--target Open3DViewerMSIX
- - name: Generate MSIX attestation
- if: ${{ github.ref == 'refs/heads/main' && matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.device == 'cpu' && matrix.CONFIG == 'Release' }}
- uses: actions/attest@v4
- with:
- subject-path: ${{ env.MSIX_PATH }}
-
- - name: Upload MSIX
+ - name: Upload MSIX test package
if: ${{ matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.device == 'cpu' && matrix.CONFIG == 'Release' }}
uses: actions/upload-artifact@v4
with:
@@ -301,13 +319,6 @@ jobs:
${{ env.BUILD_DIR }}\msix-package\Open3D.cer
if-no-files-found: error
- - name: Update devel release with viewer
- if: ${{ github.ref == 'refs/heads/main' && matrix.BUILD_SHARED_LIBS == 'OFF' && matrix.STATIC_RUNTIME == 'ON' && matrix.device == 'cpu' && matrix.CONFIG == 'Release' }}
- env:
- GH_TOKEN: ${{ github.token }}
- run: |
- bash .github/workflows/update_release.sh "${{ env.MSIX_PATH }}" "${{ env.BUILD_DIR }}\msix-package\Open3D.cer"
-
- name: Run C++ unit tests
if: ${{ matrix.device != 'cuda' }}
working-directory: ${{ env.BUILD_DIR }}
diff --git a/cpp/apps/CMakeLists.txt b/cpp/apps/CMakeLists.txt
index 838f7f9921f..807aa111066 100644
--- a/cpp/apps/CMakeLists.txt
+++ b/cpp/apps/CMakeLists.txt
@@ -137,7 +137,7 @@ macro(open3d_add_app_common SRC_DIR APP_NAME TARGET_NAME)
set_target_properties(${TARGET_NAME} PROPERTIES WIN32_EXECUTABLE TRUE)
if (MSVC)
target_link_options(${TARGET_NAME} PRIVATE "/ENTRY:mainCRTStartup")
- elseif (CMAKE_CXX_COMPILER_ID STREQUAL "IntelLLVM")
+ else # (CMAKE_CXX_COMPILER_ID STREQUAL "IntelLLVM" or "Clang")
target_link_options(${TARGET_NAME} PRIVATE
"-Xlinker" "/ENTRY:mainCRTStartup")
endif()
diff --git a/cpp/apps/Open3DViewer/WindowsMSIX/build_msix.ps1 b/cpp/apps/Open3DViewer/WindowsMSIX/build_msix.ps1
index ee532f1a196..43a5cfaeffe 100644
--- a/cpp/apps/Open3DViewer/WindowsMSIX/build_msix.ps1
+++ b/cpp/apps/Open3DViewer/WindowsMSIX/build_msix.ps1
@@ -36,18 +36,24 @@ $manifestPath = Join-Path $STAGING "AppxManifest.xml"
# Generate self-signed cert whose subject matches Publisher="CN=Open3D".
# Export .cer (public key only) so users can install it to trust the package.
-winapp cert generate `
- --manifest $manifestPath `
- --output (Join-Path $OutDir "Open3D.pfx") `
- --export-cer `
- --if-exists overwrite
-
-# Pack and sign the MSIX.
$MSIX_NAME = "Open3DViewer-$OPEN3D_VERSION-x64.msix"
$MSIX_PATH = Join-Path $OutDir $MSIX_NAME
-winapp pack $STAGING `
- --output $MSIX_PATH `
- --cert (Join-Path $OutDir "Open3D.pfx")
+$PFX_PATH = Join-Path $OutDir "Open3D.pfx"
+try {
+ winapp cert generate `
+ --manifest $manifestPath `
+ --output $PFX_PATH `
+ --export-cer `
+ --if-exists overwrite
+
+ # Pack and sign the MSIX. The private key is removed immediately after use.
+ winapp pack $STAGING `
+ --output $MSIX_PATH `
+ --cert $PFX_PATH
+}
+finally {
+ Remove-Item $PFX_PATH -Force -ErrorAction SilentlyContinue
+}
# If running in GitHub Actions, export the MSIX name and path to the environment.
if ($env:GITHUB_ENV) {
diff --git a/cpp/open3d/visualization/gui/NativeWin32.cpp b/cpp/open3d/visualization/gui/NativeWin32.cpp
index c3ddd2813f5..3f7b365c5cc 100644
--- a/cpp/open3d/visualization/gui/NativeWin32.cpp
+++ b/cpp/open3d/visualization/gui/NativeWin32.cpp
@@ -24,20 +24,20 @@ void SetNativeWindowIcon(GLFWwindow* glfw_window) {
HWND window = glfwGetWin32Window(glfw_window);
HINSTANCE instance = GetModuleHandle(nullptr);
HICON small_icon = static_cast(LoadImage(
- instance, "IDI_ICON1", IMAGE_ICON, GetSystemMetrics(SM_CXSMICON),
- GetSystemMetrics(SM_CYSMICON), 0));
+ instance, "IDI_ICON1", IMAGE_ICON, GetSystemMetrics(SM_CXSMICON),
+ GetSystemMetrics(SM_CYSMICON), 0));
HICON large_icon = static_cast(LoadImage(
- instance, "IDI_ICON1", IMAGE_ICON, GetSystemMetrics(SM_CXICON),
- GetSystemMetrics(SM_CYICON), 0));
+ instance, "IDI_ICON1", IMAGE_ICON, GetSystemMetrics(SM_CXICON),
+ GetSystemMetrics(SM_CYICON), 0));
if (small_icon && large_icon) {
SendMessage(window, WM_SETICON, ICON_SMALL,
- reinterpret_cast(small_icon));
+ reinterpret_cast(small_icon));
SendMessage(window, WM_SETICON, ICON_BIG,
- reinterpret_cast(large_icon));
- SetClassLongPtr(window, GCLP_HICON,
- reinterpret_cast(large_icon));
- SetClassLongPtr(window, GCLP_HICONSM,
- reinterpret_cast(small_icon));
+ reinterpret_cast(large_icon));
+ SetClassLongPtr(window, GCLP_HICON,
+ reinterpret_cast(large_icon));
+ SetClassLongPtr(window, GCLP_HICONSM,
+ reinterpret_cast(small_icon));
}
}
diff --git a/cpp/open3d/visualization/rendering/GpuAdapterSelection.cpp b/cpp/open3d/visualization/rendering/GpuAdapterSelection.cpp
index 98ef22ff429..e520e28d8fd 100644
--- a/cpp/open3d/visualization/rendering/GpuAdapterSelection.cpp
+++ b/cpp/open3d/visualization/rendering/GpuAdapterSelection.cpp
@@ -15,9 +15,8 @@
#ifndef NOMINMAX
#define NOMINMAX
#endif
-#include
-
#include
+#include
#pragma comment(lib, "dxgi.lib")
#endif
@@ -38,13 +37,12 @@
#endif
#include
+#include
#include
#include
#include
-#include
-
#include "open3d/utility/Logging.h"
namespace open3d {
@@ -74,8 +72,8 @@ bool FindMonitorRectForAdapter(const GpuAdapterInfo& info, RECT* out_rect) {
if (!adapter) continue;
DXGI_ADAPTER_DESC1 adesc{};
if (SUCCEEDED(adapter->GetDesc1(&adesc)) &&
- std::memcmp(&adesc.AdapterLuid, info.luid,
- sizeof(info.luid)) == 0) {
+ std::memcmp(&adesc.AdapterLuid, info.luid, sizeof(info.luid)) ==
+ 0) {
IDXGIOutput* output = nullptr;
if (adapter->EnumOutputs(0, &output) != DXGI_ERROR_NOT_FOUND &&
output) {
@@ -99,11 +97,10 @@ bool FindMonitorRectForAdapter(const GpuAdapterInfo& info, RECT* out_rect) {
GpuAdapterInfo GetAdapterInfo(VkPhysicalDevice physical_device) {
GpuAdapterInfo info;
vk::PhysicalDevice pd(physical_device);
- const auto chain =
- pd.getProperties2();
+ const auto chain = pd.getProperties2();
info.device_name = chain.get()
- .properties.deviceName.data();
+ .properties.deviceName.data();
const auto& id = chain.get();
if (id.deviceLUIDValid) {
std::memcpy(info.luid, id.deviceLUID.data(), sizeof(info.luid));
@@ -203,8 +200,7 @@ GpuAdapterInfo GetAdapterInfo(VkPhysicalDevice physical_device) {
const auto chain =
pd.getProperties2();
- const auto& pci =
- chain.get();
+ const auto& pci = chain.get();
info.pci_domain = pci.pciDomain;
info.pci_bus = pci.pciBus;
info.pci_device = pci.pciDevice;
@@ -213,8 +209,9 @@ GpuAdapterInfo GetAdapterInfo(VkPhysicalDevice physical_device) {
const auto driver_chain =
pd.getProperties2();
- info.is_nvidia = driver_chain.get()
- .driverID == vk::DriverId::eNvidiaProprietary;
+ info.is_nvidia =
+ driver_chain.get().driverID ==
+ vk::DriverId::eNvidiaProprietary;
info.valid = true;
return info;
}
@@ -222,9 +219,9 @@ GpuAdapterInfo GetAdapterInfo(VkPhysicalDevice physical_device) {
bool SteerNextGLContextToAdapter(const GpuAdapterInfo& info) {
if (!info.valid) return false;
- const std::string pci_id = fmt::format(
- "pci-{:04x}_{:02x}_{:02x}_{:01x}", info.pci_domain,
- info.pci_bus, info.pci_device, info.pci_function);
+ const std::string pci_id =
+ fmt::format("pci-{:04x}_{:02x}_{:02x}_{:01x}", info.pci_domain,
+ info.pci_bus, info.pci_device, info.pci_function);
// EXPERIMENTAL: no portable GLX API exists to select a specific GPU, so
// this relies on the Mesa/NVIDIA PRIME-offload env var convention, which
diff --git a/cpp/open3d/visualization/rendering/filament/FilamentEngine.cpp b/cpp/open3d/visualization/rendering/filament/FilamentEngine.cpp
index 38b49053558..1e8781e2453 100644
--- a/cpp/open3d/visualization/rendering/filament/FilamentEngine.cpp
+++ b/cpp/open3d/visualization/rendering/filament/FilamentEngine.cpp
@@ -163,8 +163,8 @@ EngineInstance::EngineInstance() {
// GL_EXT_memory_object import) matters more than which GPU is
// used.
if (gl_ctx.IsValid() && vk_adapter_info.valid) {
- const GpuAdapterInfo gl_actual = GetAdapterInfoForWindow(
- gl_ctx.GetNativeWindowHandle());
+ const GpuAdapterInfo gl_actual =
+ GetAdapterInfoForWindow(gl_ctx.GetNativeWindowHandle());
if (gl_actual.valid &&
!SameAdapter(gl_actual, vk_adapter_info)) {
utility::LogWarning(
@@ -176,7 +176,8 @@ EngineInstance::EngineInstance() {
if (!vk_ctx.Initialize(&gl_actual)) {
utility::LogWarning(
"EngineInstance: Vulkan reinit to match GL "
- "adapter failed: {}", vk_ctx.GetLastError());
+ "adapter failed: {}",
+ vk_ctx.GetLastError());
}
}
}
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatPassRunner.cpp b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatPassRunner.cpp
index d10b6f4cb0d..9017fb1a020 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatPassRunner.cpp
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatPassRunner.cpp
@@ -194,9 +194,11 @@ bool RunGaussianGeometryPasses(
// references binding 5 even when guarded by "if (sh_degree >= 1u)",
// so a size-0 (i.e. unallocated) buffer leaves the descriptor unwritten
// and unbound, which some drivers (e.g. Intel Arc) fault on even
- // though it's never read at that degree (VUID-vkCmdDispatch-None-08114).
- const std::size_t sh_bytes = std::max(
- 1, attrs.sh_coefficients.size()) * sizeof(std::uint32_t);
+ // though it's never read at that degree
+ // (VUID-vkCmdDispatch-None-08114).
+ const std::size_t sh_bytes =
+ std::max(1, attrs.sh_coefficients.size()) *
+ sizeof(std::uint32_t);
vs.sh_buf = ctx.ResizeBuffer(vs.sh_buf, sh_bytes, "gs.sh_coeffs");
}
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanBackend.cpp b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanBackend.cpp
index 2bf70bedeb5..6c79be47cb8 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanBackend.cpp
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanBackend.cpp
@@ -180,7 +180,8 @@ class GaussianSplatVulkanBackend final : public GaussianSplatRenderer::Backend {
resource_mgr.CreateRenderTarget(view_color, targets.depth);
}
// Disable MSAA before binding the render target: Filament validates
- // MSAA/sampleable-depth compatibility inside SetRenderTarget() (see #7495).
+ // MSAA/sampleable-depth compatibility inside SetRenderTarget() (see
+ // #7495).
auto* native = view.GetNativeView();
auto msaa = native->getMultiSampleAntiAliasingOptions();
msaa.enabled = false;
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp
index a4a6df38158..c007f92b35e 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp
@@ -38,14 +38,13 @@
VULKAN_HPP_DEFAULT_DISPATCH_LOADER_DYNAMIC_STORAGE
#include
+#include
#include
#include
#include
#include
-#include
-
#include "open3d/utility/Logging.h"
// Initialize VMA in this single translation unit.
@@ -123,7 +122,8 @@ bool CheckExtensions(const std::vector& available,
constexpr int kDeviceRejected = std::numeric_limits::min();
/// Score a physical device for interop suitability. Higher is better.
-/// Native discrete > native integrated > other native > D3D12 translation > CPU.
+/// Native discrete > native integrated > other native > D3D12 translation >
+/// CPU.
/// kDeviceRejected : no compute queue or missing required extensions/version
int ScoreDevice(const vk::raii::PhysicalDevice& dev) {
// Check for a compute queue.
@@ -152,11 +152,11 @@ int ScoreDevice(const vk::raii::PhysicalDevice& dev) {
if (props.apiVersion < VK_API_VERSION_1_3) return kDeviceRejected;
const std::string renderer(props.deviceName.data());
- const bool software = renderer.find("llvmpipe") != std::string::npos ||
- renderer.find("SwiftShader") != std::string::npos ||
- renderer.find("WARP") != std::string::npos ||
- renderer.find("Basic Render Driver") !=
- std::string::npos;
+ const bool software =
+ renderer.find("llvmpipe") != std::string::npos ||
+ renderer.find("SwiftShader") != std::string::npos ||
+ renderer.find("WARP") != std::string::npos ||
+ renderer.find("Basic Render Driver") != std::string::npos;
if (software) return 0;
int score = 10;
@@ -357,7 +357,7 @@ bool GaussianSplatVulkanInteropContext::SelectPhysicalDevice(
if (required_adapter && required_adapter->valid &&
!SameAdapter(GetAdapterInfo(static_cast(
*devices[i])),
- *required_adapter)) {
+ *required_adapter)) {
continue; // not the required adapter; skip
}
if (score > best_score) {
@@ -372,7 +372,9 @@ bool GaussianSplatVulkanInteropContext::SelectPhysicalDevice(
? "No Vulkan device matched the required adapter '" +
required_adapter->device_name + "'"
: "No suitable Vulkan device found with required "
- "interop extensions. Required extensions: " VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME;
+ "interop extensions. Required "
+ "extensions:"
+ " " VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME;
utility::LogWarning("GaussianSplat Vulkan: {}", last_error_);
return false;
}
diff --git a/cpp/pybind/t/geometry/pointcloud.cpp b/cpp/pybind/t/geometry/pointcloud.cpp
index 9f3a045a693..c2afb26bb3b 100644
--- a/cpp/pybind/t/geometry/pointcloud.cpp
+++ b/cpp/pybind/t/geometry/pointcloud.cpp
@@ -261,9 +261,10 @@ void pybind_pointcloud_definitions(py::module& m) {
"non-negative number less than number of points in the "
"input pointcloud.",
"start_index"_a = 0);
- pointcloud.def("remove_radius_outliers", &PointCloud::RemoveRadiusOutliers,
- "nb_points"_a, "search_radius"_a,
- R"(Remove points that have less than nb_points neighbors in a
+ pointcloud.def(
+ "remove_radius_outliers", &PointCloud::RemoveRadiusOutliers,
+ "nb_points"_a, "search_radius"_a,
+ R"(Remove points that have less than nb_points neighbors in a
sphere of a given search radius.
Args:
From a6bf9312aa324ede3dd2c9536c8570b4f9822a44 Mon Sep 17 00:00:00 2001
From: Sameer Sheorey <41028320+ssheorey@users.noreply.github.com>
Date: Sat, 15 Aug 2026 15:38:45 -0700
Subject: [PATCH 07/11] Fix CMake Windows compiler conditional
---
cpp/apps/CMakeLists.txt | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/cpp/apps/CMakeLists.txt b/cpp/apps/CMakeLists.txt
index 807aa111066..554078eadef 100644
--- a/cpp/apps/CMakeLists.txt
+++ b/cpp/apps/CMakeLists.txt
@@ -137,7 +137,8 @@ macro(open3d_add_app_common SRC_DIR APP_NAME TARGET_NAME)
set_target_properties(${TARGET_NAME} PROPERTIES WIN32_EXECUTABLE TRUE)
if (MSVC)
target_link_options(${TARGET_NAME} PRIVATE "/ENTRY:mainCRTStartup")
- else # (CMAKE_CXX_COMPILER_ID STREQUAL "IntelLLVM" or "Clang")
+ else()
+ # IntelLLVM and Clang on Windows use the standard main entry point.
target_link_options(${TARGET_NAME} PRIVATE
"-Xlinker" "/ENTRY:mainCRTStartup")
endif()
From 04789ba59f0b93541924c6a118a0d1a1b6812ec9 Mon Sep 17 00:00:00 2001
From: Sameer Sheorey
Date: Sat, 15 Aug 2026 23:54:01 -0700
Subject: [PATCH 08/11] Linux multi-gpu fix: PRIME steering before glfwInit(),
use doube buffered hidden window.
---
.../visualization/gui/GLFWWindowSystem.cpp | 14 ++++++++++++++
.../gaussian_splat/GaussianSplatDesign.md | 5 +++++
.../GaussianSplatOpenGLContext.cpp | 5 ++++-
.../visualization/gaussian_splatting.ipynb | 16 ++++++++++++++++
4 files changed, 39 insertions(+), 1 deletion(-)
diff --git a/cpp/open3d/visualization/gui/GLFWWindowSystem.cpp b/cpp/open3d/visualization/gui/GLFWWindowSystem.cpp
index bd3dcc944c4..30204ef46f0 100644
--- a/cpp/open3d/visualization/gui/GLFWWindowSystem.cpp
+++ b/cpp/open3d/visualization/gui/GLFWWindowSystem.cpp
@@ -21,6 +21,10 @@
#endif
#include "open3d/visualization/gui/Native.h"
#include "open3d/visualization/gui/Window.h"
+#if defined(__linux__)
+#include "open3d/visualization/rendering/GpuAdapterSelection.h"
+#include "open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.h"
+#endif
#include "open3d/visualization/rendering/filament/FilamentEngine.h"
#include "open3d/visualization/rendering/filament/FilamentRenderer.h"
@@ -111,6 +115,16 @@ void GLFWWindowSystem::Initialize() {
glfwInitHint(GLFW_COCOA_CHDIR_RESOURCES, GLFW_FALSE);
#endif
#if defined(__linux__)
+ // GLFW/GLX caches the vendor selection during glfwInit(). Select the
+ // Vulkan adapter and apply PRIME steering before initializing GLFW so the
+ // helper OpenGL context can be created on the same GPU.
+ auto& vk_ctx = rendering::GaussianSplatVulkanInteropContext::GetInstance();
+ if (!vk_ctx.IsValid() && vk_ctx.Initialize() && vk_ctx.IsValid()) {
+ const rendering::GpuAdapterInfo adapter =
+ rendering::GetAdapterInfo(vk_ctx.GetPhysicalDevice());
+ rendering::SteerNextGLContextToAdapter(adapter);
+ }
+
// Filament (April 2026) selects PlatformGLX exclusively on Linux
// (compile-time decision in PlatformFactory.cpp). Force GLFW to X11 so the
// native window handle is an X11 Window (XID), matching what PlatformGLX
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatDesign.md b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatDesign.md
index e06fe73340b..84e791e535c 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatDesign.md
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatDesign.md
@@ -163,6 +163,11 @@ is set at creation time and cannot be added retroactively.
(`PlatformGLX` on Linux, `PlatformWGL` on Windows).
5. Both contexts share the same GL object namespace; texture handles are valid in both.
+On Linux, `GLFWWindowSystem::Initialize()` initializes the Vulkan interop device
+and applies PRIME steering before calling `glfwInit()`. GLFW/GLX caches the
+vendor selection during initialization, so steering after `glfwInit()` can make
+NVIDIA GLX fail to find a compatible framebuffer configuration.
+
### Backend Abstraction
```
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp
index 22a0c97e722..fd7ddfffebc 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp
@@ -94,7 +94,10 @@ bool GaussianSplatOpenGLContext::InitializeStandalone() {
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 6);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
- glfwWindowHint(GLFW_DOUBLEBUFFER, GLFW_FALSE);
+ // NVIDIA's GLX driver does not expose a compatible single-buffered
+ // framebuffer configuration for this core-profile context. The helper
+ // window never presents, so double buffering has no runtime cost here.
+ glfwWindowHint(GLFW_DOUBLEBUFFER, GLFW_TRUE);
GLFWwindow* window =
glfwCreateWindow(1, 1, "O3D_GS_Helper", nullptr, nullptr);
diff --git a/docs/jupyter/visualization/gaussian_splatting.ipynb b/docs/jupyter/visualization/gaussian_splatting.ipynb
index f5795cef1f2..1c166ea84e3 100644
--- a/docs/jupyter/visualization/gaussian_splatting.ipynb
+++ b/docs/jupyter/visualization/gaussian_splatting.ipynb
@@ -87,6 +87,22 @@
"assets from a CSV manifest file from the command line."
]
},
+ {
+ "cell_type": "markdown",
+ "id": "5802328f",
+ "metadata": {},
+ "source": [
+ "## Multi-GPU systems\n",
+ "\n",
+ "On Linux, select the Vulkan GPU before starting Python or Open3D. For example, to select Intel devices:\n",
+ "\n",
+ "```bash\n",
+ "VK_LOADER_DRIVERS_SELECT='*intel*' python your_script.py\n",
+ "```\n",
+ "\n",
+ "Replace `*intel*` with an appropriate Vulkan loader filter for another GPU. On Windows, the GPU used for 3DGS must be connected to a display; render-only adapters without a display output cannot be selected for the shared OpenGL/Vulkan rendering path."
+ ]
+ },
{
"cell_type": "code",
"execution_count": 1,
From e14a1cd8c5ba5e6ab4b40d459392edda1cc84fdd Mon Sep 17 00:00:00 2001
From: Sameer Sheorey <41028320+ssheorey@users.noreply.github.com>
Date: Sun, 16 Aug 2026 16:27:21 -0700
Subject: [PATCH 09/11] Fix IntelLLVM Windows linker options
---
cpp/apps/CMakeLists.txt | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/cpp/apps/CMakeLists.txt b/cpp/apps/CMakeLists.txt
index 554078eadef..6ff0f87b12e 100644
--- a/cpp/apps/CMakeLists.txt
+++ b/cpp/apps/CMakeLists.txt
@@ -135,10 +135,12 @@ macro(open3d_add_app_common SRC_DIR APP_NAME TARGET_NAME)
add_executable(${TARGET_NAME} ${SOURCE_FILES} ${HEADER_FILES})
# Suppress the console window while keeping the standard main() entry point (no WinMain).
set_target_properties(${TARGET_NAME} PROPERTIES WIN32_EXECUTABLE TRUE)
- if (MSVC)
+ if (CMAKE_CXX_COMPILER_ID STREQUAL "IntelLLVM")
+ target_link_options(${TARGET_NAME} PRIVATE
+ "-Xlinker" "/ENTRY:mainCRTStartup")
+ elseif (MSVC)
target_link_options(${TARGET_NAME} PRIVATE "/ENTRY:mainCRTStartup")
else()
- # IntelLLVM and Clang on Windows use the standard main entry point.
target_link_options(${TARGET_NAME} PRIVATE
"-Xlinker" "/ENTRY:mainCRTStartup")
endif()
From 2c83d75445ce657a0e83c119d22a4445427998ed Mon Sep 17 00:00:00 2001
From: Sameer Sheorey <41028320+ssheorey@users.noreply.github.com>
Date: Mon, 17 Aug 2026 21:28:31 -0700
Subject: [PATCH 10/11] address copilot review
---
cpp/open3d/visualization/app/Viewer.cpp | 2 --
cpp/open3d/visualization/gui/NativeWin32.cpp | 3 +++
.../rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp | 1 -
.../gaussian_splat/GaussianSplatVulkanInteropContext.cpp | 1 -
docs/jupyter/visualization/gaussian_splatting.ipynb | 3 +++
5 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/cpp/open3d/visualization/app/Viewer.cpp b/cpp/open3d/visualization/app/Viewer.cpp
index 84684b68c00..c4b965bbb9c 100644
--- a/cpp/open3d/visualization/app/Viewer.cpp
+++ b/cpp/open3d/visualization/app/Viewer.cpp
@@ -24,8 +24,6 @@ static const int width = 1280;
static const int height = 960;
void RunViewer(int argc, const char *argv[]) {
- utility::Logger::GetInstance().SetVerbosityLevel(
- utility::VerbosityLevel::Debug);
std::function print_fcn =
utility::Logger::GetInstance().GetPrintFunction();
utility::Logger::GetInstance().ResetPrintFunction();
diff --git a/cpp/open3d/visualization/gui/NativeWin32.cpp b/cpp/open3d/visualization/gui/NativeWin32.cpp
index 3f7b365c5cc..2f5571cf4b2 100644
--- a/cpp/open3d/visualization/gui/NativeWin32.cpp
+++ b/cpp/open3d/visualization/gui/NativeWin32.cpp
@@ -22,6 +22,9 @@ void* GetNativeDrawable(GLFWwindow* glfw_window) {
void SetNativeWindowIcon(GLFWwindow* glfw_window) {
HWND window = glfwGetWin32Window(glfw_window);
+ if (!window) {
+ return;
+ }
HINSTANCE instance = GetModuleHandle(nullptr);
HICON small_icon = static_cast(LoadImage(
instance, "IDI_ICON1", IMAGE_ICON, GetSystemMetrics(SM_CXSMICON),
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp
index fd7ddfffebc..b5fb63465d4 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatOpenGLContext.cpp
@@ -20,7 +20,6 @@
#if !defined(__APPLE__)
-#include
#include
#include
diff --git a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp
index c007f92b35e..476d9146ce6 100644
--- a/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp
+++ b/cpp/open3d/visualization/rendering/gaussian_splat/GaussianSplatVulkanInteropContext.cpp
@@ -38,7 +38,6 @@
VULKAN_HPP_DEFAULT_DISPATCH_LOADER_DYNAMIC_STORAGE
#include
-#include
#include
#include
diff --git a/docs/jupyter/visualization/gaussian_splatting.ipynb b/docs/jupyter/visualization/gaussian_splatting.ipynb
index 1c166ea84e3..d617fe9e118 100644
--- a/docs/jupyter/visualization/gaussian_splatting.ipynb
+++ b/docs/jupyter/visualization/gaussian_splatting.ipynb
@@ -98,6 +98,9 @@
"\n",
"```bash\n",
"VK_LOADER_DRIVERS_SELECT='*intel*' python your_script.py\n",
+ "\n",
+ "# To select a specific physical device \"vendorID:deviceID\" instead of a vendor's drivers:\n",
+ "VK_LOADER_DEVICE_SELECT='0x8086:0x56a1' python your_script.py\n",
"```\n",
"\n",
"Replace `*intel*` with an appropriate Vulkan loader filter for another GPU. On Windows, the GPU used for 3DGS must be connected to a display; render-only adapters without a display output cannot be selected for the shared OpenGL/Vulkan rendering path."
From 089e99cba2f98e599afe91fee0769c69c56f0bc2 Mon Sep 17 00:00:00 2001
From: Sameer Sheorey
Date: Tue, 18 Aug 2026 23:50:12 -0700
Subject: [PATCH 11/11] style
---
cpp/pybind/t/geometry/pointcloud.cpp | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/cpp/pybind/t/geometry/pointcloud.cpp b/cpp/pybind/t/geometry/pointcloud.cpp
index c2afb26bb3b..9f3a045a693 100644
--- a/cpp/pybind/t/geometry/pointcloud.cpp
+++ b/cpp/pybind/t/geometry/pointcloud.cpp
@@ -261,10 +261,9 @@ void pybind_pointcloud_definitions(py::module& m) {
"non-negative number less than number of points in the "
"input pointcloud.",
"start_index"_a = 0);
- pointcloud.def(
- "remove_radius_outliers", &PointCloud::RemoveRadiusOutliers,
- "nb_points"_a, "search_radius"_a,
- R"(Remove points that have less than nb_points neighbors in a
+ pointcloud.def("remove_radius_outliers", &PointCloud::RemoveRadiusOutliers,
+ "nb_points"_a, "search_radius"_a,
+ R"(Remove points that have less than nb_points neighbors in a
sphere of a given search radius.
Args: