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 ────────────────────────────────────────────────────────────