From ebfce0e61039a10d5db17de636f442649dfcceef Mon Sep 17 00:00:00 2001 From: acalcutt Date: Tue, 7 Jul 2026 08:36:29 -0400 Subject: [PATCH 01/16] Ship Apple native in MapLibreNative.Maui.Vulkan package (parity with base) The Vulkan bindings package packed only the Windows DLLs, so unlike the base MapLibreNative.Maui package it delivered no native library on iOS/macCatalyst - a transitive consumer (via the handlers) linked nothing. Bring it to parity: - Pack the iOS XCFramework (device + simulator) and macCatalyst .a into buildTransitive/native/, and prefer the XCFramework for local iOS builds. - Add buildTransitive/MapLibreNative.Maui.Vulkan.targets (mirrors the base package's targets) to re-add the Apple NativeReferences - with the Metal frameworks and -lsqlite3 -lz -lc++ linker flags - for transitive consumers. - release.yml: build the Vulkan iOS XCFramework in the pack-vulkan job before packing, matching the base pack job. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 16 +++++++ CHANGELOG.md | 1 + bindings/MapLibreNative.Maui.Vulkan.csproj | 39 ++++++++++++++++- .../MapLibreNative.Maui.Vulkan.targets | 43 +++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 bindings/buildTransitive/MapLibreNative.Maui.Vulkan.targets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb9eb4b..342b5df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -441,6 +441,22 @@ jobs: echo "Vulkan native libs arranged:" find $N -type f + - name: Create iOS XCFramework + run: | + N=bindings/native-vulkan + mkdir -p $N/ios + XCFW_ARGS="" + [ -f $N/ios-arm64/libmln-cabi.a ] && XCFW_ARGS="$XCFW_ARGS -library $N/ios-arm64/libmln-cabi.a" + [ -f $N/iossimulator-arm64/libmln-cabi.a ] && XCFW_ARGS="$XCFW_ARGS -library $N/iossimulator-arm64/libmln-cabi.a" + if [ -n "$XCFW_ARGS" ]; then + xcodebuild -create-xcframework \ + $XCFW_ARGS \ + -output $N/ios/libmln-cabi.xcframework + echo "XCFramework created at $N/ios/libmln-cabi.xcframework" + else + echo "No iOS libraries found; skipping XCFramework creation." + fi + - name: Restore workloads run: dotnet workload restore maplibre-maui.sln diff --git a/CHANGELOG.md b/CHANGELOG.md index ff80c63..360a730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## 4.1.2 ### 🐞 Bug fixes - **MAUI Windows: double-clicking the nav/GPS/d-pad overlay buttons leaked through to the map** — On WinUI the second click of a fast double-click is raised as `DoubleTapped` (not a second `Tapped`), so the overlay buttons, which only handled `Tapped`, dropped every second press and let the unhandled `DoubleTapped` bubble past the button — zooming/panning the map "behind" it. Fixed by also handling `DoubleTapped` on the zoom (+/−) buttons, GPS buttons, and rotate/pitch d-pad arrows (running the same action and marking the event handled), and swallowing `DoubleTapped` on the attribution chip. +- **`MapLibreNative.Maui.Vulkan`: package shipped native binaries for Windows only** — The Vulkan bindings package packed just the Windows DLLs; unlike the base `MapLibreNative.Maui` package it did not pack the iOS XCFramework, the macCatalyst static library, or a `buildTransitive` targets file, so an iOS/macCatalyst app consuming it (transitively through the handlers) linked no native library. Brought to parity with the base package: the Vulkan package now packs the iOS XCFramework (device + simulator slices) and macCatalyst `.a`, and ships `buildTransitive/MapLibreNative.Maui.Vulkan.targets` to re-add the Apple `NativeReference`s for transitive consumers. The release pipeline now builds the Vulkan iOS XCFramework before packing. ## 4.1.1 ### 🐞 Bug fixes diff --git a/bindings/MapLibreNative.Maui.Vulkan.csproj b/bindings/MapLibreNative.Maui.Vulkan.csproj index 0f03604..cd1acd8 100644 --- a/bindings/MapLibreNative.Maui.Vulkan.csproj +++ b/bindings/MapLibreNative.Maui.Vulkan.csproj @@ -64,6 +64,36 @@ true runtimes/win-arm64/native + + + + true + buildTransitive/native/ios/libmln-cabi.xcframework/%(RecursiveDir)%(Filename)%(Extension) + + + + + true + buildTransitive/native/maccatalyst + + + + + true + buildTransitive + @@ -88,8 +118,15 @@ + + + Static + True + + + Condition="Exists('$(_MlnNativeDir)ios-arm64\libmln-cabi.a') And !Exists('$(_MlnNativeDir)ios\libmln-cabi.xcframework')"> Static True diff --git a/bindings/buildTransitive/MapLibreNative.Maui.Vulkan.targets b/bindings/buildTransitive/MapLibreNative.Maui.Vulkan.targets new file mode 100644 index 0000000..89bfe3f --- /dev/null +++ b/bindings/buildTransitive/MapLibreNative.Maui.Vulkan.targets @@ -0,0 +1,43 @@ + + + + + + Static + True + + Metal MetalKit QuartzCore CoreGraphics Foundation UIKit + + -lsqlite3 -lz -lc++ + + + + + + Static + True + Metal MetalKit QuartzCore CoreGraphics Foundation + -lsqlite3 -lz -lc++ + + + From 2a772f0a5eff73d0fa666e13d2b4a3718259497b Mon Sep 17 00:00:00 2001 From: acalcutt Date: Tue, 7 Jul 2026 08:39:12 -0400 Subject: [PATCH 02/16] ci: build + verify Vulkan iOS XCFramework in the pack-vulkan job The CI pack-vulkan job (which runs on PRs) arranged the Apple slices but never built the iOS XCFramework, so with the new packaging the iOS native would be silently skipped (file-existence gated) - the parity change would go untested. Add the Create iOS XCFramework step (mirroring the base pack job) and a verification step that fails the build if the packed Vulkan .nupkg is missing the buildTransitive targets, the macCatalyst .a, or the iOS XCFramework. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80cafec..ea29496 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -439,6 +439,22 @@ jobs: echo "Vulkan native libs arranged:" find $N -type f + - name: Create iOS XCFramework + run: | + N=bindings/native-vulkan + mkdir -p $N/ios + XCFW_ARGS="" + [ -f $N/ios-arm64/libmln-cabi.a ] && XCFW_ARGS="$XCFW_ARGS -library $N/ios-arm64/libmln-cabi.a" + [ -f $N/iossimulator-arm64/libmln-cabi.a ] && XCFW_ARGS="$XCFW_ARGS -library $N/iossimulator-arm64/libmln-cabi.a" + if [ -n "$XCFW_ARGS" ]; then + xcodebuild -create-xcframework \ + $XCFW_ARGS \ + -output $N/ios/libmln-cabi.xcframework + echo "XCFramework created at $N/ios/libmln-cabi.xcframework" + else + echo "No iOS libraries found; skipping XCFramework creation." + fi + - name: Restore workloads run: dotnet workload restore maplibre-maui.sln @@ -450,6 +466,26 @@ jobs: -p:_MlnNativeDir=$(pwd)/bindings/native-vulkan/ \ -o artifacts/ + - name: Verify Vulkan package ships Apple native (parity with base) + run: | + PKG=$(ls artifacts/MapLibreNative.Maui.Vulkan.*.nupkg | head -1) + echo "Inspecting $PKG" + CONTENTS=$(unzip -Z1 "$PKG") + echo "$CONTENTS" + fail=0 + for entry in \ + "buildTransitive/MapLibreNative.Maui.Vulkan.targets" \ + "buildTransitive/native/maccatalyst/libmln-cabi.a" \ + "buildTransitive/native/ios/libmln-cabi.xcframework/Info.plist"; do + if echo "$CONTENTS" | grep -q "$entry"; then + echo "OK $entry" + else + echo "MISSING $entry" + fail=1 + fi + done + exit $fail + - name: Upload NuGet Vulkan artifact uses: actions/upload-artifact@v4 with: From 46e7112bdb5260e80316cd787d50658c14873742 Mon Sep 17 00:00:00 2001 From: acalcutt Date: Tue, 7 Jul 2026 09:33:35 -0400 Subject: [PATCH 03/16] native: backend-agnostic frontend C ABI + shared Vulkan frontend base Foundation for real Vulkan frontends. Adds, without changing existing GL/Metal behaviour (compiles on all backends): - mbgl_get_render_backend() -> "opengl"|"vulkan"|"metal" so the shared managed layer can pick the right surface handshake at runtime. - mbgl_frontend_create() (backend-agnostic) with mbgl_frontend_create_gl() kept as a thin alias. - mbgl_frontend_read_pixels() + PlatformFrontend::readPixels() (default no-op) for the offscreen (Vulkan Windows) read-back path. - platform_frontend_vulkan_common.hpp: VulkanFrontendT implementing the PlatformFrontend/RendererFrontend interface once; each platform supplies only a surface-specific mbgl::vulkan::RendererBackend. Not yet wired in. Co-Authored-By: Claude Opus 4.8 (1M context) --- native/include/mln_cabi.h | 31 ++++++ native/src/mln_cabi.cpp | 34 ++++++- native/src/platform_frontend.hpp | 8 +- .../src/platform_frontend_vulkan_common.hpp | 94 +++++++++++++++++++ 4 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 native/src/platform_frontend_vulkan_common.hpp diff --git a/native/include/mln_cabi.h b/native/include/mln_cabi.h index 5e654c0..1cfc6fa 100644 --- a/native/include/mln_cabi.h +++ b/native/include/mln_cabi.h @@ -139,7 +139,30 @@ MLN_CABI_API mbgl_runloop_t* mbgl_runloop_create(void) MLN_CABI_NOEXCEPT; MLN_CABI_API mbgl_status_t mbgl_runloop_destroy(mbgl_runloop_t* rl) MLN_CABI_NOEXCEPT; MLN_CABI_API mbgl_status_t mbgl_runloop_run_once(mbgl_runloop_t* rl) MLN_CABI_NOEXCEPT; +/* ── Render backend ────────────────────────────────────────────────────────── */ +/** Returns the renderer this build of mln-cabi was compiled against: + * "opengl", "vulkan", or "metal". Never NULL. Lets the (shared) managed layer + * pick the correct surface handshake at runtime — the GL and Vulkan packages + * ship the same C# but different native libraries under the same name. */ +MLN_CABI_API const char* mbgl_get_render_backend(void) MLN_CABI_NOEXCEPT; + /* ── Frontend ──────────────────────────────────────────────────────────────── */ +/** Backend-agnostic frontend factory. The meaning of surface_handle depends on + * the compiled backend and platform: + * OpenGL (Windows): HDC + gl_context = HGLRC + * Vulkan (Windows): ignored (offscreen render + read-back via mbgl_frontend_read_pixels) + * Vulkan/GL (Android): ANativeWindow* + gl_context = NULL + * Metal/Vulkan (Apple): NULL + gl_context = NULL (view is created internally; + * retrieve it via mbgl_frontend_get_native_view) */ +MLN_CABI_API mbgl_frontend_t* mbgl_frontend_create( + void* surface_handle, + void* gl_context, + int width_px, + int height_px, + float pixel_ratio, + mbgl_render_fn render_callback, + void* render_userdata) MLN_CABI_NOEXCEPT; +/** Deprecated alias for mbgl_frontend_create, kept for ABI/source compatibility. */ MLN_CABI_API mbgl_frontend_t* mbgl_frontend_create_gl( void* surface_handle, void* gl_context, @@ -152,6 +175,14 @@ MLN_CABI_API mbgl_status_t mbgl_frontend_destroy(mbgl_frontend_t* fe) MLN_CAB MLN_CABI_API mbgl_status_t mbgl_frontend_render(mbgl_frontend_t* fe) MLN_CABI_NOEXCEPT; MLN_CABI_API mbgl_status_t mbgl_frontend_set_size(mbgl_frontend_t* fe, int width_px, int height_px) MLN_CABI_NOEXCEPT; MLN_CABI_API void* mbgl_frontend_get_native_view(mbgl_frontend_t* fe) MLN_CABI_NOEXCEPT; +/** Copies the most recently rendered frame as tightly-packed premultiplied RGBA + * (width*height*4 bytes, top-down) into out_buf. Used by the offscreen (Vulkan + * Windows) path to blit into the in-tree bitmap surface. Returns MBGL_UNSUPPORTED + * for frontends that present directly (GL Windows read back GL-side; Android/Apple + * present to their own surface/view). buf_len must be >= width*height*4. */ +MLN_CABI_API mbgl_status_t mbgl_frontend_read_pixels(mbgl_frontend_t* fe, + uint8_t* out_buf, + size_t buf_len) MLN_CABI_NOEXCEPT; /* ── Map ───────────────────────────────────────────────────────────────────── */ MLN_CABI_API mbgl_map_t* mbgl_map_create( diff --git a/native/src/mln_cabi.cpp b/native/src/mln_cabi.cpp index c2d9bfb..7ae93da 100644 --- a/native/src/mln_cabi.cpp +++ b/native/src/mln_cabi.cpp @@ -279,7 +279,17 @@ mbgl_status_t mbgl_runloop_run_once(mbgl_runloop_t* rl) noexcept { /* ─── Frontend ──────────────────────────────────────────────────────────────── */ -mbgl_frontend_t* mbgl_frontend_create_gl( +const char* mbgl_get_render_backend() noexcept { +#if defined(MLN_RENDER_BACKEND_VULKAN) + return "vulkan"; +#elif defined(MLN_RENDER_BACKEND_METAL) + return "metal"; +#else + return "opengl"; +#endif +} + +mbgl_frontend_t* mbgl_frontend_create( void* surface_handle, void* gl_context, int width_px, @@ -297,6 +307,19 @@ mbgl_frontend_t* mbgl_frontend_create_gl( } catch (const std::exception& e) { set_native_error(e); return nullptr; } } +mbgl_frontend_t* mbgl_frontend_create_gl( + void* surface_handle, + void* gl_context, + int width_px, + int height_px, + float pixel_ratio, + mbgl_render_fn render_callback, + void* render_userdata) noexcept +{ + return mbgl_frontend_create(surface_handle, gl_context, width_px, height_px, + pixel_ratio, render_callback, render_userdata); +} + mbgl_status_t mbgl_frontend_destroy(mbgl_frontend_t* fe) noexcept { if (!fe) return set_error(MBGL_INVALID_ARG, "mbgl_frontend_destroy: null handle"); try { delete fe_ptr(fe); return MBGL_OK; } @@ -322,6 +345,15 @@ void* mbgl_frontend_get_native_view(mbgl_frontend_t* fe) noexcept { return fe_ptr(fe)->getNativeView(); } +mbgl_status_t mbgl_frontend_read_pixels(mbgl_frontend_t* fe, uint8_t* out_buf, size_t buf_len) noexcept { + if (!fe || !out_buf) return set_error(MBGL_INVALID_ARG, "mbgl_frontend_read_pixels: null arg"); + try { + return fe_ptr(fe)->readPixels(out_buf, buf_len) + ? MBGL_OK + : set_error(MBGL_UNSUPPORTED, "mbgl_frontend_read_pixels: frontend has no CPU read-back"); + } catch (const std::exception& e) { return set_native_error(e); } +} + /* ─── Map ───────────────────────────────────────────────────────────────────── */ static mbgl_map_t* map_create_impl( diff --git a/native/src/platform_frontend.hpp b/native/src/platform_frontend.hpp index 777e0cf..d060041 100644 --- a/native/src/platform_frontend.hpp +++ b/native/src/platform_frontend.hpp @@ -33,9 +33,15 @@ class PlatformFrontend : public mbgl::RendererFrontend { virtual mbgl::MapObserver& getObserver() = 0; /// Returns the platform-native view created by the frontend, or nullptr. - /// On Apple this is the MTKView*; on other platforms returns nullptr. + /// On Apple this is the MTKView* (Metal) or CAMetalLayer-backed UIView* + /// (Vulkan/MoltenVK); on other platforms returns nullptr. virtual void* getNativeView() { return nullptr; } + /// Copies the most recently rendered frame as tightly-packed premultiplied + /// RGBA (w*h*4 bytes, top-down) into out. Only offscreen frontends (Vulkan + /// Windows) implement this; direct-present frontends return false. + virtual bool readPixels(uint8_t* /*out*/, size_t /*len*/) { return false; } + /// Returns the underlying Renderer for feature queries, or nullptr. virtual mbgl::Renderer* getRenderer() { return nullptr; } }; diff --git a/native/src/platform_frontend_vulkan_common.hpp b/native/src/platform_frontend_vulkan_common.hpp new file mode 100644 index 0000000..f88186f --- /dev/null +++ b/native/src/platform_frontend_vulkan_common.hpp @@ -0,0 +1,94 @@ +/** + * platform_frontend_vulkan_common.hpp — shared Vulkan PlatformFrontend. + * + * The Windows, Android, and Apple Vulkan builds differ only in how the render + * surface is created (offscreen image / ANativeWindow / CAMetalLayer). Everything + * else — owning the mbgl::Renderer, marshalling UpdateParameters onto the render + * thread, driving render()/setSize() — is identical, so it lives here. + * + * Each platform's frontend .cpp defines a `Backend` deriving from + * mbgl::vulkan::RendererBackend + mbgl::vulkan::Renderable that provides: + * Backend(, mbgl::Size, float pixelRatio) // calls init() + * mbgl::Size getSize() const; + * void setSize(mbgl::Size); + * const mbgl::TaggedScheduler& getThreadPool(); + * void* getNativeView(); // nullptr unless a view is created (Apple) + * bool readPixels(uint8_t* out, size_t len); // false unless offscreen read-back (Windows) + * and instantiates VulkanFrontendT from createPlatformFrontend(). + */ +#pragma once + +#include "platform_frontend.hpp" +#include "null_map_observer.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +template +class VulkanFrontendT final : public PlatformFrontend { +public: + template + VulkanFrontendT(float pixelRatio, mbgl_render_fn renderCb, void* renderUd, BackendArgs&&... args) + : _backend(std::forward(args)...) + , _renderer(std::make_unique(_backend, pixelRatio)) + , _renderCb(renderCb), _renderUd(renderUd) + {} + + ~VulkanFrontendT() override { + mbgl::gfx::BackendScope guard(_backend, mbgl::gfx::BackendScope::ScopeType::Implicit); + _renderer.reset(); + } + + /* RendererFrontend */ + void reset() override { _renderer.reset(); } + + void setObserver(mbgl::RendererObserver& obs) override { _renderer->setObserver(&obs); } + + void update(std::shared_ptr params) override { + { + std::unique_lock lock(_mutex); + _updateParams = std::move(params); + } + if (_renderCb) _renderCb(_renderUd); + } + + const mbgl::TaggedScheduler& getThreadPool() const override { + return const_cast(_backend).getThreadPool(); + } + + /* PlatformFrontend */ + void render() override { + std::shared_ptr params; + { + std::unique_lock lock(_mutex); + params = std::move(_updateParams); + } + if (!params) return; + mbgl::gfx::BackendScope guard(_backend, mbgl::gfx::BackendScope::ScopeType::Implicit); + _renderer->render(params); + } + + void setSize(mbgl::Size sz) override { _backend.setSize(sz); } + mbgl::Size getSize() const override { return _backend.getSize(); } + + mbgl::MapObserver& getObserver() override { return _nullObserver; } + mbgl::Renderer* getRenderer() override { return _renderer.get(); } + + void* getNativeView() override { return _backend.getNativeView(); } + bool readPixels(uint8_t* out, size_t len) override { return _backend.readPixels(out, len); } + +private: + Backend _backend; + std::unique_ptr _renderer; + mbgl_render_fn _renderCb; + void* _renderUd; + std::shared_ptr _updateParams; + std::mutex _mutex; + NullMapObserver _nullObserver; +}; From ae799d33ed5bea7a497ae84137cdecc4fc3df35b Mon Sep 17 00:00:00 2001 From: acalcutt Date: Tue, 7 Jul 2026 09:35:52 -0400 Subject: [PATCH 04/16] native(android): real Vulkan frontend rendering into the ANativeWindow Replaces the throwing stub with an ANativeWindow surface backend mirroring maplibre-native's android_vulkan_renderer_backend (VK_KHR_surface + VK_KHR_android_surface, swapchain presents into the TextureView surface, matching today's EGL path), driven through the shared VulkanFrontendT. Co-Authored-By: Claude Opus 4.8 (1M context) --- native/src/platform_frontend_android.cpp | 92 +++++++++++++++++++++--- 1 file changed, 84 insertions(+), 8 deletions(-) diff --git a/native/src/platform_frontend_android.cpp b/native/src/platform_frontend_android.cpp index ccc0114..4039cde 100644 --- a/native/src/platform_frontend_android.cpp +++ b/native/src/platform_frontend_android.cpp @@ -173,18 +173,94 @@ PlatformFrontend* createPlatformFrontend( ); } -#else // non-OpenGL build (e.g. Vulkan) — stub until a Vulkan frontend is implemented +#else // Vulkan build — render into the TextureView's ANativeWindow via VK_KHR_android_surface -#include +#include "platform_frontend_vulkan_common.hpp" + +#include +#include +#include + +#include +#include + +#include + +namespace { + +class AndroidVulkanBackend; + +/* ── Surface resource (mirrors maplibre-native android_vulkan_renderer_backend) ── */ +class AndroidVulkanResource final : public mbgl::vulkan::SurfaceRenderableResource { +public: + explicit AndroidVulkanResource(AndroidVulkanBackend& b); + + std::vector getDeviceExtensions() override { return {VK_KHR_SWAPCHAIN_EXTENSION_NAME}; } + void createPlatformSurface() override; + void bind() override {} +}; + +/* ── Backend ─────────────────────────────────────────────────────────────────── */ +class AndroidVulkanBackend final : public mbgl::vulkan::RendererBackend, + public mbgl::vulkan::Renderable { +public: + AndroidVulkanBackend(ANativeWindow* window, mbgl::Size sz) + : mbgl::vulkan::RendererBackend(mbgl::gfx::ContextMode::Unique), + mbgl::vulkan::Renderable(sz, std::make_unique(*this)), + _window(window) { + init(); + } + ~AndroidVulkanBackend() override { context.reset(); } + + ANativeWindow* getWindow() const { return _window; } + + mbgl::gfx::Renderable& getDefaultRenderable() override { return *this; } + + // Backend contract required by VulkanFrontendT. + mbgl::Size getSize() const { return size; } + void setSize(mbgl::Size sz) { + size = sz; + if (context) static_cast(*context).requestSurfaceUpdate(); + } + void* getNativeView() { return nullptr; } // presents into the ANativeWindow directly + bool readPixels(uint8_t*, size_t) { return false; } + +protected: + std::vector getInstanceExtensions() override { + auto ext = mbgl::vulkan::RendererBackend::getInstanceExtensions(); + ext.push_back(VK_KHR_SURFACE_EXTENSION_NAME); + ext.push_back(VK_KHR_ANDROID_SURFACE_EXTENSION_NAME); + return ext; + } + void activate() override {} + void deactivate() override {} + +private: + ANativeWindow* _window; +}; + +AndroidVulkanResource::AndroidVulkanResource(AndroidVulkanBackend& b) + : mbgl::vulkan::SurfaceRenderableResource(b) {} + +void AndroidVulkanResource::createPlatformSurface() { + auto& b = static_cast(backend); + const vk::AndroidSurfaceCreateInfoKHR createInfo({}, b.getWindow()); + surface = b.getInstance()->createAndroidSurfaceKHRUnique(createInfo, nullptr, b.getDispatcher()); + + const int apiLevel = android_get_device_api_level(); + if (apiLevel < __ANDROID_API_Q__) setSurfaceTransformPollingInterval(30); +} + +} // namespace PlatformFrontend* createPlatformFrontend( - void* /*surface_handle*/, void* /*context*/, - mbgl::Size /*sz*/, float /*pixelRatio*/, - mbgl_render_fn /*renderCb*/, void* /*renderUd*/) + void* surface_handle, void* /*context*/, + mbgl::Size sz, float pixelRatio, + mbgl_render_fn renderCb, void* renderUd) { - throw std::runtime_error( - "Android Vulkan frontend is not yet implemented. " - "This build was compiled without MLN_RENDER_BACKEND_OPENGL."); + return new VulkanFrontendT( + pixelRatio, renderCb, renderUd, + reinterpret_cast(surface_handle), sz); } #endif // MLN_RENDER_BACKEND_OPENGL From ffeb87ea2b4cfe472420b715ef2b0ada51992011 Mon Sep 17 00:00:00 2001 From: acalcutt Date: Tue, 7 Jul 2026 09:39:15 -0400 Subject: [PATCH 05/16] native(windows): real offscreen Vulkan frontend with CPU read-back Replaces the throwing stub with an offscreen frontend that reuses mbgl::vulkan::HeadlessBackend (already compiled into mbgl-core on Windows Vulkan builds via platform/windows/windows.cmake): renders into a headless color texture and returns the pixels through mbgl_frontend_read_pixels(), which the managed layer blits into the WriteableBitmap. No HWND/window surface, so the airspace-free in-tree model of the WGL path is preserved. CMake adds platform/default/include for the headless header on Vulkan builds. Co-Authored-By: Claude Opus 4.8 (1M context) --- native/CMakeLists.txt | 6 ++ native/src/platform_frontend_windows.cpp | 89 ++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 2a04fe1..381ba9c 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -51,6 +51,12 @@ if(WIN32) target_sources(mln-cabi PRIVATE src/platform_frontend_windows.cpp) target_compile_options(mln-cabi PRIVATE /wd4267) target_include_directories(mln-cabi PRIVATE "${MAPLIBRE_NATIVE_DIR}/src") + # Vulkan build: the offscreen frontend reuses mbgl::vulkan::HeadlessBackend, + # whose header lives in platform/default/include (added to mbgl-core by + # platform/windows/windows.cmake). + if(MLN_WITH_VULKAN) + target_include_directories(mln-cabi PRIVATE "${MAPLIBRE_NATIVE_DIR}/platform/default/include") + endif() elseif(ANDROID) target_sources(mln-cabi PRIVATE src/platform_frontend_android.cpp) target_link_libraries(mln-cabi PRIVATE EGL android log) diff --git a/native/src/platform_frontend_windows.cpp b/native/src/platform_frontend_windows.cpp index 2f8424a..01a1f30 100644 --- a/native/src/platform_frontend_windows.cpp +++ b/native/src/platform_frontend_windows.cpp @@ -163,18 +163,93 @@ PlatformFrontend* createPlatformFrontend( ); } -#else // non-OpenGL build (e.g. Vulkan) — stub until a Vulkan frontend is implemented +#else // Vulkan build — offscreen (headless) render + CPU read-back into the in-tree bitmap -#include +#include "null_map_observer.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include + +/* Offscreen Vulkan frontend. There is no HWND / window surface: the map renders + * into a headless color texture and the managed layer pulls the pixels back via + * mbgl_frontend_read_pixels() and blits them into the WriteableBitmap. Same + * airspace-free, in-tree model as the WGL path (which reads back GL-side). */ +class VulkanOffscreenFrontend : public PlatformFrontend { +public: + VulkanOffscreenFrontend(mbgl::Size sz, float pixelRatio, mbgl_render_fn cb, void* ud) + : _size(sz) + , _backend(sz, mbgl::gfx::Renderable::SwapBehaviour::NoFlush, mbgl::gfx::ContextMode::Unique) + , _renderer(std::make_unique(_backend, pixelRatio)) + , _renderCb(cb), _renderUd(ud) + {} + + ~VulkanOffscreenFrontend() override { + mbgl::gfx::BackendScope guard(_backend, mbgl::gfx::BackendScope::ScopeType::Implicit); + _renderer.reset(); + } + + /* RendererFrontend */ + void reset() override { _renderer.reset(); } + void setObserver(mbgl::RendererObserver& obs) override { _renderer->setObserver(&obs); } + void update(std::shared_ptr params) override { + { std::unique_lock lock(_mutex); _updateParams = std::move(params); } + if (_renderCb) _renderCb(_renderUd); + } + const mbgl::TaggedScheduler& getThreadPool() const override { + return const_cast(_backend).getThreadPool(); + } + + /* PlatformFrontend */ + void render() override { + std::shared_ptr params; + { std::unique_lock lock(_mutex); params = std::move(_updateParams); } + if (!params) return; + mbgl::gfx::BackendScope guard(_backend, mbgl::gfx::BackendScope::ScopeType::Implicit); + _renderer->render(params); + } + + void setSize(mbgl::Size sz) override { _size = sz; _backend.setSize(sz); } + mbgl::Size getSize() const override { return _size; } + mbgl::MapObserver& getObserver() override { return _nullObserver; } + mbgl::Renderer* getRenderer() override { return _renderer.get(); } + + bool readPixels(uint8_t* out, size_t len) override { + const size_t need = static_cast(_size.width) * _size.height * 4u; + if (!out || len < need) return false; + mbgl::PremultipliedImage img; + { + mbgl::gfx::BackendScope guard(_backend, mbgl::gfx::BackendScope::ScopeType::Implicit); + img = _backend.readStillImage(); + } + if (img.bytes() < need) return false; + std::memcpy(out, img.data.get(), need); + return true; + } + +private: + mbgl::Size _size; + mbgl::vulkan::HeadlessBackend _backend; + std::unique_ptr _renderer; + mbgl_render_fn _renderCb; + void* _renderUd; + std::shared_ptr _updateParams; + std::mutex _mutex; + NullMapObserver _nullObserver; +}; PlatformFrontend* createPlatformFrontend( void* /*surface_handle*/, void* /*gl_context*/, - mbgl::Size /*sz*/, float /*pixelRatio*/, - mbgl_render_fn /*renderCb*/, void* /*renderUd*/) + mbgl::Size sz, float pixelRatio, + mbgl_render_fn renderCb, void* renderUd) { - throw std::runtime_error( - "Windows Vulkan frontend is not yet implemented. " - "This build was compiled without MLN_RENDER_BACKEND_OPENGL."); + return new VulkanOffscreenFrontend(sz, pixelRatio, renderCb, renderUd); } #endif // MLN_RENDER_BACKEND_OPENGL From 22ee59f9f1825fa2ba95519e07739cbed38a5e9c Mon Sep 17 00:00:00 2001 From: acalcutt Date: Tue, 7 Jul 2026 17:46:47 -0400 Subject: [PATCH 06/16] native: include in mln_cabi.h for size_t mbgl_frontend_read_pixels uses size_t but the header only pulled in . Apple clang doesn't get size_t transitively there, so every target including the header (incl. the Metal iOS/macOS builds) failed with "unknown type name 'size_t'". Add . Co-Authored-By: Claude Opus 4.8 (1M context) --- native/include/mln_cabi.h | 1 + 1 file changed, 1 insertion(+) diff --git a/native/include/mln_cabi.h b/native/include/mln_cabi.h index 1cfc6fa..5905fa3 100644 --- a/native/include/mln_cabi.h +++ b/native/include/mln_cabi.h @@ -16,6 +16,7 @@ #pragma once #include +#include /* size_t */ #ifdef __cplusplus extern "C" { From 06903f1826d295b9e5c74f0a383467bbe0173e29 Mon Sep 17 00:00:00 2001 From: acalcutt Date: Tue, 7 Jul 2026 18:30:23 -0400 Subject: [PATCH 07/16] native(apple): MoltenVK Vulkan frontend behind the renderer flag (Metal default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the existing Metal frontend in #if MLN_RENDER_BACKEND_METAL and adds a #elif MLN_RENDER_BACKEND_VULKAN path: renders Vulkan into a CAMetalLayer-backed UIView via VK_EXT_metal_surface (vk::MetalSurfaceCreateInfoEXT + createMetalSurfaceEXTUnique), driven through the shared VulkanFrontendT, and returns the view from getNativeView() like the MTKView. Apple keeps defaulting to Metal; the Vulkan path only compiles under -DMLN_WITH_VULKAN=ON. CMake selects link libs accordingly (Metal/MetalKit vs QuartzCore/UIKit; MoltenVK is linked by the consuming app since mln-cabi is a static archive on Apple). Not yet wired into CI — the native-apple-vulkan build job comes next. Co-Authored-By: Claude Opus 4.8 (1M context) --- native/CMakeLists.txt | 28 +++++-- native/src/platform_frontend_apple.mm | 111 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 7 deletions(-) diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 381ba9c..313ac9f 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -92,13 +92,27 @@ elseif(APPLE) set_source_files_properties(src/platform_frontend_apple.mm PROPERTIES COMPILE_FLAGS "-x objective-c++") target_include_directories(mln-cabi PRIVATE "${MAPLIBRE_NATIVE_DIR}/src") - # metal-cpp headers (vendored inside maplibre-native) - target_include_directories(mln-cabi PRIVATE - "${MAPLIBRE_NATIVE_DIR}/vendor/metal-cpp") - find_library(METAL_LIB Metal REQUIRED) - find_library(METALKIT_LIB MetalKit REQUIRED) - find_library(QUARTZCORE_LIB QuartzCore REQUIRED) - target_link_libraries(mln-cabi PRIVATE ${METAL_LIB} ${METALKIT_LIB} ${QUARTZCORE_LIB}) + + if(MLN_WITH_VULKAN) + # Vulkan via MoltenVK. mln-cabi is a static archive on Apple, so MoltenVK and + # the Vulkan loader are linked into the final app (by the consuming project / + # buildTransitive NativeReference); here we only need headers to compile. + # QuartzCore provides CAMetalLayer; UIKit provides UIView (iOS + macCatalyst). + target_include_directories(mln-cabi PRIVATE "${MAPLIBRE_NATIVE_DIR}/platform/default/include") + find_library(QUARTZCORE_LIB QuartzCore REQUIRED) + find_library(UIKIT_LIB UIKit) + target_link_libraries(mln-cabi PRIVATE ${QUARTZCORE_LIB}) + if(UIKIT_LIB) + target_link_libraries(mln-cabi PRIVATE ${UIKIT_LIB}) + endif() + else() + # Metal (default Apple renderer). metal-cpp headers are vendored in maplibre-native. + target_include_directories(mln-cabi PRIVATE "${MAPLIBRE_NATIVE_DIR}/vendor/metal-cpp") + find_library(METAL_LIB Metal REQUIRED) + find_library(METALKIT_LIB MetalKit REQUIRED) + find_library(QUARTZCORE_LIB QuartzCore REQUIRED) + target_link_libraries(mln-cabi PRIVATE ${METAL_LIB} ${METALKIT_LIB} ${QUARTZCORE_LIB}) + endif() # mbgl-core is compiled with -fno-rtti on Apple (same as Android/Linux). # We must match that flag so MetalBackend's typeinfo does not emit a # reference to the non-existent base class typeinfo diff --git a/native/src/platform_frontend_apple.mm b/native/src/platform_frontend_apple.mm index 37410f0..5f1ac9e 100644 --- a/native/src/platform_frontend_apple.mm +++ b/native/src/platform_frontend_apple.mm @@ -13,6 +13,9 @@ */ #include "platform_frontend.hpp" + +#if defined(MLN_RENDER_BACKEND_METAL) // ── Metal (default Apple renderer) ────────── + #include "null_map_observer.hpp" #include @@ -291,3 +294,111 @@ void drawFrame() { { return new MetalFrontend(sz, pixelRatio, renderCb, renderUd); } + +#elif defined(MLN_RENDER_BACKEND_VULKAN) // ── Vulkan via MoltenVK (opt-in) ────────── + +/* + * MoltenVK frontend: renders Vulkan into a CAMetalLayer-backed UIView using the + * VK_EXT_metal_surface extension. The view is handed back via getNativeView() and + * added as a subview by the MAUI handler, exactly like the MTKView on the Metal + * path. Enable VK_USE_PLATFORM_METAL_EXT before vulkan.hpp is pulled in (by the + * mbgl vulkan headers below) so vk::MetalSurfaceCreateInfoEXT is declared. + */ +#define VK_USE_PLATFORM_METAL_EXT 1 + +#include "platform_frontend_vulkan_common.hpp" + +#include +#include +#include + +#import +#import + +#include + +/// A UIView whose backing layer is a CAMetalLayer — required by VK_EXT_metal_surface. +@interface MbglMetalLayerView : UIView +@end +@implementation MbglMetalLayerView ++ (Class)layerClass { return [CAMetalLayer class]; } +@end + +namespace { + +class AppleVulkanBackend; + +class AppleVulkanResource final : public mbgl::vulkan::SurfaceRenderableResource { +public: + explicit AppleVulkanResource(AppleVulkanBackend& b); + std::vector getDeviceExtensions() override { + return {VK_KHR_SWAPCHAIN_EXTENSION_NAME, "VK_KHR_portability_subset"}; + } + void createPlatformSurface() override; + void bind() override {} +}; + +class AppleVulkanBackend final : public mbgl::vulkan::RendererBackend, + public mbgl::vulkan::Renderable { +public: + explicit AppleVulkanBackend(mbgl::Size sz) + : mbgl::vulkan::RendererBackend(mbgl::gfx::ContextMode::Unique), + mbgl::vulkan::Renderable(sz, std::make_unique(*this)) { + _view = [[MbglMetalLayerView alloc] initWithFrame:CGRectZero]; + ((CAMetalLayer*)_view.layer).drawableSize = CGSizeMake(sz.width, sz.height); + init(); + } + ~AppleVulkanBackend() override { context.reset(); } + + CAMetalLayer* getMetalLayer() const { return (CAMetalLayer*)_view.layer; } + + mbgl::gfx::Renderable& getDefaultRenderable() override { return *this; } + + // Backend contract required by VulkanFrontendT. + mbgl::Size getSize() const { return size; } + void setSize(mbgl::Size sz) { + size = sz; + ((CAMetalLayer*)_view.layer).drawableSize = CGSizeMake(sz.width, sz.height); + if (context) static_cast(*context).requestSurfaceUpdate(); + } + void* getNativeView() { return (__bridge void*)_view; } + bool readPixels(uint8_t*, size_t) { return false; } + +protected: + std::vector getInstanceExtensions() override { + auto ext = mbgl::vulkan::RendererBackend::getInstanceExtensions(); + ext.push_back(VK_KHR_SURFACE_EXTENSION_NAME); + ext.push_back(VK_EXT_METAL_SURFACE_EXTENSION_NAME); + ext.push_back(VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME); + return ext; + } + void activate() override {} + void deactivate() override {} + +private: + MbglMetalLayerView* _view = nil; +}; + +AppleVulkanResource::AppleVulkanResource(AppleVulkanBackend& b) + : mbgl::vulkan::SurfaceRenderableResource(b) {} + +void AppleVulkanResource::createPlatformSurface() { + auto& b = static_cast(backend); + const vk::MetalSurfaceCreateInfoEXT createInfo( + vk::MetalSurfaceCreateFlagsEXT{}, (__bridge const CAMetalLayer*)b.getMetalLayer()); + surface = b.getInstance()->createMetalSurfaceEXTUnique(createInfo, nullptr, b.getDispatcher()); +} + +} // namespace + +PlatformFrontend* createPlatformFrontend( + void* /*surface_handle*/, void* /*gl_context*/, + mbgl::Size sz, float pixelRatio, + mbgl_render_fn renderCb, void* renderUd) +{ + return new VulkanFrontendT(pixelRatio, renderCb, renderUd, sz); +} + +#else +# error "Apple mln-cabi build requires MLN_WITH_METAL or MLN_WITH_VULKAN" +#endif From c73147727d1c8447a3c36a37e0c6e7e7929b43eb Mon Sep 17 00:00:00 2001 From: acalcutt Date: Tue, 7 Jul 2026 18:34:46 -0400 Subject: [PATCH 08/16] ci: build Apple Vulkan (MoltenVK) native and pack it into the Vulkan package Adds native-apple-vulkan.yml (mirrors native-apple.yml: iOS device/simulator + macCatalyst) building mln-cabi with -DMLN_WITH_METAL=OFF -DMLN_WITH_VULKAN=ON. Vulkan headers + VMA are vendored by maplibre-native, so only a Vulkan SDK (MoltenVK) is installed to satisfy find_package(Vulkan) on the Darwin path; mln-cabi is a static archive on Apple so MoltenVK links in the consuming app. Artifacts: native-mln-{ios-arm64,iossimulator-arm64,maccatalyst}-vulkan. Wires build-apple-vulkan into ci.yml + release.yml and repoints pack-vulkan to consume the Vulkan Apple slices (was reusing the Metal build-apple output), so the Vulkan package now ships a real Vulkan Apple binary. The base package still uses the Metal build-apple. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 43 +++---- .github/workflows/native-apple-vulkan.yml | 149 ++++++++++++++++++++++ .github/workflows/release.yml | 44 +++---- 3 files changed, 182 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/native-apple-vulkan.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea29496..1f9faad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,11 @@ jobs: needs: version uses: ./.github/workflows/native-android-vulkan.yml + build-apple-vulkan: + name: Build Apple Vulkan native + needs: version + uses: ./.github/workflows/native-apple-vulkan.yml + # ── NuGet pack (verify packability, no publish) ─────────────────────────── pack: name: Pack NuGet @@ -382,7 +387,7 @@ jobs: pack-vulkan: name: Pack NuGet (Vulkan) runs-on: macos-latest - needs: [version, build-windows-vulkan, build-android-vulkan, build-apple] + needs: [version, build-windows-vulkan, build-android-vulkan, build-apple-vulkan] steps: - uses: actions/checkout@v4 @@ -397,30 +402,14 @@ jobs: 9.0.x 10.0.x - - name: Download MLN native artifacts (Windows + Android) + # Vulkan native artifacts for every platform share the native-mln-*-vulkan + # naming (Windows/Android/Apple), so one pattern collects them all. + - name: Download MLN Vulkan native artifacts uses: actions/download-artifact@v4 with: pattern: native-mln-* path: mln-artifacts - - name: Download Apple native artifacts - uses: actions/download-artifact@v4 - with: - pattern: native-ios-arm64 - path: mln-artifacts - - - name: Download Apple iOS simulator native artifacts - uses: actions/download-artifact@v4 - with: - pattern: native-iossimulator-arm64 - path: mln-artifacts - - - name: Download Apple macCatalyst native artifacts - uses: actions/download-artifact@v4 - with: - pattern: native-maccatalyst - path: mln-artifacts - - name: Arrange native libs for Vulkan bindings project run: | N=bindings/native-vulkan @@ -428,13 +417,13 @@ jobs: mkdir -p $N/android-arm64 $N/android-x64 mkdir -p $N/ios-arm64 $N/iossimulator-arm64 $N/maccatalyst - find mln-artifacts/native-mln-windows-x64-vulkan -name "mln-cabi.dll" -exec cp {} $N/win-x64/ \; 2>/dev/null || true - find mln-artifacts/native-mln-windows-arm64-vulkan -name "mln-cabi.dll" -exec cp {} $N/win-arm64/ \; 2>/dev/null || true - find mln-artifacts/native-mln-android-arm64-v8a -name "libmln-cabi.so" -exec cp {} $N/android-arm64/ \; 2>/dev/null || true - find mln-artifacts/native-mln-android-x86_64 -name "libmln-cabi.so" -exec cp {} $N/android-x64/ \; 2>/dev/null || true - find mln-artifacts/native-ios-arm64 -name "libmln-cabi.a" -exec cp {} $N/ios-arm64/ \; 2>/dev/null || true - find mln-artifacts/native-iossimulator-arm64 -name "libmln-cabi.a" -exec cp {} $N/iossimulator-arm64/ \; 2>/dev/null || true - find mln-artifacts/native-maccatalyst -name "libmln-cabi.a" -exec cp {} $N/maccatalyst/ \; 2>/dev/null || true + find mln-artifacts/native-mln-windows-x64-vulkan -name "mln-cabi.dll" -exec cp {} $N/win-x64/ \; 2>/dev/null || true + find mln-artifacts/native-mln-windows-arm64-vulkan -name "mln-cabi.dll" -exec cp {} $N/win-arm64/ \; 2>/dev/null || true + find mln-artifacts/native-mln-android-arm64-v8a -name "libmln-cabi.so" -exec cp {} $N/android-arm64/ \; 2>/dev/null || true + find mln-artifacts/native-mln-android-x86_64 -name "libmln-cabi.so" -exec cp {} $N/android-x64/ \; 2>/dev/null || true + find mln-artifacts/native-mln-ios-arm64-vulkan -name "libmln-cabi.a" -exec cp {} $N/ios-arm64/ \; 2>/dev/null || true + find mln-artifacts/native-mln-iossimulator-arm64-vulkan -name "libmln-cabi.a" -exec cp {} $N/iossimulator-arm64/ \; 2>/dev/null || true + find mln-artifacts/native-mln-maccatalyst-vulkan -name "libmln-cabi.a" -exec cp {} $N/maccatalyst/ \; 2>/dev/null || true echo "Vulkan native libs arranged:" find $N -type f diff --git a/.github/workflows/native-apple-vulkan.yml b/.github/workflows/native-apple-vulkan.yml new file mode 100644 index 0000000..3971d68 --- /dev/null +++ b/.github/workflows/native-apple-vulkan.yml @@ -0,0 +1,149 @@ +name: Native Apple Vulkan (MoltenVK) + +on: + workflow_call: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + build: + name: ${{ matrix.name }} + runs-on: macos-14 + strategy: + fail-fast: false + matrix: + include: + - name: iOS arm64 Vulkan + cmake-system: iOS + cmake-sysroot: iphoneos + cmake-archs: arm64 + extra-flags: "" + artifact: native-mln-ios-arm64-vulkan + + - name: iOS Simulator arm64 Vulkan + cmake-system: iOS + cmake-sysroot: iphonesimulator + cmake-archs: arm64 + extra-flags: "" + artifact: native-mln-iossimulator-arm64-vulkan + + - name: macCatalyst arm64+x64 Vulkan + cmake-system: Darwin + cmake-sysroot: macosx + cmake-archs: "x86_64;arm64" + extra-flags: -DCMAKE_XCODE_ATTRIBUTE_SUPPORTS_MACCATALYST=YES -DCMAKE_XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET=15.0 -DMLN_WITH_GLFW=OFF + artifact: native-mln-maccatalyst-vulkan + + steps: + - name: Enable Git long paths + run: git config --global core.longpaths true + + - uses: actions/checkout@v4 + with: + submodules: recursive + + # Vulkan headers + VMA are vendored by maplibre-native, so compilation needs + # no external SDK. But platform/macos/macos.cmake calls find_package(Vulkan + # REQUIRED); the SDK (which bundles MoltenVK) satisfies that. mln-cabi is a + # static archive here, so MoltenVK itself is linked later, by the app. + - name: Install Vulkan SDK (MoltenVK) + uses: humbletim/setup-vulkan-sdk@v1.2.1 + with: + vulkan-query-version: latest + vulkan-components: MoltenVK, Vulkan-Headers, Vulkan-Loader + vulkan-use-cache: true + + - name: Patch darwin.cmake for macCatalyst + if: ${{ matrix.artifact == 'native-mln-maccatalyst-vulkan' }} + run: | + echo 'target_link_libraries(mbgl-core PRIVATE mbgl-vendor-filesystem)' \ + >> dependencies/maplibre-native/platform/darwin/darwin.cmake + + - name: Configure CMake + if: ${{ matrix.artifact != 'native-mln-maccatalyst-vulkan' }} + run: | + cmake -B build -G Xcode \ + -DCMAKE_SYSTEM_NAME=${{ matrix.cmake-system }} \ + -DCMAKE_OSX_ARCHITECTURES="${{ matrix.cmake-archs }}" \ + -DCMAKE_OSX_SYSROOT=${{ matrix.cmake-sysroot }} \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=14.0 \ + ${{ matrix.extra-flags }} \ + -DMLN_WITH_METAL=OFF \ + -DMLN_WITH_VULKAN=ON + + - name: Configure and build macCatalyst (macabi) + if: ${{ matrix.artifact == 'native-mln-maccatalyst-vulkan' }} + run: | + SYSROOT=$(xcrun --sdk macosx --show-sdk-path) + IOSUPPORT_FWKS="$SYSROOT/System/iOSSupport/System/Library/Frameworks" + IOSUPPORT_INC="$SYSROOT/System/iOSSupport/usr/include" + CLANG=$(xcrun -f clang) + CLANGXX=$(xcrun -f clang++) + for ARCH in x86_64 arm64; do + TRIPLE="${ARCH}-apple-ios15.0-macabi" + EXTRA_FLAGS="-iframework $IOSUPPORT_FWKS -I$IOSUPPORT_INC -Wno-overriding-t-option" + cmake -B "build-${ARCH}" \ + -G Ninja \ + -DCMAKE_SYSTEM_NAME=Darwin \ + -DCMAKE_OSX_SYSROOT=macosx \ + -DCMAKE_OSX_ARCHITECTURES="" \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_COMPILER="$CLANG" \ + -DCMAKE_CXX_COMPILER="$CLANGXX" \ + -DCMAKE_OBJC_COMPILER="$CLANG" \ + -DCMAKE_OBJCXX_COMPILER="$CLANGXX" \ + "-DCMAKE_C_COMPILER_TARGET=${TRIPLE}" \ + "-DCMAKE_CXX_COMPILER_TARGET=${TRIPLE}" \ + "-DCMAKE_OBJC_COMPILER_TARGET=${TRIPLE}" \ + "-DCMAKE_OBJCXX_COMPILER_TARGET=${TRIPLE}" \ + "-DCMAKE_C_FLAGS=${EXTRA_FLAGS}" \ + "-DCMAKE_CXX_FLAGS=${EXTRA_FLAGS}" \ + "-DCMAKE_OBJC_FLAGS=${EXTRA_FLAGS}" \ + "-DCMAKE_OBJCXX_FLAGS=${EXTRA_FLAGS}" \ + -DMLN_WITH_GLFW=OFF \ + -DMLN_WITH_METAL=OFF \ + -DMLN_WITH_VULKAN=ON + cmake --build "build-${ARCH}" --target mln-cabi --config Release --parallel + CABI=$(find "build-${ARCH}" -name "libmln-cabi.a" | head -1) + ALL_LIBS=$(find "build-${ARCH}" -name "*.a" -type f | sort) + # shellcheck disable=SC2086 + libtool -static -o "${CABI}.merged" ${ALL_LIBS} + mv "${CABI}.merged" "$CABI" + done + X86=$(find build-x86_64 -name "libmln-cabi.a" | head -1) + ARM=$(find build-arm64 -name "libmln-cabi.a" | head -1) + mkdir -p build + lipo -create "$X86" "$ARM" -output build/libmln-cabi.a + echo "Universal macCatalyst binary:" + lipo -info build/libmln-cabi.a + + - name: Build mln-cabi + if: ${{ matrix.artifact != 'native-mln-maccatalyst-vulkan' }} + run: cmake --build build --target mln-cabi --config Release + + - name: Merge into self-contained archive + if: ${{ matrix.artifact != 'native-mln-maccatalyst-vulkan' }} + run: | + CABI=$(find build -name "libmln-cabi.a" | head -1) + echo "Target archive: $CABI" + ALL_LIBS=$(find build -name "*.a" -type f | sort) + # shellcheck disable=SC2086 + libtool -static -o "${CABI}.merged" ${ALL_LIBS} + mv "${CABI}.merged" "$CABI" + echo "Merged object count: $(ar -t "$CABI" | wc -l)" + + - name: Locate built library + id: find-lib + run: | + LIB=$(find build -name "libmln-cabi.a" | head -1) + echo "path=$LIB" >> "$GITHUB_OUTPUT" + echo "Found: $LIB" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: ${{ steps.find-lib.outputs.path }} + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 342b5df..7bc7266 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,6 +76,12 @@ jobs: if: needs.version-check.outputs.published == 'false' uses: ./.github/workflows/native-android-vulkan.yml + build-apple-vulkan: + name: Build Apple Vulkan native + needs: version-check + if: needs.version-check.outputs.published == 'false' + uses: ./.github/workflows/native-apple-vulkan.yml + # ── NuGet packaging ─────────────────────────────────────────────────────── pack: name: Pack NuGet @@ -385,7 +391,7 @@ jobs: pack-vulkan: name: Pack Vulkan NuGet runs-on: macos-latest - needs: [version-check, build-windows-vulkan, build-android-vulkan, build-apple] + needs: [version-check, build-windows-vulkan, build-android-vulkan, build-apple-vulkan] if: needs.version-check.outputs.published == 'false' steps: @@ -399,30 +405,14 @@ jobs: 9.0.x 10.0.x - - name: Download MLN native artifacts (Windows + Android) + # Vulkan native artifacts for every platform share the native-mln-*-vulkan + # naming (Windows/Android/Apple), so one pattern collects them all. + - name: Download MLN Vulkan native artifacts uses: actions/download-artifact@v4 with: pattern: native-mln-* path: mln-artifacts - - name: Download Apple native artifacts - uses: actions/download-artifact@v4 - with: - pattern: native-ios-arm64 - path: mln-artifacts - - - name: Download Apple iOS simulator native artifacts - uses: actions/download-artifact@v4 - with: - pattern: native-iossimulator-arm64 - path: mln-artifacts - - - name: Download Apple macCatalyst native artifacts - uses: actions/download-artifact@v4 - with: - pattern: native-maccatalyst - path: mln-artifacts - - name: Arrange native libs for Vulkan bindings project run: | N=bindings/native-vulkan @@ -430,13 +420,13 @@ jobs: mkdir -p $N/android-arm64 $N/android-x64 mkdir -p $N/ios-arm64 $N/iossimulator-arm64 $N/maccatalyst - find mln-artifacts/native-mln-windows-x64-vulkan -name "mln-cabi.dll" -exec cp {} $N/win-x64/ \; 2>/dev/null || true - find mln-artifacts/native-mln-windows-arm64-vulkan -name "mln-cabi.dll" -exec cp {} $N/win-arm64/ \; 2>/dev/null || true - find mln-artifacts/native-mln-android-arm64-v8a -name "libmln-cabi.so" -exec cp {} $N/android-arm64/ \; 2>/dev/null || true - find mln-artifacts/native-mln-android-x86_64 -name "libmln-cabi.so" -exec cp {} $N/android-x64/ \; 2>/dev/null || true - find mln-artifacts/native-ios-arm64 -name "libmln-cabi.a" -exec cp {} $N/ios-arm64/ \; 2>/dev/null || true - find mln-artifacts/native-iossimulator-arm64 -name "libmln-cabi.a" -exec cp {} $N/iossimulator-arm64/ \; 2>/dev/null || true - find mln-artifacts/native-maccatalyst -name "libmln-cabi.a" -exec cp {} $N/maccatalyst/ \; 2>/dev/null || true + find mln-artifacts/native-mln-windows-x64-vulkan -name "mln-cabi.dll" -exec cp {} $N/win-x64/ \; 2>/dev/null || true + find mln-artifacts/native-mln-windows-arm64-vulkan -name "mln-cabi.dll" -exec cp {} $N/win-arm64/ \; 2>/dev/null || true + find mln-artifacts/native-mln-android-arm64-v8a -name "libmln-cabi.so" -exec cp {} $N/android-arm64/ \; 2>/dev/null || true + find mln-artifacts/native-mln-android-x86_64 -name "libmln-cabi.so" -exec cp {} $N/android-x64/ \; 2>/dev/null || true + find mln-artifacts/native-mln-ios-arm64-vulkan -name "libmln-cabi.a" -exec cp {} $N/ios-arm64/ \; 2>/dev/null || true + find mln-artifacts/native-mln-iossimulator-arm64-vulkan -name "libmln-cabi.a" -exec cp {} $N/iossimulator-arm64/ \; 2>/dev/null || true + find mln-artifacts/native-mln-maccatalyst-vulkan -name "libmln-cabi.a" -exec cp {} $N/maccatalyst/ \; 2>/dev/null || true echo "Vulkan native libs arranged:" find $N -type f From c9737601b5bb3d7e11fbcec74cb156fab61aaea1 Mon Sep 17 00:00:00 2001 From: acalcutt Date: Tue, 7 Jul 2026 18:40:27 -0400 Subject: [PATCH 09/16] maui: drive the Vulkan frontend from the shared C# layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bindings: add mbgl_get_render_backend, the backend-agnostic mbgl_frontend_create, and mbgl_frontend_read_pixels P/Invokes. MbglFrontend now creates via the generic entry point, exposes the static RenderBackend (OpenGL/Vulkan/Metal, queried once), and adds ReadPixels() for the offscreen path. - Windows MapImageView: branch on backend. OpenGL keeps the WGL context + glReadPixels; Vulkan skips WGL entirely, renders headless, and copies frames back via frontend.ReadPixels into the WriteableBitmap. The GL vertical flip (ScaleY = -1) is undone for Vulkan since the headless read-back is already top-down. Android/Apple need no change: they pass the ANativeWindow / consume getNativeView(), which the generic create routes to the Vulkan frontends. (WPF MlnMapImage still assumes GL — a Vulkan branch there is a follow-up.) Co-Authored-By: Claude Opus 4.8 (1M context) --- bindings/MbglFrontend.cs | 26 ++++++++++- bindings/NativeMethods.cs | 22 +++++++++ handlers/Windows/MapImageView.Windows.cs | 57 +++++++++++++++++------- 3 files changed, 88 insertions(+), 17 deletions(-) diff --git a/bindings/MbglFrontend.cs b/bindings/MbglFrontend.cs index 72769f7..3850b73 100644 --- a/bindings/MbglFrontend.cs +++ b/bindings/MbglFrontend.cs @@ -5,6 +5,9 @@ namespace MapLibreNative.Maui; +/// The renderer the loaded native library was built against. +public enum MbglRenderBackend { OpenGL, Vulkan, Metal } + /// /// Wraps mbgl_frontend_t*. /// @@ -19,6 +22,17 @@ public sealed class MbglFrontend : IDisposable { internal IntPtr Handle { get; private set; } + /// The renderer this native build uses (queried once from the native library). + /// Lets the shared managed layer pick the right surface handshake — the GL and Vulkan + /// packages ship identical C# but different native libraries under the same name. + public static MbglRenderBackend RenderBackend { get; } = + NativeMethods.GetRenderBackend() switch + { + "vulkan" => MbglRenderBackend.Vulkan, + "metal" => MbglRenderBackend.Metal, + _ => MbglRenderBackend.OpenGL, + }; + // Set to true after MbglMap takes ownership. Dispose() becomes a no-op // but Handle intentionally stays valid so Render/SetSize calls continue // to work normally through the frontend's lifetime. @@ -54,15 +68,23 @@ public MbglFrontend( _renderCallback = onRender; _renderDelegate = _ => _renderCallback(); - Handle = NativeMethods.FrontendCreateGl( + Handle = NativeMethods.FrontendCreate( surfaceHandle, glContext, widthPx, heightPx, pixelRatio, _renderDelegate, IntPtr.Zero); if (Handle == IntPtr.Zero) - throw new InvalidOperationException("mbgl_frontend_create_gl returned null."); + throw new InvalidOperationException("mbgl_frontend_create returned null."); } + /// + /// Copies the most recently rendered frame as tightly-packed premultiplied RGBA + /// ( must be ≥ width*height*4) into . + /// Only the offscreen (Vulkan Windows) frontend supports this; returns false otherwise. + /// + public bool ReadPixels(IntPtr buffer, nuint byteLength) + => NativeMethods.FrontendReadPixels(Handle, buffer, byteLength) == MbglStatus.Ok; + /// /// Execute the pending render pass. Call from the render thread when /// fires. diff --git a/bindings/NativeMethods.cs b/bindings/NativeMethods.cs index ce384c0..77e0b89 100644 --- a/bindings/NativeMethods.cs +++ b/bindings/NativeMethods.cs @@ -136,7 +136,25 @@ public delegate int LogFn( [LibraryImport(Lib, EntryPoint = "mbgl_runloop_run_once")] public static partial MbglStatus RunLoopRunOnce(IntPtr rl); + // ── Render backend ──────────────────────────────────────────────────────── + /// Returns the renderer this native build uses: "opengl", "vulkan", or "metal". + [LibraryImport(Lib, EntryPoint = "mbgl_get_render_backend")] + [return: MarshalAs(UnmanagedType.LPUTF8Str)] + public static partial string GetRenderBackend(); + // ── Frontend ────────────────────────────────────────────────────────────── + /// Backend-agnostic frontend factory (surface_handle meaning depends on backend). + [LibraryImport(Lib, EntryPoint = "mbgl_frontend_create")] + public static partial IntPtr FrontendCreate( + IntPtr surfaceHandle, + IntPtr glContext, + int widthPx, + int heightPx, + float pixelRatio, + RenderFn renderCallback, + IntPtr renderUserdata); + + /// Deprecated alias for . [LibraryImport(Lib, EntryPoint = "mbgl_frontend_create_gl")] public static partial IntPtr FrontendCreateGl( IntPtr surfaceHandle, @@ -147,6 +165,10 @@ public static partial IntPtr FrontendCreateGl( RenderFn renderCallback, IntPtr renderUserdata); + /// Copies the last rendered frame as premultiplied RGBA into outBuf (offscreen/Vulkan). + [LibraryImport(Lib, EntryPoint = "mbgl_frontend_read_pixels")] + public static partial MbglStatus FrontendReadPixels(IntPtr fe, IntPtr outBuf, nuint bufLen); + [LibraryImport(Lib, EntryPoint = "mbgl_frontend_destroy")] public static partial MbglStatus FrontendDestroy(IntPtr fe); diff --git a/handlers/Windows/MapImageView.Windows.cs b/handlers/Windows/MapImageView.Windows.cs index 0ebc887..6047105 100644 --- a/handlers/Windows/MapImageView.Windows.cs +++ b/handlers/Windows/MapImageView.Windows.cs @@ -79,6 +79,11 @@ private interface IBufferByteAccess { [PreserveSig] int Buffer(out IntPtr value) private int _width = 1, _height = 1; private float _dpi = 1f; private bool _renderNeedsUpdate = true, _rendering, _isDragging, _disposed; + + // Vulkan builds render offscreen (headless) and read pixels back through the + // frontend; OpenGL builds render into a WGL FBO and read back via glReadPixels. + private static readonly bool _vulkan = MbglFrontend.RenderBackend == MbglRenderBackend.Vulkan; + private bool _started; private Windows.Foundation.Point _lastPos; private static int _diagCounter; @@ -95,6 +100,9 @@ private void MDiag(string msg) public MapImageView() { View.Children.Add(_mapImage); + // The GL FBO has a bottom-left origin so the GL path flips vertically (ScaleY = -1, + // set on _mapImage). The Vulkan headless read-back is already top-down, so undo the flip. + if (_vulkan && _mapImage.RenderTransform is WUXM.ScaleTransform st) st.ScaleY = 1; // Nav / GPS / attribution controls are added by MapLibreMapController.Windows. View.Loaded += (_, _) => Start(); @@ -117,21 +125,30 @@ private void Start() // Once disposed (controller teardown on tab switch), a stale View.Loaded must NOT // resurrect this instance: the owning controller has already nulled its _mapView, so // re-firing MapReady would dereference null. The new tab visit builds a fresh MapImageView. - if (_disposed || _interop != null) return; + if (_disposed || _started) return; + _started = true; _dpi = (float)View.XamlRoot.RasterizationScale; _width = Math.Max(1, (int)(View.ActualWidth * _dpi)); _height = Math.Max(1, (int)(View.ActualHeight * _dpi)); - MDiag($"Start dpi={_dpi} size={_width}x{_height} actual={View.ActualWidth}x{View.ActualHeight} style={StyleUrl}"); + MDiag($"Start backend={(_vulkan ? "vulkan" : "opengl")} dpi={_dpi} size={_width}x{_height} actual={View.ActualWidth}x{View.ActualHeight} style={StyleUrl}"); - _interop = new HiddenWglContext(); - _interop.Initialize(); - _interop.Resize(_width, _height); + if (!_vulkan) + { + // OpenGL: off-screen WGL context we glReadPixels from each frame. + _interop = new HiddenWglContext(); + _interop.Initialize(); + _interop.Resize(_width, _height); + } CreateBitmap(_width, _height); _runLoop = _sharedRunLoop ??= new MbglRunLoop(); - _frontend = new MbglFrontend(_interop.Hdc, _interop.GlContext, _width, _height, _dpi, - () => _renderNeedsUpdate = true); + // Vulkan renders headless (no surface handle); OpenGL needs the WGL HDC + context. + _frontend = _vulkan + ? new MbglFrontend(IntPtr.Zero, IntPtr.Zero, _width, _height, _dpi, + () => _renderNeedsUpdate = true) + : new MbglFrontend(_interop!.Hdc, _interop.GlContext, _width, _height, _dpi, + () => _renderNeedsUpdate = true); // Persistent tile/resource cache (mbgl's default is :memory:). Shares // MbglCache.DefaultPath with MbglOfflineManager so offline regions // downloaded by the manager are served to the map. @@ -159,14 +176,14 @@ private void CreateBitmap(int w, int h) private void Resize(int dipWidth, int dipHeight) { - if (_interop == null || _frontend == null || _map == null) return; + if (_frontend == null || _map == null) return; float scale = (float)(View.XamlRoot?.RasterizationScale ?? _dpi); int w = Math.Max(1, (int)(dipWidth * scale)); int h = Math.Max(1, (int)(dipHeight * scale)); if (w == _width && h == _height) return; _width = w; _height = h; _dpi = scale; - _interop.Resize(w, h); + _interop?.Resize(w, h); // OpenGL only; null on Vulkan CreateBitmap(w, h); _frontend.SetSize(w, h); _map.SetSize(w, h); @@ -176,20 +193,30 @@ private void Resize(int dipWidth, int dipHeight) private void OnRendering(object? sender, object e) { _runLoop?.RunOnce(); - if (!_renderNeedsUpdate || _interop == null || _frontend == null || _bitmap == null) + if (!_renderNeedsUpdate || _frontend == null || _bitmap == null) return; _renderNeedsUpdate = false; - _interop.MakeCurrent(); - glViewport(0, 0, _width, _height); - try { _frontend.Render(); } catch { return; } - // Write pixels directly into the WriteableBitmap's backing store via IBufferByteAccess. // NOTE: a plain (IBufferByteAccess)(object) cast throws InvalidCastException under CsWinRT // (WinUI 3) — the projected IBuffer must be QueryInterface'd via WinRT's .As(). var ibb = _bitmap.PixelBuffer.As(); ibb.Buffer(out IntPtr ptr); - _interop.ReadPixels(ptr); + + if (_vulkan) + { + // Headless Vulkan: render off-screen, then copy the frame back into the bitmap. + try { _frontend.Render(); } catch { return; } + _frontend.ReadPixels(ptr, (nuint)((long)_width * _height * 4)); + } + else + { + if (_interop == null) return; + _interop.MakeCurrent(); + glViewport(0, 0, _width, _height); + try { _frontend.Render(); } catch { return; } + _interop.ReadPixels(ptr); + } _bitmap.Invalidate(); } From badd2ca05767b436b3dccac333781c44ad6deab6 Mon Sep 17 00:00:00 2001 From: acalcutt Date: Tue, 7 Jul 2026 18:49:39 -0400 Subject: [PATCH 10/16] ci: publish a Vulkan Windows sample app in the Windows samples bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MauiSample-vulkan-win-x64 to the "Pack WPF NuGet + Windows samples" job output (ci.yml + release.yml). Because the managed layer is backend-agnostic, the Vulkan sample is the normal win-x64 MauiSample publish with the Vulkan-built mln-cabi.dll overlaid — at runtime MapImageView detects the "vulkan" backend and drives the offscreen Vulkan frontend. Downloads the native-mln-windows-x64-vulkan artifact (adds build-windows-vulkan to the job's needs) and zips the result alongside the existing Windows samples. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++-- .github/workflows/release.yml | 25 +++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f9faad..35f5a2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,7 +168,7 @@ jobs: pack-wpf: name: Pack WPF NuGet + Windows samples runs-on: windows-latest - needs: [version, build-windows, pack] + needs: [version, build-windows, build-windows-vulkan, pack] steps: - uses: actions/checkout@v4 @@ -187,6 +187,12 @@ jobs: pattern: native-windows-* path: native-artifacts + - name: Download Windows Vulkan native + uses: actions/download-artifact@v4 + with: + pattern: native-mln-windows-x64-vulkan + path: vulkan-native + - name: Stage native DLLs shell: bash run: | @@ -274,6 +280,22 @@ jobs: -p:UseLocalPackages=true \ -o publish/MauiSample-win-x64 + # Vulkan variant: the managed layer is backend-agnostic, so the same published + # app runs on Vulkan simply by swapping in the Vulkan-built mln-cabi.dll — at + # runtime MapImageView detects the "vulkan" backend and uses the offscreen path. + - name: Publish MauiSample (Vulkan, win-x64) + shell: bash + run: | + cp -r publish/MauiSample-win-x64 publish/MauiSample-vulkan-win-x64 + DLL=$(find vulkan-native -name mln-cabi.dll | head -1) + if [ -z "$DLL" ]; then echo "Vulkan mln-cabi.dll not found"; exit 1; fi + echo "Overlaying Vulkan native: $DLL" + # The RID publish flattens runtimes/win-x64/native/ to the app root; replace + # both locations if present so whichever the loader picks is the Vulkan build. + cp "$DLL" publish/MauiSample-vulkan-win-x64/mln-cabi.dll + find publish/MauiSample-vulkan-win-x64 -path "*runtimes/win-x64/native/mln-cabi.dll" \ + -exec cp "$DLL" {} \; + - name: Publish MauiSample (win-arm64) shell: bash run: | @@ -292,7 +314,8 @@ jobs: @( 'ConsoleExample-win-x64','ConsoleExample-win-arm64', 'WpfExample-win-x64','WpfExample-win-arm64', - 'MauiSample-win-x64','MauiSample-win-arm64' + 'MauiSample-win-x64','MauiSample-win-arm64', + 'MauiSample-vulkan-win-x64' ) | ForEach-Object { Compress-Archive -Path "publish/$_/*" -DestinationPath "samples/$_.zip" } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7bc7266..1b80ef3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -167,7 +167,7 @@ jobs: pack-wpf: name: Pack WPF NuGet + Windows samples runs-on: windows-latest - needs: [version-check, build-windows, pack] + needs: [version-check, build-windows, build-windows-vulkan, pack] if: needs.version-check.outputs.published == 'false' steps: @@ -187,6 +187,12 @@ jobs: pattern: native-windows-* path: native-artifacts + - name: Download Windows Vulkan native + uses: actions/download-artifact@v4 + with: + pattern: native-mln-windows-x64-vulkan + path: vulkan-native + - name: Stage native DLLs shell: bash run: | @@ -270,6 +276,20 @@ jobs: -p:UseLocalPackages=true \ -o publish/MauiSample-win-x64 + # Vulkan variant: the managed layer is backend-agnostic, so the same published + # app runs on Vulkan simply by swapping in the Vulkan-built mln-cabi.dll — at + # runtime MapImageView detects the "vulkan" backend and uses the offscreen path. + - name: Publish MauiSample (Vulkan, win-x64) + shell: bash + run: | + cp -r publish/MauiSample-win-x64 publish/MauiSample-vulkan-win-x64 + DLL=$(find vulkan-native -name mln-cabi.dll | head -1) + if [ -z "$DLL" ]; then echo "Vulkan mln-cabi.dll not found"; exit 1; fi + echo "Overlaying Vulkan native: $DLL" + cp "$DLL" publish/MauiSample-vulkan-win-x64/mln-cabi.dll + find publish/MauiSample-vulkan-win-x64 -path "*runtimes/win-x64/native/mln-cabi.dll" \ + -exec cp "$DLL" {} \; + - name: Publish MauiSample (win-arm64) shell: bash run: | @@ -288,7 +308,8 @@ jobs: @( 'ConsoleExample-win-x64','ConsoleExample-win-arm64', 'WpfExample-win-x64','WpfExample-win-arm64', - 'MauiSample-win-x64','MauiSample-win-arm64' + 'MauiSample-win-x64','MauiSample-win-arm64', + 'MauiSample-vulkan-win-x64' ) | ForEach-Object { Compress-Archive -Path "publish/$_/*" -DestinationPath "samples/$_.zip" } From 4c28395c51ac6eb5fa5002baa109a9ce151a74a1 Mon Sep 17 00:00:00 2001 From: acalcutt Date: Tue, 7 Jul 2026 18:59:29 -0400 Subject: [PATCH 11/16] ci(apple-vulkan): drop invalid MoltenVK SDK component humbletim/setup-vulkan-sdk cannot build MoltenVK (it errored with "unknown component: MoltenVK"). MoltenVK isn't needed at build time anyway: mln-cabi is a static archive using Vulkan's dynamic dispatch loader with vendored headers, so no Vulkan library links here (MoltenVK links later in the app). Install only Vulkan-Headers + Vulkan-Loader, which is enough to satisfy find_package(Vulkan) on the macCatalyst leg. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/native-apple-vulkan.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/native-apple-vulkan.yml b/.github/workflows/native-apple-vulkan.yml index 3971d68..3a5f333 100644 --- a/.github/workflows/native-apple-vulkan.yml +++ b/.github/workflows/native-apple-vulkan.yml @@ -43,15 +43,17 @@ jobs: with: submodules: recursive - # Vulkan headers + VMA are vendored by maplibre-native, so compilation needs - # no external SDK. But platform/macos/macos.cmake calls find_package(Vulkan - # REQUIRED); the SDK (which bundles MoltenVK) satisfies that. mln-cabi is a - # static archive here, so MoltenVK itself is linked later, by the app. - - name: Install Vulkan SDK (MoltenVK) + # Vulkan headers + VMA are vendored by maplibre-native, and mln-cabi is a static + # archive that uses Vulkan's dynamic dispatch loader — so no Vulkan library is + # linked at build time (MoltenVK links later, in the app). The only build-time + # need is satisfying platform/macos/macos.cmake's find_package(Vulkan REQUIRED) + # on the macCatalyst leg, which the Vulkan-Loader component covers. (MoltenVK is + # not a component this action can build.) + - name: Install Vulkan SDK (headers + loader) uses: humbletim/setup-vulkan-sdk@v1.2.1 with: vulkan-query-version: latest - vulkan-components: MoltenVK, Vulkan-Headers, Vulkan-Loader + vulkan-components: Vulkan-Headers, Vulkan-Loader vulkan-use-cache: true - name: Patch darwin.cmake for macCatalyst From 6037bc04014c875427154b2b35f2efc58608a413 Mon Sep 17 00:00:00 2001 From: acalcutt Date: Tue, 7 Jul 2026 19:20:01 -0400 Subject: [PATCH 12/16] ci(apple-vulkan): disable -Werror for the Apple Vulkan native build maplibre-native's own mbgl/vulkan/*.cpp has latent -Wshorten-64-to-32 (size_t -> uint32_t) truncations that only surface under Apple's strict flags, since upstream never compiles the Vulkan backend for Apple (upload_pass.cpp:54 was the first of many). Set -DMLN_WITH_WERROR=OFF so these upstream warnings don't fail our build; our own code is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/native-apple-vulkan.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/native-apple-vulkan.yml b/.github/workflows/native-apple-vulkan.yml index 3a5f333..6a6cbc4 100644 --- a/.github/workflows/native-apple-vulkan.yml +++ b/.github/workflows/native-apple-vulkan.yml @@ -72,7 +72,8 @@ jobs: -DCMAKE_OSX_DEPLOYMENT_TARGET=14.0 \ ${{ matrix.extra-flags }} \ -DMLN_WITH_METAL=OFF \ - -DMLN_WITH_VULKAN=ON + -DMLN_WITH_VULKAN=ON \ + -DMLN_WITH_WERROR=OFF - name: Configure and build macCatalyst (macabi) if: ${{ matrix.artifact == 'native-mln-maccatalyst-vulkan' }} @@ -106,7 +107,8 @@ jobs: "-DCMAKE_OBJCXX_FLAGS=${EXTRA_FLAGS}" \ -DMLN_WITH_GLFW=OFF \ -DMLN_WITH_METAL=OFF \ - -DMLN_WITH_VULKAN=ON + -DMLN_WITH_VULKAN=ON \ + -DMLN_WITH_WERROR=OFF cmake --build "build-${ARCH}" --target mln-cabi --config Release --parallel CABI=$(find "build-${ARCH}" -name "libmln-cabi.a" | head -1) ALL_LIBS=$(find "build-${ARCH}" -name "*.a" -type f | sort) From 8331f3c71cb374dfb2ba15c853ee5b84840ad314 Mon Sep 17 00:00:00 2001 From: acalcutt Date: Tue, 7 Jul 2026 20:28:22 -0400 Subject: [PATCH 13/16] Make WPF + ConsoleExample backend-aware; ship Vulkan variants of all Windows samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WPF MlnMapImage: branch on MbglFrontend.RenderBackend like MapImageView — OpenGL keeps the WGL context + glReadPixels; Vulkan skips WGL, renders headless, and copies frames back via frontend.ReadPixels into the WriteableBitmap (flip undone for the top-down read-back). - ConsoleExample: add a RunVulkan() headless render path (no WGL/Win32) selected when the loaded native reports the Vulkan backend. - ci.yml + release.yml: the Windows-samples job now overlays the Vulkan mln-cabi.dll onto all three win-x64 apps and zips ConsoleExample-vulkan / WpfExample-vulkan / MauiSample-vulkan alongside the existing samples, so the windows-samples bundle has a working Vulkan build of each renderer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 25 +++++----- .github/workflows/release.yml | 21 +++++---- sample/ConsoleExample/Program.cs | 80 ++++++++++++++++++++++++++++++++ wpf/MlnMapImage.cs | 64 +++++++++++++++++-------- 4 files changed, 150 insertions(+), 40 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35f5a2d..f3f1a43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -280,21 +280,24 @@ jobs: -p:UseLocalPackages=true \ -o publish/MauiSample-win-x64 - # Vulkan variant: the managed layer is backend-agnostic, so the same published - # app runs on Vulkan simply by swapping in the Vulkan-built mln-cabi.dll — at - # runtime MapImageView detects the "vulkan" backend and uses the offscreen path. - - name: Publish MauiSample (Vulkan, win-x64) + # Vulkan variants: the managed layer is backend-agnostic, so each published app + # runs on Vulkan simply by swapping in the Vulkan-built mln-cabi.dll — at runtime + # the renderers (MapImageView, MlnMapImage, ConsoleExample) detect the "vulkan" + # backend and use the offscreen read-back path. + - name: Create Vulkan sample variants (win-x64) shell: bash run: | - cp -r publish/MauiSample-win-x64 publish/MauiSample-vulkan-win-x64 DLL=$(find vulkan-native -name mln-cabi.dll | head -1) if [ -z "$DLL" ]; then echo "Vulkan mln-cabi.dll not found"; exit 1; fi echo "Overlaying Vulkan native: $DLL" - # The RID publish flattens runtimes/win-x64/native/ to the app root; replace - # both locations if present so whichever the loader picks is the Vulkan build. - cp "$DLL" publish/MauiSample-vulkan-win-x64/mln-cabi.dll - find publish/MauiSample-vulkan-win-x64 -path "*runtimes/win-x64/native/mln-cabi.dll" \ - -exec cp "$DLL" {} \; + for app in ConsoleExample WpfExample MauiSample; do + cp -r "publish/${app}-win-x64" "publish/${app}-vulkan-win-x64" + # The RID publish flattens runtimes/win-x64/native/ to the app root; replace + # both locations if present so whichever the loader picks is the Vulkan build. + cp "$DLL" "publish/${app}-vulkan-win-x64/mln-cabi.dll" + find "publish/${app}-vulkan-win-x64" -path "*runtimes/win-x64/native/mln-cabi.dll" \ + -exec cp "$DLL" {} \; + done - name: Publish MauiSample (win-arm64) shell: bash @@ -315,7 +318,7 @@ jobs: 'ConsoleExample-win-x64','ConsoleExample-win-arm64', 'WpfExample-win-x64','WpfExample-win-arm64', 'MauiSample-win-x64','MauiSample-win-arm64', - 'MauiSample-vulkan-win-x64' + 'ConsoleExample-vulkan-win-x64','WpfExample-vulkan-win-x64','MauiSample-vulkan-win-x64' ) | ForEach-Object { Compress-Archive -Path "publish/$_/*" -DestinationPath "samples/$_.zip" } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b80ef3..155238f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -276,19 +276,22 @@ jobs: -p:UseLocalPackages=true \ -o publish/MauiSample-win-x64 - # Vulkan variant: the managed layer is backend-agnostic, so the same published - # app runs on Vulkan simply by swapping in the Vulkan-built mln-cabi.dll — at - # runtime MapImageView detects the "vulkan" backend and uses the offscreen path. - - name: Publish MauiSample (Vulkan, win-x64) + # Vulkan variants: the managed layer is backend-agnostic, so each published app + # runs on Vulkan simply by swapping in the Vulkan-built mln-cabi.dll — at runtime + # the renderers (MapImageView, MlnMapImage, ConsoleExample) detect the "vulkan" + # backend and use the offscreen read-back path. + - name: Create Vulkan sample variants (win-x64) shell: bash run: | - cp -r publish/MauiSample-win-x64 publish/MauiSample-vulkan-win-x64 DLL=$(find vulkan-native -name mln-cabi.dll | head -1) if [ -z "$DLL" ]; then echo "Vulkan mln-cabi.dll not found"; exit 1; fi echo "Overlaying Vulkan native: $DLL" - cp "$DLL" publish/MauiSample-vulkan-win-x64/mln-cabi.dll - find publish/MauiSample-vulkan-win-x64 -path "*runtimes/win-x64/native/mln-cabi.dll" \ - -exec cp "$DLL" {} \; + for app in ConsoleExample WpfExample MauiSample; do + cp -r "publish/${app}-win-x64" "publish/${app}-vulkan-win-x64" + cp "$DLL" "publish/${app}-vulkan-win-x64/mln-cabi.dll" + find "publish/${app}-vulkan-win-x64" -path "*runtimes/win-x64/native/mln-cabi.dll" \ + -exec cp "$DLL" {} \; + done - name: Publish MauiSample (win-arm64) shell: bash @@ -309,7 +312,7 @@ jobs: 'ConsoleExample-win-x64','ConsoleExample-win-arm64', 'WpfExample-win-x64','WpfExample-win-arm64', 'MauiSample-win-x64','MauiSample-win-arm64', - 'MauiSample-vulkan-win-x64' + 'ConsoleExample-vulkan-win-x64','WpfExample-vulkan-win-x64','MauiSample-vulkan-win-x64' ) | ForEach-Object { Compress-Archive -Path "publish/$_/*" -DestinationPath "samples/$_.zip" } diff --git a/sample/ConsoleExample/Program.cs b/sample/ConsoleExample/Program.cs index fd35160..813cee0 100644 --- a/sample/ConsoleExample/Program.cs +++ b/sample/ConsoleExample/Program.cs @@ -100,12 +100,92 @@ struct PIXELFORMATDESCRIPTOR const int Width = 1024; const int Height = 768; + // Headless Vulkan render — no WGL/Win32. The off-screen Vulkan frontend renders + // into a headless texture that we read back via frontend.ReadPixels. + [STAThread] + static void RunVulkan() + { + Console.WriteLine(" Backend: Vulkan (headless off-screen render)."); + + bool renderNeeded = false, mapIdle = false; + string? failMsg = null; + + using var runLoop = new MbglRunLoop(); + using var frontend = new MbglFrontend(IntPtr.Zero, IntPtr.Zero, Width, Height, 1.0f, + onRender: () => renderNeeded = true); + using var map = new MbglMap(frontend, runLoop, + observer: (evt, detail) => + { + switch (evt) + { + case "onDidFinishLoadingStyle": Console.WriteLine(" Style loaded."); break; + case "onDidBecomeIdle": Console.WriteLine(" Map idle — all tiles ready."); mapIdle = true; break; + case "onDidFailLoadingMap": failMsg = detail ?? "unknown error"; mapIdle = true; break; + } + }); + + map.SetSize(Width, Height); + map.JumpTo(lat: 47.6062, lon: -122.3321, zoom: 9); // Seattle + map.SetStyleUrl("https://demotiles.maplibre.org/style.json"); + + Console.WriteLine("Pumping run loop (max 30 s)..."); + var deadline = DateTime.UtcNow.AddSeconds(30); + while (!mapIdle && DateTime.UtcNow < deadline) + { + runLoop.RunOnce(); + if (renderNeeded) { renderNeeded = false; try { frontend.Render(); } catch { } } + Thread.Sleep(8); + } + if (failMsg != null) Console.Error.WriteLine($"Map load failed: {failMsg}"); + else if (!mapIdle) Console.Error.WriteLine("Timed out waiting for map idle."); + + Console.WriteLine("Rendering final frame..."); + for (int pass = 0; pass < 5; pass++) { runLoop.RunOnce(); try { frontend.Render(); } catch { } Thread.Sleep(16); } + + // Read back the off-screen frame (premultiplied RGBA, top-down). + var rgba = new byte[Width * Height * 4]; + var pin = GCHandle.Alloc(rgba, GCHandleType.Pinned); + bool ok; + try { ok = frontend.ReadPixels(pin.AddrOfPinnedObject(), (nuint)rgba.Length); } + finally { pin.Free(); } + if (!ok) { Console.Error.WriteLine("ReadPixels failed."); return; } + + // RGBA → BGRA for WriteableBitmap (no vertical flip; read-back is top-down). + int stride = Width * 4; + var bgra = new byte[rgba.Length]; + for (int i = 0; i < rgba.Length; i += 4) + { + bgra[i + 0] = rgba[i + 2]; // B ← R + bgra[i + 1] = rgba[i + 1]; // G + bgra[i + 2] = rgba[i + 0]; // R ← B + bgra[i + 3] = rgba[i + 3]; // A + } + + string outPath = Path.Combine(AppContext.BaseDirectory, "map_output.png"); + var bitmap = new WriteableBitmap(Width, Height, 96, 96, PixelFormats.Bgra32, null); + bitmap.WritePixels(new Int32Rect(0, 0, Width, Height), bgra, stride, 0); + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + using var fs = File.Create(outPath); + encoder.Save(fs); + Console.WriteLine($"Saved: {outPath}"); + } + [STAThread] static void Main() { Console.WriteLine("MapLibreNative.Maui — console static render example"); Console.WriteLine($"Rendering {Width}×{Height} map centred on Seattle..."); + // The Vulkan native renders off-screen (headless) and needs no WGL/Win32 + // context — take a separate, much simpler path. (Selected at runtime from + // whichever mln-cabi.dll is loaded, so the same exe works for either backend.) + if (MbglFrontend.RenderBackend == MbglRenderBackend.Vulkan) + { + RunVulkan(); + return; + } + // ── Create a hidden window as an OpenGL context host ────────────────── var hInst = GetModuleHandle(IntPtr.Zero); WndProcDelegate wndProc = DefWindowProc; // keep delegate alive diff --git a/wpf/MlnMapImage.cs b/wpf/MlnMapImage.cs index f5ea7e0..32ca8dd 100644 --- a/wpf/MlnMapImage.cs +++ b/wpf/MlnMapImage.cs @@ -196,6 +196,9 @@ public MapLibreNative.Maui.Geometry.MapSpan? VisibleRegion private DispatcherTimer? _renderTimer; private bool _initialized, _renderNeedsUpdate = true, _styleReady; + // Vulkan builds render off-screen (headless) and read pixels back through the + // frontend; OpenGL builds render into a WGL FBO and read back via glReadPixels. + private static readonly bool _vulkan = MbglFrontend.RenderBackend == MbglRenderBackend.Vulkan; private float _dpi = 1f; private int _physW = 1, _physH = 1; @@ -215,9 +218,10 @@ public MlnMapImage() // is resolved first for every TextBlock in this subtree. Resources.Add(typeof(TextBlock), new Style(typeof(TextBlock))); - // GL renders bottom-left origin; WPF WriteableBitmap is top-left → flip vertically. + // GL renders bottom-left origin so it flips vertically; the Vulkan headless + // read-back is already top-down, so no flip there. _image.RenderTransformOrigin = new Point(0.5, 0.5); - _image.RenderTransform = new ScaleTransform(1, -1); + _image.RenderTransform = new ScaleTransform(1, _vulkan ? 1 : -1); Children.Add(_image); BuildNavOverlay(); @@ -243,14 +247,22 @@ private void TryInitialize() _physW = Math.Max(1, (int)Math.Round(ActualWidth * _dpi)); _physH = Math.Max(1, (int)Math.Round(ActualHeight * _dpi)); - _interop = new HiddenWglContext(); - _interop.Initialize(); - _interop.Resize(_physW, _physH); + if (!_vulkan) + { + // OpenGL: off-screen WGL context we glReadPixels from each frame. + _interop = new HiddenWglContext(); + _interop.Initialize(); + _interop.Resize(_physW, _physH); + } CreateBitmap(_physW, _physH); _runLoop = new MbglRunLoop(); - _frontend = new MbglFrontend(_interop.Hdc, _interop.GlContext, _physW, _physH, _dpi, - () => _renderNeedsUpdate = true); + // Vulkan renders headless (no surface handle); OpenGL needs the WGL HDC + context. + _frontend = _vulkan + ? new MbglFrontend(IntPtr.Zero, IntPtr.Zero, _physW, _physH, _dpi, + () => _renderNeedsUpdate = true) + : new MbglFrontend(_interop!.Hdc, _interop.GlContext, _physW, _physH, _dpi, + () => _renderNeedsUpdate = true); // Persistent tile/resource cache (mbgl's default is :memory:), shared // with MbglOfflineManager via MbglCache.DefaultPath. _map = new MbglMap(_frontend, _runLoop, cachePath: MbglCache.DefaultPath, @@ -274,15 +286,14 @@ private void TryInitialize() private void UpdateSize() { - if (!_initialized || _interop == null || _map == null || _frontend == null) return; + if (!_initialized || _map == null || _frontend == null) return; if (ActualWidth < 1 || ActualHeight < 1) return; _dpi = (float)GetDpiScale(); int w = Math.Max(1, (int)Math.Round(ActualWidth * _dpi)); int h = Math.Max(1, (int)Math.Round(ActualHeight * _dpi)); if (w == _physW && h == _physH) return; _physW = w; _physH = h; - _interop.MakeCurrent(); - _interop.Resize(w, h); + if (_interop != null) { _interop.MakeCurrent(); _interop.Resize(w, h); } // OpenGL only CreateBitmap(w, h); _frontend.SetSize(w, h); _map.SetSize(w, h); @@ -299,18 +310,31 @@ private void CreateBitmap(int w, int h) private void OnRenderTick(object? sender, EventArgs e) { _runLoop?.RunOnce(); - if (!_renderNeedsUpdate || _interop == null || _frontend == null || _bitmap == null) return; + if (!_renderNeedsUpdate || _frontend == null || _bitmap == null) return; _renderNeedsUpdate = false; - _interop.MakeCurrent(); - glViewport(0, 0, _interop.Width, _interop.Height); - try { _frontend.Render(); } catch { return; /* swallow per-frame render faults */ } - - // Read pixels (GL bottom-left → WPF top-left; _image has ScaleTransform(1,-1) to compensate). - _bitmap.Lock(); - _interop.ReadPixels(_bitmap.BackBuffer); - _bitmap.AddDirtyRect(new Int32Rect(0, 0, _physW, _physH)); - _bitmap.Unlock(); + if (_vulkan) + { + // Headless Vulkan: render off-screen, then copy the frame into the bitmap. + try { _frontend.Render(); } catch { return; /* swallow per-frame render faults */ } + _bitmap.Lock(); + _frontend.ReadPixels(_bitmap.BackBuffer, (nuint)((long)_physW * _physH * 4)); + _bitmap.AddDirtyRect(new Int32Rect(0, 0, _physW, _physH)); + _bitmap.Unlock(); + } + else + { + if (_interop == null) return; + _interop.MakeCurrent(); + glViewport(0, 0, _interop.Width, _interop.Height); + try { _frontend.Render(); } catch { return; /* swallow per-frame render faults */ } + + // Read pixels (GL bottom-left → WPF top-left; _image has ScaleTransform(1,-1) to compensate). + _bitmap.Lock(); + _interop.ReadPixels(_bitmap.BackBuffer); + _bitmap.AddDirtyRect(new Int32Rect(0, 0, _physW, _physH)); + _bitmap.Unlock(); + } } // ── Camera API ──────────────────────────────────────────────────────────── From 87cc65c7f358a01dce59a7accc638d8c446d5434 Mon Sep 17 00:00:00 2001 From: acalcutt Date: Wed, 8 Jul 2026 04:26:15 -0400 Subject: [PATCH 14/16] fix(windows-vulkan): heap corruption from wrong backend scope on read-back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offscreen frontend rendered under BackendScope::Implicit and then read pixels back in a *separate* scope. Implicit never calls HeadlessBackend::activate() (which creates the backend impl and validates the Vulkan context), and reading in a second scope tears the just-rendered frame's resources down before readStillImage() copies getAcquiredImage() — reading/freeing invalid Vulkan memory and corrupting the heap (0xc0000374, crashing MauiSample/WpfExample on the Vulkan native). Mirror maplibre-native's HeadlessFrontend: render under the default (Explicit) scope and read the still image back inside the SAME scope, caching the RGBA frame so readPixels() just copies it out. Co-Authored-By: Claude Opus 4.8 (1M context) --- native/src/platform_frontend_windows.cpp | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/native/src/platform_frontend_windows.cpp b/native/src/platform_frontend_windows.cpp index 01a1f30..a383d87 100644 --- a/native/src/platform_frontend_windows.cpp +++ b/native/src/platform_frontend_windows.cpp @@ -176,6 +176,7 @@ PlatformFrontend* createPlatformFrontend( #include #include #include +#include /* Offscreen Vulkan frontend. There is no HWND / window surface: the map renders * into a headless color texture and the managed layer pulls the pixels back via @@ -211,8 +212,17 @@ class VulkanOffscreenFrontend : public PlatformFrontend { std::shared_ptr params; { std::unique_lock lock(_mutex); params = std::move(_updateParams); } if (!params) return; - mbgl::gfx::BackendScope guard(_backend, mbgl::gfx::BackendScope::ScopeType::Implicit); + // Default (Explicit) scope: the headless backend's activate() creates its impl + // and validates the Vulkan context — Implicit would skip that. Read the frame + // back inside the SAME scope, while the just-rendered image + context are still + // live; reading it in a separate scope tears frame resources down first and + // corrupts the heap. readStillImage() waits for the frame and copies the image. + mbgl::gfx::BackendScope guard(_backend); _renderer->render(params); + try { + mbgl::PremultipliedImage img = _backend.readStillImage(); + _lastImage.assign(img.data.get(), img.data.get() + img.bytes()); + } catch (...) { /* keep the previous frame's pixels */ } } void setSize(mbgl::Size sz) override { _size = sz; _backend.setSize(sz); } @@ -222,14 +232,8 @@ class VulkanOffscreenFrontend : public PlatformFrontend { bool readPixels(uint8_t* out, size_t len) override { const size_t need = static_cast(_size.width) * _size.height * 4u; - if (!out || len < need) return false; - mbgl::PremultipliedImage img; - { - mbgl::gfx::BackendScope guard(_backend, mbgl::gfx::BackendScope::ScopeType::Implicit); - img = _backend.readStillImage(); - } - if (img.bytes() < need) return false; - std::memcpy(out, img.data.get(), need); + if (!out || len < need || _lastImage.size() < need) return false; + std::memcpy(out, _lastImage.data(), need); return true; } @@ -237,6 +241,7 @@ class VulkanOffscreenFrontend : public PlatformFrontend { mbgl::Size _size; mbgl::vulkan::HeadlessBackend _backend; std::unique_ptr _renderer; + std::vector _lastImage; // most recent frame, RGBA mbgl_render_fn _renderCb; void* _renderUd; std::shared_ptr _updateParams; From 7df839e1790dbe4d3aad1e1a4054524ea4dd85ad Mon Sep 17 00:00:00 2001 From: acalcutt Date: Wed, 8 Jul 2026 07:57:45 -0400 Subject: [PATCH 15/16] diag(windows-vulkan): lifecycle tracing to localise the heap-corruption crash The offscreen Vulkan path still crashes with heap corruption (0xc0000374) and guessing hasn't pinned it, so instrument each stage of VulkanOffscreenFrontend (create / ctor / update / render steps / readStillImage / readPixels / dtor) to %TEMP%\mln_vulkan_diag.log, flushed per line so the last entry survives the crash and tells us exactly which step dies. Also ship mln-cabi.pdb in the Windows Vulkan artifact and alongside the overlaid sample DLL so the crash dump can be symbolicated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 2 ++ .github/workflows/native-windows-vulkan.yml | 5 +++- native/src/platform_frontend_windows.cpp | 33 ++++++++++++++++++--- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3f1a43..6af4c91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,6 +288,7 @@ jobs: shell: bash run: | DLL=$(find vulkan-native -name mln-cabi.dll | head -1) + PDB=$(find vulkan-native -name mln-cabi.pdb | head -1) if [ -z "$DLL" ]; then echo "Vulkan mln-cabi.dll not found"; exit 1; fi echo "Overlaying Vulkan native: $DLL" for app in ConsoleExample WpfExample MauiSample; do @@ -295,6 +296,7 @@ jobs: # The RID publish flattens runtimes/win-x64/native/ to the app root; replace # both locations if present so whichever the loader picks is the Vulkan build. cp "$DLL" "publish/${app}-vulkan-win-x64/mln-cabi.dll" + [ -n "$PDB" ] && cp "$PDB" "publish/${app}-vulkan-win-x64/mln-cabi.pdb" find "publish/${app}-vulkan-win-x64" -path "*runtimes/win-x64/native/mln-cabi.dll" \ -exec cp "$DLL" {} \; done diff --git a/.github/workflows/native-windows-vulkan.yml b/.github/workflows/native-windows-vulkan.yml index 2a59f42..7519f47 100644 --- a/.github/workflows/native-windows-vulkan.yml +++ b/.github/workflows/native-windows-vulkan.yml @@ -86,5 +86,8 @@ jobs: uses: actions/upload-artifact@v4 with: name: native-mln-windows-${{ matrix.arch }}-vulkan - path: build/**/mln-cabi.dll + # Include the .pdb so a crash dump from the sample can be symbolicated. + path: | + build/**/mln-cabi.dll + build/**/mln-cabi.pdb retention-days: 7 diff --git a/native/src/platform_frontend_windows.cpp b/native/src/platform_frontend_windows.cpp index a383d87..71f0d0a 100644 --- a/native/src/platform_frontend_windows.cpp +++ b/native/src/platform_frontend_windows.cpp @@ -177,6 +177,20 @@ PlatformFrontend* createPlatformFrontend( #include #include #include +#include +#include + +// Lifecycle tracing to localise the Vulkan-Windows crash. Writes (and flushes) each +// step to %TEMP%\mln_vulkan_diag.log so the last line survives a hard crash. Cheap; +// remove once the offscreen path is stable. +static void VkDiag(const char* msg) { + char dir[MAX_PATH]; + DWORD n = GetTempPathA(MAX_PATH, dir); + try { + std::ofstream f(std::string(dir, n) + "mln_vulkan_diag.log", std::ios::app); + f << msg << "\n"; + } catch (...) { /* ignore */ } +} /* Offscreen Vulkan frontend. There is no HWND / window surface: the map renders * into a headless color texture and the managed layer pulls the pixels back via @@ -189,17 +203,20 @@ class VulkanOffscreenFrontend : public PlatformFrontend { , _backend(sz, mbgl::gfx::Renderable::SwapBehaviour::NoFlush, mbgl::gfx::ContextMode::Unique) , _renderer(std::make_unique(_backend, pixelRatio)) , _renderCb(cb), _renderUd(ud) - {} + { VkDiag("ctor: backend+renderer constructed"); } ~VulkanOffscreenFrontend() override { + VkDiag("dtor: begin"); mbgl::gfx::BackendScope guard(_backend, mbgl::gfx::BackendScope::ScopeType::Implicit); _renderer.reset(); + VkDiag("dtor: end"); } /* RendererFrontend */ void reset() override { _renderer.reset(); } void setObserver(mbgl::RendererObserver& obs) override { _renderer->setObserver(&obs); } void update(std::shared_ptr params) override { + VkDiag("update"); { std::unique_lock lock(_mutex); _updateParams = std::move(params); } if (_renderCb) _renderCb(_renderUd); } @@ -217,15 +234,20 @@ class VulkanOffscreenFrontend : public PlatformFrontend { // back inside the SAME scope, while the just-rendered image + context are still // live; reading it in a separate scope tears frame resources down first and // corrupts the heap. readStillImage() waits for the frame and copies the image. + VkDiag("render: begin"); mbgl::gfx::BackendScope guard(_backend); _renderer->render(params); + VkDiag("render: renderer->render done"); try { mbgl::PremultipliedImage img = _backend.readStillImage(); + VkDiag("render: readStillImage done"); _lastImage.assign(img.data.get(), img.data.get() + img.bytes()); - } catch (...) { /* keep the previous frame's pixels */ } + VkDiag("render: cached frame"); + } catch (...) { VkDiag("render: readStillImage threw"); } + VkDiag("render: end"); } - void setSize(mbgl::Size sz) override { _size = sz; _backend.setSize(sz); } + void setSize(mbgl::Size sz) override { VkDiag("setSize"); _size = sz; _backend.setSize(sz); } mbgl::Size getSize() const override { return _size; } mbgl::MapObserver& getObserver() override { return _nullObserver; } mbgl::Renderer* getRenderer() override { return _renderer.get(); } @@ -254,7 +276,10 @@ PlatformFrontend* createPlatformFrontend( mbgl::Size sz, float pixelRatio, mbgl_render_fn renderCb, void* renderUd) { - return new VulkanOffscreenFrontend(sz, pixelRatio, renderCb, renderUd); + VkDiag("create: begin"); + auto* fe = new VulkanOffscreenFrontend(sz, pixelRatio, renderCb, renderUd); + VkDiag("create: end ok"); + return fe; } #endif // MLN_RENDER_BACKEND_OPENGL From 599c8fd3590b29bd5f413777e751300317ffea32 Mon Sep 17 00:00:00 2001 From: acalcutt Date: Thu, 16 Jul 2026 13:40:45 -0400 Subject: [PATCH 16/16] Fix heap corruption: don't free native-owned const char* returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mbgl_get_render_backend and mbgl_get_last_error return pointers to memory the native library owns — a static string literal and a thread_local std::string buffer respectively. Marshalling their return as a `string` makes the source-generated P/Invoke free that pointer with FreeCoTaskMem, corrupting the heap (0xC0000374). mbgl_get_render_backend is the first native call at startup (static RenderBackend initializer), so every build — GL and Vulkan — crashed on load before showing a window. Return IntPtr and copy with Marshal.PtrToStringUTF8, which does not free. Co-Authored-By: Claude Opus 4.8 --- bindings/NativeMethods.cs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/bindings/NativeMethods.cs b/bindings/NativeMethods.cs index c6d955a..ea1c596 100644 --- a/bindings/NativeMethods.cs +++ b/bindings/NativeMethods.cs @@ -107,10 +107,15 @@ public delegate int LogFn( IntPtr userdata); // ── Diagnostics ─────────────────────────────────────────────────────────── - /// Returns a thread-local string describing the most recent non-OK status. + // Native returns s_last_error.c_str() from a thread_local std::string it owns — + // marshalling the return as `string` would free that pointer (FreeCoTaskMem) and + // corrupt the heap. Return the raw pointer and copy it without freeing. [LibraryImport(Lib, EntryPoint = "mbgl_get_last_error")] - [return: MarshalAs(UnmanagedType.LPUTF8Str)] - public static partial string GetLastError(); + private static partial IntPtr GetLastErrorPtr(); + + /// Returns a thread-local string describing the most recent non-OK status. + public static string GetLastError() + => Marshal.PtrToStringUTF8(GetLastErrorPtr()) ?? string.Empty; /// Install a process-global log callback. Pass null to restore default logging. [LibraryImport(Lib, EntryPoint = "mbgl_install_log_callback")] @@ -137,10 +142,16 @@ public delegate int LogFn( public static partial MbglStatus RunLoopRunOnce(IntPtr rl); // ── Render backend ──────────────────────────────────────────────────────── - /// Returns the renderer this native build uses: "opengl", "vulkan", or "metal". + // The native returns a pointer to a STATIC string literal it owns. Marshalling the + // return as a `string` makes the generated marshaller free that pointer with + // FreeCoTaskMem, which corrupts the heap (0xC0000374 on the first call at startup). + // Return the raw pointer and copy it without freeing. [LibraryImport(Lib, EntryPoint = "mbgl_get_render_backend")] - [return: MarshalAs(UnmanagedType.LPUTF8Str)] - public static partial string GetRenderBackend(); + private static partial IntPtr GetRenderBackendPtr(); + + /// Returns the renderer this native build uses: "opengl", "vulkan", or "metal". + public static string GetRenderBackend() + => Marshal.PtrToStringUTF8(GetRenderBackendPtr()) ?? "opengl"; // ── Frontend ────────────────────────────────────────────────────────────── /// Backend-agnostic frontend factory (surface_handle meaning depends on backend).