diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 80cafec..6af4c91 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
@@ -163,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
@@ -182,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: |
@@ -269,6 +280,27 @@ jobs:
-p:UseLocalPackages=true \
-o publish/MauiSample-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: |
+ 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
+ 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"
+ [ -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
+
- name: Publish MauiSample (win-arm64)
shell: bash
run: |
@@ -287,7 +319,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',
+ 'ConsoleExample-vulkan-win-x64','WpfExample-vulkan-win-x64','MauiSample-vulkan-win-x64'
) | ForEach-Object {
Compress-Archive -Path "publish/$_/*" -DestinationPath "samples/$_.zip"
}
@@ -382,7 +415,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 +430,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,17 +445,33 @@ 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
+ - 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 +483,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:
diff --git a/.github/workflows/native-apple-vulkan.yml b/.github/workflows/native-apple-vulkan.yml
new file mode 100644
index 0000000..6a6cbc4
--- /dev/null
+++ b/.github/workflows/native-apple-vulkan.yml
@@ -0,0 +1,153 @@
+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, 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: 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 \
+ -DMLN_WITH_WERROR=OFF
+
+ - 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 \
+ -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)
+ # 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/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/.github/workflows/release.yml b/.github/workflows/release.yml
index eb9eb4b..155238f 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
@@ -161,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:
@@ -181,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: |
@@ -264,6 +276,23 @@ jobs:
-p:UseLocalPackages=true \
-o publish/MauiSample-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: |
+ 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"
+ 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
run: |
@@ -282,7 +311,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',
+ 'ConsoleExample-vulkan-win-x64','WpfExample-vulkan-win-x64','MauiSample-vulkan-win-x64'
) | ForEach-Object {
Compress-Archive -Path "publish/$_/*" -DestinationPath "samples/$_.zip"
}
@@ -385,7 +415,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 +429,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,17 +444,33 @@ 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
+ - 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 2098cf2..1576857 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -48,6 +48,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/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 457d104..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")]
@@ -136,7 +141,31 @@ public delegate int LogFn(
[LibraryImport(Lib, EntryPoint = "mbgl_runloop_run_once")]
public static partial MbglStatus RunLoopRunOnce(IntPtr rl);
+ // ── Render backend ────────────────────────────────────────────────────────
+ // 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")]
+ 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).
+ [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 +176,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/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++
+
+
+
diff --git a/handlers/Windows/MapImageView.Windows.cs b/handlers/Windows/MapImageView.Windows.cs
index e3a561f..632c574 100644
--- a/handlers/Windows/MapImageView.Windows.cs
+++ b/handlers/Windows/MapImageView.Windows.cs
@@ -86,6 +86,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;
@@ -102,6 +107,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();
@@ -124,16 +132,21 @@ 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);
// UiScale multiplies only the style-unit pixel ratio (text/icon/circle/line
@@ -141,8 +154,13 @@ private void Start()
float pixelRatio = _dpi * UiScale;
_runLoop = _sharedRunLoop ??= new MbglRunLoop();
- _frontend = new MbglFrontend(_interop.Hdc, _interop.GlContext, _width, _height, pixelRatio,
- () => _renderNeedsUpdate = true);
+ // Vulkan renders headless (no surface handle); OpenGL needs the WGL HDC + context.
+ // pixelRatio (= _dpi * UiScale) scales style-unit sizes; the surface dims above stay physical.
+ _frontend = _vulkan
+ ? new MbglFrontend(IntPtr.Zero, IntPtr.Zero, _width, _height, pixelRatio,
+ () => _renderNeedsUpdate = true)
+ : new MbglFrontend(_interop!.Hdc, _interop.GlContext, _width, _height, pixelRatio,
+ () => _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.
@@ -170,14 +188,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);
@@ -187,20 +205,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();
}
diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt
index 2a04fe1..313ac9f 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)
@@ -86,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/include/mln_cabi.h b/native/include/mln_cabi.h
index e4f978a..22638e3 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" {
@@ -139,7 +140,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 +176,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 4fb4eeb..2e95b71 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_android.cpp b/native/src/platform_frontend_android.cpp
index a734b17..30700ca 100644
--- a/native/src/platform_frontend_android.cpp
+++ b/native/src/platform_frontend_android.cpp
@@ -221,18 +221,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
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
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;
+};
diff --git a/native/src/platform_frontend_windows.cpp b/native/src/platform_frontend_windows.cpp
index 2f8424a..71f0d0a 100644
--- a/native/src/platform_frontend_windows.cpp
+++ b/native/src/platform_frontend_windows.cpp
@@ -163,18 +163,123 @@ 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
+#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
+ * 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)
+ { 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);
+ }
+ 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;
+ // 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.
+ 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());
+ VkDiag("render: cached frame");
+ } catch (...) { VkDiag("render: readStillImage threw"); }
+ VkDiag("render: end");
+ }
+
+ 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(); }
+
+ bool readPixels(uint8_t* out, size_t len) override {
+ const size_t need = static_cast(_size.width) * _size.height * 4u;
+ if (!out || len < need || _lastImage.size() < need) return false;
+ std::memcpy(out, _lastImage.data(), need);
+ return true;
+ }
+
+private:
+ 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;
+ 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.");
+ VkDiag("create: begin");
+ auto* fe = new VulkanOffscreenFrontend(sz, pixelRatio, renderCb, renderUd);
+ VkDiag("create: end ok");
+ return fe;
}
#endif // MLN_RENDER_BACKEND_OPENGL
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 227247c..356e2f7 100644
--- a/wpf/MlnMapImage.cs
+++ b/wpf/MlnMapImage.cs
@@ -237,6 +237,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;
@@ -256,9 +259,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();
@@ -284,9 +288,13 @@ 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);
// UiScale multiplies only the style-unit pixel ratio (text/icon/circle/line
@@ -294,8 +302,13 @@ private void TryInitialize()
float pixelRatio = _dpi * (float)UiScale;
_runLoop = new MbglRunLoop();
- _frontend = new MbglFrontend(_interop.Hdc, _interop.GlContext, _physW, _physH, pixelRatio,
- () => _renderNeedsUpdate = true);
+ // Vulkan renders headless (no surface handle); OpenGL needs the WGL HDC + context.
+ // pixelRatio (= _dpi * UiScale) scales style-unit sizes; the surface dims above stay physical.
+ _frontend = _vulkan
+ ? new MbglFrontend(IntPtr.Zero, IntPtr.Zero, _physW, _physH, pixelRatio,
+ () => _renderNeedsUpdate = true)
+ : new MbglFrontend(_interop!.Hdc, _interop.GlContext, _physW, _physH, pixelRatio,
+ () => _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,
@@ -319,15 +332,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);
@@ -344,18 +356,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 ────────────────────────────────────────────────────────────