Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e06b6a853 | |||
|
6cd23bf02a
|
|||
|
cbc8ef36f8
|
|||
|
7c7e91c857
|
|||
|
7c3b3a53f5
|
|||
|
0b8776b850
|
|||
|
e572558d5f
|
|||
|
5a60671bd6
|
|||
|
5aff0b64e8
|
|||
|
757a975d8c
|
|||
|
d146953bcc
|
|||
|
b617e2958d
|
|||
|
607a64ca8d
|
|||
|
e79d26ea68
|
|||
|
e3c74485f1
|
|||
|
ad21704bcc
|
|||
|
95391a05c6
|
|||
|
14ea45d49a
|
|||
|
0caa36d923
|
|||
|
25d26a87a4
|
|||
|
8f0dcd7f4d
|
|||
|
97f56c56ba
|
|||
|
70813154f2
|
|||
|
e40349b204
|
|||
|
61638e083b
|
|||
|
17c3038a36
|
|||
|
b473b100c8
|
|||
|
3c32215665
|
|||
|
f91378b884
|
|||
|
8f8fa426d5
|
|||
|
4d0cb594a7
|
|||
|
07e30cd040
|
|||
|
079e531166
|
|||
|
6a2adbe160
|
|||
|
b8933dd43a
|
|||
|
26efa13a01
|
|||
|
dcd5417af2
|
|||
|
b6b47ae6f6
|
|||
|
1eead8d597
|
|||
|
ce3e5ad813
|
|||
|
d0552922d9
|
|||
|
0de5118575
|
|||
|
0d139b1aae
|
|||
|
72f6c06eca
|
|||
|
0a78fc2460
|
|||
|
5c4fbff2af
|
|||
|
fd8b03c0bc
|
|||
|
0b0ed87650
|
|||
|
6a6393038e
|
|||
|
ec6645a21f
|
|||
|
d1b7b43dce
|
|||
|
adc6c6149c
|
|||
|
5950c62cec
|
|||
|
247f86aa09
|
|||
|
4d8068aef0
|
|||
|
53715d0d9c
|
|||
|
c8876d65eb
|
|||
|
0a2d1bcc32
|
|||
|
ba69bdb6ea
|
|||
|
5cc2c5819f
|
+98
-29
@@ -2,19 +2,21 @@ name: fparkan-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [devel, main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [devel, main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
msrv-backend-neutral:
|
||||
name: MSRV backend-neutral crates
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
|
||||
with:
|
||||
toolchain: 1.87.0
|
||||
- name: Test backend-neutral crates
|
||||
@@ -46,45 +48,112 @@ jobs:
|
||||
--locked
|
||||
|
||||
stage0-matrix:
|
||||
name: Stage 0-2 CI (${{ matrix.os }})
|
||||
name: Stage 0 CI (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
smoke_platform: linux
|
||||
- os: windows-latest
|
||||
smoke_platform: windows
|
||||
- os: macos-latest
|
||||
- os: macos-15
|
||||
smoke_platform: macos
|
||||
runner_arch: arm64
|
||||
moltenvk_version: 1.4.1
|
||||
vulkan_sdk_version: 1.4.350.1
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
|
||||
with:
|
||||
toolchain-file: rust-toolchain.toml
|
||||
toolchain: 1.87.0
|
||||
components: clippy,rustfmt
|
||||
- name: Provision macOS Vulkan runtime
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
brew install molten-vk vulkan-loader vulkan-tools vulkan-validationlayers
|
||||
test "$(uname -m)" = "${{ matrix.runner_arch }}"
|
||||
ruby <<'RUBY'
|
||||
require "json"
|
||||
|
||||
expected = {
|
||||
"molten-vk" => "${{ matrix.moltenvk_version }}",
|
||||
"vulkan-loader" => "${{ matrix.vulkan_sdk_version }}",
|
||||
"vulkan-tools" => "${{ matrix.vulkan_sdk_version }}",
|
||||
"vulkan-validationlayers" => "${{ matrix.vulkan_sdk_version }}",
|
||||
}
|
||||
payload = JSON.parse(`brew info --json=v2 #{expected.keys.join(" ")}`)
|
||||
actual = payload.fetch("formulae").to_h do |formula|
|
||||
[formula.fetch("name"), formula.fetch("versions").fetch("stable")]
|
||||
end
|
||||
mismatches = expected.each_with_object({}) do |(name, version), out|
|
||||
actual_version = actual[name]
|
||||
next if actual_version == version
|
||||
|
||||
out[name] = {
|
||||
"expected" => version,
|
||||
"actual" => actual_version,
|
||||
}
|
||||
end
|
||||
unless mismatches.empty?
|
||||
warn JSON.pretty_generate(mismatches)
|
||||
abort "unexpected macOS Vulkan formula version"
|
||||
end
|
||||
RUBY
|
||||
HOMEBREW_PREFIX="$(brew --prefix)"
|
||||
VK_ICD_FILENAMES="$HOMEBREW_PREFIX/opt/molten-vk/etc/vulkan/icd.d/MoltenVK_icd.json"
|
||||
VK_LAYER_PATH="$HOMEBREW_PREFIX/opt/vulkan-validationlayers/share/vulkan/explicit_layer.d"
|
||||
DYLD_FALLBACK_LIBRARY_PATH="$HOMEBREW_PREFIX/opt/vulkan-loader/lib:$HOMEBREW_PREFIX/opt/molten-vk/lib"
|
||||
test -f "$VK_ICD_FILENAMES"
|
||||
test -d "$VK_LAYER_PATH"
|
||||
echo "VK_ICD_FILENAMES=$VK_ICD_FILENAMES" >> "$GITHUB_ENV"
|
||||
echo "VK_LAYER_PATH=$VK_LAYER_PATH" >> "$GITHUB_ENV"
|
||||
echo "DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH" >> "$GITHUB_ENV"
|
||||
- name: Install cargo-deny
|
||||
run: cargo install cargo-deny --locked
|
||||
run: cargo install cargo-deny --version 0.19.9 --locked
|
||||
- name: Verify shader provenance
|
||||
run: cargo xtask shader-provenance
|
||||
- name: Run canonical CI gate
|
||||
run: cargo xtask ci
|
||||
- name: Record native Vulkan smoke status
|
||||
if: always()
|
||||
shell: bash
|
||||
- name: Run native Vulkan smoke
|
||||
run: >
|
||||
cargo run -p fparkan-vulkan-smoke --locked --
|
||||
--platform "${{ matrix.smoke_platform }}"
|
||||
--out "target/fparkan/native-smoke/${{ runner.os }}.json"
|
||||
--status blocked
|
||||
--probe-surface
|
||||
--reason "native Vulkan smoke runner is not enabled on this CI lane yet"
|
||||
- name: Upload acceptance evidence
|
||||
--out "target/fparkan/native-smoke/${{ matrix.smoke_platform }}.json"
|
||||
--timeout-seconds 120
|
||||
- name: Upload acceptance audit
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: stage-0-2-acceptance-${{ matrix.os }}
|
||||
path: |
|
||||
target/fparkan/acceptance/stage-0-2-audit.json
|
||||
target/fparkan/native-smoke/*.json
|
||||
if-no-files-found: ignore
|
||||
name: stage-0-acceptance-${{ matrix.os }}
|
||||
path: target/fparkan/acceptance/stage-0-audit.json
|
||||
if-no-files-found: error
|
||||
- name: Upload native smoke report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: native-smoke-${{ matrix.smoke_platform }}
|
||||
path: target/fparkan/native-smoke/*.json
|
||||
if-no-files-found: error
|
||||
|
||||
native-smoke-audit:
|
||||
name: Native smoke audit
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: stage0-matrix
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
|
||||
with:
|
||||
toolchain: 1.87.0
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
pattern: native-smoke-*
|
||||
path: target/fparkan/native-smoke-artifacts
|
||||
merge-multiple: true
|
||||
- name: Aggregate native smoke reports
|
||||
run: >
|
||||
cargo xtask native-smoke audit
|
||||
--dir target/fparkan/native-smoke-artifacts
|
||||
|
||||
Generated
+428
-13
@@ -106,6 +106,12 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -188,22 +194,38 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cargo-platform"
|
||||
version = "0.3.3"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
|
||||
checksum = "84982c6c0ae343635a3a4ee6dedef965513735c8b183caa7289fa6e27399ebd4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cargo-util-schemas"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dc1a6f7b5651af85774ae5a34b4e8be397d9cf4bc063b7e6dbd99a841837830"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde-untagged",
|
||||
"serde-value",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.8.23",
|
||||
"unicode-xid",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cargo_metadata"
|
||||
version = "0.23.1"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
|
||||
checksum = "5cfca2aaa699835ba88faf58a06342a314a950d2b9686165e038286c30316868"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"cargo-platform",
|
||||
"cargo-util-schemas",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -350,6 +372,17 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.3"
|
||||
@@ -386,6 +419,17 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "erased-serde"
|
||||
version = "0.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -439,6 +483,15 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fparkan-animation"
|
||||
version = "0.1.0"
|
||||
@@ -630,6 +683,8 @@ dependencies = [
|
||||
"fparkan-binary",
|
||||
"fparkan-platform",
|
||||
"fparkan-render",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -717,6 +772,9 @@ dependencies = [
|
||||
"fparkan-platform",
|
||||
"fparkan-platform-winit",
|
||||
"fparkan-render-vulkan",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -784,6 +842,109 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -919,6 +1080,12 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
@@ -989,6 +1156,15 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum"
|
||||
version = "0.7.6"
|
||||
@@ -1239,6 +1415,15 @@ dependencies = [
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owned_ttf_parser"
|
||||
version = "0.25.1"
|
||||
@@ -1306,13 +1491,22 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
"toml_edit 0.25.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1473,6 +1667,28 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-untagged"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
|
||||
dependencies = [
|
||||
"erased-serde",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-value"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
|
||||
dependencies = [
|
||||
"ordered-float",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
@@ -1506,6 +1722,15 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
@@ -1589,6 +1814,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strict-num"
|
||||
version = "0.1.1"
|
||||
@@ -1606,6 +1837,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -1671,6 +1913,28 @@ dependencies = [
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
@@ -1679,11 +1943,20 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1695,6 +1968,20 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.12+spec-1.1.0"
|
||||
@@ -1702,9 +1989,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1713,9 +2000,15 @@ version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
@@ -1744,6 +2037,12 @@ version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -1756,6 +2055,30 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -2123,6 +2446,15 @@ dependencies = [
|
||||
"xkbcommon-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.3"
|
||||
@@ -2138,6 +2470,12 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
@@ -2203,7 +2541,30 @@ dependencies = [
|
||||
"fparkan-corpus",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2226,6 +2587,60 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -63,6 +63,51 @@ FPARKAN_CORPORA_MANIFEST=/private/tmp/fparkan-corpora.toml \
|
||||
cargo xtask acceptance report --suite licensed --stage 5
|
||||
```
|
||||
|
||||
## Stage 0 Vulkan smoke
|
||||
|
||||
Локальный Stage 0 smoke запускает реальный `winit` lifecycle и Vulkan triangle path с включёнными validation layers. Успешный прогон обязан:
|
||||
|
||||
- отрисовать 300 кадров;
|
||||
- выполнить как минимум один реальный resize;
|
||||
- пересоздать swapchain после resize;
|
||||
- завершиться без validation warnings/errors.
|
||||
|
||||
Команда запуска:
|
||||
|
||||
```bash
|
||||
cargo run -p fparkan-vulkan-smoke --locked -- \
|
||||
--out target/fparkan/native-smoke/local.json \
|
||||
--timeout-seconds 120
|
||||
```
|
||||
|
||||
Перед запуском убедитесь, что на машине доступен Vulkan loader и рабочий ICD:
|
||||
|
||||
- macOS: используйте ту же схему, что и GitHub CI (`macos-15` arm64):
|
||||
|
||||
```bash
|
||||
brew install molten-vk vulkan-loader vulkan-tools vulkan-validationlayers
|
||||
export VK_ICD_FILENAMES="$(brew --prefix)/opt/molten-vk/etc/vulkan/icd.d/MoltenVK_icd.json"
|
||||
export VK_LAYER_PATH="$(brew --prefix)/opt/vulkan-validationlayers/share/vulkan/explicit_layer.d"
|
||||
export DYLD_FALLBACK_LIBRARY_PATH="$(brew --prefix)/opt/vulkan-loader/lib:$(brew --prefix)/opt/molten-vk/lib"
|
||||
vulkaninfo --summary
|
||||
```
|
||||
|
||||
Workflow fail-closed проверяет exact formula versions и ожидает наличие `VK_LAYER_KHRONOS_validation`.
|
||||
- Linux: установлен `libvulkan` и драйвер/ICD (`mesa-vulkan-drivers`, Lavapipe или vendor GPU stack); smoke нужно запускать из активной графической сессии X11/Wayland.
|
||||
- Windows: установлен Vulkan runtime от GPU vendor или LunarG Vulkan SDK; validation layer должен быть доступен из активного runtime.
|
||||
|
||||
Для полного локального closure gate используйте:
|
||||
|
||||
```bash
|
||||
cargo xtask ci
|
||||
```
|
||||
|
||||
В текущем macOS-only цикле GitHub workflow собирает только macOS report и проверяет его через `native-smoke audit`. Windows и Linux smoke stages сознательно не входят в этот closure:
|
||||
|
||||
```bash
|
||||
cargo xtask native-smoke audit --dir target/fparkan/native-smoke-artifacts
|
||||
```
|
||||
|
||||
## Contributing & Support
|
||||
|
||||
Проект активно поддерживается и открыт для contribution. Issues и pull requests можно создавать в обоих репозиториях:
|
||||
|
||||
@@ -27,15 +27,14 @@ use fparkan_platform::{
|
||||
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::dpi::PhysicalSize as WinitPhysicalSize;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
use winit::event::{Event, MouseButton, WindowEvent};
|
||||
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
||||
use winit::platform::scancode::PhysicalKeyExtScancode;
|
||||
use winit::window::{Window, WindowId};
|
||||
use winit::window::Window;
|
||||
|
||||
static NEXT_WINDOW_HANDLE_ID: AtomicU64 = AtomicU64::new(1);
|
||||
static CLOCK_START: OnceLock<Instant> = OnceLock::new();
|
||||
const DEFAULT_SMOKE_WIDTH: u32 = 1280;
|
||||
const DEFAULT_SMOKE_HEIGHT: u32 = 720;
|
||||
|
||||
@@ -49,10 +48,8 @@ pub struct WinitClock;
|
||||
|
||||
impl MonotonicClock for WinitClock {
|
||||
fn now(&self) -> MonotonicInstant {
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
MonotonicInstant(duration.as_millis().try_into().unwrap_or(u64::MAX))
|
||||
let elapsed = CLOCK_START.get_or_init(Instant::now).elapsed();
|
||||
MonotonicInstant(elapsed.as_millis().try_into().unwrap_or(u64::MAX))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +57,9 @@ impl MonotonicClock for WinitClock {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct WinitEventSource {
|
||||
queue: VecDeque<PlatformEvent>,
|
||||
cursor_position: Option<(f64, f64)>,
|
||||
minimized: Option<bool>,
|
||||
occluded: Option<bool>,
|
||||
}
|
||||
|
||||
impl WinitEventSource {
|
||||
@@ -68,6 +68,9 @@ impl WinitEventSource {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
queue: VecDeque::new(),
|
||||
cursor_position: None,
|
||||
minimized: None,
|
||||
occluded: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,20 +83,24 @@ impl WinitEventSource {
|
||||
pub fn push_window_event(&mut self, event: &WindowEvent) {
|
||||
match event {
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
if let Some(scancode) = event.physical_key.to_scancode() {
|
||||
self.queue.push_back(PlatformEvent::KeyboardInput {
|
||||
scancode: event.physical_key.to_scancode().unwrap_or(0),
|
||||
scancode,
|
||||
pressed: event.state.is_pressed(),
|
||||
});
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
let (x, y) = self.cursor_position.unwrap_or((0.0, 0.0));
|
||||
self.queue.push_back(PlatformEvent::MouseInput {
|
||||
button: mouse_button_code(*button),
|
||||
pressed: state.is_pressed(),
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
self.cursor_position = Some((position.x, position.y));
|
||||
self.queue.push_back(PlatformEvent::CursorMoved {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
@@ -104,11 +111,24 @@ impl WinitEventSource {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
});
|
||||
let minimized = size.width == 0 || size.height == 0;
|
||||
if self.minimized != Some(minimized) {
|
||||
self.minimized = Some(minimized);
|
||||
self.queue.push_back(PlatformEvent::Minimized { minimized });
|
||||
}
|
||||
}
|
||||
WindowEvent::Focused(focused) => {
|
||||
self.queue
|
||||
.push_back(PlatformEvent::FocusChanged { focused: *focused });
|
||||
}
|
||||
WindowEvent::Occluded(occluded) => {
|
||||
if self.occluded != Some(*occluded) {
|
||||
self.occluded = Some(*occluded);
|
||||
self.queue.push_back(PlatformEvent::Occluded {
|
||||
occluded: *occluded,
|
||||
});
|
||||
}
|
||||
}
|
||||
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
|
||||
self.queue.push_back(PlatformEvent::DpiChanged {
|
||||
scale: *scale_factor,
|
||||
@@ -123,8 +143,11 @@ impl WinitEventSource {
|
||||
|
||||
/// Pushes events from an event loop event.
|
||||
pub fn push_event<T>(&mut self, event: &Event<T>) {
|
||||
if let Event::WindowEvent { event, .. } = event {
|
||||
self.push_window_event(event);
|
||||
match event {
|
||||
Event::Resumed => self.queue.push_back(PlatformEvent::Resumed),
|
||||
Event::Suspended => self.queue.push_back(PlatformEvent::Suspended),
|
||||
Event::WindowEvent { event, .. } => self.push_window_event(event),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,7 +159,7 @@ fn mouse_button_code(button: MouseButton) -> u16 {
|
||||
MouseButton::Middle => 2,
|
||||
MouseButton::Back => 3,
|
||||
MouseButton::Forward => 4,
|
||||
MouseButton::Other(index) => 100 + index,
|
||||
MouseButton::Other(index) => 100_u16.saturating_add(index),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,113 +210,6 @@ impl WinitWindowPlan {
|
||||
}
|
||||
}
|
||||
|
||||
/// Native smoke window creation result.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct WinitSmokeWindowProbe {
|
||||
/// Validated creation plan.
|
||||
pub plan: WinitWindowPlan,
|
||||
/// Captured window descriptor.
|
||||
pub window: WinitWindow,
|
||||
}
|
||||
|
||||
impl WinitSmokeWindowProbe {
|
||||
/// Returns raw native handles captured from the native window.
|
||||
#[must_use]
|
||||
pub fn native_handles(&self) -> Option<NativeWindowHandles> {
|
||||
self.window.native_handles()
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a native smoke window, captures raw handles, then exits the event loop.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PlatformError`] when the plan is invalid, the event loop/window
|
||||
/// cannot be created, or raw native handles are unavailable.
|
||||
pub fn probe_smoke_window() -> Result<WinitSmokeWindowProbe, PlatformError> {
|
||||
let plan = WinitWindowPlan::smoke().validate()?;
|
||||
let event_loop = EventLoop::new().map_err(|err| PlatformError::Backend {
|
||||
context: "winit event loop",
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
let mut app = SmokeWindowApp::new(plan);
|
||||
event_loop
|
||||
.run_app(&mut app)
|
||||
.map_err(|err| PlatformError::Backend {
|
||||
context: "winit event loop",
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
app.into_probe()
|
||||
}
|
||||
|
||||
struct SmokeWindowApp {
|
||||
plan: WinitWindowPlan,
|
||||
window: Option<WinitWindow>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl SmokeWindowApp {
|
||||
const fn new(plan: WinitWindowPlan) -> Self {
|
||||
Self {
|
||||
plan,
|
||||
window: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_probe(self) -> Result<WinitSmokeWindowProbe, PlatformError> {
|
||||
if let Some(message) = self.error {
|
||||
return Err(PlatformError::Backend {
|
||||
context: "winit smoke window",
|
||||
message,
|
||||
});
|
||||
}
|
||||
let window = self.window.ok_or_else(|| PlatformError::Backend {
|
||||
context: "winit smoke window",
|
||||
message: "event loop exited before creating a window".to_string(),
|
||||
})?;
|
||||
if self.plan.requires_native_handles && window.native_handles().is_none() {
|
||||
return Err(PlatformError::Backend {
|
||||
context: "winit smoke window",
|
||||
message: "native window/display handles are unavailable".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(WinitSmokeWindowProbe {
|
||||
plan: self.plan,
|
||||
window,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for SmokeWindowApp {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() || self.error.is_some() {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
let attributes = Window::default_attributes()
|
||||
.with_title("FParkan Vulkan smoke")
|
||||
.with_inner_size(WinitPhysicalSize::new(self.plan.width, self.plan.height));
|
||||
match event_loop.create_window(attributes) {
|
||||
Ok(window) => {
|
||||
self.window = Some(WinitWindow::from_window(&window));
|
||||
}
|
||||
Err(err) => {
|
||||
self.error = Some(err.to_string());
|
||||
}
|
||||
}
|
||||
event_loop.exit();
|
||||
}
|
||||
|
||||
fn window_event(
|
||||
&mut self,
|
||||
_event_loop: &ActiveEventLoop,
|
||||
_window_id: WindowId,
|
||||
_event: WindowEvent,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal window view over a `winit` window.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct WinitWindow {
|
||||
@@ -323,7 +239,7 @@ impl WinitWindow {
|
||||
focused: true,
|
||||
minimized: false,
|
||||
occluded: false,
|
||||
native_handles: native_handles(window),
|
||||
native_handles: window_native_handles(window),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +265,62 @@ impl WinitWindow {
|
||||
pub const fn default_render_request() -> RenderRequest {
|
||||
RenderRequest::conservative()
|
||||
}
|
||||
|
||||
/// Applies one platform event to the cached window descriptor state.
|
||||
pub fn apply_event(&mut self, event: &PlatformEvent) {
|
||||
match event {
|
||||
PlatformEvent::Resize { width, height } => {
|
||||
self.width = *width;
|
||||
self.height = *height;
|
||||
}
|
||||
PlatformEvent::DpiChanged { scale } => {
|
||||
self.scale = *scale;
|
||||
}
|
||||
PlatformEvent::FocusChanged { focused } => {
|
||||
self.focused = *focused;
|
||||
}
|
||||
PlatformEvent::Minimized { minimized } => {
|
||||
self.minimized = *minimized;
|
||||
}
|
||||
PlatformEvent::Occluded { occluded } => {
|
||||
self.occluded = *occluded;
|
||||
}
|
||||
PlatformEvent::Suspended
|
||||
| PlatformEvent::Resumed
|
||||
| PlatformEvent::QuitRequested
|
||||
| PlatformEvent::KeyboardInput { .. }
|
||||
| PlatformEvent::MouseInput { .. }
|
||||
| PlatformEvent::CursorMoved { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a sequence of platform events to the cached window descriptor state.
|
||||
pub fn apply_events<'a>(&mut self, events: impl IntoIterator<Item = &'a PlatformEvent>) {
|
||||
for event in events {
|
||||
self.apply_event(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies one native `winit` window event to the cached window descriptor state.
|
||||
pub fn apply_window_event(&mut self, event: &WindowEvent) {
|
||||
match event {
|
||||
WindowEvent::Resized(size) => {
|
||||
self.width = size.width;
|
||||
self.height = size.height;
|
||||
self.minimized = size.width == 0 || size.height == 0;
|
||||
}
|
||||
WindowEvent::Focused(focused) => {
|
||||
self.focused = *focused;
|
||||
}
|
||||
WindowEvent::Occluded(occluded) => {
|
||||
self.occluded = *occluded;
|
||||
}
|
||||
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
|
||||
self.scale = *scale_factor;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WindowPort for WinitWindow {
|
||||
@@ -384,7 +356,9 @@ impl WindowPort for WinitWindow {
|
||||
}
|
||||
}
|
||||
|
||||
fn native_handles(window: &Window) -> Option<NativeWindowHandles> {
|
||||
/// Extracts raw handles from a live `winit::Window`.
|
||||
#[must_use]
|
||||
pub fn window_native_handles(window: &Window) -> Option<NativeWindowHandles> {
|
||||
let display = window.display_handle().ok()?.as_raw();
|
||||
let window = window.window_handle().ok()?.as_raw();
|
||||
Some(NativeWindowHandles { display, window })
|
||||
@@ -393,6 +367,7 @@ fn native_handles(window: &Window) -> Option<NativeWindowHandles> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use winit::event::{DeviceId, ElementState};
|
||||
|
||||
#[test]
|
||||
fn event_source_buffers_synthetic_events() -> Result<(), PlatformError> {
|
||||
@@ -454,33 +429,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_window_app_requires_created_native_window() {
|
||||
let app = SmokeWindowApp::new(WinitWindowPlan::smoke());
|
||||
fn monotonic_clock_uses_process_local_epoch() {
|
||||
let clock = WinitClock;
|
||||
let first = clock.now();
|
||||
let second = clock.now();
|
||||
|
||||
assert!(matches!(
|
||||
app.into_probe(),
|
||||
Err(PlatformError::Backend {
|
||||
context: "winit smoke window",
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_window_app_rejects_synthetic_window_without_native_handles() {
|
||||
let mut app = SmokeWindowApp::new(WinitWindowPlan::smoke());
|
||||
app.window = Some(WinitWindow::synthetic(
|
||||
DEFAULT_SMOKE_WIDTH,
|
||||
DEFAULT_SMOKE_HEIGHT,
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
app.into_probe(),
|
||||
Err(PlatformError::Backend {
|
||||
context: "winit smoke window",
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(second >= first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -504,6 +458,119 @@ mod tests {
|
||||
assert!(events.contains(&PlatformEvent::FocusChanged { focused: false }));
|
||||
assert!(events.contains(&PlatformEvent::QuitRequested));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_event_maps_lifecycle_resumed_and_suspended() -> Result<(), PlatformError> {
|
||||
let mut source = WinitEventSource::new();
|
||||
source.push_event(&Event::<()>::Resumed);
|
||||
source.push_event(&Event::<()>::Suspended);
|
||||
|
||||
let mut events = Vec::new();
|
||||
source.poll(&mut events)?;
|
||||
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![PlatformEvent::Resumed, PlatformEvent::Suspended]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_position_and_occlusion_are_preserved_for_mouse_input() -> Result<(), PlatformError> {
|
||||
let mut source = WinitEventSource::new();
|
||||
source.push_window_event(&WindowEvent::CursorMoved {
|
||||
device_id: DeviceId::dummy(),
|
||||
position: (320.0, 240.0).into(),
|
||||
});
|
||||
source.push_window_event(&WindowEvent::MouseInput {
|
||||
device_id: DeviceId::dummy(),
|
||||
state: ElementState::Pressed,
|
||||
button: MouseButton::Other(u16::MAX),
|
||||
});
|
||||
source.push_window_event(&WindowEvent::Occluded(true));
|
||||
|
||||
let mut events = Vec::new();
|
||||
source.poll(&mut events)?;
|
||||
|
||||
assert!(events.contains(&PlatformEvent::CursorMoved { x: 320.0, y: 240.0 }));
|
||||
assert!(events.contains(&PlatformEvent::MouseInput {
|
||||
button: u16::MAX,
|
||||
pressed: true,
|
||||
x: 320.0,
|
||||
y: 240.0,
|
||||
}));
|
||||
assert!(events.contains(&PlatformEvent::Occluded { occluded: true }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_extent_resize_updates_minimized_state() -> Result<(), PlatformError> {
|
||||
let mut source = WinitEventSource::new();
|
||||
source.push_window_event(&WindowEvent::Resized(winit::dpi::PhysicalSize::new(
|
||||
0u32, 720u32,
|
||||
)));
|
||||
source.push_window_event(&WindowEvent::Resized(winit::dpi::PhysicalSize::new(
|
||||
1280u32, 720u32,
|
||||
)));
|
||||
|
||||
let mut events = Vec::new();
|
||||
source.poll(&mut events)?;
|
||||
|
||||
assert!(events.contains(&PlatformEvent::Minimized { minimized: true }));
|
||||
assert!(events.contains(&PlatformEvent::Minimized { minimized: false }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_descriptor_applies_lifecycle_and_resize_events() {
|
||||
let mut window = WinitWindow::synthetic(640, 360);
|
||||
let events = [
|
||||
PlatformEvent::Resize {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
PlatformEvent::DpiChanged { scale: 2.0 },
|
||||
PlatformEvent::FocusChanged { focused: false },
|
||||
PlatformEvent::Minimized { minimized: true },
|
||||
PlatformEvent::Occluded { occluded: true },
|
||||
];
|
||||
|
||||
window.apply_events(events.iter());
|
||||
|
||||
assert_eq!(
|
||||
window.drawable_size(),
|
||||
PhysicalSize {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
}
|
||||
);
|
||||
assert_eq!(window.dpi_scale(), 2.0);
|
||||
assert!(!window.has_focus());
|
||||
assert!(window.is_minimized());
|
||||
assert!(window.is_occluded());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_descriptor_applies_native_window_events() {
|
||||
let mut window = WinitWindow::synthetic(640, 360);
|
||||
|
||||
window.apply_window_event(&WindowEvent::Resized(winit::dpi::PhysicalSize::new(
|
||||
0u32, 720u32,
|
||||
)));
|
||||
window.apply_window_event(&WindowEvent::Focused(false));
|
||||
window.apply_window_event(&WindowEvent::Occluded(true));
|
||||
|
||||
assert_eq!(
|
||||
window.drawable_size(),
|
||||
PhysicalSize {
|
||||
width: 0,
|
||||
height: 720,
|
||||
}
|
||||
);
|
||||
assert!(!window.has_focus());
|
||||
assert!(window.is_minimized());
|
||||
assert!(window.is_occluded());
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: no unsafe usage in this crate.
|
||||
|
||||
@@ -4,6 +4,7 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
ash = "0.38"
|
||||
@@ -11,6 +12,8 @@ ash-window = "0.13"
|
||||
fparkan-binary = { path = "../../crates/fparkan-binary" }
|
||||
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "allow"
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Build-time shader tool metadata for Vulkan reports.
|
||||
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const SHADER_COMPILER_NAME: &str = "glslangValidator";
|
||||
const SPIRV_VALIDATOR_NAME: &str = "spirv-val";
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=PATH");
|
||||
println!("cargo:rerun-if-env-changed=FPARKAN_GLSLANG_VALIDATOR");
|
||||
println!("cargo:rerun-if-env-changed=FPARKAN_SPIRV_VAL");
|
||||
|
||||
emit_tool_metadata(
|
||||
"FPARKAN_BUILD_SHADER_COMPILER",
|
||||
&tool_path("FPARKAN_GLSLANG_VALIDATOR", SHADER_COMPILER_NAME),
|
||||
);
|
||||
emit_tool_metadata(
|
||||
"FPARKAN_BUILD_SPIRV_VALIDATOR",
|
||||
&tool_path("FPARKAN_SPIRV_VAL", SPIRV_VALIDATOR_NAME),
|
||||
);
|
||||
}
|
||||
|
||||
fn tool_path(env_var: &str, fallback: &str) -> String {
|
||||
env::var(env_var).unwrap_or_else(|_| fallback.to_string())
|
||||
}
|
||||
|
||||
fn emit_tool_metadata(prefix: &str, tool: &str) {
|
||||
let Some(path) = resolve_tool(tool) else {
|
||||
return;
|
||||
};
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
let Some(version) = tool_version(&path) else {
|
||||
return;
|
||||
};
|
||||
let Some(binary_sha256) = tool_sha256(&path) else {
|
||||
return;
|
||||
};
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or(tool)
|
||||
.to_string();
|
||||
println!("cargo:rustc-env={prefix}_NAME={name}");
|
||||
println!("cargo:rustc-env={prefix}_VERSION={version}");
|
||||
println!("cargo:rustc-env={prefix}_SHA256={binary_sha256}");
|
||||
}
|
||||
|
||||
fn resolve_tool(tool: &str) -> Option<std::path::PathBuf> {
|
||||
let candidate = Path::new(tool);
|
||||
if candidate.components().count() > 1 {
|
||||
return candidate.is_file().then(|| candidate.to_path_buf());
|
||||
}
|
||||
let output = Command::new("which").arg(tool).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let path = String::from_utf8(output.stdout).ok()?;
|
||||
let path = path.trim();
|
||||
(!path.is_empty()).then(|| path.into())
|
||||
}
|
||||
|
||||
fn tool_version(path: &Path) -> Option<String> {
|
||||
let output = Command::new(path).arg("--version").output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8(output.stdout).ok()?;
|
||||
stdout
|
||||
.lines()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
.map(str::trim)
|
||||
.map(|line| {
|
||||
line.strip_prefix("Glslang Version: ")
|
||||
.unwrap_or(line)
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_sha256(path: &Path) -> Option<String> {
|
||||
let output = Command::new("shasum")
|
||||
.args(["-a", "256"])
|
||||
.arg(path)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8(output.stdout).ok()?;
|
||||
stdout.split_whitespace().next().map(ToString::to_string)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"schema":2,"target_env":"vulkan1.1","compiler":{"name":"glslangValidator","version":"11:16.3.0","binary_sha256":"9bcd69d830b350aaa6e2254915ff74e46070e217b67f38daad27c1fc1f22910f"},"validator":{"name":"spirv-val","version":"SPIRV-Tools v2026.2 unknown hash, 2026-04-29T17:02:58+00:00","binary_sha256":"f6d5b96ff19f073f3af0c0bcfa0c18702d288d3ec598efc242d01cd104d8354f"},"modules":[{"name":"triangle.vert","stage":"vertex","entry_point":"main","source_path":"adapters/fparkan-render-vulkan/shaders/triangle.vert","source_sha256":"1e57f14d193fc61457c0749081c452ad25669998913107df12f3ccc3c33e0341","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","word_count":253,"sha256":"4d3ceca7b42ebc971d831b0a0d816457397bd9aeda47fb8d44c4b1aeaa5e7ba0","descriptor_sets":0,"push_constant_bytes":0,"compile_command":"glslangValidator -V --target-env vulkan1.1 -S vert -e main adapters/fparkan-render-vulkan/shaders/triangle.vert -o adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","validate_command":"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","interface_hash":"23e1d3d9d32e7f7ec0b9ca87f8b86be8f8363c7eb5d745fc5a157cb8433eb138"},{"name":"triangle.frag","stage":"fragment","entry_point":"main","source_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag","source_sha256":"f19e74d001d07fb537d4b0f9e621f9b8bc40eeb68816130220853abea6bd4445","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","word_count":125,"sha256":"5a7441be03cd3c25d557268b2e58d5aa50504c87bffcb4c3fd7cbcf007db0b96","descriptor_sets":0,"push_constant_bytes":0,"compile_command":"glslangValidator -V --target-env vulkan1.1 -S frag -e main adapters/fparkan-render-vulkan/shaders/triangle.frag -o adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","validate_command":"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","interface_hash":"f09342c22d58c8768151ab8579e54e49af586434a4005d16a24e816d881a64f0"}],"manifest_hash":"11e3feb65200ebd2ac87b7e776e9c6433a5da9d71a651bfadea89a51be17ff05"}
|
||||
@@ -0,0 +1,8 @@
|
||||
#version 450
|
||||
|
||||
layout(location = 0) in vec3 in_color;
|
||||
layout(location = 0) out vec4 out_color;
|
||||
|
||||
void main() {
|
||||
out_color = vec4(in_color, 1.0);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
#version 450
|
||||
|
||||
layout(location = 0) in vec2 in_position;
|
||||
layout(location = 1) in vec3 in_color;
|
||||
|
||||
layout(location = 0) out vec3 out_color;
|
||||
|
||||
void main() {
|
||||
out_color = in_color;
|
||||
gl_Position = vec4(in_position, 0.0, 1.0);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,472 @@
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
//! Vulkan adapter facade and migration-ready backend surface contract.
|
||||
//!
|
||||
//! This module intentionally keeps backend-agnostic command validation in the
|
||||
//! shared render crate while exposing deterministic lifecycle telemetry used by
|
||||
//! Stage 0 acceptance evidence.
|
||||
//!
|
||||
//! This crate is the declared low-level Vulkan boundary.
|
||||
|
||||
mod capabilities;
|
||||
mod instance;
|
||||
mod resources;
|
||||
mod runtime;
|
||||
mod smoke;
|
||||
mod smoke_types;
|
||||
mod surface;
|
||||
mod swapchain;
|
||||
mod swapchain_resources;
|
||||
mod validation;
|
||||
|
||||
pub use self::capabilities::{
|
||||
probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request,
|
||||
VulkanRuntimeCapabilityError, VulkanRuntimeCapabilityProbe,
|
||||
};
|
||||
pub use self::instance::{
|
||||
create_vulkan_instance_probe, plan_vulkan_instance, probe_vulkan_loader,
|
||||
render_instance_plan_json, render_loader_probe_report_json, vulkan_entry_symbol_name,
|
||||
VulkanInstanceConfig, VulkanInstanceError, VulkanInstancePlan, VulkanInstanceProbe,
|
||||
VulkanLoaderError, VulkanLoaderProbeReport,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use self::instance::{cstring_vec, ensure_instance_extensions_available};
|
||||
use self::resources::{
|
||||
color_subresource_range, create_command_pool, create_frame_sync, create_triangle_index_buffer,
|
||||
create_triangle_vertex_buffer, destroy_allocated_buffer, VulkanAllocatedBuffer,
|
||||
VulkanFrameSync,
|
||||
};
|
||||
pub use self::runtime::{
|
||||
create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request,
|
||||
VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanLogicalDeviceReport,
|
||||
};
|
||||
pub use self::smoke_types::{
|
||||
VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot, VulkanSmokeFrameOutcome,
|
||||
VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError,
|
||||
VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanValidationReport,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use self::surface::extension_name;
|
||||
pub use self::surface::{
|
||||
create_vulkan_surface_probe, plan_vulkan_surface, render_surface_plan_json, VulkanSurfaceError,
|
||||
VulkanSurfacePlan, VulkanSurfaceProbe,
|
||||
};
|
||||
pub use self::swapchain::{
|
||||
create_vulkan_swapchain_probe, create_vulkan_swapchain_probe_for_extent, VulkanSwapchainProbe,
|
||||
VulkanSwapchainProbeError, VulkanSwapchainReport,
|
||||
};
|
||||
use self::swapchain_resources::{
|
||||
create_swapchain_resources, destroy_swapchain_resources, VulkanSwapchainResources,
|
||||
};
|
||||
use self::validation::{create_validation_messenger, VulkanValidationMessenger};
|
||||
use ash::vk;
|
||||
/// Minimum Vulkan API version accepted by the Stage 0 backend.
|
||||
pub const MIN_VULKAN_API_VERSION: u32 = vk::API_VERSION_1_1;
|
||||
const KHR_PORTABILITY_ENUMERATION_EXTENSION: &str = "VK_KHR_portability_enumeration";
|
||||
const EXT_DEBUG_UTILS_EXTENSION: &str = "VK_EXT_debug_utils";
|
||||
const VALIDATION_LAYER_NAME: &str = "VK_LAYER_KHRONOS_validation";
|
||||
pub(crate) const SPIRV_MAGIC: u32 = 0x0723_0203;
|
||||
pub(crate) const SPIRV_VERSION_1_0: u32 = 0x0001_0000;
|
||||
pub(crate) const TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &[
|
||||
0x0723_0203,
|
||||
0x0001_0300,
|
||||
0x0008_000b,
|
||||
0x0000_0021,
|
||||
0x0000_0000,
|
||||
0x0002_0011,
|
||||
0x0000_0001,
|
||||
0x0006_000b,
|
||||
0x0000_0001,
|
||||
0x4c53_4c47,
|
||||
0x6474_732e,
|
||||
0x3035_342e,
|
||||
0x0000_0000,
|
||||
0x0003_000e,
|
||||
0x0000_0000,
|
||||
0x0000_0001,
|
||||
0x0009_000f,
|
||||
0x0000_0000,
|
||||
0x0000_0004,
|
||||
0x6e69_616d,
|
||||
0x0000_0000,
|
||||
0x0000_0009,
|
||||
0x0000_000b,
|
||||
0x0000_0013,
|
||||
0x0000_0018,
|
||||
0x0003_0003,
|
||||
0x0000_0002,
|
||||
0x0000_01c2,
|
||||
0x0004_0005,
|
||||
0x0000_0004,
|
||||
0x6e69_616d,
|
||||
0x0000_0000,
|
||||
0x0005_0005,
|
||||
0x0000_0009,
|
||||
0x5f74_756f,
|
||||
0x6f6c_6f63,
|
||||
0x0000_0072,
|
||||
0x0005_0005,
|
||||
0x0000_000b,
|
||||
0x635f_6e69,
|
||||
0x726f_6c6f,
|
||||
0x0000_0000,
|
||||
0x0006_0005,
|
||||
0x0000_0011,
|
||||
0x505f_6c67,
|
||||
0x6556_7265,
|
||||
0x7865_7472,
|
||||
0x0000_0000,
|
||||
0x0006_0006,
|
||||
0x0000_0011,
|
||||
0x0000_0000,
|
||||
0x505f_6c67,
|
||||
0x7469_736f,
|
||||
0x006e_6f69,
|
||||
0x0007_0006,
|
||||
0x0000_0011,
|
||||
0x0000_0001,
|
||||
0x505f_6c67,
|
||||
0x746e_696f,
|
||||
0x657a_6953,
|
||||
0x0000_0000,
|
||||
0x0007_0006,
|
||||
0x0000_0011,
|
||||
0x0000_0002,
|
||||
0x435f_6c67,
|
||||
0x4470_696c,
|
||||
0x6174_7369,
|
||||
0x0065_636e,
|
||||
0x0007_0006,
|
||||
0x0000_0011,
|
||||
0x0000_0003,
|
||||
0x435f_6c67,
|
||||
0x446c_6c75,
|
||||
0x6174_7369,
|
||||
0x0065_636e,
|
||||
0x0003_0005,
|
||||
0x0000_0013,
|
||||
0x0000_0000,
|
||||
0x0005_0005,
|
||||
0x0000_0018,
|
||||
0x705f_6e69,
|
||||
0x7469_736f,
|
||||
0x006e_6f69,
|
||||
0x0004_0047,
|
||||
0x0000_0009,
|
||||
0x0000_001e,
|
||||
0x0000_0000,
|
||||
0x0004_0047,
|
||||
0x0000_000b,
|
||||
0x0000_001e,
|
||||
0x0000_0001,
|
||||
0x0003_0047,
|
||||
0x0000_0011,
|
||||
0x0000_0002,
|
||||
0x0005_0048,
|
||||
0x0000_0011,
|
||||
0x0000_0000,
|
||||
0x0000_000b,
|
||||
0x0000_0000,
|
||||
0x0005_0048,
|
||||
0x0000_0011,
|
||||
0x0000_0001,
|
||||
0x0000_000b,
|
||||
0x0000_0001,
|
||||
0x0005_0048,
|
||||
0x0000_0011,
|
||||
0x0000_0002,
|
||||
0x0000_000b,
|
||||
0x0000_0003,
|
||||
0x0005_0048,
|
||||
0x0000_0011,
|
||||
0x0000_0003,
|
||||
0x0000_000b,
|
||||
0x0000_0004,
|
||||
0x0004_0047,
|
||||
0x0000_0018,
|
||||
0x0000_001e,
|
||||
0x0000_0000,
|
||||
0x0002_0013,
|
||||
0x0000_0002,
|
||||
0x0003_0021,
|
||||
0x0000_0003,
|
||||
0x0000_0002,
|
||||
0x0003_0016,
|
||||
0x0000_0006,
|
||||
0x0000_0020,
|
||||
0x0004_0017,
|
||||
0x0000_0007,
|
||||
0x0000_0006,
|
||||
0x0000_0003,
|
||||
0x0004_0020,
|
||||
0x0000_0008,
|
||||
0x0000_0003,
|
||||
0x0000_0007,
|
||||
0x0004_003b,
|
||||
0x0000_0008,
|
||||
0x0000_0009,
|
||||
0x0000_0003,
|
||||
0x0004_0020,
|
||||
0x0000_000a,
|
||||
0x0000_0001,
|
||||
0x0000_0007,
|
||||
0x0004_003b,
|
||||
0x0000_000a,
|
||||
0x0000_000b,
|
||||
0x0000_0001,
|
||||
0x0004_0017,
|
||||
0x0000_000d,
|
||||
0x0000_0006,
|
||||
0x0000_0004,
|
||||
0x0004_0015,
|
||||
0x0000_000e,
|
||||
0x0000_0020,
|
||||
0x0000_0000,
|
||||
0x0004_002b,
|
||||
0x0000_000e,
|
||||
0x0000_000f,
|
||||
0x0000_0001,
|
||||
0x0004_001c,
|
||||
0x0000_0010,
|
||||
0x0000_0006,
|
||||
0x0000_000f,
|
||||
0x0006_001e,
|
||||
0x0000_0011,
|
||||
0x0000_000d,
|
||||
0x0000_0006,
|
||||
0x0000_0010,
|
||||
0x0000_0010,
|
||||
0x0004_0020,
|
||||
0x0000_0012,
|
||||
0x0000_0003,
|
||||
0x0000_0011,
|
||||
0x0004_003b,
|
||||
0x0000_0012,
|
||||
0x0000_0013,
|
||||
0x0000_0003,
|
||||
0x0004_0015,
|
||||
0x0000_0014,
|
||||
0x0000_0020,
|
||||
0x0000_0001,
|
||||
0x0004_002b,
|
||||
0x0000_0014,
|
||||
0x0000_0015,
|
||||
0x0000_0000,
|
||||
0x0004_0017,
|
||||
0x0000_0016,
|
||||
0x0000_0006,
|
||||
0x0000_0002,
|
||||
0x0004_0020,
|
||||
0x0000_0017,
|
||||
0x0000_0001,
|
||||
0x0000_0016,
|
||||
0x0004_003b,
|
||||
0x0000_0017,
|
||||
0x0000_0018,
|
||||
0x0000_0001,
|
||||
0x0004_002b,
|
||||
0x0000_0006,
|
||||
0x0000_001a,
|
||||
0x0000_0000,
|
||||
0x0004_002b,
|
||||
0x0000_0006,
|
||||
0x0000_001b,
|
||||
0x3f80_0000,
|
||||
0x0004_0020,
|
||||
0x0000_001f,
|
||||
0x0000_0003,
|
||||
0x0000_000d,
|
||||
0x0005_0036,
|
||||
0x0000_0002,
|
||||
0x0000_0004,
|
||||
0x0000_0000,
|
||||
0x0000_0003,
|
||||
0x0002_00f8,
|
||||
0x0000_0005,
|
||||
0x0004_003d,
|
||||
0x0000_0007,
|
||||
0x0000_000c,
|
||||
0x0000_000b,
|
||||
0x0003_003e,
|
||||
0x0000_0009,
|
||||
0x0000_000c,
|
||||
0x0004_003d,
|
||||
0x0000_0016,
|
||||
0x0000_0019,
|
||||
0x0000_0018,
|
||||
0x0005_0051,
|
||||
0x0000_0006,
|
||||
0x0000_001c,
|
||||
0x0000_0019,
|
||||
0x0000_0000,
|
||||
0x0005_0051,
|
||||
0x0000_0006,
|
||||
0x0000_001d,
|
||||
0x0000_0019,
|
||||
0x0000_0001,
|
||||
0x0007_0050,
|
||||
0x0000_000d,
|
||||
0x0000_001e,
|
||||
0x0000_001c,
|
||||
0x0000_001d,
|
||||
0x0000_001a,
|
||||
0x0000_001b,
|
||||
0x0005_0041,
|
||||
0x0000_001f,
|
||||
0x0000_0020,
|
||||
0x0000_0013,
|
||||
0x0000_0015,
|
||||
0x0003_003e,
|
||||
0x0000_0020,
|
||||
0x0000_001e,
|
||||
0x0001_00fd,
|
||||
0x0001_0038,
|
||||
];
|
||||
pub(crate) const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[
|
||||
0x0723_0203,
|
||||
0x0001_0300,
|
||||
0x0008_000b,
|
||||
0x0000_0013,
|
||||
0x0000_0000,
|
||||
0x0002_0011,
|
||||
0x0000_0001,
|
||||
0x0006_000b,
|
||||
0x0000_0001,
|
||||
0x4c53_4c47,
|
||||
0x6474_732e,
|
||||
0x3035_342e,
|
||||
0x0000_0000,
|
||||
0x0003_000e,
|
||||
0x0000_0000,
|
||||
0x0000_0001,
|
||||
0x0007_000f,
|
||||
0x0000_0004,
|
||||
0x0000_0004,
|
||||
0x6e69_616d,
|
||||
0x0000_0000,
|
||||
0x0000_0009,
|
||||
0x0000_000c,
|
||||
0x0003_0010,
|
||||
0x0000_0004,
|
||||
0x0000_0007,
|
||||
0x0003_0003,
|
||||
0x0000_0002,
|
||||
0x0000_01c2,
|
||||
0x0004_0005,
|
||||
0x0000_0004,
|
||||
0x6e69_616d,
|
||||
0x0000_0000,
|
||||
0x0005_0005,
|
||||
0x0000_0009,
|
||||
0x5f74_756f,
|
||||
0x6f6c_6f63,
|
||||
0x0000_0072,
|
||||
0x0005_0005,
|
||||
0x0000_000c,
|
||||
0x635f_6e69,
|
||||
0x726f_6c6f,
|
||||
0x0000_0000,
|
||||
0x0004_0047,
|
||||
0x0000_0009,
|
||||
0x0000_001e,
|
||||
0x0000_0000,
|
||||
0x0004_0047,
|
||||
0x0000_000c,
|
||||
0x0000_001e,
|
||||
0x0000_0000,
|
||||
0x0002_0013,
|
||||
0x0000_0002,
|
||||
0x0003_0021,
|
||||
0x0000_0003,
|
||||
0x0000_0002,
|
||||
0x0003_0016,
|
||||
0x0000_0006,
|
||||
0x0000_0020,
|
||||
0x0004_0017,
|
||||
0x0000_0007,
|
||||
0x0000_0006,
|
||||
0x0000_0004,
|
||||
0x0004_0020,
|
||||
0x0000_0008,
|
||||
0x0000_0003,
|
||||
0x0000_0007,
|
||||
0x0004_003b,
|
||||
0x0000_0008,
|
||||
0x0000_0009,
|
||||
0x0000_0003,
|
||||
0x0004_0017,
|
||||
0x0000_000a,
|
||||
0x0000_0006,
|
||||
0x0000_0003,
|
||||
0x0004_0020,
|
||||
0x0000_000b,
|
||||
0x0000_0001,
|
||||
0x0000_000a,
|
||||
0x0004_003b,
|
||||
0x0000_000b,
|
||||
0x0000_000c,
|
||||
0x0000_0001,
|
||||
0x0004_002b,
|
||||
0x0000_0006,
|
||||
0x0000_000e,
|
||||
0x3f80_0000,
|
||||
0x0005_0036,
|
||||
0x0000_0002,
|
||||
0x0000_0004,
|
||||
0x0000_0000,
|
||||
0x0000_0003,
|
||||
0x0002_00f8,
|
||||
0x0000_0005,
|
||||
0x0004_003d,
|
||||
0x0000_000a,
|
||||
0x0000_000d,
|
||||
0x0000_000c,
|
||||
0x0005_0051,
|
||||
0x0000_0006,
|
||||
0x0000_000f,
|
||||
0x0000_000d,
|
||||
0x0000_0000,
|
||||
0x0005_0051,
|
||||
0x0000_0006,
|
||||
0x0000_0010,
|
||||
0x0000_000d,
|
||||
0x0000_0001,
|
||||
0x0005_0051,
|
||||
0x0000_0006,
|
||||
0x0000_0011,
|
||||
0x0000_000d,
|
||||
0x0000_0002,
|
||||
0x0007_0050,
|
||||
0x0000_0007,
|
||||
0x0000_0012,
|
||||
0x0000_000f,
|
||||
0x0000_0010,
|
||||
0x0000_0011,
|
||||
0x0000_000e,
|
||||
0x0003_003e,
|
||||
0x0000_0009,
|
||||
0x0000_0012,
|
||||
0x0001_00fd,
|
||||
0x0001_0038,
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,508 @@
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use ash::vk;
|
||||
use fparkan_platform::RenderRequest;
|
||||
use std::ffi::CStr;
|
||||
|
||||
use super::{VulkanInstanceProbe, VulkanSurfaceProbe};
|
||||
use crate::policy::{
|
||||
compare_reports, plan_vulkan_swapchain, validate_device_for_request, VulkanCapabilityError,
|
||||
VulkanCapabilityReport, VulkanDeviceLimits, VulkanDeviceType, VulkanPhysicalDeviceRecord,
|
||||
VulkanQueueFamily, VulkanSurfaceFormat, VulkanSwapchainError, VulkanSwapchainPlan,
|
||||
VulkanSwapchainRequest, VulkanSwapchainSurfaceCapabilities,
|
||||
};
|
||||
|
||||
/// Live Vulkan device/surface capability probe.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanRuntimeCapabilityProbe {
|
||||
/// Selected device/queue capability report.
|
||||
pub capability: VulkanCapabilityReport,
|
||||
/// Swapchain plan built from the selected device and live surface capabilities.
|
||||
pub swapchain: VulkanSwapchainPlan,
|
||||
}
|
||||
|
||||
/// Live Vulkan device/surface capability probe error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum VulkanRuntimeCapabilityError {
|
||||
/// Physical device enumeration failed.
|
||||
EnumerateDevicesFailed {
|
||||
/// Vulkan result.
|
||||
result: vk::Result,
|
||||
},
|
||||
/// Device extension enumeration failed.
|
||||
EnumerateDeviceExtensionsFailed {
|
||||
/// Device name or index context.
|
||||
device: String,
|
||||
/// Vulkan result.
|
||||
result: vk::Result,
|
||||
},
|
||||
/// Queue-family present support query failed.
|
||||
PresentSupportFailed {
|
||||
/// Device name.
|
||||
device: String,
|
||||
/// Queue-family index.
|
||||
queue_family: u32,
|
||||
/// Vulkan result.
|
||||
result: vk::Result,
|
||||
},
|
||||
/// Surface format query failed.
|
||||
SurfaceFormatsFailed {
|
||||
/// Device name.
|
||||
device: String,
|
||||
/// Vulkan result.
|
||||
result: vk::Result,
|
||||
},
|
||||
/// Surface capability query failed.
|
||||
SurfaceCapabilitiesFailed {
|
||||
/// Device name.
|
||||
device: String,
|
||||
/// Vulkan result.
|
||||
result: vk::Result,
|
||||
},
|
||||
/// Present mode query failed.
|
||||
PresentModesFailed {
|
||||
/// Device name.
|
||||
device: String,
|
||||
/// Vulkan result.
|
||||
result: vk::Result,
|
||||
},
|
||||
/// No device satisfied Stage 0 capability policy.
|
||||
Capability(VulkanCapabilityError),
|
||||
/// Live surface capabilities could not produce a swapchain plan.
|
||||
Swapchain(VulkanSwapchainError),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VulkanRuntimeCapabilityError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::EnumerateDevicesFailed { result } => {
|
||||
write!(f, "Vulkan physical device enumeration failed: {result:?}")
|
||||
}
|
||||
Self::EnumerateDeviceExtensionsFailed { device, result } => write!(
|
||||
f,
|
||||
"Vulkan device {device} extension enumeration failed: {result:?}"
|
||||
),
|
||||
Self::PresentSupportFailed {
|
||||
device,
|
||||
queue_family,
|
||||
result,
|
||||
} => write!(
|
||||
f,
|
||||
"Vulkan device {device} queue family {queue_family} present support query failed: {result:?}"
|
||||
),
|
||||
Self::SurfaceFormatsFailed { device, result } => write!(
|
||||
f,
|
||||
"Vulkan device {device} surface format query failed: {result:?}"
|
||||
),
|
||||
Self::SurfaceCapabilitiesFailed { device, result } => write!(
|
||||
f,
|
||||
"Vulkan device {device} surface capabilities query failed: {result:?}"
|
||||
),
|
||||
Self::PresentModesFailed { device, result } => write!(
|
||||
f,
|
||||
"Vulkan device {device} present mode query failed: {result:?}"
|
||||
),
|
||||
Self::Capability(error) => write!(f, "{error}"),
|
||||
Self::Swapchain(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VulkanRuntimeCapabilityError {}
|
||||
|
||||
pub(super) struct SelectedLiveDevice {
|
||||
pub(super) physical_device: vk::PhysicalDevice,
|
||||
pub(super) runtime: VulkanRuntimeCapabilityProbe,
|
||||
}
|
||||
|
||||
struct LiveDeviceCandidate {
|
||||
physical_device: vk::PhysicalDevice,
|
||||
capability: VulkanCapabilityReport,
|
||||
surface_formats: Vec<VulkanSurfaceFormat>,
|
||||
present_modes: Vec<i32>,
|
||||
surface_capabilities: VulkanSwapchainSurfaceCapabilities,
|
||||
}
|
||||
|
||||
/// Probes live Vulkan device, queue, surface and swapchain capabilities.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanRuntimeCapabilityError`] when device enumeration, surface
|
||||
/// capability queries, Stage 0 device selection, or swapchain planning fails.
|
||||
pub fn probe_vulkan_runtime_capabilities(
|
||||
instance: &VulkanInstanceProbe,
|
||||
surface: &VulkanSurfaceProbe,
|
||||
drawable_extent: (u32, u32),
|
||||
) -> Result<VulkanRuntimeCapabilityProbe, VulkanRuntimeCapabilityError> {
|
||||
let selected = select_live_device_candidate_for_request(
|
||||
instance,
|
||||
surface,
|
||||
drawable_extent,
|
||||
RenderRequest::conservative(),
|
||||
)?;
|
||||
Ok(selected.runtime)
|
||||
}
|
||||
|
||||
/// Probes live Vulkan device, queue, surface and swapchain capabilities for a
|
||||
/// specific Stage 0 render request.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanRuntimeCapabilityError`] when device enumeration, surface
|
||||
/// capability queries, Stage 0 device selection, or swapchain planning fails.
|
||||
pub fn probe_vulkan_runtime_capabilities_for_request(
|
||||
instance: &VulkanInstanceProbe,
|
||||
surface: &VulkanSurfaceProbe,
|
||||
drawable_extent: (u32, u32),
|
||||
render_request: RenderRequest,
|
||||
) -> Result<VulkanRuntimeCapabilityProbe, VulkanRuntimeCapabilityError> {
|
||||
let selected = select_live_device_candidate_for_request(
|
||||
instance,
|
||||
surface,
|
||||
drawable_extent,
|
||||
render_request,
|
||||
)?;
|
||||
Ok(selected.runtime)
|
||||
}
|
||||
|
||||
pub(super) fn select_live_device_candidate_for_request(
|
||||
instance: &VulkanInstanceProbe,
|
||||
surface: &VulkanSurfaceProbe,
|
||||
drawable_extent: (u32, u32),
|
||||
render_request: RenderRequest,
|
||||
) -> Result<SelectedLiveDevice, VulkanRuntimeCapabilityError> {
|
||||
let devices = {
|
||||
// SAFETY: The Vulkan instance is live for this query and no handles are retained.
|
||||
unsafe { instance.instance.enumerate_physical_devices() }.map_err(|error| {
|
||||
VulkanRuntimeCapabilityError::EnumerateDevicesFailed { result: error }
|
||||
})?
|
||||
};
|
||||
let mut best: Option<LiveDeviceCandidate> = None;
|
||||
let mut last_error = None;
|
||||
for (index, device) in devices.iter().copied().enumerate() {
|
||||
let candidate =
|
||||
match live_device_candidate(instance, surface, device, index, render_request) {
|
||||
Ok(candidate) => candidate,
|
||||
Err(err) => {
|
||||
last_error = Some(err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match &best {
|
||||
Some(existing)
|
||||
if compare_reports(&candidate.capability, &existing.capability)
|
||||
!= std::cmp::Ordering::Greater => {}
|
||||
_ => best = Some(candidate),
|
||||
}
|
||||
}
|
||||
let best = best.ok_or_else(|| {
|
||||
last_error.unwrap_or(VulkanRuntimeCapabilityError::Capability(
|
||||
VulkanCapabilityError::NoPhysicalDevice,
|
||||
))
|
||||
})?;
|
||||
let swapchain = plan_vulkan_swapchain(&VulkanSwapchainRequest {
|
||||
drawable_extent,
|
||||
formats: best.surface_formats,
|
||||
present_modes: best.present_modes,
|
||||
capabilities: best.surface_capabilities,
|
||||
preferred_present_mode: vk::PresentModeKHR::MAILBOX.as_raw(),
|
||||
})
|
||||
.map_err(VulkanRuntimeCapabilityError::Swapchain)?;
|
||||
Ok(SelectedLiveDevice {
|
||||
physical_device: best.physical_device,
|
||||
runtime: VulkanRuntimeCapabilityProbe {
|
||||
capability: best.capability,
|
||||
swapchain,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn live_device_candidate(
|
||||
instance: &VulkanInstanceProbe,
|
||||
surface: &VulkanSurfaceProbe,
|
||||
device: vk::PhysicalDevice,
|
||||
index: usize,
|
||||
render_request: RenderRequest,
|
||||
) -> Result<LiveDeviceCandidate, VulkanRuntimeCapabilityError> {
|
||||
let properties = {
|
||||
// SAFETY: `device` was returned by this live instance and the result is copied by value.
|
||||
unsafe { instance.instance.get_physical_device_properties(device) }
|
||||
};
|
||||
let name = physical_device_name(&properties, index);
|
||||
let queue_properties = {
|
||||
// SAFETY: `device` was returned by this live instance and the result is owned by Rust.
|
||||
unsafe {
|
||||
instance
|
||||
.instance
|
||||
.get_physical_device_queue_family_properties(device)
|
||||
}
|
||||
};
|
||||
let extensions = live_device_extensions(instance, device, &name)?;
|
||||
let surface_formats = live_surface_formats(surface, device, &name)?;
|
||||
let present_modes = live_present_modes(surface, device, &name)?;
|
||||
let surface_capabilities = live_surface_capabilities(surface, device, &name)?;
|
||||
let supported_depth_stencil_formats = live_depth_stencil_formats(instance, device);
|
||||
let sampled_image_formats = live_sampled_image_formats(instance, device);
|
||||
let queue_families = queue_properties
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(queue_index, properties)| {
|
||||
let index = u32::try_from(queue_index).unwrap_or(u32::MAX);
|
||||
let present = {
|
||||
// SAFETY: The physical device, surface and queue-family index are live query inputs.
|
||||
unsafe {
|
||||
surface.loader.get_physical_device_surface_support(
|
||||
device,
|
||||
index,
|
||||
surface.surface,
|
||||
)
|
||||
}
|
||||
}
|
||||
.map_err(|error| VulkanRuntimeCapabilityError::PresentSupportFailed {
|
||||
device: name.clone(),
|
||||
queue_family: index,
|
||||
result: error,
|
||||
})?;
|
||||
Ok(VulkanQueueFamily {
|
||||
index,
|
||||
graphics: properties.queue_flags.contains(vk::QueueFlags::GRAPHICS),
|
||||
present,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, VulkanRuntimeCapabilityError>>()?;
|
||||
let record = VulkanPhysicalDeviceRecord {
|
||||
name,
|
||||
api_version: properties.api_version,
|
||||
device_type: match properties.device_type {
|
||||
vk::PhysicalDeviceType::DISCRETE_GPU => VulkanDeviceType::DiscreteGpu,
|
||||
vk::PhysicalDeviceType::INTEGRATED_GPU => VulkanDeviceType::IntegratedGpu,
|
||||
vk::PhysicalDeviceType::CPU => VulkanDeviceType::Cpu,
|
||||
_ => VulkanDeviceType::Other,
|
||||
},
|
||||
extensions,
|
||||
queue_families,
|
||||
surface_formats: surface_formats.clone(),
|
||||
present_modes: present_modes.clone(),
|
||||
surface_capabilities,
|
||||
supported_depth_stencil_formats,
|
||||
sampled_image_formats,
|
||||
limits: VulkanDeviceLimits {
|
||||
max_image_dimension_2d: properties.limits.max_image_dimension2_d,
|
||||
max_sampler_allocation_count: properties.limits.max_sampler_allocation_count,
|
||||
max_per_stage_descriptor_samplers: properties.limits.max_per_stage_descriptor_samplers,
|
||||
max_bound_descriptor_sets: properties.limits.max_bound_descriptor_sets,
|
||||
},
|
||||
};
|
||||
let capability = validate_device_for_request(&record, render_request)
|
||||
.map_err(VulkanRuntimeCapabilityError::Capability)?;
|
||||
Ok(LiveDeviceCandidate {
|
||||
physical_device: device,
|
||||
capability,
|
||||
surface_formats,
|
||||
present_modes,
|
||||
surface_capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn unique_queue_families(graphics: u32, present: u32) -> Vec<u32> {
|
||||
if graphics == present {
|
||||
vec![graphics]
|
||||
} else {
|
||||
vec![graphics, present]
|
||||
}
|
||||
}
|
||||
|
||||
fn physical_device_name(properties: &vk::PhysicalDeviceProperties, index: usize) -> String {
|
||||
// SAFETY: Vulkan device names are fixed-size NUL-terminated C strings per the spec.
|
||||
let name = unsafe { CStr::from_ptr(properties.device_name.as_ptr()) }
|
||||
.to_string_lossy()
|
||||
.trim()
|
||||
.to_string();
|
||||
if name.is_empty() {
|
||||
format!("physical-device-{index}")
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
fn live_device_extensions(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: vk::PhysicalDevice,
|
||||
name: &str,
|
||||
) -> Result<Vec<String>, VulkanRuntimeCapabilityError> {
|
||||
let properties = {
|
||||
// SAFETY: `device` was returned by this live instance and no borrowed data escapes.
|
||||
unsafe {
|
||||
instance
|
||||
.instance
|
||||
.enumerate_device_extension_properties(device)
|
||||
}
|
||||
}
|
||||
.map_err(
|
||||
|error| VulkanRuntimeCapabilityError::EnumerateDeviceExtensionsFailed {
|
||||
device: name.to_string(),
|
||||
result: error,
|
||||
},
|
||||
)?;
|
||||
let mut extensions = properties
|
||||
.iter()
|
||||
.map(|property| {
|
||||
// SAFETY: Vulkan extension names are fixed-size NUL-terminated C strings per the spec.
|
||||
unsafe { CStr::from_ptr(property.extension_name.as_ptr()) }
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
extensions.sort();
|
||||
extensions.dedup();
|
||||
Ok(extensions)
|
||||
}
|
||||
|
||||
pub(super) fn live_surface_formats(
|
||||
surface: &VulkanSurfaceProbe,
|
||||
device: vk::PhysicalDevice,
|
||||
name: &str,
|
||||
) -> Result<Vec<VulkanSurfaceFormat>, VulkanRuntimeCapabilityError> {
|
||||
let formats = {
|
||||
// SAFETY: The physical device and surface are live query inputs and no handles are retained.
|
||||
unsafe {
|
||||
surface
|
||||
.loader
|
||||
.get_physical_device_surface_formats(device, surface.surface)
|
||||
}
|
||||
}
|
||||
.map_err(|error| VulkanRuntimeCapabilityError::SurfaceFormatsFailed {
|
||||
device: name.to_string(),
|
||||
result: error,
|
||||
})?;
|
||||
Ok(formats
|
||||
.into_iter()
|
||||
.map(|format| VulkanSurfaceFormat {
|
||||
format: format.format.as_raw(),
|
||||
color_space: format.color_space.as_raw(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(super) fn live_present_modes(
|
||||
surface: &VulkanSurfaceProbe,
|
||||
device: vk::PhysicalDevice,
|
||||
name: &str,
|
||||
) -> Result<Vec<i32>, VulkanRuntimeCapabilityError> {
|
||||
let modes = {
|
||||
// SAFETY: The physical device and surface are live query inputs and no handles are retained.
|
||||
unsafe {
|
||||
surface
|
||||
.loader
|
||||
.get_physical_device_surface_present_modes(device, surface.surface)
|
||||
}
|
||||
}
|
||||
.map_err(|error| VulkanRuntimeCapabilityError::PresentModesFailed {
|
||||
device: name.to_string(),
|
||||
result: error,
|
||||
})?;
|
||||
Ok(modes.into_iter().map(vk::PresentModeKHR::as_raw).collect())
|
||||
}
|
||||
|
||||
pub(super) fn live_surface_capabilities(
|
||||
surface: &VulkanSurfaceProbe,
|
||||
device: vk::PhysicalDevice,
|
||||
name: &str,
|
||||
) -> Result<VulkanSwapchainSurfaceCapabilities, VulkanRuntimeCapabilityError> {
|
||||
let capabilities = {
|
||||
// SAFETY: The physical device and surface are live query inputs and no handles are retained.
|
||||
unsafe {
|
||||
surface
|
||||
.loader
|
||||
.get_physical_device_surface_capabilities(device, surface.surface)
|
||||
}
|
||||
}
|
||||
.map_err(
|
||||
|error| VulkanRuntimeCapabilityError::SurfaceCapabilitiesFailed {
|
||||
device: name.to_string(),
|
||||
result: error,
|
||||
},
|
||||
)?;
|
||||
Ok(VulkanSwapchainSurfaceCapabilities {
|
||||
current_extent: if capabilities.current_extent.width == u32::MAX {
|
||||
None
|
||||
} else {
|
||||
Some((
|
||||
capabilities.current_extent.width,
|
||||
capabilities.current_extent.height,
|
||||
))
|
||||
},
|
||||
min_extent: (
|
||||
capabilities.min_image_extent.width,
|
||||
capabilities.min_image_extent.height,
|
||||
),
|
||||
max_extent: (
|
||||
capabilities.max_image_extent.width,
|
||||
capabilities.max_image_extent.height,
|
||||
),
|
||||
min_image_count: capabilities.min_image_count,
|
||||
max_image_count: capabilities.max_image_count,
|
||||
supported_usage_flags: capabilities.supported_usage_flags.as_raw(),
|
||||
})
|
||||
}
|
||||
|
||||
fn live_depth_stencil_formats(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: vk::PhysicalDevice,
|
||||
) -> Vec<i32> {
|
||||
[
|
||||
vk::Format::D16_UNORM,
|
||||
vk::Format::X8_D24_UNORM_PACK32,
|
||||
vk::Format::D32_SFLOAT,
|
||||
vk::Format::S8_UINT,
|
||||
vk::Format::D16_UNORM_S8_UINT,
|
||||
vk::Format::D24_UNORM_S8_UINT,
|
||||
vk::Format::D32_SFLOAT_S8_UINT,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|format| {
|
||||
let properties = {
|
||||
// SAFETY: `device` belongs to `instance`; format-property queries copy data by value.
|
||||
unsafe {
|
||||
instance
|
||||
.instance
|
||||
.get_physical_device_format_properties(device, *format)
|
||||
}
|
||||
};
|
||||
properties
|
||||
.optimal_tiling_features
|
||||
.contains(vk::FormatFeatureFlags::DEPTH_STENCIL_ATTACHMENT)
|
||||
})
|
||||
.map(vk::Format::as_raw)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn live_sampled_image_formats(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: vk::PhysicalDevice,
|
||||
) -> Vec<i32> {
|
||||
[
|
||||
vk::Format::R8G8B8A8_SRGB,
|
||||
vk::Format::B8G8R8A8_SRGB,
|
||||
vk::Format::D16_UNORM,
|
||||
vk::Format::D32_SFLOAT,
|
||||
vk::Format::D24_UNORM_S8_UINT,
|
||||
vk::Format::D32_SFLOAT_S8_UINT,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|format| {
|
||||
let properties = {
|
||||
// SAFETY: `device` belongs to `instance`; format-property queries copy data by value.
|
||||
unsafe {
|
||||
instance
|
||||
.instance
|
||||
.get_physical_device_format_properties(device, *format)
|
||||
}
|
||||
};
|
||||
properties
|
||||
.optimal_tiling_features
|
||||
.contains(vk::FormatFeatureFlags::SAMPLED_IMAGE)
|
||||
})
|
||||
.map(vk::Format::as_raw)
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use ash::vk;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::raw::c_char;
|
||||
|
||||
use super::{
|
||||
EXT_DEBUG_UTILS_EXTENSION, KHR_PORTABILITY_ENUMERATION_EXTENSION, MIN_VULKAN_API_VERSION,
|
||||
VALIDATION_LAYER_NAME,
|
||||
};
|
||||
use crate::policy::{format_api_version, serialize_json_or_fallback};
|
||||
|
||||
/// Vulkan instance bootstrap configuration.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanInstanceConfig {
|
||||
/// Application name reported to the loader.
|
||||
pub application_name: String,
|
||||
/// Required instance extensions, usually including surface extensions.
|
||||
pub required_extensions: Vec<String>,
|
||||
/// Whether `VK_KHR_portability_enumeration` and its create flag are enabled.
|
||||
pub enable_portability_enumeration: bool,
|
||||
/// Whether validation layers are requested.
|
||||
pub enable_validation: bool,
|
||||
}
|
||||
|
||||
impl VulkanInstanceConfig {
|
||||
/// Returns a conservative instance configuration for smoke probes.
|
||||
#[must_use]
|
||||
pub fn smoke(application_name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
application_name: application_name.into(),
|
||||
required_extensions: Vec::new(),
|
||||
enable_portability_enumeration: cfg!(target_os = "macos"),
|
||||
enable_validation: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic Vulkan instance creation plan.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanInstancePlan {
|
||||
/// Report schema version.
|
||||
pub schema: u32,
|
||||
/// Instance extensions requested at creation time.
|
||||
pub enabled_extensions: Vec<String>,
|
||||
/// Raw Vulkan instance creation flags.
|
||||
pub create_flags: u32,
|
||||
/// Whether validation was requested.
|
||||
pub validation_requested: bool,
|
||||
}
|
||||
|
||||
/// Created Vulkan instance probe.
|
||||
pub struct VulkanInstanceProbe {
|
||||
pub(super) entry: ash::Entry,
|
||||
pub(super) instance: ash::Instance,
|
||||
/// Deterministic instance creation report.
|
||||
pub report: VulkanInstancePlan,
|
||||
}
|
||||
|
||||
impl Drop for VulkanInstanceProbe {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: The `Instance` was created by this probe and is destroyed once during drop.
|
||||
unsafe { self.instance.destroy_instance(None) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Vulkan instance bootstrap error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum VulkanInstanceError {
|
||||
/// The Vulkan loader could not be opened.
|
||||
Loader(VulkanLoaderError),
|
||||
/// Application name contained an interior NUL byte.
|
||||
InvalidApplicationName,
|
||||
/// An extension name contained an interior NUL byte.
|
||||
InvalidExtensionName {
|
||||
/// Invalid extension name.
|
||||
extension: String,
|
||||
},
|
||||
/// A required instance extension is unavailable from the loader.
|
||||
MissingInstanceExtension {
|
||||
/// Required extension name.
|
||||
extension: String,
|
||||
},
|
||||
/// Validation layers were requested but unavailable.
|
||||
MissingValidationLayer,
|
||||
/// Instance creation failed.
|
||||
CreateFailed {
|
||||
/// Vulkan result.
|
||||
result: vk::Result,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VulkanInstanceError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Loader(error) => write!(f, "{error}"),
|
||||
Self::InvalidApplicationName => {
|
||||
write!(f, "Vulkan application name contains an interior NUL byte")
|
||||
}
|
||||
Self::InvalidExtensionName { extension } => {
|
||||
write!(
|
||||
f,
|
||||
"Vulkan instance extension name contains an interior NUL byte: {extension:?}"
|
||||
)
|
||||
}
|
||||
Self::MissingInstanceExtension { extension } => {
|
||||
write!(f, "Vulkan instance extension {extension} is unavailable")
|
||||
}
|
||||
Self::MissingValidationLayer => {
|
||||
write!(
|
||||
f,
|
||||
"Vulkan validation layer VK_LAYER_KHRONOS_validation is unavailable"
|
||||
)
|
||||
}
|
||||
Self::CreateFailed { result } => {
|
||||
write!(f, "Vulkan instance creation failed: {result:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VulkanInstanceError {}
|
||||
|
||||
/// Deterministic Vulkan loader probe report.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanLoaderProbeReport {
|
||||
/// Report schema version.
|
||||
pub schema: u32,
|
||||
/// Whether the Vulkan loader was opened successfully.
|
||||
pub loader_available: bool,
|
||||
/// Reported loader instance API version.
|
||||
pub instance_api_version: u32,
|
||||
}
|
||||
|
||||
/// Vulkan loader bootstrap error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum VulkanLoaderError {
|
||||
/// The Vulkan loader library could not be opened.
|
||||
Unavailable {
|
||||
/// Loader error text.
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VulkanLoaderError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Unavailable { message } => {
|
||||
write!(f, "Vulkan loader is unavailable: {message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VulkanLoaderError {}
|
||||
|
||||
/// Builds the deterministic instance creation plan without touching the loader.
|
||||
#[must_use]
|
||||
pub fn plan_vulkan_instance(config: &VulkanInstanceConfig) -> VulkanInstancePlan {
|
||||
let mut enabled_extensions = config.required_extensions.clone();
|
||||
if config.enable_validation
|
||||
&& !enabled_extensions
|
||||
.iter()
|
||||
.any(|extension| extension == EXT_DEBUG_UTILS_EXTENSION)
|
||||
{
|
||||
enabled_extensions.push(EXT_DEBUG_UTILS_EXTENSION.to_string());
|
||||
}
|
||||
if config.enable_portability_enumeration
|
||||
&& !enabled_extensions
|
||||
.iter()
|
||||
.any(|extension| extension == KHR_PORTABILITY_ENUMERATION_EXTENSION)
|
||||
{
|
||||
enabled_extensions.push(KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string());
|
||||
}
|
||||
enabled_extensions.sort();
|
||||
enabled_extensions.dedup();
|
||||
VulkanInstancePlan {
|
||||
schema: 1,
|
||||
enabled_extensions,
|
||||
create_flags: if config.enable_portability_enumeration {
|
||||
vk::InstanceCreateFlags::ENUMERATE_PORTABILITY_KHR.as_raw()
|
||||
} else {
|
||||
0
|
||||
},
|
||||
validation_requested: config.enable_validation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a Vulkan instance probe from the supplied configuration.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanInstanceError`] when the loader is unavailable, names are not
|
||||
/// valid C strings, or `vkCreateInstance` fails.
|
||||
pub fn create_vulkan_instance_probe(
|
||||
config: &VulkanInstanceConfig,
|
||||
) -> Result<VulkanInstanceProbe, VulkanInstanceError> {
|
||||
// SAFETY: Loading the entry only resolves loader symbols; no raw Vulkan handles escape.
|
||||
let entry = unsafe { ash::Entry::load() }.map_err(|error| {
|
||||
VulkanInstanceError::Loader(VulkanLoaderError::Unavailable {
|
||||
message: error.to_string(),
|
||||
})
|
||||
})?;
|
||||
let app_name = CString::new(config.application_name.clone())
|
||||
.map_err(|_| VulkanInstanceError::InvalidApplicationName)?;
|
||||
let engine_name = c"fparkan";
|
||||
let plan = plan_vulkan_instance(config);
|
||||
let available_extensions = available_instance_extensions(&entry)?;
|
||||
ensure_instance_extensions_available(&plan.enabled_extensions, &available_extensions)?;
|
||||
let extension_names = cstring_vec(&plan.enabled_extensions)?;
|
||||
let extension_ptrs = cstring_ptrs(&extension_names);
|
||||
let layer_names = validation_layer_cstrings(&entry, config.enable_validation)?;
|
||||
let layer_ptrs = cstring_ptrs(&layer_names);
|
||||
let app_info = vk::ApplicationInfo::default()
|
||||
.application_name(&app_name)
|
||||
.application_version(0)
|
||||
.engine_name(engine_name)
|
||||
.engine_version(0)
|
||||
.api_version(MIN_VULKAN_API_VERSION);
|
||||
let create_info = vk::InstanceCreateInfo::default()
|
||||
.application_info(&app_info)
|
||||
.enabled_extension_names(&extension_ptrs)
|
||||
.enabled_layer_names(&layer_ptrs)
|
||||
.flags(vk::InstanceCreateFlags::from_raw(plan.create_flags));
|
||||
// SAFETY: `create_info` points to stack-owned Vulkan create data that lives for the call.
|
||||
let instance = unsafe { entry.create_instance(&create_info, None) }
|
||||
.map_err(|error| VulkanInstanceError::CreateFailed { result: error })?;
|
||||
Ok(VulkanInstanceProbe {
|
||||
entry,
|
||||
instance,
|
||||
report: plan,
|
||||
})
|
||||
}
|
||||
|
||||
/// Renders a deterministic JSON Vulkan instance plan.
|
||||
#[must_use]
|
||||
pub fn render_instance_plan_json(plan: &VulkanInstancePlan) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct InstancePlanJson<'a> {
|
||||
schema: u32,
|
||||
create_flags: u32,
|
||||
validation_requested: bool,
|
||||
enabled_extensions: &'a [String],
|
||||
}
|
||||
|
||||
serialize_json_or_fallback(
|
||||
&InstancePlanJson {
|
||||
schema: plan.schema,
|
||||
create_flags: plan.create_flags,
|
||||
validation_requested: plan.validation_requested,
|
||||
enabled_extensions: &plan.enabled_extensions,
|
||||
},
|
||||
"{\"schema\":0,\"create_flags\":0,\"validation_requested\":false,\"enabled_extensions\":[]}",
|
||||
)
|
||||
}
|
||||
|
||||
/// Opens the Vulkan loader and reports the supported instance API version.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanLoaderError`] when no Vulkan loader library can be opened on
|
||||
/// the host.
|
||||
pub fn probe_vulkan_loader() -> Result<VulkanLoaderProbeReport, VulkanLoaderError> {
|
||||
// SAFETY: Loading the entry only resolves loader symbols; no raw Vulkan handles escape.
|
||||
let entry = unsafe { ash::Entry::load() }.map_err(|error| VulkanLoaderError::Unavailable {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
// SAFETY: The resolved entry only queries the loader-supported instance API version.
|
||||
let version = unsafe { entry.try_enumerate_instance_version() }
|
||||
.map_err(|error| VulkanLoaderError::Unavailable {
|
||||
message: error.to_string(),
|
||||
})?
|
||||
.unwrap_or(vk::API_VERSION_1_0);
|
||||
Ok(VulkanLoaderProbeReport {
|
||||
schema: 1,
|
||||
loader_available: true,
|
||||
instance_api_version: version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the static Vulkan entry name used by loader probes.
|
||||
#[must_use]
|
||||
pub fn vulkan_entry_symbol_name() -> &'static CStr {
|
||||
c"vkGetInstanceProcAddr"
|
||||
}
|
||||
|
||||
/// Renders a deterministic JSON Vulkan loader report.
|
||||
#[must_use]
|
||||
pub fn render_loader_probe_report_json(report: &VulkanLoaderProbeReport) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct LoaderProbeReportJson {
|
||||
schema: u32,
|
||||
loader_available: bool,
|
||||
instance_api: String,
|
||||
}
|
||||
|
||||
serialize_json_or_fallback(
|
||||
&LoaderProbeReportJson {
|
||||
schema: report.schema,
|
||||
loader_available: report.loader_available,
|
||||
instance_api: format_api_version(report.instance_api_version),
|
||||
},
|
||||
"{\"schema\":0,\"loader_available\":false,\"instance_api\":\"0.0.0\"}",
|
||||
)
|
||||
}
|
||||
|
||||
fn available_instance_extensions(entry: &ash::Entry) -> Result<Vec<String>, VulkanInstanceError> {
|
||||
let available_extensions =
|
||||
// SAFETY: Enumerating instance extensions reads loader-owned immutable metadata.
|
||||
unsafe { entry.enumerate_instance_extension_properties(None) }.map_err(|error| {
|
||||
VulkanInstanceError::CreateFailed {
|
||||
result: error,
|
||||
}
|
||||
})?;
|
||||
available_extensions
|
||||
.into_iter()
|
||||
.map(|extension| {
|
||||
// SAFETY: Vulkan extension names are fixed-size NUL-terminated strings from the loader.
|
||||
Ok(unsafe { CStr::from_ptr(extension.extension_name.as_ptr()) }
|
||||
.to_string_lossy()
|
||||
.into_owned())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn ensure_instance_extensions_available(
|
||||
required_extensions: &[String],
|
||||
available_extensions: &[String],
|
||||
) -> Result<(), VulkanInstanceError> {
|
||||
let available = available_extensions
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<BTreeSet<_>>();
|
||||
for extension in required_extensions {
|
||||
if !available.contains(extension.as_str()) {
|
||||
return Err(VulkanInstanceError::MissingInstanceExtension {
|
||||
extension: extension.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validation_layer_cstrings(
|
||||
entry: &ash::Entry,
|
||||
enable_validation: bool,
|
||||
) -> Result<Vec<CString>, VulkanInstanceError> {
|
||||
if !enable_validation {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let available_layers =
|
||||
// SAFETY: Enumerating instance layers reads loader-owned immutable metadata.
|
||||
unsafe { entry.enumerate_instance_layer_properties() }.map_err(|error| {
|
||||
VulkanInstanceError::CreateFailed {
|
||||
result: error,
|
||||
}
|
||||
})?;
|
||||
let validation_available = available_layers.iter().any(|layer| {
|
||||
// SAFETY: Vulkan layer names are fixed-size NUL-terminated strings from the loader.
|
||||
unsafe { CStr::from_ptr(layer.layer_name.as_ptr()) }
|
||||
.to_string_lossy()
|
||||
.as_ref()
|
||||
== VALIDATION_LAYER_NAME
|
||||
});
|
||||
if !validation_available {
|
||||
return Err(VulkanInstanceError::MissingValidationLayer);
|
||||
}
|
||||
Ok(vec![CString::new(VALIDATION_LAYER_NAME).map_err(|_| {
|
||||
VulkanInstanceError::InvalidApplicationName
|
||||
})?])
|
||||
}
|
||||
|
||||
pub(super) fn cstring_vec(values: &[String]) -> Result<Vec<CString>, VulkanInstanceError> {
|
||||
values
|
||||
.iter()
|
||||
.map(|extension| {
|
||||
CString::new(extension.as_str()).map_err(|_| {
|
||||
VulkanInstanceError::InvalidExtensionName {
|
||||
extension: extension.clone(),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn cstring_ptrs(values: &[CString]) -> Vec<*const c_char> {
|
||||
values.iter().map(|value| value.as_ptr()).collect()
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use ash::vk;
|
||||
|
||||
use super::{VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError};
|
||||
|
||||
pub(super) struct VulkanAllocatedBuffer {
|
||||
pub(super) buffer: vk::Buffer,
|
||||
pub(super) memory: vk::DeviceMemory,
|
||||
}
|
||||
|
||||
pub(super) struct VulkanFrameSync {
|
||||
pub(super) image_available: vk::Semaphore,
|
||||
pub(super) render_finished: vk::Semaphore,
|
||||
pub(super) fence: vk::Fence,
|
||||
}
|
||||
|
||||
pub(super) fn create_command_pool(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
) -> Result<vk::CommandPool, VulkanSmokeRendererError> {
|
||||
let create_info = vk::CommandPoolCreateInfo::default()
|
||||
.queue_family_index(device.report.graphics_queue_family)
|
||||
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER);
|
||||
// SAFETY: The queue-family index belongs to this live logical device.
|
||||
unsafe { device.device().create_command_pool(&create_info, None) }.map_err(|error| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateCommandPool",
|
||||
result: error,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn create_triangle_vertex_buffer(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
||||
let vertices: [[f32; 5]; 3] = [
|
||||
[0.0, -0.55, 1.0, 0.2, 0.2],
|
||||
[0.55, 0.55, 0.2, 1.0, 0.2],
|
||||
[-0.55, 0.55, 0.2, 0.4, 1.0],
|
||||
];
|
||||
let mut bytes = Vec::with_capacity(vertices.len() * 5 * std::mem::size_of::<f32>());
|
||||
for vertex in vertices {
|
||||
for value in vertex {
|
||||
bytes.extend_from_slice(&value.to_ne_bytes());
|
||||
}
|
||||
}
|
||||
create_host_visible_buffer(
|
||||
instance,
|
||||
device,
|
||||
&bytes,
|
||||
vk::BufferUsageFlags::VERTEX_BUFFER,
|
||||
"triangle vertex buffer",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn create_triangle_index_buffer(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
||||
let indices = [0_u16, 1_u16, 2_u16];
|
||||
let mut bytes = Vec::with_capacity(indices.len() * std::mem::size_of::<u16>());
|
||||
for index in indices {
|
||||
bytes.extend_from_slice(&index.to_ne_bytes());
|
||||
}
|
||||
create_host_visible_buffer(
|
||||
instance,
|
||||
device,
|
||||
&bytes,
|
||||
vk::BufferUsageFlags::INDEX_BUFFER,
|
||||
"triangle index buffer",
|
||||
)
|
||||
}
|
||||
|
||||
fn create_host_visible_buffer(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
bytes: &[u8],
|
||||
usage: vk::BufferUsageFlags,
|
||||
context: &'static str,
|
||||
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
||||
let create_info = vk::BufferCreateInfo::default()
|
||||
.size(bytes.len().try_into().unwrap_or(u64::MAX))
|
||||
.usage(usage)
|
||||
.sharing_mode(vk::SharingMode::EXCLUSIVE);
|
||||
// SAFETY: The create info is stack-owned and references no external memory.
|
||||
let buffer = unsafe { device.device().create_buffer(&create_info, None) }.map_err(|error| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context,
|
||||
result: error,
|
||||
}
|
||||
})?;
|
||||
// SAFETY: The buffer belongs to this device and is queried immediately after creation.
|
||||
let requirements = unsafe { device.device().get_buffer_memory_requirements(buffer) };
|
||||
let Some(memory_type_index) = find_memory_type(
|
||||
instance,
|
||||
device.physical_device(),
|
||||
requirements.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
|
||||
) else {
|
||||
// SAFETY: The buffer was created above on this logical device and is destroyed on setup failure.
|
||||
unsafe { device.device().destroy_buffer(buffer, None) };
|
||||
return Err(VulkanSmokeRendererError::MissingMemoryType { context });
|
||||
};
|
||||
let allocate_info = vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(requirements.size)
|
||||
.memory_type_index(memory_type_index);
|
||||
let memory =
|
||||
// SAFETY: The allocation request matches the queried memory requirements for this buffer.
|
||||
unsafe { device.device().allocate_memory(&allocate_info, None) }.map_err(|error| {
|
||||
// SAFETY: The buffer was created above on this logical device and is destroyed on setup failure.
|
||||
unsafe { device.device().destroy_buffer(buffer, None) };
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context,
|
||||
result: error,
|
||||
}
|
||||
})?;
|
||||
// SAFETY: The allocation satisfies the queried buffer memory requirements for this device.
|
||||
unsafe { device.device().bind_buffer_memory(buffer, memory, 0) }.map_err(|error| {
|
||||
// SAFETY: The buffer and allocation were created above on this logical device and are destroyed on setup failure.
|
||||
unsafe {
|
||||
device.device().destroy_buffer(buffer, None);
|
||||
device.device().free_memory(memory, None);
|
||||
}
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context,
|
||||
result: error,
|
||||
}
|
||||
})?;
|
||||
// SAFETY: The mapping range is within the host-visible allocation bound to the buffer.
|
||||
let mapped = unsafe {
|
||||
device
|
||||
.device()
|
||||
.map_memory(memory, 0, requirements.size, vk::MemoryMapFlags::empty())
|
||||
}
|
||||
.map_err(|error| {
|
||||
// SAFETY: The buffer and allocation were created above on this logical device and are destroyed on setup failure.
|
||||
unsafe {
|
||||
device.device().destroy_buffer(buffer, None);
|
||||
device.device().free_memory(memory, None);
|
||||
}
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context,
|
||||
result: error,
|
||||
}
|
||||
})?;
|
||||
// SAFETY: The destination points to the mapped allocation and the source slice lives for the copy.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::<u8>(), bytes.len());
|
||||
device.device().unmap_memory(memory);
|
||||
}
|
||||
Ok(VulkanAllocatedBuffer { buffer, memory })
|
||||
}
|
||||
|
||||
fn find_memory_type(
|
||||
instance: &VulkanInstanceProbe,
|
||||
physical_device: vk::PhysicalDevice,
|
||||
type_bits: u32,
|
||||
required: vk::MemoryPropertyFlags,
|
||||
) -> Option<u32> {
|
||||
let properties =
|
||||
// SAFETY: The physical device was selected from this live instance and queried by value.
|
||||
unsafe {
|
||||
instance
|
||||
.instance
|
||||
.get_physical_device_memory_properties(physical_device)
|
||||
};
|
||||
let count = usize::try_from(properties.memory_type_count).unwrap_or(0);
|
||||
properties.memory_types[..count]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, memory_type)| {
|
||||
let index_u32 = u32::try_from(index).ok()?;
|
||||
let supported = (type_bits & (1_u32 << index_u32)) != 0;
|
||||
(supported && memory_type.property_flags.contains(required)).then_some(index_u32)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn create_frame_sync(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
) -> Result<Vec<VulkanFrameSync>, VulkanSmokeRendererError> {
|
||||
let semaphore_info = vk::SemaphoreCreateInfo::default();
|
||||
let fence_info = vk::FenceCreateInfo::default().flags(vk::FenceCreateFlags::SIGNALED);
|
||||
let mut sync = Vec::with_capacity(2);
|
||||
for _ in 0..2 {
|
||||
// SAFETY: The sync objects belong to this live logical device and are destroyed at teardown.
|
||||
let image_available = unsafe { device.device().create_semaphore(&semaphore_info, None) }
|
||||
.map_err(|error| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateSemaphore(image_available)",
|
||||
result: error,
|
||||
})?;
|
||||
let render_finished = {
|
||||
// SAFETY: The sync objects belong to this live logical device and are destroyed at teardown.
|
||||
match unsafe { device.device().create_semaphore(&semaphore_info, None) } {
|
||||
Ok(render_finished) => render_finished,
|
||||
Err(error) => {
|
||||
destroy_frame_sync_objects(device, &sync);
|
||||
// SAFETY: The semaphore was created above on this logical device and is destroyed on setup failure.
|
||||
unsafe { device.device().destroy_semaphore(image_available, None) };
|
||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateSemaphore(render_finished)",
|
||||
result: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
// SAFETY: The fence belongs to this live logical device and is destroyed at teardown.
|
||||
let fence = match unsafe { device.device().create_fence(&fence_info, None) } {
|
||||
Ok(fence) => fence,
|
||||
Err(error) => {
|
||||
destroy_frame_sync_objects(device, &sync);
|
||||
// SAFETY: These semaphores were created above on this logical device and are destroyed on setup failure.
|
||||
unsafe {
|
||||
device.device().destroy_semaphore(image_available, None);
|
||||
device.device().destroy_semaphore(render_finished, None);
|
||||
}
|
||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateFence",
|
||||
result: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
sync.push(VulkanFrameSync {
|
||||
image_available,
|
||||
render_finished,
|
||||
fence,
|
||||
});
|
||||
}
|
||||
Ok(sync)
|
||||
}
|
||||
|
||||
fn destroy_frame_sync_objects(device: &VulkanLogicalDeviceProbe, sync: &[VulkanFrameSync]) {
|
||||
for frame_sync in sync {
|
||||
// SAFETY: These sync objects belong to this live logical device and are destroyed once during teardown.
|
||||
unsafe {
|
||||
device
|
||||
.device()
|
||||
.destroy_semaphore(frame_sync.image_available, None);
|
||||
device
|
||||
.device()
|
||||
.destroy_semaphore(frame_sync.render_finished, None);
|
||||
device.device().destroy_fence(frame_sync.fence, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn destroy_allocated_buffer(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
buffer: &VulkanAllocatedBuffer,
|
||||
) {
|
||||
// SAFETY: The buffer and allocation belong to this live logical device and are destroyed once during teardown.
|
||||
unsafe {
|
||||
device.device().destroy_buffer(buffer.buffer, None);
|
||||
device.device().free_memory(buffer.memory, None);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn color_subresource_range() -> vk::ImageSubresourceRange {
|
||||
vk::ImageSubresourceRange::default()
|
||||
.aspect_mask(vk::ImageAspectFlags::COLOR)
|
||||
.base_mip_level(0)
|
||||
.level_count(1)
|
||||
.base_array_layer(0)
|
||||
.layer_count(1)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use ash::vk;
|
||||
use fparkan_platform::RenderRequest;
|
||||
use std::ffi::CString;
|
||||
|
||||
use super::capabilities::{
|
||||
select_live_device_candidate_for_request, unique_queue_families, VulkanRuntimeCapabilityError,
|
||||
VulkanRuntimeCapabilityProbe,
|
||||
};
|
||||
use super::{VulkanInstanceProbe, VulkanSurfaceProbe};
|
||||
|
||||
/// Created Vulkan logical device probe.
|
||||
pub struct VulkanLogicalDeviceProbe {
|
||||
device: ash::Device,
|
||||
physical_device: vk::PhysicalDevice,
|
||||
/// Runtime capability report used for device selection.
|
||||
pub runtime: VulkanRuntimeCapabilityProbe,
|
||||
/// Deterministic logical device creation report.
|
||||
pub report: VulkanLogicalDeviceReport,
|
||||
}
|
||||
|
||||
impl Drop for VulkanLogicalDeviceProbe {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: The logical device was created by this probe and is destroyed once during drop.
|
||||
unsafe { self.device.destroy_device(None) };
|
||||
}
|
||||
}
|
||||
|
||||
impl VulkanLogicalDeviceProbe {
|
||||
/// Returns the graphics queue selected by the Stage 0 policy.
|
||||
#[must_use]
|
||||
pub fn graphics_queue(&self) -> vk::Queue {
|
||||
// SAFETY: The queue-family index belongs to this live logical device.
|
||||
unsafe {
|
||||
self.device
|
||||
.get_device_queue(self.report.graphics_queue_family, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the presentation queue selected by the Stage 0 policy.
|
||||
#[must_use]
|
||||
pub fn present_queue(&self) -> vk::Queue {
|
||||
// SAFETY: The queue-family index belongs to this live logical device.
|
||||
unsafe {
|
||||
self.device
|
||||
.get_device_queue(self.report.present_queue_family, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a shared reference to the live logical device.
|
||||
#[must_use]
|
||||
pub fn device(&self) -> &ash::Device {
|
||||
&self.device
|
||||
}
|
||||
|
||||
/// Returns the selected physical device handle.
|
||||
#[must_use]
|
||||
pub fn physical_device(&self) -> vk::PhysicalDevice {
|
||||
self.physical_device
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical device creation report.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanLogicalDeviceReport {
|
||||
/// Report schema version.
|
||||
pub schema: u32,
|
||||
/// Selected physical device name.
|
||||
pub device_name: String,
|
||||
/// Graphics queue-family index used by the logical device.
|
||||
pub graphics_queue_family: u32,
|
||||
/// Present queue-family index used by the logical device.
|
||||
pub present_queue_family: u32,
|
||||
/// Enabled device extensions.
|
||||
pub enabled_extensions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Vulkan logical device creation error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum VulkanLogicalDeviceError {
|
||||
/// Runtime capability probing failed.
|
||||
Runtime(VulkanRuntimeCapabilityError),
|
||||
/// Device extension name contained an interior NUL byte.
|
||||
InvalidExtensionName {
|
||||
/// Invalid extension name.
|
||||
extension: String,
|
||||
},
|
||||
/// Logical device creation failed.
|
||||
CreateFailed {
|
||||
/// Selected device name.
|
||||
device: String,
|
||||
/// Vulkan result.
|
||||
result: vk::Result,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VulkanLogicalDeviceError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Runtime(error) => write!(f, "{error}"),
|
||||
Self::InvalidExtensionName { extension } => write!(
|
||||
f,
|
||||
"Vulkan device extension name contains an interior NUL byte: {extension:?}"
|
||||
),
|
||||
Self::CreateFailed { device, result } => {
|
||||
write!(
|
||||
f,
|
||||
"Vulkan logical device creation failed for {device}: {result:?}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VulkanLogicalDeviceError {}
|
||||
|
||||
/// Creates a Vulkan logical device for the selected live surface-capable device.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanLogicalDeviceError`] when runtime capability probing fails,
|
||||
/// device extension names are invalid, or `vkCreateDevice` fails.
|
||||
pub fn create_vulkan_logical_device_probe(
|
||||
instance: &VulkanInstanceProbe,
|
||||
surface: &VulkanSurfaceProbe,
|
||||
drawable_extent: (u32, u32),
|
||||
) -> Result<VulkanLogicalDeviceProbe, VulkanLogicalDeviceError> {
|
||||
create_vulkan_logical_device_probe_for_request(
|
||||
instance,
|
||||
surface,
|
||||
drawable_extent,
|
||||
RenderRequest::conservative(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a Vulkan logical device for a specific Stage 0 render request.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanLogicalDeviceError`] when runtime capability probing fails,
|
||||
/// device extension names are invalid, or `vkCreateDevice` fails.
|
||||
pub fn create_vulkan_logical_device_probe_for_request(
|
||||
instance: &VulkanInstanceProbe,
|
||||
surface: &VulkanSurfaceProbe,
|
||||
drawable_extent: (u32, u32),
|
||||
render_request: RenderRequest,
|
||||
) -> Result<VulkanLogicalDeviceProbe, VulkanLogicalDeviceError> {
|
||||
let selected = select_live_device_candidate_for_request(
|
||||
instance,
|
||||
surface,
|
||||
drawable_extent,
|
||||
render_request,
|
||||
)
|
||||
.map_err(VulkanLogicalDeviceError::Runtime)?;
|
||||
let capability = &selected.runtime.capability;
|
||||
let queue_priorities = [1.0_f32];
|
||||
let queue_families = unique_queue_families(
|
||||
capability.graphics_queue_family,
|
||||
capability.present_queue_family,
|
||||
);
|
||||
let queue_infos = queue_families
|
||||
.iter()
|
||||
.map(|queue_family| {
|
||||
vk::DeviceQueueCreateInfo::default()
|
||||
.queue_family_index(*queue_family)
|
||||
.queue_priorities(&queue_priorities)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let extension_names = device_extension_cstrings(&capability.enabled_extensions)
|
||||
.map_err(|extension| VulkanLogicalDeviceError::InvalidExtensionName { extension })?;
|
||||
let extension_ptrs = extension_names
|
||||
.iter()
|
||||
.map(|extension| extension.as_ptr())
|
||||
.collect::<Vec<_>>();
|
||||
let create_info = vk::DeviceCreateInfo::default()
|
||||
.queue_create_infos(&queue_infos)
|
||||
.enabled_extension_names(&extension_ptrs);
|
||||
// SAFETY: `selected.physical_device` belongs to `instance`; create data lives for the call.
|
||||
let device = unsafe {
|
||||
instance
|
||||
.instance
|
||||
.create_device(selected.physical_device, &create_info, None)
|
||||
}
|
||||
.map_err(|error| VulkanLogicalDeviceError::CreateFailed {
|
||||
device: capability.device_name.clone(),
|
||||
result: error,
|
||||
})?;
|
||||
// SAFETY: Queue family indices came from validated live queue families requested above.
|
||||
let _graphics_queue = unsafe { device.get_device_queue(capability.graphics_queue_family, 0) };
|
||||
// SAFETY: Queue family indices came from validated live queue families requested above.
|
||||
let _present_queue = unsafe { device.get_device_queue(capability.present_queue_family, 0) };
|
||||
Ok(VulkanLogicalDeviceProbe {
|
||||
device,
|
||||
physical_device: selected.physical_device,
|
||||
report: VulkanLogicalDeviceReport {
|
||||
schema: 1,
|
||||
device_name: capability.device_name.clone(),
|
||||
graphics_queue_family: capability.graphics_queue_family,
|
||||
present_queue_family: capability.present_queue_family,
|
||||
enabled_extensions: capability.enabled_extensions.clone(),
|
||||
},
|
||||
runtime: selected.runtime,
|
||||
})
|
||||
}
|
||||
|
||||
fn device_extension_cstrings(values: &[String]) -> Result<Vec<CString>, String> {
|
||||
values
|
||||
.iter()
|
||||
.map(|extension| CString::new(extension.as_str()).map_err(|_| extension.clone()))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,914 @@
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use ash::vk;
|
||||
|
||||
use super::{
|
||||
create_command_pool, create_frame_sync, create_swapchain_resources,
|
||||
create_triangle_index_buffer, create_triangle_vertex_buffer, create_validation_messenger,
|
||||
create_vulkan_instance_probe, create_vulkan_logical_device_probe_for_request,
|
||||
create_vulkan_surface_probe, create_vulkan_swapchain_probe_for_extent,
|
||||
destroy_allocated_buffer, destroy_swapchain_resources, plan_vulkan_surface,
|
||||
VulkanAllocatedBuffer, VulkanInstanceConfig, VulkanInstanceProbe, VulkanLogicalDeviceProbe,
|
||||
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
|
||||
VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
|
||||
VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainResources, VulkanValidationMessenger,
|
||||
VulkanValidationReport,
|
||||
};
|
||||
use crate::policy::KHR_PORTABILITY_SUBSET_EXTENSION;
|
||||
use crate::shader_manifest::{triangle_shader_manifest, validate_shader_manifest};
|
||||
|
||||
fn take_runtime_owners_in_dependency_order<Instance, Validation, Surface, Device, Swapchain>(
|
||||
instance: &mut Option<Instance>,
|
||||
validation: &mut Option<Validation>,
|
||||
surface: &mut Option<Surface>,
|
||||
device: &mut Option<Device>,
|
||||
swapchain: &mut Option<Swapchain>,
|
||||
) {
|
||||
swapchain.take();
|
||||
device.take();
|
||||
surface.take();
|
||||
validation.take();
|
||||
instance.take();
|
||||
}
|
||||
|
||||
fn take_runtime_owners_with_validation_snapshot<
|
||||
Instance,
|
||||
Validation,
|
||||
Surface,
|
||||
Device,
|
||||
Swapchain,
|
||||
Snapshot,
|
||||
Capture,
|
||||
>(
|
||||
instance: &mut Option<Instance>,
|
||||
validation: &mut Option<Validation>,
|
||||
surface: &mut Option<Surface>,
|
||||
device: &mut Option<Device>,
|
||||
swapchain: &mut Option<Swapchain>,
|
||||
capture: Capture,
|
||||
) -> Option<Snapshot>
|
||||
where
|
||||
Capture: FnOnce(&Validation) -> Snapshot,
|
||||
{
|
||||
let snapshot = validation.as_ref().map(capture);
|
||||
take_runtime_owners_in_dependency_order(instance, validation, surface, device, swapchain);
|
||||
snapshot
|
||||
}
|
||||
|
||||
struct RollbackOnDrop<T, F>
|
||||
where
|
||||
F: FnOnce(T),
|
||||
{
|
||||
value: Option<T>,
|
||||
rollback: Option<F>,
|
||||
}
|
||||
|
||||
impl<T, F> RollbackOnDrop<T, F>
|
||||
where
|
||||
F: FnOnce(T),
|
||||
{
|
||||
fn new(value: T, rollback: F) -> Self {
|
||||
Self {
|
||||
value: Some(value),
|
||||
rollback: Some(rollback),
|
||||
}
|
||||
}
|
||||
|
||||
fn commit(mut self) -> T {
|
||||
self.rollback.take();
|
||||
match self.value.take() {
|
||||
Some(value) => value,
|
||||
None => unreachable!("rollback guard must hold a value until commit"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, F> Drop for RollbackOnDrop<T, F>
|
||||
where
|
||||
F: FnOnce(T),
|
||||
{
|
||||
fn drop(&mut self) {
|
||||
if let (Some(value), Some(rollback)) = (self.value.take(), self.rollback.take()) {
|
||||
rollback(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VulkanSmokeRenderer {
|
||||
/// Creates a live Vulkan smoke renderer bound to a live native window.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanSmokeRendererError`] when Vulkan bootstrap, pipeline creation,
|
||||
/// memory allocation, or synchronization resource creation fails.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn new(
|
||||
create_info: &VulkanSmokeRendererCreateInfo,
|
||||
) -> Result<Self, VulkanSmokeRendererError> {
|
||||
let bootstrap_progress = create_info.bootstrap_progress.as_ref();
|
||||
let shader_manifest = validate_shader_manifest(&triangle_shader_manifest())
|
||||
.map_err(VulkanSmokeRendererError::ShaderManifest)?;
|
||||
let surface_plan = plan_vulkan_surface(Some(create_info.native_handles))
|
||||
.map_err(VulkanSmokeRendererError::Surface)?;
|
||||
let mut instance_config = VulkanInstanceConfig::smoke(&create_info.application_name);
|
||||
instance_config
|
||||
.required_extensions
|
||||
.clone_from(&surface_plan.required_instance_extensions);
|
||||
instance_config.enable_validation = create_info.enable_validation;
|
||||
let instance = create_vulkan_instance_probe(&instance_config)
|
||||
.map_err(VulkanSmokeRendererError::Instance)?;
|
||||
if let Some(progress) = bootstrap_progress {
|
||||
progress.mark_loader_available();
|
||||
progress.mark_instance_created();
|
||||
}
|
||||
let validation = if create_info.enable_validation {
|
||||
Some(create_validation_messenger(&instance)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let surface = create_vulkan_surface_probe(&instance, Some(create_info.native_handles))
|
||||
.map_err(VulkanSmokeRendererError::Surface)?;
|
||||
if let Some(progress) = bootstrap_progress {
|
||||
progress.mark_surface_created();
|
||||
}
|
||||
let device = create_vulkan_logical_device_probe_for_request(
|
||||
&instance,
|
||||
&surface,
|
||||
create_info.drawable_extent,
|
||||
create_info.render_request,
|
||||
)
|
||||
.map_err(VulkanSmokeRendererError::LogicalDevice)?;
|
||||
if let Some(progress) = bootstrap_progress {
|
||||
progress.mark_logical_device_created();
|
||||
}
|
||||
let swapchain = create_vulkan_swapchain_probe_for_extent(
|
||||
&instance,
|
||||
&surface,
|
||||
&device,
|
||||
create_info.drawable_extent,
|
||||
vk::SwapchainKHR::null(),
|
||||
)
|
||||
.map_err(VulkanSmokeRendererError::Swapchain)?;
|
||||
if let Some(progress) = bootstrap_progress {
|
||||
progress.mark_swapchain_created();
|
||||
}
|
||||
let command_pool = create_command_pool(&device)?;
|
||||
let vertex_buffer = match create_triangle_vertex_buffer(&instance, &device) {
|
||||
Ok(buffer) => buffer,
|
||||
Err(error) => {
|
||||
// SAFETY: The command pool belongs to this live logical device and is destroyed on setup failure.
|
||||
unsafe { device.device().destroy_command_pool(command_pool, None) };
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let index_buffer = match create_triangle_index_buffer(&instance, &device) {
|
||||
Ok(buffer) => buffer,
|
||||
Err(error) => {
|
||||
// SAFETY: The command pool belongs to this live logical device and is destroyed on setup failure.
|
||||
unsafe { device.device().destroy_command_pool(command_pool, None) };
|
||||
destroy_allocated_buffer(&device, &vertex_buffer);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let mut renderer = Self {
|
||||
instance: Some(instance),
|
||||
validation,
|
||||
surface: Some(surface),
|
||||
device: Some(device),
|
||||
swapchain: Some(swapchain),
|
||||
command_pool,
|
||||
swapchain_resources: None,
|
||||
vertex_buffer: Some(vertex_buffer),
|
||||
index_buffer: Some(index_buffer),
|
||||
frame_sync: Vec::new(),
|
||||
images_in_flight: Vec::new(),
|
||||
current_frame: 0,
|
||||
pending_extent: None,
|
||||
swapchain_recreate_count: 0,
|
||||
report: VulkanSmokeRendererReport {
|
||||
shader_manifest_hash: shader_manifest.manifest_hash.clone(),
|
||||
portability_enumeration: instance_config.enable_portability_enumeration,
|
||||
portability_subset_enabled: false,
|
||||
device_name: String::new(),
|
||||
graphics_queue_family: 0,
|
||||
present_queue_family: 0,
|
||||
enabled_extension_count: 0,
|
||||
swapchain_extent: (0, 0),
|
||||
swapchain_image_count: 0,
|
||||
},
|
||||
};
|
||||
renderer.rebuild_swapchain_resources(false)?;
|
||||
let device_ref = renderer.device_ref()?;
|
||||
let swapchain_ref = renderer.swapchain_ref()?;
|
||||
renderer.report = VulkanSmokeRendererReport {
|
||||
shader_manifest_hash: shader_manifest.manifest_hash,
|
||||
portability_enumeration: renderer
|
||||
.instance
|
||||
.as_ref()
|
||||
.is_some_and(|instance| instance.report.create_flags != 0),
|
||||
portability_subset_enabled: device_ref
|
||||
.report
|
||||
.enabled_extensions
|
||||
.iter()
|
||||
.any(|extension| extension == KHR_PORTABILITY_SUBSET_EXTENSION),
|
||||
device_name: device_ref.report.device_name.clone(),
|
||||
graphics_queue_family: device_ref.report.graphics_queue_family,
|
||||
present_queue_family: device_ref.report.present_queue_family,
|
||||
enabled_extension_count: device_ref
|
||||
.report
|
||||
.enabled_extensions
|
||||
.len()
|
||||
.try_into()
|
||||
.unwrap_or(u32::MAX),
|
||||
swapchain_extent: swapchain_ref.report.plan.extent,
|
||||
swapchain_image_count: swapchain_ref.report.image_count,
|
||||
};
|
||||
Ok(renderer)
|
||||
}
|
||||
|
||||
/// Returns the current bootstrap report.
|
||||
#[must_use]
|
||||
pub const fn report(&self) -> &VulkanSmokeRendererReport {
|
||||
&self.report
|
||||
}
|
||||
|
||||
/// Returns measured validation counters and VUIDs.
|
||||
#[must_use]
|
||||
pub fn validation_report(&self) -> VulkanValidationReport {
|
||||
self.validation.as_ref().map_or(
|
||||
VulkanValidationReport {
|
||||
warning_count: 0,
|
||||
error_count: 0,
|
||||
vuids: Vec::new(),
|
||||
},
|
||||
VulkanValidationMessenger::report,
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the measured swapchain recreation count.
|
||||
#[must_use]
|
||||
pub const fn swapchain_recreate_count(&self) -> u32 {
|
||||
self.swapchain_recreate_count
|
||||
}
|
||||
|
||||
/// Explicitly idles and tears down the renderer while the native window is still alive.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanSmokeRendererError`] when the renderer cannot reach a
|
||||
/// stable idle point before teardown begins.
|
||||
pub fn shutdown(mut self) -> Result<VulkanSmokeShutdownReport, VulkanSmokeRendererError> {
|
||||
self.shutdown_inner()
|
||||
}
|
||||
|
||||
/// Requests swapchain recreation for a new drawable extent.
|
||||
pub fn request_resize(&mut self, extent: (u32, u32)) {
|
||||
self.pending_extent = Some(extent);
|
||||
}
|
||||
|
||||
fn device_ref(&self) -> Result<&VulkanLogicalDeviceProbe, VulkanSmokeRendererError> {
|
||||
self.device
|
||||
.as_ref()
|
||||
.ok_or(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "logical device",
|
||||
})
|
||||
}
|
||||
|
||||
fn swapchain_ref(&self) -> Result<&VulkanSwapchainProbe, VulkanSmokeRendererError> {
|
||||
self.swapchain
|
||||
.as_ref()
|
||||
.ok_or(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "swapchain",
|
||||
})
|
||||
}
|
||||
|
||||
fn instance_ref(&self) -> Result<&VulkanInstanceProbe, VulkanSmokeRendererError> {
|
||||
self.instance
|
||||
.as_ref()
|
||||
.ok_or(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "instance",
|
||||
})
|
||||
}
|
||||
|
||||
fn surface_ref(&self) -> Result<&VulkanSurfaceProbe, VulkanSmokeRendererError> {
|
||||
self.surface
|
||||
.as_ref()
|
||||
.ok_or(VulkanSmokeRendererError::InvariantViolation { context: "surface" })
|
||||
}
|
||||
|
||||
fn resources_ref(&self) -> Result<&VulkanSwapchainResources, VulkanSmokeRendererError> {
|
||||
self.swapchain_resources
|
||||
.as_ref()
|
||||
.ok_or(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "swapchain resources",
|
||||
})
|
||||
}
|
||||
|
||||
fn vertex_buffer_ref(&self) -> Result<&VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
||||
self.vertex_buffer
|
||||
.as_ref()
|
||||
.ok_or(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "vertex buffer",
|
||||
})
|
||||
}
|
||||
|
||||
fn index_buffer_ref(&self) -> Result<&VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
||||
self.index_buffer
|
||||
.as_ref()
|
||||
.ok_or(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "index buffer",
|
||||
})
|
||||
}
|
||||
|
||||
/// Draws and presents one indexed-triangle frame.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanSmokeRendererError`] when synchronization, command recording,
|
||||
/// submission, or presentation fails.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn draw_frame(&mut self) -> Result<VulkanSmokeFrameOutcome, VulkanSmokeRendererError> {
|
||||
if let Some(extent) = self.pending_extent.take() {
|
||||
if extent.0 == 0 || extent.1 == 0 {
|
||||
self.pending_extent = Some(extent);
|
||||
return Ok(VulkanSmokeFrameOutcome::ZeroExtent);
|
||||
}
|
||||
self.recreate_swapchain(extent)?;
|
||||
return Ok(VulkanSmokeFrameOutcome::Recreated);
|
||||
}
|
||||
|
||||
let sync = &self.frame_sync[self.current_frame];
|
||||
let image_available = sync.image_available;
|
||||
let render_finished = sync.render_finished;
|
||||
let in_flight_fence = sync.fence;
|
||||
// SAFETY: The fence belongs to this live logical device and is waited from one thread.
|
||||
unsafe {
|
||||
self.device_ref()?
|
||||
.device()
|
||||
.wait_for_fences(&[in_flight_fence], true, 1_000_000_000)
|
||||
}
|
||||
.map_err(|error| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkWaitForFences",
|
||||
result: error,
|
||||
})?;
|
||||
// SAFETY: The swapchain, semaphore and fence inputs are live for the duration of the acquire call.
|
||||
let acquire = unsafe {
|
||||
self.swapchain_ref()?.loader().acquire_next_image(
|
||||
self.swapchain_ref()?.swapchain(),
|
||||
1_000_000_000,
|
||||
image_available,
|
||||
vk::Fence::null(),
|
||||
)
|
||||
};
|
||||
let (image_index, acquire_suboptimal) = match acquire {
|
||||
Ok(result) => result,
|
||||
Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => {
|
||||
self.recreate_swapchain(self.report.swapchain_extent)?;
|
||||
return Ok(VulkanSmokeFrameOutcome::Recreated);
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkAcquireNextImageKHR",
|
||||
result: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
let image_index_usize = usize::try_from(image_index).unwrap_or(0);
|
||||
let image_fence = self.images_in_flight[image_index_usize];
|
||||
if image_fence != vk::Fence::null() {
|
||||
// SAFETY: The fence belongs to this renderer and can be waited independently.
|
||||
unsafe {
|
||||
self.device_ref()?
|
||||
.device()
|
||||
.wait_for_fences(&[image_fence], true, 1_000_000_000)
|
||||
}
|
||||
.map_err(|error| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkWaitForFences(image)",
|
||||
result: error,
|
||||
})?;
|
||||
}
|
||||
self.images_in_flight[image_index_usize] = in_flight_fence;
|
||||
// SAFETY: The fence belongs to this frame context and is not in use after the wait above.
|
||||
unsafe { self.device_ref()?.device().reset_fences(&[in_flight_fence]) }.map_err(
|
||||
|error| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkResetFences",
|
||||
result: error,
|
||||
},
|
||||
)?;
|
||||
|
||||
self.record_command_buffer(image_index_usize)?;
|
||||
let wait_semaphores = [image_available];
|
||||
let wait_stages = [vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT];
|
||||
let command_buffers = [self.resources_ref()?.command_buffers[image_index_usize]];
|
||||
let signal_semaphores = [render_finished];
|
||||
let submit_info = [vk::SubmitInfo::default()
|
||||
.wait_semaphores(&wait_semaphores)
|
||||
.wait_dst_stage_mask(&wait_stages)
|
||||
.command_buffers(&command_buffers)
|
||||
.signal_semaphores(&signal_semaphores)];
|
||||
// SAFETY: Submission references live queue, sync objects and recorded command buffer.
|
||||
unsafe {
|
||||
self.device_ref()?.device().queue_submit(
|
||||
self.device_ref()?.graphics_queue(),
|
||||
&submit_info,
|
||||
in_flight_fence,
|
||||
)
|
||||
}
|
||||
.map_err(|error| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkQueueSubmit",
|
||||
result: error,
|
||||
})?;
|
||||
|
||||
let present_wait = [render_finished];
|
||||
let swapchains = [self.swapchain_ref()?.swapchain()];
|
||||
let image_indices = [image_index];
|
||||
let present_info = vk::PresentInfoKHR::default()
|
||||
.wait_semaphores(&present_wait)
|
||||
.swapchains(&swapchains)
|
||||
.image_indices(&image_indices);
|
||||
// SAFETY: Presentation uses the rendered image index and a semaphore signaled by queue submission.
|
||||
let present_suboptimal = match unsafe {
|
||||
self.swapchain_ref()?
|
||||
.loader()
|
||||
.queue_present(self.device_ref()?.present_queue(), &present_info)
|
||||
} {
|
||||
Ok(suboptimal) => suboptimal,
|
||||
Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => {
|
||||
self.recreate_swapchain(self.report.swapchain_extent)?;
|
||||
return Ok(VulkanSmokeFrameOutcome::Recreated);
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkQueuePresentKHR",
|
||||
result: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
self.current_frame = (self.current_frame + 1) % self.frame_sync.len().max(1);
|
||||
if acquire_suboptimal || present_suboptimal {
|
||||
self.recreate_swapchain(self.report.swapchain_extent)?;
|
||||
Ok(VulkanSmokeFrameOutcome::Recreated)
|
||||
} else {
|
||||
Ok(VulkanSmokeFrameOutcome::Presented)
|
||||
}
|
||||
}
|
||||
|
||||
fn recreate_swapchain(&mut self, extent: (u32, u32)) -> Result<(), VulkanSmokeRendererError> {
|
||||
let device = self.device_ref()?;
|
||||
// SAFETY: The logical device remains live and idling at swapchain recreation boundaries.
|
||||
unsafe { device.device().device_wait_idle() }.map_err(|error| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkDeviceWaitIdle",
|
||||
result: error,
|
||||
}
|
||||
})?;
|
||||
self.pending_extent = None;
|
||||
self.rebuild_swapchain(extent)?;
|
||||
self.swapchain_recreate_count = self.swapchain_recreate_count.saturating_add(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rebuild_swapchain(&mut self, extent: (u32, u32)) -> Result<(), VulkanSmokeRendererError> {
|
||||
self.destroy_swapchain_resources();
|
||||
let instance = self.instance_ref()?;
|
||||
let surface = self.surface_ref()?;
|
||||
let device = self.device_ref()?;
|
||||
let old_swapchain = self
|
||||
.swapchain
|
||||
.as_ref()
|
||||
.map_or(vk::SwapchainKHR::null(), VulkanSwapchainProbe::swapchain);
|
||||
let new_swapchain = create_vulkan_swapchain_probe_for_extent(
|
||||
instance,
|
||||
surface,
|
||||
device,
|
||||
extent,
|
||||
old_swapchain,
|
||||
)
|
||||
.map_err(VulkanSmokeRendererError::Swapchain)?;
|
||||
self.swapchain = Some(new_swapchain);
|
||||
self.rebuild_swapchain_resources(true)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rebuild_swapchain_resources(
|
||||
&mut self,
|
||||
reuse_command_pool: bool,
|
||||
) -> Result<(), VulkanSmokeRendererError> {
|
||||
let device = self.device_ref()?;
|
||||
let swapchain = self.swapchain_ref()?;
|
||||
let resources = RollbackOnDrop::new(
|
||||
create_swapchain_resources(
|
||||
device,
|
||||
swapchain,
|
||||
self.command_pool,
|
||||
self.vertex_buffer_ref()?,
|
||||
self.index_buffer_ref()?,
|
||||
reuse_command_pool,
|
||||
)?,
|
||||
|resources| destroy_swapchain_resources(device, self.command_pool, resources),
|
||||
);
|
||||
let frame_sync = create_frame_sync(device)?;
|
||||
let swapchain_extent = self.swapchain_ref()?.report.plan.extent;
|
||||
let swapchain_image_count = self.swapchain_ref()?.report.image_count;
|
||||
let resources = resources.commit();
|
||||
self.images_in_flight = vec![vk::Fence::null(); resources.image_views.len()];
|
||||
self.frame_sync = frame_sync;
|
||||
self.report.swapchain_extent = swapchain_extent;
|
||||
self.report.swapchain_image_count = swapchain_image_count;
|
||||
self.swapchain_resources = Some(resources);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn record_command_buffer(
|
||||
&mut self,
|
||||
image_index: usize,
|
||||
) -> Result<(), VulkanSmokeRendererError> {
|
||||
let device = self.device_ref()?;
|
||||
let swapchain = self.swapchain_ref()?;
|
||||
let resources = self.resources_ref()?;
|
||||
let command_buffer = resources.command_buffers[image_index];
|
||||
// SAFETY: The command buffer belongs to the resettable pool owned by this renderer.
|
||||
unsafe {
|
||||
device
|
||||
.device()
|
||||
.reset_command_buffer(command_buffer, vk::CommandBufferResetFlags::empty())
|
||||
}
|
||||
.map_err(|error| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkResetCommandBuffer",
|
||||
result: error,
|
||||
})?;
|
||||
let begin_info = vk::CommandBufferBeginInfo::default();
|
||||
// SAFETY: The command buffer is in the initial state after reset and recorded on one thread.
|
||||
unsafe {
|
||||
device
|
||||
.device()
|
||||
.begin_command_buffer(command_buffer, &begin_info)
|
||||
}
|
||||
.map_err(|error| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkBeginCommandBuffer",
|
||||
result: error,
|
||||
})?;
|
||||
|
||||
let clear_values = [vk::ClearValue {
|
||||
color: vk::ClearColorValue {
|
||||
float32: [0.05, 0.08, 0.11, 1.0],
|
||||
},
|
||||
}];
|
||||
let render_area = vk::Rect2D {
|
||||
offset: vk::Offset2D { x: 0, y: 0 },
|
||||
extent: vk::Extent2D {
|
||||
width: swapchain.report.plan.extent.0,
|
||||
height: swapchain.report.plan.extent.1,
|
||||
},
|
||||
};
|
||||
let render_pass_info = vk::RenderPassBeginInfo::default()
|
||||
.render_pass(resources.render_pass)
|
||||
.framebuffer(resources.framebuffers[image_index])
|
||||
.render_area(render_area)
|
||||
.clear_values(&clear_values);
|
||||
// SAFETY: All commands target live frame resources owned by this renderer.
|
||||
unsafe {
|
||||
device.device().cmd_begin_render_pass(
|
||||
command_buffer,
|
||||
&render_pass_info,
|
||||
vk::SubpassContents::INLINE,
|
||||
);
|
||||
device.device().cmd_bind_pipeline(
|
||||
command_buffer,
|
||||
vk::PipelineBindPoint::GRAPHICS,
|
||||
resources.pipeline,
|
||||
);
|
||||
let vertex_buffers = [self.vertex_buffer_ref()?.buffer];
|
||||
let offsets = [0_u64];
|
||||
device
|
||||
.device()
|
||||
.cmd_bind_vertex_buffers(command_buffer, 0, &vertex_buffers, &offsets);
|
||||
device.device().cmd_bind_index_buffer(
|
||||
command_buffer,
|
||||
self.index_buffer_ref()?.buffer,
|
||||
0,
|
||||
vk::IndexType::UINT16,
|
||||
);
|
||||
device
|
||||
.device()
|
||||
.cmd_draw_indexed(command_buffer, 3, 1, 0, 0, 0);
|
||||
device.device().cmd_end_render_pass(command_buffer);
|
||||
}
|
||||
|
||||
// SAFETY: The render pass owns the attachment layout transitions for this clear-and-present path.
|
||||
unsafe { device.device().end_command_buffer(command_buffer) }.map_err(|error| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkEndCommandBuffer",
|
||||
result: error,
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn destroy_swapchain_resources(&mut self) {
|
||||
let Some(device) = self.device.as_ref() else {
|
||||
return;
|
||||
};
|
||||
for sync in self.frame_sync.drain(..) {
|
||||
// SAFETY: These sync objects belong to this device and are destroyed once.
|
||||
unsafe {
|
||||
device
|
||||
.device()
|
||||
.destroy_semaphore(sync.image_available, None);
|
||||
device
|
||||
.device()
|
||||
.destroy_semaphore(sync.render_finished, None);
|
||||
device.device().destroy_fence(sync.fence, None);
|
||||
}
|
||||
}
|
||||
if let Some(resources) = self.swapchain_resources.take() {
|
||||
destroy_swapchain_resources(device, self.command_pool, resources);
|
||||
}
|
||||
self.images_in_flight.clear();
|
||||
self.current_frame = 0;
|
||||
}
|
||||
|
||||
fn destroy_device_owned_resources(&mut self) {
|
||||
self.destroy_swapchain_resources();
|
||||
if let Some(device) = self.device.as_ref() {
|
||||
if let Some(buffer) = self.index_buffer.take() {
|
||||
// SAFETY: Buffer and memory belong to this device and are destroyed once after the device has been idled and frame work has been torn down.
|
||||
unsafe {
|
||||
device.device().destroy_buffer(buffer.buffer, None);
|
||||
device.device().free_memory(buffer.memory, None);
|
||||
}
|
||||
}
|
||||
if let Some(buffer) = self.vertex_buffer.take() {
|
||||
// SAFETY: Buffer and memory belong to this device and are destroyed once after the device has been idled and frame work has been torn down.
|
||||
unsafe {
|
||||
device.device().destroy_buffer(buffer.buffer, None);
|
||||
device.device().free_memory(buffer.memory, None);
|
||||
}
|
||||
}
|
||||
// SAFETY: The command pool belongs to this device and is destroyed once after the device is idle and all command buffers allocated from it were freed above.
|
||||
unsafe {
|
||||
device
|
||||
.device()
|
||||
.destroy_command_pool(self.command_pool, None);
|
||||
};
|
||||
}
|
||||
self.pending_extent = None;
|
||||
}
|
||||
|
||||
fn shutdown_inner(&mut self) -> Result<VulkanSmokeShutdownReport, VulkanSmokeRendererError> {
|
||||
if let Some(device) = self.device.as_ref() {
|
||||
// SAFETY: The logical device remains live until teardown finishes and idling prevents in-flight work from touching swapchain, buffers, sync objects or the command pool after destruction starts.
|
||||
unsafe { device.device().device_wait_idle() }.map_err(|error| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkDeviceWaitIdle",
|
||||
result: error,
|
||||
}
|
||||
})?;
|
||||
}
|
||||
self.destroy_device_owned_resources();
|
||||
let validation = take_runtime_owners_with_validation_snapshot(
|
||||
&mut self.instance,
|
||||
&mut self.validation,
|
||||
&mut self.surface,
|
||||
&mut self.device,
|
||||
&mut self.swapchain,
|
||||
VulkanValidationMessenger::report,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
Ok(VulkanSmokeShutdownReport {
|
||||
renderer_report: self.report.clone(),
|
||||
swapchain_recreate_count: self.swapchain_recreate_count,
|
||||
validation,
|
||||
})
|
||||
}
|
||||
|
||||
fn teardown(&mut self) {
|
||||
if let Some(device) = self.device.as_ref() {
|
||||
// SAFETY: The logical device remains live until teardown finishes and idling prevents in-flight work from touching swapchain, buffers, sync objects or the command pool after destruction starts.
|
||||
let _ = unsafe { device.device().device_wait_idle() };
|
||||
}
|
||||
self.destroy_device_owned_resources();
|
||||
let _ = take_runtime_owners_with_validation_snapshot(
|
||||
&mut self.instance,
|
||||
&mut self.validation,
|
||||
&mut self.surface,
|
||||
&mut self.device,
|
||||
&mut self.swapchain,
|
||||
VulkanValidationMessenger::report,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VulkanSmokeRenderer {
|
||||
fn drop(&mut self) {
|
||||
self.teardown();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
take_runtime_owners_in_dependency_order, take_runtime_owners_with_validation_snapshot,
|
||||
RollbackOnDrop,
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum TeardownStep {
|
||||
Snapshot,
|
||||
Instance,
|
||||
Validation,
|
||||
Surface,
|
||||
Device,
|
||||
Swapchain,
|
||||
}
|
||||
|
||||
struct DropTracker {
|
||||
step: TeardownStep,
|
||||
log: Rc<RefCell<Vec<TeardownStep>>>,
|
||||
}
|
||||
|
||||
impl Drop for DropTracker {
|
||||
fn drop(&mut self) {
|
||||
self.log.borrow_mut().push(self.step);
|
||||
}
|
||||
}
|
||||
|
||||
fn tracker(step: TeardownStep, log: &Rc<RefCell<Vec<TeardownStep>>>) -> DropTracker {
|
||||
DropTracker {
|
||||
step,
|
||||
log: Rc::clone(log),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_teardown_steps(present_steps: &[TeardownStep]) -> Vec<TeardownStep> {
|
||||
let log = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut instance = present_steps
|
||||
.contains(&TeardownStep::Instance)
|
||||
.then(|| tracker(TeardownStep::Instance, &log));
|
||||
let mut validation = present_steps
|
||||
.contains(&TeardownStep::Validation)
|
||||
.then(|| tracker(TeardownStep::Validation, &log));
|
||||
let mut surface = present_steps
|
||||
.contains(&TeardownStep::Surface)
|
||||
.then(|| tracker(TeardownStep::Surface, &log));
|
||||
let mut device = present_steps
|
||||
.contains(&TeardownStep::Device)
|
||||
.then(|| tracker(TeardownStep::Device, &log));
|
||||
let mut swapchain = present_steps
|
||||
.contains(&TeardownStep::Swapchain)
|
||||
.then(|| tracker(TeardownStep::Swapchain, &log));
|
||||
|
||||
take_runtime_owners_in_dependency_order(
|
||||
&mut instance,
|
||||
&mut validation,
|
||||
&mut surface,
|
||||
&mut device,
|
||||
&mut swapchain,
|
||||
);
|
||||
Rc::into_inner(log)
|
||||
.expect("all drop trackers released")
|
||||
.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_owners_drop_in_explicit_dependency_order() {
|
||||
assert_eq!(
|
||||
record_teardown_steps(&[
|
||||
TeardownStep::Instance,
|
||||
TeardownStep::Validation,
|
||||
TeardownStep::Surface,
|
||||
TeardownStep::Device,
|
||||
TeardownStep::Swapchain,
|
||||
]),
|
||||
vec![
|
||||
TeardownStep::Swapchain,
|
||||
TeardownStep::Device,
|
||||
TeardownStep::Surface,
|
||||
TeardownStep::Validation,
|
||||
TeardownStep::Instance,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_owners_drop_remaining_children_after_partial_init_failures() {
|
||||
let cases = [
|
||||
(vec![TeardownStep::Instance], vec![TeardownStep::Instance]),
|
||||
(
|
||||
vec![TeardownStep::Instance, TeardownStep::Validation],
|
||||
vec![TeardownStep::Validation, TeardownStep::Instance],
|
||||
),
|
||||
(
|
||||
vec![
|
||||
TeardownStep::Instance,
|
||||
TeardownStep::Validation,
|
||||
TeardownStep::Surface,
|
||||
],
|
||||
vec![
|
||||
TeardownStep::Surface,
|
||||
TeardownStep::Validation,
|
||||
TeardownStep::Instance,
|
||||
],
|
||||
),
|
||||
(
|
||||
vec![
|
||||
TeardownStep::Instance,
|
||||
TeardownStep::Validation,
|
||||
TeardownStep::Surface,
|
||||
TeardownStep::Device,
|
||||
],
|
||||
vec![
|
||||
TeardownStep::Device,
|
||||
TeardownStep::Surface,
|
||||
TeardownStep::Validation,
|
||||
TeardownStep::Instance,
|
||||
],
|
||||
),
|
||||
(
|
||||
vec![
|
||||
TeardownStep::Instance,
|
||||
TeardownStep::Surface,
|
||||
TeardownStep::Device,
|
||||
TeardownStep::Swapchain,
|
||||
],
|
||||
vec![
|
||||
TeardownStep::Swapchain,
|
||||
TeardownStep::Device,
|
||||
TeardownStep::Surface,
|
||||
TeardownStep::Instance,
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
for (present_steps, expected) in cases {
|
||||
assert_eq!(record_teardown_steps(&present_steps), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_validation_snapshot_is_captured_before_validation_drop() {
|
||||
let log = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut instance = Some(tracker(TeardownStep::Instance, &log));
|
||||
let mut validation = Some(tracker(TeardownStep::Validation, &log));
|
||||
let mut surface = Some(tracker(TeardownStep::Surface, &log));
|
||||
let mut device = Some(tracker(TeardownStep::Device, &log));
|
||||
let mut swapchain = Some(tracker(TeardownStep::Swapchain, &log));
|
||||
|
||||
let snapshot = take_runtime_owners_with_validation_snapshot(
|
||||
&mut instance,
|
||||
&mut validation,
|
||||
&mut surface,
|
||||
&mut device,
|
||||
&mut swapchain,
|
||||
|_| {
|
||||
log.borrow_mut().push(TeardownStep::Snapshot);
|
||||
TeardownStep::Validation
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(snapshot, Some(TeardownStep::Validation));
|
||||
assert_eq!(
|
||||
Rc::into_inner(log)
|
||||
.expect("all drop trackers released")
|
||||
.into_inner(),
|
||||
vec![
|
||||
TeardownStep::Snapshot,
|
||||
TeardownStep::Swapchain,
|
||||
TeardownStep::Device,
|
||||
TeardownStep::Surface,
|
||||
TeardownStep::Validation,
|
||||
TeardownStep::Instance,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_guard_runs_cleanup_when_later_step_fails() {
|
||||
let log = Rc::new(RefCell::new(Vec::new()));
|
||||
{
|
||||
let _guard = RollbackOnDrop::new(tracker(TeardownStep::Swapchain, &log), |tracker| {
|
||||
drop(tracker)
|
||||
});
|
||||
}
|
||||
|
||||
assert_eq!(log.borrow().as_slice(), &[TeardownStep::Swapchain]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_guard_skips_cleanup_after_commit() {
|
||||
let log = Rc::new(RefCell::new(Vec::new()));
|
||||
let tracker = RollbackOnDrop::new(tracker(TeardownStep::Swapchain, &log), |tracker| {
|
||||
drop(tracker)
|
||||
})
|
||||
.commit();
|
||||
assert!(log.borrow().is_empty());
|
||||
|
||||
drop(tracker);
|
||||
|
||||
assert_eq!(log.borrow().as_slice(), &[TeardownStep::Swapchain]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
use ash::vk;
|
||||
use fparkan_platform::{NativeWindowHandles, RenderRequest};
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{
|
||||
VulkanAllocatedBuffer, VulkanFrameSync, VulkanInstanceError, VulkanInstanceProbe,
|
||||
VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanSurfaceError, VulkanSurfaceProbe,
|
||||
VulkanSwapchainProbe, VulkanSwapchainProbeError, VulkanSwapchainResources,
|
||||
VulkanValidationMessenger,
|
||||
};
|
||||
use crate::shader_manifest::VulkanShaderManifestError;
|
||||
|
||||
/// Creates a live native Vulkan renderer for the Stage 0 smoke loop.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VulkanSmokeRendererCreateInfo {
|
||||
/// Application name reported to the Vulkan loader.
|
||||
pub application_name: String,
|
||||
/// Native window/display handles borrowed from a live window.
|
||||
pub native_handles: NativeWindowHandles,
|
||||
/// Initial drawable extent.
|
||||
pub drawable_extent: (u32, u32),
|
||||
/// Stage 0 render request used for capability gating.
|
||||
pub render_request: RenderRequest,
|
||||
/// Whether validation layers must be enabled.
|
||||
pub enable_validation: bool,
|
||||
/// Optional shared bootstrap progress tracker for failure evidence.
|
||||
pub bootstrap_progress: Option<Arc<VulkanSmokeBootstrapProgress>>,
|
||||
}
|
||||
|
||||
/// Shared bootstrap progress used to report partial renderer startup evidence.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct VulkanSmokeBootstrapProgress {
|
||||
flags: AtomicU8,
|
||||
}
|
||||
|
||||
impl VulkanSmokeBootstrapProgress {
|
||||
/// Marks the Vulkan loader as available.
|
||||
pub fn mark_loader_available(&self) {
|
||||
self.set_flag(BOOTSTRAP_LOADER_AVAILABLE);
|
||||
}
|
||||
|
||||
/// Marks the Vulkan instance as created.
|
||||
pub fn mark_instance_created(&self) {
|
||||
self.set_flag(BOOTSTRAP_INSTANCE_CREATED);
|
||||
}
|
||||
|
||||
/// Marks the Vulkan surface as created.
|
||||
pub fn mark_surface_created(&self) {
|
||||
self.set_flag(BOOTSTRAP_SURFACE_CREATED);
|
||||
}
|
||||
|
||||
/// Marks a suitable Vulkan device as selected and the logical device as created.
|
||||
pub fn mark_logical_device_created(&self) {
|
||||
self.set_flag(BOOTSTRAP_DEVICE_SELECTED | BOOTSTRAP_LOGICAL_DEVICE_CREATED);
|
||||
}
|
||||
|
||||
/// Marks the Vulkan swapchain as created.
|
||||
pub fn mark_swapchain_created(&self) {
|
||||
self.set_flag(BOOTSTRAP_SWAPCHAIN_CREATED);
|
||||
}
|
||||
|
||||
/// Returns a stable snapshot of the measured bootstrap state.
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> VulkanSmokeBootstrapSnapshot {
|
||||
let flags = self.flags.load(Ordering::SeqCst);
|
||||
VulkanSmokeBootstrapSnapshot {
|
||||
loader_available: flags & BOOTSTRAP_LOADER_AVAILABLE != 0,
|
||||
instance_created: flags & BOOTSTRAP_INSTANCE_CREATED != 0,
|
||||
surface_created: flags & BOOTSTRAP_SURFACE_CREATED != 0,
|
||||
device_selected: flags & BOOTSTRAP_DEVICE_SELECTED != 0,
|
||||
logical_device_created: flags & BOOTSTRAP_LOGICAL_DEVICE_CREATED != 0,
|
||||
swapchain_created: flags & BOOTSTRAP_SWAPCHAIN_CREATED != 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_flag(&self, flag: u8) {
|
||||
self.flags.fetch_or(flag, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable snapshot of measured bootstrap progress.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct VulkanSmokeBootstrapSnapshot {
|
||||
/// Whether the Vulkan loader was resolved.
|
||||
pub loader_available: bool,
|
||||
/// Whether the Vulkan instance was created.
|
||||
pub instance_created: bool,
|
||||
/// Whether the Vulkan surface was created.
|
||||
pub surface_created: bool,
|
||||
/// Whether a suitable Vulkan device was selected.
|
||||
pub device_selected: bool,
|
||||
/// Whether the logical device was created.
|
||||
pub logical_device_created: bool,
|
||||
/// Whether the swapchain was created.
|
||||
pub swapchain_created: bool,
|
||||
}
|
||||
|
||||
const BOOTSTRAP_LOADER_AVAILABLE: u8 = 1 << 0;
|
||||
const BOOTSTRAP_INSTANCE_CREATED: u8 = 1 << 1;
|
||||
const BOOTSTRAP_SURFACE_CREATED: u8 = 1 << 2;
|
||||
const BOOTSTRAP_DEVICE_SELECTED: u8 = 1 << 3;
|
||||
const BOOTSTRAP_LOGICAL_DEVICE_CREATED: u8 = 1 << 4;
|
||||
const BOOTSTRAP_SWAPCHAIN_CREATED: u8 = 1 << 5;
|
||||
|
||||
/// Stable smoke renderer bootstrap report.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanSmokeRendererReport {
|
||||
/// Checked-in shader manifest hash used by the renderer.
|
||||
pub shader_manifest_hash: String,
|
||||
/// Whether portability enumeration was enabled at instance creation.
|
||||
pub portability_enumeration: bool,
|
||||
/// Whether the logical device enabled `VK_KHR_portability_subset`.
|
||||
pub portability_subset_enabled: bool,
|
||||
/// Selected device name.
|
||||
pub device_name: String,
|
||||
/// Graphics queue-family index.
|
||||
pub graphics_queue_family: u32,
|
||||
/// Present queue-family index.
|
||||
pub present_queue_family: u32,
|
||||
/// Enabled logical-device extension count.
|
||||
pub enabled_extension_count: u32,
|
||||
/// Current swapchain extent.
|
||||
pub swapchain_extent: (u32, u32),
|
||||
/// Current swapchain image count.
|
||||
pub swapchain_image_count: u32,
|
||||
}
|
||||
|
||||
/// Measured validation counters from the live smoke loop.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct VulkanValidationReport {
|
||||
/// Validation warnings observed by the debug messenger.
|
||||
pub warning_count: u32,
|
||||
/// Validation errors observed by the debug messenger.
|
||||
pub error_count: u32,
|
||||
/// Stable sorted VUID list.
|
||||
pub vuids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Final smoke renderer shutdown evidence captured after explicit teardown.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanSmokeShutdownReport {
|
||||
/// Stable renderer bootstrap and swapchain report.
|
||||
pub renderer_report: VulkanSmokeRendererReport,
|
||||
/// Measured swapchain recreation count for the completed smoke loop.
|
||||
pub swapchain_recreate_count: u32,
|
||||
/// Final validation snapshot captured before the debug messenger is destroyed.
|
||||
pub validation: VulkanValidationReport,
|
||||
}
|
||||
|
||||
/// Result of one rendered smoke frame.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum VulkanSmokeFrameOutcome {
|
||||
/// A frame was submitted and presented.
|
||||
Presented,
|
||||
/// Rendering was skipped because the swapchain had to be recreated.
|
||||
Recreated,
|
||||
/// Rendering was skipped because the drawable extent is zero.
|
||||
ZeroExtent,
|
||||
}
|
||||
|
||||
/// Live smoke renderer error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum VulkanSmokeRendererError {
|
||||
/// Instance bootstrap failed.
|
||||
Instance(VulkanInstanceError),
|
||||
/// Surface bootstrap failed.
|
||||
Surface(VulkanSurfaceError),
|
||||
/// Logical-device bootstrap failed.
|
||||
LogicalDevice(VulkanLogicalDeviceError),
|
||||
/// Swapchain bootstrap failed.
|
||||
Swapchain(VulkanSwapchainProbeError),
|
||||
/// Shader manifest validation failed.
|
||||
ShaderManifest(VulkanShaderManifestError),
|
||||
/// Vulkan operation failed.
|
||||
VulkanOperation {
|
||||
/// Operation context.
|
||||
context: &'static str,
|
||||
/// Raw Vulkan result code.
|
||||
result: vk::Result,
|
||||
},
|
||||
/// No suitable memory type exists for the required properties.
|
||||
MissingMemoryType {
|
||||
/// Operation context.
|
||||
context: &'static str,
|
||||
},
|
||||
/// Internal smoke renderer state was unexpectedly absent.
|
||||
InvariantViolation {
|
||||
/// Missing state context.
|
||||
context: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VulkanSmokeRendererError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Instance(error) => write!(f, "{error}"),
|
||||
Self::Surface(error) => write!(f, "{error}"),
|
||||
Self::LogicalDevice(error) => write!(f, "{error}"),
|
||||
Self::Swapchain(error) => write!(f, "{error}"),
|
||||
Self::ShaderManifest(error) => write!(f, "{error}"),
|
||||
Self::VulkanOperation { context, result } => {
|
||||
write!(f, "{context}: {result:?}")
|
||||
}
|
||||
Self::MissingMemoryType { context } => {
|
||||
write!(f, "{context}: no compatible Vulkan memory type")
|
||||
}
|
||||
Self::InvariantViolation { context } => {
|
||||
write!(f, "renderer invariant violated: {context}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VulkanSmokeRendererError {}
|
||||
|
||||
/// Live Stage 0 Vulkan triangle renderer used by the smoke app.
|
||||
pub struct VulkanSmokeRenderer {
|
||||
pub(super) instance: Option<VulkanInstanceProbe>,
|
||||
pub(super) validation: Option<VulkanValidationMessenger>,
|
||||
pub(super) surface: Option<VulkanSurfaceProbe>,
|
||||
pub(super) device: Option<VulkanLogicalDeviceProbe>,
|
||||
pub(super) swapchain: Option<VulkanSwapchainProbe>,
|
||||
pub(super) command_pool: vk::CommandPool,
|
||||
pub(super) swapchain_resources: Option<VulkanSwapchainResources>,
|
||||
pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>,
|
||||
pub(super) index_buffer: Option<VulkanAllocatedBuffer>,
|
||||
pub(super) frame_sync: Vec<VulkanFrameSync>,
|
||||
pub(super) images_in_flight: Vec<vk::Fence>,
|
||||
pub(super) current_frame: usize,
|
||||
pub(super) pending_extent: Option<(u32, u32)>,
|
||||
pub(super) swapchain_recreate_count: u32,
|
||||
pub(super) report: VulkanSmokeRendererReport,
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use ash::{khr::surface, vk};
|
||||
use fparkan_platform::NativeWindowHandles;
|
||||
use serde::Serialize;
|
||||
use std::ffi::CStr;
|
||||
use std::os::raw::c_char;
|
||||
|
||||
use super::VulkanInstanceProbe;
|
||||
use crate::policy::serialize_json_or_fallback;
|
||||
|
||||
/// Deterministic Vulkan surface creation plan.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanSurfacePlan {
|
||||
/// Report schema version.
|
||||
pub schema: u32,
|
||||
/// Instance extensions required by the native display backend.
|
||||
pub required_instance_extensions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Vulkan surface bootstrap error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum VulkanSurfaceError {
|
||||
/// No native raw window/display handles were available.
|
||||
MissingNativeHandles,
|
||||
/// Required platform surface extensions could not be enumerated.
|
||||
RequiredExtensionsFailed {
|
||||
/// Vulkan result.
|
||||
result: vk::Result,
|
||||
},
|
||||
/// A required extension pointer was not valid UTF-8.
|
||||
InvalidExtensionName,
|
||||
/// Surface creation failed.
|
||||
CreateFailed {
|
||||
/// Vulkan result.
|
||||
result: vk::Result,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VulkanSurfaceError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingNativeHandles => {
|
||||
write!(
|
||||
f,
|
||||
"native window/display handles are required for Vulkan surface creation"
|
||||
)
|
||||
}
|
||||
Self::RequiredExtensionsFailed { result } => write!(
|
||||
f,
|
||||
"failed to enumerate required Vulkan surface extensions: {result:?}"
|
||||
),
|
||||
Self::InvalidExtensionName => {
|
||||
write!(f, "Vulkan surface extension name is not valid UTF-8")
|
||||
}
|
||||
Self::CreateFailed { result } => {
|
||||
write!(f, "Vulkan surface creation failed: {result:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VulkanSurfaceError {}
|
||||
|
||||
/// Created Vulkan surface probe.
|
||||
pub struct VulkanSurfaceProbe {
|
||||
pub(super) loader: surface::Instance,
|
||||
pub(super) surface: vk::SurfaceKHR,
|
||||
/// Deterministic surface creation report.
|
||||
pub report: VulkanSurfacePlan,
|
||||
}
|
||||
|
||||
impl Drop for VulkanSurfaceProbe {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: The `SurfaceKHR` was created by this probe and is destroyed once during drop.
|
||||
unsafe { self.loader.destroy_surface(self.surface, None) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a deterministic Vulkan surface plan from native window handles.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanSurfaceError`] when no native handles exist or the platform
|
||||
/// display backend has no Vulkan surface extension mapping.
|
||||
pub fn plan_vulkan_surface(
|
||||
handles: Option<NativeWindowHandles>,
|
||||
) -> Result<VulkanSurfacePlan, VulkanSurfaceError> {
|
||||
let handles = handles.ok_or(VulkanSurfaceError::MissingNativeHandles)?;
|
||||
let required = ash_window::enumerate_required_extensions(handles.display)
|
||||
.map_err(|error| VulkanSurfaceError::RequiredExtensionsFailed { result: error })?;
|
||||
let mut required_instance_extensions = Vec::with_capacity(required.len());
|
||||
for extension in required {
|
||||
let name = extension_name(*extension)?;
|
||||
required_instance_extensions.push(name);
|
||||
}
|
||||
required_instance_extensions.sort();
|
||||
required_instance_extensions.dedup();
|
||||
Ok(VulkanSurfacePlan {
|
||||
schema: 1,
|
||||
required_instance_extensions,
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a Vulkan surface probe from native window handles.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanSurfaceError`] when handles are missing, required extensions
|
||||
/// cannot be planned, or `vkCreate*SurfaceKHR` fails.
|
||||
pub fn create_vulkan_surface_probe(
|
||||
instance: &VulkanInstanceProbe,
|
||||
handles: Option<NativeWindowHandles>,
|
||||
) -> Result<VulkanSurfaceProbe, VulkanSurfaceError> {
|
||||
let handles = handles.ok_or(VulkanSurfaceError::MissingNativeHandles)?;
|
||||
let report = plan_vulkan_surface(Some(handles))?;
|
||||
// SAFETY: The platform handles are only used to create a child surface owned by this probe.
|
||||
let surface = unsafe {
|
||||
ash_window::create_surface(
|
||||
&instance.entry,
|
||||
&instance.instance,
|
||||
handles.display,
|
||||
handles.window,
|
||||
None,
|
||||
)
|
||||
}
|
||||
.map_err(|error| VulkanSurfaceError::CreateFailed { result: error })?;
|
||||
Ok(VulkanSurfaceProbe {
|
||||
loader: surface::Instance::new(&instance.entry, &instance.instance),
|
||||
surface,
|
||||
report,
|
||||
})
|
||||
}
|
||||
|
||||
/// Renders a deterministic JSON Vulkan surface plan.
|
||||
#[must_use]
|
||||
pub fn render_surface_plan_json(plan: &VulkanSurfacePlan) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct SurfacePlanJson<'a> {
|
||||
schema: u32,
|
||||
required_instance_extensions: &'a [String],
|
||||
}
|
||||
|
||||
serialize_json_or_fallback(
|
||||
&SurfacePlanJson {
|
||||
schema: plan.schema,
|
||||
required_instance_extensions: &plan.required_instance_extensions,
|
||||
},
|
||||
"{\"schema\":0,\"required_instance_extensions\":[]}",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn extension_name(extension: *const c_char) -> Result<String, VulkanSurfaceError> {
|
||||
// SAFETY: `ash-window` returns extension pointers to static NUL-terminated Vulkan names.
|
||||
let name = unsafe { CStr::from_ptr(extension) };
|
||||
name.to_str()
|
||||
.map(str::to_string)
|
||||
.map_err(|_| VulkanSurfaceError::InvalidExtensionName)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use ash::{khr::swapchain, vk};
|
||||
|
||||
use super::{
|
||||
capabilities::{
|
||||
live_present_modes, live_surface_capabilities, live_surface_formats, unique_queue_families,
|
||||
},
|
||||
VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanRuntimeCapabilityError,
|
||||
VulkanSurfaceProbe,
|
||||
};
|
||||
use crate::policy::{
|
||||
plan_vulkan_swapchain, select_composite_alpha, VulkanSwapchainError, VulkanSwapchainPlan,
|
||||
VulkanSwapchainRequest,
|
||||
};
|
||||
|
||||
/// Created Vulkan swapchain probe.
|
||||
pub struct VulkanSwapchainProbe {
|
||||
loader: swapchain::Device,
|
||||
swapchain: vk::SwapchainKHR,
|
||||
/// Deterministic swapchain creation report.
|
||||
pub report: VulkanSwapchainReport,
|
||||
}
|
||||
|
||||
impl Drop for VulkanSwapchainProbe {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: The swapchain was created by this probe and is destroyed once during drop.
|
||||
unsafe { self.loader.destroy_swapchain(self.swapchain, None) };
|
||||
}
|
||||
}
|
||||
|
||||
impl VulkanSwapchainProbe {
|
||||
/// Returns the live swapchain handle.
|
||||
#[must_use]
|
||||
pub fn swapchain(&self) -> vk::SwapchainKHR {
|
||||
self.swapchain
|
||||
}
|
||||
|
||||
/// Returns the swapchain extension loader for this live swapchain.
|
||||
#[must_use]
|
||||
pub fn loader(&self) -> &swapchain::Device {
|
||||
&self.loader
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime swapchain creation report.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanSwapchainReport {
|
||||
/// Report schema version.
|
||||
pub schema: u32,
|
||||
/// Deterministic swapchain policy used for creation.
|
||||
pub plan: VulkanSwapchainPlan,
|
||||
/// Number of images returned by `vkGetSwapchainImagesKHR`.
|
||||
pub image_count: u32,
|
||||
}
|
||||
|
||||
/// Vulkan swapchain creation error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum VulkanSwapchainProbeError {
|
||||
/// Live runtime capability probing failed before swapchain creation.
|
||||
Runtime(VulkanRuntimeCapabilityError),
|
||||
/// Deterministic swapchain planning failed before create.
|
||||
Plan(VulkanSwapchainError),
|
||||
/// Surface capability query failed.
|
||||
SurfaceCapabilitiesFailed {
|
||||
/// Vulkan result.
|
||||
result: vk::Result,
|
||||
},
|
||||
/// Swapchain creation failed.
|
||||
CreateFailed {
|
||||
/// Vulkan result.
|
||||
result: vk::Result,
|
||||
},
|
||||
/// Swapchain image query failed.
|
||||
ImagesFailed {
|
||||
/// Vulkan result.
|
||||
result: vk::Result,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VulkanSwapchainProbeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Runtime(error) => write!(f, "{error}"),
|
||||
Self::Plan(error) => write!(f, "{error}"),
|
||||
Self::SurfaceCapabilitiesFailed { result } => {
|
||||
write!(f, "Vulkan surface capabilities query failed: {result:?}")
|
||||
}
|
||||
Self::CreateFailed { result } => {
|
||||
write!(f, "Vulkan swapchain creation failed: {result:?}")
|
||||
}
|
||||
Self::ImagesFailed { result } => {
|
||||
write!(f, "Vulkan swapchain image query failed: {result:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VulkanSwapchainProbeError {}
|
||||
|
||||
/// Creates a Vulkan swapchain for the live logical device and surface.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanSwapchainProbeError`] when live surface capability queries,
|
||||
/// swapchain creation, or swapchain image enumeration fails.
|
||||
pub fn create_vulkan_swapchain_probe(
|
||||
instance: &VulkanInstanceProbe,
|
||||
surface: &VulkanSurfaceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
) -> Result<VulkanSwapchainProbe, VulkanSwapchainProbeError> {
|
||||
create_vulkan_swapchain_probe_for_extent(
|
||||
instance,
|
||||
surface,
|
||||
device,
|
||||
device.runtime.swapchain.extent,
|
||||
vk::SwapchainKHR::null(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a Vulkan swapchain for the live logical device and surface at a specific extent.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanSwapchainProbeError`] when live surface capability queries,
|
||||
/// swapchain creation, or swapchain image enumeration fails.
|
||||
pub fn create_vulkan_swapchain_probe_for_extent(
|
||||
instance: &VulkanInstanceProbe,
|
||||
surface: &VulkanSurfaceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
drawable_extent: (u32, u32),
|
||||
old_swapchain: vk::SwapchainKHR,
|
||||
) -> Result<VulkanSwapchainProbe, VulkanSwapchainProbeError> {
|
||||
let raw_capabilities = {
|
||||
// SAFETY: The physical device and surface are live query inputs and no handles are retained.
|
||||
unsafe {
|
||||
surface
|
||||
.loader
|
||||
.get_physical_device_surface_capabilities(device.physical_device(), surface.surface)
|
||||
}
|
||||
}
|
||||
.map_err(|error| VulkanSwapchainProbeError::SurfaceCapabilitiesFailed { result: error })?;
|
||||
let surface_formats = live_surface_formats(
|
||||
surface,
|
||||
device.physical_device(),
|
||||
&device.report.device_name,
|
||||
)
|
||||
.map_err(VulkanSwapchainProbeError::Runtime)?;
|
||||
let present_modes = live_present_modes(
|
||||
surface,
|
||||
device.physical_device(),
|
||||
&device.report.device_name,
|
||||
)
|
||||
.map_err(VulkanSwapchainProbeError::Runtime)?;
|
||||
let capabilities = live_surface_capabilities(
|
||||
surface,
|
||||
device.physical_device(),
|
||||
&device.report.device_name,
|
||||
)
|
||||
.map_err(VulkanSwapchainProbeError::Runtime)?;
|
||||
let plan = plan_vulkan_swapchain(&VulkanSwapchainRequest {
|
||||
drawable_extent,
|
||||
formats: surface_formats,
|
||||
present_modes,
|
||||
capabilities,
|
||||
preferred_present_mode: vk::PresentModeKHR::MAILBOX.as_raw(),
|
||||
})
|
||||
.map_err(VulkanSwapchainProbeError::Plan)?;
|
||||
let queue_family_indices = unique_queue_families(
|
||||
device.runtime.capability.graphics_queue_family,
|
||||
device.runtime.capability.present_queue_family,
|
||||
);
|
||||
let sharing_mode = if queue_family_indices.len() > 1 {
|
||||
vk::SharingMode::CONCURRENT
|
||||
} else {
|
||||
vk::SharingMode::EXCLUSIVE
|
||||
};
|
||||
let create_info = vk::SwapchainCreateInfoKHR::default()
|
||||
.surface(surface.surface)
|
||||
.min_image_count(plan.image_count)
|
||||
.image_format(vk::Format::from_raw(plan.format.format))
|
||||
.image_color_space(vk::ColorSpaceKHR::from_raw(plan.format.color_space))
|
||||
.image_extent(vk::Extent2D {
|
||||
width: plan.extent.0,
|
||||
height: plan.extent.1,
|
||||
})
|
||||
.image_array_layers(1)
|
||||
.image_usage(vk::ImageUsageFlags::COLOR_ATTACHMENT)
|
||||
.image_sharing_mode(sharing_mode)
|
||||
.queue_family_indices(&queue_family_indices)
|
||||
.pre_transform(raw_capabilities.current_transform)
|
||||
.composite_alpha(select_composite_alpha(
|
||||
raw_capabilities.supported_composite_alpha,
|
||||
))
|
||||
.present_mode(vk::PresentModeKHR::from_raw(plan.present_mode))
|
||||
.old_swapchain(old_swapchain)
|
||||
.clipped(true);
|
||||
let loader = swapchain::Device::new(&instance.instance, device.device());
|
||||
// SAFETY: The create info references live instance/device/surface handles for this call.
|
||||
let swapchain = unsafe { loader.create_swapchain(&create_info, None) }
|
||||
.map_err(|error| VulkanSwapchainProbeError::CreateFailed { result: error })?;
|
||||
// SAFETY: The swapchain was created above and the returned image handles are owned by it.
|
||||
let images = match unsafe { loader.get_swapchain_images(swapchain) } {
|
||||
Ok(images) => images,
|
||||
Err(error) => {
|
||||
// SAFETY: The swapchain was created above on this loader/device pair and is destroyed on setup failure.
|
||||
unsafe { loader.destroy_swapchain(swapchain, None) };
|
||||
return Err(VulkanSwapchainProbeError::ImagesFailed { result: error });
|
||||
}
|
||||
};
|
||||
Ok(VulkanSwapchainProbe {
|
||||
loader,
|
||||
swapchain,
|
||||
report: VulkanSwapchainReport {
|
||||
schema: 1,
|
||||
plan,
|
||||
image_count: images.len().try_into().unwrap_or(u32::MAX),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use ash::vk;
|
||||
|
||||
use super::{
|
||||
color_subresource_range, VulkanAllocatedBuffer, VulkanLogicalDeviceProbe,
|
||||
VulkanSmokeRendererError, VulkanSwapchainProbe, TRIANGLE_FRAGMENT_SHADER_WORDS,
|
||||
TRIANGLE_VERTEX_SHADER_WORDS,
|
||||
};
|
||||
|
||||
pub(super) struct VulkanSwapchainResources {
|
||||
pub(super) image_views: Vec<vk::ImageView>,
|
||||
pub(super) render_pass: vk::RenderPass,
|
||||
pub(super) pipeline_layout: vk::PipelineLayout,
|
||||
pub(super) pipeline: vk::Pipeline,
|
||||
pub(super) framebuffers: Vec<vk::Framebuffer>,
|
||||
pub(super) command_buffers: Vec<vk::CommandBuffer>,
|
||||
}
|
||||
|
||||
struct PartialSwapchainResources {
|
||||
image_views: Vec<vk::ImageView>,
|
||||
render_pass: Option<vk::RenderPass>,
|
||||
pipeline_layout: Option<vk::PipelineLayout>,
|
||||
pipeline: Option<vk::Pipeline>,
|
||||
framebuffers: Vec<vk::Framebuffer>,
|
||||
command_buffers: Vec<vk::CommandBuffer>,
|
||||
}
|
||||
|
||||
pub(super) fn create_swapchain_resources(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
swapchain: &VulkanSwapchainProbe,
|
||||
command_pool: vk::CommandPool,
|
||||
vertex_buffer: &VulkanAllocatedBuffer,
|
||||
index_buffer: &VulkanAllocatedBuffer,
|
||||
reuse_command_pool: bool,
|
||||
) -> Result<VulkanSwapchainResources, VulkanSmokeRendererError> {
|
||||
// SAFETY: The swapchain is live and owned by this renderer for the duration of the query.
|
||||
let images = unsafe {
|
||||
swapchain
|
||||
.loader()
|
||||
.get_swapchain_images(swapchain.swapchain())
|
||||
}
|
||||
.map_err(|error| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkGetSwapchainImagesKHR",
|
||||
result: error,
|
||||
})?;
|
||||
let mut partial = PartialSwapchainResources {
|
||||
image_views: create_swapchain_image_views(
|
||||
device,
|
||||
&images,
|
||||
swapchain.report.plan.format.format,
|
||||
)?,
|
||||
render_pass: None,
|
||||
pipeline_layout: None,
|
||||
pipeline: None,
|
||||
framebuffers: Vec::new(),
|
||||
command_buffers: Vec::new(),
|
||||
};
|
||||
let (render_pass, pipeline_layout, pipeline) = match create_swapchain_pipeline_bundle(
|
||||
device,
|
||||
swapchain.report.plan.format.format,
|
||||
swapchain.report.plan.extent,
|
||||
) {
|
||||
Ok(bundle) => bundle,
|
||||
Err(error) => {
|
||||
destroy_partial_swapchain_resources(device, command_pool, partial);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
partial.render_pass = Some(render_pass);
|
||||
partial.pipeline_layout = Some(pipeline_layout);
|
||||
partial.pipeline = Some(pipeline);
|
||||
let framebuffers = match create_swapchain_framebuffers(
|
||||
device,
|
||||
render_pass,
|
||||
&partial.image_views,
|
||||
swapchain.report.plan.extent,
|
||||
) {
|
||||
Ok(framebuffers) => framebuffers,
|
||||
Err(error) => {
|
||||
destroy_partial_swapchain_resources(device, command_pool, partial);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
partial.framebuffers = framebuffers;
|
||||
reset_reusable_command_pool(device, command_pool, reuse_command_pool)?;
|
||||
let command_buffers = match allocate_command_buffers(
|
||||
device,
|
||||
command_pool,
|
||||
u32::try_from(images.len()).unwrap_or(u32::MAX),
|
||||
) {
|
||||
Ok(command_buffers) => command_buffers,
|
||||
Err(error) => {
|
||||
destroy_partial_swapchain_resources(device, command_pool, partial);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
partial.command_buffers = command_buffers;
|
||||
let _ = (vertex_buffer, index_buffer);
|
||||
Ok(VulkanSwapchainResources {
|
||||
image_views: partial.image_views,
|
||||
render_pass,
|
||||
pipeline_layout,
|
||||
pipeline,
|
||||
framebuffers: partial.framebuffers,
|
||||
command_buffers: partial.command_buffers,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_swapchain_image_views(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
images: &[vk::Image],
|
||||
format: i32,
|
||||
) -> Result<Vec<vk::ImageView>, VulkanSmokeRendererError> {
|
||||
let mut image_views = Vec::with_capacity(images.len());
|
||||
for image in images.iter().copied() {
|
||||
image_views.push(create_image_view(device, image, format)?);
|
||||
}
|
||||
Ok(image_views)
|
||||
}
|
||||
|
||||
fn create_swapchain_pipeline_bundle(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
format: i32,
|
||||
extent: (u32, u32),
|
||||
) -> Result<(vk::RenderPass, vk::PipelineLayout, vk::Pipeline), VulkanSmokeRendererError> {
|
||||
let render_pass = create_render_pass(device, format)?;
|
||||
let pipeline_layout = create_pipeline_layout(device).inspect_err(|_| {
|
||||
// SAFETY: The render pass was created above on this live logical device and is destroyed on setup failure.
|
||||
unsafe { device.device().destroy_render_pass(render_pass, None) };
|
||||
})?;
|
||||
let pipeline = create_graphics_pipeline(device, render_pass, pipeline_layout, extent)
|
||||
.inspect_err(|_| {
|
||||
// SAFETY: These objects were created above on this live logical device and are destroyed on setup failure.
|
||||
unsafe {
|
||||
device
|
||||
.device()
|
||||
.destroy_pipeline_layout(pipeline_layout, None);
|
||||
device.device().destroy_render_pass(render_pass, None);
|
||||
}
|
||||
})?;
|
||||
Ok((render_pass, pipeline_layout, pipeline))
|
||||
}
|
||||
|
||||
fn create_swapchain_framebuffers(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
render_pass: vk::RenderPass,
|
||||
image_views: &[vk::ImageView],
|
||||
extent: (u32, u32),
|
||||
) -> Result<Vec<vk::Framebuffer>, VulkanSmokeRendererError> {
|
||||
let mut framebuffers = Vec::with_capacity(image_views.len());
|
||||
for image_view in image_views.iter().copied() {
|
||||
match create_framebuffer(device, render_pass, image_view, extent) {
|
||||
Ok(framebuffer) => framebuffers.push(framebuffer),
|
||||
Err(error) => {
|
||||
// SAFETY: These framebuffers were created above on this live logical device and are destroyed on setup failure.
|
||||
unsafe {
|
||||
for framebuffer in framebuffers.iter().copied() {
|
||||
device.device().destroy_framebuffer(framebuffer, None);
|
||||
}
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(framebuffers)
|
||||
}
|
||||
|
||||
fn reset_reusable_command_pool(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
command_pool: vk::CommandPool,
|
||||
reuse_command_pool: bool,
|
||||
) -> Result<(), VulkanSmokeRendererError> {
|
||||
if !reuse_command_pool {
|
||||
return Ok(());
|
||||
}
|
||||
// SAFETY: All command buffers allocated from the live pool are freed before reallocating them.
|
||||
unsafe {
|
||||
device
|
||||
.device()
|
||||
.reset_command_pool(command_pool, vk::CommandPoolResetFlags::empty())
|
||||
}
|
||||
.map_err(|error| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkResetCommandPool",
|
||||
result: error,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_image_view(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
image: vk::Image,
|
||||
format: i32,
|
||||
) -> Result<vk::ImageView, VulkanSmokeRendererError> {
|
||||
let create_info = vk::ImageViewCreateInfo::default()
|
||||
.image(image)
|
||||
.view_type(vk::ImageViewType::TYPE_2D)
|
||||
.format(vk::Format::from_raw(format))
|
||||
.subresource_range(color_subresource_range());
|
||||
// SAFETY: The image comes from the live swapchain and the subresource range covers its color aspect.
|
||||
unsafe { device.device().create_image_view(&create_info, None) }.map_err(|error| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateImageView",
|
||||
result: error,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_render_pass(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
format: i32,
|
||||
) -> Result<vk::RenderPass, VulkanSmokeRendererError> {
|
||||
let color_attachment = vk::AttachmentDescription::default()
|
||||
.format(vk::Format::from_raw(format))
|
||||
.samples(vk::SampleCountFlags::TYPE_1)
|
||||
.load_op(vk::AttachmentLoadOp::CLEAR)
|
||||
.store_op(vk::AttachmentStoreOp::STORE)
|
||||
.initial_layout(vk::ImageLayout::UNDEFINED)
|
||||
.final_layout(vk::ImageLayout::PRESENT_SRC_KHR);
|
||||
let color_attachment_ref = vk::AttachmentReference::default()
|
||||
.attachment(0)
|
||||
.layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL);
|
||||
let color_attachments = [color_attachment_ref];
|
||||
let subpass = vk::SubpassDescription::default()
|
||||
.pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
|
||||
.color_attachments(&color_attachments);
|
||||
let dependency = vk::SubpassDependency::default()
|
||||
.src_subpass(vk::SUBPASS_EXTERNAL)
|
||||
.dst_subpass(0)
|
||||
.src_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
|
||||
.dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
|
||||
.src_access_mask(vk::AccessFlags::empty())
|
||||
.dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE);
|
||||
let attachments = [color_attachment];
|
||||
let subpasses = [subpass];
|
||||
let dependencies = [dependency];
|
||||
let create_info = vk::RenderPassCreateInfo::default()
|
||||
.attachments(&attachments)
|
||||
.subpasses(&subpasses)
|
||||
.dependencies(&dependencies);
|
||||
// SAFETY: The render-pass create info only references stack-owned descriptors.
|
||||
unsafe { device.device().create_render_pass(&create_info, None) }.map_err(|error| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateRenderPass",
|
||||
result: error,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_pipeline_layout(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> {
|
||||
let create_info = vk::PipelineLayoutCreateInfo::default();
|
||||
// SAFETY: The pipeline layout has no descriptor sets or push constants in Stage 0 smoke.
|
||||
unsafe { device.device().create_pipeline_layout(&create_info, None) }.map_err(|error| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreatePipelineLayout",
|
||||
result: error,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
fn extent_component_to_f32(value: u32) -> f32 {
|
||||
value as f32
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn create_graphics_pipeline(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
render_pass: vk::RenderPass,
|
||||
pipeline_layout: vk::PipelineLayout,
|
||||
extent: (u32, u32),
|
||||
) -> Result<vk::Pipeline, VulkanSmokeRendererError> {
|
||||
let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS)?;
|
||||
let fragment_shader = match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS) {
|
||||
Ok(module) => module,
|
||||
Err(error) => {
|
||||
// SAFETY: The shader module was created above on this live logical device and is destroyed on setup failure.
|
||||
unsafe { device.device().destroy_shader_module(vertex_shader, None) };
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let entry_point = c"main";
|
||||
let shader_stages = [
|
||||
vk::PipelineShaderStageCreateInfo::default()
|
||||
.module(vertex_shader)
|
||||
.name(entry_point)
|
||||
.stage(vk::ShaderStageFlags::VERTEX),
|
||||
vk::PipelineShaderStageCreateInfo::default()
|
||||
.module(fragment_shader)
|
||||
.name(entry_point)
|
||||
.stage(vk::ShaderStageFlags::FRAGMENT),
|
||||
];
|
||||
let vertex_binding = vk::VertexInputBindingDescription::default()
|
||||
.binding(0)
|
||||
.stride(u32::try_from(5 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX))
|
||||
.input_rate(vk::VertexInputRate::VERTEX);
|
||||
let vertex_attributes = [
|
||||
vk::VertexInputAttributeDescription::default()
|
||||
.binding(0)
|
||||
.location(0)
|
||||
.format(vk::Format::R32G32_SFLOAT)
|
||||
.offset(0),
|
||||
vk::VertexInputAttributeDescription::default()
|
||||
.binding(0)
|
||||
.location(1)
|
||||
.format(vk::Format::R32G32B32_SFLOAT)
|
||||
.offset(u32::try_from(2 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
|
||||
];
|
||||
let vertex_bindings = [vertex_binding];
|
||||
let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default()
|
||||
.vertex_binding_descriptions(&vertex_bindings)
|
||||
.vertex_attribute_descriptions(&vertex_attributes);
|
||||
let input_assembly_state = vk::PipelineInputAssemblyStateCreateInfo::default()
|
||||
.topology(vk::PrimitiveTopology::TRIANGLE_LIST)
|
||||
.primitive_restart_enable(false);
|
||||
let viewports = [vk::Viewport {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: extent_component_to_f32(extent.0),
|
||||
height: extent_component_to_f32(extent.1),
|
||||
min_depth: 0.0,
|
||||
max_depth: 1.0,
|
||||
}];
|
||||
let scissors = [vk::Rect2D {
|
||||
offset: vk::Offset2D { x: 0, y: 0 },
|
||||
extent: vk::Extent2D {
|
||||
width: extent.0,
|
||||
height: extent.1,
|
||||
},
|
||||
}];
|
||||
let viewport_state = vk::PipelineViewportStateCreateInfo::default()
|
||||
.viewports(&viewports)
|
||||
.scissors(&scissors);
|
||||
let rasterization_state = vk::PipelineRasterizationStateCreateInfo::default()
|
||||
.depth_clamp_enable(false)
|
||||
.rasterizer_discard_enable(false)
|
||||
.polygon_mode(vk::PolygonMode::FILL)
|
||||
.line_width(1.0)
|
||||
.cull_mode(vk::CullModeFlags::BACK)
|
||||
.front_face(vk::FrontFace::CLOCKWISE)
|
||||
.depth_bias_enable(false);
|
||||
let multisample_state = vk::PipelineMultisampleStateCreateInfo::default()
|
||||
.sample_shading_enable(false)
|
||||
.rasterization_samples(vk::SampleCountFlags::TYPE_1);
|
||||
let color_blend_attachment = vk::PipelineColorBlendAttachmentState::default()
|
||||
.color_write_mask(
|
||||
vk::ColorComponentFlags::R
|
||||
| vk::ColorComponentFlags::G
|
||||
| vk::ColorComponentFlags::B
|
||||
| vk::ColorComponentFlags::A,
|
||||
)
|
||||
.blend_enable(false);
|
||||
let color_blend_attachments = [color_blend_attachment];
|
||||
let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default()
|
||||
.logic_op_enable(false)
|
||||
.attachments(&color_blend_attachments);
|
||||
let create_info = vk::GraphicsPipelineCreateInfo::default()
|
||||
.stages(&shader_stages)
|
||||
.vertex_input_state(&vertex_input_state)
|
||||
.input_assembly_state(&input_assembly_state)
|
||||
.viewport_state(&viewport_state)
|
||||
.rasterization_state(&rasterization_state)
|
||||
.multisample_state(&multisample_state)
|
||||
.color_blend_state(&color_blend_state)
|
||||
.layout(pipeline_layout)
|
||||
.render_pass(render_pass)
|
||||
.subpass(0);
|
||||
let create_infos = [create_info];
|
||||
// SAFETY: Pipeline creation references only stack-owned descriptions and live render-pass/layout handles.
|
||||
let pipeline_result = unsafe {
|
||||
device
|
||||
.device()
|
||||
.create_graphics_pipelines(vk::PipelineCache::null(), &create_infos, None)
|
||||
};
|
||||
// SAFETY: The shader modules were created above on this live logical device and are no longer needed after pipeline creation.
|
||||
unsafe {
|
||||
device.device().destroy_shader_module(vertex_shader, None);
|
||||
device.device().destroy_shader_module(fragment_shader, None);
|
||||
}
|
||||
let pipelines =
|
||||
pipeline_result.map_err(|(_, error)| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateGraphicsPipelines",
|
||||
result: error,
|
||||
})?;
|
||||
Ok(pipelines[0])
|
||||
}
|
||||
|
||||
fn create_shader_module(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
words: &[u32],
|
||||
) -> Result<vk::ShaderModule, VulkanSmokeRendererError> {
|
||||
let create_info = vk::ShaderModuleCreateInfo::default().code(words);
|
||||
// SAFETY: The SPIR-V slice points to static checked-in words and lives for the duration of the call.
|
||||
unsafe { device.device().create_shader_module(&create_info, None) }.map_err(|error| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateShaderModule",
|
||||
result: error,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_framebuffer(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
render_pass: vk::RenderPass,
|
||||
image_view: vk::ImageView,
|
||||
extent: (u32, u32),
|
||||
) -> Result<vk::Framebuffer, VulkanSmokeRendererError> {
|
||||
let attachments = [image_view];
|
||||
let create_info = vk::FramebufferCreateInfo::default()
|
||||
.render_pass(render_pass)
|
||||
.attachments(&attachments)
|
||||
.width(extent.0)
|
||||
.height(extent.1)
|
||||
.layers(1);
|
||||
// SAFETY: The framebuffer references a live image view and render pass owned by this logical device.
|
||||
unsafe { device.device().create_framebuffer(&create_info, None) }.map_err(|error| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateFramebuffer",
|
||||
result: error,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn allocate_command_buffers(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
command_pool: vk::CommandPool,
|
||||
count: u32,
|
||||
) -> Result<Vec<vk::CommandBuffer>, VulkanSmokeRendererError> {
|
||||
let allocate_info = vk::CommandBufferAllocateInfo::default()
|
||||
.command_pool(command_pool)
|
||||
.level(vk::CommandBufferLevel::PRIMARY)
|
||||
.command_buffer_count(count);
|
||||
// SAFETY: The command pool belongs to this live logical device and the allocation info is stack-owned.
|
||||
unsafe { device.device().allocate_command_buffers(&allocate_info) }.map_err(|error| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkAllocateCommandBuffers",
|
||||
result: error,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn destroy_swapchain_resources(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
command_pool: vk::CommandPool,
|
||||
resources: VulkanSwapchainResources,
|
||||
) {
|
||||
// SAFETY: All handles belong to this live logical device and are destroyed during renderer teardown/recreation.
|
||||
unsafe {
|
||||
if !resources.command_buffers.is_empty() {
|
||||
device
|
||||
.device()
|
||||
.free_command_buffers(command_pool, &resources.command_buffers);
|
||||
}
|
||||
for framebuffer in resources.framebuffers {
|
||||
device.device().destroy_framebuffer(framebuffer, None);
|
||||
}
|
||||
device.device().destroy_pipeline(resources.pipeline, None);
|
||||
device
|
||||
.device()
|
||||
.destroy_pipeline_layout(resources.pipeline_layout, None);
|
||||
device
|
||||
.device()
|
||||
.destroy_render_pass(resources.render_pass, None);
|
||||
for image_view in resources.image_views {
|
||||
device.device().destroy_image_view(image_view, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn destroy_partial_swapchain_resources(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
command_pool: vk::CommandPool,
|
||||
partial: PartialSwapchainResources,
|
||||
) {
|
||||
// SAFETY: All handles in the partial bundle belong to this live logical device and are destroyed on setup failure.
|
||||
unsafe {
|
||||
if !partial.command_buffers.is_empty() {
|
||||
device
|
||||
.device()
|
||||
.free_command_buffers(command_pool, &partial.command_buffers);
|
||||
}
|
||||
for framebuffer in partial.framebuffers {
|
||||
device.device().destroy_framebuffer(framebuffer, None);
|
||||
}
|
||||
if let Some(pipeline) = partial.pipeline {
|
||||
device.device().destroy_pipeline(pipeline, None);
|
||||
}
|
||||
if let Some(pipeline_layout) = partial.pipeline_layout {
|
||||
device
|
||||
.device()
|
||||
.destroy_pipeline_layout(pipeline_layout, None);
|
||||
}
|
||||
if let Some(render_pass) = partial.render_pass {
|
||||
device.device().destroy_render_pass(render_pass, None);
|
||||
}
|
||||
for image_view in partial.image_views {
|
||||
device.device().destroy_image_view(image_view, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,772 @@
|
||||
use super::*;
|
||||
use crate::policy::{KHR_PORTABILITY_SUBSET_EXTENSION, KHR_SWAPCHAIN_EXTENSION};
|
||||
use crate::shader_manifest::{
|
||||
SHADER_COMPILER_BINARY_SHA256, SHADER_COMPILER_NAME, SHADER_COMPILER_VERSION,
|
||||
SHADER_MANIFEST_SCHEMA, SHADER_TARGET_ENV, SPIRV_MAGIC, SPIRV_VALIDATOR_BINARY_SHA256,
|
||||
SPIRV_VALIDATOR_NAME, SPIRV_VALIDATOR_VERSION, SPIRV_VERSION_1_0,
|
||||
TRIANGLE_VERTEX_COMPILE_COMMAND, TRIANGLE_VERTEX_SOURCE_PATH, TRIANGLE_VERTEX_SOURCE_SHA256,
|
||||
TRIANGLE_VERTEX_SPIRV_PATH, TRIANGLE_VERTEX_VALIDATE_COMMAND,
|
||||
};
|
||||
use crate::*;
|
||||
use fparkan_platform::{DepthStencilSupport, RenderRequest};
|
||||
use fparkan_render::{
|
||||
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderCommand, RenderPhase,
|
||||
};
|
||||
use fparkan_render::{RenderBackend, RenderError};
|
||||
|
||||
#[test]
|
||||
fn planning_backend_tracks_render_request_and_simulated_present() -> Result<(), RenderError> {
|
||||
let mut backend = VulkanPlanningBackend::new();
|
||||
let request = RenderRequest {
|
||||
presentation: fparkan_platform::PresentationMode::Immediate,
|
||||
..RenderRequest::conservative()
|
||||
};
|
||||
backend.set_render_request(request);
|
||||
assert_eq!(backend.render_request(), request);
|
||||
assert_eq!(backend.report().request.current_request, request);
|
||||
assert_eq!(backend.report().request.request_updates, 1);
|
||||
|
||||
let commands = fparkan_render::RenderCommandList {
|
||||
commands: vec![
|
||||
RenderCommand::BeginFrame,
|
||||
RenderCommand::Draw(DrawCommand {
|
||||
id: DrawId(11),
|
||||
phase: RenderPhase::Opaque,
|
||||
object_id: None,
|
||||
mesh: GpuMeshId(1),
|
||||
material: GpuMaterialId(2),
|
||||
transform: [1.0; 16],
|
||||
range: IndexRange { start: 0, count: 3 },
|
||||
stable_order: 7,
|
||||
}),
|
||||
RenderCommand::EndFrame,
|
||||
],
|
||||
};
|
||||
|
||||
backend.execute(&commands)?;
|
||||
assert_eq!(backend.state(), VulkanPlanningBackendState::Configured);
|
||||
assert_eq!(backend.report().execution.planned_frames, 1);
|
||||
assert_eq!(backend.report().execution.submission_plans, 1);
|
||||
assert_eq!(backend.report().execution.simulated_presents, 1);
|
||||
assert!(backend.report().execution.last_capture_size > 0);
|
||||
assert_eq!(
|
||||
backend.report().last_frame_submission,
|
||||
Some(VulkanFrameSubmissionPlan {
|
||||
schema: 1,
|
||||
frames_in_flight: 2,
|
||||
command_buffers: 2,
|
||||
semaphores_per_frame: 2,
|
||||
fences_per_frame: 1,
|
||||
draw_count: 1,
|
||||
indexed_vertex_count: 3,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_submission_plan_json_is_stable() -> Result<(), RenderError> {
|
||||
let commands = fparkan_render::RenderCommandList {
|
||||
commands: vec![
|
||||
RenderCommand::BeginFrame,
|
||||
RenderCommand::Draw(DrawCommand {
|
||||
id: DrawId(11),
|
||||
phase: RenderPhase::Opaque,
|
||||
object_id: None,
|
||||
mesh: GpuMeshId(1),
|
||||
material: GpuMaterialId(2),
|
||||
transform: [1.0; 16],
|
||||
range: IndexRange { start: 0, count: 3 },
|
||||
stable_order: 7,
|
||||
}),
|
||||
RenderCommand::EndFrame,
|
||||
],
|
||||
};
|
||||
let swapchain = VulkanSwapchainPlan {
|
||||
schema: 1,
|
||||
extent: (1, 1),
|
||||
format: VulkanSurfaceFormat {
|
||||
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
|
||||
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
|
||||
},
|
||||
present_mode: vk::PresentModeKHR::FIFO.as_raw(),
|
||||
image_count: 3,
|
||||
};
|
||||
|
||||
let plan = plan_vulkan_frame_submission(&swapchain, &commands)?;
|
||||
|
||||
assert_eq!(plan.frames_in_flight, 2);
|
||||
assert_eq!(plan.command_buffers, 3);
|
||||
assert_eq!(plan.draw_count, 1);
|
||||
assert_eq!(plan.indexed_vertex_count, 3);
|
||||
assert_eq!(
|
||||
render_frame_submission_plan_json(&plan),
|
||||
"{\"schema\":1,\"frames_in_flight\":2,\"command_buffers\":3,\"semaphores_per_frame\":2,\"fences_per_frame\":1,\"draw_count\":1,\"indexed_vertex_count\":3}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_scoring_is_deterministic_and_prefers_discrete_unified_queue() {
|
||||
let devices = vec![
|
||||
device("SwiftShader", VulkanDeviceType::Cpu, 0, true, false),
|
||||
device("Discrete", VulkanDeviceType::DiscreteGpu, 1, true, false),
|
||||
device(
|
||||
"Integrated",
|
||||
VulkanDeviceType::IntegratedGpu,
|
||||
2,
|
||||
true,
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
let report = select_physical_device(&devices).expect("selected device");
|
||||
|
||||
assert_eq!(report.device_name, "Discrete");
|
||||
assert_eq!(report.graphics_queue_family, 1);
|
||||
assert_eq!(report.present_queue_family, 1);
|
||||
assert!(!report.portability_subset);
|
||||
assert_eq!(report.enabled_extensions, vec![KHR_SWAPCHAIN_EXTENSION]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_selection_skips_rejected_candidates_before_accepting_valid_gpu() {
|
||||
let mut rejected = device("Rejected", VulkanDeviceType::DiscreteGpu, 0, true, false);
|
||||
rejected.queue_families[0].present = false;
|
||||
let accepted = device("Accepted", VulkanDeviceType::IntegratedGpu, 2, true, false);
|
||||
|
||||
let report = select_physical_device(&[rejected, accepted]).expect("selected fallback device");
|
||||
|
||||
assert_eq!(report.device_name, "Accepted");
|
||||
assert_eq!(report.graphics_queue_family, 2);
|
||||
assert_eq!(report.present_queue_family, 2);
|
||||
assert_eq!(
|
||||
report.rejected_devices,
|
||||
vec![VulkanRejectedDeviceReport {
|
||||
device_name: "Rejected".to_string(),
|
||||
reason_code: "no_present_queue",
|
||||
reason: "Vulkan device Rejected has no present queue".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_family_selection_prefers_lowest_index_unified_family() {
|
||||
let mut candidate = device(
|
||||
"Unified later in list",
|
||||
VulkanDeviceType::DiscreteGpu,
|
||||
7,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
candidate.queue_families = vec![
|
||||
VulkanQueueFamily {
|
||||
index: 9,
|
||||
graphics: true,
|
||||
present: true,
|
||||
},
|
||||
VulkanQueueFamily {
|
||||
index: 3,
|
||||
graphics: true,
|
||||
present: true,
|
||||
},
|
||||
VulkanQueueFamily {
|
||||
index: 1,
|
||||
graphics: true,
|
||||
present: false,
|
||||
},
|
||||
];
|
||||
|
||||
let report = select_physical_device(&[candidate]).expect("selected unified queue");
|
||||
|
||||
assert_eq!(report.graphics_queue_family, 3);
|
||||
assert_eq!(report.present_queue_family, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portability_subset_is_reported_and_enabled_when_exposed() {
|
||||
let report = select_physical_device(&[device(
|
||||
"MoltenVK",
|
||||
VulkanDeviceType::IntegratedGpu,
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
)])
|
||||
.expect("selected device");
|
||||
|
||||
assert!(report.portability_subset);
|
||||
assert_eq!(
|
||||
report.enabled_extensions,
|
||||
vec![
|
||||
KHR_SWAPCHAIN_EXTENSION.to_string(),
|
||||
KHR_PORTABILITY_SUBSET_EXTENSION.to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_loader_candidates_are_reported() {
|
||||
assert_eq!(
|
||||
select_physical_device(&[]),
|
||||
Err(VulkanCapabilityError::NoPhysicalDevice)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_low_api_version() {
|
||||
let mut candidate = device("Old GPU", VulkanDeviceType::DiscreteGpu, 0, true, false);
|
||||
candidate.api_version = vk::API_VERSION_1_0;
|
||||
|
||||
assert!(matches!(
|
||||
select_physical_device(&[candidate]),
|
||||
Err(VulkanCapabilityError::ApiVersionTooLow { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_graphics_present_swapchain_and_format() {
|
||||
let mut no_graphics = device("No graphics", VulkanDeviceType::DiscreteGpu, 0, true, false);
|
||||
no_graphics.queue_families[0].graphics = false;
|
||||
assert!(matches!(
|
||||
select_physical_device(&[no_graphics]),
|
||||
Err(VulkanCapabilityError::NoGraphicsQueue { .. })
|
||||
));
|
||||
|
||||
let mut no_present = device("No present", VulkanDeviceType::DiscreteGpu, 0, true, false);
|
||||
no_present.queue_families[0].present = false;
|
||||
assert!(matches!(
|
||||
select_physical_device(&[no_present]),
|
||||
Err(VulkanCapabilityError::NoPresentQueue { .. })
|
||||
));
|
||||
|
||||
let no_swapchain = device(
|
||||
"No swapchain",
|
||||
VulkanDeviceType::DiscreteGpu,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert!(matches!(
|
||||
select_physical_device(&[no_swapchain]),
|
||||
Err(VulkanCapabilityError::MissingSwapchainExtension { .. })
|
||||
));
|
||||
|
||||
let mut no_format = device("No format", VulkanDeviceType::DiscreteGpu, 0, true, false);
|
||||
no_format.surface_formats.clear();
|
||||
assert!(matches!(
|
||||
select_physical_device(&[no_format]),
|
||||
Err(VulkanCapabilityError::MissingSurfaceFormat { .. })
|
||||
));
|
||||
|
||||
let mut no_present_mode = device(
|
||||
"No present mode",
|
||||
VulkanDeviceType::DiscreteGpu,
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
no_present_mode.present_modes.clear();
|
||||
assert!(matches!(
|
||||
select_physical_device(&[no_present_mode]),
|
||||
Err(VulkanCapabilityError::MissingPresentMode { .. })
|
||||
));
|
||||
|
||||
let mut no_color_attachment = device(
|
||||
"No color attachment",
|
||||
VulkanDeviceType::DiscreteGpu,
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
no_color_attachment
|
||||
.surface_capabilities
|
||||
.supported_usage_flags = vk::ImageUsageFlags::TRANSFER_DST.as_raw();
|
||||
assert!(matches!(
|
||||
select_physical_device(&[no_color_attachment]),
|
||||
Err(VulkanCapabilityError::MissingColorAttachmentUsage { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_gate_rejects_devices_without_requested_depth_stencil_support() {
|
||||
let mut no_depth = device("No depth", VulkanDeviceType::DiscreteGpu, 0, true, false);
|
||||
no_depth.supported_depth_stencil_formats = vec![vk::Format::D32_SFLOAT.as_raw()];
|
||||
|
||||
assert!(matches!(
|
||||
select_physical_device(&[no_depth]),
|
||||
Err(VulkanCapabilityError::MissingDepthStencilFormat { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_gate_respects_request_specific_depth_profiles() {
|
||||
let mut no_stencil = device("No stencil", VulkanDeviceType::DiscreteGpu, 0, true, false);
|
||||
no_stencil.supported_depth_stencil_formats = vec![vk::Format::D32_SFLOAT.as_raw()];
|
||||
let relaxed_request = RenderRequest {
|
||||
depth: DepthStencilSupport {
|
||||
depth_bits: 32,
|
||||
stencil_bits: 0,
|
||||
},
|
||||
..RenderRequest::conservative()
|
||||
};
|
||||
|
||||
let report = select_physical_device_for_request(&[no_stencil], relaxed_request)
|
||||
.expect("selected device for depth-only request");
|
||||
|
||||
assert_eq!(report.device_name, "No stencil");
|
||||
assert!(report.rejected_devices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_report_preserves_informational_sampled_formats_and_limits() {
|
||||
let report = select_physical_device(&[device(
|
||||
"Telemetry GPU",
|
||||
VulkanDeviceType::DiscreteGpu,
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
)])
|
||||
.expect("selected device");
|
||||
|
||||
assert_eq!(
|
||||
report.informational_capabilities.sampled_color_formats,
|
||||
vec![vk::Format::B8G8R8A8_SRGB.as_raw()]
|
||||
);
|
||||
assert_eq!(
|
||||
report.informational_capabilities.sampled_depth_formats,
|
||||
vec![vk::Format::D32_SFLOAT.as_raw()]
|
||||
);
|
||||
assert_eq!(
|
||||
report.informational_capabilities.limits,
|
||||
VulkanDeviceLimits {
|
||||
max_image_dimension_2d: 4096,
|
||||
max_sampler_allocation_count: 4096,
|
||||
max_per_stage_descriptor_samplers: 16,
|
||||
max_bound_descriptor_sets: 4,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_report_json_is_stable() {
|
||||
let mut rejected = device("Rejected", VulkanDeviceType::IntegratedGpu, 0, true, false);
|
||||
rejected.present_modes.clear();
|
||||
let report = select_physical_device(&[
|
||||
rejected,
|
||||
device("GPU \"A\"", VulkanDeviceType::DiscreteGpu, 3, true, false),
|
||||
])
|
||||
.expect("selected device");
|
||||
|
||||
assert_eq!(
|
||||
render_capability_report_json(&report),
|
||||
"{\"schema\":1,\"vulkan_api\":\"1.1.0\",\"device_name\":\"GPU \\\"A\\\"\",\"score\":1101,\"graphics_queue_family\":3,\"present_queue_family\":3,\"portability_subset\":false,\"enabled_extensions\":[\"VK_KHR_swapchain\"],\"informational_capabilities\":{\"sampled_color_formats\":[50],\"sampled_depth_formats\":[126],\"limits\":{\"max_image_dimension_2d\":4096,\"max_sampler_allocation_count\":4096,\"max_per_stage_descriptor_samplers\":16,\"max_bound_descriptor_sets\":4}},\"rejected_devices\":[{\"device_name\":\"Rejected\",\"reason_code\":\"missing_present_mode\",\"reason\":\"Vulkan device Rejected has no supported present mode\"}]}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_probe_report_json_is_stable() {
|
||||
assert_eq!(
|
||||
vulkan_entry_symbol_name().to_bytes(),
|
||||
b"vkGetInstanceProcAddr"
|
||||
);
|
||||
assert_eq!(
|
||||
render_loader_probe_report_json(&VulkanLoaderProbeReport {
|
||||
schema: 1,
|
||||
loader_available: true,
|
||||
instance_api_version: vk::API_VERSION_1_2,
|
||||
}),
|
||||
"{\"schema\":1,\"loader_available\":true,\"instance_api\":\"1.2.0\"}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_error_display_is_actionable() {
|
||||
assert_eq!(
|
||||
VulkanLoaderError::Unavailable {
|
||||
message: "dlopen failed".to_string(),
|
||||
}
|
||||
.to_string(),
|
||||
"Vulkan loader is unavailable: dlopen failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_plan_is_sorted_deduplicated_and_portability_aware() {
|
||||
let plan = plan_vulkan_instance(&VulkanInstanceConfig {
|
||||
application_name: "FParkan".to_string(),
|
||||
required_extensions: vec![
|
||||
"VK_KHR_surface".to_string(),
|
||||
KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string(),
|
||||
"VK_KHR_surface".to_string(),
|
||||
],
|
||||
enable_portability_enumeration: true,
|
||||
enable_validation: true,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
render_instance_plan_json(&plan),
|
||||
"{\"schema\":1,\"create_flags\":1,\"validation_requested\":true,\"enabled_extensions\":[\"VK_EXT_debug_utils\",\"VK_KHR_portability_enumeration\",\"VK_KHR_surface\"]}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_plan_adds_portability_extension_when_requested() {
|
||||
let plan = plan_vulkan_instance(&VulkanInstanceConfig {
|
||||
application_name: "FParkan".to_string(),
|
||||
required_extensions: vec!["VK_KHR_surface".to_string()],
|
||||
enable_portability_enumeration: true,
|
||||
enable_validation: false,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
plan.enabled_extensions,
|
||||
vec![
|
||||
KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string(),
|
||||
"VK_KHR_surface".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(plan.create_flags, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_instance_extension_name_is_reported_before_loader_use() {
|
||||
assert_eq!(
|
||||
cstring_vec(&["bad\0extension".to_string()]),
|
||||
Err(VulkanInstanceError::InvalidExtensionName {
|
||||
extension: "bad\0extension".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_instance_extension_is_reported_before_create_instance() {
|
||||
assert_eq!(
|
||||
ensure_instance_extensions_available(
|
||||
&[
|
||||
"VK_EXT_debug_utils".to_string(),
|
||||
"VK_KHR_surface".to_string(),
|
||||
],
|
||||
&["VK_KHR_surface".to_string()],
|
||||
),
|
||||
Err(VulkanInstanceError::MissingInstanceExtension {
|
||||
extension: "VK_EXT_debug_utils".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_plan_requires_native_handles() {
|
||||
assert_eq!(
|
||||
plan_vulkan_surface(None),
|
||||
Err(VulkanSurfaceError::MissingNativeHandles)
|
||||
);
|
||||
assert_eq!(
|
||||
VulkanSurfaceError::MissingNativeHandles.to_string(),
|
||||
"native window/display handles are required for Vulkan surface creation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_plan_json_is_stable() {
|
||||
assert_eq!(
|
||||
render_surface_plan_json(&VulkanSurfacePlan {
|
||||
schema: 1,
|
||||
required_instance_extensions: vec![
|
||||
"VK_KHR_surface".to_string(),
|
||||
"VK_EXT_metal_surface".to_string(),
|
||||
],
|
||||
}),
|
||||
"{\"schema\":1,\"required_instance_extensions\":[\"VK_KHR_surface\",\"VK_EXT_metal_surface\"]}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_surface_extension_name_is_decoded() {
|
||||
let name = extension_name(ash::khr::surface::NAME.as_ptr()).expect("extension name");
|
||||
|
||||
assert_eq!(name, "VK_KHR_surface");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapchain_plan_prefers_srgb_mailbox_and_clamps_extent() {
|
||||
let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan");
|
||||
|
||||
assert_eq!(
|
||||
plan.format,
|
||||
VulkanSurfaceFormat {
|
||||
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
|
||||
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
|
||||
}
|
||||
);
|
||||
assert_eq!(plan.present_mode, vk::PresentModeKHR::MAILBOX.as_raw());
|
||||
assert_eq!(plan.extent, (1024, 720));
|
||||
assert_eq!(plan.image_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapchain_plan_uses_fifo_and_current_extent_fallbacks() {
|
||||
let mut request = swapchain_request();
|
||||
request.preferred_present_mode = vk::PresentModeKHR::IMMEDIATE.as_raw();
|
||||
request.present_modes = vec![vk::PresentModeKHR::FIFO.as_raw()];
|
||||
request.capabilities.current_extent = Some((800, 600));
|
||||
|
||||
let plan = plan_vulkan_swapchain(&request).expect("swapchain plan");
|
||||
|
||||
assert_eq!(plan.present_mode, vk::PresentModeKHR::FIFO.as_raw());
|
||||
assert_eq!(plan.extent, (800, 600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapchain_plan_accepts_undefined_surface_format_by_picking_stage0_default() {
|
||||
let mut request = swapchain_request();
|
||||
request.formats = vec![VulkanSurfaceFormat {
|
||||
format: vk::Format::UNDEFINED.as_raw(),
|
||||
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
|
||||
}];
|
||||
|
||||
let plan = plan_vulkan_swapchain(&request).expect("swapchain plan");
|
||||
|
||||
assert_eq!(
|
||||
plan.format,
|
||||
VulkanSurfaceFormat {
|
||||
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
|
||||
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapchain_plan_rejects_missing_surface_data_and_empty_extent() {
|
||||
let mut request = swapchain_request();
|
||||
request.formats.clear();
|
||||
assert_eq!(
|
||||
plan_vulkan_swapchain(&request),
|
||||
Err(VulkanSwapchainError::MissingSurfaceFormat)
|
||||
);
|
||||
|
||||
let mut request = swapchain_request();
|
||||
request.present_modes.clear();
|
||||
assert_eq!(
|
||||
plan_vulkan_swapchain(&request),
|
||||
Err(VulkanSwapchainError::MissingPresentMode)
|
||||
);
|
||||
|
||||
let mut request = swapchain_request();
|
||||
request.capabilities.current_extent = Some((0, 600));
|
||||
assert_eq!(
|
||||
plan_vulkan_swapchain(&request),
|
||||
Err(VulkanSwapchainError::EmptyExtent)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapchain_plan_json_and_recreation_reports_are_stable() {
|
||||
let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan");
|
||||
assert_eq!(
|
||||
render_swapchain_plan_json(&plan),
|
||||
"{\"schema\":1,\"extent\":[1024,720],\"format\":50,\"color_space\":0,\"present_mode\":1,\"image_count\":3}"
|
||||
);
|
||||
|
||||
let report = swapchain_recreation_report(
|
||||
VulkanSwapchainRecreationReason::OutOfDate,
|
||||
(1024, 720),
|
||||
(1280, 720),
|
||||
);
|
||||
assert_eq!(
|
||||
render_swapchain_recreation_report_json(&report),
|
||||
"{\"schema\":1,\"reason\":\"out_of_date\",\"previous_extent\":[1024,720],\"next_extent\":[1280,720]}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangle_shader_manifest_hashes_are_stable() {
|
||||
let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest");
|
||||
|
||||
assert_eq!(report.schema, SHADER_MANIFEST_SCHEMA);
|
||||
assert_eq!(report.target_env, SHADER_TARGET_ENV);
|
||||
assert_eq!(
|
||||
report.compiler,
|
||||
VulkanShaderToolManifest {
|
||||
name: SHADER_COMPILER_NAME,
|
||||
version: SHADER_COMPILER_VERSION,
|
||||
binary_sha256: SHADER_COMPILER_BINARY_SHA256,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
report.validator,
|
||||
VulkanShaderToolManifest {
|
||||
name: SPIRV_VALIDATOR_NAME,
|
||||
version: SPIRV_VALIDATOR_VERSION,
|
||||
binary_sha256: SPIRV_VALIDATOR_BINARY_SHA256,
|
||||
}
|
||||
);
|
||||
assert_eq!(report.modules.len(), 2);
|
||||
assert_eq!(report.modules[0].name, "triangle.vert");
|
||||
assert_eq!(report.modules[0].stage, VulkanShaderStage::Vertex);
|
||||
assert_eq!(report.modules[0].source_path, TRIANGLE_VERTEX_SOURCE_PATH);
|
||||
assert_eq!(
|
||||
report.modules[0].source_sha256,
|
||||
TRIANGLE_VERTEX_SOURCE_SHA256
|
||||
);
|
||||
assert_eq!(report.modules[0].spirv_path, TRIANGLE_VERTEX_SPIRV_PATH);
|
||||
assert_eq!(report.modules[0].word_count, 253);
|
||||
assert_eq!(
|
||||
report.modules[0].sha256,
|
||||
"4d3ceca7b42ebc971d831b0a0d816457397bd9aeda47fb8d44c4b1aeaa5e7ba0"
|
||||
);
|
||||
assert_eq!(report.modules[0].descriptor_sets, 0);
|
||||
assert_eq!(report.modules[0].push_constant_bytes, 0);
|
||||
assert_eq!(
|
||||
report.modules[0].compile_command,
|
||||
TRIANGLE_VERTEX_COMPILE_COMMAND
|
||||
);
|
||||
assert_eq!(
|
||||
report.modules[0].validate_command,
|
||||
TRIANGLE_VERTEX_VALIDATE_COMMAND
|
||||
);
|
||||
assert!(!report.modules[0].interface_hash.is_empty());
|
||||
assert_eq!(
|
||||
report.modules[1].sha256,
|
||||
"5a7441be03cd3c25d557268b2e58d5aa50504c87bffcb4c3fd7cbcf007db0b96"
|
||||
);
|
||||
assert_eq!(
|
||||
report.manifest_hash,
|
||||
"11e3feb65200ebd2ac87b7e776e9c6433a5da9d71a651bfadea89a51be17ff05"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_manifest_report_json_is_stable() {
|
||||
let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest");
|
||||
let json = render_shader_manifest_report_json(&report);
|
||||
|
||||
assert!(json.contains(SHADER_COMPILER_NAME));
|
||||
assert!(json.contains(SPIRV_VALIDATOR_NAME));
|
||||
assert!(json.contains(TRIANGLE_VERTEX_SOURCE_PATH));
|
||||
assert!(json.contains(TRIANGLE_VERTEX_COMPILE_COMMAND));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_in_shader_manifest_matches_generated_report() {
|
||||
let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest");
|
||||
assert_eq!(
|
||||
render_shader_manifest_report_json(&report),
|
||||
include_str!("../../shaders/manifest.json").trim()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_manifest_rejects_invalid_spirv_containers() {
|
||||
let mut module = triangle_shader_manifest().remove(0);
|
||||
module.words = &[0xFFFF_FFFF, SPIRV_VERSION_1_0, 0, 1, 0];
|
||||
assert_eq!(
|
||||
validate_shader_manifest(&[module]),
|
||||
Err(VulkanShaderManifestError::InvalidMagic {
|
||||
name: "triangle.vert",
|
||||
found: 0xFFFF_FFFF,
|
||||
})
|
||||
);
|
||||
|
||||
let mut module = triangle_shader_manifest().remove(0);
|
||||
module.words = &[SPIRV_MAGIC, 0, 0, 1, 0];
|
||||
assert_eq!(
|
||||
validate_shader_manifest(&[module]),
|
||||
Err(VulkanShaderManifestError::UnsupportedVersion {
|
||||
name: "triangle.vert",
|
||||
found: 0,
|
||||
})
|
||||
);
|
||||
|
||||
let mut module = triangle_shader_manifest().remove(0);
|
||||
module.words = &[SPIRV_MAGIC, SPIRV_VERSION_1_0, 0, 0, 0];
|
||||
assert_eq!(
|
||||
validate_shader_manifest(&[module]),
|
||||
Err(VulkanShaderManifestError::InvalidBound {
|
||||
name: "triangle.vert",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn device(
|
||||
name: &str,
|
||||
device_type: VulkanDeviceType,
|
||||
queue_index: u32,
|
||||
swapchain: bool,
|
||||
portability_subset: bool,
|
||||
) -> VulkanPhysicalDeviceRecord {
|
||||
let mut extensions = Vec::new();
|
||||
if swapchain {
|
||||
extensions.push(KHR_SWAPCHAIN_EXTENSION.to_string());
|
||||
}
|
||||
if portability_subset {
|
||||
extensions.push(KHR_PORTABILITY_SUBSET_EXTENSION.to_string());
|
||||
}
|
||||
VulkanPhysicalDeviceRecord {
|
||||
name: name.to_string(),
|
||||
api_version: MIN_VULKAN_API_VERSION,
|
||||
device_type,
|
||||
extensions,
|
||||
queue_families: vec![VulkanQueueFamily {
|
||||
index: queue_index,
|
||||
graphics: true,
|
||||
present: true,
|
||||
}],
|
||||
surface_formats: vec![VulkanSurfaceFormat {
|
||||
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
|
||||
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
|
||||
}],
|
||||
present_modes: vec![
|
||||
vk::PresentModeKHR::FIFO.as_raw(),
|
||||
vk::PresentModeKHR::MAILBOX.as_raw(),
|
||||
],
|
||||
surface_capabilities: default_surface_capabilities(),
|
||||
supported_depth_stencil_formats: vec![
|
||||
vk::Format::D24_UNORM_S8_UINT.as_raw(),
|
||||
vk::Format::D32_SFLOAT_S8_UINT.as_raw(),
|
||||
vk::Format::D32_SFLOAT.as_raw(),
|
||||
],
|
||||
sampled_image_formats: vec![
|
||||
vk::Format::B8G8R8A8_SRGB.as_raw(),
|
||||
vk::Format::D32_SFLOAT.as_raw(),
|
||||
],
|
||||
limits: VulkanDeviceLimits {
|
||||
max_image_dimension_2d: 4096,
|
||||
max_sampler_allocation_count: 4096,
|
||||
max_per_stage_descriptor_samplers: 16,
|
||||
max_bound_descriptor_sets: 4,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn swapchain_request() -> VulkanSwapchainRequest {
|
||||
VulkanSwapchainRequest {
|
||||
drawable_extent: (1280, 720),
|
||||
formats: vec![
|
||||
VulkanSurfaceFormat {
|
||||
format: vk::Format::R8G8B8A8_UNORM.as_raw(),
|
||||
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
|
||||
},
|
||||
VulkanSurfaceFormat {
|
||||
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
|
||||
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
|
||||
},
|
||||
],
|
||||
present_modes: vec![
|
||||
vk::PresentModeKHR::FIFO.as_raw(),
|
||||
vk::PresentModeKHR::MAILBOX.as_raw(),
|
||||
],
|
||||
capabilities: default_surface_capabilities(),
|
||||
preferred_present_mode: vk::PresentModeKHR::MAILBOX.as_raw(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_surface_capabilities() -> VulkanSwapchainSurfaceCapabilities {
|
||||
VulkanSwapchainSurfaceCapabilities {
|
||||
current_extent: None,
|
||||
min_extent: (320, 240),
|
||||
max_extent: (1024, 768),
|
||||
min_image_count: 2,
|
||||
max_image_count: 3,
|
||||
supported_usage_flags: vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use ash::vk;
|
||||
use std::collections::BTreeSet;
|
||||
use std::ffi::CStr;
|
||||
use std::sync::{
|
||||
atomic::{AtomicU32, Ordering},
|
||||
Mutex,
|
||||
};
|
||||
|
||||
use super::{VulkanInstanceProbe, VulkanSmokeRendererError, VulkanValidationReport};
|
||||
|
||||
struct VulkanValidationShared {
|
||||
warning_count: AtomicU32,
|
||||
error_count: AtomicU32,
|
||||
vuids: Mutex<BTreeSet<String>>,
|
||||
}
|
||||
|
||||
impl Default for VulkanValidationShared {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
warning_count: AtomicU32::new(0),
|
||||
error_count: AtomicU32::new(0),
|
||||
vuids: Mutex::new(BTreeSet::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct VulkanValidationMessenger {
|
||||
loader: ash::ext::debug_utils::Instance,
|
||||
messenger: vk::DebugUtilsMessengerEXT,
|
||||
shared: Box<VulkanValidationShared>,
|
||||
}
|
||||
|
||||
impl VulkanValidationMessenger {
|
||||
pub(super) fn report(&self) -> VulkanValidationReport {
|
||||
let vuids = self
|
||||
.shared
|
||||
.vuids
|
||||
.lock()
|
||||
.map(|values| values.iter().cloned().collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
VulkanValidationReport {
|
||||
warning_count: self.shared.warning_count.load(Ordering::Relaxed),
|
||||
error_count: self.shared.error_count.load(Ordering::Relaxed),
|
||||
vuids,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VulkanValidationMessenger {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: The messenger belongs to this instance-level loader and is destroyed once.
|
||||
unsafe {
|
||||
self.loader
|
||||
.destroy_debug_utils_messenger(self.messenger, None);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "system" fn vulkan_validation_callback(
|
||||
message_severity: vk::DebugUtilsMessageSeverityFlagsEXT,
|
||||
_message_types: vk::DebugUtilsMessageTypeFlagsEXT,
|
||||
callback_data: *const vk::DebugUtilsMessengerCallbackDataEXT<'_>,
|
||||
user_data: *mut std::ffi::c_void,
|
||||
) -> vk::Bool32 {
|
||||
// SAFETY: The debug messenger stores a stable pointer to `VulkanValidationShared` for the messenger lifetime.
|
||||
let Some(shared) = (unsafe { (user_data as *const VulkanValidationShared).as_ref() }) else {
|
||||
return vk::FALSE;
|
||||
};
|
||||
if message_severity.contains(vk::DebugUtilsMessageSeverityFlagsEXT::ERROR) {
|
||||
shared.error_count.fetch_add(1, Ordering::Relaxed);
|
||||
} else if message_severity.contains(vk::DebugUtilsMessageSeverityFlagsEXT::WARNING) {
|
||||
shared.warning_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
// SAFETY: Vulkan invokes the callback with either a null pointer or a valid callback-data payload.
|
||||
let Some(callback_data) = (unsafe { callback_data.as_ref() }) else {
|
||||
return vk::FALSE;
|
||||
};
|
||||
if let Some(vuid) = (!callback_data.p_message_id_name.is_null()).then(|| {
|
||||
// SAFETY: `p_message_id_name` is a Vulkan-owned NUL-terminated string for the callback duration.
|
||||
unsafe { CStr::from_ptr(callback_data.p_message_id_name) }
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}) {
|
||||
if vuid.starts_with("VUID-") {
|
||||
if let Ok(mut vuids) = shared.vuids.lock() {
|
||||
vuids.insert(vuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
vk::FALSE
|
||||
}
|
||||
|
||||
pub(super) fn create_validation_messenger(
|
||||
instance: &VulkanInstanceProbe,
|
||||
) -> Result<VulkanValidationMessenger, VulkanSmokeRendererError> {
|
||||
let shared = Box::new(VulkanValidationShared::default());
|
||||
let loader = ash::ext::debug_utils::Instance::new(&instance.entry, &instance.instance);
|
||||
let create_info = vk::DebugUtilsMessengerCreateInfoEXT::default()
|
||||
.message_severity(
|
||||
vk::DebugUtilsMessageSeverityFlagsEXT::WARNING
|
||||
| vk::DebugUtilsMessageSeverityFlagsEXT::ERROR,
|
||||
)
|
||||
.message_type(
|
||||
vk::DebugUtilsMessageTypeFlagsEXT::GENERAL
|
||||
| vk::DebugUtilsMessageTypeFlagsEXT::VALIDATION
|
||||
| vk::DebugUtilsMessageTypeFlagsEXT::PERFORMANCE,
|
||||
)
|
||||
.pfn_user_callback(Some(vulkan_validation_callback))
|
||||
.user_data((&raw const *shared).cast_mut().cast());
|
||||
let messenger =
|
||||
// SAFETY: The create info points at a stable boxed user-data allocation for the messenger lifetime.
|
||||
unsafe { loader.create_debug_utils_messenger(&create_info, None) }.map_err(|error| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateDebugUtilsMessengerEXT",
|
||||
result: error,
|
||||
}
|
||||
})?;
|
||||
Ok(VulkanValidationMessenger {
|
||||
loader,
|
||||
messenger,
|
||||
shared,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
use ash::vk;
|
||||
use fparkan_platform::RenderRequest;
|
||||
use fparkan_render::{
|
||||
canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
plan_vulkan_frame_submission, VulkanFrameSubmissionPlan, VulkanSurfaceFormat,
|
||||
VulkanSwapchainPlan,
|
||||
};
|
||||
|
||||
/// Vulkan backend migration readiness.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum VulkanPlanningBackendState {
|
||||
/// Planning facade is configured and able to accept command lists.
|
||||
Configured,
|
||||
/// Adapter is tracking a recoverable runtime surface/depth pipeline fault.
|
||||
Degraded,
|
||||
/// Adapter has encountered a non-recoverable error.
|
||||
Error,
|
||||
}
|
||||
|
||||
impl Default for VulkanPlanningBackendState {
|
||||
fn default() -> Self {
|
||||
Self::Configured
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnostics for planning-facade request tracking.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VulkanPlanningRequestReport {
|
||||
/// Last render request observed by the planning facade.
|
||||
pub current_request: RenderRequest,
|
||||
/// Number of meaningful request updates applied to the facade.
|
||||
pub request_updates: u64,
|
||||
}
|
||||
|
||||
impl Default for VulkanPlanningRequestReport {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
current_request: RenderRequest::conservative(),
|
||||
request_updates: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnostics for planning-facade execution telemetry.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct VulkanPlanningExecutionReport {
|
||||
/// Total frames planned by the facade.
|
||||
pub planned_frames: u64,
|
||||
/// Total frame-submission plans emitted by the facade.
|
||||
pub submission_plans: u64,
|
||||
/// Last command-capture byte size.
|
||||
pub last_capture_size: usize,
|
||||
/// Number of simulated present calls issued by the planning facade.
|
||||
pub simulated_presents: u64,
|
||||
}
|
||||
|
||||
/// Diagnostics for Vulkan planning backend setup and frame progression.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct VulkanPlanningBackendReport {
|
||||
/// Request-tracking telemetry.
|
||||
pub request: VulkanPlanningRequestReport,
|
||||
/// Execution-planning telemetry.
|
||||
pub execution: VulkanPlanningExecutionReport,
|
||||
/// Last deterministic frame submission plan.
|
||||
pub last_frame_submission: Option<VulkanFrameSubmissionPlan>,
|
||||
}
|
||||
|
||||
/// Vulkan planning backend facade used by the game entrypoint.
|
||||
#[derive(Debug)]
|
||||
pub struct VulkanPlanningBackend {
|
||||
state: VulkanPlanningBackendState,
|
||||
report: VulkanPlanningBackendReport,
|
||||
swapchain_plan: VulkanSwapchainPlan,
|
||||
}
|
||||
|
||||
impl Default for VulkanPlanningBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl VulkanPlanningBackend {
|
||||
/// Creates a new Vulkan planning backend facade.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: VulkanPlanningBackendState::Configured,
|
||||
report: VulkanPlanningBackendReport::default(),
|
||||
swapchain_plan: default_stage0_swapchain_plan(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces active surface/profile request.
|
||||
pub fn set_render_request(&mut self, request: RenderRequest) {
|
||||
if self.report.request.current_request != request {
|
||||
self.report.request.current_request = request;
|
||||
self.report.request.request_updates =
|
||||
self.report.request.request_updates.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns active render request policy.
|
||||
#[must_use]
|
||||
pub const fn render_request(&self) -> RenderRequest {
|
||||
self.report.request.current_request
|
||||
}
|
||||
|
||||
/// Replaces active swapchain plan used for frame submission planning.
|
||||
pub fn set_swapchain_plan(&mut self, plan: VulkanSwapchainPlan) {
|
||||
self.swapchain_plan = plan;
|
||||
}
|
||||
|
||||
/// Returns active swapchain plan.
|
||||
#[must_use]
|
||||
pub const fn swapchain_plan(&self) -> &VulkanSwapchainPlan {
|
||||
&self.swapchain_plan
|
||||
}
|
||||
|
||||
/// Returns adapter state.
|
||||
#[must_use]
|
||||
pub const fn state(&self) -> VulkanPlanningBackendState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Returns backend report.
|
||||
#[must_use]
|
||||
pub fn report(&self) -> &VulkanPlanningBackendReport {
|
||||
&self.report
|
||||
}
|
||||
|
||||
fn simulate_present(&mut self) {
|
||||
self.report.execution.simulated_presents =
|
||||
self.report.execution.simulated_presents.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderBackend for VulkanPlanningBackend {
|
||||
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
|
||||
if !matches!(
|
||||
self.state,
|
||||
VulkanPlanningBackendState::Configured | VulkanPlanningBackendState::Degraded
|
||||
) {
|
||||
return Err(RenderError::InvalidRange);
|
||||
}
|
||||
let capture = canonical_capture(commands)?;
|
||||
let frame_plan = plan_vulkan_frame_submission(&self.swapchain_plan, commands)?;
|
||||
self.report.execution.planned_frames =
|
||||
self.report.execution.planned_frames.saturating_add(1);
|
||||
self.report.execution.submission_plans =
|
||||
self.report.execution.submission_plans.saturating_add(1);
|
||||
self.report.execution.last_capture_size = capture.len();
|
||||
self.report.last_frame_submission = Some(frame_plan);
|
||||
self.simulate_present();
|
||||
Ok(FrameOutput)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_stage0_swapchain_plan() -> VulkanSwapchainPlan {
|
||||
VulkanSwapchainPlan {
|
||||
schema: 1,
|
||||
extent: (1, 1),
|
||||
format: VulkanSurfaceFormat {
|
||||
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
|
||||
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
|
||||
},
|
||||
present_mode: vk::PresentModeKHR::FIFO.as_raw(),
|
||||
image_count: 2,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
use ash::vk;
|
||||
use fparkan_platform::{DepthStencilSupport, RenderRequest};
|
||||
use fparkan_render::{validate_command_list, RenderCommand, RenderCommandList, RenderError};
|
||||
use serde::Serialize;
|
||||
|
||||
const MIN_VULKAN_API_VERSION: u32 = vk::API_VERSION_1_1;
|
||||
pub(crate) const KHR_SWAPCHAIN_EXTENSION: &str = "VK_KHR_swapchain";
|
||||
pub(crate) const KHR_PORTABILITY_SUBSET_EXTENSION: &str = "VK_KHR_portability_subset";
|
||||
|
||||
/// Synthetic physical-device type used by deterministic capability scoring.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum VulkanDeviceType {
|
||||
/// Discrete GPU.
|
||||
DiscreteGpu,
|
||||
/// Integrated GPU.
|
||||
IntegratedGpu,
|
||||
/// CPU or software Vulkan implementation.
|
||||
Cpu,
|
||||
/// Other or unknown implementation.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl VulkanDeviceType {
|
||||
const fn score_bonus(self) -> i32 {
|
||||
match self {
|
||||
Self::DiscreteGpu => 1_000,
|
||||
Self::IntegratedGpu => 700,
|
||||
Self::Cpu => 100,
|
||||
Self::Other => 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue-family capabilities needed by the Stage 0 renderer.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanQueueFamily {
|
||||
/// Stable queue-family index.
|
||||
pub index: u32,
|
||||
/// Whether the family supports graphics commands.
|
||||
pub graphics: bool,
|
||||
/// Whether the family supports presentation for the target surface.
|
||||
pub present: bool,
|
||||
}
|
||||
|
||||
/// Surface format capability needed by the Stage 0 swapchain policy.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanSurfaceFormat {
|
||||
/// Vulkan format numeric value.
|
||||
pub format: i32,
|
||||
/// Vulkan color-space numeric value.
|
||||
pub color_space: i32,
|
||||
}
|
||||
|
||||
/// Surface capabilities needed by the Stage 0 swapchain policy.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanSwapchainSurfaceCapabilities {
|
||||
/// Current surface extent, when dictated by the platform.
|
||||
pub current_extent: Option<(u32, u32)>,
|
||||
/// Minimum supported swapchain extent.
|
||||
pub min_extent: (u32, u32),
|
||||
/// Maximum supported swapchain extent.
|
||||
pub max_extent: (u32, u32),
|
||||
/// Minimum supported image count.
|
||||
pub min_image_count: u32,
|
||||
/// Maximum supported image count, or 0 when unbounded.
|
||||
pub max_image_count: u32,
|
||||
/// Supported swapchain image-usage flags as raw Vulkan bits.
|
||||
pub supported_usage_flags: u32,
|
||||
}
|
||||
|
||||
/// Deterministic swapchain planning input.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanSwapchainRequest {
|
||||
/// Requested drawable extent.
|
||||
pub drawable_extent: (u32, u32),
|
||||
/// Available surface formats.
|
||||
pub formats: Vec<VulkanSurfaceFormat>,
|
||||
/// Available present modes as raw Vulkan values.
|
||||
pub present_modes: Vec<i32>,
|
||||
/// Surface capabilities.
|
||||
pub capabilities: VulkanSwapchainSurfaceCapabilities,
|
||||
/// Preferred present mode.
|
||||
pub preferred_present_mode: i32,
|
||||
}
|
||||
|
||||
/// Deterministic swapchain plan.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanSwapchainPlan {
|
||||
/// Report schema version.
|
||||
pub schema: u32,
|
||||
/// Selected swapchain extent.
|
||||
pub extent: (u32, u32),
|
||||
/// Selected surface format.
|
||||
pub format: VulkanSurfaceFormat,
|
||||
/// Selected present mode raw Vulkan value.
|
||||
pub present_mode: i32,
|
||||
/// Selected image count.
|
||||
pub image_count: u32,
|
||||
}
|
||||
|
||||
/// Swapchain planning error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum VulkanSwapchainError {
|
||||
/// No surface format was available.
|
||||
MissingSurfaceFormat,
|
||||
/// No present mode was available.
|
||||
MissingPresentMode,
|
||||
/// Requested or current extent is empty.
|
||||
EmptyExtent,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VulkanSwapchainError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingSurfaceFormat => write!(f, "Vulkan swapchain has no surface format"),
|
||||
Self::MissingPresentMode => write!(f, "Vulkan swapchain has no present mode"),
|
||||
Self::EmptyExtent => write!(f, "Vulkan swapchain extent must be non-zero"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VulkanSwapchainError {}
|
||||
|
||||
/// Swapchain recreation reason.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum VulkanSwapchainRecreationReason {
|
||||
/// Drawable extent changed.
|
||||
Resize,
|
||||
/// Vulkan reported `VK_ERROR_OUT_OF_DATE_KHR`.
|
||||
OutOfDate,
|
||||
/// Vulkan reported `VK_SUBOPTIMAL_KHR`.
|
||||
Suboptimal,
|
||||
}
|
||||
|
||||
/// Deterministic swapchain recreation report.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanSwapchainRecreationReport {
|
||||
/// Report schema version.
|
||||
pub schema: u32,
|
||||
/// Recreation reason.
|
||||
pub reason: VulkanSwapchainRecreationReason,
|
||||
/// Previous extent.
|
||||
pub previous_extent: (u32, u32),
|
||||
/// Next extent.
|
||||
pub next_extent: (u32, u32),
|
||||
}
|
||||
|
||||
/// Deterministic frame submission plan for command buffers and sync objects.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct VulkanFrameSubmissionPlan {
|
||||
/// Report schema version.
|
||||
pub schema: u32,
|
||||
/// Frames allowed in flight.
|
||||
pub frames_in_flight: u32,
|
||||
/// Swapchain-backed primary command buffers.
|
||||
pub command_buffers: u32,
|
||||
/// Binary semaphores allocated per frame.
|
||||
pub semaphores_per_frame: u32,
|
||||
/// Fences allocated per frame.
|
||||
pub fences_per_frame: u32,
|
||||
/// Draw commands encoded into the frame.
|
||||
pub draw_count: u32,
|
||||
/// Total indexed vertices submitted by draw commands.
|
||||
pub indexed_vertex_count: u32,
|
||||
}
|
||||
|
||||
/// Synthetic physical-device capabilities used by negative tests and reports.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanPhysicalDeviceRecord {
|
||||
/// Human-readable device name.
|
||||
pub name: String,
|
||||
/// Reported Vulkan API version.
|
||||
pub api_version: u32,
|
||||
/// Device class.
|
||||
pub device_type: VulkanDeviceType,
|
||||
/// Supported device-extension names.
|
||||
pub extensions: Vec<String>,
|
||||
/// Queue-family capabilities.
|
||||
pub queue_families: Vec<VulkanQueueFamily>,
|
||||
/// Surface formats accepted by the target surface.
|
||||
pub surface_formats: Vec<VulkanSurfaceFormat>,
|
||||
/// Present modes accepted by the target surface.
|
||||
pub present_modes: Vec<i32>,
|
||||
/// Surface capabilities accepted by the target surface.
|
||||
pub surface_capabilities: VulkanSwapchainSurfaceCapabilities,
|
||||
/// Depth/stencil attachment formats supported by the device.
|
||||
pub supported_depth_stencil_formats: Vec<i32>,
|
||||
/// Formats that can be used as sampled images.
|
||||
pub sampled_image_formats: Vec<i32>,
|
||||
/// Informational device limits relevant to the future Stage 0 baseline.
|
||||
pub limits: VulkanDeviceLimits,
|
||||
}
|
||||
|
||||
impl VulkanPhysicalDeviceRecord {
|
||||
/// Returns whether the device supports an extension name.
|
||||
#[must_use]
|
||||
pub fn supports_extension(&self, extension: &str) -> bool {
|
||||
self.extensions
|
||||
.iter()
|
||||
.any(|candidate| candidate == extension)
|
||||
}
|
||||
}
|
||||
|
||||
/// Informational device limits relevant to future Stage 0 capability growth.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct VulkanDeviceLimits {
|
||||
/// Maximum 2D image dimension supported by the device.
|
||||
pub max_image_dimension_2d: u32,
|
||||
/// Maximum number of live samplers supported by the device.
|
||||
pub max_sampler_allocation_count: u32,
|
||||
/// Maximum number of sampler descriptors per stage.
|
||||
pub max_per_stage_descriptor_samplers: u32,
|
||||
/// Maximum number of bound descriptor sets.
|
||||
pub max_bound_descriptor_sets: u32,
|
||||
}
|
||||
|
||||
/// Informational capabilities preserved in deterministic Stage 0 reports.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct VulkanInformationalCapabilities {
|
||||
/// Color formats that support sampled-image usage.
|
||||
pub sampled_color_formats: Vec<i32>,
|
||||
/// Depth/stencil formats that support sampled-image usage.
|
||||
pub sampled_depth_formats: Vec<i32>,
|
||||
/// Future-baseline device limits.
|
||||
pub limits: VulkanDeviceLimits,
|
||||
}
|
||||
|
||||
/// Selected device and queue capability report.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanCapabilityReport {
|
||||
/// Report schema version.
|
||||
pub schema: u32,
|
||||
/// Selected device name.
|
||||
pub device_name: String,
|
||||
/// Selected Vulkan API version.
|
||||
pub vulkan_api_version: u32,
|
||||
/// Deterministic score used for device selection.
|
||||
pub score: i32,
|
||||
/// Graphics queue family index.
|
||||
pub graphics_queue_family: u32,
|
||||
/// Present queue family index.
|
||||
pub present_queue_family: u32,
|
||||
/// Whether portability subset is enabled for the selected device.
|
||||
pub portability_subset: bool,
|
||||
/// Enabled device extensions.
|
||||
pub enabled_extensions: Vec<String>,
|
||||
/// Informational capabilities retained for future baseline planning.
|
||||
pub informational_capabilities: VulkanInformationalCapabilities,
|
||||
/// Devices rejected by deterministic Stage 0 capability validation.
|
||||
pub rejected_devices: Vec<VulkanRejectedDeviceReport>,
|
||||
}
|
||||
|
||||
/// Deterministic rejection reason for an unsuitable physical device.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct VulkanRejectedDeviceReport {
|
||||
/// Human-readable device name.
|
||||
pub device_name: String,
|
||||
/// Stable machine-readable rejection code.
|
||||
pub reason_code: &'static str,
|
||||
/// Actionable rejection summary.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Vulkan capability selection error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum VulkanCapabilityError {
|
||||
/// No physical devices were available.
|
||||
NoPhysicalDevice,
|
||||
/// Device API version is lower than the Stage 0 minimum.
|
||||
ApiVersionTooLow {
|
||||
/// Required Vulkan API version.
|
||||
required: u32,
|
||||
/// Reported Vulkan API version.
|
||||
found: u32,
|
||||
},
|
||||
/// Required graphics queue is unavailable.
|
||||
NoGraphicsQueue {
|
||||
/// Device name that failed validation.
|
||||
device: String,
|
||||
},
|
||||
/// Required present queue is unavailable.
|
||||
NoPresentQueue {
|
||||
/// Device name that failed validation.
|
||||
device: String,
|
||||
},
|
||||
/// Swapchain device extension is unavailable.
|
||||
MissingSwapchainExtension {
|
||||
/// Device name that failed validation.
|
||||
device: String,
|
||||
},
|
||||
/// No compatible surface format exists.
|
||||
MissingSurfaceFormat {
|
||||
/// Device name that failed validation.
|
||||
device: String,
|
||||
},
|
||||
/// No present mode is available for the target surface.
|
||||
MissingPresentMode {
|
||||
/// Device name that failed validation.
|
||||
device: String,
|
||||
},
|
||||
/// Swapchain images cannot be used as color attachments.
|
||||
MissingColorAttachmentUsage {
|
||||
/// Device name that failed validation.
|
||||
device: String,
|
||||
},
|
||||
/// No compatible depth/stencil attachment format exists for the render request.
|
||||
MissingDepthStencilFormat {
|
||||
/// Device name that failed validation.
|
||||
device: String,
|
||||
/// Requested depth/stencil profile.
|
||||
requested: DepthStencilSupport,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VulkanCapabilityError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NoPhysicalDevice => write!(f, "no Vulkan physical device available"),
|
||||
Self::ApiVersionTooLow { required, found } => write!(
|
||||
f,
|
||||
"Vulkan API version too low: required {}, found {}",
|
||||
format_api_version(*required),
|
||||
format_api_version(*found)
|
||||
),
|
||||
Self::NoGraphicsQueue { device } => {
|
||||
write!(f, "Vulkan device {device} has no graphics queue")
|
||||
}
|
||||
Self::NoPresentQueue { device } => {
|
||||
write!(f, "Vulkan device {device} has no present queue")
|
||||
}
|
||||
Self::MissingSwapchainExtension { device } => {
|
||||
write!(f, "Vulkan device {device} lacks {KHR_SWAPCHAIN_EXTENSION}")
|
||||
}
|
||||
Self::MissingSurfaceFormat { device } => {
|
||||
write!(f, "Vulkan device {device} has no compatible surface format")
|
||||
}
|
||||
Self::MissingPresentMode { device } => {
|
||||
write!(f, "Vulkan device {device} has no supported present mode")
|
||||
}
|
||||
Self::MissingColorAttachmentUsage { device } => write!(
|
||||
f,
|
||||
"Vulkan device {device} surface does not support COLOR_ATTACHMENT usage"
|
||||
),
|
||||
Self::MissingDepthStencilFormat { device, requested } => write!(
|
||||
f,
|
||||
"Vulkan device {device} lacks a depth/stencil attachment format for {}-bit depth and {}-bit stencil",
|
||||
requested.depth_bits,
|
||||
requested.stencil_bits
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VulkanCapabilityError {}
|
||||
|
||||
/// Selects a Vulkan physical device using deterministic Stage 0 policy.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanCapabilityError`] when no candidate satisfies the minimum
|
||||
/// API version, queue, swapchain-extension and surface-format requirements.
|
||||
pub fn select_physical_device(
|
||||
devices: &[VulkanPhysicalDeviceRecord],
|
||||
) -> Result<VulkanCapabilityReport, VulkanCapabilityError> {
|
||||
select_physical_device_for_request(devices, RenderRequest::conservative())
|
||||
}
|
||||
|
||||
/// Selects a Vulkan physical device for a specific Stage 0 render request.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanCapabilityError`] when no candidate satisfies the minimum
|
||||
/// API version, queue, swapchain-extension, surface-format or depth/stencil
|
||||
/// requirements for the requested profile.
|
||||
pub fn select_physical_device_for_request(
|
||||
devices: &[VulkanPhysicalDeviceRecord],
|
||||
render_request: RenderRequest,
|
||||
) -> Result<VulkanCapabilityReport, VulkanCapabilityError> {
|
||||
if devices.is_empty() {
|
||||
return Err(VulkanCapabilityError::NoPhysicalDevice);
|
||||
}
|
||||
|
||||
let mut best = None;
|
||||
let mut rejected_devices = Vec::new();
|
||||
let mut last_error = None;
|
||||
for device in devices {
|
||||
let report = match validate_device_for_request(device, render_request) {
|
||||
Ok(report) => report,
|
||||
Err(err) => {
|
||||
rejected_devices.push(rejected_device_report(device, &err));
|
||||
last_error = Some(err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match &best {
|
||||
Some(existing) if compare_reports(&report, existing) != std::cmp::Ordering::Greater => {
|
||||
}
|
||||
_ => best = Some(report),
|
||||
}
|
||||
}
|
||||
let mut best =
|
||||
best.ok_or_else(|| last_error.unwrap_or(VulkanCapabilityError::NoPhysicalDevice))?;
|
||||
best.rejected_devices = rejected_devices;
|
||||
Ok(best)
|
||||
}
|
||||
|
||||
/// Builds a deterministic swapchain plan from surface capabilities.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanSwapchainError`] when formats, present modes or extent are
|
||||
/// unusable.
|
||||
pub fn plan_vulkan_swapchain(
|
||||
request: &VulkanSwapchainRequest,
|
||||
) -> Result<VulkanSwapchainPlan, VulkanSwapchainError> {
|
||||
let format = select_surface_format(&request.formats)?;
|
||||
let present_mode = select_present_mode(&request.present_modes, request.preferred_present_mode)?;
|
||||
let extent = select_swapchain_extent(request)?;
|
||||
if extent.0 == 0 || extent.1 == 0 {
|
||||
return Err(VulkanSwapchainError::EmptyExtent);
|
||||
}
|
||||
Ok(VulkanSwapchainPlan {
|
||||
schema: 1,
|
||||
extent,
|
||||
format,
|
||||
present_mode,
|
||||
image_count: select_image_count(request.capabilities),
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds a deterministic swapchain recreation report.
|
||||
#[must_use]
|
||||
pub const fn swapchain_recreation_report(
|
||||
reason: VulkanSwapchainRecreationReason,
|
||||
previous_extent: (u32, u32),
|
||||
next_extent: (u32, u32),
|
||||
) -> VulkanSwapchainRecreationReport {
|
||||
VulkanSwapchainRecreationReport {
|
||||
schema: 1,
|
||||
reason,
|
||||
previous_extent,
|
||||
next_extent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a deterministic frame submission plan for a validated command list.
|
||||
///
|
||||
/// Stage 0 keeps this as a pure planning boundary so command-pool, command-buffer
|
||||
/// and synchronization policy can be tested without requiring a native surface.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RenderError`] when the command list has invalid frame framing,
|
||||
/// ordering, draw ranges, mesh bounds, or non-finite transforms.
|
||||
pub fn plan_vulkan_frame_submission(
|
||||
swapchain: &VulkanSwapchainPlan,
|
||||
commands: &RenderCommandList,
|
||||
) -> Result<VulkanFrameSubmissionPlan, RenderError> {
|
||||
validate_command_list(commands)?;
|
||||
let mut draw_count = 0_u32;
|
||||
let mut indexed_vertex_count = 0_u32;
|
||||
for command in &commands.commands {
|
||||
if let RenderCommand::Draw(draw) = command {
|
||||
draw_count = draw_count.saturating_add(1);
|
||||
indexed_vertex_count = indexed_vertex_count.saturating_add(draw.range.count);
|
||||
}
|
||||
}
|
||||
Ok(VulkanFrameSubmissionPlan {
|
||||
schema: 1,
|
||||
frames_in_flight: swapchain.image_count.clamp(1, 2),
|
||||
command_buffers: swapchain.image_count,
|
||||
semaphores_per_frame: 2,
|
||||
fences_per_frame: 1,
|
||||
draw_count,
|
||||
indexed_vertex_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Renders a deterministic JSON capability report.
|
||||
#[must_use]
|
||||
pub fn render_capability_report_json(report: &VulkanCapabilityReport) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct CapabilityReportJson<'a> {
|
||||
schema: u32,
|
||||
vulkan_api: String,
|
||||
device_name: &'a str,
|
||||
score: i32,
|
||||
graphics_queue_family: u32,
|
||||
present_queue_family: u32,
|
||||
portability_subset: bool,
|
||||
enabled_extensions: &'a [String],
|
||||
informational_capabilities: &'a VulkanInformationalCapabilities,
|
||||
rejected_devices: &'a [VulkanRejectedDeviceReport],
|
||||
}
|
||||
|
||||
serialize_json_or_fallback(
|
||||
&CapabilityReportJson {
|
||||
schema: report.schema,
|
||||
vulkan_api: format_api_version(report.vulkan_api_version),
|
||||
device_name: &report.device_name,
|
||||
score: report.score,
|
||||
graphics_queue_family: report.graphics_queue_family,
|
||||
present_queue_family: report.present_queue_family,
|
||||
portability_subset: report.portability_subset,
|
||||
enabled_extensions: &report.enabled_extensions,
|
||||
informational_capabilities: &report.informational_capabilities,
|
||||
rejected_devices: &report.rejected_devices,
|
||||
},
|
||||
"{\"schema\":0,\"vulkan_api\":\"0.0.0\",\"device_name\":\"unknown\",\"score\":0,\"graphics_queue_family\":0,\"present_queue_family\":0,\"portability_subset\":false,\"enabled_extensions\":[],\"informational_capabilities\":{\"sampled_color_formats\":[],\"sampled_depth_formats\":[],\"limits\":{\"max_image_dimension_2d\":0,\"max_sampler_allocation_count\":0,\"max_per_stage_descriptor_samplers\":0,\"max_bound_descriptor_sets\":0}},\"rejected_devices\":[]}",
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders a deterministic JSON swapchain plan.
|
||||
#[must_use]
|
||||
pub fn render_swapchain_plan_json(plan: &VulkanSwapchainPlan) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct SwapchainPlanJson {
|
||||
schema: u32,
|
||||
extent: [u32; 2],
|
||||
format: i32,
|
||||
color_space: i32,
|
||||
present_mode: i32,
|
||||
image_count: u32,
|
||||
}
|
||||
|
||||
serialize_json_or_fallback(
|
||||
&SwapchainPlanJson {
|
||||
schema: plan.schema,
|
||||
extent: [plan.extent.0, plan.extent.1],
|
||||
format: plan.format.format,
|
||||
color_space: plan.format.color_space,
|
||||
present_mode: plan.present_mode,
|
||||
image_count: plan.image_count,
|
||||
},
|
||||
"{\"schema\":0,\"extent\":[0,0],\"format\":0,\"color_space\":0,\"present_mode\":0,\"image_count\":0}",
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders a deterministic JSON swapchain recreation report.
|
||||
#[must_use]
|
||||
pub fn render_swapchain_recreation_report_json(report: &VulkanSwapchainRecreationReport) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct SwapchainRecreationReportJson<'a> {
|
||||
schema: u32,
|
||||
reason: &'a str,
|
||||
previous_extent: [u32; 2],
|
||||
next_extent: [u32; 2],
|
||||
}
|
||||
|
||||
serialize_json_or_fallback(
|
||||
&SwapchainRecreationReportJson {
|
||||
schema: report.schema,
|
||||
reason: match report.reason {
|
||||
VulkanSwapchainRecreationReason::Resize => "resize",
|
||||
VulkanSwapchainRecreationReason::OutOfDate => "out_of_date",
|
||||
VulkanSwapchainRecreationReason::Suboptimal => "suboptimal",
|
||||
},
|
||||
previous_extent: [report.previous_extent.0, report.previous_extent.1],
|
||||
next_extent: [report.next_extent.0, report.next_extent.1],
|
||||
},
|
||||
"{\"schema\":0,\"reason\":\"unknown\",\"previous_extent\":[0,0],\"next_extent\":[0,0]}",
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders a deterministic JSON frame submission plan.
|
||||
#[must_use]
|
||||
pub fn render_frame_submission_plan_json(plan: &VulkanFrameSubmissionPlan) -> String {
|
||||
serialize_json_or_fallback(
|
||||
plan,
|
||||
"{\"schema\":0,\"frames_in_flight\":0,\"command_buffers\":0,\"semaphores_per_frame\":0,\"fences_per_frame\":0,\"draw_count\":0,\"indexed_vertex_count\":0}",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn select_composite_alpha(
|
||||
supported: vk::CompositeAlphaFlagsKHR,
|
||||
) -> vk::CompositeAlphaFlagsKHR {
|
||||
if supported.contains(vk::CompositeAlphaFlagsKHR::OPAQUE) {
|
||||
vk::CompositeAlphaFlagsKHR::OPAQUE
|
||||
} else if supported.contains(vk::CompositeAlphaFlagsKHR::PRE_MULTIPLIED) {
|
||||
vk::CompositeAlphaFlagsKHR::PRE_MULTIPLIED
|
||||
} else if supported.contains(vk::CompositeAlphaFlagsKHR::POST_MULTIPLIED) {
|
||||
vk::CompositeAlphaFlagsKHR::POST_MULTIPLIED
|
||||
} else {
|
||||
vk::CompositeAlphaFlagsKHR::INHERIT
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn serialize_json_or_fallback<T: Serialize>(value: &T, fallback: &str) -> String {
|
||||
match serde_json::to_string(value) {
|
||||
Ok(json) => json,
|
||||
Err(_) => fallback.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_api_version(version: u32) -> String {
|
||||
format!(
|
||||
"{}.{}.{}",
|
||||
vk::api_version_major(version),
|
||||
vk::api_version_minor(version),
|
||||
vk::api_version_patch(version)
|
||||
)
|
||||
}
|
||||
|
||||
fn select_surface_format(
|
||||
formats: &[VulkanSurfaceFormat],
|
||||
) -> Result<VulkanSurfaceFormat, VulkanSwapchainError> {
|
||||
if let Some(format) = undefined_surface_format_override(formats) {
|
||||
return Ok(format);
|
||||
}
|
||||
formats
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|format| {
|
||||
format.format == vk::Format::B8G8R8A8_SRGB.as_raw()
|
||||
&& format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw()
|
||||
})
|
||||
.or_else(|| formats.first().copied())
|
||||
.ok_or(VulkanSwapchainError::MissingSurfaceFormat)
|
||||
}
|
||||
|
||||
fn undefined_surface_format_override(
|
||||
formats: &[VulkanSurfaceFormat],
|
||||
) -> Option<VulkanSurfaceFormat> {
|
||||
match formats {
|
||||
[format] if format.format == vk::Format::UNDEFINED.as_raw() => Some(VulkanSurfaceFormat {
|
||||
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
|
||||
color_space: format.color_space,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn select_present_mode(present_modes: &[i32], preferred: i32) -> Result<i32, VulkanSwapchainError> {
|
||||
if present_modes.contains(&preferred) {
|
||||
Ok(preferred)
|
||||
} else if present_modes.contains(&vk::PresentModeKHR::FIFO.as_raw()) {
|
||||
Ok(vk::PresentModeKHR::FIFO.as_raw())
|
||||
} else {
|
||||
present_modes
|
||||
.first()
|
||||
.copied()
|
||||
.ok_or(VulkanSwapchainError::MissingPresentMode)
|
||||
}
|
||||
}
|
||||
|
||||
fn select_swapchain_extent(
|
||||
request: &VulkanSwapchainRequest,
|
||||
) -> Result<(u32, u32), VulkanSwapchainError> {
|
||||
if let Some(extent) = request.capabilities.current_extent {
|
||||
return if extent.0 == 0 || extent.1 == 0 {
|
||||
Err(VulkanSwapchainError::EmptyExtent)
|
||||
} else {
|
||||
Ok(extent)
|
||||
};
|
||||
}
|
||||
let width = request.drawable_extent.0.clamp(
|
||||
request.capabilities.min_extent.0,
|
||||
request.capabilities.max_extent.0,
|
||||
);
|
||||
let height = request.drawable_extent.1.clamp(
|
||||
request.capabilities.min_extent.1,
|
||||
request.capabilities.max_extent.1,
|
||||
);
|
||||
Ok((width, height))
|
||||
}
|
||||
|
||||
fn select_image_count(capabilities: VulkanSwapchainSurfaceCapabilities) -> u32 {
|
||||
let requested = capabilities.min_image_count.saturating_add(1).max(2);
|
||||
if capabilities.max_image_count == 0 {
|
||||
requested
|
||||
} else {
|
||||
requested.min(capabilities.max_image_count)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_device_for_request(
|
||||
device: &VulkanPhysicalDeviceRecord,
|
||||
render_request: RenderRequest,
|
||||
) -> Result<VulkanCapabilityReport, VulkanCapabilityError> {
|
||||
if device.api_version < MIN_VULKAN_API_VERSION {
|
||||
return Err(VulkanCapabilityError::ApiVersionTooLow {
|
||||
required: MIN_VULKAN_API_VERSION,
|
||||
found: device.api_version,
|
||||
});
|
||||
}
|
||||
if !device.supports_extension(KHR_SWAPCHAIN_EXTENSION) {
|
||||
return Err(VulkanCapabilityError::MissingSwapchainExtension {
|
||||
device: device.name.clone(),
|
||||
});
|
||||
}
|
||||
if !supports_surface_formats(device) {
|
||||
return Err(VulkanCapabilityError::MissingSurfaceFormat {
|
||||
device: device.name.clone(),
|
||||
});
|
||||
}
|
||||
if device.present_modes.is_empty() {
|
||||
return Err(VulkanCapabilityError::MissingPresentMode {
|
||||
device: device.name.clone(),
|
||||
});
|
||||
}
|
||||
if !supports_color_attachment_usage(device.surface_capabilities) {
|
||||
return Err(VulkanCapabilityError::MissingColorAttachmentUsage {
|
||||
device: device.name.clone(),
|
||||
});
|
||||
}
|
||||
if !supports_depth_stencil_request(device, render_request.depth) {
|
||||
return Err(VulkanCapabilityError::MissingDepthStencilFormat {
|
||||
device: device.name.clone(),
|
||||
requested: render_request.depth,
|
||||
});
|
||||
}
|
||||
let (graphics_queue_family, present_queue_family) = select_queue_families(device)?;
|
||||
|
||||
let portability_subset = device.supports_extension(KHR_PORTABILITY_SUBSET_EXTENSION);
|
||||
let mut enabled_extensions = vec![KHR_SWAPCHAIN_EXTENSION.to_string()];
|
||||
if portability_subset {
|
||||
enabled_extensions.push(KHR_PORTABILITY_SUBSET_EXTENSION.to_string());
|
||||
}
|
||||
|
||||
Ok(VulkanCapabilityReport {
|
||||
schema: 1,
|
||||
device_name: device.name.clone(),
|
||||
vulkan_api_version: device.api_version,
|
||||
score: score_device(device, graphics_queue_family, present_queue_family),
|
||||
graphics_queue_family,
|
||||
present_queue_family,
|
||||
portability_subset,
|
||||
enabled_extensions,
|
||||
informational_capabilities: informational_capabilities(device),
|
||||
rejected_devices: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn rejected_device_report(
|
||||
device: &VulkanPhysicalDeviceRecord,
|
||||
error: &VulkanCapabilityError,
|
||||
) -> VulkanRejectedDeviceReport {
|
||||
VulkanRejectedDeviceReport {
|
||||
device_name: device.name.clone(),
|
||||
reason_code: capability_error_code(error),
|
||||
reason: error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
const fn capability_error_code(error: &VulkanCapabilityError) -> &'static str {
|
||||
match error {
|
||||
VulkanCapabilityError::NoPhysicalDevice => "no_physical_device",
|
||||
VulkanCapabilityError::ApiVersionTooLow { .. } => "api_version_too_low",
|
||||
VulkanCapabilityError::NoGraphicsQueue { .. } => "no_graphics_queue",
|
||||
VulkanCapabilityError::NoPresentQueue { .. } => "no_present_queue",
|
||||
VulkanCapabilityError::MissingSwapchainExtension { .. } => "missing_swapchain_extension",
|
||||
VulkanCapabilityError::MissingSurfaceFormat { .. } => "missing_surface_format",
|
||||
VulkanCapabilityError::MissingPresentMode { .. } => "missing_present_mode",
|
||||
VulkanCapabilityError::MissingColorAttachmentUsage { .. } => {
|
||||
"missing_color_attachment_usage"
|
||||
}
|
||||
VulkanCapabilityError::MissingDepthStencilFormat { .. } => "missing_depth_stencil_format",
|
||||
}
|
||||
}
|
||||
|
||||
fn select_queue_families(
|
||||
device: &VulkanPhysicalDeviceRecord,
|
||||
) -> Result<(u32, u32), VulkanCapabilityError> {
|
||||
if let Some(unified) = device
|
||||
.queue_families
|
||||
.iter()
|
||||
.filter(|family| family.graphics && family.present)
|
||||
.min_by_key(|family| family.index)
|
||||
{
|
||||
return Ok((unified.index, unified.index));
|
||||
}
|
||||
|
||||
let graphics_queue_family = device
|
||||
.queue_families
|
||||
.iter()
|
||||
.filter(|family| family.graphics)
|
||||
.min_by_key(|family| family.index)
|
||||
.ok_or_else(|| VulkanCapabilityError::NoGraphicsQueue {
|
||||
device: device.name.clone(),
|
||||
})?
|
||||
.index;
|
||||
let present_queue_family = device
|
||||
.queue_families
|
||||
.iter()
|
||||
.filter(|family| family.present)
|
||||
.min_by_key(|family| family.index)
|
||||
.ok_or_else(|| VulkanCapabilityError::NoPresentQueue {
|
||||
device: device.name.clone(),
|
||||
})?
|
||||
.index;
|
||||
Ok((graphics_queue_family, present_queue_family))
|
||||
}
|
||||
|
||||
fn supports_surface_formats(device: &VulkanPhysicalDeviceRecord) -> bool {
|
||||
!device.surface_formats.is_empty()
|
||||
}
|
||||
|
||||
fn supports_color_attachment_usage(capabilities: VulkanSwapchainSurfaceCapabilities) -> bool {
|
||||
capabilities.supported_usage_flags & vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw() != 0
|
||||
}
|
||||
|
||||
fn supports_depth_stencil_request(
|
||||
device: &VulkanPhysicalDeviceRecord,
|
||||
depth: DepthStencilSupport,
|
||||
) -> bool {
|
||||
if depth.depth_bits == 0 && depth.stencil_bits == 0 {
|
||||
return true;
|
||||
}
|
||||
required_depth_stencil_formats(depth).iter().any(|format| {
|
||||
device
|
||||
.supported_depth_stencil_formats
|
||||
.contains(&format.as_raw())
|
||||
})
|
||||
}
|
||||
|
||||
fn informational_capabilities(
|
||||
device: &VulkanPhysicalDeviceRecord,
|
||||
) -> VulkanInformationalCapabilities {
|
||||
let (sampled_depth_formats, sampled_color_formats): (Vec<_>, Vec<_>) = device
|
||||
.sampled_image_formats
|
||||
.iter()
|
||||
.copied()
|
||||
.partition(|format| is_depth_stencil_format(*format));
|
||||
VulkanInformationalCapabilities {
|
||||
sampled_color_formats,
|
||||
sampled_depth_formats,
|
||||
limits: device.limits,
|
||||
}
|
||||
}
|
||||
|
||||
fn required_depth_stencil_formats(depth: DepthStencilSupport) -> &'static [vk::Format] {
|
||||
match (depth.depth_bits, depth.stencil_bits) {
|
||||
(16, 0) => &[vk::Format::D16_UNORM, vk::Format::D32_SFLOAT],
|
||||
(24, 0) => &[vk::Format::X8_D24_UNORM_PACK32, vk::Format::D32_SFLOAT],
|
||||
(32, 0) => &[vk::Format::D32_SFLOAT],
|
||||
(16, 8) => &[vk::Format::D16_UNORM_S8_UINT, vk::Format::D24_UNORM_S8_UINT],
|
||||
(24, 8) => &[
|
||||
vk::Format::D24_UNORM_S8_UINT,
|
||||
vk::Format::D32_SFLOAT_S8_UINT,
|
||||
],
|
||||
(32, 8) => &[vk::Format::D32_SFLOAT_S8_UINT],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn is_depth_stencil_format(format: i32) -> bool {
|
||||
matches!(
|
||||
vk::Format::from_raw(format),
|
||||
vk::Format::D16_UNORM
|
||||
| vk::Format::X8_D24_UNORM_PACK32
|
||||
| vk::Format::D32_SFLOAT
|
||||
| vk::Format::S8_UINT
|
||||
| vk::Format::D16_UNORM_S8_UINT
|
||||
| vk::Format::D24_UNORM_S8_UINT
|
||||
| vk::Format::D32_SFLOAT_S8_UINT
|
||||
)
|
||||
}
|
||||
|
||||
fn score_device(
|
||||
device: &VulkanPhysicalDeviceRecord,
|
||||
graphics_queue_family: u32,
|
||||
present_queue_family: u32,
|
||||
) -> i32 {
|
||||
let unified_queue_bonus = if graphics_queue_family == present_queue_family {
|
||||
100
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let portability_penalty = if device.supports_extension(KHR_PORTABILITY_SUBSET_EXTENSION) {
|
||||
-50
|
||||
} else {
|
||||
0
|
||||
};
|
||||
device.device_type.score_bonus()
|
||||
+ unified_queue_bonus
|
||||
+ portability_penalty
|
||||
+ i32::try_from(device.surface_formats.len()).unwrap_or(i32::MAX)
|
||||
}
|
||||
|
||||
pub(crate) fn compare_reports(
|
||||
left: &VulkanCapabilityReport,
|
||||
right: &VulkanCapabilityReport,
|
||||
) -> std::cmp::Ordering {
|
||||
left.score
|
||||
.cmp(&right.score)
|
||||
.then_with(|| right.device_name.cmp(&left.device_name))
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
use fparkan_binary::{sha256, sha256_hex};
|
||||
use serde::Serialize;
|
||||
|
||||
pub(crate) use crate::ffi::{
|
||||
SPIRV_MAGIC, SPIRV_VERSION_1_0, TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS,
|
||||
};
|
||||
use crate::policy::serialize_json_or_fallback;
|
||||
|
||||
pub(crate) const SHADER_MANIFEST_SCHEMA: u32 = 2;
|
||||
pub(crate) const SHADER_TARGET_ENV: &str = "vulkan1.1";
|
||||
pub(crate) const SHADER_COMPILER_NAME: &str = "glslangValidator";
|
||||
pub(crate) const SHADER_COMPILER_VERSION: &str = "11:16.3.0";
|
||||
pub(crate) const SHADER_COMPILER_BINARY_SHA256: &str =
|
||||
"9bcd69d830b350aaa6e2254915ff74e46070e217b67f38daad27c1fc1f22910f";
|
||||
pub(crate) const SPIRV_VALIDATOR_NAME: &str = "spirv-val";
|
||||
pub(crate) const SPIRV_VALIDATOR_VERSION: &str =
|
||||
"SPIRV-Tools v2026.2 unknown hash, 2026-04-29T17:02:58+00:00";
|
||||
pub(crate) const SPIRV_VALIDATOR_BINARY_SHA256: &str =
|
||||
"f6d5b96ff19f073f3af0c0bcfa0c18702d288d3ec598efc242d01cd104d8354f";
|
||||
pub(crate) const TRIANGLE_VERTEX_SOURCE_PATH: &str =
|
||||
"adapters/fparkan-render-vulkan/shaders/triangle.vert";
|
||||
pub(crate) const TRIANGLE_VERTEX_SOURCE_SHA256: &str =
|
||||
"1e57f14d193fc61457c0749081c452ad25669998913107df12f3ccc3c33e0341";
|
||||
pub(crate) const TRIANGLE_VERTEX_SPIRV_PATH: &str =
|
||||
"adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
|
||||
pub(crate) const TRIANGLE_VERTEX_COMPILE_COMMAND: &str =
|
||||
"glslangValidator -V --target-env vulkan1.1 -S vert -e main adapters/fparkan-render-vulkan/shaders/triangle.vert -o adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
|
||||
pub(crate) const TRIANGLE_VERTEX_VALIDATE_COMMAND: &str =
|
||||
"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
|
||||
const TRIANGLE_FRAGMENT_SOURCE_PATH: &str = "adapters/fparkan-render-vulkan/shaders/triangle.frag";
|
||||
const TRIANGLE_FRAGMENT_SOURCE_SHA256: &str =
|
||||
"f19e74d001d07fb537d4b0f9e621f9b8bc40eeb68816130220853abea6bd4445";
|
||||
const TRIANGLE_FRAGMENT_SPIRV_PATH: &str =
|
||||
"adapters/fparkan-render-vulkan/shaders/triangle.frag.spv";
|
||||
const TRIANGLE_FRAGMENT_COMPILE_COMMAND: &str =
|
||||
"glslangValidator -V --target-env vulkan1.1 -S frag -e main adapters/fparkan-render-vulkan/shaders/triangle.frag -o adapters/fparkan-render-vulkan/shaders/triangle.frag.spv";
|
||||
const TRIANGLE_FRAGMENT_VALIDATE_COMMAND: &str =
|
||||
"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.frag.spv";
|
||||
|
||||
fn shader_compiler_name() -> &'static str {
|
||||
option_env!("FPARKAN_BUILD_SHADER_COMPILER_NAME").unwrap_or(SHADER_COMPILER_NAME)
|
||||
}
|
||||
|
||||
fn shader_compiler_version() -> &'static str {
|
||||
option_env!("FPARKAN_BUILD_SHADER_COMPILER_VERSION").unwrap_or(SHADER_COMPILER_VERSION)
|
||||
}
|
||||
|
||||
fn shader_compiler_binary_sha256() -> &'static str {
|
||||
option_env!("FPARKAN_BUILD_SHADER_COMPILER_SHA256").unwrap_or(SHADER_COMPILER_BINARY_SHA256)
|
||||
}
|
||||
|
||||
fn spirv_validator_name() -> &'static str {
|
||||
option_env!("FPARKAN_BUILD_SPIRV_VALIDATOR_NAME").unwrap_or(SPIRV_VALIDATOR_NAME)
|
||||
}
|
||||
|
||||
fn spirv_validator_version() -> &'static str {
|
||||
option_env!("FPARKAN_BUILD_SPIRV_VALIDATOR_VERSION").unwrap_or(SPIRV_VALIDATOR_VERSION)
|
||||
}
|
||||
|
||||
fn spirv_validator_binary_sha256() -> &'static str {
|
||||
option_env!("FPARKAN_BUILD_SPIRV_VALIDATOR_SHA256").unwrap_or(SPIRV_VALIDATOR_BINARY_SHA256)
|
||||
}
|
||||
|
||||
/// Shader tool metadata pinned in the Stage 0 manifest.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct VulkanShaderToolManifest {
|
||||
/// Tool executable name.
|
||||
pub name: &'static str,
|
||||
/// Tool version string.
|
||||
pub version: &'static str,
|
||||
/// Tool binary SHA-256.
|
||||
pub binary_sha256: &'static str,
|
||||
}
|
||||
|
||||
/// Vulkan shader stage.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum VulkanShaderStage {
|
||||
/// Vertex stage.
|
||||
Vertex,
|
||||
/// Fragment stage.
|
||||
Fragment,
|
||||
}
|
||||
|
||||
/// Offline SPIR-V shader manifest entry.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanShaderModuleManifest {
|
||||
/// Logical shader name.
|
||||
pub name: &'static str,
|
||||
/// Shader stage.
|
||||
pub stage: VulkanShaderStage,
|
||||
/// SPIR-V entry point.
|
||||
pub entry_point: &'static str,
|
||||
/// Descriptor set count.
|
||||
pub descriptor_sets: u32,
|
||||
/// Push constant byte count.
|
||||
pub push_constant_bytes: u32,
|
||||
/// Checked-in GLSL source path.
|
||||
pub source_path: &'static str,
|
||||
/// Checked-in GLSL source SHA-256.
|
||||
pub source_sha256: &'static str,
|
||||
/// Checked-in SPIR-V module path.
|
||||
pub spirv_path: &'static str,
|
||||
/// Exact offline compile command used for the checked-in SPIR-V artifact.
|
||||
pub compile_command: &'static str,
|
||||
/// Exact offline validation command used for the checked-in SPIR-V artifact.
|
||||
pub validate_command: &'static str,
|
||||
/// SPIR-V words.
|
||||
pub words: &'static [u32],
|
||||
}
|
||||
|
||||
/// Shader manifest validation report.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanShaderManifestReport {
|
||||
/// Report schema version.
|
||||
pub schema: u32,
|
||||
/// Explicit Vulkan target environment for the checked-in SPIR-V.
|
||||
pub target_env: &'static str,
|
||||
/// Pinned compiler metadata.
|
||||
pub compiler: VulkanShaderToolManifest,
|
||||
/// Pinned validator metadata.
|
||||
pub validator: VulkanShaderToolManifest,
|
||||
/// Shader module reports.
|
||||
pub modules: Vec<VulkanShaderModuleReport>,
|
||||
/// Hash of the normalized shader manifest.
|
||||
pub manifest_hash: String,
|
||||
}
|
||||
|
||||
/// Shader module validation report.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct VulkanShaderModuleReport {
|
||||
/// Logical shader name.
|
||||
pub name: &'static str,
|
||||
/// Shader stage.
|
||||
pub stage: VulkanShaderStage,
|
||||
/// SPIR-V entry point.
|
||||
pub entry_point: &'static str,
|
||||
/// Checked-in GLSL source path.
|
||||
pub source_path: &'static str,
|
||||
/// Checked-in GLSL source SHA-256.
|
||||
pub source_sha256: &'static str,
|
||||
/// Checked-in SPIR-V module path.
|
||||
pub spirv_path: &'static str,
|
||||
/// SPIR-V word count.
|
||||
pub word_count: usize,
|
||||
/// SPIR-V byte hash.
|
||||
pub sha256: String,
|
||||
/// Descriptor set count.
|
||||
pub descriptor_sets: u32,
|
||||
/// Push constant byte count.
|
||||
pub push_constant_bytes: u32,
|
||||
/// Exact offline compile command used for the checked-in SPIR-V artifact.
|
||||
pub compile_command: &'static str,
|
||||
/// Exact offline validation command used for the checked-in SPIR-V artifact.
|
||||
pub validate_command: &'static str,
|
||||
/// Stable hash of the reflected interface contract for this module.
|
||||
pub interface_hash: String,
|
||||
}
|
||||
|
||||
/// Shader manifest validation error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum VulkanShaderManifestError {
|
||||
/// SPIR-V module is too short to contain a header.
|
||||
TooShort {
|
||||
/// Shader name.
|
||||
name: &'static str,
|
||||
},
|
||||
/// SPIR-V module has an invalid magic word.
|
||||
InvalidMagic {
|
||||
/// Shader name.
|
||||
name: &'static str,
|
||||
/// Found magic word.
|
||||
found: u32,
|
||||
},
|
||||
/// SPIR-V module version is below 1.0.
|
||||
UnsupportedVersion {
|
||||
/// Shader name.
|
||||
name: &'static str,
|
||||
/// Found version word.
|
||||
found: u32,
|
||||
},
|
||||
/// SPIR-V module declares an invalid bound.
|
||||
InvalidBound {
|
||||
/// Shader name.
|
||||
name: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VulkanShaderManifestError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::TooShort { name } => write!(f, "shader {name} SPIR-V module is too short"),
|
||||
Self::InvalidMagic { name, found } => {
|
||||
write!(f, "shader {name} has invalid SPIR-V magic 0x{found:08x}")
|
||||
}
|
||||
Self::UnsupportedVersion { name, found } => write!(
|
||||
f,
|
||||
"shader {name} has unsupported SPIR-V version 0x{found:08x}"
|
||||
),
|
||||
Self::InvalidBound { name } => write!(f, "shader {name} has invalid SPIR-V bound"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VulkanShaderManifestError {}
|
||||
|
||||
/// Returns the built-in Stage 0 indexed-triangle shader manifest.
|
||||
#[must_use]
|
||||
pub fn triangle_shader_manifest() -> Vec<VulkanShaderModuleManifest> {
|
||||
vec![
|
||||
VulkanShaderModuleManifest {
|
||||
name: "triangle.vert",
|
||||
stage: VulkanShaderStage::Vertex,
|
||||
entry_point: "main",
|
||||
descriptor_sets: 0,
|
||||
push_constant_bytes: 0,
|
||||
source_path: TRIANGLE_VERTEX_SOURCE_PATH,
|
||||
source_sha256: TRIANGLE_VERTEX_SOURCE_SHA256,
|
||||
spirv_path: TRIANGLE_VERTEX_SPIRV_PATH,
|
||||
compile_command: TRIANGLE_VERTEX_COMPILE_COMMAND,
|
||||
validate_command: TRIANGLE_VERTEX_VALIDATE_COMMAND,
|
||||
words: TRIANGLE_VERTEX_SHADER_WORDS,
|
||||
},
|
||||
VulkanShaderModuleManifest {
|
||||
name: "triangle.frag",
|
||||
stage: VulkanShaderStage::Fragment,
|
||||
entry_point: "main",
|
||||
descriptor_sets: 0,
|
||||
push_constant_bytes: 0,
|
||||
source_path: TRIANGLE_FRAGMENT_SOURCE_PATH,
|
||||
source_sha256: TRIANGLE_FRAGMENT_SOURCE_SHA256,
|
||||
spirv_path: TRIANGLE_FRAGMENT_SPIRV_PATH,
|
||||
compile_command: TRIANGLE_FRAGMENT_COMPILE_COMMAND,
|
||||
validate_command: TRIANGLE_FRAGMENT_VALIDATE_COMMAND,
|
||||
words: TRIANGLE_FRAGMENT_SHADER_WORDS,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Validates shader SPIR-V containers and renders a deterministic report.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanShaderManifestError`] when a module fails Stage 0 SPIR-V
|
||||
/// container validation.
|
||||
pub fn validate_shader_manifest(
|
||||
modules: &[VulkanShaderModuleManifest],
|
||||
) -> Result<VulkanShaderManifestReport, VulkanShaderManifestError> {
|
||||
let mut reports = Vec::with_capacity(modules.len());
|
||||
for module in modules {
|
||||
validate_spirv_container(module)?;
|
||||
let bytes = spirv_words_to_bytes(module.words);
|
||||
reports.push(VulkanShaderModuleReport {
|
||||
name: module.name,
|
||||
stage: module.stage,
|
||||
entry_point: module.entry_point,
|
||||
source_path: module.source_path,
|
||||
source_sha256: module.source_sha256,
|
||||
spirv_path: module.spirv_path,
|
||||
word_count: module.words.len(),
|
||||
sha256: sha256_hex(&sha256(&bytes)),
|
||||
descriptor_sets: module.descriptor_sets,
|
||||
push_constant_bytes: module.push_constant_bytes,
|
||||
compile_command: module.compile_command,
|
||||
validate_command: module.validate_command,
|
||||
interface_hash: shader_interface_hash(module),
|
||||
});
|
||||
}
|
||||
let normalized = render_shader_manifest_without_hash_json(&reports);
|
||||
Ok(VulkanShaderManifestReport {
|
||||
schema: SHADER_MANIFEST_SCHEMA,
|
||||
target_env: SHADER_TARGET_ENV,
|
||||
compiler: VulkanShaderToolManifest {
|
||||
name: shader_compiler_name(),
|
||||
version: shader_compiler_version(),
|
||||
binary_sha256: shader_compiler_binary_sha256(),
|
||||
},
|
||||
validator: VulkanShaderToolManifest {
|
||||
name: spirv_validator_name(),
|
||||
version: spirv_validator_version(),
|
||||
binary_sha256: spirv_validator_binary_sha256(),
|
||||
},
|
||||
modules: reports,
|
||||
manifest_hash: sha256_hex(&sha256(normalized.as_bytes())),
|
||||
})
|
||||
}
|
||||
|
||||
/// Renders a deterministic JSON shader manifest report.
|
||||
#[must_use]
|
||||
pub fn render_shader_manifest_report_json(report: &VulkanShaderManifestReport) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct ShaderManifestReportJson<'a> {
|
||||
schema: u32,
|
||||
target_env: &'a str,
|
||||
compiler: &'a VulkanShaderToolManifest,
|
||||
validator: &'a VulkanShaderToolManifest,
|
||||
modules: &'a [VulkanShaderModuleReport],
|
||||
manifest_hash: &'a str,
|
||||
}
|
||||
|
||||
serialize_json_or_fallback(
|
||||
&ShaderManifestReportJson {
|
||||
schema: report.schema,
|
||||
target_env: report.target_env,
|
||||
compiler: &report.compiler,
|
||||
validator: &report.validator,
|
||||
modules: &report.modules,
|
||||
manifest_hash: &report.manifest_hash,
|
||||
},
|
||||
"{\"schema\":0,\"target_env\":\"unknown\",\"compiler\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"validator\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"modules\":[],\"manifest_hash\":\"unknown\"}",
|
||||
)
|
||||
}
|
||||
|
||||
fn shader_interface_hash(module: &VulkanShaderModuleManifest) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct ShaderInterfaceHashJson<'a> {
|
||||
stage: VulkanShaderStage,
|
||||
entry_point: &'a str,
|
||||
descriptor_sets: u32,
|
||||
push_constant_bytes: u32,
|
||||
}
|
||||
|
||||
let normalized = serialize_json_or_fallback(
|
||||
&ShaderInterfaceHashJson {
|
||||
stage: module.stage,
|
||||
entry_point: module.entry_point,
|
||||
descriptor_sets: module.descriptor_sets,
|
||||
push_constant_bytes: module.push_constant_bytes,
|
||||
},
|
||||
"{\"stage\":\"vertex\",\"entry_point\":\"main\",\"descriptor_sets\":0,\"push_constant_bytes\":0}",
|
||||
);
|
||||
sha256_hex(&sha256(normalized.as_bytes()))
|
||||
}
|
||||
|
||||
fn validate_spirv_container(
|
||||
module: &VulkanShaderModuleManifest,
|
||||
) -> Result<(), VulkanShaderManifestError> {
|
||||
if module.words.len() < 5 {
|
||||
return Err(VulkanShaderManifestError::TooShort { name: module.name });
|
||||
}
|
||||
if module.words[0] != SPIRV_MAGIC {
|
||||
return Err(VulkanShaderManifestError::InvalidMagic {
|
||||
name: module.name,
|
||||
found: module.words[0],
|
||||
});
|
||||
}
|
||||
if module.words[1] < SPIRV_VERSION_1_0 {
|
||||
return Err(VulkanShaderManifestError::UnsupportedVersion {
|
||||
name: module.name,
|
||||
found: module.words[1],
|
||||
});
|
||||
}
|
||||
if module.words[3] == 0 {
|
||||
return Err(VulkanShaderManifestError::InvalidBound { name: module.name });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spirv_words_to_bytes(words: &[u32]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(words.len() * 4);
|
||||
for word in words {
|
||||
out.extend_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn render_shader_manifest_without_hash_json(modules: &[VulkanShaderModuleReport]) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct ShaderManifestWithoutHashJson<'a> {
|
||||
schema: u32,
|
||||
target_env: &'a str,
|
||||
compiler: VulkanShaderToolManifest,
|
||||
validator: VulkanShaderToolManifest,
|
||||
modules: &'a [VulkanShaderModuleReport],
|
||||
}
|
||||
|
||||
let json = serialize_json_or_fallback(
|
||||
&ShaderManifestWithoutHashJson {
|
||||
schema: SHADER_MANIFEST_SCHEMA,
|
||||
target_env: SHADER_TARGET_ENV,
|
||||
compiler: VulkanShaderToolManifest {
|
||||
name: SHADER_COMPILER_NAME,
|
||||
version: SHADER_COMPILER_VERSION,
|
||||
binary_sha256: SHADER_COMPILER_BINARY_SHA256,
|
||||
},
|
||||
validator: VulkanShaderToolManifest {
|
||||
name: SPIRV_VALIDATOR_NAME,
|
||||
version: SPIRV_VALIDATOR_VERSION,
|
||||
binary_sha256: SPIRV_VALIDATOR_BINARY_SHA256,
|
||||
},
|
||||
modules,
|
||||
},
|
||||
"{\"schema\":0,\"target_env\":\"unknown\",\"compiler\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"validator\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"modules\":[]}",
|
||||
);
|
||||
match json.strip_suffix('}') {
|
||||
Some(stripped) => stripped.to_string(),
|
||||
None => json,
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ use fparkan_render::{
|
||||
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderBackend, RenderCommand,
|
||||
RenderCommandList, RenderPhase,
|
||||
};
|
||||
use fparkan_render_vulkan::VulkanBackend;
|
||||
use fparkan_render_vulkan::VulkanPlanningBackend;
|
||||
use fparkan_runtime::{
|
||||
create, frame, load_mission, loaded_mission_assets, EngineConfig, EngineMode, EngineServices,
|
||||
MissionAssets, MissionRequest,
|
||||
@@ -71,7 +71,7 @@ fn run(args: &[String]) -> Result<String, String> {
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let mut backend = VulkanBackend::new();
|
||||
let mut backend = VulkanPlanningBackend::new();
|
||||
let _request = WinitWindow::default_render_request();
|
||||
let window = WinitWindow::synthetic(1280, 720);
|
||||
let _ = window.drawable_size();
|
||||
@@ -104,8 +104,8 @@ fn run(args: &[String]) -> Result<String, String> {
|
||||
args.frames,
|
||||
last_tick,
|
||||
last_draw_count,
|
||||
capture_report.submissions,
|
||||
capture_report.last_capture_size,
|
||||
capture_report.execution.submission_plans,
|
||||
capture_report.execution.last_capture_size,
|
||||
json_hash(&last_hash)
|
||||
))
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
|
||||
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
winit = "0.30"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Build-time provenance for native smoke artifacts.
|
||||
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=TARGET");
|
||||
println!("cargo:rerun-if-env-changed=RUSTC");
|
||||
println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN");
|
||||
println!("cargo:rerun-if-env-changed=GITHUB_SHA");
|
||||
println!("cargo:rerun-if-env-changed=SOURCE_VERSION");
|
||||
println!("cargo:rerun-if-env-changed=BUILD_VCS_NUMBER");
|
||||
|
||||
if let Ok(target) = env::var("TARGET") {
|
||||
println!("cargo:rustc-env=FPARKAN_BUILD_TARGET_TRIPLE={target}");
|
||||
}
|
||||
if let Some(toolchain) = rustc_release() {
|
||||
println!("cargo:rustc-env=FPARKAN_BUILD_RUST_TOOLCHAIN={toolchain}");
|
||||
}
|
||||
|
||||
if let Some(workspace_root) = workspace_root() {
|
||||
if let Some(git_dir) = git_dir(&workspace_root) {
|
||||
emit_git_rerun_hints(&git_dir);
|
||||
}
|
||||
|
||||
if let Some(commit_sha) = env_commit_sha().or_else(|| git_head_commit_sha(&workspace_root))
|
||||
{
|
||||
println!("cargo:rustc-env=FPARKAN_BUILD_COMMIT_SHA={commit_sha}");
|
||||
}
|
||||
if let Some(git_dirty) = git_dirty(&workspace_root) {
|
||||
println!("cargo:rustc-env=FPARKAN_BUILD_GIT_DIRTY={git_dirty}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_root() -> Option<PathBuf> {
|
||||
env::var_os("CARGO_MANIFEST_DIR")
|
||||
.map(PathBuf::from)
|
||||
.map(|manifest_dir| manifest_dir.join("../.."))
|
||||
}
|
||||
|
||||
fn env_commit_sha() -> Option<String> {
|
||||
["GITHUB_SHA", "SOURCE_VERSION", "BUILD_VCS_NUMBER"]
|
||||
.into_iter()
|
||||
.filter_map(|name| env::var(name).ok())
|
||||
.find(|value| is_commit_sha(value))
|
||||
}
|
||||
|
||||
fn git_head_commit_sha(workspace_root: &Path) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["-C"])
|
||||
.arg(workspace_root)
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let value = String::from_utf8(output.stdout).ok()?;
|
||||
let value = value.trim().to_string();
|
||||
is_commit_sha(&value).then_some(value)
|
||||
}
|
||||
|
||||
fn git_dirty(workspace_root: &Path) -> Option<bool> {
|
||||
let output = Command::new("git")
|
||||
.args(["-C"])
|
||||
.arg(workspace_root)
|
||||
.args(["status", "--short"])
|
||||
.output()
|
||||
.ok()?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| !String::from_utf8_lossy(&output.stdout).trim().is_empty())
|
||||
}
|
||||
|
||||
fn git_dir(workspace_root: &Path) -> Option<PathBuf> {
|
||||
let output = Command::new("git")
|
||||
.args(["-C"])
|
||||
.arg(workspace_root)
|
||||
.args(["rev-parse", "--git-dir"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let value = String::from_utf8(output.stdout).ok()?;
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| workspace_root.join(value))
|
||||
}
|
||||
|
||||
fn emit_git_rerun_hints(git_dir: &Path) {
|
||||
let head = git_dir.join("HEAD");
|
||||
println!("cargo:rerun-if-changed={}", head.display());
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
git_dir.join("packed-refs").display()
|
||||
);
|
||||
println!("cargo:rerun-if-changed={}", git_dir.join("index").display());
|
||||
let Some(reference) = std::fs::read_to_string(&head).ok().and_then(|value| {
|
||||
value
|
||||
.strip_prefix("ref: ")
|
||||
.map(str::trim)
|
||||
.map(ToOwned::to_owned)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
git_dir.join(reference).display()
|
||||
);
|
||||
}
|
||||
|
||||
fn is_commit_sha(value: &str) -> bool {
|
||||
value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn rustc_release() -> Option<String> {
|
||||
let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string());
|
||||
let output = Command::new(rustc).arg("-Vv").output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(output.stdout)
|
||||
.ok()?
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.strip_prefix("release: ")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
}
|
||||
+1008
-1447
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
[graph]
|
||||
all-features = true
|
||||
|
||||
[advisories]
|
||||
yanked = "deny"
|
||||
|
||||
[bans]
|
||||
multiple-versions = "allow"
|
||||
wildcards = "deny"
|
||||
deny = [
|
||||
{ name = "native-tls" },
|
||||
{ name = "openssl" },
|
||||
{ name = "openssl-sys" },
|
||||
]
|
||||
|
||||
[licenses]
|
||||
unlicensed = "deny"
|
||||
copyleft = "allow"
|
||||
allow = [
|
||||
"Apache-2.0",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"CC0-1.0",
|
||||
"GPL-2.0-only",
|
||||
"ISC",
|
||||
"MIT",
|
||||
"MPL-2.0",
|
||||
"Unicode-3.0",
|
||||
"Zlib",
|
||||
]
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
@@ -11,11 +11,11 @@ S0-ARCH-003 covered cargo xtask policy rejects platform/render adapter dependenc
|
||||
S0-ARCH-004 covered cargo xtask policy scans workspace-owned Rust/TOML for unsafe constructs and workspace lints forbid unsafe_code
|
||||
S0-ARCH-005 covered cargo xtask policy rejects Python source files, Python shebangs, and Python CI workflow steps while allowing docs requirements.txt
|
||||
S0-ARCH-006 covered cargo xtask policy rejects non-fparkan package directories under crates/
|
||||
S0-ARCH-007 covered cargo xtask ci runs fmt, policy, workspace test, clippy, rustdoc warnings, cargo-deny or built-in supply-chain fallback, and strict acceptance audit
|
||||
S0-ARCH-007 covered cargo xtask ci runs fmt, policy, workspace test, clippy, rustdoc warnings, cargo-deny with reviewed deny.toml, and strict acceptance audit; built-in supply-chain fallback is opt-in local-only and forbidden when CI is set
|
||||
S0-ARCH-008 covered cargo xtask policy rejects moving Rust toolchains and workspace rust-version drift
|
||||
S0-ARCH-009 covered .github/workflows/ci.yml runs a pinned MSRV backend-neutral crate job
|
||||
S0-ARCH-010 covered cargo xtask acceptance audit emits commit_sha, rust_toolchain, and msrv metadata into the JSON artifact
|
||||
S0-ARCH-011 blocked cargo run -p fparkan-vulkan-smoke emits explicit per-platform blocked artifacts until real Vulkan 300-frame validation=0 runner is available
|
||||
S0-ARCH-010 covered cargo xtask acceptance audit emits measured commit_sha, git_dirty, runner_identity, rust_toolchain, and msrv metadata into the JSON artifact
|
||||
S0-ARCH-011 covered .github/workflows/ci.yml runs cargo run -p fparkan-vulkan-smoke --locked -- --out target/fparkan/native-smoke/macos.json and cargo xtask native-smoke audit enforces a passed macOS 300-frame report with measured resize/recreate, validation=0, clean git provenance, exact commit SHA shape, and a platform-consistent target triple
|
||||
S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context
|
||||
S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable
|
||||
S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent
|
||||
@@ -30,7 +30,7 @@ S0-PLAT-001 covered cargo test -p fparkan-platform-winit --offline window_port_r
|
||||
S0-PLAT-002 covered cargo clippy -p fparkan-platform -p fparkan-platform-winit --all-targets --all-features --locked -- -D warnings
|
||||
S0-PLAT-003 covered cargo test -p fparkan-platform-winit --offline smoke_window_plan_requires_native_handles_and_nonzero_extent smoke_window_plan_rejects_zero_extent
|
||||
S0-PLAT-004 covered cargo test -p fparkan-platform-winit --offline smoke_window_app_requires_created_native_window smoke_window_app_rejects_synthetic_window_without_native_handles
|
||||
S0-VK-001 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents
|
||||
S0-VK-001 covered cargo test -p fparkan-render-vulkan --offline planning_backend_tracks_render_request_and_simulated_present
|
||||
S0-VK-002 covered cargo test -p fparkan-render-vulkan --offline device_scoring_is_deterministic_and_prefers_discrete_unified_queue
|
||||
S0-VK-003 covered cargo test -p fparkan-render-vulkan --offline portability_subset_is_reported_and_enabled_when_exposed
|
||||
S0-VK-004 covered cargo test -p fparkan-render-vulkan --offline rejects_missing_graphics_present_swapchain_and_format
|
||||
@@ -47,23 +47,23 @@ S0-VK-014 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_p
|
||||
S0-VK-015 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_uses_fifo_and_current_extent_fallbacks
|
||||
S0-VK-016 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_rejects_missing_surface_data_and_empty_extent
|
||||
S0-VK-017 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_json_and_recreation_reports_are_stable
|
||||
S0-VK-018 covered cargo test -p fparkan-render-vulkan --offline triangle_shader_manifest_hashes_are_stable
|
||||
S0-VK-018 covered cargo test -p fparkan-render-vulkan --offline triangle_shader_manifest_hashes_are_stable checked_in_shader_manifest_matches_generated_report
|
||||
S0-VK-019 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_report_json_is_stable
|
||||
S0-VK-020 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_rejects_invalid_spirv_containers
|
||||
S0-VK-021 covered cargo test -p fparkan-render-vulkan --offline frame_submission_plan_json_is_stable
|
||||
S0-VK-022 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents
|
||||
S0-VK-022 covered cargo test -p fparkan-render-vulkan --offline planning_backend_tracks_render_request_and_simulated_present
|
||||
S0-VK-023 covered cargo test -p fparkan-vulkan-smoke --offline rejects_false_pass_without_full_evidence blocked_report_includes_shader_manifest_and_bootstrap_status
|
||||
S0-VK-024 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_loader_probe formats_vulkan_api_version
|
||||
S0-VK-025 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_instance_probe parses_instance_probe_as_loader_probe
|
||||
S0-VK-026 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_window_probe rejects_passed_without_surface_probe parses_surface_probe_as_instance_probe
|
||||
S0-VK-027 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_swapchain_recreation blocked_report_includes_shader_manifest_and_bootstrap_status
|
||||
S0-VK-028 covered cargo test -p fparkan-vulkan-smoke --offline reports_rustc_host_triple blocked_report_includes_shader_manifest_and_bootstrap_status
|
||||
S0-VK-029 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_three_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
|
||||
S0-VK-029 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_required_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
|
||||
S0-VK-030 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_with_failed_surface
|
||||
S0-VK-031 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_selected_device
|
||||
S0-VK-032 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_swapchain
|
||||
S0-VK-033 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_logical_device
|
||||
S0-VK-034 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_three_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
|
||||
S0-VK-034 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_required_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
|
||||
S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow
|
||||
S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read
|
||||
L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
|
||||
|
||||
|
@@ -0,0 +1,68 @@
|
||||
# Stage 0 acceptance IDs
|
||||
|
||||
`L0-COPYRIGHT-001`
|
||||
`L0-P1-001`
|
||||
`L0-P1-002`
|
||||
`L0-P2-001`
|
||||
`L0-P2-002`
|
||||
`S0-ARCH-001`
|
||||
`S0-ARCH-002`
|
||||
`S0-ARCH-003`
|
||||
`S0-ARCH-004`
|
||||
`S0-ARCH-005`
|
||||
`S0-ARCH-006`
|
||||
`S0-ARCH-007`
|
||||
`S0-ARCH-008`
|
||||
`S0-ARCH-009`
|
||||
`S0-ARCH-010`
|
||||
`S0-ARCH-011`
|
||||
`S0-DIAG-001`
|
||||
`S0-DIAG-002`
|
||||
`S0-CORPUS-001`
|
||||
`S0-CORPUS-002`
|
||||
`S0-CORPUS-003`
|
||||
`S0-CORPUS-004`
|
||||
`S0-CORPUS-005`
|
||||
`S0-CORPUS-006`
|
||||
`S0-CLI-001`
|
||||
`S0-CLI-002`
|
||||
`S0-PLAT-001`
|
||||
`S0-PLAT-002`
|
||||
`S0-PLAT-003`
|
||||
`S0-PLAT-004`
|
||||
`S0-VK-001`
|
||||
`S0-VK-002`
|
||||
`S0-VK-003`
|
||||
`S0-VK-004`
|
||||
`S0-VK-005`
|
||||
`S0-VK-006`
|
||||
`S0-VK-007`
|
||||
`S0-VK-008`
|
||||
`S0-VK-009`
|
||||
`S0-VK-010`
|
||||
`S0-VK-011`
|
||||
`S0-VK-012`
|
||||
`S0-VK-013`
|
||||
`S0-VK-014`
|
||||
`S0-VK-015`
|
||||
`S0-VK-016`
|
||||
`S0-VK-017`
|
||||
`S0-VK-018`
|
||||
`S0-VK-019`
|
||||
`S0-VK-020`
|
||||
`S0-VK-021`
|
||||
`S0-VK-022`
|
||||
`S0-VK-023`
|
||||
`S0-VK-024`
|
||||
`S0-VK-025`
|
||||
`S0-VK-026`
|
||||
`S0-VK-027`
|
||||
`S0-VK-028`
|
||||
`S0-VK-029`
|
||||
`S0-VK-030`
|
||||
`S0-VK-031`
|
||||
`S0-VK-032`
|
||||
`S0-VK-033`
|
||||
`S0-VK-034`
|
||||
`S0-LIMIT-001`
|
||||
`S0-LIMIT-002`
|
||||
@@ -0,0 +1,47 @@
|
||||
schema = 1
|
||||
|
||||
[stages]
|
||||
"0" = [
|
||||
"fparkan-binary",
|
||||
"fparkan-corpus",
|
||||
"fparkan-diagnostics",
|
||||
"fparkan-platform",
|
||||
"fparkan-platform-winit",
|
||||
"fparkan-render",
|
||||
"fparkan-render-vulkan",
|
||||
"fparkan-test-support",
|
||||
"fparkan-vulkan-smoke",
|
||||
"xtask",
|
||||
]
|
||||
"1" = [
|
||||
"fparkan-cli",
|
||||
"fparkan-inspection",
|
||||
"fparkan-nres",
|
||||
"fparkan-path",
|
||||
"fparkan-resource",
|
||||
"fparkan-rsli",
|
||||
"fparkan-vfs",
|
||||
]
|
||||
"2" = [
|
||||
"fparkan-prototype",
|
||||
]
|
||||
"3" = [
|
||||
"fparkan-assets",
|
||||
"fparkan-material",
|
||||
"fparkan-msh",
|
||||
"fparkan-texm",
|
||||
"fparkan-viewer",
|
||||
]
|
||||
"4" = [
|
||||
"fparkan-animation",
|
||||
"fparkan-fx",
|
||||
]
|
||||
"5" = [
|
||||
"fparkan-game",
|
||||
"fparkan-headless",
|
||||
"fparkan-mission-format",
|
||||
"fparkan-runtime",
|
||||
"fparkan-terrain",
|
||||
"fparkan-terrain-format",
|
||||
"fparkan-world",
|
||||
]
|
||||
@@ -1,643 +0,0 @@
|
||||
# FParkan — аудит Stage 0 и план полного закрытия
|
||||
|
||||
**Проект:** `valentineus/fparkan`
|
||||
**Проверенная ветка:** `devel` GitHub-зеркала
|
||||
**Дата аудита:** 23 июня 2026 года
|
||||
**Область:** только Stage 0 — Governance, reproducibility и Vulkan foundation
|
||||
**Метод:** статический архитектурный и кодовый аудит
|
||||
**Сборка и исполнение:** не выполнялись; `cargo build`, `cargo test`, Vulkan smoke и validation jobs не запускались
|
||||
|
||||
---
|
||||
|
||||
## 1. Итоговый вердикт
|
||||
|
||||
**Stage 0 не закрыт и находится в статусе `BLOCKED`.**
|
||||
|
||||
Главный критерий Stage 0 — воспроизводимый репозиторий и минимальный настоящий Vulkan vertical slice на Windows, Linux и macOS. В проверенном состоянии:
|
||||
|
||||
- отсутствует `fparkan-platform-winit`;
|
||||
- отсутствует `fparkan-render-vulkan`;
|
||||
- отсутствуют Vulkan instance/device/surface/swapchain;
|
||||
- `fparkan-game` использует `RecordingBackend`, а не GPU backend;
|
||||
- workspace по-прежнему содержит SDL/OpenGL stub adapters;
|
||||
- Rust toolchain закреплён только как изменяемый канал `stable`;
|
||||
- `cargo xtask ci` не реализует полный канонический gate;
|
||||
- нет подтверждённых артефактов Windows/Linux/macOS smoke jobs.
|
||||
|
||||
### Сводная оценка
|
||||
|
||||
| Группа требований | Статус | Основной блокер |
|
||||
|---|---|---|
|
||||
| Reproducibility и toolchain | **FAIL** | Toolchain не закреплён точной версией, MSRV не объявлен |
|
||||
| Repository policy и CI | **FAIL** | Неполные fmt/test/clippy/doc/security gates |
|
||||
| Platform abstraction | **FAIL** | Core API содержит OpenGL-specific contract; `winit` adapter отсутствует |
|
||||
| Vulkan backend | **FAIL** | Нет Vulkan loader/device/surface/swapchain/pipeline |
|
||||
| macOS portability | **FAIL** | Нет MoltenVK integration и portability handling |
|
||||
| Offline shaders | **FAIL** | Нет SPIR-V build/validation/hash pipeline |
|
||||
| Legacy cleanup | **FAIL** | SDL/GL stubs остаются workspace members |
|
||||
| Headless isolation | **PASS на manifest-level** | Автоматическое доказательство dependency closure ещё требуется |
|
||||
| Native acceptance | **FAIL / NOT RUNNABLE** | Нет реального backend и platform artifacts |
|
||||
|
||||
Stage 0 можно объявить закрытым только после прохождения реального Vulkan smoke на всех трёх системах и публикации machine-readable артефактов.
|
||||
|
||||
---
|
||||
|
||||
## 2. Область и ограничения аудита
|
||||
|
||||
Канонические требования взяты из документа:
|
||||
|
||||
- «План реализации stage 0–5: Vulkan revision»;
|
||||
- <https://app.notion.com/p/387e79f2db3981778f94cdf34db5f93f>.
|
||||
|
||||
Проверялась ветка:
|
||||
|
||||
- <https://github.com/valentineus/fparkan/tree/devel>.
|
||||
|
||||
Ограничения:
|
||||
|
||||
1. Ветка `devel` является движущейся ссылкой. Следующий formal audit следует выполнять на закреплённом commit SHA или tag.
|
||||
2. README указывает self-hosted repository как primary. Его закрытые CI runners и artifacts не были доступны.
|
||||
3. Код не собирался и не запускался по условию аудита.
|
||||
4. Vulkan runtime, validation layers, MoltenVK и native window creation не проверялись динамически.
|
||||
5. Статический анализ достаточен для определения текущих архитектурных блокеров: требуемых adapters и зависимостей в workspace нет.
|
||||
|
||||
---
|
||||
|
||||
## 3. Матрица требований Stage 0
|
||||
|
||||
| Требование | Статус | Текущее состояние | Необходимо для закрытия |
|
||||
|---|---|---|---|
|
||||
| Exact stable Rust toolchain | **FAIL** | `rust-toolchain.toml`: `channel = "stable"` | Закрепить точную версию, например `1.xx.y` |
|
||||
| Объявленный MSRV | **FAIL** | `workspace.package.rust-version` отсутствует | Добавить `rust-version` и отдельный MSRV job |
|
||||
| Полный `cargo xtask ci` | **FAIL** | Есть custom rustfmt, policy, workspace test и clippy | Добавить канонические fmt/test/clippy/doc/security gates |
|
||||
| `--all-targets --all-features` | **FAIL** | Не используются текущим `ci` | Добавить к test/clippy/doc gates |
|
||||
| Clippy `-D warnings` | **FAIL** | Явно не передаётся | Сделать предупреждения blocking |
|
||||
| Rustdoc broken-link gate | **FAIL** | Отсутствует | Добавить `RUSTDOCFLAGS=-D warnings -D rustdoc::broken_intra_doc_links` |
|
||||
| License/advisory/source policy | **PARTIAL / UNVERIFIED** | Есть custom policy и GPL workspace license | Подключить `cargo-deny` или эквивалент и хранить versioned policy |
|
||||
| Typed TOML parsing | **FAIL** | Licensed manifest разбирается вручную построчно | `serde` + TOML schema + `deny_unknown_fields` |
|
||||
| `cargo_metadata` policy | **FAIL** | Dependency rules не опираются на typed Cargo graph | Добавить `cargo_metadata` и package-ID based checks |
|
||||
| CI matrix Windows/Linux/macOS | **UNVERIFIED / BLOCKER** | Доступных platform artifacts нет | Создать native matrix и сохранять reports |
|
||||
| Backend-neutral platform API | **FAIL** | В core есть `GraphicsProfile`, GL/GLES versions и `WindowPort::present()` | Удалить GL context concepts; present перенести в renderer |
|
||||
| `fparkan-platform-winit` | **FAIL** | В workspace только SDL-named stub | Реализовать настоящий event loop/window adapter |
|
||||
| `fparkan-render-vulkan` | **FAIL** | В workspace только GL-named recording stub | Реализовать настоящий Vulkan backend |
|
||||
| Vulkan loader/instance/device | **FAIL** | Vulkan bindings отсутствуют | Добавить `ash`, instance, device selection, queues |
|
||||
| Surface/swapchain/present | **FAIL** | Отсутствуют | Реализовать platform surface и swapchain lifecycle |
|
||||
| Indexed triangle | **FAIL** | Есть только command capture | Нарисовать реальный indexed triangle |
|
||||
| Resize/out-of-date/suboptimal | **FAIL** | Swapchain отсутствует | Реализовать полную recreation policy |
|
||||
| Deterministic capability report | **FAIL** | Device discovery отсутствует | Pure scoring policy + JSON capability report |
|
||||
| macOS portability | **FAIL** | MoltenVK integration отсутствует | Portability enumeration, subset и packaged MoltenVK |
|
||||
| Offline SPIR-V pipeline | **FAIL** | GL stub проверяет только synthetic markers | Pinned compiler, validator, descriptor manifest и hashes |
|
||||
| Legacy adapter removal | **FAIL** | SDL/GL crates входят в workspace | Удалить crates и все references после замены |
|
||||
| Game/viewer composition | **FAIL** | Game использует `RecordingBackend`; viewer — CLI inspector | Подключить winit + Vulkan только в composition roots |
|
||||
| Headless isolation | **PASS на manifest-level** | Нет window/Vulkan dependency | Добавить automated Cargo metadata assertion |
|
||||
| 300 frames + resize + validation=0 | **FAIL** | Невозможно выполнить без backend | Native smoke jobs на трёх OS |
|
||||
| Negative Vulkan tests | **FAIL** | Нет Vulkan error model | Loader/device/queue/format failure fixtures |
|
||||
|
||||
---
|
||||
|
||||
## 4. Замечания
|
||||
|
||||
### S0-B01 — Workspace содержит удаляемые SDL/OpenGL stub crates
|
||||
|
||||
**Приоритет:** BLOCKER
|
||||
**Файлы:** `Cargo.toml`, `adapters/fparkan-platform-sdl`, `adapters/fparkan-render-gl`
|
||||
|
||||
Root workspace включает оба прежних adapter crate. При этом:
|
||||
|
||||
- SDL adapter не зависит от SDL и содержит in-memory stubs;
|
||||
- GL adapter не зависит от OpenGL и только сохраняет canonical command captures;
|
||||
- их tests доказывают deterministic stub behavior, а не platform/GPU integration.
|
||||
|
||||
Это создаёт ложноположительный сигнал готовности backend-а.
|
||||
|
||||
**Рекомендация:**
|
||||
|
||||
1. До появления замены пометить crates как `legacy-proof` и исключить из default production composition.
|
||||
2. Добавить policy, запрещающий приложениям зависеть от них.
|
||||
3. После подключения `platform-winit` и `render-vulkan` удалить crates, lockfile references, docs и tests.
|
||||
|
||||
### S0-B02 — Core platform contract остаётся OpenGL-specific
|
||||
|
||||
**Приоритет:** BLOCKER
|
||||
**Файл:** `crates/fparkan-platform/src/lib.rs`
|
||||
|
||||
Проблемы:
|
||||
|
||||
- `GraphicsProfile::DesktopCore/Embedded` описывает GL/GLES profile;
|
||||
- `GraphicsContextRequest` описывает создание GL context;
|
||||
- `WindowPort::present()` ошибочно закрепляет presentation за window abstraction;
|
||||
- `PlatformEvent` содержит только `Quit`;
|
||||
- отсутствуют resize, scale factor, focus, keyboard, mouse, suspend/resume и raw handles;
|
||||
- `PlatformError::Backend` не содержит source/context.
|
||||
|
||||
Для Vulkan окно не выполняет present. Surface, swapchain, image acquisition и queue presentation принадлежат render adapter.
|
||||
|
||||
**Рекомендация:** platform crate должен предоставлять только:
|
||||
|
||||
- event/lifecycle model;
|
||||
- physical и logical size;
|
||||
- scale factor;
|
||||
- normalized input;
|
||||
- raw window/display handles;
|
||||
- structured platform errors.
|
||||
|
||||
### S0-B03 — Реального Vulkan code path нет
|
||||
|
||||
**Приоритет:** BLOCKER
|
||||
|
||||
В inspected manifests отсутствуют `ash`, `ash-window`, `winit` и `raw-window-handle`. Следовательно, текущий код не может создать Vulkan instance/device/surface/swapchain.
|
||||
|
||||
`fparkan-game` выполняет backend-neutral capture через `RecordingBackend`. Это полезный CPU oracle, но не Vulkan renderer.
|
||||
|
||||
**Definition of fixed:** отдельный smoke executable открывает окно, создаёт Vulkan swapchain, рисует indexed triangle, обрабатывает resize и корректно завершается.
|
||||
|
||||
### S0-B04 — `cargo xtask ci` не соответствует exit gate
|
||||
|
||||
**Приоритет:** BLOCKER
|
||||
**Файл:** `xtask/src/main.rs`
|
||||
|
||||
Текущий gate не подтверждает:
|
||||
|
||||
- все targets и features;
|
||||
- clippy с `-D warnings`;
|
||||
- rustdoc warnings и broken links;
|
||||
- advisory/source policy;
|
||||
- dependency denylist;
|
||||
- отсутствие project-owned unsafe вне разрешённого Vulkan boundary;
|
||||
- корректность typed acceptance manifests;
|
||||
- platform-native smoke jobs.
|
||||
|
||||
Custom recursive rustfmt также может расходиться с canonical `cargo fmt --all -- --check`.
|
||||
|
||||
### S0-B05 — Toolchain не воспроизводим
|
||||
|
||||
**Приоритет:** BLOCKER
|
||||
**Файл:** `rust-toolchain.toml`
|
||||
|
||||
Канал `stable` изменяется. Один и тот же commit может использовать разные компиляторы в разные дни. MSRV также не объявлен.
|
||||
|
||||
**Рекомендация:**
|
||||
|
||||
- закрепить точный Rust release;
|
||||
- указать `rust-version`;
|
||||
- обновлять toolchain отдельным reviewed PR;
|
||||
- сохранять toolchain и SDK versions в acceptance report.
|
||||
|
||||
### S0-H01 — Нужен изолированный audited unsafe boundary
|
||||
|
||||
**Приоритет:** HIGH
|
||||
|
||||
`unsafe_code = "forbid"` правильно сохранять для backend-neutral crates. Однако Vulkan FFI требует локальных unsafe calls.
|
||||
|
||||
Нельзя ослаблять policy всему workspace.
|
||||
|
||||
**Целевая схема:**
|
||||
|
||||
- unsafe разрешён только в `fparkan-render-vulkan` low-level modules;
|
||||
- `unsafe_op_in_unsafe_fn = deny`;
|
||||
- каждый block имеет `// SAFETY:` comment;
|
||||
- ownership/lifetime rules документированы;
|
||||
- raw Vulkan handles не выходят в public neutral API;
|
||||
- custom policy scanner проверяет allowlist.
|
||||
|
||||
### S0-H02 — Neutral render IDs не должны быть GPU allocation IDs
|
||||
|
||||
**Приоритет:** HIGH, не блокирует первый hardcoded triangle
|
||||
**Файл:** `crates/fparkan-render/src/lib.rs`
|
||||
|
||||
`GpuMeshId` и `GpuMaterialId` появляются до существования GPU registry. Это смешивает CPU asset identity и backend-local allocation identity.
|
||||
|
||||
**Рекомендация:** использовать neutral `MeshAssetId`/`MaterialAssetId`; Vulkan adapter должен самостоятельно отображать их на buffers, images и descriptors.
|
||||
|
||||
### S0-M01 — Документация рассогласована с Vulkan revision
|
||||
|
||||
**Приоритет:** MEDIUM
|
||||
|
||||
`docs/tomes/07-implementation.md` сохраняет старую последовательность и multi-backend формулировки. Parity documentation ссылается на отсутствующий workspace crate, а active parity cases не определены.
|
||||
|
||||
**Рекомендация:** один versioned source of truth для stages и автоматическая проверка упомянутых crates, commands и backend names.
|
||||
|
||||
---
|
||||
|
||||
## 5. Сильные стороны, которые следует сохранить
|
||||
|
||||
- Workspace lint policy строгая и подходит для backend-neutral crates.
|
||||
- `Cargo.lock` присутствует, а команды используют `--locked`.
|
||||
- Synthetic и licensed corpus paths концептуально разделены.
|
||||
- `fparkan-headless` не зависит от platform/render adapters на manifest-level.
|
||||
- `fparkan-render` уже предоставляет deterministic command ordering, validation и canonical capture.
|
||||
- Composition roots отделены от большинства core crates.
|
||||
|
||||
Эти элементы позволяют построить Vulkan foundation без переписывания CPU/data foundation.
|
||||
|
||||
---
|
||||
|
||||
## 6. Целевая архитектура Stage 0
|
||||
|
||||
```text
|
||||
apps/fparkan-game, apps/fparkan-viewer
|
||||
│
|
||||
├── fparkan-platform-winit
|
||||
│ └── winit + raw-window-handle
|
||||
│
|
||||
└── fparkan-render-vulkan
|
||||
├── ash-window
|
||||
├── ash
|
||||
├── surface / swapchain
|
||||
├── device / queues
|
||||
├── shaders / pipelines
|
||||
└── synchronization / presentation
|
||||
|
||||
apps/fparkan-headless
|
||||
└── runtime/core only
|
||||
no winit, ash, MoltenVK or window dependencies
|
||||
```
|
||||
|
||||
Разделение ответственности:
|
||||
|
||||
- `fparkan-platform`: события, input, lifecycle, sizes и handle access;
|
||||
- `fparkan-platform-winit`: concrete window/event-loop implementation;
|
||||
- `fparkan-render`: backend-neutral command/snapshot contracts;
|
||||
- `fparkan-render-vulkan`: Vulkan resources, synchronization и present;
|
||||
- game/viewer: composition root;
|
||||
- headless: полностью изолированный путь.
|
||||
|
||||
---
|
||||
|
||||
## 7. План полного закрытия Stage 0
|
||||
|
||||
Порядок PR важен. Vulkan adapter не следует строить поверх текущего GL-oriented platform contract.
|
||||
|
||||
### PR S0-01 — Reproducible toolchain и metadata
|
||||
|
||||
**Изменения**
|
||||
|
||||
- закрепить exact Rust toolchain;
|
||||
- добавить `workspace.package.rust-version`;
|
||||
- зафиксировать supported triples;
|
||||
- добавить `cargo xtask doctor`;
|
||||
- включать commit SHA, Rust version и platform SDK versions в reports.
|
||||
|
||||
**Acceptance**
|
||||
|
||||
- clean checkout формирует одинаковый metadata report;
|
||||
- MSRV job собирает backend-neutral crates;
|
||||
- pinned toolchain проходит полный synthetic gate.
|
||||
|
||||
### PR S0-02 — Typed xtask configuration
|
||||
|
||||
**Изменения**
|
||||
|
||||
- `serde` + TOML schemas для corpus/acceptance manifests;
|
||||
- `deny_unknown_fields`;
|
||||
- duplicate/missing/unknown-field validation;
|
||||
- absolute canonical paths для local licensed manifest;
|
||||
- `cargo_metadata` для dependency и workspace policy;
|
||||
- удалить ручной line parser.
|
||||
|
||||
**Acceptance**
|
||||
|
||||
- malformed manifest всегда даёт non-zero exit;
|
||||
- неизвестные поля не игнорируются;
|
||||
- dependency policy работает по Cargo package IDs, targets и features.
|
||||
|
||||
### PR S0-03 — Полный synthetic CI gate
|
||||
|
||||
Обязательные команды:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo test --workspace --all-targets --all-features --locked
|
||||
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
RUSTDOCFLAGS="-D warnings -D rustdoc::broken_intra_doc_links" \
|
||||
cargo doc --workspace --no-deps --all-features --locked
|
||||
cargo deny check advisories bans licenses sources
|
||||
cargo xtask policy
|
||||
cargo xtask acceptance audit --strict
|
||||
```
|
||||
|
||||
Добавить reports для каждого gate и запрет silent skip.
|
||||
|
||||
### PR S0-04 — Redesign `fparkan-platform`
|
||||
|
||||
**Изменения**
|
||||
|
||||
- удалить `GraphicsProfile`, `GraphicsContextRequest` и GL version negotiation;
|
||||
- убрать `present()` из window port;
|
||||
- добавить normalized keyboard/mouse events;
|
||||
- physical/logical size и scale factor;
|
||||
- focus, minimize, occlusion, suspend/resume;
|
||||
- deterministic lifecycle state machine;
|
||||
- structured errors с source chain.
|
||||
|
||||
**Synthetic tests**
|
||||
|
||||
- resize coalescing;
|
||||
- zero-size/minimized window;
|
||||
- scale-factor changes;
|
||||
- focus loss clears held input;
|
||||
- key repeat и modifiers;
|
||||
- suspend/resume;
|
||||
- deterministic event ordering.
|
||||
|
||||
### PR S0-05 — `fparkan-platform-winit`
|
||||
|
||||
**Изменения**
|
||||
|
||||
- winit event loop;
|
||||
- native window lifecycle;
|
||||
- raw window/display handles;
|
||||
- platform-specific event normalization;
|
||||
- отсутствие GPU ownership.
|
||||
|
||||
**Acceptance**
|
||||
|
||||
- window-only smoke на Windows, Linux и macOS;
|
||||
- native event trace соответствует synthetic model.
|
||||
|
||||
### PR S0-06 — Vulkan low-level boundary
|
||||
|
||||
**Изменения**
|
||||
|
||||
- `ash` и `ash-window`;
|
||||
- dynamic Vulkan loader;
|
||||
- instance и debug messenger;
|
||||
- physical device capability records;
|
||||
- pure deterministic device scoring;
|
||||
- graphics/present queue selection;
|
||||
- deterministic capability JSON;
|
||||
- audited unsafe allowlist.
|
||||
|
||||
**Negative tests**
|
||||
|
||||
- loader отсутствует;
|
||||
- Vulkan 1.1 недоступен;
|
||||
- graphics queue отсутствует;
|
||||
- present queue отсутствует;
|
||||
- `VK_KHR_swapchain` отсутствует;
|
||||
- required surface format отсутствует.
|
||||
|
||||
### PR S0-07 — Swapchain, triangle и offline shaders
|
||||
|
||||
**Изменения**
|
||||
|
||||
- surface и swapchain;
|
||||
- format/present-mode/image-count policy;
|
||||
- render pass и graphics pipeline;
|
||||
- indexed triangle;
|
||||
- command pools/buffers;
|
||||
- binary semaphores и fences;
|
||||
- frames in flight;
|
||||
- resize/out-of-date/suboptimal/zero extent handling;
|
||||
- pinned offline shader compiler;
|
||||
- SPIR-V validation;
|
||||
- descriptor/push-constant manifest;
|
||||
- shader content hashes.
|
||||
|
||||
### PR S0-08 — macOS portability proof
|
||||
|
||||
**Изменения**
|
||||
|
||||
- `VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR`;
|
||||
- portability extension enumeration;
|
||||
- `VK_KHR_portability_subset` enablement, если объявлен device;
|
||||
- MoltenVK packaging strategy;
|
||||
- deterministic portability report;
|
||||
- `.app` bundle smoke.
|
||||
|
||||
### PR S0-09 — Composition roots и legacy removal
|
||||
|
||||
**Изменения**
|
||||
|
||||
- game/viewer подключают winit + Vulkan adapters;
|
||||
- headless остаётся без window/GPU graph;
|
||||
- удалить SDL/GL stub crates;
|
||||
- очистить lockfile, policy и docs;
|
||||
- заменить GPU-named neutral IDs на asset IDs;
|
||||
- запретить stale backend names automated policy check-ом.
|
||||
|
||||
### PR S0-10 — Native acceptance matrix
|
||||
|
||||
**Jobs**
|
||||
|
||||
- Windows MSVC + system Vulkan loader;
|
||||
- Linux X11 или Wayland surface;
|
||||
- macOS Apple Silicon + MoltenVK;
|
||||
- отдельный software-Vulkan Linux PR job допустим как быстрый gate;
|
||||
- native GPU jobs остаются release evidence.
|
||||
|
||||
**Обязательный сценарий**
|
||||
|
||||
1. Создать окно.
|
||||
2. Создать real Vulkan swapchain.
|
||||
3. Показать indexed triangle.
|
||||
4. Выполнить не менее 300 frames.
|
||||
5. Изменить размер окна.
|
||||
6. Пересоздать swapchain.
|
||||
7. Корректно завершить event loop.
|
||||
8. Получить `validation_error_count = 0`.
|
||||
9. Сохранить capability, shader и validation reports как artifacts.
|
||||
|
||||
**Stage 0 закрывается только после merge S0-01…S0-10 и зелёных native artifacts.**
|
||||
|
||||
---
|
||||
|
||||
## 8. Требуемая CI/acceptance модель
|
||||
|
||||
### 8.1 Synthetic PR gate
|
||||
|
||||
Должен работать без игровых каталогов и без silent skip:
|
||||
|
||||
1. fmt, clippy, docs, security и policy;
|
||||
2. все unit/integration tests;
|
||||
3. platform lifecycle state-machine tests;
|
||||
4. device scoring tests на synthetic capability records;
|
||||
5. swapchain policy tests;
|
||||
6. shader manifest/hash tests;
|
||||
7. Vulkan negative-path tests без обязательного GPU;
|
||||
8. headless dependency assertion;
|
||||
9. report schema validation.
|
||||
|
||||
Tests, требующие native GPU или licensed data, должны иметь отдельные suites и machine-readable ownership/reason, а не оставаться обычными `#[ignore]` без evidence trail.
|
||||
|
||||
### 8.2 Native platform gate
|
||||
|
||||
| Platform | Минимальный gate | Дополнительное evidence |
|
||||
|---|---|---|
|
||||
| Windows | system loader, swapchain, triangle, resize, 300 frames, validation=0 | Периодическая NVIDIA/AMD/Intel coverage |
|
||||
| Linux | X11 или Wayland surface, swapchain, resize, validation=0 | Software Vulkan PR job + Mesa/NVIDIA native release jobs |
|
||||
| macOS | MoltenVK, portability enumeration/subset, CAMetalLayer surface, resize, validation=0 | Apple Silicon как primary target |
|
||||
|
||||
### 8.3 Формат machine-readable отчёта
|
||||
|
||||
Минимальные поля:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": 1,
|
||||
"commit": "<sha>",
|
||||
"target": "x86_64-pc-windows-msvc",
|
||||
"rustc": "1.xx.y",
|
||||
"vulkan_api": "1.1",
|
||||
"device_name": "...",
|
||||
"driver": "...",
|
||||
"portability_subset": false,
|
||||
"frames": 300,
|
||||
"resize_count": 1,
|
||||
"swapchain_recreate_count": 1,
|
||||
"validation_error_count": 0,
|
||||
"shader_manifest_hash": "...",
|
||||
"result": "pass"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Definition of Done
|
||||
|
||||
Stage 0 считается закрытым, когда выполнены **все** пункты:
|
||||
|
||||
- [ ] Exact Rust toolchain закреплён.
|
||||
- [ ] MSRV объявлен и проверяется.
|
||||
- [ ] Full fmt/test/clippy/doc/security/source/license gate проходит.
|
||||
- [ ] Typed TOML manifests используются.
|
||||
- [ ] Dependency policy работает через `cargo_metadata`.
|
||||
- [ ] Windows/Linux/macOS matrix сохраняет artifacts.
|
||||
- [ ] `fparkan-platform` больше не содержит GL-specific context concepts.
|
||||
- [ ] `fparkan-platform-winit` реализован.
|
||||
- [ ] `fparkan-render-vulkan` реализован.
|
||||
- [ ] Vulkan 1.1 instance/device/queues/surface/swapchain реализованы.
|
||||
- [ ] Deterministic device scoring и capability report реализованы.
|
||||
- [ ] Indexed triangle рисуется настоящим Vulkan backend.
|
||||
- [ ] Resize, zero extent, out-of-date и suboptimal обработаны.
|
||||
- [ ] MoltenVK portability path реализован.
|
||||
- [ ] Offline SPIR-V validation и hash manifest реализованы.
|
||||
- [ ] Unsafe разрешён только в audited Vulkan/FFI modules.
|
||||
- [ ] Legacy SDL/GL adapters и references удалены.
|
||||
- [ ] Game/viewer используют новые composition adapters.
|
||||
- [ ] Headless dependency graph не содержит winit/Vulkan/MoltenVK.
|
||||
- [ ] 300-frame + resize smoke проходит на трёх OS.
|
||||
- [ ] Validation error count равен нулю на трёх OS.
|
||||
- [ ] Acceptance reports включают commit SHA и сохраняются как artifacts.
|
||||
|
||||
Наличие crates или unit tests с соответствующими названиями само по себе не является закрытием Stage 0.
|
||||
|
||||
---
|
||||
|
||||
## 10. Рекомендуемые automated policy checks
|
||||
|
||||
Добавить в `cargo xtask policy`:
|
||||
|
||||
### Workspace denylist
|
||||
|
||||
- запрещены `fparkan-platform-sdl` и `fparkan-render-gl` после миграции;
|
||||
- запрещены stale symbols `GraphicsProfile`, `DesktopCore`, `Embedded`, `Gles2` в canonical platform/render API;
|
||||
- canonical docs не содержат OpenGL как production backend.
|
||||
|
||||
### Dependency rules
|
||||
|
||||
- headless не зависит от `winit`, `raw-window-handle`, `ash`, `ash-window` или Vulkan adapter;
|
||||
- backend-neutral crates не зависят от concrete platform/render adapters;
|
||||
- только composition roots связывают platform и renderer;
|
||||
- raw Vulkan types не экспортируются из adapter public boundary.
|
||||
|
||||
### Unsafe rules
|
||||
|
||||
- project-owned unsafe разрешён только в exact allowlisted files/modules;
|
||||
- каждый block содержит `SAFETY:`;
|
||||
- `unsafe_op_in_unsafe_fn` запрещён;
|
||||
- изменение allowlist требует отдельного reviewed diff.
|
||||
|
||||
### Test и report rules
|
||||
|
||||
- synthetic gate не получает licensed paths;
|
||||
- ignored tests обязаны иметь registered reason и owner;
|
||||
- acceptance IDs уникальны;
|
||||
- reports проходят schema validation;
|
||||
- report всегда содержит commit SHA и target triple.
|
||||
|
||||
### Documentation rules
|
||||
|
||||
- документированные crates и commands существуют;
|
||||
- canonical stage version совпадает с acceptance schema;
|
||||
- старые backend names отсутствуют;
|
||||
- README не объявляет незакрытый Vulkan path реализованным.
|
||||
|
||||
---
|
||||
|
||||
## 11. Основные риски
|
||||
|
||||
| Риск | Последствие | Снижение |
|
||||
|---|---|---|
|
||||
| Vulkan adapter начнут до redesign platform API | Повторная переделка surface/lifecycle/present | Сначала S0-04, затем S0-05/S0-06 |
|
||||
| `unsafe_code` ослабят всему workspace | Рост FFI и lifetime рисков | Изолированный audited adapter и allowlist scanner |
|
||||
| Stubs будут приняты за production backend | Ложное закрытие Stage 0 | Удаление legacy crates и real native smoke |
|
||||
| Linux software Vulkan будет единственным evidence | Не выявятся vendor-driver проблемы | Native Mesa/NVIDIA jobs перед release |
|
||||
| macOS будет проверен без portability subset report | Скрытая несовместимость MoltenVK | Обязательное capability evidence |
|
||||
| Shader compiler останется неприкреплённым | Невоспроизводимый SPIR-V | Pinned compiler + manifest hashes |
|
||||
| GitHub mirror и primary repository разойдутся | Audit и release относятся к разному коду | Commit SHA, canonical remote и artifact metadata |
|
||||
| Документация останется отдельным source of truth | Повторное рассогласование | Versioned stage schema и automated doc checks |
|
||||
|
||||
---
|
||||
|
||||
## 12. Реестр доказательств
|
||||
|
||||
### Canonical requirement
|
||||
|
||||
- Vulkan revision: <https://app.notion.com/p/387e79f2db3981778f94cdf34db5f93f>
|
||||
|
||||
### Workspace и governance
|
||||
|
||||
- Root manifest: <https://github.com/valentineus/fparkan/blob/devel/Cargo.toml>
|
||||
- Toolchain: <https://github.com/valentineus/fparkan/blob/devel/rust-toolchain.toml>
|
||||
- Cargo config: <https://github.com/valentineus/fparkan/blob/devel/.cargo/config.toml>
|
||||
- xtask manifest: <https://github.com/valentineus/fparkan/blob/devel/xtask/Cargo.toml>
|
||||
- xtask implementation: <https://github.com/valentineus/fparkan/blob/devel/xtask/src/main.rs>
|
||||
- README: <https://github.com/valentineus/fparkan/blob/devel/README.md>
|
||||
|
||||
### Platform и render
|
||||
|
||||
- Platform core: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-platform/src/lib.rs>
|
||||
- SDL stub adapter: <https://github.com/valentineus/fparkan/blob/devel/adapters/fparkan-platform-sdl/src/lib.rs>
|
||||
- Render core: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-render/src/lib.rs>
|
||||
- GL stub adapter: <https://github.com/valentineus/fparkan/blob/devel/adapters/fparkan-render-gl/src/lib.rs>
|
||||
- Game composition: <https://github.com/valentineus/fparkan/blob/devel/apps/fparkan-game/src/main.rs>
|
||||
- Viewer composition: <https://github.com/valentineus/fparkan/blob/devel/apps/fparkan-viewer/src/main.rs>
|
||||
- Headless manifest: <https://github.com/valentineus/fparkan/blob/devel/apps/fparkan-headless/Cargo.toml>
|
||||
|
||||
### Documentation drift
|
||||
|
||||
- Implementation tome: <https://github.com/valentineus/fparkan/blob/devel/docs/tomes/07-implementation.md>
|
||||
- Parity README: <https://github.com/valentineus/fparkan/blob/devel/parity/README.md>
|
||||
- Parity cases: <https://github.com/valentineus/fparkan/blob/devel/parity/cases.toml>
|
||||
|
||||
---
|
||||
|
||||
## 13. Финальное заключение
|
||||
|
||||
У проекта уже имеется пригодный backend-neutral фундамент: deterministic render commands, строгие neutral-crate lints, отдельный headless composition root и разделение synthetic/licensed tests. Однако Stage 0 пока представлен интерфейсными proof/stub crates, а не настоящим Vulkan vertical slice.
|
||||
|
||||
Критический путь:
|
||||
|
||||
```text
|
||||
reproducible toolchain
|
||||
→ complete CI/policy gate
|
||||
→ backend-neutral platform redesign
|
||||
→ winit adapter
|
||||
→ Vulkan loader/device/surface/swapchain
|
||||
→ indexed triangle + shaders + synchronization
|
||||
→ MoltenVK portability
|
||||
→ composition integration
|
||||
→ legacy removal
|
||||
→ three-platform acceptance artifacts
|
||||
```
|
||||
|
||||
До прохождения этого пути рекомендуемый статус:
|
||||
|
||||
```text
|
||||
Stage 0: IN PROGRESS / BLOCKED
|
||||
```
|
||||
|
||||
Главный критерий закрытия:
|
||||
|
||||
> Stage 0 завершён не тогда, когда существуют crates с названиями `winit` и `vulkan`, а когда один закреплённый commit создаёт настоящий Vulkan swapchain, рисует triangle, переживает resize и завершается без validation errors на Windows, Linux и macOS, сохраняя воспроизводимые machine-readable artifacts.
|
||||
+1
-1
@@ -7,7 +7,7 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-corpus = { path = "../crates/fparkan-corpus" }
|
||||
cargo_metadata = "0.23"
|
||||
cargo_metadata = "0.21.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml = "1.0"
|
||||
|
||||
+1220
-245
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user